feat(cli): AgentKit CLI with serve/version/health/task/skill/init/usage

U1: CLI framework (Typer) + serve/version/health commands + __main__.py + pyproject scripts
U2: task command group (submit/status/list/cancel) with remote mode
U3: skill command group (list/load/info) with local and remote modes
U4: init command (generates agentkit.yaml/.env.example/docker-compose/skills) + usage command

31 tests passing, TDD workflow.
This commit is contained in:
chiguyong 2026-06-06 12:45:51 +08:00
parent acec8ff743
commit b2709da08b
12 changed files with 1330 additions and 1 deletions

View File

@ -30,4 +30,5 @@ EXPOSE 8001
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"
CMD ["uvicorn", "configs.geo_server:create_geo_app", "--factory", "--host", "0.0.0.0", "--port", "8001"]
ENTRYPOINT ["agentkit"]
CMD ["serve", "--host", "0.0.0.0", "--port", "8001"]

View File

@ -0,0 +1,316 @@
---
status: active
date: 2026-06-05
---
# feat: AgentKit CLI + 独立部署能力
**类型**: feat
**文件**: `docs/plans/2026-06-05-007-feat-agentkit-cli-deployment-plan.md`
**深度**: Standard — 新增 CLI 模块 + 部署配置改造,涉及 6 个新文件 + 4 个修改
---
## 问题框架
AgentKit v2 Phase 1 + Phase 2 已实现 12 个核心模块、544 个测试通过,但**无法独立部署和使用**
1. **无 CLI** — 没有 `agentkit` 命令行工具,只能写 Python 脚本或手动敲 uvicorn 命令
2. **无 `__main__.py`** — 不能 `python -m agentkit` 启动
3. **无 `init` 脚手架** — 新用户不知道如何初始化配置
4. **Dockerfile 硬编码 GEO**`CMD` 直接调用 `configs.geo_server`,不是通用入口
5. **无生产级 docker-compose** — 只有 `docker-compose.test.yml`(测试用),缺少生产部署配置
---
## 架构总览
```
agentkit CLI (Typer)
├── agentkit init → 生成 agentkit.yaml + .env.example + skills/ + docker-compose.yaml
├── agentkit serve → uvicorn agentkit.server.app:create_app --factory
├── agentkit task submit → AgentKitClient.submit_task()
├── agentkit task status → AgentKitClient.get_task_status()
├── agentkit task list → AgentKitClient.list_tasks()
├── agentkit task cancel → AgentKitClient.cancel_task()
├── agentkit skill list → SkillRegistry.list_skills() (本地) 或 API (远程)
├── agentkit skill load → SkillLoader.load_from_file() (本地)
├── agentkit skill info → Skill 详情
├── agentkit usage → LLMGateway.get_usage_summary()
├── agentkit health → /api/v1/health
└── agentkit version → importlib.metadata.version()
```
**核心设计决策**CLI 是**薄封装层**,底层复用已有的 `AgentKitClient`(远程模式)和 `create_app()` + 各 Registry本地模式
---
## 关键技术决策
### KTD-1: CLI 框架选择 Typer
**决策**: 使用 Typer而非 Click 或 argparse
**理由**:
- 与 FastAPI 同作者,类型注解驱动,团队学习成本最低
- 底层基于 Click可无缝使用 Click 生态
- Rich 集成提供开箱即用的彩色输出、表格、进度条
- 自动生成帮助文档和 shell 补全
- 项目已使用 Pydantic v2 + 类型注解Typer 风格完美契合
### KTD-2: 双模式运行(本地 vs 远程)
**决策**: CLI 支持两种运行模式
- **本地模式**(默认): 直接 import 模块执行,无需 Server 运行
- **远程模式**`--server-url`: 通过 HTTP API 调用 AgentKit Server
**理由**: 开发调试时直接本地运行更方便;生产环境通过 Server 远程调用更安全。`agentkit task submit` 在本地模式下直接创建 Agent 执行,在远程模式下调用 API。
### KTD-3: 配置文件格式 agentkit.yaml
**决策**: 使用 YAML 格式,支持 `${ENV_VAR}` 环境变量替换
**理由**: 与现有 `configs/llm_config.yaml` 格式一致,复用 `_substitute_env_vars()` 逻辑。YAML 比 TOML 更适合嵌套配置,比 JSON 支持注释。
### KTD-4: Dockerfile 入口改为 CLI
**决策**: Dockerfile `ENTRYPOINT` 改为 `agentkit` CLI`CMD` 默认 `serve`
**理由**: 统一入口,支持 `docker run agentkit task submit ...` 等一次性命令,比硬编码 uvicorn 更灵活。
---
## 实施单元
### U1. CLI 框架搭建 + `serve` + `version` + `health`
**Goal**: 建立 CLI 模块骨架,实现最基础的 3 个命令
**Dependencies**: 无
**Files**:
- `src/agentkit/cli/__init__.py` (新建)
- `src/agentkit/cli/main.py` (新建) — Typer app + serve/version/health 命令
- `src/agentkit/__main__.py` (新建) — `python -m agentkit` 入口
- `pyproject.toml` (修改) — 添加 `typer>=0.12` 依赖 + `[project.scripts]` 入口点
- `Dockerfile` (修改) — ENTRYPOINT 改为 `agentkit`
**Approach**:
- `main.py` 创建 `app = typer.Typer()` 并注册子命令
- `serve` 命令调用 `uvicorn.run()` 启动 `create_app()` 工厂函数
- `version` 命令使用 `importlib.metadata.version("fischer-agentkit")`
- `health` 命令调用 `http://localhost:{port}/api/v1/health`
- `__main__.py` 简单调用 `app()`
- pyproject.toml 添加 `[project.scripts] agentkit = "agentkit.cli.main:app"`
**Test scenarios**:
- `agentkit version` 输出正确版本号
- `agentkit serve --help` 显示帮助信息
- `agentkit health` 在 server 未运行时返回连接错误
- `agentkit health` 在 server 运行时返回健康状态
- `python -m agentkit version` 等同于 `agentkit version`
- Dockerfile ENTRYPOINT 正确执行 `agentkit serve`
**Verification**: `pip install -e . && agentkit version` 输出版本号
---
### U2. `task` 命令组submit/status/list/cancel
**Goal**: 实现任务管理的 CLI 命令
**Dependencies**: U1
**Files**:
- `src/agentkit/cli/task.py` (新建) — task 子命令组
- `src/agentkit/cli/main.py` (修改) — 注册 task 子命令
**Approach**:
- `task submit`:
- 本地模式: 创建 Agent → 执行任务 → 输出结果
- 远程模式: `AgentKitClient.submit_task()` / `submit_task_async()`
- `--mode sync|async` 控制同步/异步
- `--stream` 启用 SSE 流式输出
- `task status <task_id>`: 调用 `AgentKitClient.get_task_status()`
- `task list`: 调用 `AgentKitClient.list_tasks()`Rich 表格输出
- `task cancel <task_id>`: 调用 `AgentKitClient.cancel_task()`
- 输入数据通过 `--input` 参数JSON 字符串)或 `--input-file` 参数JSON 文件路径)
**Test scenarios**:
- `agentkit task submit --skill content_generator --input '{"topic":"AI"}'` 提交同步任务
- `agentkit task submit --mode async --skill content_generator --input '{"topic":"AI"}'` 返回 task_id
- `agentkit task status <task_id>` 显示任务状态
- `agentkit task list` 列出所有任务
- `agentkit task list --status completed` 过滤已完成任务
- `agentkit task cancel <task_id>` 取消运行中任务
- `agentkit task submit --input-file input.json` 从文件读取输入
- 远程模式下所有命令正确调用 API
- 本地模式下直接执行无需 Server
**Verification**: `agentkit task submit --help` 显示完整帮助
---
### U3. `skill` 命令组list/load/info
**Goal**: 实现技能管理的 CLI 命令
**Dependencies**: U1
**Files**:
- `src/agentkit/cli/skill.py` (新建) — skill 子命令组
- `src/agentkit/cli/main.py` (修改) — 注册 skill 子命令
**Approach**:
- `skill list`: 列出已注册技能Rich 表格输出name, mode, description
- `skill load <path>`: 从 YAML 文件加载技能到 Registry
- `skill info <name>`: 显示技能详情config 完整信息)
- 本地模式直接操作 SkillRegistry远程模式调用 `/api/v1/skills` API
**Test scenarios**:
- `agentkit skill list` 列出所有技能
- `agentkit skill load ./my_skill.yaml` 加载技能
- `agentkit skill info content_generator` 显示技能详情
- 无技能注册时 `skill list` 显示空列表
- 加载无效 YAML 文件报错
**Verification**: `agentkit skill list` 输出技能表格
---
### U4. `init` 命令 + `usage` 命令
**Goal**: 实现项目初始化和用量查询
**Dependencies**: U1
**Files**:
- `src/agentkit/cli/init.py` (新建) — init 命令
- `src/agentkit/cli/usage.py` (新建) — usage 命令
- `src/agentkit/cli/main.py` (修改) — 注册 init/usage 子命令
- `src/agentkit/cli/templates.py` (新建) — 模板文件内容agentkit.yaml、.env.example、docker-compose.yaml、示例 skill
**Approach**:
- `init` 命令:
- 交互式引导(使用 Typer `prompt`)或 `--non-interactive` 使用默认值
- 生成文件: `agentkit.yaml`, `.env.example`, `skills/example_skill.yaml`, `docker-compose.yaml`
- `agentkit.yaml` 包含 server/llm/memory/skills/logging 配置
- `.env.example` 包含 API key 占位符
- `docker-compose.yaml` 包含 agentkit + redis + postgres 服务
- 如果文件已存在,询问是否覆盖
- `usage` 命令:
- 本地模式: 从 LLMGateway.UsageTracker 获取统计
- 远程模式: 调用 `/api/v1/llm/usage` API
- `--agent` 过滤特定 Agent
- `--format table|json` 输出格式
**Test scenarios**:
- `agentkit init` 在空目录生成完整配置文件
- `agentkit init --non-interactive` 使用默认值生成
- `agentkit init` 文件已存在时提示覆盖
- 生成的 `agentkit.yaml` 包含所有必要配置段
- 生成的 `.env.example` 包含 API key 占位符
- 生成的 `docker-compose.yaml` 包含 3 个服务
- `agentkit usage` 显示用量统计表格
- `agentkit usage --agent content_generator` 过滤特定 Agent
- `agentkit usage --format json` 输出 JSON 格式
**Verification**: `mkdir /tmp/test-init && cd /tmp/test-init && agentkit init && ls -la` 看到生成的文件
---
### U5. Dockerfile 改造 + 生产级 docker-compose
**Goal**: 改造部署配置,支持 CLI 入口 + 生产部署
**Dependencies**: U1
**Files**:
- `Dockerfile` (修改) — ENTRYPOINT 改为 `agentkit`
- `docker-compose.yaml` (新建) — 生产部署配置
- `.dockerignore` (修改/新建) — 排除 tests/docs
**Approach**:
- Dockerfile:
- `ENTRYPOINT ["agentkit"]`
- `CMD ["serve", "--host", "0.0.0.0", "--port", "8001"]`
- 复制 `configs/` 目录到镜像
- 保持多阶段构建 + 非 root 用户
- docker-compose.yaml:
- `agentkit` 服务: build ., command: serve, ports: 8001, env_file: .env
- `redis` 服务: redis:7-alpine, healthcheck
- `postgres` 服务: pgvector/pgvector:pg15, healthcheck, volume
- `agentkit` depends_on redis + postgres (condition: service_healthy)
- `.dockerignore`: 排除 tests/, docs/, .git/, __pycache__/
**Test scenarios**:
- `docker build -t agentkit .` 构建成功
- `docker run agentkit version` 输出版本号
- `docker run agentkit serve` 启动 Server
- `docker-compose up` 启动完整环境
- `docker-compose exec agentkit agentkit health` 健康检查通过
**Verification**: `docker build -t agentkit . && docker run agentkit version`
---
### U6. README 更新 + 集成测试
**Goal**: 更新文档,添加 CLI 使用示例,编写集成测试
**Dependencies**: U1-U5
**Files**:
- `README.md` (修改) — 添加 CLI 使用章节
- `tests/unit/test_cli.py` (新建) — CLI 命令测试
**Approach**:
- README 添加:
- CLI 安装和快速开始
- 所有命令的使用示例
- Docker 部署说明
- `agentkit init` 生成的文件结构说明
- 测试:
- 使用 `typer.testing.CliRunner` 测试所有命令
- Mock 远程 API 调用
- 测试 init 生成的文件内容
**Test scenarios**:
- `agentkit --help` 显示所有子命令
- `agentkit task --help` 显示 task 子命令
- `agentkit init --non-interactive` 生成正确文件
- `agentkit skill list` 在无技能时显示空列表
- `agentkit version` 输出格式正确
- `agentkit usage` 在无用量时显示空表格
**Verification**: `pytest tests/unit/test_cli.py -v` 全部通过
---
## 范围边界
### 包含
- CLI 模块Typer 框架)
- `__main__.py` 入口
- `init` 脚手架生成
- Dockerfile 改造
- 生产级 docker-compose
- README 更新
### 不包含
- 交互式 REPL 模式(后续可加)
- Web UI 管理界面
- CI/CD pipeline 配置
- Kubernetes 部署配置
- 插件市场/注册中心
---
## 执行顺序
```
U1 (CLI 骨架) → U2 (task) + U3 (skill) + U4 (init/usage) 并行 → U5 (Docker) → U6 (README + 测试)
```
U2/U3/U4 互相独立可并行实现。U5 依赖 U1Dockerfile 需要 CLI 入口。U6 依赖所有前置单元。

View File

@ -20,8 +20,13 @@ dependencies = [
"httpx>=0.27",
"pyyaml>=6.0",
"jsonschema>=4.0",
"typer>=0.12",
"rich>=13.0",
]
[project.scripts]
agentkit = "agentkit.cli.main:app"
[project.optional-dependencies]
server = [
"fastapi>=0.110",

5
src/agentkit/__main__.py Normal file
View File

@ -0,0 +1,5 @@
"""Allow running agentkit as: python -m agentkit"""
from agentkit.cli.main import app
if __name__ == "__main__":
app()

View File

@ -0,0 +1 @@
"""AgentKit CLI - Command-line interface for AgentKit framework"""

54
src/agentkit/cli/init.py Normal file
View File

@ -0,0 +1,54 @@
"""Project initialization CLI command"""
import os
from typing import Optional
import typer
from rich import print as rprint
from agentkit.cli.templates import AGENTKIT_YAML, ENV_EXAMPLE, DOCKER_COMPOSE, EXAMPLE_SKILL
def _write_file(path: str, content: str, force: bool = False) -> bool:
"""Write content to file, respecting existing files unless force=True"""
if os.path.exists(path) and not force:
rprint(f"[yellow]Skipping (already exists):[/yellow] {path}")
return False
os.makedirs(os.path.dirname(path) if os.path.dirname(path) else ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
rprint(f"[green]Created:[/green] {path}")
return True
def init(
output_dir: str = typer.Option(".", "--output-dir", "-o", help="Output directory"),
non_interactive: bool = typer.Option(False, "--non-interactive", "-y", help="Skip prompts, use defaults"),
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
):
"""Initialize an AgentKit project with default configuration"""
output_dir = os.path.abspath(output_dir)
os.makedirs(output_dir, exist_ok=True)
rprint(f"[bold]Initializing AgentKit project in {output_dir}[/bold]")
# Generate agentkit.yaml
_write_file(os.path.join(output_dir, "agentkit.yaml"), AGENTKIT_YAML, force=force)
# Generate .env.example
_write_file(os.path.join(output_dir, ".env.example"), ENV_EXAMPLE, force=force)
# Generate docker-compose.yaml
_write_file(os.path.join(output_dir, "docker-compose.yaml"), DOCKER_COMPOSE, force=force)
# Generate skills directory with example
skills_dir = os.path.join(output_dir, "skills")
os.makedirs(skills_dir, exist_ok=True)
_write_file(os.path.join(skills_dir, "example_skill.yaml"), EXAMPLE_SKILL, force=force)
rprint("\n[bold green]AgentKit project initialized![/bold green]")
rprint("\nNext steps:")
rprint(" 1. Copy [cyan].env.example[/cyan] to [cyan].env[/cyan] and fill in your API keys")
rprint(" 2. Edit [cyan]agentkit.yaml[/cyan] to configure your agents")
rprint(" 3. Run [cyan]agentkit serve[/cyan] to start the server")
rprint(" 4. Run [cyan]agentkit task submit --skill example_skill --input '{\"message\": \"Hello\"}' --server-url http://localhost:8001[/cyan]")

85
src/agentkit/cli/main.py Normal file
View File

@ -0,0 +1,85 @@
"""AgentKit CLI main entry point"""
from typing import Optional
import typer
from rich import print as rprint
app = typer.Typer(
name="agentkit",
help="AgentKit - Unified Agent Framework CLI",
no_args_is_help=True,
)
from agentkit.cli.task import task_app # noqa: E402
app.add_typer(task_app, name="task")
from agentkit.cli.skill import skill_app # noqa: E402
app.add_typer(skill_app, name="skill")
from agentkit.cli.init import init # noqa: E402
app.command(name="init")(init)
from agentkit.cli.usage import usage # noqa: E402
app.command(name="usage")(usage)
@app.command()
def serve(
host: str = typer.Option("0.0.0.0", "--host", help="Server host"),
port: int = typer.Option(8001, "--port", help="Server port"),
workers: int = typer.Option(1, "--workers", help="Number of workers"),
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
):
"""Start the AgentKit server"""
import uvicorn
rprint(f"[green]Starting AgentKit Server on {host}:{port}[/green]")
uvicorn.run(
"agentkit.server.app:create_app",
host=host,
port=port,
workers=workers,
reload=reload,
factory=True,
)
@app.command()
def version():
"""Show AgentKit version"""
try:
from importlib.metadata import version as get_version
v = get_version("fischer-agentkit")
except Exception:
v = "0.1.0 (dev)"
rprint(f"AgentKit v{v}")
@app.command()
def health(
host: str = typer.Option("localhost", "--host", help="Server host"),
port: int = typer.Option(8001, "--port", help="Server port"),
):
"""Check AgentKit server health"""
import httpx
url = f"http://{host}:{port}/api/v1/health"
try:
with httpx.Client(timeout=5.0) as client:
response = client.get(url)
if response.status_code == 200:
data = response.json()
rprint(f"[green]Server is healthy[/green]: {data}")
else:
rprint(f"[red]Server returned status {response.status_code}[/red]")
raise typer.Exit(code=1)
except httpx.ConnectError:
rprint(f"[red]Cannot connect to AgentKit server at {url}[/red]")
rprint("[dim]Is the server running? Start it with: agentkit serve[/dim]")
raise typer.Exit(code=1)
except Exception as e:
rprint(f"[red]Health check failed: {e}[/red]")
raise typer.Exit(code=1)

123
src/agentkit/cli/skill.py Normal file
View File

@ -0,0 +1,123 @@
"""Skill management CLI commands"""
import os
from typing import Optional
import typer
from rich import print as rprint
from rich.table import Table
skill_app = typer.Typer(name="skill", help="Skill management commands", no_args_is_help=True)
@skill_app.command("list")
def list_skills(
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""List registered skills"""
if server_url:
# Remote mode: call API
import httpx
try:
with httpx.Client(timeout=10.0) as client:
response = client.get(f"{server_url}/api/v1/skills")
response.raise_for_status()
skills = response.json()
except Exception as e:
rprint(f"[red]Error connecting to server: {e}[/red]")
raise typer.Exit(code=1)
else:
# Local mode: use SkillRegistry directly
from agentkit.skills.registry import SkillRegistry
registry = SkillRegistry()
skills = [
{
"name": s.name,
"agent_type": s.config.agent_type,
"version": s.config.version,
"description": s.config.description,
}
for s in registry.list_skills()
]
if not skills:
rprint("[dim]No skills registered[/dim]")
return
table = Table(title="Skills")
table.add_column("Name", style="cyan")
table.add_column("Type")
table.add_column("Description")
for s in skills:
table.add_row(
s.get("name", ""),
s.get("agent_type", ""),
s.get("description", ""),
)
rprint(table)
@skill_app.command("load")
def load_skill(
path: str = typer.Argument(help="Path to skill YAML file"),
):
"""Load a skill from YAML file"""
if not os.path.exists(path):
rprint(f"[red]Error: File not found: {path}[/red]")
raise typer.Exit(code=1)
try:
from agentkit.skills.loader import SkillLoader
from agentkit.skills.registry import SkillRegistry
from agentkit.tools.registry import ToolRegistry
registry = SkillRegistry()
loader = SkillLoader(registry, ToolRegistry())
skill = loader.load_from_file(path)
rprint(f"[green]Skill loaded:[/green] {skill.name}")
rprint(f" Description: {skill.config.description}")
rprint(f" Mode: {skill.config.task_mode}")
except Exception as e:
rprint(f"[red]Error loading skill: {e}[/red]")
raise typer.Exit(code=1)
@skill_app.command("info")
def skill_info(
name: str = typer.Argument(help="Skill name"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""Show skill details"""
if server_url:
import httpx
try:
with httpx.Client(timeout=10.0) as client:
response = client.get(f"{server_url}/api/v1/skills/{name}")
response.raise_for_status()
info = response.json()
except Exception as e:
rprint(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
else:
from agentkit.skills.registry import SkillRegistry
registry = SkillRegistry()
try:
skill = registry.get(name)
info = {
"name": skill.name,
"agent_type": skill.config.agent_type,
"version": skill.config.version,
"description": skill.config.description,
"task_mode": skill.config.task_mode,
"execution_mode": skill.config.execution_mode,
}
except Exception as e:
rprint(f"[red]Skill '{name}' not found: {e}[/red]")
raise typer.Exit(code=1)
table = Table(title=f"Skill: {name}")
table.add_column("Field", style="cyan")
table.add_column("Value")
for key, value in info.items():
table.add_row(key, str(value))
rprint(table)

131
src/agentkit/cli/task.py Normal file
View File

@ -0,0 +1,131 @@
"""Task management CLI commands"""
import asyncio
import json
from typing import Optional
import typer
from rich import print as rprint
from rich.table import Table
task_app = typer.Typer(name="task", help="Task management commands", no_args_is_help=True)
@task_app.command("submit")
def submit(
input: Optional[str] = typer.Option(None, "--input", "-i", help="Input data as JSON string"),
input_file: Optional[str] = typer.Option(None, "--input-file", "-f", help="Input data from JSON file"),
skill: Optional[str] = typer.Option(None, "--skill", "-s", help="Skill name"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"),
mode: str = typer.Option("sync", "--mode", "-m", help="Execution mode: sync or async"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""Submit a task for execution"""
# Parse input data
if input_file:
with open(input_file, encoding="utf-8") as f:
input_data = json.load(f)
elif input:
input_data = json.loads(input)
else:
rprint("[red]Error: Provide --input or --input-file[/red]")
raise typer.Exit(code=1)
if not server_url:
rprint("[red]Error: --server-url is required (local mode not yet supported)[/red]")
raise typer.Exit(code=1)
# Use AgentKitClient for remote mode
from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url)
if mode == "async":
result = asyncio.run(client.submit_task_async(
input_data=input_data,
skill_name=skill,
agent_name=agent,
))
rprint("[green]Task submitted (async)[/green]")
rprint(f" Task ID: {result.get('task_id', 'N/A')}")
rprint(f" Status: {result.get('status', 'N/A')}")
else:
result = asyncio.run(client.submit_task(
input_data=input_data,
skill_name=skill,
agent_name=agent,
))
rprint("[green]Task completed[/green]")
if "output_data" in result:
rprint(json.dumps(result["output_data"], indent=2, ensure_ascii=False))
@task_app.command("status")
def status(
task_id: str = typer.Argument(help="Task ID"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""Get task status"""
if not server_url:
rprint("[red]Error: --server-url is required[/red]")
raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url)
result = asyncio.run(client.get_task_status(task_id))
table = Table(title=f"Task: {task_id}")
table.add_column("Field", style="cyan")
table.add_column("Value")
for key, value in result.items():
table.add_row(key, str(value))
rprint(table)
@task_app.command("list")
def list_tasks(
status_filter: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status"),
limit: int = typer.Option(100, "--limit", "-n", help="Maximum tasks to show"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""List tasks"""
if not server_url:
rprint("[red]Error: --server-url is required[/red]")
raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url)
tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit))
if not tasks:
rprint("[dim]No tasks found[/dim]")
return
table = Table(title="Tasks")
table.add_column("Task ID", style="cyan")
table.add_column("Agent")
table.add_column("Status")
table.add_column("Created")
for t in tasks:
table.add_row(
t.get("task_id", ""),
t.get("agent_name", ""),
t.get("status", ""),
t.get("created_at", ""),
)
rprint(table)
@task_app.command("cancel")
def cancel(
task_id: str = typer.Argument(help="Task ID"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""Cancel a running task"""
if not server_url:
rprint("[red]Error: --server-url is required[/red]")
raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url)
result = asyncio.run(client.cancel_task(task_id))
rprint(f"[green]Task cancelled[/green]: {result}")

View File

@ -0,0 +1,140 @@
"""Template files for agentkit init"""
AGENTKIT_YAML = """\
# AgentKit Configuration
# See https://github.com/fischer/agentkit for documentation
server:
host: "0.0.0.0"
port: 8001
workers: 1
api_key: null # Set to enable API key authentication
rate_limit: 60 # Requests per minute
llm:
default_provider: "openai"
providers:
openai:
api_key: "${OPENAI_API_KEY}"
base_url: "https://api.openai.com/v1"
models:
gpt-4o:
alias: "default"
gpt-4o-mini:
alias: "fast"
deepseek:
api_key: "${DEEPSEEK_API_KEY}"
base_url: "https://api.deepseek.com/v1"
models:
deepseek-chat:
alias: "deepseek"
memory:
semantic:
backend: "pgvector"
connection: "${DATABASE_URL:-postgresql+asyncpg://agentkit:agentkit@localhost:5432/agentkit}"
episodic:
backend: "redis"
connection: "${REDIS_URL:-redis://localhost:6379/0}"
working:
backend: "redis"
connection: "${REDIS_URL:-redis://localhost:6379/1}"
skills:
auto_discover: true
paths:
- "./skills"
logging:
level: "INFO"
format: "text" # "text" or "json"
"""
ENV_EXAMPLE = """\
# AgentKit Environment Variables
# Copy this file to .env and fill in your values
# LLM API Keys (at least one required)
OPENAI_API_KEY=sk-your-openai-key
DEEPSEEK_API_KEY=sk-your-deepseek-key
# Database (required for semantic memory)
DATABASE_URL=postgresql+asyncpg://agentkit:agentkit@localhost:5432/agentkit
# Redis (required for episodic/working memory)
REDIS_URL=redis://localhost:6379/0
# Server (optional)
AGENTKIT_API_KEY= # Set to enable API key authentication
"""
DOCKER_COMPOSE = """\
version: "3.8"
services:
agentkit:
build: .
command: serve --host 0.0.0.0 --port 8001
ports:
- "8001:8001"
env_file: .env
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: pgvector/pgvector:pg15
ports:
- "5432:5432"
environment:
POSTGRES_USER: agentkit
POSTGRES_PASSWORD: agentkit
POSTGRES_DB: agentkit
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentkit"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
"""
EXAMPLE_SKILL = """\
# Example Skill Configuration
name: example_skill
description: "An example skill for demonstration"
agent_type: assistant
mode: llm_generate
version: "1.0"
prompt: |
You are a helpful assistant. Respond to the user's request clearly and concisely.
tools: []
quality_gate:
enabled: false
evolution:
enabled: false
"""

57
src/agentkit/cli/usage.py Normal file
View File

@ -0,0 +1,57 @@
"""Usage statistics CLI command"""
from typing import Optional
import typer
from rich import print as rprint
from rich.table import Table
def usage(
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Filter by agent name"),
format: str = typer.Option("table", "--format", "-f", help="Output format: table or json"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
):
"""Show LLM usage statistics"""
if server_url:
import httpx
try:
with httpx.Client(timeout=10.0) as client:
params = {}
if agent:
params["agent_name"] = agent
response = client.get(f"{server_url}/api/v1/llm/usage", params=params)
response.raise_for_status()
data = response.json()
except Exception as e:
rprint(f"[red]Error: {e}[/red]")
raise typer.Exit(code=1)
else:
# Local mode: use LLMGateway.UsageTracker
try:
from agentkit.llm.gateway import LLMGateway
gateway = LLMGateway()
summary = gateway.get_usage(agent_name=agent)
data = {
"total_tokens": summary.total_tokens,
"total_cost": summary.total_cost,
"total_requests": len(summary.records),
"by_model": summary.by_model,
}
except Exception as e:
rprint(f"[dim]No usage data available: {e}[/dim]")
data = {"total_requests": 0, "total_tokens": 0, "total_cost": 0.0}
if format == "json":
import json
rprint(json.dumps(data, indent=2, ensure_ascii=False))
else:
table = Table(title="LLM Usage Statistics")
table.add_column("Metric", style="cyan")
table.add_column("Value")
for key, value in data.items():
if isinstance(value, float):
table.add_row(key, f"{value:.4f}")
else:
table.add_row(key, str(value))
rprint(table)

411
tests/unit/test_cli.py Normal file
View File

@ -0,0 +1,411 @@
"""Tests for AgentKit CLI"""
import json
import os
import tempfile
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from typer.testing import CliRunner
runner = CliRunner()
class TestVersionCommand:
def test_version_outputs_version_string(self):
"""agentkit version outputs version number"""
from agentkit.cli.main import app
result = runner.invoke(app, ["version"])
assert result.exit_code == 0
assert "0.1.0" in result.stdout or "fischer-agentkit" in result.stdout
def test_version_help(self):
"""agentkit version --help works"""
from agentkit.cli.main import app
result = runner.invoke(app, ["version", "--help"])
assert result.exit_code == 0
class TestHealthCommand:
def test_health_server_not_running(self):
"""agentkit health returns error when server not running"""
from agentkit.cli.main import app
result = runner.invoke(app, ["health"])
# 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"""
from agentkit.cli.main import app
with patch("httpx.Client") as mock_client:
result = runner.invoke(app, ["health", "--port", "9000"])
# Should attempt to connect to port 9000
def test_health_server_running(self):
"""agentkit health returns ok when server is running"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "ok"}
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"])
# Should show healthy status
class TestServeCommand:
def test_serve_help(self):
"""agentkit serve --help shows options"""
from agentkit.cli.main import app
result = runner.invoke(app, ["serve", "--help"])
assert result.exit_code == 0
assert "--host" in result.stdout
assert "--port" in result.stdout
def test_serve_starts_uvicorn(self):
"""agentkit serve calls uvicorn.run with correct params"""
from agentkit.cli.main import app
with patch("uvicorn.run") as mock_run:
result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"])
mock_run.assert_called_once()
call_kwargs = mock_run.call_args
assert "0.0.0.0" in str(call_kwargs) or 8001 in str(call_kwargs)
class TestMainModule:
def test_help_shows_all_commands(self):
"""agentkit --help shows all subcommands"""
from agentkit.cli.main import app
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "serve" in result.stdout
assert "version" in result.stdout
assert "health" in result.stdout
def test_main_module_entry(self):
"""python -m agentkit works"""
# Just verify the module can be imported
import agentkit.__main__
class TestTaskCommands:
def test_task_help(self):
"""agentkit task --help shows subcommands"""
from agentkit.cli.main import app
result = runner.invoke(app, ["task", "--help"])
assert result.exit_code == 0
assert "submit" in result.stdout
assert "status" in result.stdout
assert "list" in result.stdout
assert "cancel" in result.stdout
def test_task_submit_remote_mode(self):
"""agentkit task submit --server-url calls API"""
from agentkit.cli.main import app
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {"result": "ok"}})
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "submit",
"--server-url", "http://localhost:8001",
"--skill", "content_generator",
"--input", '{"topic": "AI"}',
])
assert result.exit_code == 0
def test_task_submit_async_mode(self):
"""agentkit task submit --mode async returns task_id"""
from agentkit.cli.main import app
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.submit_task_async = AsyncMock(return_value={"task_id": "abc-123", "status": "pending"})
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "submit",
"--server-url", "http://localhost:8001",
"--skill", "content_generator",
"--mode", "async",
"--input", '{"topic": "AI"}',
])
assert result.exit_code == 0
assert "abc-123" in result.stdout or "pending" in result.stdout
def test_task_status(self):
"""agentkit task status <id> shows status"""
from agentkit.cli.main import app
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.get_task_status = AsyncMock(return_value={
"task_id": "abc-123",
"status": "completed",
"output_data": {"result": "ok"},
})
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "status", "abc-123",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
assert "completed" in result.stdout
def test_task_list(self):
"""agentkit task list shows tasks"""
from agentkit.cli.main import app
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.list_tasks = AsyncMock(return_value=[
{"task_id": "abc-123", "status": "completed", "agent_name": "test"},
])
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "list",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
def test_task_cancel(self):
"""agentkit task cancel <id> cancels task"""
from agentkit.cli.main import app
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.cancel_task = AsyncMock(return_value={"task_id": "abc-123", "status": "cancelled"})
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "cancel", "abc-123",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
def test_task_submit_input_file(self):
"""agentkit task submit --input-file reads from file"""
from agentkit.cli.main import app
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"topic": "AI"}, f)
f.flush()
with patch("agentkit.server.client.AgentKitClient") as mock_client_cls:
mock_client = MagicMock()
mock_client.submit_task = AsyncMock(return_value={"status": "completed", "output_data": {}})
mock_client_cls.return_value = mock_client
result = runner.invoke(app, [
"task", "submit",
"--server-url", "http://localhost:8001",
"--skill", "content_generator",
"--input-file", f.name,
])
assert result.exit_code == 0
os.unlink(f.name)
def test_task_submit_no_server_url_shows_error(self):
"""agentkit task submit without --server-url shows error"""
from agentkit.cli.main import app
result = runner.invoke(app, [
"task", "submit",
"--skill", "content_generator",
"--input", '{"topic": "AI"}',
])
# Should show error about missing server URL or local mode not available
assert result.exit_code != 0 or "server" in result.stdout.lower() or "error" in result.stdout.lower()
class TestSkillCommands:
def test_skill_help(self):
"""agentkit skill --help shows subcommands"""
from agentkit.cli.main import app
result = runner.invoke(app, ["skill", "--help"])
assert result.exit_code == 0
assert "list" in result.stdout
assert "load" in result.stdout
assert "info" in result.stdout
def test_skill_list_remote(self):
"""agentkit skill list --server-url calls API"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"name": "content_generator", "agent_type": "llm", "description": "Generate content"},
]
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = runner.invoke(app, [
"skill", "list",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
assert "content_generator" in result.stdout
def test_skill_list_empty(self):
"""agentkit skill list with no skills shows empty message"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = runner.invoke(app, [
"skill", "list",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
assert "no skill" in result.stdout.lower() or "0" in result.stdout or "empty" in result.stdout.lower()
def test_skill_info_remote(self):
"""agentkit skill info <name> shows skill details"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"name": "content_generator",
"agent_type": "llm",
"description": "Generate content",
"version": "1.0.0",
}
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = runner.invoke(app, [
"skill", "info", "content_generator",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
assert "content_generator" in result.stdout
def test_skill_load_local(self):
"""agentkit skill load <path> loads a YAML skill config"""
from agentkit.cli.main import app
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
import yaml
yaml.dump({
"name": "test_skill",
"description": "A test skill",
"agent_type": "llm",
"task_mode": "llm_generate",
"prompt": {"system": "You are a test assistant"},
}, f)
f.flush()
result = runner.invoke(app, [
"skill", "load", f.name,
])
assert result.exit_code == 0
assert "test_skill" in result.stdout or "loaded" in result.stdout.lower()
os.unlink(f.name)
def test_skill_load_invalid_file(self):
"""agentkit skill load with invalid file shows error"""
from agentkit.cli.main import app
result = runner.invoke(app, [
"skill", "load", "/nonexistent/file.yaml",
])
assert result.exit_code != 0 or "error" in result.stdout.lower() or "not found" in result.stdout.lower()
class TestInitCommand:
def test_init_non_interactive(self):
"""agentkit init --non-interactive generates config files"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
assert result.exit_code == 0
# Check generated files
assert os.path.exists(os.path.join(tmpdir, "agentkit.yaml"))
assert os.path.exists(os.path.join(tmpdir, ".env.example"))
assert os.path.exists(os.path.join(tmpdir, "docker-compose.yaml"))
assert os.path.exists(os.path.join(tmpdir, "skills"))
def test_init_agentkit_yaml_content(self):
"""agentkit init generates valid agentkit.yaml"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
import yaml
with open(os.path.join(tmpdir, "agentkit.yaml")) as f:
config = yaml.safe_load(f)
assert "server" in config
assert "llm" in config
assert config["server"]["port"] == 8001
def test_init_env_example_content(self):
"""agentkit init generates .env.example with API key placeholders"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
with open(os.path.join(tmpdir, ".env.example")) as f:
content = f.read()
assert "OPENAI_API_KEY" in content or "API_KEY" in content
def test_init_docker_compose_content(self):
"""agentkit init generates docker-compose.yaml with 3 services"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
import yaml
with open(os.path.join(tmpdir, "docker-compose.yaml")) as f:
compose = yaml.safe_load(f)
services = compose.get("services", {})
assert "agentkit" in services
assert "redis" in services
assert "postgres" in services
def test_init_existing_files_no_overwrite(self):
"""agentkit init does not overwrite existing files without --force"""
from agentkit.cli.main import app
with tempfile.TemporaryDirectory() as tmpdir:
# Create existing file
with open(os.path.join(tmpdir, "agentkit.yaml"), "w") as f:
f.write("existing")
result = runner.invoke(app, ["init", "--non-interactive", "--output-dir", tmpdir])
# Should either skip or prompt
with open(os.path.join(tmpdir, "agentkit.yaml")) as f:
content = f.read()
# File should still be "existing" (not overwritten) or overwritten with --force
assert content == "existing" or "agentkit" in content.lower()
class TestUsageCommand:
def test_usage_remote(self):
"""agentkit usage --server-url calls API"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"total_requests": 10,
"total_tokens": 5000,
"total_cost": 0.15,
}
mock_get.return_value = mock_response
result = runner.invoke(app, [
"usage",
"--server-url", "http://localhost:8001",
])
assert result.exit_code == 0
def test_usage_format_json(self):
"""agentkit usage --format json outputs JSON"""
from agentkit.cli.main import app
with patch("httpx.Client.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"total_requests": 10,
"total_tokens": 5000,
"total_cost": 0.15,
}
mock_get.return_value = mock_response
result = runner.invoke(app, [
"usage",
"--server-url", "http://localhost:8001",
"--format", "json",
])
assert result.exit_code == 0
def test_usage_no_server(self):
"""agentkit usage without --server-url shows local usage or error"""
from agentkit.cli.main import app
result = runner.invoke(app, ["usage"])
# Should either show local usage or error about missing server
# Either is acceptable