diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e04727..0d3c8c8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -45,7 +45,7 @@ jobs: - name: Install dependencies run: | # create venv in repo so it can be cached - python -m venv .venv + python -m venv --clear .venv source .venv/bin/activate pip install --upgrade pip uv sync --dev diff --git a/agentflow_cli/cli/commands/skill.py b/agentflow_cli/cli/commands/skill.py new file mode 100644 index 0000000..bdaa907 --- /dev/null +++ b/agentflow_cli/cli/commands/skill.py @@ -0,0 +1,86 @@ +"""Skill command — generate coding-agent skill files (CLAUDE.md, .cursorrules, ...).""" + +from pathlib import Path +from typing import Any + +from agentflow_cli.cli.commands import BaseCommand +from agentflow_cli.cli.exceptions import FileOperationError +from agentflow_cli.cli.templates.skills import SKILL_TARGETS + + +class SkillCommand(BaseCommand): + """Write AgentFlow skill files for common coding agents.""" + + def execute( + self, + path: str = ".", + agent: str = "all", + force: bool = False, + **kwargs: Any, + ) -> int: + """Generate skill files. + + Args: + path: Project directory to generate into. + agent: Agent id to target. One of the keys in ``SKILL_TARGETS`` or ``"all"``. + force: Overwrite existing files. + """ + try: + subtitle = ( + "Generate AgentFlow skill files for coding agents " + "(Claude Code, Cursor, Copilot, Windsurf, Codex)" + ) + self.output.print_banner("Skill", subtitle, color="cyan") + + base_path = Path(path) + base_path.mkdir(parents=True, exist_ok=True) + + agent_key = agent.lower().strip() + if agent_key == "all": + targets = SKILL_TARGETS + elif agent_key in SKILL_TARGETS: + targets = {agent_key: SKILL_TARGETS[agent_key]} + else: + supported = ", ".join(sorted(["all", *SKILL_TARGETS])) + raise FileOperationError(f"Unknown agent '{agent}'. Supported: {supported}.") + + written: list[Path] = [] + for name, (rel_path, content) in targets.items(): + dest = base_path / rel_path + self._write_file(dest, content, force=force) + written.append(dest) + self.output.success(f"[{name}] wrote {dest}") + + self.output.info("\nšŸš€ Next steps:") + steps = [ + "Commit the generated files so your coding agent picks them up.", + "Edit the content to capture any project-specific conventions.", + "Re-run 'agentflow skill --force' whenever AgentFlow usage changes.", + ] + for i, step in enumerate(steps, 1): + self.output.info(f"{i}. {step}") + + return 0 + + except FileOperationError as e: + return self.handle_error(e) + except Exception as e: + return self.handle_error(FileOperationError(f"Failed to generate skill files: {e}")) + + def _write_file(self, path: Path, content: str, *, force: bool) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + + if path.exists() and not force: + raise FileOperationError( + f"File already exists: {path}. Use --force to overwrite.", + file_path=str(path), + ) + + path.write_text(content, encoding="utf-8") + self.logger.debug("Wrote skill file: %s", path) + + except OSError as e: + raise FileOperationError( + f"Failed to write file {path}: {e}", file_path=str(path) + ) from e diff --git a/agentflow_cli/cli/main.py b/agentflow_cli/cli/main.py index acc5b79..ff016dc 100644 --- a/agentflow_cli/cli/main.py +++ b/agentflow_cli/cli/main.py @@ -8,6 +8,7 @@ from agentflow_cli.cli.commands.api import APICommand from agentflow_cli.cli.commands.build import BuildCommand from agentflow_cli.cli.commands.init import InitCommand +from agentflow_cli.cli.commands.skill import SkillCommand from agentflow_cli.cli.commands.version import VersionCommand from agentflow_cli.cli.constants import ( DEFAULT_CONFIG_FILE, @@ -308,6 +309,53 @@ def build( sys.exit(handle_exception(e)) +@app.command() +def skill( + path: str = typer.Option( + ".", + "--path", + "-p", + help="Directory to generate skill files in", + ), + agent: str = typer.Option( + "all", + "--agent", + "-a", + help=( + "Coding agent to target: 'claude', 'cursor', 'copilot', 'windsurf', " + "'codex', or 'all' (default)" + ), + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Overwrite existing skill files if they exist", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Enable verbose logging", + ), + quiet: bool = typer.Option( + False, + "--quiet", + "-q", + help="Suppress all output except errors", + ), +) -> None: + """Generate AgentFlow skill files for coding agents (Claude Code, Cursor, Copilot, ...).""" + setup_cli_logging(verbose=verbose, quiet=quiet) + + try: + command = SkillCommand(output) + exit_code = command.execute(path=path, agent=agent, force=force) + sys.exit(exit_code) + except Exception as e: + sys.exit(handle_exception(e)) + + def main() -> None: """Main CLI entry point.""" try: diff --git a/agentflow_cli/cli/templates/agentflow_skill.md b/agentflow_cli/cli/templates/agentflow_skill.md new file mode 100644 index 0000000..436c57a --- /dev/null +++ b/agentflow_cli/cli/templates/agentflow_skill.md @@ -0,0 +1,158 @@ +# AgentFlow + +This project uses **AgentFlow** (`10xscale-agentflow`) — a graph-based Python +framework for building LLM agents. Use this guide when writing or modifying +agent code in this repo. + +## When to use AgentFlow primitives + +- **`StateGraph`** — the top-level container. Nodes run, edges decide what runs next. +- **`Agent`** — a node that calls an LLM. Handles provider SDKs, tool routing, memory, retries. +- **`ToolNode`** — a node that executes Python functions when the LLM requests tool calls. +- **`AgentState`** / **`Message`** — conversation state and messages flowing through the graph. +- **`InMemoryCheckpointer`** / other checkpointers — persist state per `thread_id`. + +Don't reach for raw provider SDKs (`openai`, `google-genai`) inside a graph node. +Use `Agent` with the right `provider=` instead — it handles message conversion, +streaming, tool calls, retries, and fallbacks uniformly. + +## Golden-path ReAct agent + +```python +from dotenv import load_dotenv + +from agentflow.core import Agent, StateGraph, ToolNode +from agentflow.core.state import AgentState, Message +from agentflow.storage.checkpointer import InMemoryCheckpointer +from agentflow.utils.constants import END + +load_dotenv() + + +def get_weather(location: str) -> str: + """Get the current weather for a location.""" + return f"The weather in {location} is sunny" + + +tool_node = ToolNode([get_weather]) + +agent = Agent( + model="gpt-4o", + provider="openai", + system_prompt=[{"role": "system", "content": "You are a helpful assistant."}], + tool_node=tool_node, +) + + +def should_use_tools(state: AgentState) -> str: + last = state.context[-1] if state.context else None + if last and getattr(last, "tools_calls", None) and last.role == "assistant": + return "TOOL" + if last and last.role == "tool": + return "MAIN" + return END + + +graph = StateGraph() +graph.add_node("MAIN", agent) +graph.add_node("TOOL", tool_node) +graph.add_conditional_edges("MAIN", should_use_tools, {"TOOL": "TOOL", END: END}) +graph.add_edge("TOOL", "MAIN") +graph.set_entry_point("MAIN") + +app = graph.compile(checkpointer=InMemoryCheckpointer()) + +# Invoke with a stable thread_id to get persistent history +res = app.invoke( + {"messages": [Message.text_message("What is the weather in NYC?")]}, + config={"thread_id": "demo", "recursion_limit": 10}, +) +``` + +## Providers + +Pick a provider via the `provider=` argument on `Agent`. Model names are free-form strings. + +| Provider | Auth | Models | +|---|---|---| +| `"openai"` | `OPENAI_API_KEY` | `gpt-4o`, `gpt-4o-mini`, `o1`, `o3`, `o4-mini` | +| `"google"` | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `gemini-2.0-flash`, `gemini-2.5-flash`, `gemini-2.5-pro` | +| `"vertex_ai"` | `GOOGLE_CLOUD_PROJECT` + Application Default Credentials | same Gemini models | + +Rules: + +- If `provider` is omitted, it is inferred: `gpt*|o1|o3|o4` → `openai`, `gemini*` → `google`. +- `vertex_ai` is **never** inferred — set it explicitly. +- `google` and `vertex_ai` share model names and features; only auth differs. +- `google-genai` is not bundled. Install it when using either Gemini path: + `pip install google-genai`. + +## Agent parameters worth knowing + +```python +Agent( + model="gpt-4o", + provider="openai", + system_prompt=[{"role": "system", "content": "..."}], + tool_node=tool_node, # ToolNode instance or str name + output_type="text", # "text" | "json" + trim_context=True, # trim to model context window + reasoning_config=True, # enable extended thinking (o1/o3/gemini-2.5) + retry_config=True, # retry transient API errors + fallback_models=[ # try these if primary fails + "gpt-4o-mini", + ("gemini-2.5-flash", "google"), + ], + memory=my_memory_config, # long-term memory retrieval + skills=my_skill_config, # inject skill documents + multimodal_config=my_mm_config, # auto-offload large inline media +) +``` + +## State & messages + +- Custom state extends `AgentState`: + + ```python + class MyState(AgentState): + user_id: str | None = None + ``` + +- `Message.text_message("...")` builds a user message quickly. +- Tool functions can accept `tool_call_id: str` and `state: MyState` — they are + injected automatically if declared. + +## Checkpointing + +- `InMemoryCheckpointer()` for dev / tests. +- `PgCheckpointer` / `RedisCheckpointer` for production + (requires `REDIS_URL` or a Postgres DSN). +- Always pass `config={"thread_id": "..."}` on `invoke` / `stream` to keep + history across calls. + +## CLI + +AgentFlow ships a CLI (`10xscale-agentflow-cli`): + +- `agentflow init` — scaffold `agentflow.json` and `graph/react.py`. +- `agentflow api` — run the FastAPI server over your graph. +- `agentflow play` — start the API + open the hosted playground. +- `agentflow build` — generate a `Dockerfile` (optionally `docker-compose.yml`). +- `agentflow skill` — regenerate these coding-agent skill files. + +`agentflow.json` points at the compiled graph (`"agent": "graph.react:app"`). + +## Don't + +- Don't call provider SDKs directly from a graph node. Use `Agent`. +- Don't mutate `state` in place — return a new state/messages from your node. +- Don't infer `vertex_ai` from the model name. Pass it explicitly. +- Don't commit API keys; keep them in `.env` (referenced by `agentflow.json`). +- Don't hand-roll retry loops around `Agent` — use `retry_config=True`. + +## Where to look + +- Python reference: `agentflow.core`, `agentflow.core.graph`, `agentflow.core.state`, + `agentflow.storage.checkpointer`, `agentflow.utils.constants`. +- Runtime adapters (`GoogleGenAIConverter`, OpenAI converters) live under + `agentflow.runtime.adapters.llm` — only needed when wrapping raw SDK calls. diff --git a/agentflow_cli/cli/templates/skills.py b/agentflow_cli/cli/templates/skills.py new file mode 100644 index 0000000..e0cd7ee --- /dev/null +++ b/agentflow_cli/cli/templates/skills.py @@ -0,0 +1,32 @@ +"""Skill templates for coding agents (Claude Code, Cursor, Copilot, Windsurf, Codex). + +The shared body lives in ``agentflow_skill.md`` so the rendered markdown can +remain well-formatted without fighting Python line-length rules. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Final + + +_TEMPLATE_DIR: Final[Path] = Path(__file__).parent +_SKILL_BODY: Final[str] = (_TEMPLATE_DIR / "agentflow_skill.md").read_text(encoding="utf-8") + + +_CURSOR_FRONTMATTER: Final[str] = ( + "---\n" + "description: AgentFlow framework conventions and golden-path snippets\n" + "alwaysApply: true\n" + "---\n\n" +) + + +# Registry mapping agent id -> (relative output path, file content). +SKILL_TARGETS: Final[dict[str, tuple[str, str]]] = { + "claude": ("CLAUDE.md", _SKILL_BODY), + "cursor": (".cursor/rules/agentflow.mdc", _CURSOR_FRONTMATTER + _SKILL_BODY), + "copilot": (".github/copilot-instructions.md", _SKILL_BODY), + "windsurf": (".windsurfrules", _SKILL_BODY), + "codex": ("AGENTS.md", _SKILL_BODY), +} diff --git a/tests/cli/test_skill_command.py b/tests/cli/test_skill_command.py new file mode 100644 index 0000000..d8428e0 --- /dev/null +++ b/tests/cli/test_skill_command.py @@ -0,0 +1,85 @@ +"""Tests for `agentflow skill` command.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +import agentflow_cli.cli.main as main_mod +from agentflow_cli.cli.templates.skills import SKILL_TARGETS + + +runner = CliRunner() + + +def test_skill_all_writes_every_target(tmp_path: Path) -> None: + result = runner.invoke(main_mod.app, ["skill", "--path", str(tmp_path)]) + + assert result.exit_code == 0, result.output + for rel_path, _ in SKILL_TARGETS.values(): + assert (tmp_path / rel_path).exists() + + claude = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "# AgentFlow" in claude + assert "provider=" in claude + + +def test_skill_single_agent_writes_only_that_file(tmp_path: Path) -> None: + result = runner.invoke( + main_mod.app, + ["skill", "--path", str(tmp_path), "--agent", "cursor"], + ) + + assert result.exit_code == 0, result.output + assert (tmp_path / ".cursor" / "rules" / "agentflow.mdc").exists() + assert not (tmp_path / "CLAUDE.md").exists() + assert not (tmp_path / "AGENTS.md").exists() + + +def test_skill_cursor_has_frontmatter(tmp_path: Path) -> None: + result = runner.invoke( + main_mod.app, + ["skill", "--path", str(tmp_path), "--agent", "cursor"], + ) + assert result.exit_code == 0, result.output + + content = (tmp_path / ".cursor" / "rules" / "agentflow.mdc").read_text(encoding="utf-8") + assert content.startswith("---\n") + assert "alwaysApply: true" in content + + +def test_skill_refuses_existing_file_without_force(tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("existing", encoding="utf-8") + + result = runner.invoke( + main_mod.app, + ["skill", "--path", str(tmp_path), "--agent", "claude"], + ) + + assert result.exit_code != 0 + assert (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") == "existing" + + +def test_skill_force_overwrites(tmp_path: Path) -> None: + (tmp_path / "CLAUDE.md").write_text("existing", encoding="utf-8") + + result = runner.invoke( + main_mod.app, + ["skill", "--path", str(tmp_path), "--agent", "claude", "--force"], + ) + + assert result.exit_code == 0, result.output + new_content = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert new_content != "existing" + assert "# AgentFlow" in new_content + + +def test_skill_unknown_agent_fails(tmp_path: Path) -> None: + result = runner.invoke( + main_mod.app, + ["skill", "--path", str(tmp_path), "--agent", "does-not-exist"], + ) + + assert result.exit_code != 0 + assert "Unknown agent" in result.output