From b2709da08be986abb8116db7520506ac287d4433 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sat, 6 Jun 2026 12:45:51 +0800 Subject: [PATCH] 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. --- Dockerfile | 3 +- ...5-007-feat-agentkit-cli-deployment-plan.md | 316 ++++++++++++++ pyproject.toml | 5 + src/agentkit/__main__.py | 5 + src/agentkit/cli/__init__.py | 1 + src/agentkit/cli/init.py | 54 +++ src/agentkit/cli/main.py | 85 ++++ src/agentkit/cli/skill.py | 123 ++++++ src/agentkit/cli/task.py | 131 ++++++ src/agentkit/cli/templates.py | 140 ++++++ src/agentkit/cli/usage.py | 57 +++ tests/unit/test_cli.py | 411 ++++++++++++++++++ 12 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-06-05-007-feat-agentkit-cli-deployment-plan.md create mode 100644 src/agentkit/__main__.py create mode 100644 src/agentkit/cli/__init__.py create mode 100644 src/agentkit/cli/init.py create mode 100644 src/agentkit/cli/main.py create mode 100644 src/agentkit/cli/skill.py create mode 100644 src/agentkit/cli/task.py create mode 100644 src/agentkit/cli/templates.py create mode 100644 src/agentkit/cli/usage.py create mode 100644 tests/unit/test_cli.py diff --git a/Dockerfile b/Dockerfile index dc62b6e..1a32fcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/docs/plans/2026-06-05-007-feat-agentkit-cli-deployment-plan.md b/docs/plans/2026-06-05-007-feat-agentkit-cli-deployment-plan.md new file mode 100644 index 0000000..299531d --- /dev/null +++ b/docs/plans/2026-06-05-007-feat-agentkit-cli-deployment-plan.md @@ -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 `: 调用 `AgentKitClient.get_task_status()` +- `task list`: 调用 `AgentKitClient.list_tasks()`,Rich 表格输出 +- `task cancel `: 调用 `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 ` 显示任务状态 +- `agentkit task list` 列出所有任务 +- `agentkit task list --status completed` 过滤已完成任务 +- `agentkit task cancel ` 取消运行中任务 +- `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 `: 从 YAML 文件加载技能到 Registry +- `skill info `: 显示技能详情(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 依赖 U1(Dockerfile 需要 CLI 入口)。U6 依赖所有前置单元。 diff --git a/pyproject.toml b/pyproject.toml index 96da667..2b33fb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/agentkit/__main__.py b/src/agentkit/__main__.py new file mode 100644 index 0000000..ce68fe4 --- /dev/null +++ b/src/agentkit/__main__.py @@ -0,0 +1,5 @@ +"""Allow running agentkit as: python -m agentkit""" +from agentkit.cli.main import app + +if __name__ == "__main__": + app() diff --git a/src/agentkit/cli/__init__.py b/src/agentkit/cli/__init__.py new file mode 100644 index 0000000..65c7b2a --- /dev/null +++ b/src/agentkit/cli/__init__.py @@ -0,0 +1 @@ +"""AgentKit CLI - Command-line interface for AgentKit framework""" diff --git a/src/agentkit/cli/init.py b/src/agentkit/cli/init.py new file mode 100644 index 0000000..b6b456e --- /dev/null +++ b/src/agentkit/cli/init.py @@ -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]") diff --git a/src/agentkit/cli/main.py b/src/agentkit/cli/main.py new file mode 100644 index 0000000..16135da --- /dev/null +++ b/src/agentkit/cli/main.py @@ -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) diff --git a/src/agentkit/cli/skill.py b/src/agentkit/cli/skill.py new file mode 100644 index 0000000..ebe905d --- /dev/null +++ b/src/agentkit/cli/skill.py @@ -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) diff --git a/src/agentkit/cli/task.py b/src/agentkit/cli/task.py new file mode 100644 index 0000000..cefde57 --- /dev/null +++ b/src/agentkit/cli/task.py @@ -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}") diff --git a/src/agentkit/cli/templates.py b/src/agentkit/cli/templates.py new file mode 100644 index 0000000..38dac37 --- /dev/null +++ b/src/agentkit/cli/templates.py @@ -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 +""" diff --git a/src/agentkit/cli/usage.py b/src/agentkit/cli/usage.py new file mode 100644 index 0000000..c66dafa --- /dev/null +++ b/src/agentkit/cli/usage.py @@ -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) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..d8d350a --- /dev/null +++ b/tests/unit/test_cli.py @@ -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 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 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 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 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