"""Admin management CLI commands. This Typer sub-app exposes all admin REST endpoints under ``/api/v1/admin/*`` as CLI commands. It is registered on the main ``agentkit`` app as ``agentkit admin ``. Authentication is handled by :class:`AdminHttpClient`, which reads credentials from args, env vars, or ``~/.agentkit/admin_config.yaml``. Run ``agentkit admin login`` once to populate the config file. """ from __future__ import annotations import json as _json from pathlib import Path from typing import Optional import httpx import typer import yaml from rich import print as rprint from rich.console import Console from rich.table import Table from agentkit.cli.admin_client import DEFAULT_CONFIG_PATH, AdminHttpClient admin_app = typer.Typer( name="admin", help="Admin management commands (departments, users, LLM, skills, KB, usage).", no_args_is_help=True, ) console = Console() # --------------------------------------------------------------------------- # Shared option definitions (used as defaults via `= typer.Option(...)`) # --------------------------------------------------------------------------- ServerUrlOption = typer.Option( None, "--server-url", "-s", help="Server URL (default: http://localhost:8001)" ) TokenOption = typer.Option(None, "--token", "-t", help="JWT access token") ApiKeyOption = typer.Option(None, "--api-key", "-k", help="API key") JsonFlag = typer.Option(False, "--json", help="Output raw JSON instead of a Rich table") # --------------------------------------------------------------------------- # Error handling helper # --------------------------------------------------------------------------- def _handle_http_error(e: httpx.HTTPStatusError, server_url: str) -> None: """Translate an HTTP error into a friendly message and exit.""" status = e.response.status_code if status == 401: rprint("[red]Error: Authentication failed. Run 'agentkit admin login' first.[/red]") elif status == 403: rprint("[red]Error: Admin permission required.[/red]") elif status == 404: rprint("[red]Error: Resource not found.[/red]") elif status == 409: try: detail = e.response.json().get("detail", "") except ValueError: detail = e.response.text rprint(f"[red]Error: Conflict — {detail}[/red]") else: rprint(f"[red]Error: {status} — {e.response.text}[/red]") raise typer.Exit(1) def _handle_connect_error(server_url: str) -> None: rprint(f"[red]Error: Cannot connect to server at {server_url}[/red]") rprint("[dim]Is the server running? Start it with: agentkit serve[/dim]") raise typer.Exit(1) def _build_client( server_url: Optional[str], token: Optional[str], api_key: Optional[str], ) -> AdminHttpClient: """Construct an :class:`AdminHttpClient` from CLI options.""" return AdminHttpClient.from_config( server_url=server_url, token=token, api_key=api_key, ) def _emit_json(data: object) -> None: rprint(_json.dumps(data, indent=2, ensure_ascii=False, default=str)) def _safe_get( client: AdminHttpClient, path: str, server_url: str, params: dict | None = None ) -> object: try: return client.get(path, params=params) except httpx.ConnectError: _handle_connect_error(server_url) except httpx.HTTPStatusError as e: _handle_http_error(e, server_url) def _safe_post( client: AdminHttpClient, path: str, server_url: str, body: dict | None = None ) -> object: try: return client.post(path, json=body) except httpx.ConnectError: _handle_connect_error(server_url) except httpx.HTTPStatusError as e: _handle_http_error(e, server_url) def _safe_patch( client: AdminHttpClient, path: str, server_url: str, body: dict | None = None ) -> object: try: return client.patch(path, json=body) except httpx.ConnectError: _handle_connect_error(server_url) except httpx.HTTPStatusError as e: _handle_http_error(e, server_url) def _safe_put(client: AdminHttpClient, path: str, server_url: str, body: dict | None = None) -> object: try: return client.put(path, json=body) except httpx.ConnectError: _handle_connect_error(server_url) except httpx.HTTPStatusError as e: _handle_http_error(e, server_url) def _safe_delete( client: AdminHttpClient, path: str, server_url: str, params: dict | None = None ) -> object: try: return client.delete(path, params=params) except httpx.ConnectError: _handle_connect_error(server_url) except httpx.HTTPStatusError as e: _handle_http_error(e, server_url) # --------------------------------------------------------------------------- # Login command (top-level) # --------------------------------------------------------------------------- @admin_app.command("login") def admin_login( username: str = typer.Option(..., "--username", "-u", help="Admin username"), password: str = typer.Option( ..., "--password", "-p", help="Admin password", prompt=True, hide_input=True ), server_url: Optional[str] = ServerUrlOption, config_path: Optional[str] = typer.Option( None, "--config-path", help="Path to admin config file" ), ) -> None: """Login and save the access token to ``~/.agentkit/admin_config.yaml``.""" resolved_url = server_url or "http://localhost:8001" client = AdminHttpClient(resolved_url) try: token = client.login(username, password) except httpx.ConnectError: _handle_connect_error(resolved_url) except httpx.HTTPStatusError as e: _handle_http_error(e, resolved_url) path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH path.parent.mkdir(parents=True, exist_ok=True) config = {"server_url": resolved_url, "token": token} path.write_text(yaml.dump(config, default_flow_style=False, allow_unicode=True)) rprint(f"[green]Login successful. Token saved to {path}[/green]") # --------------------------------------------------------------------------- # Department sub-group # --------------------------------------------------------------------------- dept_app = typer.Typer(name="department", help="Department management", no_args_is_help=True) admin_app.add_typer(dept_app, name="department") @dept_app.command("list") def dept_list( server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List all departments.""" client = _build_client(server_url, token, api_key) depts = _safe_get(client, "/api/v1/admin/departments", client.base_url) if json_output: _emit_json(depts) return if not depts: rprint("[dim]No departments found[/dim]") return table = Table(title="Departments") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Description") table.add_column("Active") table.add_column("Created") for d in depts: table.add_row( str(d.get("id", "")), str(d.get("name", "")), str(d.get("description", "")), "✓" if d.get("is_active") else "✗", str(d.get("created_at", "")), ) console.print(table) @dept_app.command("create") def dept_create( name: str = typer.Option(..., "--name", "-n", help="Department name"), description: str = typer.Option("", "--description", "-d", help="Department description"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Create a department.""" client = _build_client(server_url, token, api_key) result = _safe_post( client, "/api/v1/admin/departments", client.base_url, {"name": name, "description": description}, ) if json_output: _emit_json(result) return rprint(f"[green]Department created:[/green] {result.get('name')} (id={result.get('id')})") @dept_app.command("update") def dept_update( dept_id: str = typer.Argument(..., help="Department ID"), name: Optional[str] = typer.Option(None, "--name", "-n", help="New name"), description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Update a department's name or description.""" body: dict[str, object] = {} if name is not None: body["name"] = name if description is not None: body["description"] = description if not body: rprint("[red]Error: Provide --name or --description to update[/red]") raise typer.Exit(1) client = _build_client(server_url, token, api_key) result = _safe_patch(client, f"/api/v1/admin/departments/{dept_id}", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]Department updated:[/green] {result.get('name')} (id={result.get('id')})") @dept_app.command("delete") def dept_delete( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Delete a department.""" client = _build_client(server_url, token, api_key) result = _safe_delete(client, f"/api/v1/admin/departments/{dept_id}", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Department deleted:[/green] {dept_id}") @dept_app.command("enable") def dept_enable( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Enable a disabled department.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/departments/{dept_id}/enable", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Department enabled:[/green] {dept_id}") @dept_app.command("disable") def dept_disable( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Disable a department.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/departments/{dept_id}/disable", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Department disabled:[/green] {dept_id}") @dept_app.command("bind-skill") def dept_bind_skill( dept_id: str = typer.Argument(..., help="Department ID"), skill_name: str = typer.Argument(..., help="Skill name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Bind a skill to a department.""" client = _build_client(server_url, token, api_key) result = _safe_post( client, f"/api/v1/admin/departments/{dept_id}/skills/{skill_name}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]Skill '{skill_name}' bound to department {dept_id}[/green]") @dept_app.command("unbind-skill") def dept_unbind_skill( dept_id: str = typer.Argument(..., help="Department ID"), skill_name: str = typer.Argument(..., help="Skill name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Unbind a skill from a department.""" client = _build_client(server_url, token, api_key) result = _safe_delete( client, f"/api/v1/admin/departments/{dept_id}/skills/{skill_name}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]Skill '{skill_name}' unbound from department {dept_id}[/green]") @dept_app.command("bind-kb") def dept_bind_kb( dept_id: str = typer.Argument(..., help="Department ID"), source_id: str = typer.Argument(..., help="KB source ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Bind a KB source to a department.""" client = _build_client(server_url, token, api_key) result = _safe_post( client, f"/api/v1/admin/departments/{dept_id}/kb/{source_id}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]KB source '{source_id}' bound to department {dept_id}[/green]") @dept_app.command("unbind-kb") def dept_unbind_kb( dept_id: str = typer.Argument(..., help="Department ID"), source_id: str = typer.Argument(..., help="KB source ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Unbind a KB source from a department.""" client = _build_client(server_url, token, api_key) result = _safe_delete( client, f"/api/v1/admin/departments/{dept_id}/kb/{source_id}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]KB source '{source_id}' unbound from department {dept_id}[/green]") @dept_app.command("list-skills") def dept_list_skills( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List skills bound to a department.""" client = _build_client(server_url, token, api_key) skills = _safe_get( client, f"/api/v1/admin/departments/{dept_id}/skills", client.base_url, ) if json_output: _emit_json(skills) return if not skills: rprint(f"[dim]No skills bound to department {dept_id}[/dim]") return table = Table(title=f"Skills for department {dept_id}") table.add_column("Skill Name", style="cyan") for s in skills: table.add_row(str(s)) console.print(table) @dept_app.command("list-kb") def dept_list_kb( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List KB sources bound to a department.""" client = _build_client(server_url, token, api_key) sources = _safe_get( client, f"/api/v1/admin/departments/{dept_id}/kb", client.base_url, ) if json_output: _emit_json(sources) return if not sources: rprint(f"[dim]No KB sources bound to department {dept_id}[/dim]") return table = Table(title=f"KB sources for department {dept_id}") table.add_column("Source ID", style="cyan") for s in sources: table.add_row(str(s)) console.print(table) @dept_app.command("list-quotas") def dept_list_quotas( dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List quotas for a department.""" client = _build_client(server_url, token, api_key) quotas = _safe_get( client, f"/api/v1/admin/departments/{dept_id}/quotas", client.base_url, ) if json_output: _emit_json(quotas) return if not quotas: rprint(f"[dim]No quotas set for department {dept_id}[/dim]") return table = Table(title=f"Quotas for department {dept_id}") table.add_column("Type", style="cyan") table.add_column("Limit") table.add_column("Period") for q in quotas: table.add_row( str(q.get("quota_type", "")), str(q.get("limit_value", "")), str(q.get("period", "")), ) console.print(table) @dept_app.command("set-quota") def dept_set_quota( dept_id: str = typer.Argument(..., help="Department ID"), quota_type: str = typer.Option(..., "--type", help="Quota type (token/cost/model_whitelist)"), limit_value: str = typer.Option( ..., "--limit", help="Limit value (int for token/cost, comma-separated for model_whitelist)" ), period: str = typer.Option("daily", "--period", help="Quota period (daily/monthly)"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Set (upsert) a quota for a department.""" if quota_type == "model_whitelist": limit: int | list[str] = [s.strip() for s in limit_value.split(",") if s.strip()] else: try: limit = int(limit_value) except ValueError: rprint(f"[red]Error: --limit must be an integer for quota_type={quota_type}[/red]") raise typer.Exit(1) client = _build_client(server_url, token, api_key) result = _safe_put( client, f"/api/v1/admin/departments/{dept_id}/quotas", client.base_url, {"quota_type": quota_type, "limit_value": limit, "period": period}, ) if json_output: _emit_json(result) return rprint(f"[green]Quota set:[/green] {quota_type}={limit} ({period}) for {dept_id}") @dept_app.command("delete-quota") def dept_delete_quota( dept_id: str = typer.Argument(..., help="Department ID"), quota_type: str = typer.Option(..., "--type", help="Quota type to delete"), period: str = typer.Option("daily", "--period", help="Quota period"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Delete a quota for a department.""" client = _build_client(server_url, token, api_key) result = _safe_delete( client, f"/api/v1/admin/departments/{dept_id}/quotas", client.base_url, {"quota_type": quota_type, "period": period}, ) if json_output: _emit_json(result) return rprint(f"[green]Quota deleted:[/green] {quota_type} ({period}) for {dept_id}") # --------------------------------------------------------------------------- # User sub-group # --------------------------------------------------------------------------- user_app = typer.Typer(name="user", help="User management", no_args_is_help=True) admin_app.add_typer(user_app, name="user") @user_app.command("list") def user_list( department_id: Optional[str] = typer.Option( None, "--department-id", "-d", help="Filter by department ID" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List users, optionally filtered by department.""" client = _build_client(server_url, token, api_key) params: dict[str, object] = {} if department_id: params["department_id"] = department_id users = _safe_get(client, "/api/v1/admin/users", client.base_url, params=params or None) if json_output: _emit_json(users) return if not users: rprint("[dim]No users found[/dim]") return table = Table(title="Users") table.add_column("ID", style="cyan") table.add_column("Username") table.add_column("Email") table.add_column("Role") table.add_column("Active") for u in users: table.add_row( str(u.get("id", "")), str(u.get("username", "")), str(u.get("email", "")), str(u.get("role", "")), "✓" if u.get("is_active") else "✗", ) console.print(table) @user_app.command("create") def user_create( username: str = typer.Option(..., "--username", "-u", help="Username"), email: str = typer.Option(..., "--email", "-e", help="Email"), password: str = typer.Option( ..., "--password", "-p", help="Password", prompt=True, hide_input=True ), role: str = typer.Option("member", "--role", "-r", help="Role (member/operator/admin)"), department_ids: Optional[str] = typer.Option( None, "--department-ids", help="Comma-separated department IDs" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Create a new user.""" body: dict[str, object] = { "username": username, "email": email, "password": password, "role": role, } if department_ids: body["department_ids"] = [s.strip() for s in department_ids.split(",") if s.strip()] client = _build_client(server_url, token, api_key) result = _safe_post(client, "/api/v1/admin/users", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]User created:[/green] {result.get('username')} (id={result.get('id')})") @user_app.command("update") def user_update( user_id: str = typer.Argument(..., help="User ID"), role: Optional[str] = typer.Option(None, "--role", "-r", help="New role"), is_active: Optional[bool] = typer.Option(None, "--active/--inactive", help="Active flag"), is_terminal_authorized: Optional[bool] = typer.Option( None, "--terminal-authorized/--no-terminal-authorized" ), is_server_terminal_authorized: Optional[bool] = typer.Option( None, "--server-terminal-authorized/--no-server-terminal-authorized" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Update a user's role or active flag.""" body: dict[str, object] = {} if role is not None: body["role"] = role if is_active is not None: body["is_active"] = is_active if is_terminal_authorized is not None: body["is_terminal_authorized"] = is_terminal_authorized if is_server_terminal_authorized is not None: body["is_server_terminal_authorized"] = is_server_terminal_authorized if not body: rprint("[red]Error: Provide at least one field to update[/red]") raise typer.Exit(1) client = _build_client(server_url, token, api_key) result = _safe_patch(client, f"/api/v1/admin/users/{user_id}", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]User updated:[/green] {result.get('username')} (id={result.get('id')})") @user_app.command("delete") def user_delete( user_id: str = typer.Argument(..., help="User ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Soft-delete a user (sets is_active=False).""" client = _build_client(server_url, token, api_key) result = _safe_delete(client, f"/api/v1/admin/users/{user_id}", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]User deleted:[/green] {user_id}") @user_app.command("reset-password") def user_reset_password( user_id: str = typer.Argument(..., help="User ID"), new_password: str = typer.Option( ..., "--password", "-p", help="New password", prompt=True, hide_input=True ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Reset a user's password (revokes all their sessions).""" client = _build_client(server_url, token, api_key) result = _safe_post( client, f"/api/v1/admin/users/{user_id}/reset-password", client.base_url, {"new_password": new_password}, ) if json_output: _emit_json(result) return rprint(f"[green]Password reset for user:[/green] {user_id}") @user_app.command("assign-department") def user_assign_department( user_id: str = typer.Argument(..., help="User ID"), dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Assign a user to a department.""" client = _build_client(server_url, token, api_key) result = _safe_post( client, f"/api/v1/admin/users/{user_id}/departments/{dept_id}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]User {user_id} assigned to department {dept_id}[/green]") @user_app.command("remove-department") def user_remove_department( user_id: str = typer.Argument(..., help="User ID"), dept_id: str = typer.Argument(..., help="Department ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Remove a user from a department.""" client = _build_client(server_url, token, api_key) result = _safe_delete( client, f"/api/v1/admin/users/{user_id}/departments/{dept_id}", client.base_url, ) if json_output: _emit_json(result) return rprint(f"[green]User {user_id} removed from department {dept_id}[/green]") # --------------------------------------------------------------------------- # LLM sub-group # --------------------------------------------------------------------------- llm_app = typer.Typer(name="llm", help="LLM configuration management", no_args_is_help=True) admin_app.add_typer(llm_app, name="llm") @llm_app.command("list-providers") def llm_list_providers( server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List all LLM providers (API keys masked).""" client = _build_client(server_url, token, api_key) providers = _safe_get(client, "/api/v1/admin/llm/providers", client.base_url) if json_output: _emit_json(providers) return if not providers: rprint("[dim]No LLM providers configured[/dim]") return table = Table(title="LLM Providers") table.add_column("Name", style="cyan") table.add_column("Type") table.add_column("Base URL") table.add_column("API Key") table.add_column("Max Tokens") table.add_column("Timeout") for p in providers: table.add_row( str(p.get("name", "")), str(p.get("type", "")), str(p.get("base_url", "")), str(p.get("api_key", "")), str(p.get("max_tokens", "")), str(p.get("timeout", "")), ) console.print(table) @llm_app.command("add-provider") def llm_add_provider( name: str = typer.Option(..., "--name", "-n", help="Provider name"), type: str = typer.Option("openai", "--type", help="Provider type"), api_key: str = typer.Option(..., "--api-key", help="API key"), base_url: str = typer.Option("", "--base-url", help="Base URL override"), max_tokens: int = typer.Option(4096, "--max-tokens", help="Max tokens"), timeout: float = typer.Option(60.0, "--timeout", help="Timeout in seconds"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key_opt: Optional[str] = typer.Option( None, "--api-key-auth", "-k", help="Admin API key (auth)" ), json_output: bool = JsonFlag, ) -> None: """Create a new LLM provider.""" body: dict[str, object] = { "name": name, "type": type, "api_key": api_key, "base_url": base_url, "max_tokens": max_tokens, "timeout": timeout, } client = _build_client(server_url, token, api_key_opt) result = _safe_post(client, "/api/v1/admin/llm/providers", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]Provider created:[/green] {result.get('name')}") @llm_app.command("update-provider") def llm_update_provider( name: str = typer.Argument(..., help="Provider name"), type: Optional[str] = typer.Option(None, "--type", help="Provider type"), api_key: Optional[str] = typer.Option(None, "--api-key", help="API key"), base_url: Optional[str] = typer.Option(None, "--base-url", help="Base URL"), max_tokens: Optional[int] = typer.Option(None, "--max-tokens", help="Max tokens"), timeout: Optional[float] = typer.Option(None, "--timeout", help="Timeout"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key_opt: Optional[str] = typer.Option( None, "--api-key-auth", "-k", help="Admin API key (auth)" ), json_output: bool = JsonFlag, ) -> None: """Update an LLM provider's configuration.""" body: dict[str, object] = {} if type is not None: body["type"] = type if api_key is not None: body["api_key"] = api_key if base_url is not None: body["base_url"] = base_url if max_tokens is not None: body["max_tokens"] = max_tokens if timeout is not None: body["timeout"] = timeout if not body: rprint("[red]Error: Provide at least one field to update[/red]") raise typer.Exit(1) client = _build_client(server_url, token, api_key_opt) result = _safe_patch(client, f"/api/v1/admin/llm/providers/{name}", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]Provider updated:[/green] {result.get('name')}") @llm_app.command("delete-provider") def llm_delete_provider( name: str = typer.Argument(..., help="Provider name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Delete an LLM provider.""" client = _build_client(server_url, token, api_key) result = _safe_delete(client, f"/api/v1/admin/llm/providers/{name}", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Provider deleted:[/green] {name}") @llm_app.command("set-api-key") def llm_set_api_key( name: str = typer.Argument(..., help="Provider name"), api_key: str = typer.Option(..., "--api-key", help="New API key"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key_opt: Optional[str] = typer.Option( None, "--api-key-auth", "-k", help="Admin API key (auth)" ), json_output: bool = JsonFlag, ) -> None: """Set the API key for a provider (stored in .env, not YAML).""" client = _build_client(server_url, token, api_key_opt) result = _safe_post( client, f"/api/v1/admin/llm/providers/{name}/api-key", client.base_url, {"api_key": api_key}, ) if json_output: _emit_json(result) return rprint(f"[green]API key set for provider:[/green] {name}") @llm_app.command("list-fallbacks") def llm_list_fallbacks( server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List all fallback chains.""" client = _build_client(server_url, token, api_key) fallbacks = _safe_get(client, "/api/v1/admin/llm/fallbacks", client.base_url) if json_output: _emit_json(fallbacks) return if not fallbacks: rprint("[dim]No fallback chains configured[/dim]") return table = Table(title="LLM Fallback Chains") table.add_column("Model", style="cyan") table.add_column("Chain") for model, chain in fallbacks.items(): table.add_row(str(model), " → ".join(str(c) for c in chain)) console.print(table) @llm_app.command("set-fallback") def llm_set_fallback( model: str = typer.Argument(..., help="Model name"), chain: str = typer.Option( ..., "--chain", "-c", help="Comma-separated fallback chain (e.g. provider1/model1,provider2/model2)", ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Set the fallback chain for a model.""" chain_list = [s.strip() for s in chain.split(",") if s.strip()] if not chain_list: rprint("[red]Error: --chain must contain at least one entry[/red]") raise typer.Exit(1) client = _build_client(server_url, token, api_key) result = _safe_put( client, f"/api/v1/admin/llm/fallbacks/{model}", client.base_url, {"chain": chain_list}, ) if json_output: _emit_json(result) return rprint(f"[green]Fallback set for {model}:[/green] {' → '.join(chain_list)}") @llm_app.command("delete-fallback") def llm_delete_fallback( model: str = typer.Argument(..., help="Model name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Delete the fallback chain for a model.""" client = _build_client(server_url, token, api_key) result = _safe_delete(client, f"/api/v1/admin/llm/fallbacks/{model}", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Fallback deleted for model:[/green] {model}") # --------------------------------------------------------------------------- # Skill sub-group # --------------------------------------------------------------------------- skill_app = typer.Typer(name="skill", help="Skill management (admin)", no_args_is_help=True) admin_app.add_typer(skill_app, name="skill") @skill_app.command("list") def skill_list( server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List all skills (calls GET /skills, not /admin/skills).""" client = _build_client(server_url, token, api_key) skills = _safe_get(client, "/api/v1/skills", client.base_url) if json_output: _emit_json(skills) return if not skills: rprint("[dim]No skills registered[/dim]") return table = Table(title="Skills") table.add_column("Name", style="cyan") table.add_column("Type") table.add_column("Description") for s in skills: table.add_row( str(s.get("name", "")), str(s.get("agent_type", "")), str(s.get("description", "")), ) console.print(table) @skill_app.command("enable") def skill_enable( name: str = typer.Argument(..., help="Skill name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Enable a previously-disabled skill.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/skills/{name}/enable", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Skill enabled:[/green] {name}") @skill_app.command("disable") def skill_disable( name: str = typer.Argument(..., help="Skill name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Disable a skill (hides it from GET /skills).""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/skills/{name}/disable", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Skill disabled:[/green] {name}") @skill_app.command("import") def skill_import( yaml_file: str = typer.Argument(..., help="Path to skill YAML file"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Import a skill from a YAML file.""" path = Path(yaml_file) if not path.exists(): rprint(f"[red]Error: File not found: {yaml_file}[/red]") raise typer.Exit(1) yaml_content = path.read_text(encoding="utf-8") client = _build_client(server_url, token, api_key) result = _safe_post( client, "/api/v1/admin/skills/import", client.base_url, {"yaml_content": yaml_content}, ) if json_output: _emit_json(result) return rprint(f"[green]Skill imported:[/green] {result.get('name', yaml_file)}") @skill_app.command("reload") def skill_reload( name: str = typer.Argument(..., help="Skill name"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Reload a skill from its YAML file.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/skills/{name}/reload", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Skill reloaded:[/green] {name}") # --------------------------------------------------------------------------- # KB sub-group # --------------------------------------------------------------------------- kb_app = typer.Typer(name="kb", help="Knowledge base management", no_args_is_help=True) admin_app.add_typer(kb_app, name="kb") @kb_app.command("list-documents") def kb_list_documents( source_id: Optional[str] = typer.Option(None, "--source-id", help="Filter by source ID"), department_id: Optional[str] = typer.Option( None, "--department-id", help="Filter by department ID" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """List KB documents.""" client = _build_client(server_url, token, api_key) params: dict[str, object] = {} if source_id: params["source_id"] = source_id if department_id: params["department_id"] = department_id data = _safe_get(client, "/api/v1/admin/kb/documents", client.base_url, params=params or None) documents = data.get("documents", []) if isinstance(data, dict) else data if json_output: _emit_json(documents) return if not documents: rprint("[dim]No KB documents found[/dim]") return table = Table(title="KB Documents") table.add_column("ID", style="cyan") table.add_column("Filename") table.add_column("Source ID") table.add_column("Department ID") table.add_column("Chunks") table.add_column("Created") for d in documents: table.add_row( str(d.get("document_id", "")), str(d.get("filename", "")), str(d.get("source_id", "")), str(d.get("department_id", "")), str(d.get("chunks", "")), str(d.get("created_at", "")), ) console.print(table) @kb_app.command("upload") def kb_upload( filename: str = typer.Option(..., "--filename", "-f", help="Document filename"), content_file: str = typer.Option( ..., "--content-file", "-c", help="Path to file with document content" ), source_id: str = typer.Option("", "--source-id", help="KB source ID"), department_id: Optional[str] = typer.Option( None, "--department-id", help="Department ID to bind to" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Upload a KB document.""" path = Path(content_file) if not path.exists(): rprint(f"[red]Error: File not found: {content_file}[/red]") raise typer.Exit(1) content = path.read_text(encoding="utf-8") body: dict[str, object] = { "filename": filename, "content": content, "source_id": source_id, } if department_id is not None: body["department_id"] = department_id client = _build_client(server_url, token, api_key) result = _safe_post(client, "/api/v1/admin/kb/documents", client.base_url, body) if json_output: _emit_json(result) return rprint(f"[green]Document uploaded:[/green] {filename} (id={result.get('id')})") @kb_app.command("delete") def kb_delete( document_id: str = typer.Argument(..., help="Document ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Delete a KB document.""" client = _build_client(server_url, token, api_key) result = _safe_delete(client, f"/api/v1/admin/kb/documents/{document_id}", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Document deleted:[/green] {document_id}") @kb_app.command("sync") def kb_sync( source_id: str = typer.Argument(..., help="KB source ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Trigger a sync for a KB source.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/kb/sources/{source_id}/sync", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Sync triggered for source:[/green] {source_id}") @kb_app.command("rebuild") def kb_rebuild( source_id: str = typer.Argument(..., help="KB source ID"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Rebuild the index for a KB source.""" client = _build_client(server_url, token, api_key) result = _safe_post(client, f"/api/v1/admin/kb/sources/{source_id}/rebuild", client.base_url) if json_output: _emit_json(result) return rprint(f"[green]Rebuild triggered for source:[/green] {source_id}") # --------------------------------------------------------------------------- # Usage sub-group # --------------------------------------------------------------------------- usage_app = typer.Typer(name="usage", help="Usage dashboard", no_args_is_help=True) admin_app.add_typer(usage_app, name="usage") def _usage_params( department_id: Optional[str], user_id: Optional[str], start: Optional[str], end: Optional[str], ) -> dict[str, object]: params: dict[str, object] = {} if department_id: params["department_id"] = department_id if user_id: params["user_id"] = user_id if start: params["start"] = start if end: params["end"] = end return params @usage_app.command("summary") def usage_summary( department_id: Optional[str] = typer.Option(None, "--department-id", "-d"), user_id: Optional[str] = typer.Option(None, "--user-id", "-u"), start: Optional[str] = typer.Option(None, "--start", help="ISO 8601 start timestamp"), end: Optional[str] = typer.Option(None, "--end", help="ISO 8601 end timestamp"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Show aggregated usage summary.""" client = _build_client(server_url, token, api_key) params = _usage_params(department_id, user_id, start, end) data = _safe_get(client, "/api/v1/admin/usage/summary", client.base_url, params=params or None) if json_output: _emit_json(data) return table = Table(title="Usage Summary") table.add_column("Metric", style="cyan") table.add_column("Value") for key, value in (data or {}).items(): if isinstance(value, float): table.add_row(str(key), f"{value:.4f}") elif isinstance(value, list | dict): table.add_row(str(key), _json.dumps(value, default=str)) else: table.add_row(str(key), str(value)) console.print(table) @usage_app.command("timeseries") def usage_timeseries( department_id: Optional[str] = typer.Option(None, "--department-id", "-d"), user_id: Optional[str] = typer.Option(None, "--user-id", "-u"), start: Optional[str] = typer.Option(None, "--start"), end: Optional[str] = typer.Option(None, "--end"), interval: str = typer.Option("day", "--interval", help="day or hour"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Show time-bucketed usage series.""" client = _build_client(server_url, token, api_key) params = _usage_params(department_id, user_id, start, end) params["interval"] = interval data = _safe_get(client, "/api/v1/admin/usage/timeseries", client.base_url, params=params) if json_output: _emit_json(data) return if not data: rprint("[dim]No usage data[/dim]") return table = Table(title=f"Usage Timeseries (interval={interval})") table.add_column("Bucket", style="cyan") table.add_column("Requests") table.add_column("Tokens") table.add_column("Cost") for row in data: table.add_row( str(row.get("bucket", "")), str(row.get("requests", "")), str(row.get("tokens", "")), str(row.get("cost", "")), ) console.print(table) @usage_app.command("by-model") def usage_by_model( department_id: Optional[str] = typer.Option(None, "--department-id", "-d"), user_id: Optional[str] = typer.Option(None, "--user-id", "-u"), start: Optional[str] = typer.Option(None, "--start"), end: Optional[str] = typer.Option(None, "--end"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Show per-model usage breakdown.""" client = _build_client(server_url, token, api_key) params = _usage_params(department_id, user_id, start, end) data = _safe_get(client, "/api/v1/admin/usage/by-model", client.base_url, params=params or None) if json_output: _emit_json(data) return if not data: rprint("[dim]No usage data[/dim]") return table = Table(title="Usage by Model") table.add_column("Model", style="cyan") table.add_column("Requests") table.add_column("Tokens") table.add_column("Cost") for row in data: table.add_row( str(row.get("model", "")), str(row.get("requests", "")), str(row.get("tokens", "")), str(row.get("cost", "")), ) console.print(table) @usage_app.command("top-users") def usage_top_users( department_id: Optional[str] = typer.Option(None, "--department-id", "-d"), start: Optional[str] = typer.Option(None, "--start"), end: Optional[str] = typer.Option(None, "--end"), limit: int = typer.Option(10, "--limit", "-n", help="Top N users"), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, json_output: bool = JsonFlag, ) -> None: """Show top-N users by token usage.""" client = _build_client(server_url, token, api_key) params: dict[str, object] = {"limit": limit} if department_id: params["department_id"] = department_id if start: params["start"] = start if end: params["end"] = end data = _safe_get(client, "/api/v1/admin/usage/top-users", client.base_url, params=params) if json_output: _emit_json(data) return if not data: rprint("[dim]No usage data[/dim]") return table = Table(title=f"Top {limit} Users by Tokens") table.add_column("Rank", style="cyan") table.add_column("User ID") table.add_column("Requests") table.add_column("Tokens") table.add_column("Cost") for i, row in enumerate(data, 1): table.add_row( str(i), str(row.get("user_id", "")), str(row.get("requests", "")), str(row.get("tokens", "")), str(row.get("cost", "")), ) console.print(table) @usage_app.command("export") def usage_export( department_id: Optional[str] = typer.Option(None, "--department-id", "-d"), user_id: Optional[str] = typer.Option(None, "--user-id", "-u"), start: Optional[str] = typer.Option(None, "--start"), end: Optional[str] = typer.Option(None, "--end"), format: str = typer.Option("csv", "--format", help="csv or json"), output: Optional[str] = typer.Option( None, "--output", "-o", help="Output file (default: stdout)" ), server_url: Optional[str] = ServerUrlOption, token: Optional[str] = TokenOption, api_key: Optional[str] = ApiKeyOption, ) -> None: """Export raw usage records as CSV or JSON.""" client = _build_client(server_url, token, api_key) params = _usage_params(department_id, user_id, start, end) params["format"] = format try: body = client.get_text("/api/v1/admin/usage/export", params=params) except httpx.ConnectError: _handle_connect_error(client.base_url) except httpx.HTTPStatusError as e: _handle_http_error(e, client.base_url) if output: Path(output).write_text(body, encoding="utf-8") rprint(f"[green]Export written to:[/green] {output}") else: # Print raw — no Rich formatting so the output is scriptable. print(body)