feat(admin): U8 — CLI admin command group

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.
This commit is contained in:
chiguyong 2026-06-21 18:56:14 +08:00
parent 09feca3307
commit 2dd0091bda
5 changed files with 2577 additions and 0 deletions

1451
src/agentkit/cli/admin.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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 <token>``) or an API key (sent as
``X-API-Key: <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"]

View File

@ -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)

View File

View File

@ -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