feat(cli): pair command + doctor rename + client config priority

- health → doctor (better naming)
- agentkit pair --name <client> generates ak_live_ API key
- agentkit pair --list / --revoke for client management
- ClientConfig class: client config > init defaults > hardcoded
- README updated with pair usage + business system pairing guide
- 38 CLI tests passing
This commit is contained in:
chiguyong 2026-06-06 13:08:14 +08:00
parent 3cd6a73d86
commit 74e2223153
5 changed files with 336 additions and 15 deletions

View File

@ -151,7 +151,7 @@ agentkit init
agentkit serve --host 0.0.0.0 --port 8001 agentkit serve --host 0.0.0.0 --port 8001
# 健康检查 # 健康检查
agentkit health agentkit doctor
# 提交任务(远程模式) # 提交任务(远程模式)
agentkit task submit --skill content_generator --input '{"topic": "AI趋势"}' --server-url http://localhost:8001 agentkit task submit --skill content_generator --input '{"topic": "AI趋势"}' --server-url http://localhost:8001
@ -180,10 +180,40 @@ agentkit skill info content_generator --server-url http://localhost:8001
# 查看 LLM 用量 # 查看 LLM 用量
agentkit usage --server-url http://localhost:8001 agentkit usage --server-url http://localhost:8001
# 配对业务系统(生成 API Key 给业务系统使用)
agentkit pair --name geo-backend
# 输出: API Key + 连接指令
# 查看已配对的客户端
agentkit pair --list
# 撤销配对
agentkit pair --revoke geo-backend
# 也可以用 python -m 方式运行 # 也可以用 python -m 方式运行
python -m agentkit version python -m agentkit version
``` ```
### 业务系统配对
业务系统(如 GEO通过 `agentkit pair` 完成配对后,即可独立调用 AgentKit
```bash
# 1. 在 AgentKit 服务器上执行配对
agentkit pair --name geo-backend --skills-dir ./configs/skills
# 2. 将输出的 API Key 配置到业务系统
# GEO 的 .env 文件:
AGENTKIT_SERVER_URL=http://agentkit:8001
AGENTKIT_API_KEY=ak_live_xxxxxxxxxxxx
# 3. 业务系统即可调用 AgentKit API
# POST http://agentkit:8001/api/v1/tasks
# Header: X-API-Key: ak_live_xxxxxxxxxxxx
```
**配置优先级**: 客户端自定义配置pair 时指定)> init 默认配置 > 硬编码默认值
### Docker 部署 ### Docker 部署
```bash ```bash
@ -201,7 +231,7 @@ docker-compose up -d
docker-compose logs -f agentkit docker-compose logs -f agentkit
# 健康检查 # 健康检查
docker-compose exec agentkit agentkit health docker-compose exec agentkit agentkit doctor
# 停止 # 停止
docker-compose down docker-compose down

View File

@ -23,6 +23,9 @@ app.command(name="init")(init)
from agentkit.cli.usage import usage # noqa: E402 from agentkit.cli.usage import usage # noqa: E402
app.command(name="usage")(usage) app.command(name="usage")(usage)
from agentkit.cli.pair import pair # noqa: E402
app.command(name="pair")(pair)
@app.command() @app.command()
def serve( def serve(
@ -59,11 +62,11 @@ def version():
@app.command() @app.command()
def health( def doctor(
host: str = typer.Option("localhost", "--host", help="Server host"), host: str = typer.Option("localhost", "--host", help="Server host"),
port: int = typer.Option(8001, "--port", help="Server port"), port: int = typer.Option(8001, "--port", help="Server port"),
): ):
"""Check AgentKit server health""" """Diagnose AgentKit server health and configuration"""
import httpx import httpx
url = f"http://{host}:{port}/api/v1/health" url = f"http://{host}:{port}/api/v1/health"

118
src/agentkit/cli/pair.py Normal file
View File

@ -0,0 +1,118 @@
"""Client pairing CLI command"""
import os
import secrets
from typing import Optional
import typer
from rich import print as rprint
from rich.table import Table
def _generate_api_key() -> str:
"""Generate a unique API key with prefix"""
return f"ak_live_{secrets.token_hex(24)}"
def _load_clients(config_dir: str) -> dict:
"""Load clients.yaml from config directory"""
import yaml
clients_path = os.path.join(config_dir, "clients.yaml")
if os.path.exists(clients_path):
with open(clients_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _save_clients(config_dir: str, clients: dict) -> None:
"""Save clients.yaml to config directory"""
import yaml
os.makedirs(config_dir, exist_ok=True)
clients_path = os.path.join(config_dir, "clients.yaml")
with open(clients_path, "w", encoding="utf-8") as f:
yaml.dump(clients, f, default_flow_style=False, allow_unicode=True)
def pair(
name: Optional[str] = typer.Option(None, "--name", "-n", help="Client name (e.g., geo-backend)"),
skills_dir: Optional[str] = typer.Option(None, "--skills-dir", help="Custom skills directory for this client"),
config_dir: str = typer.Option(".", "--config-dir", help="AgentKit config directory"),
list_clients: bool = typer.Option(False, "--list", "-l", help="List all paired clients"),
revoke: Optional[str] = typer.Option(None, "--revoke", "-r", help="Revoke a client by name"),
server_url: str = typer.Option("http://localhost:8001", "--server-url", help="AgentKit server URL for connection instructions"),
):
"""Pair a business system with AgentKit (generate API key + register client)"""
config_dir = os.path.abspath(config_dir)
# List mode
if list_clients:
clients = _load_clients(config_dir)
if not clients:
rprint("[dim]No paired clients[/dim]")
return
table = Table(title="Paired Clients")
table.add_column("Name", style="cyan")
table.add_column("API Key (prefix)")
table.add_column("Skills Dir")
table.add_column("Created")
for client_name, info in clients.items():
key_prefix = info.get("api_key", "")[:16] + "..."
table.add_row(
client_name,
key_prefix,
info.get("skills_dir", "default"),
info.get("created_at", "N/A"),
)
rprint(table)
return
# Revoke mode
if revoke:
clients = _load_clients(config_dir)
if revoke not in clients:
rprint(f"[red]Client '{revoke}' not found[/red]")
raise typer.Exit(code=1)
del clients[revoke]
_save_clients(config_dir, clients)
rprint(f"[green]Client '{revoke}' revoked[/green]")
return
# Pair mode
if not name:
rprint("[red]Error: --name is required for pairing[/red]")
raise typer.Exit(code=1)
clients = _load_clients(config_dir)
if name in clients:
rprint(f"[red]Client '{name}' already paired. Use --revoke first to re-pair.[/red]")
raise typer.Exit(code=1)
# Generate API key
api_key = _generate_api_key()
# Save client registration
from datetime import datetime, timezone
client_info = {
"api_key": api_key,
"created_at": datetime.now(timezone.utc).isoformat(),
}
if skills_dir:
client_info["skills_dir"] = os.path.abspath(skills_dir)
clients[name] = client_info
_save_clients(config_dir, clients)
# Print results
rprint(f"[bold green]Client paired successfully![/bold green]")
rprint(f"\n Client: [cyan]{name}[/cyan]")
rprint(f" API Key: [bold]{api_key}[/bold]")
if skills_dir:
rprint(f" Skills Dir: {skills_dir}")
rprint(f"\n[bold]Connection instructions for {name}:[/bold]")
rprint(f" Set these environment variables in your business system:")
rprint(f" [cyan]AGENTKIT_SERVER_URL={server_url}[/cyan]")
rprint(f" [cyan]AGENTKIT_API_KEY={api_key}[/cyan]")
rprint(f"\n Or add to your .env file:")
rprint(f" AGENTKIT_SERVER_URL={server_url}")
rprint(f" AGENTKIT_API_KEY={api_key}")
rprint(f"\n[dim]API key will not be shown again. Store it securely.[/dim]")

View File

@ -0,0 +1,63 @@
"""Client-specific configuration with priority over defaults"""
import os
from typing import Optional
import yaml
class ClientConfig:
"""Manages client-specific configuration overrides"""
def __init__(self, config_dir: str = "."):
self.config_dir = os.path.abspath(config_dir)
self._clients: Optional[dict] = None
@property
def clients(self) -> dict:
if self._clients is None:
self._clients = self._load_clients()
return self._clients
def _load_clients(self) -> dict:
clients_path = os.path.join(self.config_dir, "clients.yaml")
if os.path.exists(clients_path):
with open(clients_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def reload(self):
"""Force reload clients.yaml"""
self._clients = None
def identify_client(self, api_key: str) -> Optional[str]:
"""Identify client name from API key"""
for name, info in self.clients.items():
if info.get("api_key") == api_key:
return name
return None
def get_client_config(self, client_name: str) -> dict:
"""Get client-specific configuration"""
return self.clients.get(client_name, {})
def get_skills_dir(self, client_name: Optional[str] = None) -> Optional[str]:
"""Get skills directory for a client (client override > default)"""
if client_name:
client_info = self.get_client_config(client_name)
if "skills_dir" in client_info:
return client_info["skills_dir"]
# Fall back to default from agentkit.yaml
default_config = self._load_default_config()
return default_config.get("skills", {}).get("paths", ["./skills"])[0] if default_config else None
def _load_default_config(self) -> dict:
config_path = os.path.join(self.config_dir, "agentkit.yaml")
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def validate_api_key(self, api_key: str) -> bool:
"""Validate an API key against registered clients"""
return self.identify_client(api_key) is not None

View File

@ -25,23 +25,23 @@ class TestVersionCommand:
assert result.exit_code == 0 assert result.exit_code == 0
class TestHealthCommand: class TestDoctorCommand:
def test_health_server_not_running(self): def test_doctor_server_not_running(self):
"""agentkit health returns error when server not running""" """agentkit doctor returns error when server not running"""
from agentkit.cli.main import app from agentkit.cli.main import app
result = runner.invoke(app, ["health"]) result = runner.invoke(app, ["doctor"])
# Should show connection error or "not running" # Should show connection error or "not running"
assert result.exit_code != 0 or "not running" in result.stdout.lower() or "connection" in result.stdout.lower() or "error" in result.stdout.lower() assert result.exit_code != 0 or "not running" in result.stdout.lower() or "connection" in result.stdout.lower() or "error" in result.stdout.lower()
def test_health_with_custom_port(self): def test_doctor_with_custom_port(self):
"""agentkit health --port 9000 uses custom port""" """agentkit doctor --port 9000 uses custom port"""
from agentkit.cli.main import app from agentkit.cli.main import app
with patch("httpx.Client") as mock_client: with patch("httpx.Client") as mock_client:
result = runner.invoke(app, ["health", "--port", "9000"]) result = runner.invoke(app, ["doctor", "--port", "9000"])
# Should attempt to connect to port 9000 # Should attempt to connect to port 9000
def test_health_server_running(self): def test_doctor_server_running(self):
"""agentkit health returns ok when server is running""" """agentkit doctor returns ok when server is running"""
from agentkit.cli.main import app from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get: with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock() mock_response = MagicMock()
@ -50,7 +50,7 @@ class TestHealthCommand:
mock_response.__enter__ = MagicMock(return_value=mock_response) mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False) mock_response.__exit__ = MagicMock(return_value=False)
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = runner.invoke(app, ["health"]) result = runner.invoke(app, ["doctor"])
# Should show healthy status # Should show healthy status
@ -81,7 +81,7 @@ class TestMainModule:
assert result.exit_code == 0 assert result.exit_code == 0
assert "serve" in result.stdout assert "serve" in result.stdout
assert "version" in result.stdout assert "version" in result.stdout
assert "health" in result.stdout assert "doctor" in result.stdout
def test_main_module_entry(self): def test_main_module_entry(self):
"""python -m agentkit works""" """python -m agentkit works"""
@ -409,3 +409,110 @@ class TestUsageCommand:
result = runner.invoke(app, ["usage"]) result = runner.invoke(app, ["usage"])
# Should either show local usage or error about missing server # Should either show local usage or error about missing server
# Either is acceptable # Either is acceptable
class TestPairCommand:
def test_pair_generates_api_key(self):
"""agentkit pair --name geo generates an API key"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(app, [
"pair",
"--name", "geo-backend",
"--config-dir", tmpdir,
])
assert result.exit_code == 0
assert "ak_live_" in result.stdout or "api_key" in result.stdout.lower()
def test_pair_saves_client_config(self):
"""agentkit pair saves client registration to clients.yaml"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(app, [
"pair",
"--name", "geo-backend",
"--config-dir", tmpdir,
])
assert result.exit_code == 0
# Check clients.yaml was created
import yaml
clients_path = os.path.join(tmpdir, "clients.yaml")
assert os.path.exists(clients_path)
with open(clients_path) as f:
clients = yaml.safe_load(f)
assert "geo-backend" in clients
assert "api_key" in clients["geo-backend"]
assert clients["geo-backend"]["api_key"].startswith("ak_live_")
def test_pair_shows_connection_instructions(self):
"""agentkit pair shows how to connect"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(app, [
"pair",
"--name", "geo-backend",
"--config-dir", tmpdir,
])
assert result.exit_code == 0
assert "AGENTKIT_API_KEY" in result.stdout or "AGENTKIT_SERVER_URL" in result.stdout
def test_pair_rejects_duplicate_name(self):
"""agentkit pair rejects duplicate client name"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
# First pair
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
# Second pair with same name
result = runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
assert result.exit_code != 0 or "already" in result.stdout.lower() or "exists" in result.stdout.lower()
def test_pair_with_custom_skills(self):
"""agentkit pair --skills-dir registers custom skills for client"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
# Create a skills directory
skills_dir = os.path.join(tmpdir, "custom_skills")
os.makedirs(skills_dir)
import yaml
with open(os.path.join(skills_dir, "test_skill.yaml"), "w") as f:
yaml.dump({"name": "test_skill", "description": "Test", "agent_type": "assistant", "mode": "llm_generate", "prompt": "You are a test assistant"}, f)
result = runner.invoke(app, [
"pair",
"--name", "geo-backend",
"--skills-dir", skills_dir,
"--config-dir", tmpdir,
])
assert result.exit_code == 0
# Check client config includes skills_dir
clients_path = os.path.join(tmpdir, "clients.yaml")
with open(clients_path) as f:
clients = yaml.safe_load(f)
assert "skills_dir" in clients["geo-backend"]
def test_pair_list(self):
"""agentkit pair --list shows all paired clients"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
# Pair two clients
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
runner.invoke(app, ["pair", "--name", "another-app", "--config-dir", tmpdir])
# List
result = runner.invoke(app, ["pair", "--list", "--config-dir", tmpdir])
assert result.exit_code == 0
assert "geo-backend" in result.stdout
assert "another-app" in result.stdout
def test_pair_revoke(self):
"""agentkit pair --revoke removes a client"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
runner.invoke(app, ["pair", "--name", "geo-backend", "--config-dir", tmpdir])
result = runner.invoke(app, ["pair", "--revoke", "geo-backend", "--config-dir", tmpdir])
assert result.exit_code == 0
# Check client is removed
import yaml
clients_path = os.path.join(tmpdir, "clients.yaml")
with open(clients_path) as f:
clients = yaml.safe_load(f)
assert "geo-backend" not in clients