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:
parent
3cd6a73d86
commit
74e2223153
34
README.md
34
README.md
|
|
@ -151,7 +151,7 @@ agentkit init
|
|||
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
|
||||
|
|
@ -180,10 +180,40 @@ agentkit skill info content_generator --server-url http://localhost:8001
|
|||
# 查看 LLM 用量
|
||||
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 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 部署
|
||||
|
||||
```bash
|
||||
|
|
@ -201,7 +231,7 @@ docker-compose up -d
|
|||
docker-compose logs -f agentkit
|
||||
|
||||
# 健康检查
|
||||
docker-compose exec agentkit agentkit health
|
||||
docker-compose exec agentkit agentkit doctor
|
||||
|
||||
# 停止
|
||||
docker-compose down
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ app.command(name="init")(init)
|
|||
from agentkit.cli.usage import usage # noqa: E402
|
||||
app.command(name="usage")(usage)
|
||||
|
||||
from agentkit.cli.pair import pair # noqa: E402
|
||||
app.command(name="pair")(pair)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
|
|
@ -59,11 +62,11 @@ def version():
|
|||
|
||||
|
||||
@app.command()
|
||||
def health(
|
||||
def doctor(
|
||||
host: str = typer.Option("localhost", "--host", help="Server host"),
|
||||
port: int = typer.Option(8001, "--port", help="Server port"),
|
||||
):
|
||||
"""Check AgentKit server health"""
|
||||
"""Diagnose AgentKit server health and configuration"""
|
||||
import httpx
|
||||
|
||||
url = f"http://{host}:{port}/api/v1/health"
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
@ -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
|
||||
|
|
@ -25,23 +25,23 @@ class TestVersionCommand:
|
|||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestHealthCommand:
|
||||
def test_health_server_not_running(self):
|
||||
"""agentkit health returns error when server not running"""
|
||||
class TestDoctorCommand:
|
||||
def test_doctor_server_not_running(self):
|
||||
"""agentkit doctor returns error when server not running"""
|
||||
from agentkit.cli.main import app
|
||||
result = runner.invoke(app, ["health"])
|
||||
result = runner.invoke(app, ["doctor"])
|
||||
# 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()
|
||||
|
||||
def test_health_with_custom_port(self):
|
||||
"""agentkit health --port 9000 uses custom port"""
|
||||
def test_doctor_with_custom_port(self):
|
||||
"""agentkit doctor --port 9000 uses custom port"""
|
||||
from agentkit.cli.main import app
|
||||
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
|
||||
|
||||
def test_health_server_running(self):
|
||||
"""agentkit health returns ok when server is running"""
|
||||
def test_doctor_server_running(self):
|
||||
"""agentkit doctor returns ok when server is running"""
|
||||
from agentkit.cli.main import app
|
||||
with patch("httpx.Client.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
|
|
@ -50,7 +50,7 @@ class TestHealthCommand:
|
|||
mock_response.__enter__ = MagicMock(return_value=mock_response)
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_get.return_value = mock_response
|
||||
result = runner.invoke(app, ["health"])
|
||||
result = runner.invoke(app, ["doctor"])
|
||||
# Should show healthy status
|
||||
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ class TestMainModule:
|
|||
assert result.exit_code == 0
|
||||
assert "serve" in result.stdout
|
||||
assert "version" in result.stdout
|
||||
assert "health" in result.stdout
|
||||
assert "doctor" in result.stdout
|
||||
|
||||
def test_main_module_entry(self):
|
||||
"""python -m agentkit works"""
|
||||
|
|
@ -409,3 +409,110 @@ class TestUsageCommand:
|
|||
result = runner.invoke(app, ["usage"])
|
||||
# Should either show local usage or error about missing server
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue