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:
parent
acec8ff743
commit
b2709da08b
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 依赖 U1(Dockerfile 需要 CLI 入口)。U6 依赖所有前置单元。
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
"""Allow running agentkit as: python -m agentkit"""
|
||||
from agentkit.cli.main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""AgentKit CLI - Command-line interface for AgentKit framework"""
|
||||
|
|
@ -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]")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
"""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue