diff --git a/README.md b/README.md index 235d11e..22d75c8 100644 --- a/README.md +++ b/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 diff --git a/src/agentkit/cli/main.py b/src/agentkit/cli/main.py index 16135da..5b09d2f 100644 --- a/src/agentkit/cli/main.py +++ b/src/agentkit/cli/main.py @@ -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" diff --git a/src/agentkit/cli/pair.py b/src/agentkit/cli/pair.py new file mode 100644 index 0000000..fa948ce --- /dev/null +++ b/src/agentkit/cli/pair.py @@ -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]") diff --git a/src/agentkit/server/client_config.py b/src/agentkit/server/client_config.py new file mode 100644 index 0000000..1b23607 --- /dev/null +++ b/src/agentkit/server/client_config.py @@ -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 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index d8d350a..3523b6b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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