From 1c4f92e73d4480c1c04604d05575fa8476806970 Mon Sep 17 00:00:00 2001 From: Mothilal-hire10x Date: Sun, 19 Apr 2026 15:12:08 +0530 Subject: [PATCH 1/2] feat: add skill command to generate coding-agent skill files --- agentflow_cli/cli/commands/skill.py | 90 ++++++++++++ agentflow_cli/cli/main.py | 48 +++++++ agentflow_cli/cli/templates/skills.py | 197 ++++++++++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 agentflow_cli/cli/commands/skill.py create mode 100644 agentflow_cli/cli/templates/skills.py diff --git a/agentflow_cli/cli/commands/skill.py b/agentflow_cli/cli/commands/skill.py new file mode 100644 index 0000000..51d8feb --- /dev/null +++ b/agentflow_cli/cli/commands/skill.py @@ -0,0 +1,90 @@ +"""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/skills.py b/agentflow_cli/cli/templates/skills.py new file mode 100644 index 0000000..4526fb1 --- /dev/null +++ b/agentflow_cli/cli/templates/skills.py @@ -0,0 +1,197 @@ +"""Skill templates for coding agents (Claude Code, Cursor, Copilot, Windsurf, Codex). + +All coding agents get the same AgentFlow context. Only the filename and, in a +couple of cases, a small header differ between agents. +""" + +from __future__ import annotations + +from typing import Final + + +AGENTFLOW_SKILL_BODY: Final[str] = """\ +# 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` (or `GOOGLE_API_KEY`) | `gemini-2.0-flash`, `gemini-2.5-flash`, `gemini-2.5-pro` | +| `"vertex_ai"` | `GOOGLE_CLOUD_PROJECT` + ADC (`GOOGLE_APPLICATION_CREDENTIALS` or attached GCP SA) | 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 via `pip install google-genai` when using either Gemini path. + +## 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 docs into system prompt + 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. +""" + + +# Claude Code reads CLAUDE.md at the project root. +CLAUDE_MD: Final[str] = AGENTFLOW_SKILL_BODY + +# Cursor picks up .cursor/rules/*.mdc with a small frontmatter header. +CURSOR_MDC: Final[str] = ( + "---\n" + "description: AgentFlow framework conventions and golden-path snippets\n" + "alwaysApply: true\n" + "---\n\n" + + AGENTFLOW_SKILL_BODY +) + +# GitHub Copilot reads .github/copilot-instructions.md. +COPILOT_MD: Final[str] = AGENTFLOW_SKILL_BODY + +# Windsurf reads .windsurfrules at the project root. +WINDSURF_RULES: Final[str] = AGENTFLOW_SKILL_BODY + +# OpenAI Codex / generic agents read AGENTS.md. +AGENTS_MD: Final[str] = AGENTFLOW_SKILL_BODY + + +# Registry mapping agent id → (relative path, content) +SKILL_TARGETS: Final[dict[str, tuple[str, str]]] = { + "claude": ("CLAUDE.md", CLAUDE_MD), + "cursor": (".cursor/rules/agentflow.mdc", CURSOR_MDC), + "copilot": (".github/copilot-instructions.md", COPILOT_MD), + "windsurf": (".windsurfrules", WINDSURF_RULES), + "codex": ("AGENTS.md", AGENTS_MD), +} From 77a4bba55ffd99d92ad3899c175b0b2b0a99cf4a Mon Sep 17 00:00:00 2001 From: Mothilal-hire10x Date: Sun, 19 Apr 2026 15:37:20 +0530 Subject: [PATCH 2/2] feat: update skill command and add tests for agent file generation --- .github/workflows/ci.yaml | 2 +- agentflow_cli/cli/commands/skill.py | 8 +- .../cli/templates/agentflow_skill.md | 158 +++++++++++++++ agentflow_cli/cli/templates/skills.py | 189 ++---------------- tests/cli/test_skill_command.py | 85 ++++++++ 5 files changed, 258 insertions(+), 184 deletions(-) create mode 100644 agentflow_cli/cli/templates/agentflow_skill.md create mode 100644 tests/cli/test_skill_command.py 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 index 51d8feb..bdaa907 100644 --- a/agentflow_cli/cli/commands/skill.py +++ b/agentflow_cli/cli/commands/skill.py @@ -42,9 +42,7 @@ def execute( targets = {agent_key: SKILL_TARGETS[agent_key]} else: supported = ", ".join(sorted(["all", *SKILL_TARGETS])) - raise FileOperationError( - f"Unknown agent '{agent}'. Supported: {supported}." - ) + raise FileOperationError(f"Unknown agent '{agent}'. Supported: {supported}.") written: list[Path] = [] for name, (rel_path, content) in targets.items(): @@ -67,9 +65,7 @@ def execute( 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}") - ) + return self.handle_error(FileOperationError(f"Failed to generate skill files: {e}")) def _write_file(self, path: Path, content: str, *, force: bool) -> None: 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 index 4526fb1..e0cd7ee 100644 --- a/agentflow_cli/cli/templates/skills.py +++ b/agentflow_cli/cli/templates/skills.py @@ -1,197 +1,32 @@ """Skill templates for coding agents (Claude Code, Cursor, Copilot, Windsurf, Codex). -All coding agents get the same AgentFlow context. Only the filename and, in a -couple of cases, a small header differ between agents. +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 -AGENTFLOW_SKILL_BODY: Final[str] = """\ -# AgentFlow +_TEMPLATE_DIR: Final[Path] = Path(__file__).parent +_SKILL_BODY: Final[str] = (_TEMPLATE_DIR / "agentflow_skill.md").read_text(encoding="utf-8") -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` (or `GOOGLE_API_KEY`) | `gemini-2.0-flash`, `gemini-2.5-flash`, `gemini-2.5-pro` | -| `"vertex_ai"` | `GOOGLE_CLOUD_PROJECT` + ADC (`GOOGLE_APPLICATION_CREDENTIALS` or attached GCP SA) | 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 via `pip install google-genai` when using either Gemini path. - -## 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 docs into system prompt - 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. -""" - - -# Claude Code reads CLAUDE.md at the project root. -CLAUDE_MD: Final[str] = AGENTFLOW_SKILL_BODY - -# Cursor picks up .cursor/rules/*.mdc with a small frontmatter header. -CURSOR_MDC: Final[str] = ( +_CURSOR_FRONTMATTER: Final[str] = ( "---\n" "description: AgentFlow framework conventions and golden-path snippets\n" "alwaysApply: true\n" "---\n\n" - + AGENTFLOW_SKILL_BODY ) -# GitHub Copilot reads .github/copilot-instructions.md. -COPILOT_MD: Final[str] = AGENTFLOW_SKILL_BODY - -# Windsurf reads .windsurfrules at the project root. -WINDSURF_RULES: Final[str] = AGENTFLOW_SKILL_BODY - -# OpenAI Codex / generic agents read AGENTS.md. -AGENTS_MD: Final[str] = AGENTFLOW_SKILL_BODY - -# Registry mapping agent id → (relative path, content) +# Registry mapping agent id -> (relative output path, file content). SKILL_TARGETS: Final[dict[str, tuple[str, str]]] = { - "claude": ("CLAUDE.md", CLAUDE_MD), - "cursor": (".cursor/rules/agentflow.mdc", CURSOR_MDC), - "copilot": (".github/copilot-instructions.md", COPILOT_MD), - "windsurf": (".windsurfrules", WINDSURF_RULES), - "codex": ("AGENTS.md", AGENTS_MD), + "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