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 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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue