From 1c00cf734f9989cd5133a130a5f41d184a6c3a3d Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Mon, 6 Apr 2026 11:49:59 +0200 Subject: [PATCH 01/19] fix(extensions): rewrite extension-relative paths in generated SKILL.md files Extension command bodies reference files using paths relative to the extension root (e.g. `agents/control/commander.md`, `knowledge-base/scores.yaml`). After install these files live at `.specify/extensions//...`, but the generated SKILL.md files were emitting bare relative paths that AI agents could not resolve from the workspace root. Add `CommandRegistrar.rewrite_extension_paths()` which discovers the subdirectories that exist in the installed extension directory and rewrites matching body references to `.specify/extensions///...`. The rewrite runs before `resolve_skill_placeholders()` so that extension-local `scripts/` and `templates/` subdirectories are not incorrectly redirected to the project-level `.specify/scripts/` and `.specify/templates/` paths. The method is called from `render_skill_command()` when `source_dir` is provided, which `register_commands()` now passes through for all agents. Affected agents: any using the `/SKILL.md` extension format (currently kimi and codex). Aliases receive the same rewriting. Closes #2101 Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 59 ++++- tests/test_extensions.py | 199 ++++++++++++++++ .../test_integration_extension_skill_paths.py | 214 ++++++++++++++++++ 3 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 tests/test_integration_extension_skill_paths.py diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index bb25b3fc1..8f3a03a32 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,7 +7,7 @@ """ from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re @@ -150,6 +150,50 @@ 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}/" + 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, @@ -229,6 +273,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,6 +290,9 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} + if source_dir is not None: + 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) @@ -424,7 +472,8 @@ def register_commands( 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) @@ -452,7 +501,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 +515,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']}" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 350b368ea..951a5c779 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1209,6 +1209,205 @@ 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_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 000000000..0456a3d24 --- /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) From 64cd276b61025ede5176abf1bf389c45710bd169 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 07:29:06 +0200 Subject: [PATCH 02/19] feat: add BehaviorTranslator for neutral extension behavior vocabulary Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/behavior.py | 152 ++++++++++++++++++++++++++++++ tests/test_behavior_translator.py | 150 +++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 src/specify_cli/behavior.py create mode 100644 tests/test_behavior_translator.py diff --git a/src/specify_cli/behavior.py b/src/specify_cli/behavior.py new file mode 100644 index 000000000..6f0a5989f --- /dev/null +++ b/src/specify_cli/behavior.py @@ -0,0 +1,152 @@ +"""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 | full + "invocation", # explicit | automatic + "visibility", # user | model | both +}) + +# 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"), + "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 + 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/tests/test_behavior_translator.py b/tests/test_behavior_translator.py new file mode 100644 index 000000000..20bede082 --- /dev/null +++ b/tests/test_behavior_translator.py @@ -0,0 +1,150 @@ +# 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_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 == [] From b8768b44fc462f442ee5ebb69053a6576b9fabe0 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 07:33:26 +0200 Subject: [PATCH 03/19] feat: passthrough agent-specific skill frontmatter keys from source commands Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 50 ++++++++++++++-- .../integrations/claude/__init__.py | 4 +- tests/test_extension_skills.py | 57 +++++++++++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 8f3a03a32..1276f289d 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,28 @@ import yaml +# 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 + "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.""" from specify_cli.integrations import INTEGRATION_REGISTRY @@ -290,7 +312,7 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} - if source_dir is not None: + 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"}: @@ -302,6 +324,7 @@ def render_skill_command( skill_name, description, f"{source_id}:{source_file}", + source_frontmatter=frontmatter, ) return self.render_frontmatter(skill_frontmatter) + "\n" + body @@ -311,9 +334,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", @@ -323,10 +359,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 diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0..a520eea77 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/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a9f19e74..f6cecb3f9 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -740,3 +740,60 @@ 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 From dbb4fcb1a5971fb9de67537fdbc17b24fd540f13 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 07:37:01 +0200 Subject: [PATCH 04/19] feat: translate behavior: vocabulary into agent-specific frontmatter during rendering Wire translate_behavior() and strip_behavior_keys() into render_skill_command() so that behavior: and agents: blocks are stripped from output and translated into agent-specific fields (context, model, effort, allowed-tools, etc.) during skill rendering. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 27 ++++++++++++- tests/test_extension_skills.py | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 1276f289d..bcd5bc4bc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -14,6 +14,8 @@ 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 @@ -318,14 +320,35 @@ def render_skill_command( 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=frontmatter, + 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 diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index f6cecb3f9..eaf33796f 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -797,3 +797,72 @@ def test_non_passthrough_key_not_leaked(self, tmp_path): 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" From 8688503903817a8ee2e35422dc06491b3179abb3 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 07:41:17 +0200 Subject: [PATCH 05/19] feat: route behavior.execution:agent commands to .claude/agents/ instead of .claude/skills/ Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 77 +++++++++++++++ tests/test_agent_deployment.py | 169 +++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 tests/test_agent_deployment.py diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index bcd5bc4bc..6354b38f4 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -288,6 +288,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, @@ -533,6 +589,21 @@ 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, @@ -673,6 +744,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/tests/test_agent_deployment.py b/tests/test_agent_deployment.py new file mode 100644 index 000000000..00e423c17 --- /dev/null +++ b/tests/test_agent_deployment.py @@ -0,0 +1,169 @@ +"""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() From 9942c642c2f2dbde22d084e8f43c8fd74f9950e6 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 10:01:21 +0200 Subject: [PATCH 06/19] feat: inject mode and tools into Copilot .agent.md for execution:agent commands Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 35 ++++++++++++++++++-- tests/test_agent_deployment.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6354b38f4..811bd70c1 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -610,7 +610,23 @@ def register_commands( 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: @@ -639,7 +655,22 @@ def register_commands( source_dir=source_dir, ) elif agent_config["format"] == "markdown": - alias_output = self.render_markdown_command(alias_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 = alias_frontmatter.get("behavior") or {} + agents_overrides = alias_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 + alias_copilot_fm = strip_behavior_keys(alias_frontmatter) + alias_copilot_fm.update(extra_fields) + alias_output = self.render_markdown_command(alias_copilot_fm, body, source_id, context_note) + else: + alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": alias_output = self.render_toml_command(alias_frontmatter, body, source_id) else: diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py index 00e423c17..a8d4fb340 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -167,3 +167,62 @@ def test_unregister_removes_agent_file(self, project_root, source_dir): 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 From 807b8a4ee73e1b479a59fbf94c731c37c81048bc Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 10:08:19 +0200 Subject: [PATCH 07/19] fix: remove dead copilot alias injection, add missing agent deployment tests Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 17 +---------- tests/test_agent_deployment.py | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 811bd70c1..ae175fe52 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -655,22 +655,7 @@ def register_commands( source_dir=source_dir, ) elif agent_config["format"] == "markdown": - # For Copilot execution:agent, inject behavior-derived fields into frontmatter - if agent_name == "copilot" and cmd_type == "agent": - behavior = alias_frontmatter.get("behavior") or {} - agents_overrides = alias_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 - alias_copilot_fm = strip_behavior_keys(alias_frontmatter) - alias_copilot_fm.update(extra_fields) - alias_output = self.render_markdown_command(alias_copilot_fm, body, source_id, context_note) - else: - alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) + alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": alias_output = self.render_toml_command(alias_frontmatter, body, source_id) else: diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py index a8d4fb340..ee5346f4c 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -226,3 +226,55 @@ def test_copilot_type_command_no_tools_injected(self, tmp_path): 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" From a3459c8589652db78a0e38d1dfd7cbbf117caf01 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 10:18:16 +0200 Subject: [PATCH 08/19] test: end-to-end agent deployment integration test + RFC addendum Co-Authored-By: Claude Sonnet 4.6 --- docs/extension-behavior-deployment.md | 143 ++++++++++++++++++++++++++ tests/test_agent_deployment.py | 91 ++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 docs/extension-behavior-deployment.md diff --git a/docs/extension-behavior-deployment.md b/docs/extension-behavior-deployment.md new file mode 100644 index 000000000..e61391b48 --- /dev/null +++ b/docs/extension-behavior-deployment.md @@ -0,0 +1,143 @@ +# 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 | full + invocation: explicit | automatic + visibility: user | model | both +``` + +### 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` | `none` | `allowed-tools: ""` | — | — | — | +| `invocation` | `explicit` | `disable-model-invocation: true` | — | — | — | +| `invocation` | `automatic` | `disable-model-invocation: false` | — | — | — | +| `visibility` | `user` | `user-invocable: true` | — | — | — | +| `visibility` | `model` | `user-invocable: false` | — | — | — | + +Cells marked `—` mean "no concept, field omitted silently." + +--- + +## `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 +agents: + claude: + paths: "src/**" +--- +You are a codebase analysis specialist. +Analyze $ARGUMENTS and return structured domain findings. +``` diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py index ee5346f4c..2eb34d64f 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -278,3 +278,94 @@ def test_copilot_agents_override_survives(self, tmp_path): 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() + (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() From 8ddfb60c7987fb36b59255604e89f01407c68f55 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Wed, 8 Apr 2026 10:26:12 +0200 Subject: [PATCH 09/19] fix: clarify RFC doc tool key remapping, add missing table rows - Add note below per-agent translation table explaining that allowed-tools is remapped to tools for Claude agent definitions (execution: agent) - Add tools: full and visibility: both rows to the table (both are no-ops) - Add comment in TestEndToEnd explaining why ai_skills is omitted from init-options.json (skill routing uses CommandRegistrar, not ai_skills path) Co-Authored-By: Claude Sonnet 4.6 --- docs/extension-behavior-deployment.md | 4 ++++ tests/test_agent_deployment.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs/extension-behavior-deployment.md b/docs/extension-behavior-deployment.md index e61391b48..a563702a6 100644 --- a/docs/extension-behavior-deployment.md +++ b/docs/extension-behavior-deployment.md @@ -35,13 +35,17 @@ behavior: | `effort` | any | `effort: {value}` | — | `effort: {value}` | — | | `tools` | `read-only` | `allowed-tools: Read Grep Glob` | `tools: [read_file, list_directory, search_files]` | — | — | | `tools` | `none` | `allowed-tools: ""` | — | — | — | +| `tools` | `full` | — | — | — | — | | `invocation` | `explicit` | `disable-model-invocation: true` | — | — | — | | `invocation` | `automatic` | `disable-model-invocation: false` | — | — | — | | `visibility` | `user` | `user-invocable: true` | — | — | — | | `visibility` | `model` | `user-invocable: false` | — | — | — | +| `visibility` | `both` | — | — | — | — | 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. + --- ## `agents:` Escape Hatch diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py index 2eb34d64f..0eff5137c 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -291,6 +291,9 @@ def test_extension_with_agent_command_deploys_correctly(self, tmp_path): (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"}) ) From ce1233157822bb9f9bc86f75105f383b21c03adf Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 09:21:20 +0200 Subject: [PATCH 10/19] =?UTF-8?q?feat(behavior):=20add=20'write'=20tools?= =?UTF-8?q?=20level=20=E2=80=94=20Read=20Write=20Edit=20Grep=20Glob=20with?= =?UTF-8?q?out=20Bash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/specify_cli/behavior.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/specify_cli/behavior.py b/src/specify_cli/behavior.py index 6f0a5989f..39f2046eb 100644 --- a/src/specify_cli/behavior.py +++ b/src/specify_cli/behavior.py @@ -65,6 +65,7 @@ "tools": { "none": ("allowed-tools", ""), "read-only": ("allowed-tools", "Read Grep Glob"), + "write": ("allowed-tools", "Read Write Edit Grep Glob"), "full": (None, None), }, "invocation": { From 58e0c721fcbc36c3b4c05d24eca98d33b8aa0f4b Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 09:35:01 +0200 Subject: [PATCH 11/19] =?UTF-8?q?feat(behavior):=20support=20custom=20tool?= =?UTF-8?q?=20lists=20=E2=80=94=20tools=20accepts=20list=20or=20literal=20?= =?UTF-8?q?string=20in=20addition=20to=20presets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/specify_cli/behavior.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/behavior.py b/src/specify_cli/behavior.py index 39f2046eb..9270a55a8 100644 --- a/src/specify_cli/behavior.py +++ b/src/specify_cli/behavior.py @@ -36,7 +36,7 @@ "execution", # command | isolated | agent "capability", # fast | balanced | strong "effort", # low | medium | high | max - "tools", # none | read-only | full + "tools", # none | read-only | write | full | custom list (str or list[str]) "invocation", # explicit | automatic "visibility", # user | model | both }) @@ -115,6 +115,23 @@ def translate_behavior( for key, value in behavior.items(): if key not in BEHAVIOR_KEYS: 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: From c804c5f196258ca55205c09fcd097a98d9231920 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:47:38 +0200 Subject: [PATCH 12/19] feat(behavior): add color and write preset to behavior vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - behavior.py: add `color` key to BEHAVIOR_KEYS; passthrough to Claude agent frontmatter (red|blue|green|yellow|purple|orange|pink|cyan) - behavior.py: add `write` preset (Read Write Edit Grep Glob, no Bash) - behavior.py: add custom tool list support — YAML list or unrecognised string passed verbatim as allowed-tools - agents.py: add `color` to _SKILL_PASSTHROUGH_KEYS["claude"] - tests: add 4 new behavior translator tests (write, color passthrough, all color values, color ignored for non-Claude agents) - RFC: update RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md to document write preset, custom tool values, and color field with examples Co-Authored-By: Claude Sonnet 4.6 --- .../RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md | 223 ++++++++++++++++++ src/specify_cli/agents.py | 12 + src/specify_cli/behavior.py | 7 + tests/test_behavior_translator.py | 17 ++ 4 files changed, 259 insertions(+) create mode 100644 extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md diff --git a/extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md b/extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md new file mode 100644 index 000000000..59dd9c600 --- /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/src/specify_cli/agents.py b/src/specify_cli/agents.py index ae175fe52..8fcc3ab08 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -27,6 +27,7 @@ "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 @@ -208,6 +209,10 @@ def rewrite_extension_paths(text: str, extension_id: str, extension_dir: Path) - 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( @@ -575,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", []): diff --git a/src/specify_cli/behavior.py b/src/specify_cli/behavior.py index 9270a55a8..5c694cb30 100644 --- a/src/specify_cli/behavior.py +++ b/src/specify_cli/behavior.py @@ -39,6 +39,7 @@ "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. @@ -116,6 +117,12 @@ def translate_behavior( 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": diff --git a/tests/test_behavior_translator.py b/tests/test_behavior_translator.py index 20bede082..f8506a54f 100644 --- a/tests/test_behavior_translator.py +++ b/tests/test_behavior_translator.py @@ -53,6 +53,23 @@ 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 == {} From b3162728c9a0e827322d9b66c74ac69988ca8825 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:56:05 +0200 Subject: [PATCH 13/19] docs(rfc): add RFC addenda section linking behavior+deployment addendum Add table of addenda at the bottom of RFC-EXTENSION-SYSTEM.md and a see-also cross-reference in the conversion appendix, so readers are directed to RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md. Co-Authored-By: Claude Sonnet 4.6 --- extensions/RFC-EXTENSION-SYSTEM.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/extensions/RFC-EXTENSION-SYSTEM.md b/extensions/RFC-EXTENSION-SYSTEM.md index dd4c97e8a..6c4b87cc3 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) | From aa8c9a18c90671e9e48e8bc5fd2b67de475f2317 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:56:11 +0200 Subject: [PATCH 14/19] fix(extensions): skip agent-deployment commands in skill registration loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _register_extension_skills() now checks behavior.execution before creating a SKILL.md directory. Commands routed to .claude/agents/ (execution: agent) are skipped — they were already deployed by register_commands_for_all_agents(). Manifest-level behavior is merged when the source file has no behavior: block, matching the same merge logic used by the agent deployment path. Also adds a preset regression test: preset source dirs (no extension.yml) must not have paths rewritten to .specify/extensions/... prefixes. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/extensions.py | 12 +++++++++++ tests/test_extensions.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 3420a7651..6cafc5fb8 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/tests/test_extensions.py b/tests/test_extensions.py index 951a5c779..f8c790a51 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1408,6 +1408,44 @@ def test_rewrite_extension_paths_no_subdirs(self, project_dir, temp_dir): 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 From 9b86c1fac3c39747996c86357b58a8f68422716a Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:56:16 +0200 Subject: [PATCH 15/19] feat(templates): mark all built-in commands as invocation: automatic All built-in command templates (analyze, checklist, clarify, constitution, implement, plan, specify, tasks, taskstoissues) now declare behavior.invocation: automatic, which translates to disable-model-invocation: false for Claude. This allows the model to invoke these commands autonomously, matching their design intent as workflow commands within an automated pipeline. Co-Authored-By: Claude Sonnet 4.6 --- templates/commands/analyze.md | 2 ++ templates/commands/checklist.md | 2 ++ templates/commands/clarify.md | 2 ++ templates/commands/constitution.md | 2 ++ templates/commands/implement.md | 2 ++ templates/commands/plan.md | 2 ++ templates/commands/specify.md | 2 ++ templates/commands/tasks.md | 2 ++ templates/commands/taskstoissues.md | 2 ++ 9 files changed, 18 insertions(+) diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index b3174338d..39dd0d900 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 a79131a20..987a8851b 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 26efb5aed..b4728379b 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 63d4f662a..8fd42b5bd 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 9a91d2dc4..d5fb6b004 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 4f1e9ed29..320af46bf 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 a81b8f12f..b8c99ced2 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 4e204abc1..676d235e8 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 d6aa3bbf5..99d4e6c35 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 From 5618fd15cc4d471ccd13d9c0b82a39f606c7ac87 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:56:20 +0200 Subject: [PATCH 16/19] chore(gitignore): ignore local dev dirs and specs Add .venv312, .specify, .claude, and specs/ to .gitignore to prevent local development artefacts from appearing as untracked files. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 1688c8299..c2896705c 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/ From 2e4a5568e9af10c213d1e29bc67940d975606d10 Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Fri, 10 Apr 2026 11:56:24 +0200 Subject: [PATCH 17/19] chore(docs): remove old extension-behavior-deployment.md Content superseded by extensions/RFC-EXTENSION-BEHAVIOR-DEPLOYMENT.md, which is the canonical addendum to RFC-EXTENSION-SYSTEM.md. Co-Authored-By: Claude Sonnet 4.6 --- docs/extension-behavior-deployment.md | 147 -------------------------- 1 file changed, 147 deletions(-) delete mode 100644 docs/extension-behavior-deployment.md diff --git a/docs/extension-behavior-deployment.md b/docs/extension-behavior-deployment.md deleted file mode 100644 index a563702a6..000000000 --- a/docs/extension-behavior-deployment.md +++ /dev/null @@ -1,147 +0,0 @@ -# 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 | full - invocation: explicit | automatic - visibility: user | model | both -``` - -### 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` | `none` | `allowed-tools: ""` | — | — | — | -| `tools` | `full` | — | — | — | — | -| `invocation` | `explicit` | `disable-model-invocation: true` | — | — | — | -| `invocation` | `automatic` | `disable-model-invocation: false` | — | — | — | -| `visibility` | `user` | `user-invocable: true` | — | — | — | -| `visibility` | `model` | `user-invocable: false` | — | — | — | -| `visibility` | `both` | — | — | — | — | - -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. - ---- - -## `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 -agents: - claude: - paths: "src/**" ---- -You are a codebase analysis specialist. -Analyze $ARGUMENTS and return structured domain findings. -``` From 09365c417553487dd6ac151c2671726a8c0965bc Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Sat, 11 Apr 2026 06:41:53 +0200 Subject: [PATCH 18/19] fix(integrations): translate behavior in SkillsIntegration before Claude post-processing SkillsIntegration.setup() now runs translate_behavior() on each template's behavior: block and emits the resulting fields (e.g. disable-model-invocation, model) into the SKILL.md frontmatter before ClaudeIntegration.setup() runs its post-processing pass. _inject_frontmatter_flag() already skips keys that are already present, so behavior: invocation: automatic now correctly produces disable-model-invocation: false instead of being overwritten with true. Regression covered by TestSkillsIntegrationBehaviorTranslation (5 tests). Also adds: - TestManifestBehaviorMerge: manifest-level behavior: fields cascade into rendered skills and agent definitions via register_commands() - TestExtensionSkillAgentRoutingSkip: _register_extension_skills() correctly skips execution:agent commands (both source and manifest-declared) Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/integrations/base.py | 22 ++++ tests/integrations/test_integration_claude.py | 117 +++++++++++++++++- tests/test_agent_deployment.py | 108 ++++++++++++++++ tests/test_extension_skills.py | 79 ++++++++++++ 4 files changed, 324 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index dac5063f5..55179de96 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/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df17..8c7f2bf02 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 index 0eff5137c..669015d8f 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -372,3 +372,111 @@ def test_extension_with_agent_command_deploys_correctly(self, tmp_path): # 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_extension_skills.py b/tests/test_extension_skills.py index eaf33796f..e8a646f13 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -866,3 +866,82 @@ def test_passthrough_wins_over_behavior(self): "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"] From 5356704e5cbd4d7c1beb9709c638eaaabb1cb06b Mon Sep 17 00:00:00 2001 From: iamaeroplane Date: Sun, 12 Apr 2026 06:07:28 +0200 Subject: [PATCH 19/19] fix(review): address Copilot inline feedback - Guard behavior value as dict before passing to translate_behavior() in Copilot execution:agent branch; non-dict values (string/list) now fall back to {} instead of raising at runtime - Generate .claude/agents/.md for each alias on execution:agent commands, matching alias behaviour for skill/command registration - Call rewrite_extension_paths() before resolve_skill_placeholders() in _register_extension_skills(), closing the gap with render_skill_command() Tests added for all three fixes. Co-Authored-By: Claude Sonnet 4.6 --- src/specify_cli/agents.py | 16 ++++++++-- src/specify_cli/extensions.py | 1 + tests/test_agent_deployment.py | 54 ++++++++++++++++++++++++++++++++++ tests/test_extension_skills.py | 31 +++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 8fcc3ab08..5a453bfc6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -614,6 +614,18 @@ def register_commands( dest_file = agents_dir / f"{output_name}.md" dest_file.write_text(output, encoding="utf-8") registered.append(cmd_name) + # Also generate agent definition files for any aliases + for alias in cmd_info.get("aliases", []): + alias_output_name = self._compute_output_name(agent_name, alias, agent_config) + alias_frontmatter = deepcopy(frontmatter) + alias_frontmatter["name"] = alias_output_name + alias_output = self.render_agent_definition( + agent_name, alias_output_name, alias_frontmatter, body, + source_id, cmd_file, project_root, source_dir=source_dir, + ) + alias_file = agents_dir / f"{alias_output_name}.md" + alias_file.write_text(alias_output, encoding="utf-8") + registered.append(alias) continue # skip normal skill/command rendering if agent_config["extension"] == "/SKILL.md": @@ -624,13 +636,13 @@ def register_commands( elif agent_config["format"] == "markdown": # For Copilot execution:agent, inject behavior-derived fields into frontmatter if agent_name == "copilot" and cmd_type == "agent": - behavior = frontmatter.get("behavior") or {} + behavior = frontmatter.get("behavior") if isinstance(frontmatter.get("behavior"), dict) else {} 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 {}) + copilot_tools = get_copilot_tools(behavior) if copilot_tools: extra_fields["tools"] = copilot_tools # Build modified frontmatter: strip internal keys, add extra diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6cafc5fb8..7b02622d3 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -806,6 +806,7 @@ def _register_extension_skills( continue frontmatter, body = registrar.parse_frontmatter(content) frontmatter = registrar._adjust_script_paths(frontmatter) + body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir) body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root ) diff --git a/tests/test_agent_deployment.py b/tests/test_agent_deployment.py index 669015d8f..e7fb87472 100644 --- a/tests/test_agent_deployment.py +++ b/tests/test_agent_deployment.py @@ -279,6 +279,30 @@ def test_copilot_agents_override_survives(self, tmp_path): fm = yaml.safe_load(parts[1]) or {} assert fm.get("someCustomKey") == "someValue" + def test_copilot_non_dict_behavior_does_not_raise(self, tmp_path): + """A non-dict behavior value in Copilot execution:agent branch must not raise.""" + root = self._setup_copilot_project(tmp_path) + src = tmp_path / "ext" / "commands" + src.mkdir(parents=True) + # behavior in source file is a string, not a mapping; manifest provides the execution type + (src / "bad.md").write_text(dedent("""\ + --- + description: Bad behavior + behavior: "this is not a dict" + --- + Body text + """)) + registrar = CommandRegistrar() + # Should not raise even though source behavior is a string; manifest behavior wins + registrar.register_commands( + "copilot", + [{"name": "speckit.test-ext.bad", "file": "bad.md", + "behavior": {"execution": "agent"}}], + "test-ext", src, root, + ) + agent_file = root / ".github" / "agents" / "speckit.test-ext.bad.agent.md" + assert agent_file.exists() + class TestEndToEnd: """Full pipeline: extension with behavior.execution:agent → correct files deployed.""" @@ -480,3 +504,33 @@ def test_manifest_execution_agent_routes_to_agents_dir(self, tmp_path): 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 + + def test_agent_aliases_generate_agent_files(self, tmp_path): + """Aliases on execution:agent commands get their own .claude/agents/.md files.""" + root, src = self._setup(tmp_path) + (src / "worker.md").write_text(dedent("""\ + --- + description: Worker agent + behavior: + execution: agent + --- + You are a worker agent. + """)) + registrar = CommandRegistrar() + registrar.register_commands( + "claude", + [{ + "name": "speckit.test-ext.worker", + "file": "worker.md", + "aliases": ["speckit.test-ext.w", "speckit.test-ext.work"], + }], + "test-ext", src, root, + ) + agents_dir = root / ".claude" / "agents" + assert (agents_dir / "speckit-test-ext-worker.md").exists() + assert (agents_dir / "speckit-test-ext-w.md").exists() + assert (agents_dir / "speckit-test-ext-work.md").exists() + # Alias file should use the alias name in its frontmatter + fm = yaml.safe_load((agents_dir / "speckit-test-ext-w.md").read_text().split("---")[1]) + assert fm["name"] == "speckit-test-ext-w" + diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index e8a646f13..254a4ba62 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -16,6 +16,7 @@ import shutil import yaml from pathlib import Path +from textwrap import dedent from specify_cli.extensions import ( ExtensionManifest, @@ -945,3 +946,33 @@ def test_agent_command_from_manifest_behavior_not_registered_as_skill(self, skil 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"] + + def test_extension_skill_body_paths_rewritten(self, skills_project, temp_dir): + """_register_extension_skills rewrites extension-relative paths before placeholder resolution.""" + project_dir, skills_dir = skills_project + ext_dir = self._make_ext(temp_dir, "path-rewrite-ext", [ + { + "name": "speckit.path-rewrite-ext.cmd", + "file": "commands/cmd.md", + "description": "Command with extension-relative paths", + }, + ]) + # Create a subdir so rewrite_extension_paths picks it up + (ext_dir / "agents").mkdir() + (ext_dir / "commands" / "cmd.md").write_text(dedent("""\ + --- + description: Test command + --- + See agents/control/commander.md for instructions. + """)) + + manager = ExtensionManager(project_dir) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + skill_file = skills_dir / "speckit-path-rewrite-ext-cmd" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + # Path must be rewritten to the installed extension location + assert ".specify/extensions/path-rewrite-ext/agents/control/commander.md" in content + # Must NOT appear as a bare relative path without the full prefix + assert "See agents/control/commander.md" not in content