From 2dd0091bdae91d243d73e64eb40b3a70735c293e Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 21 Jun 2026 18:56:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20U8=20=E2=80=94=20CLI=20admin=20c?= =?UTF-8?q?ommand=20group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminHttpClient: sync HTTP client with JWT/API key auth, config file support (~/.agentkit/admin_config.yaml), env var fallback. 35+ CLI commands across 7 groups: login, department (CRUD + bind/unbind skill/KB + quotas), user (CRUD + reset-password + assign/remove dept), llm (providers + api-key + fallbacks), skill (list/enable/disable/ import/reload), kb (documents CRUD + sync/rebuild), usage (summary/ timeseries/by-model/top-users/export). All commands support --server-url, --token, --api-key, --json flags. Rich table output by default, raw JSON with --json. Friendly error handling for connection/auth/not-found/conflict errors. 64 new tests, 102 CLI tests pass, no regressions. --- src/agentkit/cli/admin.py | 1451 +++++++++++++++++++++++++ src/agentkit/cli/admin_client.py | 172 +++ src/agentkit/cli/main.py | 4 + tests/unit/cli/__init__.py | 0 tests/unit/cli/test_admin_commands.py | 950 ++++++++++++++++ 5 files changed, 2577 insertions(+) create mode 100644 src/agentkit/cli/admin.py create mode 100644 src/agentkit/cli/admin_client.py create mode 100644 tests/unit/cli/__init__.py create mode 100644 tests/unit/cli/test_admin_commands.py diff --git a/src/agentkit/cli/admin.py b/src/agentkit/cli/admin.py new file mode 100644 index 0000000..a220b1f --- /dev/null +++ b/src/agentkit/cli/admin.py @@ -0,0 +1,1451 @@ +"""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 Any, 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: Any) -> 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 +) -> Any: + 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 +) -> Any: + 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 +) -> Any: + 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) -> Any: + 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 +) -> Any: + 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, Any] = {} + 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, Any] = {} + 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, Any] = { + "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, Any] = {} + 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, Any] = { + "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, Any] = {} + 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, Any] = {} + 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("Size") + table.add_column("Created") + for d in documents: + table.add_row( + str(d.get("id", "")), + str(d.get("filename", "")), + str(d.get("source_id", "")), + str(d.get("department_id", "")), + str(d.get("size", "")), + 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, Any] = { + "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, Any]: + params: dict[str, Any] = {} + 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, Any] = {"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("Username") + 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("username", "")), + 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) diff --git a/src/agentkit/cli/admin_client.py b/src/agentkit/cli/admin_client.py new file mode 100644 index 0000000..5dda624 --- /dev/null +++ b/src/agentkit/cli/admin_client.py @@ -0,0 +1,172 @@ +"""HTTP client for admin API calls with JWT or API key auth. + +This module is intentionally synchronous (uses :mod:`httpx`'s sync +``Client``) so that Typer command callbacks can call it directly without +``asyncio.run`` boilerplate. The client reads credentials from (in +priority order): explicit args, environment variables, or the config +file at ``~/.agentkit/admin_config.yaml``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import httpx +import yaml + +DEFAULT_SERVER_URL = "http://localhost:8001" +DEFAULT_CONFIG_PATH = Path.home() / ".agentkit" / "admin_config.yaml" +DEFAULT_TIMEOUT = 30.0 + + +class AdminHttpClient: + """Synchronous HTTP client for admin API calls. + + Authentication is provided via either a JWT ``access_token`` (sent + as ``Authorization: Bearer ``) or an API key (sent as + ``X-API-Key: ``). When neither is supplied, requests are made + unauthenticated (the server will return 401/403). + """ + + def __init__( + self, + server_url: str, + token: str | None = None, + api_key: str | None = None, + ) -> None: + self._base_url = server_url.rstrip("/") + self._token = token + self._api_key = api_key + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def from_config( + cls, + server_url: str | None = None, + token: str | None = None, + api_key: str | None = None, + config_path: Path | str | None = None, + ) -> AdminHttpClient: + """Build a client from args, env vars, or config file. + + Priority (highest first): + 1. Explicit kwargs (``server_url``, ``token``, ``api_key``) + 2. Environment variables: ``AGENTKIT_SERVER_URL``, + ``AGENTKIT_ADMIN_TOKEN``, ``AGENTKIT_ADMIN_API_KEY`` + 3. Config file at ``~/.agentkit/admin_config.yaml`` + 4. Hard-coded defaults (server URL only) + """ + path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH + file_cfg: dict[str, Any] = {} + if path.exists(): + try: + with path.open(encoding="utf-8") as f: + loaded = yaml.safe_load(f) + if isinstance(loaded, dict): + file_cfg = loaded + except (yaml.YAMLError, OSError): + # Corrupt or unreadable config — fall back to defaults. + file_cfg = {} + + resolved_url = ( + server_url + or os.environ.get("AGENTKIT_SERVER_URL") + or file_cfg.get("server_url") + or DEFAULT_SERVER_URL + ) + resolved_token = token or os.environ.get("AGENTKIT_ADMIN_TOKEN") or file_cfg.get("token") + resolved_key = ( + api_key or os.environ.get("AGENTKIT_ADMIN_API_KEY") or file_cfg.get("api_key") + ) + return cls( + server_url=resolved_url, + token=resolved_token, + api_key=resolved_key, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + elif self._api_key: + headers["X-API-Key"] = self._api_key + return headers + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> httpx.Response: + url = f"{self._base_url}{path}" + with httpx.Client(timeout=timeout) as client: + resp = client.request( + method, + url, + params=params, + json=json, + headers=self._headers(), + ) + resp.raise_for_status() + return resp + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def base_url(self) -> str: + return self._base_url + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + resp = self._request("GET", path, params=params) + return resp.json() + + def get_text(self, path: str, params: dict[str, Any] | None = None) -> str: + """GET returning response text (for CSV exports).""" + resp = self._request("GET", path, params=params) + return resp.text + + def post(self, path: str, json: dict[str, Any] | None = None) -> Any: + resp = self._request("POST", path, json=json) + return resp.json() + + def put(self, path: str, json: dict[str, Any] | None = None) -> Any: + resp = self._request("PUT", path, json=json) + return resp.json() + + def patch(self, path: str, json: dict[str, Any] | None = None) -> Any: + resp = self._request("PATCH", path, json=json) + return resp.json() + + def delete(self, path: str, params: dict[str, Any] | None = None) -> Any: + resp = self._request("DELETE", path, params=params) + return resp.json() + + def login(self, username: str, password: str) -> str: + """Authenticate with username/password and return an access token. + + Calls ``POST /api/v1/auth/login``. Raises + :class:`httpx.HTTPStatusError` on non-2xx responses (e.g. 401 + for invalid credentials). + """ + with httpx.Client(timeout=10.0) as client: + resp = client.post( + f"{self._base_url}/api/v1/auth/login", + json={"username": username, "password": password}, + ) + resp.raise_for_status() + data = resp.json() + return data["access_token"] diff --git a/src/agentkit/cli/main.py b/src/agentkit/cli/main.py index 60ddadb..257f3e3 100644 --- a/src/agentkit/cli/main.py +++ b/src/agentkit/cli/main.py @@ -19,6 +19,10 @@ from agentkit.cli.skill import skill_app # noqa: E402 app.add_typer(skill_app, name="skill") +from agentkit.cli.admin import admin_app # noqa: E402 + +app.add_typer(admin_app, name="admin") + from agentkit.cli.init import init # noqa: E402 app.command(name="init")(init) diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/cli/test_admin_commands.py b/tests/unit/cli/test_admin_commands.py new file mode 100644 index 0000000..7e17157 --- /dev/null +++ b/tests/unit/cli/test_admin_commands.py @@ -0,0 +1,950 @@ +"""Tests for the admin CLI command group (U8). + +These tests exercise the Typer command callbacks in +:mod:`agentkit.cli.admin` by mocking :class:`AdminHttpClient` so no +real HTTP traffic is generated. They verify that each command: +- Calls the correct endpoint with the correct method/body/params. +- Exits 0 on success and prints the expected output. +- Handles connection / HTTP errors gracefully with a non-zero exit. +""" + +from __future__ import annotations + +import os +import tempfile +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from typer.testing import CliRunner + +from agentkit.cli.admin import admin_app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_client(): + """Patch :class:`AdminHttpClient` and return the mock instance. + + The mock's ``base_url`` is set so error handlers can format messages. + """ + with patch("agentkit.cli.admin.AdminHttpClient") as mock_client_class: + mock = MagicMock() + mock.base_url = "http://localhost:8001" + mock_client_class.from_config.return_value = mock + yield mock + + +# --------------------------------------------------------------------------- +# Department commands +# --------------------------------------------------------------------------- + + +class TestDepartmentCommands: + def test_dept_list_json(self, mock_client): + """department list --json outputs raw JSON.""" + mock_client.get.return_value = [ + { + "id": "dept-1", + "name": "HR", + "description": "Human Resources", + "is_active": True, + "created_at": "2026-01-01T00:00:00Z", + } + ] + result = runner.invoke(admin_app, ["department", "list", "--json"]) + assert result.exit_code == 0 + assert "HR" in result.stdout + assert "dept-1" in result.stdout + mock_client.get.assert_called_once_with("/api/v1/admin/departments", params=None) + + def test_dept_list_table(self, mock_client): + """department list renders a Rich table by default.""" + mock_client.get.return_value = [ + { + "id": "dept-1", + "name": "Engineering", + "description": "Eng team", + "is_active": True, + "created_at": "2026-01-01", + } + ] + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code == 0 + assert "Engineering" in result.stdout + assert "Departments" in result.stdout + + def test_dept_list_empty(self, mock_client): + """department list with no results shows empty message.""" + mock_client.get.return_value = [] + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code == 0 + assert "No departments" in result.stdout + + def test_dept_create(self, mock_client): + """department create posts the correct body.""" + mock_client.post.return_value = { + "id": "dept-2", + "name": "Finance", + "description": "Finance team", + } + result = runner.invoke( + admin_app, + [ + "department", + "create", + "--name", + "Finance", + "--description", + "Finance team", + ], + ) + assert result.exit_code == 0 + assert "Finance" in result.stdout + mock_client.post.assert_called_once_with( + "/api/v1/admin/departments", + json={"name": "Finance", "description": "Finance team"}, + ) + + def test_dept_update(self, mock_client): + """department update patches only provided fields.""" + mock_client.patch.return_value = {"id": "dept-1", "name": "HR Updated"} + result = runner.invoke( + admin_app, + ["department", "update", "dept-1", "--name", "HR Updated"], + ) + assert result.exit_code == 0 + mock_client.patch.assert_called_once_with( + "/api/v1/admin/departments/dept-1", + json={"name": "HR Updated"}, + ) + + def test_dept_update_no_fields_errors(self, mock_client): + """department update without --name or --description exits non-zero.""" + result = runner.invoke(admin_app, ["department", "update", "dept-1"]) + assert result.exit_code != 0 + assert "Provide" in result.stdout or "Error" in result.stdout + + def test_dept_delete(self, mock_client): + """department delete calls DELETE and prints success.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke(admin_app, ["department", "delete", "dept-1"]) + assert result.exit_code == 0 + assert "deleted" in result.stdout.lower() + mock_client.delete.assert_called_once_with("/api/v1/admin/departments/dept-1", params=None) + + def test_dept_enable(self, mock_client): + """department enable calls the enable endpoint.""" + mock_client.post.return_value = {"id": "dept-1", "is_active": True} + result = runner.invoke(admin_app, ["department", "enable", "dept-1"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/departments/dept-1/enable", json=None + ) + + def test_dept_disable(self, mock_client): + """department disable calls the disable endpoint.""" + mock_client.post.return_value = {"id": "dept-1", "is_active": False} + result = runner.invoke(admin_app, ["department", "disable", "dept-1"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/departments/dept-1/disable", json=None + ) + + def test_dept_bind_skill(self, mock_client): + """department bind-skill posts to the binding endpoint.""" + mock_client.post.return_value = {"bound": True} + result = runner.invoke( + admin_app, + ["department", "bind-skill", "dept-1", "content_generator"], + ) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/departments/dept-1/skills/content_generator", + json=None, + ) + + def test_dept_unbind_skill(self, mock_client): + """department unbind-skill deletes the binding.""" + mock_client.delete.return_value = {"unbound": True} + result = runner.invoke( + admin_app, + ["department", "unbind-skill", "dept-1", "content_generator"], + ) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with( + "/api/v1/admin/departments/dept-1/skills/content_generator", + params=None, + ) + + def test_dept_bind_kb(self, mock_client): + """department bind-kb posts to the KB binding endpoint.""" + mock_client.post.return_value = {"bound": True} + result = runner.invoke( + admin_app, + ["department", "bind-kb", "dept-1", "kb-src-1"], + ) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/departments/dept-1/kb/kb-src-1", + json=None, + ) + + def test_dept_unbind_kb(self, mock_client): + """department unbind-kb deletes the KB binding.""" + mock_client.delete.return_value = {"unbound": True} + result = runner.invoke( + admin_app, + ["department", "unbind-kb", "dept-1", "kb-src-1"], + ) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with( + "/api/v1/admin/departments/dept-1/kb/kb-src-1", + params=None, + ) + + def test_dept_list_skills(self, mock_client): + """department list-skills returns bound skill names.""" + mock_client.get.return_value = ["content_generator", "code_reviewer"] + result = runner.invoke(admin_app, ["department", "list-skills", "dept-1"]) + assert result.exit_code == 0 + assert "content_generator" in result.stdout + + def test_dept_list_quotas(self, mock_client): + """department list-quotas returns quota rows.""" + mock_client.get.return_value = [ + {"quota_type": "token", "limit_value": 100000, "period": "daily"} + ] + result = runner.invoke(admin_app, ["department", "list-quotas", "dept-1"]) + assert result.exit_code == 0 + assert "token" in result.stdout + + def test_dept_set_quota_token(self, mock_client): + """department set-quota with token type sends int limit.""" + mock_client.put.return_value = {"quota_type": "token", "limit_value": 100000} + result = runner.invoke( + admin_app, + [ + "department", + "set-quota", + "dept-1", + "--type", + "token", + "--limit", + "100000", + ], + ) + assert result.exit_code == 0 + mock_client.put.assert_called_once_with( + "/api/v1/admin/departments/dept-1/quotas", + json={ + "quota_type": "token", + "limit_value": 100000, + "period": "daily", + }, + ) + + def test_dept_set_quota_whitelist(self, mock_client): + """department set-quota with model_whitelist sends list.""" + mock_client.put.return_value = {"quota_type": "model_whitelist"} + result = runner.invoke( + admin_app, + [ + "department", + "set-quota", + "dept-1", + "--type", + "model_whitelist", + "--limit", + "gpt-4,claude-3", + ], + ) + assert result.exit_code == 0 + mock_client.put.assert_called_once_with( + "/api/v1/admin/departments/dept-1/quotas", + json={ + "quota_type": "model_whitelist", + "limit_value": ["gpt-4", "claude-3"], + "period": "daily", + }, + ) + + def test_dept_delete_quota(self, mock_client): + """department delete-quota passes query params.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke( + admin_app, + [ + "department", + "delete-quota", + "dept-1", + "--type", + "token", + "--period", + "monthly", + ], + ) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with( + "/api/v1/admin/departments/dept-1/quotas", + params={"quota_type": "token", "period": "monthly"}, + ) + + +# --------------------------------------------------------------------------- +# User commands +# --------------------------------------------------------------------------- + + +class TestUserCommands: + def test_user_list(self, mock_client): + """user list returns users table.""" + mock_client.get.return_value = [ + { + "id": "user-1", + "username": "alice", + "email": "alice@example.com", + "role": "member", + "is_active": True, + } + ] + result = runner.invoke(admin_app, ["user", "list"]) + assert result.exit_code == 0 + assert "alice" in result.stdout + mock_client.get.assert_called_once_with("/api/v1/admin/users", params=None) + + def test_user_list_with_department_filter(self, mock_client): + """user list --department-id passes the filter.""" + mock_client.get.return_value = [] + result = runner.invoke(admin_app, ["user", "list", "--department-id", "dept-1"]) + assert result.exit_code == 0 + mock_client.get.assert_called_once_with( + "/api/v1/admin/users", params={"department_id": "dept-1"} + ) + + def test_user_create(self, mock_client): + """user create posts the user body.""" + mock_client.post.return_value = { + "id": "user-2", + "username": "bob", + "email": "bob@example.com", + } + result = runner.invoke( + admin_app, + [ + "user", + "create", + "--username", + "bob", + "--email", + "bob@example.com", + "--password", + "secret123", + ], + ) + assert result.exit_code == 0 + assert "bob" in result.stdout + mock_client.post.assert_called_once_with( + "/api/v1/admin/users", + json={ + "username": "bob", + "email": "bob@example.com", + "password": "secret123", + "role": "member", + }, + ) + + def test_user_create_with_departments(self, mock_client): + """user create --department-ids splits the comma list.""" + mock_client.post.return_value = {"id": "user-3", "username": "carol"} + result = runner.invoke( + admin_app, + [ + "user", + "create", + "--username", + "carol", + "--email", + "carol@example.com", + "--password", + "secret123", + "--department-ids", + "dept-1,dept-2", + ], + ) + assert result.exit_code == 0 + body = mock_client.post.call_args.kwargs["json"] + assert body["department_ids"] == ["dept-1", "dept-2"] + + def test_user_update_role(self, mock_client): + """user update --role patches only the role field.""" + mock_client.patch.return_value = {"id": "user-1", "role": "admin"} + result = runner.invoke(admin_app, ["user", "update", "user-1", "--role", "admin"]) + assert result.exit_code == 0 + mock_client.patch.assert_called_once_with( + "/api/v1/admin/users/user-1", + json={"role": "admin"}, + ) + + def test_user_delete(self, mock_client): + """user delete soft-deletes the user.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke(admin_app, ["user", "delete", "user-1"]) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with("/api/v1/admin/users/user-1", params=None) + + def test_user_reset_password(self, mock_client): + """user reset-password posts the new password.""" + mock_client.post.return_value = {"reset": True} + result = runner.invoke( + admin_app, + ["user", "reset-password", "user-1", "--password", "newpass"], + ) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/users/user-1/reset-password", + json={"new_password": "newpass"}, + ) + + def test_user_assign_department(self, mock_client): + """user assign-department posts to the assignment endpoint.""" + mock_client.post.return_value = {"assigned": True} + result = runner.invoke(admin_app, ["user", "assign-department", "user-1", "dept-1"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/users/user-1/departments/dept-1", + json=None, + ) + + def test_user_remove_department(self, mock_client): + """user remove-department deletes the assignment.""" + mock_client.delete.return_value = {"removed": True} + result = runner.invoke(admin_app, ["user", "remove-department", "user-1", "dept-1"]) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with( + "/api/v1/admin/users/user-1/departments/dept-1", + params=None, + ) + + +# --------------------------------------------------------------------------- +# LLM commands +# --------------------------------------------------------------------------- + + +class TestLlmCommands: + def test_llm_list_providers(self, mock_client): + """llm list-providers returns the providers table.""" + mock_client.get.return_value = [ + { + "name": "openai", + "type": "openai", + "base_url": "", + "api_key": "sk-***", + "max_tokens": 4096, + "timeout": 60.0, + } + ] + result = runner.invoke(admin_app, ["llm", "list-providers"]) + assert result.exit_code == 0 + assert "openai" in result.stdout + + def test_llm_add_provider(self, mock_client): + """llm add-provider posts the provider config.""" + mock_client.post.return_value = {"name": "anthropic"} + result = runner.invoke( + admin_app, + [ + "llm", + "add-provider", + "--name", + "anthropic", + "--api-key", + "sk-ant-xxx", + ], + ) + assert result.exit_code == 0 + body = mock_client.post.call_args.kwargs["json"] + assert body["name"] == "anthropic" + assert body["api_key"] == "sk-ant-xxx" + + def test_llm_update_provider(self, mock_client): + """llm update-provider patches the provider.""" + mock_client.patch.return_value = {"name": "openai"} + result = runner.invoke( + admin_app, + ["llm", "update-provider", "openai", "--max-tokens", "8192"], + ) + assert result.exit_code == 0 + mock_client.patch.assert_called_once_with( + "/api/v1/admin/llm/providers/openai", + json={"max_tokens": 8192}, + ) + + def test_llm_delete_provider(self, mock_client): + """llm delete-provider deletes the provider.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke(admin_app, ["llm", "delete-provider", "openai"]) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with( + "/api/v1/admin/llm/providers/openai", params=None + ) + + def test_llm_set_api_key(self, mock_client): + """llm set-api-key posts the new key.""" + mock_client.post.return_value = {"name": "openai", "api_key": "sk-***"} + result = runner.invoke( + admin_app, + ["llm", "set-api-key", "openai", "--api-key", "sk-new"], + ) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/llm/providers/openai/api-key", + json={"api_key": "sk-new"}, + ) + + def test_llm_list_fallbacks(self, mock_client): + """llm list-fallbacks returns the fallback map.""" + mock_client.get.return_value = {"gpt-4": ["openai/gpt-4", "anthropic/claude-3"]} + result = runner.invoke(admin_app, ["llm", "list-fallbacks"]) + assert result.exit_code == 0 + assert "gpt-4" in result.stdout + + def test_llm_set_fallback(self, mock_client): + """llm set-fallback puts the chain list.""" + mock_client.put.return_value = {"model": "gpt-4", "chain": ["a", "b"]} + result = runner.invoke( + admin_app, + ["llm", "set-fallback", "gpt-4", "--chain", "openai/gpt-4,anthropic/claude-3"], + ) + assert result.exit_code == 0 + mock_client.put.assert_called_once_with( + "/api/v1/admin/llm/fallbacks/gpt-4", + json={"chain": ["openai/gpt-4", "anthropic/claude-3"]}, + ) + + def test_llm_delete_fallback(self, mock_client): + """llm delete-fallback deletes the chain.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke(admin_app, ["llm", "delete-fallback", "gpt-4"]) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with("/api/v1/admin/llm/fallbacks/gpt-4", params=None) + + +# --------------------------------------------------------------------------- +# Skill commands +# --------------------------------------------------------------------------- + + +class TestSkillCommands: + def test_skill_list(self, mock_client): + """skill list calls GET /skills (not /admin/skills).""" + mock_client.get.return_value = [ + {"name": "content_generator", "agent_type": "llm", "description": "Gen"} + ] + result = runner.invoke(admin_app, ["skill", "list"]) + assert result.exit_code == 0 + mock_client.get.assert_called_once_with("/api/v1/skills", params=None) + + def test_skill_enable(self, mock_client): + """skill enable posts to the enable endpoint.""" + mock_client.post.return_value = {"enabled": True, "skill_name": "x"} + result = runner.invoke(admin_app, ["skill", "enable", "x"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/enable", json=None) + + def test_skill_disable(self, mock_client): + """skill disable posts to the disable endpoint.""" + mock_client.post.return_value = {"disabled": True} + result = runner.invoke(admin_app, ["skill", "disable", "x"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/disable", json=None) + + def test_skill_reload(self, mock_client): + """skill reload posts to the reload endpoint.""" + mock_client.post.return_value = {"name": "x", "reloaded": True} + result = runner.invoke(admin_app, ["skill", "reload", "x"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with("/api/v1/admin/skills/x/reload", json=None) + + def test_skill_import(self, mock_client): + """skill import reads the YAML file and posts its content.""" + mock_client.post.return_value = {"name": "imported_skill"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("name: imported_skill\ndescription: test\n") + f.flush() + path = f.name + try: + result = runner.invoke(admin_app, ["skill", "import", path]) + assert result.exit_code == 0 + body = mock_client.post.call_args.kwargs["json"] + assert "imported_skill" in body["yaml_content"] + finally: + os.unlink(path) + + def test_skill_import_missing_file(self, mock_client): + """skill import with a missing file exits non-zero.""" + result = runner.invoke(admin_app, ["skill", "import", "/nonexistent.yaml"]) + assert result.exit_code != 0 + assert "not found" in result.stdout.lower() + + +# --------------------------------------------------------------------------- +# KB commands +# --------------------------------------------------------------------------- + + +class TestKbCommands: + def test_kb_list_documents(self, mock_client): + """kb list-documents returns the documents table.""" + mock_client.get.return_value = { + "documents": [ + { + "id": "doc-1", + "filename": "spec.md", + "source_id": "src-1", + "department_id": "dept-1", + "size": 1024, + "created_at": "2026-01-01", + } + ] + } + result = runner.invoke(admin_app, ["kb", "list-documents"]) + assert result.exit_code == 0 + assert "spec.md" in result.stdout + + def test_kb_upload(self, mock_client): + """kb upload reads the file and posts the content.""" + mock_client.post.return_value = {"id": "doc-2", "filename": "x.md"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("# Title\nbody") + f.flush() + path = f.name + try: + result = runner.invoke( + admin_app, + [ + "kb", + "upload", + "--filename", + "x.md", + "--content-file", + path, + "--source-id", + "src-1", + ], + ) + assert result.exit_code == 0 + body = mock_client.post.call_args.kwargs["json"] + assert body["filename"] == "x.md" + assert "# Title" in body["content"] + finally: + os.unlink(path) + + def test_kb_delete(self, mock_client): + """kb delete deletes the document.""" + mock_client.delete.return_value = {"deleted": True} + result = runner.invoke(admin_app, ["kb", "delete", "doc-1"]) + assert result.exit_code == 0 + mock_client.delete.assert_called_once_with("/api/v1/admin/kb/documents/doc-1", params=None) + + def test_kb_sync(self, mock_client): + """kb sync triggers a source sync.""" + mock_client.post.return_value = {"synced": True} + result = runner.invoke(admin_app, ["kb", "sync", "src-1"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with("/api/v1/admin/kb/sources/src-1/sync", json=None) + + def test_kb_rebuild(self, mock_client): + """kb rebuild triggers a source rebuild.""" + mock_client.post.return_value = {"rebuilt": True} + result = runner.invoke(admin_app, ["kb", "rebuild", "src-1"]) + assert result.exit_code == 0 + mock_client.post.assert_called_once_with( + "/api/v1/admin/kb/sources/src-1/rebuild", json=None + ) + + +# --------------------------------------------------------------------------- +# Usage commands +# --------------------------------------------------------------------------- + + +class TestUsageCommands: + def test_usage_summary(self, mock_client): + """usage summary returns aggregated metrics.""" + mock_client.get.return_value = { + "total_requests": 100, + "total_tokens": 50000, + "total_cost": 1.23, + } + result = runner.invoke(admin_app, ["usage", "summary"]) + assert result.exit_code == 0 + assert "total_requests" in result.stdout + assert "100" in result.stdout + + def test_usage_summary_json(self, mock_client): + """usage summary --json outputs raw JSON.""" + mock_client.get.return_value = {"total_requests": 5} + result = runner.invoke(admin_app, ["usage", "summary", "--json"]) + assert result.exit_code == 0 + assert "total_requests" in result.stdout + assert "5" in result.stdout + + def test_usage_timeseries(self, mock_client): + """usage timeseries returns bucketed rows.""" + mock_client.get.return_value = [ + {"bucket": "2026-01-01", "requests": 10, "tokens": 1000, "cost": 0.1} + ] + result = runner.invoke(admin_app, ["usage", "timeseries", "--interval", "day"]) + assert result.exit_code == 0 + assert "2026-01-01" in result.stdout + params = mock_client.get.call_args.kwargs["params"] + assert params["interval"] == "day" + + def test_usage_by_model(self, mock_client): + """usage by-model returns per-model rows.""" + mock_client.get.return_value = [ + {"model": "gpt-4", "requests": 50, "tokens": 20000, "cost": 0.8} + ] + result = runner.invoke(admin_app, ["usage", "by-model"]) + assert result.exit_code == 0 + assert "gpt-4" in result.stdout + + def test_usage_top_users(self, mock_client): + """usage top-users returns ranked rows.""" + mock_client.get.return_value = [ + { + "user_id": "user-1", + "username": "alice", + "requests": 100, + "tokens": 50000, + "cost": 1.5, + } + ] + result = runner.invoke(admin_app, ["usage", "top-users", "--limit", "5"]) + assert result.exit_code == 0 + assert "alice" in result.stdout + params = mock_client.get.call_args.kwargs["params"] + assert params["limit"] == 5 + + def test_usage_export_stdout(self, mock_client): + """usage export prints CSV to stdout.""" + mock_client.get_text.return_value = "user_id,tokens\nalice,1000\n" + result = runner.invoke(admin_app, ["usage", "export", "--format", "csv"]) + assert result.exit_code == 0 + assert "user_id" in result.stdout + + def test_usage_export_to_file(self, mock_client): + """usage export --output writes to a file.""" + mock_client.get_text.return_value = "user_id,tokens\nalice,1000\n" + with tempfile.NamedTemporaryFile(mode="r", suffix=".csv", delete=False) as f: + path = f.name + try: + result = runner.invoke(admin_app, ["usage", "export", "--output", path]) + assert result.exit_code == 0 + with open(path) as f: + content = f.read() + assert "user_id" in content + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# Login command +# --------------------------------------------------------------------------- + + +class TestLoginCommand: + def test_login_success(self): + """admin login saves the token to the config file.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "admin_config.yaml") + with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls: + mock_instance = MagicMock() + mock_instance.login.return_value = "jwt-token-123" + mock_cls.return_value = mock_instance + result = runner.invoke( + admin_app, + [ + "login", + "--username", + "admin", + "--password", + "secret", + "--config-path", + config_path, + ], + ) + assert result.exit_code == 0 + assert "Login successful" in result.stdout + import yaml + + with open(config_path) as f: + cfg = yaml.safe_load(f) + assert cfg["token"] == "jwt-token-123" + assert cfg["server_url"] == "http://localhost:8001" + + def test_login_with_server_url(self): + """admin login --server-url saves the custom URL.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "admin_config.yaml") + with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls: + mock_instance = MagicMock() + mock_instance.login.return_value = "tok" + mock_cls.return_value = mock_instance + result = runner.invoke( + admin_app, + [ + "login", + "--username", + "admin", + "--password", + "secret", + "--server-url", + "http://my-server:9000", + "--config-path", + config_path, + ], + ) + assert result.exit_code == 0 + import yaml + + with open(config_path) as f: + cfg = yaml.safe_load(f) + assert cfg["server_url"] == "http://my-server:9000" + + def test_login_failure_auth_error(self): + """admin login with bad credentials exits non-zero.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "admin_config.yaml") + with patch("agentkit.cli.admin.AdminHttpClient") as mock_cls: + mock_instance = MagicMock() + # Simulate a 401 from the server. + response = MagicMock(spec=httpx.Response) + response.status_code = 401 + response.text = "Unauthorized" + response.json.return_value = {"detail": "Invalid credentials"} + mock_instance.login.side_effect = httpx.HTTPStatusError( + "Unauthorized", request=MagicMock(), response=response + ) + mock_cls.return_value = mock_instance + result = runner.invoke( + admin_app, + [ + "login", + "--username", + "admin", + "--password", + "wrong", + "--config-path", + config_path, + ], + ) + assert result.exit_code != 0 + assert "Authentication failed" in result.stdout + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + def test_connection_error(self, mock_client): + """Connection errors produce a friendly message and exit 1.""" + mock_client.get.side_effect = httpx.ConnectError("connection refused") + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code != 0 + assert "Cannot connect" in result.stdout + + def test_auth_error_401(self, mock_client): + """401 errors prompt the user to run 'admin login'.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 401 + response.text = "Unauthorized" + response.json.return_value = {"detail": "Unauthorized"} + mock_client.get.side_effect = httpx.HTTPStatusError( + "Unauthorized", request=MagicMock(), response=response + ) + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code != 0 + assert "Authentication failed" in result.stdout + + def test_forbidden_error_403(self, mock_client): + """403 errors explain admin permission is required.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 403 + response.text = "Forbidden" + response.json.return_value = {"detail": "Forbidden"} + mock_client.get.side_effect = httpx.HTTPStatusError( + "Forbidden", request=MagicMock(), response=response + ) + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code != 0 + assert "Admin permission required" in result.stdout + + def test_not_found_error_404(self, mock_client): + """404 errors print a not-found message.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 404 + response.text = "Not Found" + response.json.return_value = {"detail": "Not found"} + mock_client.get.side_effect = httpx.HTTPStatusError( + "Not Found", request=MagicMock(), response=response + ) + result = runner.invoke(admin_app, ["department", "list"]) + assert result.exit_code != 0 + assert "not found" in result.stdout.lower() + + def test_conflict_error_409(self, mock_client): + """409 errors print the conflict detail.""" + response = MagicMock(spec=httpx.Response) + response.status_code = 409 + response.text = "Conflict" + response.json.return_value = {"detail": "Name already exists"} + mock_client.post.side_effect = httpx.HTTPStatusError( + "Conflict", request=MagicMock(), response=response + ) + result = runner.invoke( + admin_app, + ["department", "create", "--name", "dup"], + ) + assert result.exit_code != 0 + assert "Conflict" in result.stdout + assert "Name already exists" in result.stdout + + +# --------------------------------------------------------------------------- +# Help / registration smoke tests +# --------------------------------------------------------------------------- + + +class TestAdminAppRegistration: + def test_admin_help_shows_groups(self): + """agentkit admin --help lists all sub-groups.""" + result = runner.invoke(admin_app, ["--help"]) + assert result.exit_code == 0 + for group in ("department", "user", "llm", "skill", "kb", "usage", "login"): + assert group in result.stdout + + def test_admin_department_help(self): + """agentkit admin department --help lists department commands.""" + result = runner.invoke(admin_app, ["department", "--help"]) + assert result.exit_code == 0 + for cmd in ("list", "create", "update", "delete", "bind-skill", "unbind-skill"): + assert cmd in result.stdout + + def test_admin_registered_on_main_app(self): + """The admin sub-app is registered on the main agentkit app.""" + from agentkit.cli.main import app as main_app + + result = runner.invoke(main_app, ["--help"]) + assert result.exit_code == 0 + assert "admin" in result.stdout