diff --git a/.gitignore b/.gitignore index 1688c8299e..c2896705c0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ env/ *.swo .DS_Store *.tmp +.venv312 +.specify +.gitignore +.claude # Project specific *.log @@ -45,6 +49,8 @@ env/ *.zip sdd-*/ docs/dev +specs + # Extension system .specify/extensions/.cache/ diff --git a/extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md b/extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md new file mode 100644 index 0000000000..59dd9c600b --- /dev/null +++ b/extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md @@ -0,0 +1,223 @@ +# Extension Behavior & Deployment — RFC Addendum + +## Overview + +Extension commands can declare two new frontmatter sections: + +1. **`behavior:`** — agent-neutral intent vocabulary +2. **`agents:`** — per-agent escape hatch for fields with no neutral equivalent + +Deployment target is fully derived from `behavior.execution` — no separate manifest field is needed. + +--- + +## `behavior:` Vocabulary + +```yaml +behavior: + execution: command | isolated | agent + capability: fast | balanced | strong + effort: low | medium | high | max + tools: none | read-only | write | full | + invocation: explicit | automatic + visibility: user | model | both + color: red | blue | green | yellow | purple | orange | pink | cyan +``` + +### Per-agent translation + +| behavior field | value | Claude | Copilot | Codex | Others | +|---|---|---|---|---|---| +| `execution` | `isolated` | `context: fork` | `mode: agent` | — | — | +| `execution` | `agent` | routing only (see Deployment section) | `mode: agent` | — | — | +| `capability` | `fast` | `model: claude-haiku-4-5-20251001` | — | — | — | +| `capability` | `balanced` | `model: claude-sonnet-4-6` | — | — | — | +| `capability` | `strong` | `model: claude-opus-4-6` | — | — | — | +| `effort` | any | `effort: {value}` | — | `effort: {value}` | — | +| `tools` | `read-only` | `allowed-tools: Read Grep Glob` | `tools: [read_file, list_directory, search_files]` | — | — | +| `tools` | `write` | `allowed-tools: Read Write Edit Grep Glob` | — | — | — | +| `tools` | `none` | `allowed-tools: ""` | — | — | — | +| `tools` | `full` | — (no restriction, all tools available) | — | — | — | +| `tools` | `` | `allowed-tools: ` (literal passthrough) | — | — | — | +| `tools` | `` | `allowed-tools: ` | — | — | — | +| `invocation` | `explicit` | `disable-model-invocation: true` | — | — | — | +| `invocation` | `automatic` | `disable-model-invocation: false` | — | — | — | +| `visibility` | `user` | `user-invocable: true` | — | — | — | +| `visibility` | `model` | `user-invocable: false` | — | — | — | +| `visibility` | `both` | — | — | — | — | +| `color` | any valid value | `color: {value}` | — | — | — | + +Cells marked `—` mean "no concept, field omitted silently." + +> **Note:** For Claude agent definitions (`execution: agent`), the `allowed-tools` key is automatically remapped to `tools` by spec-kit during deployment. The table above shows the `allowed-tools` form used in skill files (SKILL.md); the agent definition example below shows the resulting `tools` key after remapping. + +### `tools` presets and custom values (Claude) + +The `tools` field accepts four named presets or a custom value: + +| value | `allowed-tools` written | use case | +|---|---|---| +| `none` | `""` (empty — no tools) | pure reasoning, no file access | +| `read-only` | `Read Grep Glob` | read/search, no writes | +| `write` | `Read Write Edit Grep Glob` | file reads + writes, no shell | +| `full` | _(key omitted)_ | all tools including Bash | + +For anything outside these presets, pass a **custom string** or **YAML list** — it is written verbatim as `allowed-tools`: + +```yaml +# Custom string (space-separated) +behavior: + tools: "Read Write Bash" + +# YAML list (joined with spaces) +behavior: + tools: + - Read + - Write + - Bash +``` + +> Custom values bypass preset lookup entirely and are not validated. Use named presets whenever possible. + +### `color` (Claude Code only) + +Controls the UI color of the agent entry in the Claude Code task list and transcript. Accepted values: `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan`. The value is passed through verbatim to the agent definition frontmatter — no translation occurs. Other agents ignore this field. + +--- + +## `agents:` Escape Hatch + +For fields with no neutral equivalent, declare them per-agent: + +```yaml +agents: + claude: + paths: "src/**" + argument-hint: "Path to the codebase" + copilot: + someCustomKey: someValue +``` + +Agent-specific overrides win over `behavior:` translations. + +--- + +## Deployment Routing from `behavior.execution` + +Deployment target is fully derived from `behavior.execution` in the command file — no separate manifest field needed. + +| `behavior.execution` | Claude | Copilot | Codex | Others | +|---|---|---|---|---| +| `command` (default) | `.claude/skills/{name}/SKILL.md` | `.github/agents/{name}.agent.md` | `.agents/skills/{name}/SKILL.md` | per-agent format | +| `isolated` | `.claude/skills/{name}/SKILL.md` + `context: fork` | `.github/agents/{name}.agent.md` + `mode: agent` | per-agent format | per-agent format | +| `agent` | `.claude/agents/{name}.md` | `.github/agents/{name}.agent.md` + `mode: agent` + `tools:` | not supported | not supported | + +### Agent definition format (Claude, `execution: agent`) + +Spec-kit writes a Claude agent definition file at `.claude/agents/{name}.md`. +The body becomes the **system prompt**. Frontmatter is minimal — no +`user-invocable`, `disable-model-invocation`, `context`, or `metadata` keys. + +```markdown +--- +name: speckit-revenge-analyzer +description: Codebase analyzer subagent +model: claude-opus-4-6 +tools: Read Grep Glob +--- +You are a codebase analysis specialist... +``` + +### Deferred: `execution: isolated` as agent definition + +It is theoretically possible to want a command that runs in an isolated +context (`context: fork`) AND is deployed as a named agent definition +(`.claude/agents/`). These two concerns are orthogonal — isolation is a +runtime concern, agent definition is a deployment concern. + +This combination is **not supported** in this implementation. `execution: +isolated` always deploys as a skill file. Decoupling runtime context from +deployment target is deferred until a concrete use case requires it. + +--- + +## Full Example: Orchestrator + Reusable Subagent + +**`extension.yml`** (no manifest `type` field — deployment derived from command frontmatter): +```yaml +provides: + commands: + - name: speckit.revenge.extract + file: commands/extract.md + + - name: speckit.revenge.analyzer + file: commands/analyzer.md +``` + +**`commands/extract.md`** (orchestrator skill — no `execution:` → deploys to skills): +```markdown +--- +description: Run the extraction pipeline +behavior: + invocation: automatic +agents: + claude: + argument-hint: "Path to codebase (optional)" +--- +Orchestrate extraction for $ARGUMENTS... +``` + +**`commands/analyzer.md`** (reusable subagent — `execution: agent` → deploys to `.claude/agents/`): +```markdown +--- +description: Analyze codebase structure and extract domain information +behavior: + execution: agent + capability: strong + tools: read-only + color: green +agents: + claude: + paths: "src/**" +--- +You are a codebase analysis specialist. +Analyze $ARGUMENTS and return structured domain findings. +``` + +The deployed `.claude/agents/speckit-revenge-analyzer.md` will contain: + +```markdown +--- +name: speckit-revenge-analyzer +description: Analyze codebase structure and extract domain information +model: claude-opus-4-6 +tools: Read Grep Glob +color: green +--- +You are a codebase analysis specialist. +... +``` + +### `tools: write` example + +Use `write` when an agent needs to create or modify files but does not need shell access (Bash): + +```yaml +behavior: + execution: agent + capability: strong + tools: write # Read Write Edit Grep Glob — no Bash + color: yellow +``` + +### `tools: full` example + +Use `full` when an agent needs unrestricted access including Bash (running tests, git commands, CLI tools): + +```yaml +behavior: + execution: agent + capability: strong + tools: full # all tools; no allowed-tools key injected + color: red +``` diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index dd4c97e8a2..6c4b87cc30 100644 --- a/extensions/RFC-EXTENSION-SYSTEM.md +++ b/extensions/RFC-EXTENSION-SYSTEM.md @@ -27,6 +27,7 @@ 16. [Resolved Questions](#resolved-questions) 17. [Open Questions (Remaining)](#open-questions-remaining) 18. [Appendices](#appendices) +19. [RFC Addenda](#rfc-addenda) --- @@ -597,6 +598,8 @@ def convert_to_claude( dest.write_text(render_frontmatter(frontmatter) + "\n" + body) ``` +> **See also:** [RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md](RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md) — addendum covering agent-neutral `behavior:` vocabulary, per-agent translation, `agents:` escape hatch, and deployment routing (`behavior.execution: agent` → `.claude/agents/`). + --- ## Configuration Management @@ -1960,3 +1963,13 @@ This RFC proposes a comprehensive extension system for Spec Kit that: 3. Should we support extension dependencies (extension A requires extension B)? 4. How should we handle extension deprecation/removal from catalog? 5. What level of sandboxing/permissions do we need in v1.0? + +--- + +## RFC Addenda + +Addenda extend this RFC with post-initial-implementation decisions. They are authoritative and supersede any conflicting content in the main RFC. + +| Addendum | Topic | Status | +|---|---|---| +| [RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md](RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md) | Agent-neutral `behavior:` vocabulary, deployment routing from `behavior.execution`, `agents:` escape hatch | Implemented (2026-04-08) | diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index bb25b3fc1a..8fcc3ab085 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,13 +7,38 @@ """ from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re from copy import deepcopy import yaml +from specify_cli.behavior import translate_behavior, strip_behavior_keys, get_deployment_type, get_copilot_tools + + +# Agent-specific frontmatter keys that extension/preset authors may declare in +# source command frontmatter and have passed through verbatim to the generated +# skill file. Keys not in this set are ignored during skill rendering. +_SKILL_PASSTHROUGH_KEYS: dict[str, frozenset[str]] = { + "claude": frozenset({ + "context", # fork execution model + "agent", # subagent type when context: fork + "model", # model override + "effort", # effort level + "allowed-tools", # tool restriction list + "color", # UI color in Claude Code task list + "paths", # path-based activation glob + "argument-hint", # UI hint in slash-command menu + "disable-model-invocation", # override default True + "user-invocable", # override default True + }), + "codex": frozenset({ + "model", + "effort", + }), +} + def _build_agent_configs() -> dict[str, Any]: """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" @@ -150,6 +175,54 @@ def rewrite_project_relative_paths(text: str) -> str: return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") + @staticmethod + def rewrite_extension_paths(text: str, extension_id: str, extension_dir: Path) -> str: + """Rewrite extension-relative paths to their installed project locations. + + Extension command bodies reference files using paths relative to the + extension root (e.g. ``agents/control/commander.md``). After install, + those files live at ``.specify/extensions//...``. This method + rewrites such references so that AI agents can locate them after install. + + Only directories that actually exist inside *extension_dir* are rewritten, + keeping the behaviour conservative and avoiding false positives on prose. + + Args: + text: Body text of the command file. + extension_id: The extension identifier (e.g. ``"echelon"``). + extension_dir: Path to the installed extension directory. + + Returns: + Body text with extension-relative paths expanded. + """ + if not isinstance(text, str) or not text: + return text + + _SKIP = {"commands", ".git"} + try: + subdirs = [ + d.name + for d in extension_dir.iterdir() + if d.is_dir() and d.name not in _SKIP + ] + except OSError: + return text + + base_prefix = f".specify/extensions/{extension_id}/" + + # Replace $EXTENSION_PATH shell variable with the actual installed path. + text = text.replace("$EXTENSION_PATH", base_prefix.rstrip("/")) + + for subdir in subdirs: + escaped = re.escape(subdir) + text = re.sub( + r"(^|[\s`\"'(])(?:\.?/)?" + escaped + r"/", + r"\1" + base_prefix + subdir + "/", + text, + ) + + return text + def render_markdown_command( self, frontmatter: dict, @@ -220,6 +293,62 @@ def render_toml_command( return "\n".join(toml_lines) + def render_agent_definition( + self, + agent_name: str, + skill_name: str, + frontmatter: dict, + body: str, + source_id: str, + source_file: str, + project_root: Path, + source_dir: Optional[Path] = None, + ) -> str: + """Render a command as a Claude agent definition file (.claude/agents/{name}.md). + + Agent definitions differ from skills: + - Body is the system prompt, not a task prompt + - Frontmatter is minimal: name, description, and behavior-derived fields + - No user-invocable, disable-model-invocation, context, or metadata keys + """ + if not isinstance(frontmatter, dict): + frontmatter = {} + + if source_dir is not None and (source_dir / "extension.yml").exists(): + body = self.rewrite_extension_paths(body, source_id, source_dir) + + behavior = frontmatter.get("behavior") or {} + agents_overrides = frontmatter.get("agents") or {} + behavior_fields: dict = {} + if isinstance(behavior, dict): + behavior_fields = translate_behavior( + agent_name, behavior, + agents_overrides if isinstance(agents_overrides, dict) else {} + ) + + clean_frontmatter = strip_behavior_keys(frontmatter) + description = clean_frontmatter.get("description", f"Spec-kit agent: {skill_name}") + + # Agent definition frontmatter: minimal set, no skill-specific keys + agent_fm: dict = { + "name": skill_name, + "description": description, + } + + # Merge behavior-translated fields; remap allowed-tools → tools for agent defs + for k, v in behavior_fields.items(): + if k == "allowed-tools": + agent_fm["tools"] = v + elif k not in {"disable-model-invocation", "user-invocable", "context", "agent"}: + agent_fm[k] = v + + # Explicit model/tools in source frontmatter win + for key in ("model", "tools"): + if key in clean_frontmatter: + agent_fm[key] = clean_frontmatter[key] + + return self.render_frontmatter(agent_fm) + "\n" + body + def render_skill_command( self, agent_name: str, @@ -229,6 +358,7 @@ def render_skill_command( source_id: str, source_file: str, project_root: Path, + source_dir: Optional[Path] = None, ) -> str: """Render a command override as a SKILL.md file. @@ -245,16 +375,41 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} + if source_dir is not None and (source_dir / "extension.yml").exists(): + body = self.rewrite_extension_paths(body, source_id, source_dir) + if agent_name in {"codex", "kimi"}: body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) - description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") + # Extract and translate behavior + agents escape hatch + behavior = frontmatter.get("behavior") or {} + agents_overrides = frontmatter.get("agents") or {} + behavior_fields: dict = {} + if isinstance(behavior, dict): + behavior_fields = translate_behavior( + agent_name, behavior, + agents_overrides if isinstance(agents_overrides, dict) else {} + ) + + # Strip behavior/agents keys before building skill frontmatter + clean_frontmatter = strip_behavior_keys(frontmatter) + + description = clean_frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") skill_frontmatter = self.build_skill_frontmatter( agent_name, skill_name, description, f"{source_id}:{source_file}", + source_frontmatter=clean_frontmatter, ) + # Merge behavior translation — passthrough (already in skill_frontmatter) wins + # because we only set behavior fields if they are not already set via passthrough, + # EXCEPT for the set of fields that behavior can legitimately override defaults for. + _behavior_overridable = {"disable-model-invocation", "user-invocable", "model", "effort", "context", "agent", "allowed-tools"} + for k, v in behavior_fields.items(): + if k not in skill_frontmatter or k in _behavior_overridable: + skill_frontmatter[k] = v + return self.render_frontmatter(skill_frontmatter) + "\n" + body @staticmethod @@ -263,9 +418,22 @@ def build_skill_frontmatter( skill_name: str, description: str, source: str, + source_frontmatter: dict | None = None, ) -> dict: - """Build consistent SKILL.md frontmatter across all skill generators.""" - skill_frontmatter = { + """Build consistent SKILL.md frontmatter across all skill generators. + + Args: + agent_name: Target agent key (e.g. "claude", "codex"). + skill_name: Generated skill name (e.g. "speckit-revenge-extract"). + description: Human-readable description. + source: Source tracking string (e.g. "revenge:commands/extract.md"). + source_frontmatter: Original command frontmatter. Keys present in + ``_SKILL_PASSTHROUGH_KEYS[agent_name]`` are merged after + defaults, allowing source authors to override injected values. + """ + source_frontmatter = source_frontmatter or {} + + skill_frontmatter: dict = { "name": skill_name, "description": description, "compatibility": "Requires spec-kit project structure with .specify/ directory", @@ -275,10 +443,14 @@ def build_skill_frontmatter( }, } if agent_name == "claude": - # Claude skills should be user-invocable (accessible via /command) - # and only run when explicitly invoked (not auto-triggered by the model). skill_frontmatter["user-invocable"] = True skill_frontmatter["disable-model-invocation"] = True + + # Merge passthrough keys from source (wins over defaults above) + for key in _SKILL_PASSTHROUGH_KEYS.get(agent_name, frozenset()): + if key in source_frontmatter: + skill_frontmatter[key] = source_frontmatter[key] + return skill_frontmatter @staticmethod @@ -408,6 +580,13 @@ def register_commands( content = source_file.read_text(encoding="utf-8") frontmatter, body = self.parse_frontmatter(content) + # Merge manifest-level fields into frontmatter — source file wins. + # This lets extension.yml declare behavior/description for agent files + # that carry no frontmatter of their own (e.g. pure persona prompts). + for key in ("description", "behavior", "agents"): + if key in cmd_info and key not in frontmatter: + frontmatter[key] = cmd_info[key] + frontmatter = self._adjust_script_paths(frontmatter) for key in agent_config.get("strip_frontmatter_keys", []): @@ -422,12 +601,44 @@ def register_commands( output_name = self._compute_output_name(agent_name, cmd_name, agent_config) + # Deployment target is fully derived from behavior.execution + cmd_type = get_deployment_type(frontmatter) + + if cmd_type == "agent" and agent_name == "claude": + output = self.render_agent_definition( + agent_name, output_name, frontmatter, body, + source_id, cmd_file, project_root, source_dir=source_dir, + ) + agents_dir = project_root / ".claude" / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + dest_file = agents_dir / f"{output_name}.md" + dest_file.write_text(output, encoding="utf-8") + registered.append(cmd_name) + continue # skip normal skill/command rendering + if agent_config["extension"] == "/SKILL.md": output = self.render_skill_command( - agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command(frontmatter, body, source_id, context_note) + # For Copilot execution:agent, inject behavior-derived fields into frontmatter + if agent_name == "copilot" and cmd_type == "agent": + behavior = frontmatter.get("behavior") or {} + agents_overrides = frontmatter.get("agents") or {} + extra_fields = translate_behavior( + agent_name, behavior, + agents_overrides if isinstance(agents_overrides, dict) else {} + ) + copilot_tools = get_copilot_tools(behavior if isinstance(behavior, dict) else {}) + if copilot_tools: + extra_fields["tools"] = copilot_tools + # Build modified frontmatter: strip internal keys, add extra + copilot_fm = strip_behavior_keys(frontmatter) + copilot_fm.update(extra_fields) + output = self.render_markdown_command(copilot_fm, body, source_id, context_note) + else: + output = self.render_markdown_command(frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": output = self.render_toml_command(frontmatter, body, source_id) else: @@ -452,7 +663,8 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root + agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) elif agent_config["format"] == "markdown": alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) @@ -465,7 +677,8 @@ def register_commands( alias_output = output if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root, + source_dir=source_dir, ) alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" @@ -559,6 +772,12 @@ def unregister_commands( if prompt_file.exists(): prompt_file.unlink() + # Also try agent definition file (Claude-specific) + if agent_name == "claude": + agent_def = project_root / ".claude" / "agents" / f"{output_name}.md" + if agent_def.exists(): + agent_def.unlink() + # Populate AGENT_CONFIGS after class definition. # Catches ImportError from circular imports during module loading; diff --git a/src/specify_cli/behavior.py b/src/specify_cli/behavior.py new file mode 100644 index 0000000000..5c694cb30e --- /dev/null +++ b/src/specify_cli/behavior.py @@ -0,0 +1,177 @@ +"""Neutral behavior vocabulary for extension commands. + +Extension command source files can declare a ``behavior:`` block in their +frontmatter to express agent-neutral intent (isolation, capability, tools, +etc.). This module translates that vocabulary to concrete per-agent +frontmatter fields during rendering. + +Extension authors can also declare an ``agents:`` escape-hatch block for +agent-specific fields that have no neutral equivalent:: + + behavior: + execution: isolated + capability: strong + effort: high + tools: read-only + invocation: explicit + visibility: user + + agents: + claude: + paths: "src/**" + argument-hint: "Codebase path to analyze" + copilot: + handoffs: + - label: "Generate plan" + agent: speckit.plan + send: true +""" + +from __future__ import annotations + +from copy import deepcopy + +# Keys that belong to the neutral behavior vocabulary +BEHAVIOR_KEYS: frozenset[str] = frozenset({ + "execution", # command | isolated | agent + "capability", # fast | balanced | strong + "effort", # low | medium | high | max + "tools", # none | read-only | write | full | custom list (str or list[str]) + "invocation", # explicit | automatic + "visibility", # user | model | both + "color", # red | blue | green | yellow | purple | orange | pink | cyan (Claude Code UI color) +}) + +# Per-agent translation tables. +# Structure: agent_name -> behavior_key -> value -> (frontmatter_key, frontmatter_value) +# (None, None) means "no frontmatter injection for this combination" +_TRANSLATIONS: dict[str, dict[str, dict[str, tuple[str | None, object]]]] = { + "claude": { + "execution": { + "isolated": ("context", "fork"), + "command": (None, None), + "agent": (None, None), # routing concern, not frontmatter + }, + "capability": { + "fast": ("model", "claude-haiku-4-5-20251001"), + "balanced": ("model", "claude-sonnet-4-6"), + "strong": ("model", "claude-opus-4-6"), + }, + "effort": { + "low": ("effort", "low"), + "medium": ("effort", "medium"), + "high": ("effort", "high"), + "max": ("effort", "max"), + }, + "tools": { + "none": ("allowed-tools", ""), + "read-only": ("allowed-tools", "Read Grep Glob"), + "write": ("allowed-tools", "Read Write Edit Grep Glob"), + "full": (None, None), + }, + "invocation": { + "explicit": ("disable-model-invocation", True), + "automatic": ("disable-model-invocation", False), + }, + "visibility": { + "user": ("user-invocable", True), + "model": ("user-invocable", False), + "both": (None, None), + }, + }, + "copilot": { + "execution": { + "agent": ("mode", "agent"), + "isolated": ("mode", "agent"), + "command": (None, None), + }, + }, + "codex": { + "effort": { + "low": ("effort", "low"), + "medium": ("effort", "medium"), + "high": ("effort", "high"), + "max": ("effort", "max"), + }, + }, +} + +# Tools list for Copilot when behavior.tools is set on an agent-type command. +_COPILOT_TOOLS: dict[str, list[str]] = { + "read-only": ["read_file", "list_directory", "search_files"], + "full": [], + "none": [], +} + + +def translate_behavior( + agent_name: str, + behavior: dict, + agents_overrides: dict | None = None, +) -> dict: + """Translate neutral behavior dict to agent-specific frontmatter fields.""" + result: dict = {} + agent_table = _TRANSLATIONS.get(agent_name, {}) + + for key, value in behavior.items(): + if key not in BEHAVIOR_KEYS: + continue + + # color: pass through directly to Claude Code agent frontmatter. + # Valid values: red | blue | green | yellow | purple | orange | pink | cyan + if key == "color" and agent_name == "claude": + result["color"] = str(value) + continue + + # tools: accept a list or a space-separated string of tool names as a + # custom literal, bypassing the preset lookup entirely. + if key == "tools" and agent_name == "claude": + if isinstance(value, list): + result["allowed-tools"] = " ".join(str(t) for t in value) + continue + preset = _TRANSLATIONS.get(agent_name, {}).get("tools", {}).get(str(value)) + if preset is None: + # Unrecognised preset — treat as a literal tool list string + result["allowed-tools"] = str(value) + continue + fm_key, fm_value = preset + if fm_key is not None: + result[fm_key] = fm_value + continue + + key_table = agent_table.get(key, {}) + fm_key, fm_value = key_table.get(str(value), (None, None)) + if fm_key is not None: + result[fm_key] = fm_value + + if agents_overrides and isinstance(agents_overrides, dict): + overrides = agents_overrides.get(agent_name) + if isinstance(overrides, dict): + result.update(overrides) + + return result + + +def get_copilot_tools(behavior: dict) -> list[str]: + """Return Copilot tool list for a given behavior.tools value.""" + tools_value = behavior.get("tools", "full") + return _COPILOT_TOOLS.get(str(tools_value), []) + + +def strip_behavior_keys(frontmatter: dict) -> dict: + """Return a copy of frontmatter with ``behavior:`` and ``agents:`` removed.""" + result = deepcopy(frontmatter) + result.pop("behavior", None) + result.pop("agents", None) + return result + + +def get_deployment_type(frontmatter: dict) -> str: + """Determine deployment type from behavior.execution. + + Returns 'agent' if behavior.execution == 'agent', otherwise 'command'. + """ + behavior = frontmatter.get("behavior") + if isinstance(behavior, dict) and behavior.get("execution") == "agent": + return "agent" + return "command" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3420a7651b..6cafc5fb8b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -777,6 +777,18 @@ def _register_extension_skills( # Do not overwrite user-customized skills continue + # Skip commands that behavior-routing deploys as agent definitions + # (to .claude/agents/) rather than as skill files. + from specify_cli.behavior import get_deployment_type + _source_fm, _ = registrar.parse_frontmatter( + source_file.read_text(encoding="utf-8") + ) + # Merge manifest-level behavior when source file has none + if "behavior" not in _source_fm and "behavior" in cmd_info: + _source_fm["behavior"] = cmd_info["behavior"] + if get_deployment_type(_source_fm) == "agent": + continue + # Create skill directory; track whether we created it so we can clean # up safely if reading the source file subsequently fails. created_now = not skill_subdir.exists() diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index dac5063f5c..55179de96f 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -769,6 +769,27 @@ def _quote(v: str) -> str: escaped = v.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' + # Translate behavior block to agent-specific frontmatter fields. + # This lets templates declare e.g. `behavior: invocation: automatic` + # to produce `disable-model-invocation: false` in the skill. + # Fields are emitted here so downstream post-processors (e.g. + # ClaudeIntegration.setup) see them already set and skip injection. + behavior = frontmatter.get("behavior") or {} + behavior_fm_lines = "" + if isinstance(behavior, dict) and behavior: + try: + from specify_cli.behavior import translate_behavior + behavior_fields = translate_behavior(self.key, behavior, {}) + for bk, bv in behavior_fields.items(): + if isinstance(bv, bool): + behavior_fm_lines += f"{bk}: {'true' if bv else 'false'}\n" + elif isinstance(bv, str): + behavior_fm_lines += f"{bk}: {_quote(bv)}\n" + else: + behavior_fm_lines += f"{bk}: {bv}\n" + except ImportError: + pass + skill_content = ( f"---\n" f"name: {_quote(skill_name)}\n" @@ -777,6 +798,7 @@ def _quote(v: str) -> str: f"metadata:\n" f" author: {_quote('github-spec-kit')}\n" f" source: {_quote('templates/commands/' + src_file.name)}\n" + f"{behavior_fm_lines}" f"---\n" f"{processed_body}" ) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0e..a520eea776 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -105,10 +105,10 @@ def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: s frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" - def _build_skill_fm(self, name: str, description: str, source: str) -> dict: + def _build_skill_fm(self, name: str, description: str, source: str, source_frontmatter: dict | None = None) -> dict: from specify_cli.agents import CommandRegistrar return CommandRegistrar.build_skill_frontmatter( - self.key, name, description, source + self.key, name, description, source, source_frontmatter=source_frontmatter ) @staticmethod diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index b3174338d9..39dd0d9008 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -1,5 +1,7 @@ --- description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. +behavior: + invocation: automatic scripts: sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index a79131a204..987a8851bc 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -1,5 +1,7 @@ --- description: Generate a custom checklist for the current feature based on user requirements. +behavior: + invocation: automatic scripts: sh: scripts/bash/check-prerequisites.sh --json ps: scripts/powershell/check-prerequisites.ps1 -Json diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index 26efb5aedb..b4728379bb 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -1,5 +1,7 @@ --- description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. +behavior: + invocation: automatic handoffs: - label: Build Technical Plan agent: speckit.plan diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 63d4f662ae..8fd42b5bdf 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -1,5 +1,7 @@ --- description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. +behavior: + invocation: automatic handoffs: - label: Build Specification agent: speckit.specify diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 9a91d2dc4b..d5fb6b004a 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -1,5 +1,7 @@ --- description: Execute the implementation plan by processing and executing all tasks defined in tasks.md +behavior: + invocation: automatic scripts: sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks ps: scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..320af46bfb 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -1,5 +1,7 @@ --- description: Execute the implementation planning workflow using the plan template to generate design artifacts. +behavior: + invocation: automatic handoffs: - label: Create Tasks agent: speckit.tasks diff --git a/templates/commands/specify.md b/templates/commands/specify.md index a81b8f12f1..b8c99ced21 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -1,5 +1,7 @@ --- description: Create or update the feature specification from a natural language feature description. +behavior: + invocation: automatic handoffs: - label: Build Technical Plan agent: speckit.plan diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..676d235e8a 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -1,5 +1,7 @@ --- description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. +behavior: + invocation: automatic handoffs: - label: Analyze For Consistency agent: speckit.analyze diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index d6aa3bbf55..99d4e6c356 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -1,5 +1,7 @@ --- description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. +behavior: + invocation: automatic tools: ['github/github-mcp-server/issue_write'] scripts: sh: scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df176..8c7f2bf020 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -2,6 +2,7 @@ import json import os +from textwrap import dedent from unittest.mock import patch import yaml @@ -59,7 +60,8 @@ def test_setup_creates_skill_files(self, tmp_path): parsed = yaml.safe_load(parts[1]) assert parsed["name"] == "speckit-plan" assert parsed["user-invocable"] is True - assert parsed["disable-model-invocation"] is True + # plan.md has behavior: invocation: automatic → disable-model-invocation: false + assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" def test_setup_installs_update_context_scripts(self, tmp_path): @@ -179,7 +181,8 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): assert skill_file.exists() skill_content = skill_file.read_text(encoding="utf-8") assert "user-invocable: true" in skill_content - assert "disable-model-invocation: true" in skill_content + # plan.md has behavior: invocation: automatic → disable-model-invocation: false + assert "disable-model-invocation: false" in skill_content init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") @@ -400,3 +403,113 @@ def test_inject_argument_hint_skips_if_already_present(self): lines = result.splitlines() hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + + +class TestSkillsIntegrationBehaviorTranslation: + """SkillsIntegration.setup() must translate behavior: blocks from templates + into agent-specific frontmatter fields *before* ClaudeIntegration.setup() + post-processes the file. + + Regression: templates declaring 'behavior: invocation: automatic' used to + get disable-model-invocation: true anyway because ClaudeIntegration.setup() + injected the default unconditionally, and SkillsIntegration.setup() never + ran translate_behavior() before writing the SKILL.md. + """ + + def _run_claude_setup(self, tmp_path, template_content: str) -> dict: + """Install a single fake template via ClaudeIntegration and return the SKILL.md frontmatter.""" + from specify_cli.integrations.claude import ClaudeIntegration + from specify_cli.integrations.manifest import IntegrationManifest + + integration = ClaudeIntegration() + + # Inject a fake template list so we don't touch the real templates on disk. + fake_template = tmp_path / "commands" / "testcmd.md" + fake_template.parent.mkdir(parents=True) + fake_template.write_text(template_content, encoding="utf-8") + + original = integration.list_command_templates + + def patched_templates(): + return [fake_template] + + import unittest.mock as mock + with mock.patch.object(integration, "list_command_templates", patched_templates): + m = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, m) + + skill_file = tmp_path / ".claude" / "skills" / "speckit-testcmd" / "SKILL.md" + assert skill_file.exists(), "SKILL.md was not created" + content = skill_file.read_text(encoding="utf-8") + parts = content.split("---", 2) + return yaml.safe_load(parts[1]) + + def test_invocation_automatic_produces_disable_model_invocation_false(self, tmp_path): + """behavior: invocation: automatic must produce disable-model-invocation: false. + + This is the primary regression test: before the fix, ClaudeIntegration.setup() + always injected disable-model-invocation: true regardless of behavior. + """ + fm = self._run_claude_setup(tmp_path, dedent("""\ + --- + description: Test command with automatic invocation + behavior: + invocation: automatic + --- + Command body here. + """)) + assert fm.get("disable-model-invocation") is False, ( + "behavior: invocation: automatic must produce disable-model-invocation: false, " + f"got {fm.get('disable-model-invocation')!r}" + ) + + def test_invocation_explicit_produces_disable_model_invocation_true(self, tmp_path): + """behavior: invocation: explicit must produce disable-model-invocation: true.""" + fm = self._run_claude_setup(tmp_path, dedent("""\ + --- + description: Test command with explicit invocation + behavior: + invocation: explicit + --- + Command body here. + """)) + assert fm.get("disable-model-invocation") is True + + def test_no_behavior_block_defaults_to_disable_model_invocation_true(self, tmp_path): + """Templates without a behavior: block get the default disable-model-invocation: true.""" + fm = self._run_claude_setup(tmp_path, dedent("""\ + --- + description: Plain template with no behavior + --- + Command body here. + """)) + assert fm.get("disable-model-invocation") is True + + def test_capability_strong_produces_model_opus(self, tmp_path): + """behavior: capability: strong must produce model: claude-opus-4-6 in the skill.""" + fm = self._run_claude_setup(tmp_path, dedent("""\ + --- + description: Strong capability command + behavior: + capability: strong + --- + Body. + """)) + assert fm.get("model") == "claude-opus-4-6" + + def test_behavior_fields_present_before_post_processing(self, tmp_path): + """Verify behavior fields appear in final SKILL.md alongside user-invocable.""" + fm = self._run_claude_setup(tmp_path, dedent("""\ + --- + description: Automatic command + behavior: + invocation: automatic + capability: fast + --- + Body. + """)) + # Both behavior-translated fields must be present + assert fm.get("disable-model-invocation") is False + assert fm.get("model") == "claude-haiku-4-5-20251001" + # Claude post-processor still injects user-invocable + assert fm.get("user-invocable") is True diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py new file mode 100644 index 0000000000..669015d8f0 --- /dev/null +++ b/tests/test_agent_deployment.py @@ -0,0 +1,482 @@ +"""Tests for behavior.execution:agent deployment to agent-specific directories.""" +import json +import yaml +import pytest +import tempfile +import shutil +from pathlib import Path +from textwrap import dedent + +from specify_cli.agents import CommandRegistrar + + +@pytest.fixture +def project_root(tmp_path): + root = tmp_path / "proj" + (root / ".claude" / "skills").mkdir(parents=True) + (root / ".claude" / "agents").mkdir(parents=True) + (root / ".specify").mkdir() + (root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True, "script": "sh"}) + ) + return root + + +@pytest.fixture +def source_dir(tmp_path): + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + return src + + +class TestClaudeAgentDeployment: + def _write_command(self, source_dir, filename, content): + f = source_dir / filename + f.write_text(content) + return f + + def test_no_execution_behavior_deploys_to_skills(self, project_root, source_dir): + self._write_command(source_dir, "hello.md", dedent("""\ + --- + description: Test command + --- + Hello world + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.hello", "file": "hello.md"}], + "test-ext", source_dir, project_root, + ) + skill_file = project_root / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md" + agent_file = project_root / ".claude" / "agents" / "speckit-test-ext-hello.md" + assert skill_file.exists() + assert not agent_file.exists() + + def test_execution_agent_deploys_to_agents_dir(self, project_root, source_dir): + self._write_command(source_dir, "analyzer.md", dedent("""\ + --- + description: Analyze the codebase + behavior: + execution: agent + capability: strong + tools: read-only + --- + You are a codebase analysis specialist. $ARGUMENTS + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.analyzer", "file": "analyzer.md"}], + "test-ext", source_dir, project_root, + ) + agent_file = project_root / ".claude" / "agents" / "speckit-test-ext-analyzer.md" + skill_file = project_root / ".claude" / "skills" / "speckit-test-ext-analyzer" / "SKILL.md" + assert agent_file.exists() + assert not skill_file.exists() + + def test_agent_file_has_correct_frontmatter(self, project_root, source_dir): + self._write_command(source_dir, "analyzer.md", dedent("""\ + --- + description: Analyze the codebase + behavior: + execution: agent + capability: strong + tools: read-only + --- + You are a specialist. $ARGUMENTS + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.analyzer", "file": "analyzer.md"}], + "test-ext", source_dir, project_root, + ) + content = (project_root / ".claude" / "agents" / "speckit-test-ext-analyzer.md").read_text() + parts = content.split("---") + fm = yaml.safe_load(parts[1]) + assert fm["name"] == "speckit-test-ext-analyzer" + assert fm["description"] == "Analyze the codebase" + assert fm.get("model") == "claude-opus-4-6" # from capability: strong + assert fm.get("tools") == "Read Grep Glob" # from tools: read-only + # These must NOT appear in agent definition files + assert "user-invocable" not in fm + assert "disable-model-invocation" not in fm + assert "context" not in fm + assert "behavior" not in fm + + def test_agent_file_body_is_system_prompt(self, project_root, source_dir): + self._write_command(source_dir, "analyzer.md", dedent("""\ + --- + description: Analyze the codebase + behavior: + execution: agent + --- + You are a specialist. $ARGUMENTS + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.analyzer", "file": "analyzer.md"}], + "test-ext", source_dir, project_root, + ) + content = (project_root / ".claude" / "agents" / "speckit-test-ext-analyzer.md").read_text() + body = "---".join(content.split("---")[2:]).strip() + assert "You are a specialist" in body + + def test_execution_isolated_deploys_to_skills_not_agents(self, project_root, source_dir): + self._write_command(source_dir, "hello.md", dedent("""\ + --- + description: Test isolated + behavior: + execution: isolated + --- + Hello + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.hello", "file": "hello.md"}], + "test-ext", source_dir, project_root, + ) + skill_file = project_root / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md" + agent_file = project_root / ".claude" / "agents" / "speckit-test-ext-hello.md" + assert skill_file.exists() + assert not agent_file.exists() + + def test_unregister_removes_agent_file(self, project_root, source_dir): + self._write_command(source_dir, "analyzer.md", dedent("""\ + --- + description: Test + behavior: + execution: agent + --- + Body + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{"name": "speckit.test-ext.analyzer", "file": "analyzer.md"}], + "test-ext", source_dir, project_root, + ) + agent_file = project_root / ".claude" / "agents" / "speckit-test-ext-analyzer.md" + assert agent_file.exists() + + registrar.unregister_commands( + {"claude": ["speckit.test-ext.analyzer"]}, + project_root, + ) + assert not agent_file.exists() + + +class TestCopilotAgentDeployment: + """behavior.execution:agent on Copilot injects mode: and tools: into .agent.md frontmatter.""" + + def _setup_copilot_project(self, tmp_path): + root = tmp_path / "proj" + (root / ".github" / "agents").mkdir(parents=True) + (root / ".github" / "prompts").mkdir(parents=True) + (root / ".specify").mkdir() + (root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "copilot", "script": "sh"}) + ) + return root + + def test_copilot_type_agent_injects_mode(self, tmp_path): + root = self._setup_copilot_project(tmp_path) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + (src / "analyzer.md").write_text(dedent("""\ + --- + description: Analyze codebase + behavior: + execution: agent + tools: read-only + --- + Analyze $ARGUMENTS + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "copilot", + [{"name": "speckit.test-ext.analyzer", "file": "analyzer.md"}], + "test-ext", src, root, + ) + agent_file = root / ".github" / "agents" / "speckit.test-ext.analyzer.agent.md" + assert agent_file.exists() + content = agent_file.read_text() + parts = content.split("---") + fm = yaml.safe_load(parts[1]) + assert fm.get("mode") == "agent" + assert "read_file" in fm.get("tools", []) + + def test_copilot_type_command_no_tools_injected(self, tmp_path): + root = self._setup_copilot_project(tmp_path) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + (src / "hello.md").write_text("---\ndescription: Hello\n---\nHello") + registrar = CommandRegistrar() + registrar.register_commands( + "copilot", + [{"name": "speckit.test-ext.hello", "file": "hello.md"}], + "test-ext", src, root, + ) + agent_file = root / ".github" / "agents" / "speckit.test-ext.hello.agent.md" + content = agent_file.read_text() + parts = content.split("---") + fm = yaml.safe_load(parts[1]) or {} + assert "mode" not in fm + assert "tools" not in fm + + def test_copilot_agent_no_tools_key_omits_tools(self, tmp_path): + root = self._setup_copilot_project(tmp_path) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + (src / "worker.md").write_text(dedent("""\ + --- + description: Worker agent + behavior: + execution: agent + --- + Do work + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "copilot", + [{"name": "speckit.test-ext.worker", "file": "worker.md"}], + "test-ext", src, root, + ) + agent_file = root / ".github" / "agents" / "speckit.test-ext.worker.agent.md" + content = agent_file.read_text() + parts = content.split("---") + fm = yaml.safe_load(parts[1]) or {} + assert fm.get("mode") == "agent" + assert "tools" not in fm + + def test_copilot_agents_override_survives(self, tmp_path): + root = self._setup_copilot_project(tmp_path) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + (src / "custom.md").write_text(dedent("""\ + --- + description: Custom agent + behavior: + execution: agent + agents: + copilot: + someCustomKey: someValue + --- + Do custom work + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "copilot", + [{"name": "speckit.test-ext.custom", "file": "custom.md"}], + "test-ext", src, root, + ) + agent_file = root / ".github" / "agents" / "speckit.test-ext.custom.agent.md" + content = agent_file.read_text() + parts = content.split("---") + fm = yaml.safe_load(parts[1]) or {} + assert fm.get("someCustomKey") == "someValue" + + +class TestEndToEnd: + """Full pipeline: extension with behavior.execution:agent → correct files deployed.""" + + def test_extension_with_agent_command_deploys_correctly(self, tmp_path): + """An extension declaring execution:agent deploys to .claude/agents/, not skills.""" + from specify_cli.extensions import ExtensionManager + + project_root = tmp_path / "proj" + (project_root / ".claude" / "skills").mkdir(parents=True) + (project_root / ".claude" / "agents").mkdir(parents=True) + (project_root / ".specify").mkdir() + # ai_skills is intentionally omitted: skill deployment in this test goes through + # CommandRegistrar.register_commands (which routes based on behavior.execution), + # not the _register_extension_skills path that requires ai_skills to be True. + (project_root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "script": "sh"}) + ) + + # Create extension directory with manifest + command + ext_dir = tmp_path / "revenge" + (ext_dir / "commands").mkdir(parents=True) + (ext_dir / "extension.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "extension": { + "id": "revenge", + "name": "Revenge", + "version": "1.0.0", + "description": "Reverse engineering extension", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.revenge.extract", + "file": "commands/extract.md", + "description": "Run extraction pipeline", + }, + { + "name": "speckit.revenge.analyzer", + "file": "commands/analyzer.md", + "description": "Codebase analyzer subagent", + }, + ] + }, + })) + + # Orchestrator command (no execution: → stays a skill) + (ext_dir / "commands" / "extract.md").write_text(dedent("""\ + --- + description: Run extraction pipeline + behavior: + invocation: automatic + --- + Run the extraction pipeline for $ARGUMENTS + """)) + + # Analyzer subagent (execution:agent → .claude/agents/) + (ext_dir / "commands" / "analyzer.md").write_text(dedent("""\ + --- + description: Codebase analyzer subagent + behavior: + execution: agent + capability: strong + tools: read-only + --- + You are a codebase analysis specialist. + Analyze the codebase at $ARGUMENTS and return structured findings. + """)) + + # Install extension + manager = ExtensionManager(project_root) + manager.install_from_directory(ext_dir, speckit_version="0.1.0") + + # extract → .claude/skills/ (no execution: → command type) + skill_file = project_root / ".claude" / "skills" / "speckit-revenge-extract" / "SKILL.md" + assert skill_file.exists(), "extract should deploy as skill" + skill_fm = yaml.safe_load(skill_file.read_text().split("---")[1]) + assert skill_fm.get("disable-model-invocation") is False # behavior: invocation: automatic + + # analyzer → .claude/agents/ (execution:agent) + agent_file = project_root / ".claude" / "agents" / "speckit-revenge-analyzer.md" + assert agent_file.exists(), "analyzer should deploy as agent definition" + agent_fm = yaml.safe_load(agent_file.read_text().split("---")[1]) + assert agent_fm.get("model") == "claude-opus-4-6" # capability: strong + assert agent_fm.get("tools") == "Read Grep Glob" # tools: read-only + assert "user-invocable" not in agent_fm + assert "disable-model-invocation" not in agent_fm + assert "behavior" not in agent_fm + + # analyzer must NOT also be in skills dir + skill_analyzer = project_root / ".claude" / "skills" / "speckit-revenge-analyzer" / "SKILL.md" + assert not skill_analyzer.exists() + + +class TestManifestBehaviorMerge: + """Manifest-level behavior: field is merged into source frontmatter before rendering.""" + + def _setup(self, tmp_path): + root = tmp_path / "proj" + (root / ".claude" / "skills").mkdir(parents=True) + (root / ".claude" / "agents").mkdir(parents=True) + (root / ".specify").mkdir() + (root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True, "script": "sh"}) + ) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + return root, src + + def test_manifest_behavior_merged_when_source_has_no_behavior(self, tmp_path): + """behavior declared in manifest cmd_info reaches the rendered skill when source has none.""" + root, src = self._setup(tmp_path) + # Source file has no behavior block (pure persona prompt with only description) + (src / "agent.md").write_text(dedent("""\ + --- + description: A persona agent + --- + You are a helpful assistant. + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{ + "name": "speckit.test-ext.agent", + "file": "agent.md", + "behavior": {"invocation": "automatic"}, + }], + "test-ext", src, root, + ) + skill_file = root / ".claude" / "skills" / "speckit-test-ext-agent" / "SKILL.md" + assert skill_file.exists() + fm = yaml.safe_load(skill_file.read_text().split("---")[1]) + assert fm.get("disable-model-invocation") is False + + def test_manifest_capability_merged_to_model(self, tmp_path): + """capability in manifest cmd_info produces correct model in the skill.""" + root, src = self._setup(tmp_path) + (src / "cmd.md").write_text("---\ndescription: Strong cmd\n---\nBody") + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{ + "name": "speckit.test-ext.cmd", + "file": "cmd.md", + "behavior": {"capability": "strong"}, + }], + "test-ext", src, root, + ) + skill_file = root / ".claude" / "skills" / "speckit-test-ext-cmd" / "SKILL.md" + fm = yaml.safe_load(skill_file.read_text().split("---")[1]) + assert fm.get("model") == "claude-opus-4-6" + + def test_source_behavior_wins_over_manifest(self, tmp_path): + """When source file declares behavior, it takes precedence over manifest.""" + root, src = self._setup(tmp_path) + (src / "cmd.md").write_text(dedent("""\ + --- + description: Source wins + behavior: + invocation: explicit + --- + Body + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{ + "name": "speckit.test-ext.cmd", + "file": "cmd.md", + # manifest says automatic, but source says explicit — source wins + "behavior": {"invocation": "automatic"}, + }], + "test-ext", src, root, + ) + skill_file = root / ".claude" / "skills" / "speckit-test-ext-cmd" / "SKILL.md" + fm = yaml.safe_load(skill_file.read_text().split("---")[1]) + assert fm.get("disable-model-invocation") is True + + def test_manifest_execution_agent_routes_to_agents_dir(self, tmp_path): + """execution:agent in manifest cmd_info routes a no-frontmatter file to .claude/agents/.""" + root, src = self._setup(tmp_path) + # Pure persona prompt — no frontmatter at all + (src / "persona.md").write_text("You are a specialist agent. $ARGUMENTS") + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{ + "name": "speckit.test-ext.persona", + "file": "persona.md", + "description": "Specialist persona", + "behavior": {"execution": "agent", "capability": "balanced"}, + }], + "test-ext", src, root, + ) + agent_file = root / ".claude" / "agents" / "speckit-test-ext-persona.md" + skill_file = root / ".claude" / "skills" / "speckit-test-ext-persona" / "SKILL.md" + assert agent_file.exists() + assert not skill_file.exists() + fm = yaml.safe_load(agent_file.read_text().split("---")[1]) + assert fm.get("model") == "claude-sonnet-4-6" # capability: balanced diff --git a/tests/test_behavior_translator.py b/tests/test_behavior_translator.py new file mode 100644 index 0000000000..f8506a54f7 --- /dev/null +++ b/tests/test_behavior_translator.py @@ -0,0 +1,167 @@ +# tests/test_behavior_translator.py +import pytest +from specify_cli.behavior import translate_behavior, strip_behavior_keys, get_deployment_type, get_copilot_tools + + +class TestTranslateBehavior: + def test_execution_isolated_claude(self): + result = translate_behavior("claude", {"execution": "isolated"}) + assert result == {"context": "fork"} + + def test_execution_agent_claude_no_frontmatter_key(self): + # 'agent' execution type is handled by routing, not frontmatter + result = translate_behavior("claude", {"execution": "agent"}) + assert "context" not in result + + def test_capability_strong_claude(self): + result = translate_behavior("claude", {"capability": "strong"}) + assert result == {"model": "claude-opus-4-6"} + + def test_capability_fast_claude(self): + result = translate_behavior("claude", {"capability": "fast"}) + assert result == {"model": "claude-haiku-4-5-20251001"} + + def test_effort_high_claude(self): + result = translate_behavior("claude", {"effort": "high"}) + assert result == {"effort": "high"} + + def test_tools_read_only_claude(self): + result = translate_behavior("claude", {"tools": "read-only"}) + assert result == {"allowed-tools": "Read Grep Glob"} + + def test_tools_none_claude(self): + result = translate_behavior("claude", {"tools": "none"}) + assert result == {"allowed-tools": ""} + + def test_tools_full_claude_no_injection(self): + result = translate_behavior("claude", {"tools": "full"}) + assert "allowed-tools" not in result + + def test_invocation_explicit_claude(self): + result = translate_behavior("claude", {"invocation": "explicit"}) + assert result == {"disable-model-invocation": True} + + def test_invocation_automatic_claude(self): + result = translate_behavior("claude", {"invocation": "automatic"}) + assert result == {"disable-model-invocation": False} + + def test_visibility_model_claude(self): + result = translate_behavior("claude", {"visibility": "model"}) + assert result == {"user-invocable": False} + + def test_execution_agent_copilot(self): + result = translate_behavior("copilot", {"execution": "agent"}) + assert result == {"mode": "agent"} + + def test_tools_write_claude(self): + result = translate_behavior("claude", {"tools": "write"}) + assert result == {"allowed-tools": "Read Write Edit Grep Glob"} + + def test_color_passthrough_claude(self): + result = translate_behavior("claude", {"color": "blue"}) + assert result == {"color": "blue"} + + def test_color_any_value_passthrough_claude(self): + for color in ("red", "green", "yellow", "purple", "orange", "pink", "cyan"): + result = translate_behavior("claude", {"color": color}) + assert result == {"color": color} + + def test_color_ignored_for_non_claude_agents(self): + result = translate_behavior("copilot", {"color": "blue"}) + assert "color" not in result + + def test_unknown_key_ignored(self): + result = translate_behavior("claude", {"unknown-key": "value"}) + assert result == {} + + def test_unsupported_agent_returns_empty(self): + result = translate_behavior("gemini", {"execution": "isolated"}) + assert result == {} + + def test_agents_escape_hatch_applied(self): + result = translate_behavior( + "claude", + {"capability": "fast"}, + agents_overrides={"claude": {"model": "claude-opus-4-6", "paths": "src/**"}}, + ) + assert result["model"] == "claude-opus-4-6" + assert result["paths"] == "src/**" + + def test_agents_escape_hatch_other_agent_ignored(self): + result = translate_behavior( + "claude", + {}, + agents_overrides={"codex": {"effort": "high"}}, + ) + assert result == {} + + def test_multiple_behavior_keys(self): + result = translate_behavior("claude", { + "execution": "isolated", + "capability": "strong", + "effort": "max", + "invocation": "explicit", + }) + assert result["context"] == "fork" + assert result["model"] == "claude-opus-4-6" + assert result["effort"] == "max" + assert result["disable-model-invocation"] is True + + +class TestStripBehaviorKeys: + def test_strips_behavior(self): + fm = {"name": "foo", "behavior": {"execution": "isolated"}, "description": "bar"} + result = strip_behavior_keys(fm) + assert "behavior" not in result + assert result["name"] == "foo" + + def test_strips_agents(self): + fm = {"name": "foo", "agents": {"claude": {"paths": "src/**"}}} + result = strip_behavior_keys(fm) + assert "agents" not in result + + def test_no_behavior_keys_passthrough(self): + fm = {"name": "foo", "description": "bar"} + result = strip_behavior_keys(fm) + assert result == {"name": "foo", "description": "bar"} + + def test_returns_copy_not_mutating_original(self): + fm = {"behavior": {"execution": "isolated"}} + result = strip_behavior_keys(fm) + assert "behavior" in fm # original unchanged + + +class TestGetDeploymentType: + def test_behavior_execution_agent(self): + assert get_deployment_type({"behavior": {"execution": "agent"}}) == "agent" + + def test_behavior_execution_isolated_is_command(self): + assert get_deployment_type({"behavior": {"execution": "isolated"}}) == "command" + + def test_behavior_execution_command_is_command(self): + assert get_deployment_type({"behavior": {"execution": "command"}}) == "command" + + def test_defaults_to_command_when_no_behavior(self): + assert get_deployment_type({}) == "command" + + def test_defaults_to_command_when_no_execution(self): + assert get_deployment_type({"behavior": {"capability": "strong"}}) == "command" + + +class TestGetCopilotTools: + def test_read_only_returns_tools(self): + result = get_copilot_tools({"tools": "read-only"}) + assert "read_file" in result + assert "list_directory" in result + + def test_full_returns_empty(self): + result = get_copilot_tools({"tools": "full"}) + assert result == [] + + def test_none_returns_empty(self): + result = get_copilot_tools({"tools": "none"}) + assert result == [] + + def test_missing_tools_defaults_to_full(self): + result = get_copilot_tools({}) + assert result == [] diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a9f19e74e..e8a646f13b 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -740,3 +740,208 @@ def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension assert result is True assert not (skills_dir / "speckit-test-ext-hello").exists() assert not (skills_dir / "speckit-test-ext-world").exists() + + +class TestPassthroughFrontmatter: + """Source frontmatter keys in _SKILL_PASSTHROUGH_KEYS survive into generated SKILL.md.""" + + def _make_project(self, tmp_path): + """Create project root with claude skills dir.""" + project_root = tmp_path / "proj" + (project_root / ".claude" / "skills").mkdir(parents=True) + (project_root / ".specify").mkdir() + (project_root / ".specify" / "init-options.json").write_text( + '{"ai": "claude", "ai_skills": true, "script": "sh"}' + ) + return project_root + + def test_context_fork_passed_through(self, tmp_path): + import yaml + from specify_cli.agents import CommandRegistrar + project_root = self._make_project(tmp_path) + registrar = CommandRegistrar() + result = registrar.render_skill_command( + "claude", "speckit-test-ext-hello", + {"name": "speckit.test-ext.hello", "description": "Test", "context": "fork", "agent": "general-purpose"}, + "Hello world", "test-ext", "commands/hello.md", project_root, + ) + fm_text = result.split("---")[1] + fm = yaml.safe_load(fm_text) + assert fm.get("context") == "fork" + assert fm.get("agent") == "general-purpose" + + def test_disable_model_invocation_override(self, tmp_path): + import yaml + from specify_cli.agents import CommandRegistrar + project_root = self._make_project(tmp_path) + registrar = CommandRegistrar() + result = registrar.render_skill_command( + "claude", "speckit-test-ext-hello", + {"description": "Test", "disable-model-invocation": False}, + "Hello", "test-ext", "commands/hello.md", project_root, + ) + fm_text = result.split("---")[1] + fm = yaml.safe_load(fm_text) + assert fm.get("disable-model-invocation") is False + + def test_non_passthrough_key_not_leaked(self, tmp_path): + import yaml + from specify_cli.agents import CommandRegistrar + project_root = self._make_project(tmp_path) + registrar = CommandRegistrar() + result = registrar.render_skill_command( + "claude", "speckit-test-ext-hello", + {"description": "Test", "scripts": {"sh": "run.sh"}}, + "Hello", "test-ext", "commands/hello.md", project_root, + ) + fm_text = result.split("---")[1] + fm = yaml.safe_load(fm_text) + assert "scripts" not in fm + + +class TestBehaviorTranslationInRender: + """behavior: and agents: blocks are stripped and translated during rendering.""" + + def _render(self, source_frontmatter: dict, body: str = "Hello") -> dict: + import yaml + import json + import tempfile + from specify_cli.agents import CommandRegistrar + with tempfile.TemporaryDirectory() as tmp: + project_root = Path(tmp) + (project_root / ".specify").mkdir() + (project_root / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True, "script": "sh"}) + ) + registrar = CommandRegistrar() + result = registrar.render_skill_command( + "claude", "speckit-test-cmd", + source_frontmatter, body, "test-ext", "commands/test.md", project_root, + ) + parts = result.split("---") + return yaml.safe_load(parts[1]) + + def test_behavior_key_stripped_from_output(self): + fm = self._render({"description": "Test", "behavior": {"execution": "isolated"}}) + assert "behavior" not in fm + + def test_agents_key_stripped_from_output(self): + fm = self._render({"description": "Test", "agents": {"claude": {"paths": "src/**"}}}) + assert "agents" not in fm + + def test_execution_isolated_injects_context_fork(self): + fm = self._render({"description": "Test", "behavior": {"execution": "isolated"}}) + assert fm.get("context") == "fork" + + def test_capability_strong_injects_model(self): + fm = self._render({"description": "Test", "behavior": {"capability": "strong"}}) + assert fm.get("model") == "claude-opus-4-6" + + def test_effort_high_injected(self): + fm = self._render({"description": "Test", "behavior": {"effort": "high"}}) + assert fm.get("effort") == "high" + + def test_tools_read_only_injects_allowed_tools(self): + fm = self._render({"description": "Test", "behavior": {"tools": "read-only"}}) + assert fm.get("allowed-tools") == "Read Grep Glob" + + def test_invocation_automatic_overrides_default(self): + fm = self._render({"description": "Test", "behavior": {"invocation": "automatic"}}) + assert fm.get("disable-model-invocation") is False + + def test_agents_escape_hatch_applied(self): + fm = self._render({ + "description": "Test", + "behavior": {"capability": "fast"}, + "agents": {"claude": {"model": "claude-opus-4-6", "paths": "src/**"}}, + }) + assert fm.get("model") == "claude-opus-4-6" + assert fm.get("paths") == "src/**" + + def test_passthrough_wins_over_behavior(self): + # Explicit context: fork in source FM (passthrough) should still work alongside behavior + fm = self._render({ + "description": "Test", + "context": "fork", + "behavior": {"execution": "isolated"}, + }) + assert fm.get("context") == "fork" + + +# ===== Agent-Routing Skip in _register_extension_skills ===== + +class TestExtensionSkillAgentRoutingSkip: + """_register_extension_skills() must not create SKILL.md for execution:agent commands.""" + + def _make_ext(self, temp_dir: Path, ext_id: str, commands: list) -> Path: + ext_dir = temp_dir / ext_id + (ext_dir / "commands").mkdir(parents=True) + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": ext_id, + "name": ext_id, + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": commands}, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + return ext_dir + + def test_agent_command_not_registered_as_skill(self, skills_project, temp_dir): + """Command with behavior: execution: agent must not create a SKILL.md.""" + project_dir, skills_dir = skills_project + ext_dir = self._make_ext(temp_dir, "routing-ext", [ + { + "name": "speckit.routing-ext.orchestrator", + "file": "commands/orchestrator.md", + "description": "Orchestrator command (plain skill)", + }, + { + "name": "speckit.routing-ext.specialist", + "file": "commands/specialist.md", + "description": "Specialist subagent", + }, + ]) + (ext_dir / "commands" / "orchestrator.md").write_text( + "---\ndescription: Orchestrator\nbehavior:\n invocation: automatic\n---\nOrchestrate.\n" + ) + (ext_dir / "commands" / "specialist.md").write_text( + "---\ndescription: Specialist\nbehavior:\n execution: agent\n---\nYou are a specialist.\n" + ) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + # orchestrator → SKILL.md created + assert (skills_dir / "speckit-routing-ext-orchestrator" / "SKILL.md").exists() + # specialist → NO SKILL.md (routed to agents dir instead) + assert not (skills_dir / "speckit-routing-ext-specialist" / "SKILL.md").exists() + + metadata = manager.registry.get(manifest.id) + assert "speckit-routing-ext-orchestrator" in metadata["registered_skills"] + assert "speckit-routing-ext-specialist" not in metadata["registered_skills"] + + def test_agent_command_from_manifest_behavior_not_registered_as_skill(self, skills_project, temp_dir): + """execution:agent declared in manifest cmd_info (not source) also skips SKILL.md creation.""" + project_dir, skills_dir = skills_project + ext_dir = self._make_ext(temp_dir, "manifest-routing-ext", [ + { + "name": "speckit.manifest-routing-ext.agent", + "file": "commands/agent.md", + "description": "Agent from manifest behavior", + "behavior": {"execution": "agent"}, + }, + ]) + # Source file has NO frontmatter — pure persona prompt + (ext_dir / "commands" / "agent.md").write_text("You are a helpful agent.\n") + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + assert not (skills_dir / "speckit-manifest-routing-ext-agent" / "SKILL.md").exists() + metadata = manager.registry.get(manifest.id) + assert "speckit-manifest-routing-ext-agent" not in metadata["registered_skills"] diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 350b368eac..f8c790a515 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1209,6 +1209,243 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content assert ".specify/scripts/bash/update-agent-context.sh codex" in content + def test_skill_registration_rewrites_extension_relative_paths(self, project_dir, temp_dir): + """Extension subdirectory paths in command bodies should be rewritten to + .specify/extensions//... in generated SKILL.md files.""" + import yaml + + ext_dir = temp_dir / "ext-multidir" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + (ext_dir / "templates").mkdir() + (ext_dir / "scripts").mkdir() + (ext_dir / "knowledge-base").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-multidir", + "name": "Multi-Dir Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-multidir.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + "Use templates/report.md as output format.\n" + "Run scripts/bash/gate.sh to validate.\n" + "Load knowledge-base/scores.yaml for calibration.\n" + "Also check memory/constitution.md for project rules.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-ext-multidir-run" / "SKILL.md").read_text() + # Extension-owned directories → extension-local paths + assert ".specify/extensions/ext-multidir/agents/control/commander.md" in content + assert ".specify/extensions/ext-multidir/templates/report.md" in content + assert ".specify/extensions/ext-multidir/scripts/bash/gate.sh" in content + assert ".specify/extensions/ext-multidir/knowledge-base/scores.yaml" in content + # memory/ is not an extension directory, so stays project-level + assert "memory/constitution.md" in content + # No bare extension-relative path references remain + assert "Read agents/" not in content + assert "Load knowledge-base/" not in content + + def test_skill_registration_rewrites_extension_relative_paths_for_kimi(self, project_dir, temp_dir): + """Path rewriting should also apply to kimi, which uses the /SKILL.md extension.""" + import yaml + + ext_dir = temp_dir / "ext-kimi-paths" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + (ext_dir / "knowledge-base").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-kimi-paths", + "name": "Kimi Paths Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-kimi-paths.run", + "file": "commands/run.md", + "description": "Run command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + "Load knowledge-base/scores.yaml for calibration.\n" + ) + + skills_dir = project_dir / ".kimi" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("kimi", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-ext-kimi-paths-run" / "SKILL.md").read_text() + assert ".specify/extensions/ext-kimi-paths/agents/control/commander.md" in content + assert ".specify/extensions/ext-kimi-paths/knowledge-base/scores.yaml" in content + assert "Read agents/" not in content + + def test_skill_registration_rewrites_paths_in_aliases(self, project_dir, temp_dir): + """Alias SKILL.md files should also have extension-relative paths rewritten.""" + import yaml + + ext_dir = temp_dir / "ext-alias-paths" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + (ext_dir / "agents").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "ext-alias-paths", + "name": "Alias Paths Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.ext-alias-paths.run", + "file": "commands/run.md", + "description": "Run command", + "aliases": ["speckit.ext-alias-paths.go"], + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Run command\n" + "---\n\n" + "Read agents/control/commander.md for instructions.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + alias_content = (skills_dir / "speckit-ext-alias-paths-go" / "SKILL.md").read_text() + assert ".specify/extensions/ext-alias-paths/agents/control/commander.md" in alias_content + assert "Read agents/" not in alias_content + + def test_rewrite_extension_paths_no_subdirs(self, project_dir, temp_dir): + """Extension with no subdirectories should leave command body text unchanged.""" + import yaml + + ext_dir = temp_dir / "bare-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": {"id": "bare-ext", "name": "Bare", "version": "1.0.0", "description": "Test"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"commands": [{"name": "speckit.bare-ext.run", "file": "commands/run.md", "description": "Run"}]}, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\ndescription: Run\n---\n\nRead agents/control/commander.md and templates/report.md.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + CommandRegistrar().register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + content = (skills_dir / "speckit-bare-ext-run" / "SKILL.md").read_text() + # No subdirs to match — text unchanged + assert "agents/control/commander.md" in content + assert "templates/report.md" in content + + def test_preset_skill_registration_does_not_rewrite_paths(self, project_dir, temp_dir): + """Preset source dirs (no extension.yml) must not have paths rewritten to .specify/extensions/...""" + import yaml + + preset_dir = temp_dir / "my-preset" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + # Preset dirs may have a templates/ subdir — must not be rewritten. + (preset_dir / "templates").mkdir() + # No extension.yml — this is a preset, not an extension. + (preset_dir / "preset.yml").write_text("id: my-preset\n") + + commands = [ + { + "name": "speckit.my-preset.run", + "file": "commands/run.md", + "description": "Run", + } + ] + (preset_dir / "commands" / "run.md").write_text( + "---\ndescription: Run\n---\n\nSee templates/report.md for output format.\n" + ) + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + AgentCommandRegistrar().register_commands_for_all_agents( + commands, "my-preset", preset_dir, project_dir + ) + + content = (skills_dir / "speckit-my-preset-run" / "SKILL.md").read_text() + # Paths must NOT be rewritten to extension-style locations. + assert ".specify/extensions/" not in content + # Original reference must remain intact. + assert "templates/report.md" in content + def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" import yaml diff --git a/tests/test_integration_extension_skill_paths.py b/tests/test_integration_extension_skill_paths.py new file mode 100644 index 0000000000..0456a3d24a --- /dev/null +++ b/tests/test_integration_extension_skill_paths.py @@ -0,0 +1,214 @@ +""" +Integration tests: install a real extension into a temp project and verify +that generated SKILL.md files have correct .specify/extensions//… paths +instead of bare extension-relative references. + +Set the SPECKIT_TEST_EXT_DIR environment variable to the path of a local +extension checkout before running. Tests are skipped automatically when +the variable is not set or the directory does not exist. + +Example: + SPECKIT_TEST_EXT_DIR=~/work/my-extension pytest tests/test_integration_extension_skill_paths.py +""" + +import json +import os +import re +import shutil +import tempfile +from pathlib import Path + +import pytest + +_ext_dir_env = os.environ.get("SPECKIT_TEST_EXT_DIR", "") +EXT_DIR = Path(_ext_dir_env).expanduser().resolve() if _ext_dir_env else None + +pytestmark = pytest.mark.skipif( + EXT_DIR is None or not EXT_DIR.exists(), + reason="Set SPECKIT_TEST_EXT_DIR to an extension checkout to run these tests", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _ext_id() -> str: + from specify_cli.extensions import ExtensionManifest + return ExtensionManifest(EXT_DIR / "extension.yml").id + + +def _make_project(tmp: Path, ai: str = "codex") -> Path: + project = tmp / "project" + project.mkdir() + specify = project / ".specify" + specify.mkdir() + (specify / "init-options.json").write_text( + json.dumps({"ai": ai, "ai_skills": True, "script": "sh"}) + ) + if ai == "codex": + (project / ".agents" / "skills").mkdir(parents=True) + elif ai == "kimi": + (project / ".kimi" / "skills").mkdir(parents=True) + return project + + +def _install_ext(project: Path) -> None: + from specify_cli.extensions import ExtensionManager + try: + from importlib.metadata import version + speckit_version = version("specify-cli") + except Exception: + speckit_version = "999.0.0" + ExtensionManager(project).install_from_directory(EXT_DIR, speckit_version, register_commands=True) + + +def _skill_files(project: Path, ext_id: str, ai: str = "codex") -> dict[str, Path]: + skills_root = project / (".agents/skills" if ai == "codex" else ".kimi/skills") + return { + p.parent.name: p + for p in skills_root.glob("*/SKILL.md") + if ext_id in p.parent.name + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def ext_id(): + return _ext_id() + + +@pytest.fixture +def tmp_dir(): + d = tempfile.mkdtemp() + yield Path(d) + shutil.rmtree(d) + + +@pytest.fixture +def codex_project(tmp_dir): + project = _make_project(tmp_dir, ai="codex") + _install_ext(project) + return project + + +@pytest.fixture +def kimi_project(tmp_dir): + project = _make_project(tmp_dir, ai="kimi") + _install_ext(project) + return project + + +# --------------------------------------------------------------------------- +# Installation sanity +# --------------------------------------------------------------------------- + +class TestExtensionInstallation: + + def test_extension_files_copied_to_specify_dir(self, codex_project, ext_id): + installed = codex_project / ".specify" / "extensions" / ext_id + assert installed.is_dir() + assert (installed / "extension.yml").exists() + + def test_agent_subdirectory_installed(self, codex_project, ext_id): + installed = codex_project / ".specify" / "extensions" / ext_id + subdirs = [d.name for d in installed.iterdir() if d.is_dir()] + assert subdirs, f"No subdirectories found under {installed}" + + def test_all_commands_produce_skill_files(self, codex_project, ext_id): + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest( + codex_project / ".specify" / "extensions" / ext_id / "extension.yml" + ) + skill_files = _skill_files(codex_project, ext_id) + for cmd in manifest.commands: + short = cmd["name"].removeprefix("speckit.").replace(".", "-") + skill_name = f"speckit-{short}" + assert skill_name in skill_files, ( + f"Expected SKILL.md for '{cmd['name']}' at '{skill_name}'.\n" + f"Available: {sorted(skill_files)}" + ) + + def test_registry_records_installed_extension(self, codex_project, ext_id): + from specify_cli.extensions import ExtensionManager + assert ExtensionManager(codex_project).registry.is_installed(ext_id) + + +# --------------------------------------------------------------------------- +# Path rewriting +# --------------------------------------------------------------------------- + +class TestSkillPathRewriting: + + def test_installed_subdirs_appear_with_extension_prefix(self, codex_project, ext_id): + """At least one installed subdirectory should appear prefixed in skill files.""" + installed = codex_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(codex_project, ext_id) + all_content = "\n".join(p.read_text() for p in skill_files.values()) + + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + rewritten = [s for s in installed_subdirs if f"{prefix}{s}/" in all_content] + assert rewritten, ( + f"No installed subdir appeared as {prefix}/ in any skill file.\n" + f"Installed subdirs: {installed_subdirs}" + ) + + def test_no_bare_subdir_paths_remain(self, codex_project, ext_id): + """No bare '/…' references should survive in any skill file.""" + installed = codex_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(codex_project, ext_id) + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + failures = [] + for subdir in installed_subdirs: + for name, path in skill_files.items(): + stripped = path.read_text().replace(f"{prefix}{subdir}/", "__OK__") + bare = re.findall( + r'(?:^|[\s`"\'(])(?:\.?/)?' + re.escape(subdir) + r'/', + stripped, re.MULTILINE, + ) + if bare: + failures.append(f"{name}: bare '{subdir}/': {bare}") + assert not failures, "Bare subdirectory references found:\n" + "\n".join(failures) + + +# --------------------------------------------------------------------------- +# Kimi +# --------------------------------------------------------------------------- + +class TestSkillPathRewritingKimi: + + def test_kimi_skills_contain_extension_prefix(self, kimi_project, ext_id): + installed = kimi_project / ".specify" / "extensions" / ext_id + skill_files = _skill_files(kimi_project, ext_id, ai="kimi") + assert skill_files, f"No kimi skill files found for {ext_id}" + + prefix = f".specify/extensions/{ext_id}/" + installed_subdirs = [d.name for d in installed.iterdir() if d.is_dir() and d.name != "commands"] + all_content = "\n".join(p.read_text() for p in skill_files.values()) + rewritten = [s for s in installed_subdirs if f"{prefix}{s}/" in all_content] + assert rewritten, ( + f"No installed subdir appeared as {prefix}/ in kimi skill files.\n" + f"Installed subdirs: {installed_subdirs}" + ) + + +# --------------------------------------------------------------------------- +# Script placeholders +# --------------------------------------------------------------------------- + +class TestScriptPlaceholders: + + def test_no_unresolved_script_placeholders(self, codex_project, ext_id): + skill_files = _skill_files(codex_project, ext_id) + failures = [] + for name, path in skill_files.items(): + content = path.read_text() + for placeholder in ("{SCRIPT}", "{AGENT_SCRIPT}", "{ARGS}"): + if placeholder in content: + failures.append(f"{name}: contains {placeholder}") + assert not failures, "Unresolved placeholders:\n" + "\n".join(failures)