From 0167a0faceeed73d08f5649bc290836e4bde2b12 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 24 Jun 2026 09:59:45 +0200 Subject: [PATCH 01/60] test: reduce registry manifest test repetition Assisted-by: Codex (model: GPT-5, autonomous) --- tests/integrations/test_registry.py | 89 ++++++++++++++++------------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0110e19ec7..7408c98a36 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -48,6 +48,11 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]: ] +def _multi_install_safe_orders() -> list[list[str]]: + safe_keys = _multi_install_safe_keys() + return [safe_keys, list(reversed(safe_keys))] + + def _posix_path(value: str | None) -> str | None: if not value: return None @@ -230,60 +235,62 @@ def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, secon f"commands directory {_integration_commands_dir(first)!r}" ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + @pytest.mark.parametrize( + "ordered_keys", + _multi_install_safe_orders(), + ids=["forward", "reverse"], + ) def test_safe_integrations_have_disjoint_manifests( self, tmp_path, - first, - second, + ordered_keys, ): - for initial, additional in ((first, second), (second, first)): - project_root = tmp_path / f"project-{initial}-{additional}" - project_root.mkdir() - runner = CliRunner() - - original_cwd = os.getcwd() - try: - os.chdir(project_root) - init_result = runner.invoke( - app, - [ - "init", - "--here", - "--integration", - initial, - "--script", - "sh", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - assert init_result.exit_code == 0, init_result.output + project_root = tmp_path / "project" + project_root.mkdir() + runner = CliRunner() + # Install every safe integration once per order, then assert pairwise + # manifest isolation from the resulting manifests. + original_cwd = os.getcwd() + try: + os.chdir(project_root) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + ordered_keys[0], + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + + for key in ordered_keys[1:]: install_result = runner.invoke( app, - ["integration", "install", additional, "--script", "sh"], + ["integration", "install", key, "--script", "sh"], catch_exceptions=False, ) assert install_result.exit_code == 0, install_result.output - finally: - os.chdir(original_cwd) + finally: + os.chdir(original_cwd) - initial_manifest = json.loads( + manifests = {} + for key in ordered_keys: + manifest = json.loads( ( - project_root / ".specify" / "integrations" / f"{initial}.manifest.json" + project_root / ".specify" / "integrations" / f"{key}.manifest.json" ).read_text(encoding="utf-8") ) - additional_manifest = json.loads( - ( - project_root / ".specify" / "integrations" / f"{additional}.manifest.json" - ).read_text(encoding="utf-8") - ) - - initial_files = set(initial_manifest.get("files", {})) - additional_files = set(additional_manifest.get("files", {})) + manifests[key] = set(manifest.get("files", {})) - assert initial_files.isdisjoint(additional_files), ( - f"{initial} and {additional} are declared multi-install safe but both manage " - f"these files: {sorted(initial_files & additional_files)}" + for first, second in _multi_install_safe_pairs(): + overlap = manifests[first] & manifests[second] + assert not overlap, ( + f"{first} and {second} are declared multi-install safe but both manage " + f"these files: {sorted(overlap)}" ) From ca07e0e781399920e2546c561dbdcb90c041ad30 Mon Sep 17 00:00:00 2001 From: Pascal Date: Wed, 24 Jun 2026 10:36:00 +0200 Subject: [PATCH 02/60] test: clarify disjoint-manifest order rationale and guard safe set Add a >=2 precondition, explain why two install orders are tested (manifests are order-independent; the orders only vary the init path), and build the manifest map with a comprehension. --- tests/integrations/test_registry.py | 32 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 7408c98a36..9bcb4b2c44 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -245,12 +245,25 @@ def test_safe_integrations_have_disjoint_manifests( tmp_path, ordered_keys, ): + # The pairwise disjointness contract is only meaningful with at least + # two safe integrations. Guard so a shrunken registry fails loudly here + # rather than passing vacuously (or tripping over ordered_keys[0] below). + assert len(ordered_keys) >= 2, ( + f"expected at least two multi-install-safe integrations, got {ordered_keys}" + ) + project_root = tmp_path / "project" project_root.mkdir() runner = CliRunner() - # Install every safe integration once per order, then assert pairwise - # manifest isolation from the resulting manifests. + # Install every safe integration once into a single project, then assert + # pairwise manifest isolation. Each safe integration writes only to its + # own (disjoint) directories and always records what it writes, so a + # manifest's contents are independent of install order and of which other + # integrations are co-installed. The two parametrized orders therefore + # produce the same manifests; their purpose is to route a different + # integration through the `init` path versus `integration install` + # (forward installs the first key via init, reverse the last). original_cwd = os.getcwd() try: os.chdir(project_root) @@ -279,14 +292,15 @@ def test_safe_integrations_have_disjoint_manifests( finally: os.chdir(original_cwd) - manifests = {} - for key in ordered_keys: - manifest = json.loads( - ( - project_root / ".specify" / "integrations" / f"{key}.manifest.json" - ).read_text(encoding="utf-8") + integrations_dir = project_root / ".specify" / "integrations" + manifests = { + key: set( + json.loads( + (integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8") + ).get("files", {}) ) - manifests[key] = set(manifest.get("files", {})) + for key in ordered_keys + } for first, second in _multi_install_safe_pairs(): overlap = manifests[first] & manifests[second] From 8e76ff3d5cd4796c4515bac84ed5c4479b2ab199 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Wed, 24 Jun 2026 20:05:21 +0200 Subject: [PATCH 03/60] harden: reject shell=True in run_command (#3132) run_command() forwarded shell= straight to subprocess.run, so a caller passing shell=True would invoke a shell. Reject shell=True with ValueError (keeping the parameter for signature compatibility) and drop shell= from both subprocess.run calls. Enable ruff S602/S604/S605 to flag any future shell=True reintroduction, annotate the one intentional workflow shell sink with # noqa: S602, and document the shell-step execution risk in workflows/PUBLISHING.md. --- pyproject.toml | 10 ++++++++ src/specify_cli/_utils.py | 25 ++++++++++++++++--- .../workflows/steps/shell/__init__.py | 2 +- tests/test_utils.py | 15 +++++++++++ workflows/PUBLISHING.md | 11 ++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/test_utils.py diff --git a/pyproject.toml b/pyproject.toml index 7666c1d2cb..b8975c96ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,3 +74,13 @@ precision = 2 show_missing = true skip_covered = false +[tool.ruff.lint] +# Lock in subprocess security posture: any reintroduction of shell=True +# (or os.system / popen2) must be acknowledged with an explicit `# noqa` +# pointing at the rule, making the deviation visible in review. +extend-select = [ + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S605", # start-process-with-a-shell +] + diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index d921e591d9..df0b8ddec1 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -65,14 +65,31 @@ def dump_frontmatter(data: dict[str, Any]) -> str: return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip() -def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None: - """Run a shell command and optionally capture output.""" +def run_command( + cmd: list[str], + check_return: bool = True, + capture: bool = False, + shell: bool = False, +) -> str | None: + """Run a command without invoking a shell and optionally capture output. + + The ``shell`` parameter is kept in the signature so existing keyword + callers (and the re-export from ``specify_cli``) don't raise ``TypeError``, + but only the default ``shell=False`` is honoured. ``shell=True`` is + rejected with ``ValueError`` rather than silently ignored, so the + unsupported mode fails loudly instead of running with a different meaning. + """ + if shell: + raise ValueError( + "run_command() does not support shell=True; pass argv as a list" + ) + try: if capture: - result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell) + result = subprocess.run(cmd, check=check_return, capture_output=True, text=True) return result.stdout.strip() else: - subprocess.run(cmd, check=check_return, shell=shell) + subprocess.run(cmd, check=check_return) return None except subprocess.CalledProcessError as e: if check_return: diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py index 8c62e4cfa8..2a65fca444 100644 --- a/src/specify_cli/workflows/steps/shell/__init__.py +++ b/src/specify_cli/workflows/steps/shell/__init__.py @@ -31,7 +31,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: # control commands; catalog-installed workflows should be reviewed # before use (see PUBLISHING.md for security guidance). try: - proc = subprocess.run( + proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above) run_cmd, shell=True, capture_output=True, diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000..869c9ff9cc --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,15 @@ +"""Tests for specify_cli._utils.run_command.""" + +from __future__ import annotations + +import inspect + +import pytest + +from specify_cli import run_command + + +def test_run_command_rejects_shell_execution_compatibly(): + assert inspect.signature(run_command).parameters["shell"].default is False + with pytest.raises(ValueError, match="does not support shell=True"): + run_command(["echo", "blocked"], shell=True) # noqa: S604 diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index ce0d251826..0370ed09f9 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -272,6 +272,17 @@ When releasing a new version: - **Quote variables** — use proper quoting in shell commands to handle spaces - **Check exit codes** — shell step failures stop the workflow; make sure commands are robust +#### Security: shell steps execute arbitrary code + +Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or the platform shell. There is no sandbox between the step and the user's machine: a malicious or buggy `run` block can read environment variables, modify files outside the project, exfiltrate data, or escalate privileges. + +Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`: + +- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info ` only shows metadata (name, version, inputs, step IDs/types) — not the shell content that would actually execute. +- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax. +- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement. +- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time. + ### Integration Flexibility - **Set `integration` at workflow level** — use the `workflow.integration` field as the default From 034fbfcbb4c62de4f07dead3ace6b45477b81b42 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:13:44 +0500 Subject: [PATCH 04/60] fix: render valid TOML when a command body contains backslashes (#3135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_toml_command() emitted the body inside a multiline *basic* TOML string ("""..."""), which processes backslash escape sequences. A command body containing a backslash — e.g. a Windows path like C:\Users\... whose \U reads as an invalid unicode escape — therefore produced unparseable TOML ("Invalid hex value"), so the generated Gemini/Tabnine command file failed to load. A body ending in a backslash also silently ate the closing newline via TOML line-continuation. Route bodies containing a backslash to the multiline *literal* form ('''...'''), which does not process escapes, or to the escaped basic string when both triple-quote styles are present. Mirrors the escaping already done by base.py's TomlIntegration. Add tests covering a Windows path, a trailing backslash, and the backslash + both-triple-quote-styles fallback. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/agents.py | 11 ++++++++--- tests/test_extensions.py | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 28dc8037e7..da3ca49fa6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -236,9 +236,14 @@ def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> s toml_lines.append(f"# Source: {source_id}") toml_lines.append("") - # Keep TOML output valid even when body contains triple-quote delimiters. - # Prefer multiline forms, then fall back to escaped basic string. - if '"""' not in body: + # Keep TOML output valid even when body contains triple-quote delimiters + # or backslashes. Prefer multiline forms, then fall back to escaped basic + # string. A multiline *basic* string ("""...""") processes backslash escape + # sequences, so a body containing a backslash (e.g. a Windows path + # ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would + # produce unparseable TOML — route those to the *literal* form ('''...'''), + # which does not process escapes, or to the escaped basic string. + if '"""' not in body and "\\" not in body: toml_lines.append('prompt = """') toml_lines.append(body) toml_lines.append('"""') diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4cd052fd81..df32e7ecb3 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1669,6 +1669,47 @@ def test_render_toml_command_preserves_multiline_description(self): assert parsed["description"] == "first line\nsecond line\n" + def test_render_toml_command_preserves_backslashes_in_body(self): + """A backslash in the body (e.g. a Windows path) must not break TOML. + + A multiline basic string ("\"\"\"") processes backslash escapes, so + ``C:\\Users`` (``\\U``) would render as invalid TOML; the body must + round-trip with backslashes intact. + """ + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + r"Run C:\Users\dev\tool.exe then report.", + "extension:test-ext", + ) + parsed = tomllib.loads(output) # must not raise + assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report." + + def test_render_toml_command_handles_trailing_backslash(self): + """A body ending in a backslash must round-trip without corruption.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + "path ends with sep\\", + "extension:test-ext", + ) + parsed = tomllib.loads(output) + assert parsed["prompt"].strip() == "path ends with sep\\" + + def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self): + """Body with a backslash and both triple-quote styles → escaped basic string.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + body = "a \\ b\nc \"\"\" d\ne ''' f" + output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext") + parsed = tomllib.loads(output) + assert parsed["prompt"] == body + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory From 44ef11aa187021279ee99c4333971f07546740d2 Mon Sep 17 00:00:00 2001 From: Omar Date: Wed, 24 Jun 2026 14:44:34 -0400 Subject: [PATCH 05/60] feat(integrations): add omp support (#3107) * feat(integrations): add omp support * Update updated_at timestamp * refactor(integrations): delegate omp build_exec_args to base, register in issue templates Inherit MarkdownIntegration.build_exec_args so omp picks up shared CLI contract changes (requires_cli gating, extra-args ordering, --model handling) automatically; only specialize the --mode json flag. Also add Oh My Pi / omp to the issue-template agent lists so test_issue_template_agent_lists_match_runtime_integrations passes. * fix(integrations): use --print + positional prompt for omp argv OMP's CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and consumes the prompt as a positional message; the previous inherited `-p ` shape worked by accident only because `-p` ignores its next token. Build the argv explicitly with flags first and the prompt as a trailing positional, matching upstream args.ts. --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + README.md | 2 +- docs/installation.md | 3 +- docs/reference/integrations.md | 1 + docs/upgrade.md | 6 ++- integrations/catalog.json | 11 ++++- src/specify_cli/integrations/__init__.py | 2 + src/specify_cli/integrations/omp/__init__.py | 45 ++++++++++++++++++++ tests/integrations/test_integration_omp.py | 31 ++++++++++++++ tests/test_agent_config_consistency.py | 1 + 12 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/specify_cli/integrations/omp/__init__.py create mode 100644 tests/integrations/test_integration_omp.py diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index d9ed95eb55..69cfd090e6 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: value: | Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. - **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed + **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 59f7e9eaf8..227f98ae1c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -85,6 +85,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Oh My Pi - opencode - Pi Coding Agent - Qoder CLI diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index dc0e9b83c1..ca1ecb9c11 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -79,6 +79,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Oh My Pi - opencode - Pi Coding Agent - Qoder CLI diff --git a/README.md b/README.md index 15d016ef95..86d49da48f 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ specify init . --force --integration copilot specify init --here --force --integration copilot ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools diff --git a/docs/installation.md b/docs/installation.md index 3ee2f67b0e..0f4c9124ec 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,7 @@ ## Prerequisites - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) -- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) +- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) - [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_ @@ -51,6 +51,7 @@ specify init --integration gemini specify init --integration copilot specify init --integration codebuddy specify init --integration pi +specify init --integration omp ``` ### Specify Script Type (Shell vs PowerShell) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a04e9db1d9..1ec4c223f2 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -29,6 +29,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | | [opencode](https://opencode.ai/) | `opencode` | | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | | [Qoder CLI](https://qoder.com/cli) | `qodercli` | | diff --git a/docs/upgrade.md b/docs/upgrade.md index 026279e340..c28daf396a 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -308,6 +308,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur ls -la .gemini/commands/ # Gemini ls -la .cursor/skills/ # Cursor ls -la .pi/prompts/ # Pi Coding Agent + ls -la .omp/commands/ # Oh My Pi ``` 3. **Check agent-specific setup:** @@ -427,7 +428,7 @@ The `specify` CLI tool is used for: - **Upgrades:** `specify init --here --force` to update templates and commands - **Diagnostics:** `specify check` to verify tool installation -Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again. +Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again. **If your agent isn't recognizing slash commands:** @@ -442,6 +443,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s # For Pi ls -la .pi/prompts/ + + # For Oh My Pi + ls -la .omp/commands/ ``` 2. **Restart your IDE/editor completely** (not just reload window) diff --git a/integrations/catalog.json b/integrations/catalog.json index 5e6862ec1b..931df0d974 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-23T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -255,6 +255,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli"] }, + "omp": { + "id": "omp", + "name": "Oh My Pi", + "version": "1.0.0", + "description": "Oh My Pi (omp) terminal coding agent prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, "iflow": { "id": "iflow", "name": "iFlow CLI", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index fe09468a76..f394f64a20 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -70,6 +70,7 @@ def _register_builtins() -> None: from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration from .lingma import LingmaIntegration + from .omp import OmpIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration from .qodercli import QodercliIntegration @@ -108,6 +109,7 @@ def _register_builtins() -> None: _register(KimiIntegration()) _register(KiroCliIntegration()) _register(LingmaIntegration()) + _register(OmpIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) _register(QodercliIntegration()) diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py new file mode 100644 index 0000000000..73f95a4f2c --- /dev/null +++ b/src/specify_cli/integrations/omp/__init__.py @@ -0,0 +1,45 @@ +"""Oh My Pi (omp) coding agent integration.""" + +from __future__ import annotations + +from ..base import MarkdownIntegration + + +class OmpIntegration(MarkdownIntegration): + key = "omp" + config = { + "name": "Oh My Pi", + "folder": ".omp/", + "commands_subdir": "commands", + "install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent", + "requires_cli": True, + } + registrar_config = { + "dir": ".omp/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = "AGENTS.md" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Diverges from MarkdownIntegration.build_exec_args because OMP's + # CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and + # consumes the prompt as a positional argument — see args.ts in + # can1357/oh-my-pi. JSON output is selected via `--mode json`. + if not self.config or not self.config.get("requires_cli"): + return None + args = [self._resolve_executable(), "--print"] + self._apply_extra_args_env_var(args) + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--mode", "json"]) + args.append(prompt) + return args diff --git a/tests/integrations/test_integration_omp.py b/tests/integrations/test_integration_omp.py new file mode 100644 index 0000000000..f0c5efa490 --- /dev/null +++ b/tests/integrations/test_integration_omp.py @@ -0,0 +1,31 @@ +"""Tests for OmpIntegration.""" + +from specify_cli.integrations import get_integration + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestOmpIntegration(MarkdownIntegrationTests): + KEY = "omp" + FOLDER = ".omp/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".omp/commands" + CONTEXT_FILE = "AGENTS.md" + + def test_build_exec_args_uses_omp_json_mode(self): + i = get_integration(self.KEY) + + args = i.build_exec_args( + "/speckit.specify Build auth", + model="gpt-5", + ) + + assert args == [ + "omp", + "--print", + "--model", + "gpt-5", + "--mode", + "json", + "/speckit.specify Build auth", + ] diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 49e74ef5ef..82bd8be581 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -34,6 +34,7 @@ "kiro-cli", "lingma", "vibe", + "omp", "opencode", "pi", "qodercli", From 37e0e71b4ec20033832e3268a1b3d865219f6f0d Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 25 Jun 2026 00:02:41 +0500 Subject: [PATCH 06/60] fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130) The branch-name generator keeps a short (<3 char) word only when it appears in uppercase in the description, treating it as an acronym (the comment says as much). The bash script uses a case-sensitive grep for this, but the PowerShell script used -match, which is case-insensitive by default. As a result every short non-stop word was retained on PowerShell even when lowercase, so the same description produced different branch names across the two shells (e.g. 'go AI now' -> 001-go-ai-now on PS vs 001-ai-now on bash). Switch to -cmatch so the check is case-sensitive and the two shells agree. Adds parity tests covering a dropped lowercase short word and a kept uppercase acronym. --- scripts/powershell/create-new-feature.ps1 | 7 +++- tests/test_timestamp_branches.py | 46 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 8627caa6e7..12f15ba312 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -111,8 +111,11 @@ function Get-BranchName { # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { - # Keep short words if they appear as uppercase in original (likely acronyms) + } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + # Keep short words only if they appear as uppercase in original (likely + # acronyms). Use -cmatch so the comparison is case-sensitive, matching the + # bash script's case-sensitive grep; -match would be case-insensitive and + # would keep every short word. $meaningfulWords += $word } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 1856afb972..aa48a597fe 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -869,6 +869,52 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" +# ── Short-Word / Acronym Branch-Name Tests ────────────────────────────────── + + +def _branch_from_output(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + return line.split(":", 1)[1].strip() + return None + + +SHORT_WORD_CASES = [ + # description, expected branch — "go" (lowercase short word) is dropped, + # "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept. + ("go AI now", "001-ai-now"), + # A short word that is lowercase everywhere is dropped entirely. + ("go to the pub", "001-pub"), +] + + +@requires_bash +class TestShortWordRetentionBash: + """A short word is kept only when it appears in uppercase (an acronym).""" + + @pytest.mark.parametrize("description,expected", SHORT_WORD_CASES) + def test_short_word_retention(self, git_repo: Path, description: str, expected: str): + result = run_script(git_repo, "--dry-run", description) + assert result.returncode == 0, result.stderr + assert _branch_from_output(result.stdout) == expected + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available") +class TestShortWordRetentionPowerShell: + """PowerShell must match bash: a short word is kept only when uppercase. + + Regression guard for the `-match` (case-insensitive) vs `-cmatch` + (case-sensitive) divergence — with `-match`, every short non-stop word + leaked into the branch name even when it was lowercase. + """ + + @pytest.mark.parametrize("description,expected", SHORT_WORD_CASES) + def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str): + result = run_ps_script(ps_git_repo, "-DryRun", description) + assert result.returncode == 0, result.stderr + assert _branch_from_output(result.stdout) == expected + + # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── From f846d6526cebb7712e2205dd15b64245676dfc35 Mon Sep 17 00:00:00 2001 From: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:49:43 +0200 Subject: [PATCH 07/60] fix(workflows): validate requires keys and reject phantom permissions gate (#3079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(workflows): validate requires keys and reject phantom permissions gate A workflow's `requires` block was parsed but its keys were never validated, so a typo or an unsupported key was silently ignored. Most importantly, authors could write `requires.permissions.shell: true` expecting a runtime capability gate — but no such gate exists: a `shell` step always runs with the user's privileges. The declaration gave a false sense of sandboxing. `validate_workflow` now accepts only the recognised keys (`speckit_version`, `integrations`, `tools`, `mcp`) and rejects anything else, with an explicit error for `requires.permissions` pointing authors to `gate` steps for approval. Docs and the model comment are updated to state that `requires` is advisory, not a security boundary. - Reject non-mapping `requires`, unknown keys, and `requires.permissions` - Clarify workflows reference + PUBLISHING.md shell-step guidance - Tests for valid keys, non-mapping, unknown key, and permissions Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Assisted-by: AI * fix(workflows): address review feedback on requires validation Follow-up to the review on #3079: - Guard `requires` validation on `is not None` instead of truthiness so a falsy non-mapping value (e.g. `requires: []` or `requires: ''`) is reported as an error instead of being silently skipped; `requires:` (YAML null) is still treated as an omitted block. Add a regression test. - Reword the workflows security note so `requires.permissions` is shown as rejected/unsupported rather than as a valid example of `requires`. - Standardize on US spelling (`_RECOGNIZED_REQUIRES_KEYS`, "recognized") to match the surrounding code and ease searching. - Tighten the permissions-rejection test to assert on specific message markers (`requires.permissions` and the `gate` guidance) so it fails if the validation path or wording drifts. Assisted-by: AI Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com> * fix(workflows): scope requires validation to workflow keys (drop tools/mcp) tools and mcp belong to the bundle manifest requires schema (bundler/models/manifest.py, resolved in bundler/services/resolver.py), not the workflow requires validated here. Drop them from _RECOGNIZED_REQUIRES_KEYS and revert the PUBLISHING.md claim that this PR had introduced, so workflow requires only recognizes speckit_version and integrations. This keeps the existing docs accurate and resolves the inline doc-consistency review comments. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> * refactor(workflows): type WorkflowDefinition.requires as Any pre-validation self.requires holds the raw parsed value, which before validate_workflow() runs may be a non-mapping (None for a bare 'requires:', a list for 'requires: []', etc.). Annotating it dict[str, Any] was misleading for editors/type-checkers; use Any and document that validate_workflow() enforces the mapping shape. Addresses Copilot review feedback on engine.py. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> * fix(workflows): reject YAML-null requires: as a non-mapping Address Copilot review: validate requires the same way as inputs. A bare requires: parses as YAML null and was previously treated as an omitted block, which is inconsistent with inputs and lets a stray requires: line be silently ignored. Drop the is-not-None guard and check isinstance(..., dict) directly: an omitted block still defaults to {} (valid), but a present-but-non-mapping value -- YAML null, [] or '' -- is now an authoring error that surfaces. Tests: add YAML-null rejection + an omitted-is-still-valid guard test. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> --------- Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com> --- docs/reference/workflows.md | 2 + src/specify_cli/workflows/engine.py | 54 ++++++++++- tests/test_workflows.py | 142 ++++++++++++++++++++++++++++ workflows/PUBLISHING.md | 1 + 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 5f6e90d924..ffa25301e1 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -270,6 +270,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `fan-out` | Dispatch a step for each item in a list | | `fan-in` | Aggregate results from a fan-out step | +> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands. + ## Expressions Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index f463bc66c1..aff5e92e29 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -52,9 +52,18 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non if not isinstance(self.default_options, dict): self.default_options = {} - # Requirements (declared but not yet enforced at runtime; - # enforcement is a planned enhancement) - self.requires: dict[str, Any] = data.get("requires", {}) + # Advisory pre-conditions (spec-kit version / integrations a workflow + # expects). Validated by ``validate_workflow`` (recognized keys only; + # see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they + # are not a security boundary. In particular there is no + # ``requires.permissions`` capability gate: shell steps always run with + # the user's privileges. + # + # Holds the raw parsed value, so before ``validate_workflow`` runs it may + # be a non-mapping (``None`` for a bare ``requires:``, a list for + # ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]`` + # to avoid implying it is always a mapping at this point. + self.requires: Any = data.get("requires", {}) # Inputs self.inputs: dict[str, Any] = data.get("inputs", {}) @@ -87,6 +96,15 @@ def from_string(cls, content: str) -> WorkflowDefinition: # ID format: lowercase alphanumeric with hyphens _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") +# Keys accepted under a workflow's ``requires`` block: the advisory +# pre-conditions documented for workflows (``speckit_version`` and +# ``integrations``). This is the *workflow* schema only — the bundle manifest's +# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that +# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys. +# Any other key — notably ``permissions`` — is rejected by ``validate_workflow`` +# so it is never mistaken for an enforced runtime control. +_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"}) + # Valid step types (matching STEP_REGISTRY keys) def _get_valid_step_types() -> set[str]: """Return valid step types from the registry, with a built-in fallback.""" @@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"Input {input_name!r} has invalid default: {exc}" ) + # -- Requires --------------------------------------------------------- + # ``requires`` declares advisory pre-conditions (the spec-kit version and + # integrations a workflow expects). Only a fixed set of keys is recognized; + # reject anything else so authoring typos surface here instead of being + # silently ignored at runtime. In particular ``requires.permissions`` is + # rejected explicitly: it reads like a runtime capability gate, but no such + # gate exists — a ``shell`` step always runs with the user's privileges, so + # declaring it would give a false sense of sandboxing. + # + # Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is + # valid, but any present-but-non-mapping value — ``requires:`` (YAML null), + # ``requires: []`` or ``requires: ''`` — is an authoring error and must + # surface here rather than be silently ignored at runtime. + if not isinstance(definition.requires, dict): + errors.append("'requires' must be a mapping (or omitted).") + else: + for key in definition.requires: + if key == "permissions": + errors.append( + "'requires.permissions' is not a recognized or " + "enforced capability gate — shell steps always run " + "with the user's privileges. Remove it and gate " + "sensitive steps with a 'gate' step instead." + ) + elif key not in _RECOGNIZED_REQUIRES_KEYS: + errors.append( + f"Unknown 'requires' key {key!r}. Recognized keys: " + f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}." + ) + # -- Steps ------------------------------------------------------------ if not isinstance(definition.steps, list): errors.append("'steps' must be a list.") diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 512b354158..dfab0874cf 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2115,6 +2115,148 @@ def test_invalid_input_type(self): errors = validate_workflow(definition) assert any("invalid type" in e.lower() for e in errors) + def test_requires_with_recognized_keys_is_valid(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["claude", "gemini"] +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert errors == [] + + def test_requires_must_be_mapping(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: "claude" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_unknown_key_is_rejected(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + speckit_version: ">=0.7.2" + typo_key: true +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("typo_key" in e and "requires" in e for e in errors) + + def test_requires_permissions_is_rejected_as_not_enforced(self): + """A `requires.permissions` block looks like a runtime capability gate + but no such gate exists — shell steps always run with the user's + privileges. Reject it explicitly so authors are not misled into + believing the declaration sandboxes execution. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + permissions: + shell: true +steps: + - id: run + type: shell + run: "echo hi" +""") + errors = validate_workflow(definition) + # Assert on specific markers from the intended message (the offending + # key and the `gate` remediation) so the test fails if the validation + # path or wording drifts, rather than passing on any error that merely + # happens to contain "permissions" and "not". + assert any("requires.permissions" in e and "gate" in e for e in errors) + + def test_requires_empty_sequence_is_rejected_as_non_mapping(self): + """A non-mapping ``requires`` (e.g. an empty list) is an authoring + error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)`` + so ``requires: []`` surfaces instead of silently passing. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: [] +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_yaml_null_is_rejected_as_non_mapping(self): + """A bare ``requires:`` parses as YAML null. Like ``inputs``, a present + block must be a mapping, so YAML null is rejected as an authoring error + rather than being silently treated as an omitted block. (A truly + omitted ``requires`` defaults to ``{}`` and stays valid.) + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_omitted_is_valid(self): + """A workflow with no ``requires`` block at all defaults to ``{}`` and + must validate cleanly — only a present-but-non-mapping value is an + error (guards against over-correcting YAML-null rejection into also + flagging the omitted case). + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert not any("requires" in e for e in errors) + # ===== Workflow Engine Tests ===== diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index 0370ed09f9..d250545dc6 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -268,6 +268,7 @@ When releasing a new version: ### Shell Steps +- **Shell runs with the user's privileges** — a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate — there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step. - **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate - **Quote variables** — use proper quoting in shell commands to handle spaces - **Check exit codes** — shell step failures stop the workflow; make sure commands are robust From b042d2a843a9b57bd766135b5f73cf916bae5834 Mon Sep 17 00:00:00 2001 From: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:52:24 +0200 Subject: [PATCH 08/60] feat(extensions): verify catalog archive sha256 before install (#3080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions): verify catalog archive sha256 before install Extension and preset archives were downloaded over HTTPS and unpacked (with Zip-Slip protection) but their bytes were never checked against a known digest. Trust rested entirely on TLS and the integrity of the release host, so a tampered or swapped archive from a compromised third-party release would be installed silently. Maintainers do not audit extension code, so consumer-side integrity is the only available defence. Catalog entries may now pin an optional `sha256` digest. When present, the downloaded archive is verified before it is written to disk and installed; a mismatch aborts with a clear error. Entries without `sha256` keep working unchanged (a DEBUG line records that the download was unverified), so the change is backwards compatible. The check runs on both download paths (extensions and presets) via a single shared helper so the two stay in parity. - Add `verify_archive_sha256` helper in shared_infra (digest match, `sha256:` prefix, case-insensitive; DEBUG log when no digest declared) - Enforce it in ExtensionCatalog.download_extension and PresetCatalog.download_pack, before the archive is written to disk - Document the optional `sha256` field in the publishing guides - Tests: helper unit tests + matching/mismatch/no-digest on both paths Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Assisted-by: AI * fix(extensions): harden sha256 parsing and tidy download test mocks Follow-up to the review on #3080: - shared_infra.verify_archive_sha256: strip only a literal `sha256:` algorithm prefix (case-insensitive) instead of `split(':', 1)[-1]`, which silently dropped any prefix — so `md5:<64-hex>` was accepted as if it were a valid SHA-256. Validate that the declared value is exactly 64 hex characters and raise a clear error otherwise, and compare with `hmac.compare_digest` for a constant-time check. Add tests covering a malformed digest and a non-`sha256:` prefix (both previously accepted). - Download test helpers: configure the context-manager mock via `__enter__.return_value`/`__exit__.return_value` rather than assigning a `lambda s: s`, which is clearer and independent of the invocation arity. Assisted-by: AI Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com> * fix(extensions): reject a declared-but-empty sha256 instead of skipping verification verify_archive_sha256 skipped on any falsy expected value, so a present-but-empty digest (e.g. sha256: "" reached via ...get("sha256")) silently disabled the integrity check instead of surfacing the authoring error. Guard on expected is None so only an absent digest skips; blank/whitespace/bare-prefix values fall through to the 64-hex validation and are rejected. Adds a regression test. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> * docs(shared_infra): clarify _SHA256_HEX_RE accepts and normalizes uppercase The comment described the regex as matching '64 lowercase' hex characters, but verify_archive_sha256 lowercases the declared value (raw.lower()) before matching, so an uppercase digest is accepted and normalized rather than rejected. Clarify the comment to avoid misleading future readers. Addresses Copilot review feedback on shared_infra.py. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> * test(presets): cover the no-sha256 backwards-compatible path Address Copilot review: download_pack's optional sha256 verification was tested for match/mismatch but not the backwards-compatible path where a catalog entry has no sha256 (pack_info.get("sha256") is None). Add a no-sha256 test mirroring the extensions coverage so the helper never silently becomes mandatory for presets. Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> --------- Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com> Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com> --- extensions/EXTENSION-PUBLISHING-GUIDE.md | 1 + presets/PUBLISHING.md | 1 + src/specify_cli/extensions/__init__.py | 5 ++ src/specify_cli/presets/__init__.py | 5 ++ src/specify_cli/shared_infra.py | 71 ++++++++++++++++ tests/test_extensions.py | 83 +++++++++++++++++++ tests/test_presets.py | 84 +++++++++++++++++++ tests/test_shared_infra_integrity.py | 101 +++++++++++++++++++++++ 8 files changed, 351 insertions(+) create mode 100644 tests/test_shared_infra_integrity.py diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index be5b375241..13fd08b79c 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al "author": "string (required)", "version": "string (required, semver)", "download_url": "string (required, valid URL)", + "sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)", "repository": "string (required, valid URL)", "homepage": "string (optional, valid URL)", "documentation": "string (optional, valid URL)", diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index 661614e5c0..f823a6ef15 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -185,6 +185,7 @@ Edit `presets/catalog.community.json` and add your preset. "author": "Your Name", "version": "1.0.0", "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", + "sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install", "repository": "https://github.com/your-org/spec-kit-preset-your-preset", "license": "MIT", "requires": { diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3df917af2e..3dd46ee6d2 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -31,6 +31,7 @@ from .._utils import dump_frontmatter, relative_extension_path_violation from ..catalogs import CatalogEntry as BaseCatalogEntry from ..catalogs import CatalogStackBase +from ..shared_infra import verify_archive_sha256 _FALLBACK_CORE_COMMAND_NAMES = frozenset( { @@ -2621,6 +2622,10 @@ def download_extension( ) as response: zip_data = response.read() + verify_archive_sha256( + zip_data, ext_info.get("sha256"), extension_id, ExtensionError + ) + zip_path.write_bytes(zip_data) return zip_path diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 66f1bbc5e5..07e31185ec 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -31,6 +31,7 @@ from .._init_options import is_ai_skills_enabled from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter +from ..shared_infra import verify_archive_sha256 def _substitute_core_template( @@ -2505,6 +2506,10 @@ def download_pack( with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response: zip_data = response.read() + verify_archive_sha256( + zip_data, pack_info.get("sha256"), pack_id, PresetError + ) + zip_path.write_bytes(zip_data) return zip_path diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 83fa9d4205..0685b6c9bc 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -2,6 +2,9 @@ from __future__ import annotations +import hashlib +import hmac +import logging import os import re import tempfile @@ -11,6 +14,74 @@ from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest +logger = logging.getLogger(__name__) + +# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal +# characters. Callers lowercase the declared value before matching (see +# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and +# normalized rather than rejected. +_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") + + +def verify_archive_sha256( + data: bytes, + expected: str | None, + name: str, + error_cls: type[Exception], +) -> None: + """Verify downloaded archive bytes against a catalog-declared SHA-256. + + Catalog entries may pin the expected digest of their release archive in a + ``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the + downloaded bytes must match before they are written to disk and installed, + so a corrupted or tampered archive is rejected even though the transport was + HTTPS. Entries without a declared digest are accepted unchanged, keeping the + check backwards compatible. + + Args: + data: The raw downloaded archive bytes. + expected: The catalog-declared SHA-256 hex digest, or ``None``. + name: The extension/preset id, used in the error message. + error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``). + + Raises: + error_cls: If ``expected`` is provided and is not a well-formed + SHA-256 hex digest, or does not match ``data``. + """ + # Skip only when no digest is declared at all (``None``). A declared but + # empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an + # opt-out: let it fall through to the format check below so it is rejected + # rather than silently disabling verification. + if expected is None: + logger.debug( + "No sha256 declared for %r; archive integrity was not verified.", + name, + ) + return + # Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive). + # Any other prefix is part of the value and must not be silently dropped, + # otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would + # be quietly accepted as if it were a valid SHA-256. + raw = str(expected).strip() + if raw[:7].lower() == "sha256:": + raw = raw[7:].strip() + expected_hex = raw.lower() + if not _SHA256_HEX_RE.match(expected_hex): + raise error_cls( + f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal " + f"characters (optionally prefixed with 'sha256:'), got " + f"{expected!r}." + ) + actual_hex = hashlib.sha256(data).hexdigest() + # Constant-time comparison: both sides are fixed-length hex digests, so use + # ``hmac.compare_digest`` to avoid leaking information through timing. + if not hmac.compare_digest(actual_hex, expected_hex): + raise error_cls( + f"Integrity check failed for {name!r}: the catalog declares " + f"sha256 {expected_hex}, but the downloaded archive is " + f"{actual_hex}. The archive may be corrupted or tampered with." + ) + class SymlinkedSharedPathError(ValueError): """Raised when a shared infrastructure path or ancestor is a symlink. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index df32e7ecb3..b37b5350b4 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3801,6 +3801,89 @@ def fake_open(req, timeout=None): assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].get_header("Accept") == "application/octet-stream" + def _make_zip_bytes(self): + """Build a minimal valid extension ZIP in memory for download tests.""" + import zipfile + import io + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n") + return buf.getvalue() + + def _mock_response(self, data): + """Build a context-manager mock HTTP response returning ``data``.""" + from unittest.mock import MagicMock + + resp = MagicMock() + resp.read.return_value = data + # Configure the context-manager protocol explicitly so `with resp` + # yields `resp` itself, independent of how the protocol is invoked. + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + def test_download_extension_accepts_matching_sha256(self, temp_dir): + """A catalog ``sha256`` that matches the archive is accepted.""" + import hashlib + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + "sha256": hashlib.sha256(zip_bytes).hexdigest(), + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + zip_path = catalog.download_extension("test-ext", target_dir=temp_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_extension_rejects_sha256_mismatch(self, temp_dir): + """A catalog ``sha256`` that does not match the downloaded archive + aborts the install — a tampered or swapped archive is rejected. + """ + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + "sha256": "0" * 64, # deliberately wrong + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + with pytest.raises(ExtensionError, match="[Ii]ntegrity"): + catalog.download_extension("test-ext", target_dir=temp_dir) + + def test_download_extension_without_sha256_still_succeeds(self, temp_dir): + """Entries without ``sha256`` keep working (backwards compatible).""" + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + zip_path = catalog.download_extension("test-ext", target_dir=temp_dir) + + assert zip_path.read_bytes() == zip_bytes + def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch): """download_extension can use a GitHub REST release asset URL directly.""" from unittest.mock import patch, MagicMock diff --git a/tests/test_presets.py b/tests/test_presets.py index 58574bbc9c..39f2905a4b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2019,6 +2019,90 @@ def fake_open(req, timeout=None): assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].get_header("Accept") == "application/octet-stream" + def _pack_zip_and_response(self): + """Build a minimal preset ZIP and a context-manager mock response.""" + from unittest.mock import MagicMock + import io + + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + resp = MagicMock() + resp.read.return_value = zip_bytes + # Configure the context-manager protocol explicitly so `with resp` + # yields `resp` itself, independent of how the protocol is invoked. + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return zip_bytes, resp + + def test_download_pack_accepts_matching_sha256(self, project_dir): + """A catalog ``sha256`` that matches the preset archive is accepted.""" + import hashlib + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": hashlib.sha256(zip_bytes).hexdigest(), + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_pack_rejects_sha256_mismatch(self, project_dir): + """A catalog ``sha256`` that does not match the archive aborts install.""" + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + _zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": "0" * 64, # deliberately wrong + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + with pytest.raises(PresetError, match="[Ii]ntegrity"): + catalog.download_pack("test-pack", target_dir=project_dir) + + def test_download_pack_without_sha256_skips_verification(self, project_dir): + """A catalog entry with no ``sha256`` keeps working: verification is + opt-in, so the backwards-compatible path (``pack_info.get("sha256")`` + is ``None``) must download without aborting — mirrors the extensions + coverage so the helper never silently becomes mandatory for presets. + """ + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch): """download_pack can use a GitHub REST release asset URL directly.""" from unittest.mock import patch, MagicMock diff --git a/tests/test_shared_infra_integrity.py b/tests/test_shared_infra_integrity.py new file mode 100644 index 0000000000..548d2d5f0b --- /dev/null +++ b/tests/test_shared_infra_integrity.py @@ -0,0 +1,101 @@ +"""Unit tests for the shared archive-integrity helper. + +These exercise ``verify_archive_sha256`` directly (independently of the +extension/preset download paths that call it) so the digest-matching, +mismatch, normalisation and "no digest declared" behaviours are pinned in +one place. +""" + +from __future__ import annotations + +import hashlib +import logging + +import pytest + +from specify_cli.shared_infra import verify_archive_sha256 + + +class _BoomError(Exception): + """Sentinel error type used to assert the helper raises ``error_cls``.""" + + +def test_matching_digest_passes(): + """A digest that matches the data returns without raising.""" + data = b"hello-archive" + digest = hashlib.sha256(data).hexdigest() + verify_archive_sha256(data, digest, "thing", _BoomError) + + +def test_mismatch_raises_error_cls(): + """A non-matching digest raises the caller-supplied error type.""" + with pytest.raises(_BoomError, match="[Ii]ntegrity"): + verify_archive_sha256(b"data", "0" * 64, "thing", _BoomError) + + +def test_sha256_prefix_is_accepted(): + """A ``sha256:`` prefix on the expected digest is tolerated.""" + data = b"prefixed" + digest = hashlib.sha256(data).hexdigest() + verify_archive_sha256(data, f"sha256:{digest}", "thing", _BoomError) + + +def test_comparison_is_case_insensitive(): + """An upper-cased expected digest still matches the lower-case actual.""" + data = b"casing" + digest = hashlib.sha256(data).hexdigest().upper() + verify_archive_sha256(data, digest, "thing", _BoomError) + + +def test_malformed_digest_is_rejected(): + """A declared digest that is not 64 hex chars is rejected up front. + + A too-short, too-long, or non-hex value is an authoring/catalog error and + must surface clearly instead of being treated as a digest that simply does + not match the archive. + """ + for bad in ("deadbeef", "z" * 64, "0" * 63, "0" * 65): + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(b"data", bad, "thing", _BoomError) + + +def test_non_sha256_prefix_is_not_silently_stripped(): + """Only a literal ``sha256:`` prefix is stripped. + + A different algorithm prefix (e.g. ``md5:``) must not be silently dropped + and accepted as if the remaining characters were a valid SHA-256 digest; + the value is rejected as malformed. + """ + data = b"prefixed" + digest = hashlib.sha256(data).hexdigest() + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(data, f"md5:{digest}", "thing", _BoomError) + + +def test_absent_digest_skips_and_logs_debug(caplog): + """When no digest is declared the helper returns and logs at DEBUG. + + Installs stay backwards compatible (no error, no user-facing warning), + but the unverified download leaves an audit trail for operators who opt + into debug logging. + """ + with caplog.at_level(logging.DEBUG, logger="specify_cli.shared_infra"): + verify_archive_sha256(b"data", None, "thing", _BoomError) + assert any( + "not verified" in r.getMessage() and "thing" in r.getMessage() + for r in caplog.records + ) + + +def test_blank_declared_digest_is_rejected(): + """A present-but-empty ``sha256`` is an authoring error, not an opt-out. + + Catalog entries reach the helper via ``...get("sha256")``; a blank value + (``""``, whitespace, or a bare ``sha256:`` prefix) means the digest was + declared but left empty. It must surface as a malformed digest rather than + silently disabling the integrity check, which a bare ``if not expected`` + guard would have done. + """ + for blank in ("", " ", "sha256:"): + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(b"data", blank, "thing", _BoomError) From b577e6c137ee4428b2aa50535254548a2dc0e3e0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:04:32 -0500 Subject: [PATCH 09/60] chore: release 0.11.7, begin 0.11.8.dev0 development (#3154) * chore: bump version to 0.11.7 * chore: begin 0.11.8.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b31f5274..1a4f6cc991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.11.7] - 2026-06-24 + +### Changed + +- feat(extensions): verify catalog archive sha256 before install (#3080) +- fix(workflows): validate requires keys and reject phantom permissions gate (#3079) +- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130) +- feat(integrations): add omp support (#3107) +- fix: render valid TOML when a command body contains backslashes (#3135) +- harden: reject shell=True in run_command (#3132) +- docs: add monorepo guide (#3084) +- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123) +- fix: write Codex dev skills as files (#2988) +- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121) + ## [0.11.6] - 2026-06-23 ### Changed diff --git a/pyproject.toml b/pyproject.toml index b8975c96ae..0443bc2ecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.7.dev0" +version = "0.11.8.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From e5df517ddc0118e1e0f5278bf34ce79065994001 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Wed, 24 Jun 2026 22:08:16 +0200 Subject: [PATCH 10/60] ci: pin actions to commit SHAs and add shellcheck (#3126) * ci: pin actions to commit SHAs and add shellcheck Pin actions/github-script in catalog-assign.yml to a full commit SHA; all other workflows were already pinned. Add a repo-wide regression test that every workflow `uses:` ref is pinned to a 40-char commit SHA. Add a shellcheck job to lint.yml (--severity=error over scripts/bash/*.sh) and document the local command in CONTRIBUTING.md. * ci: use repo-standard actions/checkout v7.0.0 in shellcheck job * ci: shellcheck all tracked shell scripts Assisted-by: Codex (model: GPT-5, autonomous) * ci: address workflow hygiene review feedback Assisted-by: Codex (model: GPT-5, autonomous) --- .github/workflows/catalog-assign.yml | 2 +- .github/workflows/lint.yml | 12 ++++++++ CONTRIBUTING.md | 10 +++++++ tests/test_github_workflows.py | 41 ++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/test_github_workflows.py diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml index 78b4f552f3..f828794864 100644 --- a/.github/workflows/catalog-assign.yml +++ b/.github/workflows/catalog-assign.yml @@ -19,7 +19,7 @@ jobs: permissions: issues: write steps: - - uses: actions/github-script@v9 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 59a02702a1..84074b4791 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -42,3 +42,15 @@ jobs: globs: | '**/*.md' !extensions/**/*.md + + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # shellcheck is preinstalled on ubuntu-latest runners. + # Start at --severity=error to block real bugs without flagging style + # (notably SC2155). Tighten in a follow-up after cleanup. + - name: Run shellcheck on shell scripts + run: git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cf5514a0a..7cc6d28f86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,16 @@ uv pip install -e ".[test]" > `specify_cli` to this checkout's `src/`. This matches the gotcha documented in > `AGENTS.md` (Common Pitfalls). +#### Shell scripts + +```bash +git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error +``` + +The CI `lint.yml` `shellcheck` job currently reports and blocks only +error-severity findings. Warnings such as SC2155 are intentionally outside this +job until a follow-up cleanup tightens the threshold. + ### Manual testing #### Testing setup diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py new file mode 100644 index 0000000000..b6ee409fb0 --- /dev/null +++ b/tests/test_github_workflows.py @@ -0,0 +1,41 @@ +"""Static checks for repository GitHub Actions workflows.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" +# Match both the dedicated-step form (` uses: x@sha`) and the +# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml. +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P\S+)", re.MULTILINE) +PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE) + + +def test_github_actions_are_pinned_to_full_commit_shas(): + unpinned_refs = [] + + workflows = sorted( + list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml")) + ) + assert workflows + + for workflow in workflows: + workflow_text = workflow.read_text(encoding="utf-8") + for match in USES_RE.finditer(workflow_text): + uses_ref = match.group("ref") + if uses_ref.startswith(("./", "../")): + continue + if PINNED_SHA_RE.search(uses_ref): + continue + unpinned_refs.append(f"{workflow.relative_to(REPO_ROOT)}: {uses_ref}") + + assert unpinned_refs == [] + + +def test_pinned_action_ref_accepts_uppercase_hex_sha(): + assert PINNED_SHA_RE.search( + "actions/example@0123456789ABCDEF0123456789ABCDEF01234567" + ) From fdaaf18371e5da97ee799f9510ecb50438930834 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:10:02 +0500 Subject: [PATCH 11/60] fix(workflows): preserve commas inside quoted list-literal elements (#3134) * fix(workflows): preserve commas inside quoted list-literal elements The simple-expression evaluator parsed a list literal with a naive `inner.split(",")`, which splits on commas inside quoted strings (and nested brackets). So `{{ ["a, b", "c"] }}` evaluated to three items (`["a", "b", "c"]`) instead of two, silently corrupting `fan-out` `items:` and any list expression that contains a comma inside a quoted element. Split list-literal elements on top-level commas only, ignoring commas inside quotes or nested brackets, via a small `_split_top_level_commas` helper. Plain and empty lists are unchanged. Add tests covering quoted commas, nested lists, and the existing plain/empty cases. Co-Authored-By: Claude Opus 4.8 (1M context) * test(workflows): cover single-quoted and nested list literals Address review: extend the list-literal regression test to assert single-quoted elements with commas and nested lists parse correctly, alongside the existing double-quoted cases. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/expressions.py | 39 +++++++++++++++++++++++- tests/test_workflows.py | 18 +++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index ca10b24d1b..b7ed17e801 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -146,6 +146,40 @@ def _build_namespace(context: Any) -> dict[str, Any]: return ns +def _split_top_level_commas(text: str) -> list[str]: + """Split *text* on commas that are not inside quotes or nested brackets. + + Used for list-literal elements so a quoted element containing a comma + (e.g. ``["a, b", "c"]``) is not split mid-string, and nested lists/calls + (e.g. ``[[1, 2], 3]``) are kept intact. + """ + parts: list[str] = [] + buf: list[str] = [] + quote: str | None = None + depth = 0 + for ch in text: + if quote is not None: + buf.append(ch) + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + buf.append(ch) + elif ch in "([{": + depth += 1 + buf.append(ch) + elif ch in ")]}": + depth = max(0, depth - 1) + buf.append(ch) + elif ch == "," and depth == 0: + parts.append("".join(buf)) + buf = [] + else: + buf.append(ch) + parts.append("".join(buf)) + return parts + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -291,7 +325,10 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: inner = expr[1:-1].strip() if not inner: return [] - items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")] + items = [ + _evaluate_simple_expression(i.strip(), namespace) + for i in _split_top_level_commas(inner) + ] return items # Variable reference (dot-path) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index dfab0874cf..5bbc9b6e53 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -268,6 +268,24 @@ def test_boolean_or(self): ctx = StepContext(inputs={"a": False, "b": True}) assert evaluate_expression("{{ inputs.a or inputs.b }}", ctx) is True + def test_list_literal_preserves_quoted_commas(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + # commas inside a double-quoted element must not split it + assert evaluate_expression('{{ ["a, b", "c"] }}', ctx) == ["a, b", "c"] + assert evaluate_expression('{{ ["x, y, z"] }}', ctx) == ["x, y, z"] + # single-quoted elements are handled the same way + assert evaluate_expression("{{ ['a, b', 'c'] }}", ctx) == ["a, b", "c"] + assert evaluate_expression("{{ ['p, q, r'] }}", ctx) == ["p, q, r"] + # plain and empty lists still parse correctly + assert evaluate_expression("{{ [1, 2, 3] }}", ctx) == [1, 2, 3] + assert evaluate_expression("{{ [] }}", ctx) == [] + # nested lists (commas inside the inner brackets) stay intact + assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"] + assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]] + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext From 5404f7ee1c55ddad7887b83a26a83f0c2dc95c6c Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:16:36 +0500 Subject: [PATCH 12/60] docs: run /speckit.checklist after /speckit.plan in quickstart (#3108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: run /speckit.checklist after /speckit.plan in quickstart The quickstart workflow showed /speckit.checklist before /speckit.plan, contradicting the CLI next-steps text (commands/init.py), which lists the checklist as running after the plan. Per the maintainer on #2816 — "the docs were actually wrong here ... checklists are meant for after plan" — align the docs to the CLI: move /speckit.checklist after /speckit.plan in the workflow diagram, the prose, and both walkthrough step sequences. Docs-only; no behavior change. Closes #2606 Co-Authored-By: Claude Opus 4.8 (1M context) * docs: reword checklist as generating quality checklists, not validating directly Address review: /speckit.checklist generates quality checklists (which then validate the requirements) rather than validating directly, matching the CLI/README phrasing. Preserves the after-plan ordering. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: align checklist wording with CLI next-steps phrasing Address review: state the checklist's purpose (validate requirements completeness, clarity, and consistency) and anchor it to /speckit.plan as the CLI does, use the plural 'quality checklists', and reword the Taskify step so the spec is validated using the generated checklists. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/quickstart.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index 9479bbd282..964c1f1da4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,10 +13,10 @@ This guide will help you get started with Spec-Driven Development using Spec Kit After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates: ```text -/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement +/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement ``` -Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. +Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. ### Step 1: Install Specify @@ -75,12 +75,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init Date: Wed, 24 Jun 2026 15:17:04 -0500 Subject: [PATCH 13/60] [extension] Add Golden Demo extension to community catalog (#3151) * Add Golden Demo extension to community catalog Add golden-demo extension submitted by @jasstt to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3127 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove empty changelog field from golden-demo catalog entry Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index b30d796252..856fabbb6c 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -56,6 +56,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | +| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | | Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e72b5dc517..4ccaeff56d 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-23T00:00:00Z", + "updated_at": "2026-06-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1327,6 +1327,39 @@ "created_at": "2026-04-12T15:30:00Z", "updated_at": "2026-04-13T14:39:00Z" }, + "golden-demo": { + "name": "Golden Demo", + "id": "golden-demo", + "description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.", + "author": "jasstt", + "version": "0.1.1", + "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/jasstt/spec-kit-golden-demo", + "homepage": "https://github.com/jasstt/spec-kit-golden-demo", + "documentation": "https://github.com/jasstt/spec-kit-golden-demo", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 2 + }, + "tags": [ + "testing", + "drift-detection", + "behavioral-oracle", + "tdd", + "quality" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-24T00:00:00Z", + "updated_at": "2026-06-24T00:00:00Z" + }, "harness": { "name": "Research Harness", "id": "harness", From dc840f07d03baca1e07d2364ea682aa028b18204 Mon Sep 17 00:00:00 2001 From: meymchen <86772442+meymchen@users.noreply.github.com> Date: Thu, 25 Jun 2026 04:22:08 +0800 Subject: [PATCH 14/60] feat(integration): update Kimi integration for Kimi Code CLI (#2979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integration): update Kimi integration for Kimi Code CLI Update the Kimi integration to target the new Kimi Code CLI (MoonshotAI/kimi-code) layout: - Change skills directory from .kimi/skills/ to .kimi-code/skills/ - Change context file from KIMI.md to AGENTS.md - Extend --migrate-legacy to move old .kimi/skills/ installs and migrate KIMI.md user content to AGENTS.md - Clean up leftover legacy .kimi/skills/ directories on teardown - Update devcontainer installer to @moonshot-ai/kimi-code - Update docs and tests Relates to #1532 * fix(integration): align Kimi dispatch and harden legacy migration - Override build_command_invocation to emit /skill:speckit- so dispatched commands match Kimi Code CLI's native slash syntax. - Skip symlinked .kimi/skills directories during legacy migration and teardown to avoid operating on files outside the project. - Remove kimi from the multi-install-safe integrations table. - Add tests for command invocation and symlink safety. * fix(integration): resolve custom context markers in Kimi legacy migration Use IntegrationBase._resolve_context_markers() when migrating legacy KIMI.md content so that projects with customized context_markers in .specify/extensions/agent-context/agent-context-config.yml have the managed section stripped with the correct markers instead of the hard-coded defaults. Adds a test verifying custom markers are respected during --migrate-legacy. * fix(integration): harden Kimi legacy migration against symlinked paths * fix(kimi): guard symlinked SKILL.md during migration and teardown * docs(kimi): mention KIMI.md→AGENTS.md migration in --migrate-legacy help The --migrate-legacy help text listed only the skills directory move and dotted→hyphenated renaming, but the flag also migrates KIMI.md user content into AGENTS.md. Align the help with the actual behavior, docs, and tests. Co-Authored-By: Claude Opus 4.8 * fix(kimi): validate legacy migration destination; clarify docstrings Address Copilot review feedback on PR #2979: - setup(): gate skills migration on _is_safe_legacy_dir(new_skills_dir) as well as the source. base setup() already rejects a destination that escapes the project root, but an in-tree symlinked .kimi-code/skills (e.g. -> .) could still misdirect the move; this gives the destination the same symlink-component protection as the source. - _migrate_legacy_kimi_dotted_skills: rewrite docstring as a compatibility shim describing same-path delegation to _migrate_legacy_kimi_skills_dir. - test_presets: clarify that the dotted-skill test exercises legacy naming under the current .kimi-code/ base, not the legacy .kimi/ location. Co-Authored-By: Claude Opus 4.8 * fix(kimi): harden legacy KIMI.md→AGENTS.md context migration - Skip context-file migration when the agent-context extension is disabled, matching upsert/remove_context_section opt-out behavior so an opted-out project's KIMI.md/AGENTS.md are left untouched. - Safely skip (instead of raising) on filesystem edge cases: unreadable or non-UTF-8 KIMI.md, and AGENTS.md existing as a non-file/unwritable. - Refuse to migrate a corrupted managed section (single marker, or end before start) so a partial managed block is never copied into AGENTS.md; KIMI.md is preserved for manual repair. Add regression tests for all three cases. Co-Authored-By: Claude Opus 4.8 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Approve fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore(kimi): revert CHANGELOG.md edit (auto-generated) The CHANGELOG is generated from merged PR titles, so a hand-written entry is redundant; it was also placed under the already-released 0.10.2 section, which would make those release notes historically inaccurate. Revert to match main per maintainer feedback. Co-Authored-By: Claude Opus 4.8 * test(kimi): skip symlink-safety tests when symlinks are unavailable The Kimi legacy-migration safety tests create symlinks to assert that migration/teardown never follow them out of the project. Symlink creation fails on Windows without the create-symlink privilege and in some restricted CI sandboxes, so these tests errored during setup instead of skipping. Wrap every symlink_to() call in a shared _symlink_or_skip() helper that pytest.skip()s on OSError/NotImplementedError, matching the guard pattern already used by one of these tests. Verified on Windows: the 6 symlink tests now skip cleanly (51 passed, 6 skipped) instead of erroring. Co-Authored-By: Claude Opus 4.8 * fix(kimi): reject symlinked skills destination before install Add a destination symlink pre-check in KimiIntegration.setup() before super().setup() writes any SKILL.md. The base class only rejects a destination that escapes project_root after resolve(), so an in-tree symlinked .kimi-code/.kimi-code/skills (e.g. `-> .`) would still misdirect writes into an unintended in-tree location (./skills/). Extract the symlink-component walk into a shared _has_symlinked_component() helper and reuse it from _is_safe_legacy_dir(). Add a regression test. Also clarify that --migrate-legacy only migrates KIMI.md -> AGENTS.md when the agent-context extension is enabled, in the CLI help text and the integration docs. Co-Authored-By: Claude Opus 4.8 * Refactor formatting and simplify logic in Kimi integration * fix(kimi): reject symlinked target dir during legacy skills migration When the migration destination already exists, guard against a symlinked (or non-directory) target_dir before comparing SKILL.md bytes, so the comparison never follows a link outside the project root. Also skip a missing/non-file target SKILL.md explicitly. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .devcontainer/post-create.sh | 4 +- docs/reference/integrations.md | 5 +- src/specify_cli/integrations/kimi/__init__.py | 440 +++++++++++++++-- tests/integrations/test_integration_kimi.py | 460 +++++++++++++++++- .../test_integration_subcommand.py | 2 +- tests/test_agent_config_consistency.py | 6 +- tests/test_extensions.py | 2 +- tests/test_presets.py | 20 +- 8 files changed, 875 insertions(+), 64 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4dd17294e7..c1dbdd9458 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -88,9 +88,9 @@ fi run_command "$kiro_binary --help > /dev/null" echo "✅ Done" -echo -e "\n🤖 Installing Kimi CLI..." +echo -e "\n🤖 Installing Kimi Code CLI..." # https://code.kimi.com -run_command "pipx install kimi-cli" +run_command "npm install -g @moonshot-ai/kimi-code@latest" echo "✅ Done" echo -e "\n🤖 Installing CodeBuddy CLI..." diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 1ec4c223f2..5746382161 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -25,7 +25,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | | [Junie](https://junie.jetbrains.com/) | `junie` | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | -| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | @@ -158,7 +158,7 @@ Some integrations accept additional options via `--integration-options`: | Integration | Option | Description | | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | -| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | +| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` | Example: @@ -192,7 +192,6 @@ The currently declared multi-install safe integrations are: | `iflow` | `.iflow/commands`, `IFLOW.md` | | `junie` | `.junie/commands`, `.junie/AGENTS.md` | | `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` | -| `kimi` | `.kimi/skills`, `KIMI.md` | | `qodercli` | `.qoder/commands`, `QODER.md` | | `qwen` | `.qwen/commands`, `QWEN.md` | | `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` | diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..9c28855c02 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -1,11 +1,13 @@ """Kimi Code integration — skills-based agent (Moonshot AI). -Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +Kimi uses the ``.kimi-code/skills/speckit-/SKILL.md`` layout with ``/skill:speckit-`` invocation syntax. -Includes legacy migration logic for projects initialised before Kimi -moved from dotted skill directories (``speckit.xxx``) to hyphenated -(``speckit-xxx``). +Legacy migration covers projects created before Kimi Code CLI moved to +this layout and handles two distinct changes: the directory move from +``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md`` +context file), and the dotted-to-hyphenated skill naming +(``speckit.xxx`` → ``speckit-xxx``). """ from __future__ import annotations @@ -14,7 +16,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationOption, SkillsIntegration +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -24,19 +26,43 @@ class KimiIntegration(SkillsIntegration): key = "kimi" config = { "name": "Kimi Code", - "folder": ".kimi/", + "folder": ".kimi-code/", "commands_subdir": "skills", "install_url": "https://code.kimi.com/", "requires_cli": True, } registrar_config = { - "dir": ".kimi/skills", + "dir": ".kimi-code/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "KIMI.md" - multi_install_safe = True + context_file = "AGENTS.md" + multi_install_safe = False + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build Kimi's native skill invocation: ``/skill:speckit-``. + + Kimi Code CLI invokes installed skills with a ``/skill:`` + slash command (e.g. ``/skill:speckit-plan``), not the bare + ``/speckit-`` form produced by the generic skills base + class. Overriding here keeps ``dispatch_command()`` and workflow + command steps aligned with the ``/skill:`` guidance shown at init + time and in rendered hook invocations. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit.") :] + + invocation = "/skill:speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + + def post_process_skill_content(self, content: str) -> str: + """Ensure in-skill cross-command references use Kimi's `/skill:` syntax.""" + content = super().post_process_skill_content(content) + return content.replace("/speckit-", "/skill:speckit-") @classmethod def options(cls) -> list[IntegrationOption]: @@ -51,7 +77,12 @@ def options(cls) -> list[IntegrationOption]: "--migrate-legacy", is_flag=True, default=False, - help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + help=( + "Migrate legacy Kimi installations: " + ".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, " + "and (when the agent-context extension is enabled) " + "KIMI.md user content → AGENTS.md" + ), ), ] @@ -62,64 +93,397 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install skills with optional legacy dotted-name migration.""" + """Install skills with optional legacy migration.""" parsed_options = parsed_options or {} - # Run base setup first so hyphenated targets (speckit-*) exist, - # then migrate/clean legacy dotted dirs without risking user content loss. + # Refuse a symlinked destination before any writes occur. base + # setup() only rejects a destination that *escapes* project_root + # after resolve(), so an in-tree symlinked ``.kimi-code`` / + # ``.kimi-code/skills`` (e.g. ``-> .``) would still pass that check + # and misdirect the SKILL.md writes into an unintended in-tree + # location (e.g. ``./skills/``). Reject any symlinked destination + # component up front so this never happens. + new_skills_dir = self.skills_dest(project_root) + if _has_symlinked_component(new_skills_dir, project_root): + raise ValueError( + f"Skills destination {new_skills_dir} contains a symlinked " + f"path component; refusing to install into it." + ) + + # Run base setup first so new-path targets (speckit-*) exist, + # then migrate/clean legacy dirs without risking user content loss. created = super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) if parsed_options.get("migrate_legacy", False): - skills_dir = self.skills_dest(project_root) - if skills_dir.is_dir(): - _migrate_legacy_kimi_dotted_skills(skills_dir) + old_skills_dir = project_root / ".kimi" / "skills" + # Validate both endpoints. base setup() already rejects a + # destination that *escapes* the project root, but an in-tree + # symlinked ``.kimi-code``/``.kimi-code/skills`` (e.g. ``-> .``) + # would still misdirect the move; ``_is_safe_legacy_dir`` rejects + # any symlinked component, giving the destination the same + # protection as the source. + if _is_safe_legacy_dir(old_skills_dir, project_root) and ( + _is_safe_legacy_dir(new_skills_dir, project_root) + ): + _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) + # Mirror upsert/remove_context_section: a disabled agent-context + # extension is a full opt-out, so skip the KIMI.md → AGENTS.md + # migration entirely and leave both files untouched. + if self._agent_context_extension_enabled(project_root): + marker_start, marker_end = self._resolve_context_markers(project_root) + _migrate_legacy_kimi_context_file( + project_root, marker_start=marker_start, marker_end=marker_end + ) return created + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall Kimi skills and remove leftover legacy directories.""" + removed, skipped = super().teardown(project_root, manifest, force=force) + + old_skills_dir = project_root / ".kimi" / "skills" + if _is_safe_legacy_dir(old_skills_dir, project_root): + legacy_dirs = sorted( + [*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")] + ) + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): + continue + if _is_speckit_generated_skill(legacy_dir): + try: + shutil.rmtree(legacy_dir) + removed.append(legacy_dir) + except OSError: + skipped.append(legacy_dir) + + try: + old_skills_dir.rmdir() + except OSError: + pass + + return removed, skipped + + +def _has_symlinked_component(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* escapes *project_root* or any component is a symlink. + + Walks the components strictly between *project_root* and *path* + (including the final one) and reports whether any of them is a symlink. + Components that do not exist yet are not symlinks, so this safely handles + a not-yet-created destination. *project_root* itself is trusted and never + checked. A *path* outside *project_root* is treated as unsafe. + """ + try: + relative = path.relative_to(project_root) + except ValueError: + return True + current = project_root + for part in relative.parts: + current = current / part + if current.is_symlink(): + return True + return False -def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + +def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* is a real directory safely inside *project_root*. + + Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()`` + directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached + through a symlinked parent) must never be followed: doing so could + relocate or delete content living outside the project tree — or operate + on an unrelated in-tree directory (e.g. ``.kimi -> .`` makes + ``.kimi/skills`` resolve to ``./skills``). + + Checking only the fully-resolved path is insufficient, because a symlink + pointing elsewhere *inside* the project still resolves to a location under + *project_root*. We therefore reject the path when it is not a directory, + when any component between *project_root* and *path* is a symlink + (including the final component), or when the resolved path escapes the + resolved *project_root*. + """ + if not path.is_dir(): + return False + + # Reject if any path component below project_root is a symlink (or the + # path escapes project_root). We trust project_root itself, so only + # components strictly under it are checked. + if _has_symlinked_component(path, project_root): + return False + + try: + resolved = path.resolve() + root = project_root.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents + + +def _migrate_legacy_kimi_skills_dir( + old_skills_dir: Path, new_skills_dir: Path +) -> tuple[int, int]: + """Migrate skills from the legacy ``.kimi/skills/`` directory to ``.kimi-code/skills/``. + + Handles both hyphenated (``speckit-xxx``) and dotted (``speckit.xxx``) + legacy directory names. If a target already exists, the legacy dir is + only removed when its ``SKILL.md`` is byte-identical and no extra user + files are present. Returns ``(migrated_count, removed_count)``. """ - if not skills_dir.is_dir(): + if not old_skills_dir.is_dir(): return (0, 0) migrated_count = 0 removed_count = 0 - for legacy_dir in sorted(skills_dir.glob("speckit.*")): - if not legacy_dir.is_dir(): + # Process hyphenated dirs first, then dotted dirs. + legacy_dirs = sorted(old_skills_dir.glob("speckit-*")) + sorted( + old_skills_dir.glob("speckit.*") + ) + + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): continue - if not (legacy_dir / "SKILL.md").exists(): + legacy_skill = legacy_dir / "SKILL.md" + # Treat a symlinked SKILL.md as invalid: later read_bytes() would + # otherwise follow it and read content from outside the project. + if legacy_skill.is_symlink() or not legacy_skill.is_file(): continue - suffix = legacy_dir.name[len("speckit."):] - if not suffix: + target_name = _legacy_to_target_name(legacy_dir.name) + if not target_name: continue - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + target_dir = new_skills_dir / target_name + + # Skip if the legacy dir is already the target dir (same-directory call). + if legacy_dir.resolve() == target_dir.resolve(): + continue if not target_dir.exists(): + target_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(legacy_dir), str(target_dir)) migrated_count += 1 continue - # Target exists — only remove legacy if SKILL.md is identical + # Target exists — only remove legacy if SKILL.md is identical. + # Skip when the target dir or its SKILL.md is a symlink (or the dir is + # not a real directory) so the byte comparison never follows a link + # outside the project. (legacy_skill is already guaranteed to be a real + # file by the guard above.) + if target_dir.is_symlink() or not target_dir.is_dir(): + continue target_skill = target_dir / "SKILL.md" - legacy_skill = legacy_dir / "SKILL.md" - if target_skill.is_file(): - try: - if target_skill.read_bytes() == legacy_skill.read_bytes(): - has_extra = any( - child.name != "SKILL.md" for child in legacy_dir.iterdir() - ) - if not has_extra: - shutil.rmtree(legacy_dir) - removed_count += 1 - except OSError: - pass + if target_skill.is_symlink() or not target_skill.is_file(): + continue + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + has_extra = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + pass + + # Remove the legacy skills directory if it is now empty. + try: + old_skills_dir.rmdir() + except OSError: + pass return (migrated_count, removed_count) + + +def _legacy_to_target_name(legacy_name: str) -> str: + """Convert a legacy skill directory name to the modern hyphenated form.""" + if legacy_name.startswith("speckit-"): + return legacy_name + if legacy_name.startswith("speckit."): + suffix = legacy_name[len("speckit.") :] + if suffix: + return f"speckit-{suffix.replace('.', '-')}" + return "" + + +def _is_speckit_generated_skill(skill_dir: Path) -> bool: + """Return True when *skill_dir* contains a Speckit-generated SKILL.md. + + Uses the ``metadata.author`` and ``metadata.source`` fields written by + ``SkillsIntegration.setup()`` to avoid deleting user-authored skills. + """ + skill_file = skill_dir / "SKILL.md" + # A symlinked SKILL.md is never treated as Speckit-generated, so teardown + # cleanup never follows it to read frontmatter from outside the project. + if skill_file.is_symlink() or not skill_file.is_file(): + return False + + try: + content = skill_file.read_text(encoding="utf-8") + except OSError: + return False + + if not content.startswith("---"): + return False + + parts = content.split("---", 2) + if len(parts) < 3: + return False + + try: + import yaml + + frontmatter = yaml.safe_load(parts[1]) + except Exception: + return False + + if not isinstance(frontmatter, dict): + return False + + metadata = frontmatter.get("metadata", {}) + if not isinstance(metadata, dict): + return False + + author = metadata.get("author", "") + source = metadata.get("source", "") + return ( + author == "github-spec-kit" + and isinstance(source, str) + and source.startswith("templates/commands/") + ) + + +def _migrate_legacy_kimi_context_file( + project_root: Path, + *, + marker_start: str = IntegrationBase.CONTEXT_MARKER_START, + marker_end: str = IntegrationBase.CONTEXT_MARKER_END, +) -> bool: + """Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``. + + The Speckit managed section is stripped from ``KIMI.md`` before the + remaining content is appended to ``AGENTS.md``. The legacy file is + deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was + migrated, ``False`` when the migration is skipped. + + The migration is skipped (leaving ``KIMI.md`` untouched) in any of these + cases, so a best-effort legacy cleanup never aborts ``setup()`` or + corrupts ``AGENTS.md``: + + - ``KIMI.md`` is a symlink, missing, or unreadable (its target could be + read from outside the project, or it may not be valid UTF-8). + - ``AGENTS.md`` is a symlink (it could redirect the write to a file + outside the project root), exists as a non-file (e.g. a directory), + or is unreadable/unwritable. + - ``KIMI.md`` has a corrupted managed section — only one marker is + present, or the end marker precedes the start. Stripping is only done + when both markers are present and well-ordered, so a partial managed + block is never copied into ``AGENTS.md``; the user repairs it manually. + """ + legacy_path = project_root / "KIMI.md" + if legacy_path.is_symlink() or not legacy_path.is_file(): + return False + + target_path = project_root / "AGENTS.md" + # Never follow a symlinked target, and never treat an existing non-file + # (e.g. a directory) as a writable context file. + if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): + return False + + try: + content = legacy_path.read_text(encoding="utf-8-sig") + except (OSError, UnicodeDecodeError): + return False + + marker_pairs = [(marker_start, marker_end)] + default_pair = ( + IntegrationBase.CONTEXT_MARKER_START, + IntegrationBase.CONTEXT_MARKER_END, + ) + if default_pair not in marker_pairs: + marker_pairs.append(default_pair) + + start_idx = -1 + end_idx = -1 + has_start = False + has_end = False + for s, e in marker_pairs: + s_idx = content.find(s) + e_idx = content.find(e, s_idx if s_idx != -1 else 0) + has_s = s_idx != -1 + has_e = e_idx != -1 + if not has_s and not has_e: + continue + # Refuse to migrate a corrupted managed section: exactly one marker, or + # an end marker that does not follow the start. + if has_s != has_e or e_idx <= s_idx: + return False + marker_start, marker_end = s, e + start_idx, end_idx = s_idx, e_idx + has_start = True + has_end = True + break + if has_start and has_end: + removal_start = start_idx + removal_end = end_idx + len(marker_end) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + content = content[:removal_start] + content[removal_end:] + + user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() + if not user_content: + legacy_path.unlink() + return True + + try: + if target_path.is_file(): + existing = target_path.read_text(encoding="utf-8-sig") + existing = existing.replace("\r\n", "\n").replace("\r", "\n") + if not existing.endswith("\n"): + existing += "\n" + new_content = existing + "\n" + user_content + "\n" + else: + new_content = user_content + "\n" + + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(new_content.encode("utf-8")) + except (OSError, UnicodeDecodeError): + return False + + legacy_path.unlink() + return True + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Compatibility shim — migrate legacy dotted skill dirs in place. + + .. deprecated:: + Kept for direct callers/tests. New code should call + ``_migrate_legacy_kimi_skills_dir`` directly. + + Delegates to ``_migrate_legacy_kimi_skills_dir`` with *skills_dir* as both + source and destination, so it processes every ``speckit-*`` and + ``speckit.*`` entry under *skills_dir*. Because the two paths are + identical, the same-path short-circuit there skips any directory whose + target resolves to itself; in practice this renames dotted + ``speckit.xxx`` dirs to hyphenated ``speckit-xxx`` in place and never + moves content outside *skills_dir*. + + Returns ``(migrated_count, removed_count)``. + """ + return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 112baf0301..2f752f66e1 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -1,18 +1,42 @@ """Tests for KimiIntegration — skills integration with legacy migration.""" +from pathlib import Path + +import pytest + from specify_cli.integrations import get_integration -from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.kimi import ( + _migrate_legacy_kimi_context_file, + _migrate_legacy_kimi_dotted_skills, + _migrate_legacy_kimi_skills_dir, +) from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests +def _symlink_or_skip( + link: Path, target: Path, *, target_is_directory: bool = False +) -> None: + """Create *link* pointing at *target*, skipping the test if unsupported. + + Symlink creation fails on Windows without the create-symlink privilege and + in some restricted CI sandboxes. The symlink-safety tests below assert + behavior that only matters when symlinks exist, so skip (rather than error) + when the platform cannot create them. + """ + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + class TestKimiIntegration(SkillsIntegrationTests): KEY = "kimi" - FOLDER = ".kimi/" + FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".kimi/skills" - CONTEXT_FILE = "KIMI.md" + REGISTRAR_DIR = ".kimi-code/skills" + CONTEXT_FILE = "AGENTS.md" class TestKimiOptions: @@ -103,12 +127,32 @@ def test_nonexistent_dir_returns_zeros(self, tmp_path): assert migrated == 0 assert removed == 0 + def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path): + """--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills.""" + i = get_integration("kimi") + + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit-oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert not old_skills_dir.exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + def test_setup_with_migrate_legacy_option(self, tmp_path): """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" i = get_integration("kimi") - skills_dir = tmp_path / ".kimi" / "skills" - legacy = skills_dir / "speckit.oldcmd" + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit.oldcmd" legacy.mkdir(parents=True) (legacy / "SKILL.md").write_text("# Legacy\n") @@ -116,9 +160,409 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) assert not legacy.exists() - assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() # New skills from templates should also exist - assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiContextFileMigration: + """KIMI.md → AGENTS.md migration under --migrate-legacy.""" + + def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path): + i = get_integration("kimi") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "\n" + "only managed section\n" + "\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert (tmp_path / "AGENTS.md").exists() + assert not kimi_md.exists() + + def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): + i = get_integration("kimi") + + agents_md = tmp_path / "AGENTS.md" + agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n") + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + content = agents_md.read_text(encoding="utf-8") + assert "Existing note." in content + assert "Kimi-specific note." in content + assert "" in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): + """Migration respects context_markers from agent-context extension config.""" + i = get_integration("kimi") + + config_dir = tmp_path / ".specify" / "extensions" / "agent-context" + config_dir.mkdir(parents=True) + (config_dir / "agent-context-config.yml").write_text( + "context_file: AGENTS.md\n" + "context_markers:\n" + " start: ''\n" + " end: ''\n" + ) + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text( + "# Project context\n\n" + "\n" + "old managed section\n" + "\n\n" + "Keep this user note.\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + agents_md = tmp_path / "AGENTS.md" + assert agents_md.exists() + content = agents_md.read_text(encoding="utf-8") + assert "Keep this user note." in content + assert "old managed section" not in content + assert "" in content + assert "" in content + assert "" not in content + assert not kimi_md.exists() + + def test_setup_migrate_legacy_skipped_when_agent_context_disabled( + self, tmp_path + ): + """A disabled agent-context extension opts out of KIMI.md migration.""" + i = get_integration("kimi") + + registry = tmp_path / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True) + registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}') + + kimi_md = tmp_path / "KIMI.md" + kimi_md.write_text("# Kimi context\n\nKeep this user note.\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + # Opted-out project: KIMI.md is left untouched and AGENTS.md is not + # created/modified by the migration. + assert kimi_md.is_file() + assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n" + assert not (tmp_path / "AGENTS.md").exists() + + def test_context_migration_skips_corrupted_single_marker(self, tmp_path): + """A KIMI.md with only a start marker is left untouched (no leak).""" + project = tmp_path + kimi_md = project / "KIMI.md" + kimi_md.write_text( + "# Notes\n\n" + "\n" + "dangling managed content\n" + ) + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # KIMI.md untouched; managed block never copied into AGENTS.md. + assert kimi_md.is_file() + assert "dangling managed content" in kimi_md.read_text() + assert not (project / "AGENTS.md").exists() + + def test_context_migration_skips_unreadable_kimi_md(self, tmp_path): + """Non-UTF-8 KIMI.md is skipped instead of raising during setup.""" + project = tmp_path + kimi_md = project / "KIMI.md" + kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n") + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + assert kimi_md.is_file() + assert not (project / "AGENTS.md").exists() + + def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path): + """An AGENTS.md that exists as a directory is skipped, not written to.""" + project = tmp_path + (project / "AGENTS.md").mkdir() + kimi_md = project / "KIMI.md" + kimi_md.write_text("# Notes\n\nKeep this.\n") + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # KIMI.md is preserved and the directory is untouched. + assert kimi_md.is_file() + assert (project / "AGENTS.md").is_dir() + + +class TestKimiTeardownLegacyCleanup: + """teardown() removes leftover legacy .kimi/skills/ directories.""" + + def test_teardown_removes_legacy_speckit_skills(self, tmp_path): + i = get_integration("kimi") + + legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md" + legacy_skill.parent.mkdir(parents=True) + legacy_skill.write_text( + "---\n" + "name: \"speckit-plan\"\n" + "description: \"Plan workflow\"\n" + "metadata:\n" + " author: \"github-spec-kit\"\n" + " source: \"templates/commands/plan.md\"\n" + "---\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert not legacy_skill.exists() + assert not (tmp_path / ".kimi" / "skills").exists() + + def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path): + i = get_integration("kimi") + + user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md" + user_skill.parent.mkdir(parents=True) + user_skill.write_text("# My custom skill\n") + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert user_skill.exists() + + +class TestKimiCommandInvocation: + """Kimi dispatch must use the native ``/skill:`` slash command.""" + + def test_build_command_invocation_uses_skill_prefix(self): + i = get_integration("kimi") + assert i.build_command_invocation("specify") == "/skill:speckit-specify" + assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan" + + def test_build_command_invocation_dotted_extension(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("speckit.git.commit") + == "/skill:speckit-git-commit" + ) + + def test_build_command_invocation_appends_args(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("specify", "my feature") + == "/skill:speckit-specify my feature" + ) + + +class TestKimiLegacySymlinkSafety: + """Legacy migration/cleanup must not follow symlinks out of the project.""" + + def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path): + # An attacker-controlled directory outside the project root. Use a + # non-template skill name so a successful migration would be visible + # (the bundled templates never create "speckit-evillegacy"). + outside = tmp_path / "outside" + (outside / "speckit-evillegacy").mkdir(parents=True) + (outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + # .kimi/skills is a symlink to the outside directory. + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # Outside content must be untouched (not moved into .kimi-code). + assert (outside / "speckit-evillegacy" / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + keep = outside / "keep.txt" + keep.write_text("important\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The symlink target and its contents must survive teardown. + assert keep.exists() + + def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path): + # `.kimi` is itself a symlink to the project root, so `.kimi/skills` + # resolves to `./skills` — an unrelated in-tree directory. Even though + # the resolved path stays inside the project, migration must not + # operate on it because a path component is a symlink. + project = tmp_path / "project" + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text("# unrelated\n") + # .kimi -> project root, so .kimi/skills == ./skills. + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # The unrelated ./skills content must be untouched. + assert (unrelated / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Looks Speckit-generated, so only the symlink check protects it. + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text( + "---\nmetadata:\n author: github-spec-kit\n---\n# x\n" + ) + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The unrelated ./skills content must survive teardown. + assert (unrelated / "SKILL.md").exists() + + def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path): + # `.kimi-code` is a symlink to the project root, so the skills + # destination `.kimi-code/skills` resolves to `./skills` — an + # unintended in-tree location. base setup() only rejects a + # destination that escapes the project root, so without the + # pre-check it would write SKILL.md files into `./skills`. setup() + # must refuse before any write occurs. + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / ".kimi-code", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + with pytest.raises(ValueError, match="symlinked"): + i.setup(project, m) + + # Nothing was written into the unintended `./skills` location. + assert not (project / "skills").exists() + + def test_migrate_skips_symlinked_target_dir(self, tmp_path): + # The destination `.kimi-code/skills/speckit-foo` already exists but is + # a symlink to a directory outside the project. Migration compares + # SKILL.md bytes to decide whether to drop the legacy copy; it must not + # follow the symlinked target dir to read SKILL.md from outside. + outside = tmp_path / "outside" + outside.mkdir() + (outside / "SKILL.md").write_text("# shared\n") + + project = tmp_path / "project" + legacy = project / ".kimi" / "skills" / "speckit-foo" + legacy.mkdir(parents=True) + # Identical bytes: without the symlink guard the legacy dir would be + # removed after following the link out of the project. + (legacy / "SKILL.md").write_text("# shared\n") + + target = project / ".kimi-code" / "skills" / "speckit-foo" + target.parent.mkdir(parents=True) + _symlink_or_skip(target, outside, target_is_directory=True) + + _migrate_legacy_kimi_skills_dir( + project / ".kimi" / "skills", project / ".kimi-code" / "skills" + ) + + # Legacy copy is preserved (migration refused to follow the symlink), + # and the outside target is untouched. + assert (legacy / "SKILL.md").exists() + assert (outside / "SKILL.md").exists() + + def test_context_migration_does_not_write_through_symlinked_agents_md( + self, tmp_path + ): + # A sensitive file outside the project that a malicious AGENTS.md + # symlink points at. Migration must never overwrite it. + outside = tmp_path / "outside" + outside.mkdir() + secret = outside / "secret.txt" + secret.write_text("original secret\n") + + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / "AGENTS.md", secret) + (project / "KIMI.md").write_text("# Notes\n\nKeep this.\n") + + result = _migrate_legacy_kimi_context_file(project) + + # The outside file must not be overwritten through the symlink. + assert secret.read_text() == "original secret\n" + # KIMI.md is preserved so the user can migrate manually. + assert (project / "KIMI.md").is_file() + assert result is False + + def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path): + # A symlinked KIMI.md (source) must not be followed/consumed. + outside = tmp_path / "outside" + outside.mkdir() + external = outside / "external.md" + external.write_text("# external\n") + + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / "KIMI.md", external) + + result = _migrate_legacy_kimi_context_file(project) + + assert result is False + # The external file and the symlink are left intact. + assert external.read_text() == "# external\n" + assert (project / "KIMI.md").is_symlink() + assert not (project / "AGENTS.md").exists() class TestKimiNextSteps: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index c3ebb9773d..34114a564e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1812,7 +1812,7 @@ def test_switch_migrates_extension_commands(self, tmp_path): assert result.exit_code == 0, f"extension add failed: {result.output}" # Verify git extension skills exist for kimi - kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" + kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" result = _run_in_project(project, [ diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 82bd8be581..94496af5ef 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -226,17 +226,17 @@ def test_agent_config_includes_tabnine(self): def test_kimi_in_agent_config(self): """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" assert "kimi" in AGENT_CONFIG - assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/" assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" assert AGENT_CONFIG["kimi"]["requires_cli"] is True def test_kimi_in_extension_registrar(self): - """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + """Extension command registrar should include kimi using .kimi-code/skills and SKILL.md.""" cfg = CommandRegistrar.AGENT_CONFIGS assert "kimi" in cfg kimi_cfg = cfg["kimi"] - assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["dir"] == ".kimi-code/skills" assert kimi_cfg["extension"] == "/SKILL.md" def test_agent_config_includes_kimi(self): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index b37b5350b4..6b181a1204 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1937,7 +1937,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir @pytest.mark.parametrize("agent_name,skills_path", [ ("codex", ".agents/skills"), - ("kimi", ".kimi/skills"), + ("kimi", ".kimi-code/skills"), ("claude", ".claude/skills"), ("cursor-agent", ".cursor/skills"), ("trae", ".trae/skills"), diff --git a/tests/test_presets.py b/tests/test_presets.py index 39f2905a4b..ff37dd3a96 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3763,12 +3763,16 @@ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, tem assert note_file.read_text(encoding="utf-8") == "user content" def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): - """Preset overrides should still target legacy dotted Kimi skill directories.""" + """Preset overrides should still target legacy dotted-named skill dirs. + + This exercises legacy *naming* (``speckit.specify``) under the current + ``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*. + """ self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3785,10 +3789,10 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3805,7 +3809,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-new-skill" @@ -3854,9 +3858,9 @@ def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-placeholder-override" preset_dir.mkdir() From 0cde6be41b56e96badc9ba1f2dfcd0f9cf22e84d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:32:04 -0500 Subject: [PATCH 15/60] Add Spec Roadmap extension to community catalog (#3153) Add roadmap extension submitted by @srobroek to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3150 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/extensions.md | 1 + extensions/catalog.community.json | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 856fabbb6c..6b2df7a5d1 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -118,6 +118,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | | Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | +| Spec Roadmap | Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost. | `process` | Read+Write | [speckit-roadmap](https://github.com/srobroek/speckit-roadmap) | | Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4ccaeff56d..c6ed28cd43 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2995,6 +2995,40 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-20T00:00:00Z" }, + "roadmap": { + "name": "Spec Roadmap", + "id": "roadmap", + "description": "Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost.", + "author": "srobroek", + "version": "0.1.0", + "download_url": "https://github.com/srobroek/speckit-roadmap/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/srobroek/speckit-roadmap", + "homepage": "https://github.com/srobroek/speckit-roadmap", + "documentation": "https://github.com/srobroek/speckit-roadmap/blob/main/README.md", + "changelog": "https://github.com/srobroek/speckit-roadmap/blob/main/CHANGELOG.md", + "license": "Apache-2.0", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.11.6" + }, + "provides": { + "commands": 4, + "hooks": 3 + }, + "tags": [ + "roadmap", + "planning", + "governance", + "review", + "spec-alignment" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-24T00:00:00Z", + "updated_at": "2026-06-24T00:00:00Z" + }, "schedule": { "name": "Spec Kit Schedule — CP-SAT Agent Orchestrator", "id": "schedule", From d6cddd41278e6dcffc6498a50b99c48a354ec848 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:44:29 -0500 Subject: [PATCH 16/60] [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152) * Update Jira Integration (Sync Engine) extension to v0.4.0 Update jira-sync extension submitted by @ashbrener: - extensions/catalog.community.json (version, download_url, changelog, provides.commands, tags, requires.tools, updated_at) - docs/community/extensions.md community extensions table (no change needed, row already current) Closes #3149 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix review feedback: revert unrelated formatting, add bash version constraint, fix field ordering for jira-sync - Revert unrelated em-dash/arrow encoding and tools array reformatting changes across the catalog (only jira-sync changes remain) - Add version: \">=4.4\" to bash in jira-sync requires.tools - Move category and effect fields to after license and before requires to match field ordering of neighboring entries Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c6ed28cd43..64b6f8f902 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1581,25 +1581,34 @@ "id": "jira-sync", "description": "An idempotent, drift-aware, fail-closed reconcile engine that mirrors spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase).", "author": "Ash Brener", - "version": "0.2.0", - "download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.2.0.zip", + "version": "0.4.0", + "download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip", "repository": "https://github.com/ashbrener/spec-kit-jira-sync", "homepage": "https://github.com/ashbrener/spec-kit-jira-sync", "documentation": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/README.md", - "changelog": "https://github.com/ashbrener/spec-kit-jira-sync/releases", + "changelog": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/CHANGELOG.md", "license": "MIT", + "category": "integration", + "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { "name": "bash", "version": ">=4.4", "required": true }, + { "name": "git", "required": true }, + { "name": "curl", "required": true }, + { "name": "jq", "required": true }, + { "name": "gitleaks", "required": false }, + { "name": "trufflehog", "required": false } + ] }, "provides": { - "commands": 2, + "commands": 4, "hooks": 0 }, "tags": [ "issue-tracking", "jira", "tasks-sync", - "lifecycle-mirror", "reconcile", "drift-aware" ], @@ -1607,7 +1616,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-06-08T00:00:00Z", - "updated_at": "2026-06-08T00:00:00Z" + "updated_at": "2026-06-24T00:00:00Z" }, "learn": { "name": "Learning Extension", From 96039d36d2adada7f0653e3b985179ab397d581f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:06:51 -0500 Subject: [PATCH 17/60] Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104) * Require preset-usage README with Spec Kit CLI syntax in submissions Tighten the community preset submission workflow so it validates the README referenced by the documentation field rather than merely checking for a root README. The workflow now fails submissions whose linked README lacks a valid 'specify preset add ...' command and flags monorepo submissions that point documentation at a generic root README. - Add a required Documentation URL field to the preset issue template - Add validation step 2d (documentation README + CLI-syntax check) to .github/workflows/add-community-preset.md and recompile the lock file - Document the stricter usage-README requirement and reviewer content check in presets/PUBLISHING.md Closes #3103 Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align preset README docs with workflow's actual enforcement Address PR review feedback on #3104: - PUBLISHING.md: clarify that only README resolution + a valid 'specify preset add ...' command are mechanically enforced; the preset-scoped-README and minimum-structure items are reviewer expectations, not automated checks. - PUBLISHING.md: state that a missing 'specify preset add ...' command is a hard validation failure (check 2d), not just 'flagged for changes'. - preset_submission.yml: require 'specify preset add ...' (not the looser 'specify preset ...') to match the workflow validation. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten preset README validation and docs per PR review Address PR review feedback on #3104: - Workflow Step 2c: drop the generic repo-root README.md check so the README requirement is enforced exactly once, in Step 2d, against the file the documentation field points to (avoids monorepo false-positive). - Workflow Step 2d: restrict the documentation URL to GitHub-hosted README URLs (github.com/.../blob/... or raw.githubusercontent.com/...) before fetching user-provided input. - PUBLISHING.md: add the required 'id' field to the example catalog entry. - preset_submission.yml: fix the Documentation URL placeholder to match the recommended monorepo presets//README.md pattern. - Recompile add-community-preset.lock.yml (body hash only). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refine preset README validation rules per PR review Address PR review feedback on #3104: - Workflow Step 2d: broaden the documentation URL allowlist to also accept github.com/.../raw/... URLs; strip any fragment/query before fetching so the target is deterministic; clarify that a 'specify preset add --from ' command only counts when its URL matches the submitted Download URL (a different --from URL does not satisfy the requirement, though other accepted forms still can). - PUBLISHING.md: show both accepted download URL shapes (tag archive and release asset) in the README install example instead of implying only the releases/download form. - preset_submission.yml: remove the ambiguous generic 'README.md with description and usage instructions' checkbox; the linked-README requirement is the single source of truth. - Recompile add-community-preset.lock.yml (body hash only). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify install-command requirement wording per PR review Address PR review feedback on #3104: the previous 'matching the download URL' wording overstated the requirement. Only the 'specify preset add --from ' form needs an exact download-URL match; other accepted forms ('specify preset add ' / '--dev ') don't reference the download URL at all. - preset_submission.yml: reword the Documentation URL description and the Submission Requirements checkbox to reflect what's enforced vs preferred. - PUBLISHING.md: clarify the reviewer note so the exact-match rule is scoped to the --from form. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Require README.md target and fix release-ZIP wording per PR review Address PR review feedback on #3104: - Workflow Step 2d: add an explicit check that the documentation URL path ends with README.md (case-insensitive) after stripping fragment/query, so a non-README markdown file is rejected before fetching. - PUBLISHING.md: reword the release-ZIP note, which conflicted with the earlier preset structure guidance. The real requirement is that the README is reachable at the documentation URL before download; it's fine for the same file to also ship inside the release ZIP. - Recompile add-community-preset.lock.yml (body hash only). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use stable unnumbered anchor for Usage README Requirements Address PR review feedback on #3104: drop the '6.' prefix from the 'Usage README Requirements' heading so its GitHub anchor isn't tied to a section number (brittle under renumbering, and avoids confusion with the top-level 'Best Practices' TOC item). Update the Prerequisites cross-link to the new #usage-readme-requirements anchor. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align README requirement wording with enforced checks per PR review Address PR review feedback on #3104: - PUBLISHING.md: the 'mechanically enforces' summary now lists all Step 2d checks (GitHub-hosted URL, path ends with README.md, resolves, contains a valid 'specify preset add ...' command), instead of only two. - PUBLISHING.md: reword the PR checklist item so a usage README + install command is the requirement, with preset-scoped README recommended for monorepos (matches the workflow's flag-not-fail behavior). - preset_submission.yml: include the full 'specify preset add' prefix on the --dev and --from forms in the field description and checklist so submitters copy the exact syntax. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix grammar in Usage README Requirements intro Address PR review feedback on #3104: remove the incorrect colon after 'the linked README' so the sentence reads naturally. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid lossy raw URL rewrite for slash-containing refs per PR review Address PR review feedback on #3104: rewriting documentation URLs into the raw.githubusercontent.com//// form can't reliably represent refs that contain slashes (e.g. a feature/foo branch). Step 2d now fetches github.com blob URLs by swapping only /blob/ -> /raw/, and fetches github.com/.../raw/... and raw.githubusercontent.com/... URLs as-is, instead of reconstructing the raw host form. Recompile add-community-preset.lock.yml (body hash only). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/preset_submission.yml | 14 ++++- .../workflows/add-community-preset.lock.yml | 2 +- .github/workflows/add-community-preset.md | 62 +++++++++++++++++-- presets/PUBLISHING.md | 56 ++++++++++++++++- 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 19244e6651..f25c60f92a 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -77,6 +77,18 @@ body: validations: required: true + - type: input + id: documentation + attributes: + label: Documentation URL + description: | + Link to the README that explains how to use **this preset** (not a general product/framework pitch). + Prefer the preset-scoped README (e.g. `presets//README.md` in a monorepo) over the repository root README. + It must contain at least one valid `specify preset add ...` install command — ideally `specify preset add --from ` using the exact Download URL above (other forms such as `specify preset add ` or `specify preset add --dev ` are also accepted). + placeholder: "https://github.com/your-org/spec-kit-presets/blob/main/presets/your-preset/README.md" + validations: + required: true + - type: input id: license attributes: @@ -175,7 +187,7 @@ body: options: - label: Valid `preset.yml` manifest included required: true - - label: README.md with description and usage instructions + - label: Linked README (Documentation URL) explains how to use this preset and includes a valid `specify preset add ...` command (preferably `specify preset add --from ` using the exact download URL) required: true - label: LICENSE file included required: true diff --git a/.github/workflows/add-community-preset.lock.yml b/.github/workflows/add-community-preset.lock.yml index 9aec9914f1..eae7ba0c9b 100644 --- a/.github/workflows/add-community-preset.lock.yml +++ b/.github/workflows/add-community-preset.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"392ace500b7cb9b0aa6b020d150841de398bcbcfe54dbad729f0d860d698bde2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/add-community-preset.md b/.github/workflows/add-community-preset.md index bc2a8115e1..a05eed0095 100644 --- a/.github/workflows/add-community-preset.md +++ b/.github/workflows/add-community-preset.md @@ -73,6 +73,7 @@ fields): | Author | `author` | Yes | | Repository URL | `repository` | Yes | | Download URL | `download-url` | Yes | +| Documentation URL | `documentation` | Yes | | License | `license` | Yes | | Required Spec Kit Version | `speckit-version` | Yes | | Required Extensions | `required-extensions` | No | @@ -100,17 +101,70 @@ deciding pass/fail: ### 2c. Repository validation - Fetch the repository URL — confirm it exists and is publicly accessible - Confirm the repository contains a `preset.yml` file -- Confirm the repository contains a `README.md` file - Confirm the repository contains a `LICENSE` file -### 2d. Release and download URL validation +> The README requirement is enforced once, in **Step 2d**, against the specific file the +> `documentation` field points to — not a generic repository-root `README.md`. This avoids +> the monorepo false-positive where a root README exists but isn't the preset-usage doc. + +### 2d. Documentation README validation + +The `documentation` field must point to the README that explains **how to use this +preset** — not just any file named `README.md`, and not a product/framework pitch. + +- **Restrict the URL to GitHub before fetching.** The `documentation` value is + user-provided input. Only accept GitHub-hosted README URLs: + - `https://github.com///blob//` + - `https://github.com///raw//` + - `https://raw.githubusercontent.com////` + + If the URL points anywhere else (or isn't a URL), **fail this check** and do not fetch it. +- **Require the URL to point at a README file.** After stripping any fragment/query (see + below), the URL path must end with `README.md` (case-insensitive). If it points at some + other Markdown file, **fail this check** and ask the submitter to link the preset's README. +- Fetch the **exact URL** in the `documentation` field. First strip any fragment (`#...`) + or query string (`?...`) — these are common when copying from the browser UI and must be + ignored so the fetch target is deterministic. Then resolve the raw content to fetch: + - For a `github.com///blob//` URL, fetch the equivalent + `github.com///raw//` URL (only swap `/blob/` → `/raw/`). + - Fetch `github.com/.../raw/...` and `raw.githubusercontent.com/...` URLs as-is. + + Do **not** rewrite into `raw.githubusercontent.com////` form — that + format can't reliably represent refs containing slashes (e.g. a `feature/foo` branch). + Confirm the fetched URL resolves to a readable Markdown file. +- **Validate that the README contains a valid Spec Kit CLI install command.** The fetched + README must contain at least one `specify preset add ...` invocation. The strongest + signal is the catalog-install form whose URL matches the submitted **Download URL**: + - `specify preset add --from ` (preferred), or + - `specify preset add `, or + - `specify preset add --dev ` + + A `specify preset add --from ` command only counts when its `` **matches the + submitted Download URL exactly**. A `--from` command pointing at a *different* URL does + **not** satisfy the install-command requirement (treat it as if absent) — but the README + may still pass on one of the other accepted forms (`specify preset add ` or + `specify preset add --dev `). + + If **no** accepted `specify preset add ...` command is present, the README is treated as a + generic description/pitch rather than preset-usage documentation — **fail this check** and + tell the submitter to add a valid install command (ideally + `specify preset add --from `). +- **Prefer a preset-scoped README in monorepos.** If `documentation` resolves to a generic + repository-root README in a monorepo (the preset lives in a subdirectory such as + `presets//` and a preset-scoped README exists there), **flag it** in your comment and + recommend the submitter point `documentation` at the preset-scoped README + (e.g. `presets//README.md`) so the catalog surfaces usage instead of marketing. Treat + this as a flag rather than a hard failure **only if** the root README still contains a valid + `specify preset add ...` command for this preset; otherwise it fails check 2d above. + +### 2e. Release and download URL validation - The download URL should follow the pattern `https://github.com///archive/refs/tags/v.zip` or `https://github.com///releases/download//.zip` - Verify a GitHub release exists matching the submitted version -### 2e. Submission checklists +### 2f. Submission checklists - Confirm that all required checkboxes in the Testing Checklist and Submission Requirements sections are checked (`[x]`) @@ -154,7 +208,7 @@ Insert the entry in **alphabetical order by preset ID** within the "repository": "", "download_url": "", "homepage": "", - "documentation": "", + "documentation": "", "license": "", "requires": { "speckit_version": "" diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index f823a6ef15..24abffda54 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -19,7 +19,7 @@ Before publishing a preset, ensure you have: 1. **Valid Preset**: A working preset with a valid `preset.yml` manifest 2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) -3. **Documentation**: README.md with description and usage instructions +3. **Documentation**: A preset-scoped README.md that explains how to use **this preset**, including a valid `specify preset add ...` install command (see [Usage README Requirements](#usage-readme-requirements)) 4. **License**: Open source license file (MIT, Apache 2.0, etc.) 5. **Versioning**: Semantic versioning (e.g., 1.0.0) 6. **Testing**: Preset tested on real projects with `specify preset add --dev` @@ -147,6 +147,46 @@ https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0 specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip ``` +### Usage README Requirements + +The catalog `documentation` field must point at a README that explains how to use +**this preset** — not a product pitch for a broader framework or a separate CLI. + +The submission workflow **mechanically enforces** that the linked README is a GitHub-hosted +URL whose path ends with `README.md`, resolves to a readable file, and contains at least one +valid `specify preset add ...` command. The remaining items (preferring a preset-scoped README +in monorepos, covering the minimum structure) are expectations a human reviewer checks — +follow them so your submission isn't sent back for changes. + +- **Point `documentation` at the preset-scoped README.** In a monorepo where the preset + lives in a subdirectory (e.g. `presets//`), link the README inside that directory + (`presets//README.md`) rather than the repository-root README. The root README is + often a marketing/overview page; the catalog should surface preset usage instead. The key + requirement is that this README is reachable at the `documentation` URL so users can read + it *before* downloading the release artifact — it's fine for the same file to also ship + inside the release ZIP. +- **Include a valid Spec Kit CLI install command** *(enforced)*. The linked README must + contain at least one `specify preset add ...` invocation. Preferably use the + catalog-install form whose URL matches your Download URL: + + ```bash + # is the same URL you submit as the catalog Download URL — + # either the tag archive or a release asset, e.g.: + specify preset add --from https://github.com///archive/refs/tags/vX.Y.Z.zip + specify preset add --from https://github.com///releases/download/vX.Y.Z/-X.Y.Z.zip + ``` + + `specify preset add ` and `specify preset add --dev ` are also accepted, but the + `--from ` form is the clearest signal that the README documents this exact + preset release. +- **Cover the minimum structure** so a reader can decide whether the preset fits: + - What the preset does / what it provides + - The install command using Spec Kit CLI syntax (above) + - When to use it / when not to use it + +A submission whose linked README lacks a valid `specify preset add ...` command **fails +validation** (workflow check 2d) and will not be added until corrected. + --- ## Submit to Catalog @@ -181,12 +221,14 @@ Edit `presets/catalog.community.json` and add your preset. "presets": { "your-preset": { "name": "Your Preset Name", + "id": "your-preset", "description": "Brief description of what your preset provides", "author": "Your Name", "version": "1.0.0", "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", "sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install", "repository": "https://github.com/your-org/spec-kit-preset-your-preset", + "documentation": "https://github.com/your-org/spec-kit-preset-your-preset/blob/main/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.1.0" @@ -243,7 +285,7 @@ git push origin add-your-preset ### Checklist - [ ] Valid preset.yml manifest -- [ ] README.md with description and usage +- [ ] Usage README with a valid `specify preset add ...` command, linked from `documentation` (preset-scoped README recommended for monorepos) - [ ] LICENSE file included - [ ] GitHub release created - [ ] Preset tested with `specify preset add --dev` @@ -264,7 +306,15 @@ After submission, maintainers will review: 2. **Template quality** — templates are useful and well-structured 3. **Command coherence** — commands reference sections that exist in templates 4. **Security** — no malicious content, safe file operations -5. **Documentation** — clear README explaining what the preset does +5. **Documentation** — the README linked from `documentation` explains how to use *this* preset and contains a valid `specify preset add ...` command + +> **Reviewer note:** the workflow can mechanically check *structure* (the linked README +> resolves and contains a valid `specify preset add ...` snippet; when that snippet uses the +> `--from ` form, its URL must match the submitted download URL exactly — other accepted +> forms like `specify preset add ` don't reference the download URL at all). Whether the +> README genuinely documents *this* preset is partly a content judgment, so a human reviewer +> should still confirm the linked doc isn't just a funnel to a separate product or CLI before +> approving. Once verified, `verified: true` is set and the preset appears in `specify preset search`. From 05cf078ea4ceb189884fd85ccc2fe0234c2a04ea Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Wed, 24 Jun 2026 19:37:28 -0300 Subject: [PATCH 18/60] docs: add SpecKit Assistant npm package to Community Friends (#3142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add SpecKit Assistant npm package to Community Friends Adds SpecKit Assistant (https://www.npmjs.com/package/speckit-assistant) to the Community Friends list. It is a visual interface for the specify CLI that orchestrates Spec-Driven Development (SDD) — connecting local specification, planning, and task checklists with AI agents (Claude, Gemini, Copilot). No installation required; run it via npx speckit-assistant. As the author of both the VS Code Spec Kit Assistant extension and the SpecKit Assistant npm package, I maintain these community tools that provide a visual interface on top of the specify CLI. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: clarify SpecKit Assistant requires no global installation Address Copilot review: 'No installation required' was misleading for an npx-run package since npx still downloads it. Clarify that no global installation is required. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/friends.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/community/friends.md b/docs/community/friends.md index 31c6318699..9aff166d0f 100644 --- a/docs/community/friends.md +++ b/docs/community/friends.md @@ -7,7 +7,9 @@ Community projects that extend, visualize, or build on Spec Kit: - **[cc-spex](https://github.com/rhuss/cc-spex)** — A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. -- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. +- **[VS Code Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. + +- **[SpecKit Assistant](https://www.npmjs.com/package/speckit-assistant)** — A visual orchestrator for Spec-Driven Development (SDD). It connects your local specification, planning, and task checklists with AI agents (Claude, Gemini, GitHub Copilot). No global installation required — just run it via `npx speckit-assistant`. - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. From d65f6bd335acd630911e13159dc48cba85476fff Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:42:49 -0500 Subject: [PATCH 19/60] chore: release 0.11.8, begin 0.11.9.dev0 development (#3156) * chore: bump version to 0.11.8 * chore: begin 0.11.9.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a4f6cc991..10491ee0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.11.8] - 2026-06-24 + +### Changed + +- docs: add SpecKit Assistant npm package to Community Friends (#3142) +- Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104) +- [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152) +- Add Spec Roadmap extension to community catalog (#3153) +- feat(integration): update Kimi integration for Kimi Code CLI (#2979) +- [extension] Add Golden Demo extension to community catalog (#3151) +- docs: run /speckit.checklist after /speckit.plan in quickstart (#3108) +- fix(workflows): preserve commas inside quoted list-literal elements (#3134) +- ci: pin actions to commit SHAs and add shellcheck (#3126) +- chore: release 0.11.7, begin 0.11.8.dev0 development (#3154) + ## [0.11.7] - 2026-06-24 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 0443bc2ecb..c3ba380349 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.8.dev0" +version = "0.11.9.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 77e6f43b823c7a1b3c8f7f9484e021e71fef9ab0 Mon Sep 17 00:00:00 2001 From: "siC@r10-mw" <129638058+SiCar10mw@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:25:41 -0500 Subject: [PATCH 20/60] Point sicario-core docs to preset README (#3120) * Point sicario-core docs to preset README * Update sicario-core catalog timestamps Assisted-by: OpenAI Codex (GPT-5, autonomous) --- presets/catalog.community.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index eb751f0997..9166945614 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-25T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -573,7 +573,7 @@ "repository": "https://github.com/dfirs1car1o/sicario-spec", "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip", "homepage": "https://github.com/dfirs1car1o/sicario-spec", - "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md", + "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.9.0" @@ -590,7 +590,7 @@ "evidence" ], "created_at": "2026-06-22T00:00:00Z", - "updated_at": "2026-06-22T00:00:00Z" + "updated_at": "2026-06-25T00:00:00Z" }, "spec2cloud": { "name": "Spec2Cloud", From bb37b180d636bb3875acc2080dc94da8080b8600 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 25 Jun 2026 17:48:23 +0500 Subject: [PATCH 21/60] fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901) In agent-direct invocations nothing watches agent output for the EXECUTE_COMMAND: directive, so a mandatory hook that is only emitted never runs and the failure is silent (#2730). Add one line after each mandatory-hook block instructing the agent to actually invoke the hook and wait for it before continuing. The instruction tells the agent to run the hook the way it would run the command itself in the current agent/session, and notes the invocation may differ from the literal {command} id shown in the block (e.g. skills-mode agents run it as /skill:speckit-... or $speckit-...), so it stays correct outside the default slash-command form. Fixes #2730 --- templates/commands/analyze.md | 2 ++ templates/commands/checklist.md | 2 ++ templates/commands/clarify.md | 2 ++ templates/commands/constitution.md | 2 ++ templates/commands/converge.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 ++ 10 files changed, 20 insertions(+) diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 5b521cf2a4..e4ba8f7d81 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Goal. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Goal @@ -228,6 +229,7 @@ After reporting, check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Operating Principles diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 2e1b1040af..e202ebb667 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -66,6 +66,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Execution Steps. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Execution Steps @@ -363,4 +364,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index a83d52f026..4948fdcfaf 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -251,6 +252,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 29ae9a09e2..d003d5c9b2 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -147,4 +148,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/converge.md b/templates/commands/converge.md index 3d366e1d30..35cf3736c3 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Goal. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently @@ -266,5 +267,6 @@ After producing the result, check if `.specify/extensions.yml` exists in the pro Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/implement.md b/templates/commands/implement.md index c416fa7387..eda580d560 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -192,6 +193,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 44ab8403ac..8e00e3ef9b 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -53,6 +53,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -91,6 +92,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 4558b922ae..09a584e0ea 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -50,6 +50,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -253,6 +254,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index f863e7787f..4d3e45a7c4 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -54,6 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -111,6 +112,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index b3093baa03..f1df100010 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -100,4 +101,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently From 9fe1c4cc5ce90c93a4d7db5759cfba43142b996b Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:56:59 +0500 Subject: [PATCH 22/60] fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129) * fix(scripts): keep PowerShell branch-name acronym match case-sensitive Get-BranchName keeps a sub-3-character word only when it appears as an UPPERCASE acronym in the description. The bash twin checks this case-sensitively (grep "\b${word^^}\b" / grep -qw -- "${word^^}"), but the PowerShell twin used -match, which is case-INSENSITIVE, so it kept EVERY short word regardless of case -- contradicting its own comment and diverging from bash. The same description then produced different spec-directory and branch names on Windows/PowerShell vs macOS/Linux (e.g. "Add go support" -> 001-go-support instead of 001-support), desyncing specs/, feature.json, and git branches across a mixed-OS team. Use the case-sensitive -cmatch so a short word is kept only for a genuine uppercase acronym, matching bash. Applied to both the core scripts/powershell/create-new-feature.ps1 and the git extension's create-new-feature-branch.ps1. Add bash + PowerShell regression tests (core and git-extension) asserting a lowercase short word is dropped while an uppercase acronym is kept. Co-Authored-By: Claude Opus 4.8 (1M context) * test: fix article grammar in branch-name docstrings Address review: 'an UPPERCASE acronym' -> 'an acronym in UPPERCASE' across the four branch-name case-sensitivity test docstrings (the indefinite article reads cleanly before 'acronym'). Docstring-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../powershell/create-new-feature-branch.ps1 | 5 ++- tests/extensions/git/test_git_extension.py | 33 +++++++++++++++++++ tests/test_timestamp_branches.py | 30 +++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 65358df0ba..6a4417f8b9 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -252,7 +252,10 @@ function Get-BranchName { if ($stopWords -contains $word) { continue } if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { + } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + # Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`: + # keep a short word only when its UPPERCASE form appears in the original + # (an acronym). -match is case-insensitive and would keep every short word. $meaningfulWords += $word } } diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 3d40aef4ee..2017dae627 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -298,6 +298,24 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert data["BRANCH_NAME"] == "001-user-auth" assert data["FEATURE_NUM"] == "001" + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): + """A short word is dropped from the derived branch name unless it appears + as an acronym in UPPERCASE in the description (case-sensitive, must match the + PowerShell twin).""" + project = _setup_project(tmp_path) + # lowercase "go" (<3 chars, not an uppercase acronym) is dropped + r1 = _run_bash( + "create-new-feature-branch.sh", project, "--json", "--dry-run", "Add go support", + ) + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + # uppercase "GO" is kept as an acronym + r2 = _run_bash( + "create-new-feature-branch.sh", project, "--json", "--dry-run", "Use GO now", + ) + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_creates_branch_timestamp(self, tmp_path: Path): """Extension create-new-feature-branch.sh creates timestamp branch.""" project = _setup_project(tmp_path) @@ -426,6 +444,21 @@ def test_creates_branch_sequential(self, tmp_path: Path): data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): + """PowerShell must match the bash twin: a short word is dropped unless it + appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match).""" + project = _setup_project(tmp_path) + r1 = _run_pwsh( + "create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Add go support", + ) + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = _run_pwsh( + "create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Use GO now", + ) + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index aa48a597fe..93207746a1 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -240,6 +240,17 @@ def test_sequential_default_with_existing_specs(self, git_repo: Path): assert branch is not None assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}" + def test_branch_name_short_word_case_sensitivity(self, git_repo: Path): + """A short word is dropped from the derived branch name unless it appears + as an acronym in UPPERCASE in the description. The PowerShell twin must use + case-sensitive -cmatch to produce the same result.""" + r1 = run_script(git_repo, "--json", "--dry-run", "Add go support") + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = run_script(git_repo, "--json", "--dry-run", "Use GO now") + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_sequential_ignores_timestamp_dirs(self, git_repo: Path): """Sequential numbering skips timestamp dirs when computing next number.""" (git_repo / "specs" / "002-first-feat").mkdir(parents=True) @@ -272,6 +283,25 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): assert "[long]::TryParse($matches[1], [ref]$num)" in content assert "$num = [int]$matches[1]" not in content + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_branch_name_short_word_case_sensitivity(self, ps_git_repo: Path): + """Core create-new-feature.ps1 must drop a short word unless it appears as + an acronym in UPPERCASE (case-sensitive -cmatch), matching the bash twin.""" + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + + def _run(desc: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun", desc], + cwd=ps_git_repo, capture_output=True, text=True, + ) + + r1 = _run("Add go support") + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = _run("Use GO now") + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + # ── check_feature_branch Tests ─────────────────────────────────────────────── From 7624dd6582b3b04b47709e13b2c19638c2c2386d Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:13:14 +0800 Subject: [PATCH 23/60] Update preset composition strategy reference (#3143) * docs: update preset composition strategy reference * docs: clarify preset command composition timing * docs: clarify preset command reconciliation timing * docs: clarify preset file resolution behavior * docs: clarify preset command reconciliation wording --------- Co-authored-by: root --- docs/reference/presets.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 4a613ffc00..549177c1d6 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -137,9 +137,11 @@ catalogs: ## File Resolution -Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers. +Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers. -> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. +Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command. + +By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder. The resolution stack, from highest to lowest precedence: @@ -148,8 +150,6 @@ The resolution stack, from highest to lowest precedence: 3. **Installed extensions** — sorted by priority 4. **Spec Kit core** — `.specify/templates/` -Commands are registered at install time (not resolved through the stack at runtime). - ### Resolution Stack ```mermaid @@ -215,7 +215,7 @@ Run `specify preset resolve ` to trace the resolution stack and see which ### What's the difference between disabling and removing a preset? -**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`. +**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`. **Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry. From 1add20341db92bb127668509be20bf45fd1bb1b1 Mon Sep 17 00:00:00 2001 From: Si Zengyu <15927967+HeroSizy@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:44:30 +0800 Subject: [PATCH 24/60] fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157) * feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions): resolve GHES release assets via /api/v3 Generalizes resolve_github_release_asset_api_url to GitHub Enterprise Server hosts (gated by auth.json github hosts), fixing private GHES extension/preset downloads. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(extensions,presets): pass auth.json github hosts into release resolver Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * docs(auth): document GHES private catalog + release-asset auth Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * fix(presets,workflows): pass auth.json github hosts into remaining release resolvers Wires preset add --from and workflow add through github_provider_hosts() so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147 Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous) * test(presets): use module-level io.BytesIO in GHES preset test Addresses Copilot review on PR #3157: drop unnecessary __import__("io") in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is already imported at module level. * fix(github-http): pass through GHES asset API URLs by path shape Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset URL was only returned as already-resolved when its host was in the allowlist; otherwise the resolver returned None and the caller downloaded the same URL without 'Accept: application/octet-stream', fetching JSON metadata instead of the binary. Gate the passthrough on path shape alone, mirroring the github.com case. This is safe: passthrough returns the input URL unchanged and the caller fetches it either way, so no new request to an arbitrary host is induced; the token stays independently gated by auth.json in open_url. The allowlist remains the anti-SSRF gate on the tag-lookup resolving path. Add test_passthrough_for_unlisted_ghes_api_asset_url. --- docs/reference/authentication.md | 27 +++++ src/specify_cli/__init__.py | 6 +- src/specify_cli/_github_http.py | 87 ++++++++++------ src/specify_cli/authentication/http.py | 14 +++ src/specify_cli/extensions/__init__.py | 10 +- src/specify_cli/presets/__init__.py | 13 ++- src/specify_cli/presets/_commands.py | 5 +- tests/test_authentication.py | 42 ++++++++ tests/test_extensions.py | 35 +++++++ tests/test_github_http.py | 114 +++++++++++++++++++++ tests/test_presets.py | 98 ++++++++++++++++++ tests/test_workflows.py | 131 +++++++++++++++++++++++++ 12 files changed, 544 insertions(+), 38 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index e25bddff84..059052cd8f 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -69,6 +69,33 @@ Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes. } ``` +### GitHub Enterprise Server (GHES) + +To use a private catalog or extension hosted on a GitHub Enterprise Server +instance, add a `github` entry listing your GHES host(s). The same entry +authenticates both catalog JSON fetches **and** private release-asset +downloads — Specify recognizes the listed hosts as GitHub Enterprise and +resolves release downloads through the GHES REST API (`/api/v3`). + +```json +{ + "providers": [ + { + "hosts": ["ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_ENTERPRISE_TOKEN" + } + ] +} +``` + +List the **bare** web host (e.g. `ghes.example.com`) — release-download URLs +live there. If your instance uses subdomain isolation, also list the `raw.` +and `codeload.` subdomains your catalog/extension URLs use. A +`*.ghes.example.com` wildcard matches subdomains but **not** the bare host, +so always include the bare host explicitly. + ### Azure DevOps (`azure-devops`) | Scheme | Header | Use for | diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b2e8defb18..6713549d35 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1128,9 +1128,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: raise typer.Exit(1) from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset + from specify_cli.authentication.http import github_provider_hosts _wf_url_extra_headers = None - _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30) + _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30, github_hosts=github_provider_hosts()) if _resolved_wf_url: source = _resolved_wf_url _wf_url_extra_headers = {"Accept": "application/octet-stream"} @@ -1234,10 +1235,11 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: try: from specify_cli.authentication.http import open_url as _open_url + from specify_cli.authentication.http import github_provider_hosts from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset _wf_cat_extra_headers = None - _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30) + _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30, github_hosts=github_provider_hosts()) if _resolved_workflow_url: workflow_url = _resolved_workflow_url _wf_cat_extra_headers = {"Accept": "application/octet-stream"} diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py index d2030b57a8..31f6046395 100644 --- a/src/specify_cli/_github_http.py +++ b/src/specify_cli/_github_http.py @@ -10,6 +10,7 @@ import os import urllib.request +from fnmatch import fnmatch from typing import Callable, Dict, Optional from urllib.parse import quote, unquote, urlparse @@ -56,55 +57,79 @@ def build_github_request(url: str) -> urllib.request.Request: return urllib.request.Request(url, headers=headers) +def _host_matches(hostname: str, patterns: tuple[str, ...]) -> bool: + """Return True when *hostname* matches a pattern (exact or ``*.suffix``).""" + hostname = hostname.lower() + return any(p == hostname or fnmatch(hostname, p) for p in patterns) + + def resolve_github_release_asset_api_url( download_url: str, open_url_fn: Callable, timeout: int = 60, + github_hosts: tuple[str, ...] = (), ) -> Optional[str]: - """Resolve a GitHub browser release URL to its REST API asset URL. - - For private or SSO-protected repositories, browser release download - URLs (``https://github.com///releases/download//``) - redirect to an HTML/SSO page instead of delivering the file. This - helper resolves such a URL to the matching GitHub REST API asset URL - (``https://api.github.com/repos/…/releases/assets/``), which can - then be downloaded with ``Accept: application/octet-stream`` and an - auth token to retrieve the actual file payload. - - If *download_url* is already a REST API asset URL, it is returned - as-is. Non-GitHub URLs and GitHub URLs that are not release-download - URLs return ``None``. If the API lookup fails (e.g. network error or - asset not found), ``None`` is returned so callers can fall back to the - original URL. + """Resolve a GitHub release browser-download URL to its REST API asset URL. + + Works for public ``github.com`` and for GitHub Enterprise Server (GHES) + hosts. A host is treated as GHES when it matches one of *github_hosts* + (exact hostname or ``*.suffix``) — supply the hosts the user has trusted + under a ``github`` provider in ``auth.json``. This allowlist is the + security gate: unlisted hosts never receive GHES API treatment, so a + malicious catalog cannot induce an API request to an arbitrary host. + + For a public URL the API base is ``https://api.github.com``; for a GHES + host it is ``{scheme}://{host[:port]}/api/v3``. Returns the API asset URL + (downloadable with ``Accept: application/octet-stream`` + a token), the + input unchanged if it is already an API asset URL, or ``None`` when the + URL is not a resolvable GitHub release download or the lookup fails. Args: download_url: The URL to resolve. open_url_fn: A callable compatible with - ``specify_cli.authentication.http.open_url`` used to make the - authenticated API request. + ``specify_cli.authentication.http.open_url`` used for the + authenticated release-metadata lookup. timeout: Per-request timeout in seconds. - - Returns: - The resolved REST API asset URL, or ``None`` if resolution is not - applicable or fails. + github_hosts: Host patterns to treat as GitHub Enterprise Server. """ import json import urllib.error parsed = urlparse(download_url) + hostname = (parsed.hostname or "").lower() parts = [unquote(part) for part in parsed.path.strip("/").split("/")] - # Already a REST API asset URL — use it directly - if ( - parsed.hostname == "api.github.com" - and len(parts) >= 6 - and parts[:1] == ["repos"] - and parts[3:5] == ["releases", "assets"] - ): + is_ghes = ( + bool(hostname) + and hostname not in GITHUB_HOSTS + and _host_matches(hostname, github_hosts) + ) + + def _is_asset_path(segments: list[str]) -> bool: + return ( + len(segments) >= 6 + and segments[:1] == ["repos"] + and segments[3:5] == ["releases", "assets"] + ) + + # Already a REST API asset URL — use it directly. Pure passthrough induces + # no new request: the caller fetches this same URL regardless, so it is + # gated on path shape alone rather than the GHES allowlist. The token stays + # independently gated by auth.json in the download helper, and only the + # resolving path below (which issues a tag-lookup request) needs the + # allowlist as its anti-SSRF gate. + if hostname == "api.github.com" and _is_asset_path(parts): + return download_url + if hostname and parts[:2] == ["api", "v3"] and _is_asset_path(parts[2:]): return download_url - # Only handle github.com browser release download URLs - if parsed.hostname != "github.com": + # Determine the REST API base for browser release-download URLs. + if hostname == "github.com": + api_base = "https://api.github.com" + elif is_ghes: + authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}" + api_base = f"{parsed.scheme}://{authority}/api/v3" + else: return None # Expecting ///releases/download// @@ -114,7 +139,7 @@ def resolve_github_release_asset_api_url( owner, repo, tag = parts[0], parts[1], parts[4] asset_name = "/".join(parts[5:]) encoded_tag = quote(tag, safe="") - release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}" + release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}" try: with open_url_fn(release_url, timeout=timeout) as response: diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index e8ab8c1241..a2888bcce2 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -118,6 +118,20 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll return urllib.request.Request(url, headers=headers) +def github_provider_hosts() -> tuple[str, ...]: + """Return host patterns from every ``github`` provider entry in ``auth.json``. + + Used to classify which hosts are GitHub Enterprise Server instances when + resolving release-asset download URLs. Returns an empty tuple when no + ``auth.json`` exists or it contains no ``github`` entries. + """ + hosts: list[str] = [] + for entry in _load_config(): + if entry.provider == "github": + hosts.extend(entry.hosts) + return tuple(hosts) + + def open_url( url: str, timeout: int = 10, diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3dd46ee6d2..9271a9fde6 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2057,12 +2057,18 @@ def _resolve_github_release_asset_api_url( ) -> Optional[str]: """Resolve a GitHub release asset URL to its API asset URL. - Delegates to the shared helper in :mod:`specify_cli._github_http`. + Delegates to the shared helper in :mod:`specify_cli._github_http`, + passing the ``github`` provider hosts from ``auth.json`` so GitHub + Enterprise Server release assets resolve via ``/api/v3``. """ from specify_cli._github_http import resolve_github_release_asset_api_url + from specify_cli.authentication.http import github_provider_hosts return resolve_github_release_asset_api_url( - download_url, self._open_url, timeout=timeout + download_url, + self._open_url, + timeout=timeout, + github_hosts=github_provider_hosts(), ) def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 07e31185ec..8d5c044193 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1892,10 +1892,19 @@ def _resolve_github_release_asset_api_url( download_url: str, timeout: int = 60, ) -> Optional[str]: - """Resolve a GitHub release asset URL to its REST API asset URL.""" + """Resolve a GitHub release asset URL to its REST API asset URL. + + Passes the ``github`` provider hosts from ``auth.json`` so GitHub + Enterprise Server release assets resolve via ``/api/v3``. + """ from specify_cli._github_http import resolve_github_release_asset_api_url + from specify_cli.authentication.http import github_provider_hosts + return resolve_github_release_asset_api_url( - download_url, self._open_url, timeout=timeout + download_url, + self._open_url, + timeout=timeout, + github_hosts=github_provider_hosts(), ) def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 682bfe919d..eabfe650dd 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -144,10 +144,13 @@ def _validate_download_redirect(old_url, new_url): zip_path = Path(tmpdir) / "preset.zip" try: from specify_cli.authentication.http import open_url as _open_url + from specify_cli.authentication.http import github_provider_hosts from specify_cli._github_http import resolve_github_release_asset_api_url _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url) + _resolved_from_url = resolve_github_release_asset_api_url( + from_url, _open_url, github_hosts=github_provider_hosts() + ) if _resolved_from_url: from_url = _resolved_from_url _preset_extra_headers = {"Accept": "application/octet-stream"} diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8b09245384..a89303d3d8 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -900,3 +900,45 @@ def test_accept_header_present(self, monkeypatch): with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() assert captured["request"].get_header("Accept") == "application/vnd.github+json" + + +# --------------------------------------------------------------------------- +# github_provider_hosts +# --------------------------------------------------------------------------- + + +class TestGithubProviderHosts: + """Tests for github_provider_hosts() — the GHES host allowlist source.""" + + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", entries) + + def test_returns_hosts_from_github_entries(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"), + provider="github", auth="bearer", token="t"), + ]) + assert github_provider_hosts() == ("ghes.example", "raw.ghes.example") + + def test_empty_when_no_config(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, []) + assert github_provider_hosts() == () + + def test_ignores_non_github_providers(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops", + auth="basic-pat", token="t"), + ]) + assert github_provider_hosts() == () + + def test_unions_multiple_github_entries(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"), + ]) + assert github_provider_hosts() == ("ghes.example", "github.com") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 6b181a1204..e8dc2b7beb 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -16,8 +16,10 @@ import tempfile import shutil import tomllib +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone +from unittest.mock import MagicMock from tests.conftest import strip_ansi from specify_cli.extensions import ( @@ -7280,3 +7282,36 @@ def test_add_dev_force_reinstall(self, tmp_path): ) assert result2.exit_code == 0, strip_ansi(result2.output) assert "installed" in strip_ansi(result2.output) + + +def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring: auth.json github host → GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.extensions import ExtensionCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = ExtensionCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}] + }).encode() + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"] diff --git a/tests/test_github_http.py b/tests/test_github_http.py index e258f4917f..cd1b651aaa 100644 --- a/tests/test_github_http.py +++ b/tests/test_github_http.py @@ -188,3 +188,117 @@ def capturing_open(url, timeout=None, extra_headers=None): ) assert len(captured_urls) == 1 assert "releases/tags/v1%23beta" in captured_urls[0] + + # --- GHES (GitHub Enterprise Server) --- + + def test_resolves_ghes_browser_url_to_api_url(self): + """A GHES browser release URL resolves to the /api/v3 asset URL.""" + release_json = { + "assets": [ + {"name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"} + ] + } + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + self._make_open_url_fn(release_json), + github_hosts=("ghes.example",), + ) + assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + + def test_passthrough_for_existing_ghes_api_asset_url(self): + """An already-resolved GHES /api/v3 asset URL is returned as-is.""" + url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + result = resolve_github_release_asset_api_url( + url, lambda *a, **kw: None, github_hosts=("ghes.example",) + ) + assert result == url + + def test_returns_none_for_ghes_host_not_in_allowlist(self): + """Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF).""" + called = [] + + @contextmanager + def recording_open(url, timeout=None, extra_headers=None): + called.append(url) + resp = MagicMock() + resp.read.return_value = b"{}" + yield resp + + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + recording_open, + github_hosts=("other.example",), + ) + assert result is None + assert called == [] + + def test_passthrough_for_unlisted_ghes_api_asset_url(self): + """A direct GHES /api/v3 asset URL passes through even when the host is + not allowlisted: passthrough issues no API request, and the download + helper gates the token independently, so octet-stream resolution must + not be withheld.""" + called = [] + + @contextmanager + def recording_open(url, timeout=None, extra_headers=None): + called.append(url) + resp = MagicMock() + resp.read.return_value = b"{}" + yield resp + + url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + result = resolve_github_release_asset_api_url( + url, recording_open, github_hosts=("other.example",) + ) + assert result == url + assert called == [] + + def test_ghes_api_base_preserves_scheme_and_port(self): + """The GHES API base mirrors the URL scheme and keeps a non-standard port.""" + captured = [] + + @contextmanager + def capturing_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({"assets": []}).encode() + yield resp + + resolve_github_release_asset_api_url( + "http://localhost:8000/o/r/releases/download/v1/ext.zip", + capturing_open, + github_hosts=("localhost",), + ) + assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"] + + def test_ghes_wildcard_does_not_match_bare_host(self): + """A '*.suffix' pattern does not match the bare host (must list it explicitly).""" + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + lambda *a, **kw: None, + github_hosts=("*.ghes.example",), + ) + assert result is None + + def test_public_github_url_unaffected_by_github_hosts(self): + """Public github.com still resolves via api.github.com even with github_hosts set.""" + captured = [] + + @contextmanager + def capturing_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://api.github.com/repos/org/repo/releases/assets/99"}] + }).encode() + yield resp + + result = resolve_github_release_asset_api_url( + "https://github.com/org/repo/releases/download/v1.0/pack.zip", + capturing_open, + github_hosts=("ghes.example",), + ) + assert result == "https://api.github.com/repos/org/repo/releases/assets/99" + assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"] diff --git a/tests/test_presets.py b/tests/test_presets.py index ff37dd3a96..0632fe3a89 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -17,9 +17,11 @@ import shutil import warnings import zipfile +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone from types import SimpleNamespace +from unittest.mock import MagicMock import yaml @@ -4752,6 +4754,69 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + manifest_content = yaml.dump({ + "schema_version": "1.0", + "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, + }) + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + class FakeResponse: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(zip_bytes) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "preset", "add", + "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", + ]) + + assert result.exit_code == 0, result.output + # The tag-lookup call must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # The asset download call must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + class TestWrapStrategy: """Tests for strategy: wrap preset command substitution.""" @@ -6021,3 +6086,36 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content, (subdir / f"{template_name}.md").write_text(content) return pack_dir + + +def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring for presets: auth.json github host → GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.presets import PresetCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = PresetCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] + }).encode() + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v2/pack.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 5bbc9b6e53..988730d783 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -5477,6 +5477,137 @@ def fake_open_url(url, timeout=None, extra_headers=None): assert len(asset_calls) >= 1 assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + def test_workflow_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42" + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(self.VALID_WORKFLOW_YAML.encode()) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "workflow", "add", + "https://ghes.example/org/repo/releases/download/v1.0/workflow.yml", + ]) + + assert result.exit_code == 0, result.output + # Tag lookup must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_catalog_based_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' with a GHES catalog URL resolves via /api/v3.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55" + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + ghes_wf_yaml = """ +schema_version: "1.0" +workflow: + id: "my-wf" + name: "My GHES Workflow" + version: "1.0.0" + description: "A GHES catalog workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def fake_open_url(url, timeout=None, extra_headers=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"}] + }).encode()) + return FakeResponse(ghes_wf_yaml.encode()) + + fake_catalog_info = { + "id": "my-wf", + "name": "My GHES Workflow", + "version": "1.0.0", + "url": "https://ghes.example/org/repo/releases/download/v2.0/workflow.yml", + "_install_allowed": True, + } + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info): + result = runner.invoke(app, ["workflow", "add", "my-wf"]) + + assert result.exit_code == 0, result.output + # Tag lookup must use GHES /api/v3 + tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert "ghes.example/api/v3/repos/org/repo/releases/tags/v2.0" in tag_calls[0] + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + class TestWorkflowRunExitCodes: """CLI-level tests for the run/resume process exit codes.""" From e7ec7c190f715b5d3e39b4fe69d4571e27c4b834 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:10:23 -0500 Subject: [PATCH 25/60] Update SicarioSpec Core preset to v0.5.1 (#3165) Update sicario-core preset submitted by @SiCar10mw: - presets/catalog.community.json (version, download_url, description, tags) - docs/community/presets.md community presets table Closes #3164 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 750abc0809..52f923a3ad 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -25,7 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | -| SicarioSpec Core | Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | +| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | | Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 9166945614..901553b288 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -567,11 +567,11 @@ "sicario-core": { "name": "SicarioSpec Core", "id": "sicario-core", - "version": "0.4.0", - "description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.", + "version": "0.5.1", + "description": "Baseline secure-by-default Spec Kit governance profile.", "author": "SicarioSpec Contributors", "repository": "https://github.com/dfirs1car1o/sicario-spec", - "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip", + "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip", "homepage": "https://github.com/dfirs1car1o/sicario-spec", "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md", "license": "MIT", @@ -583,7 +583,6 @@ "commands": 0 }, "tags": [ - "security", "governance", "security-ops", "secure-by-default", From 1d989b90d55b66d5c6608a97533985ed9f1cf6ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:05:38 -0500 Subject: [PATCH 26/60] chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index a3bfc8fbeb..1abda3e91c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -35,7 +35,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d3169197a..f8dde19633 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" @@ -40,7 +40,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} From 5f9791b524ac6edcd61473b38762be4f0138a577 Mon Sep 17 00:00:00 2001 From: Alfredo Perez Date: Fri, 26 Jun 2026 10:59:32 -0500 Subject: [PATCH 27/60] =?UTF-8?q?fix(catalog):=20companion=20=E2=86=92=20R?= =?UTF-8?q?EADME=20docs,=20version-pinned=20download=20URL,=20v0.11.0,=20r?= =?UTF-8?q?efreshed=20tags=20(#2954)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(catalog): point companion documentation at README.md so it renders The companion entry's documentation URL pointed at a directory (speckit-extension/docs/), which the community site can't fetch as markdown — its extension page renders an empty README (readmeContent: null). Every other catalog entry points documentation at a specific README.md (or .md file). Point companion at its extension README so the page renders. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(catalog): companion → stable companion-latest download_url, v0.4.1, sharper tags - download_url now points at the rolling companion-latest asset so by-name install always serves the newest build (no per-release catalog PR) - version 0.3.0 → 0.4.1 - tags: drop redundant 'companion'/'progress'/'lifecycle', add spec-driven-development, spec-kit, turbo, capture * fix(catalog): companion tags → capability-first (vscode, progress, status, resume, configurable, extensible) Tags now name what Companion adds over stock spec-kit, in browse-able terms — dropped catalog-noise (spec-kit, spec-driven-development) and insider jargon (turbo, capture). * fix(catalog): pin companion to speckit-ext-v0.8.0 asset; sync entry Pin download_url to the version-matched release asset (every other catalog entry pins to a tag; the floating companion-latest URL made installs non-reproducible). Bring the entry up to v0.8.0: version 0.4.1 -> 0.8.0, commands 10 -> 12, speckit_version floor >=0.9.5.dev0, and drop the removed "turbo pipeline profile" from the description in favor of the hooks/recipes customization that shipped. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(catalog): bump companion to v0.11.0 (latest released asset, 13 commands) * fix(catalog): companion speckit_version floor >=0.9.5 (drop pointless .dev0) * fix(catalog): align companion updated_at with catalog root (2026-06-24) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- extensions/catalog.community.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 64b6f8f902..01c4ea17bc 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -772,40 +772,40 @@ "companion": { "name": "SpecKit Companion", "id": "companion", - "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and a turbo pipeline profile.", + "description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.", "author": "alfredoperez", - "version": "0.3.0", - "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.0.zip", + "version": "0.11.0", + "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip", "repository": "https://github.com/alfredoperez/speckit-companion", "homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension", - "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/", + "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md", "changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md", "license": "MIT", "category": "visibility", "effect": "read-write", "requires": { - "speckit_version": ">=0.8.5", + "speckit_version": ">=0.9.5", "tools": [ { "name": "python3", "required": false } ] }, "provides": { - "commands": 10, + "commands": 13, "hooks": 4 }, "tags": [ - "tracking", - "companion", - "progress", "vscode", - "lifecycle", - "resume" + "progress", + "status", + "resume", + "configurable", + "extensible" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-06-11T00:00:00Z", - "updated_at": "2026-06-11T00:00:00Z" + "updated_at": "2026-06-24T00:00:00Z" }, "conduct": { "name": "Conduct Extension", From 49cc05384a45c936ab6bc3adde1394671da41c7f Mon Sep 17 00:00:00 2001 From: Amirreza Alibeigi Date: Fri, 26 Jun 2026 19:07:44 +0200 Subject: [PATCH 28/60] fix: derive plan path from feature.json in update-agent-context (#3069) * fix: derive plan path from feature.json in update-agent-context When `plan_path` is omitted, prefer `.specify/feature.json` (written by /speckit-specify) over the mtime heuristic. The old approach picked the most recently modified `specs/*/plan.md`, which could inject an unrelated plan into CLAUDE.md if another spec's plan was touched after the active feature directory was created but before its own plan.md existed. Bash: handle both relative and absolute feature_directory values, normalizing absolute paths back to project-relative for the context file. Fall back to mtime only when feature.json is absent or the derived plan.md does not yet exist. PowerShell: same logic, PS 5.1-compatible (nested Join-Path, IsPathRooted guard to avoid Unix Join-Path mis-joining absolute ChildPaths, manual prefix-strip instead of GetRelativePath). Fixes #3067 * fix: address Copilot review feedback on update-agent-context - bash: add explicit encoding="utf-8" to feature.json open() call - powershell: replace GetRelativePath (.NET 5+ only) with manual prefix-strip in mtime fallback for PS 5.1 compatibility - tests: add coverage for absolute feature_directory values (under and outside PROJECT_ROOT) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: replace time.sleep with os.utime and strengthen PS normalization assertion * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: normalize trailing slash and guard non-string feature_directory in PS script * Fix: use .resolve().as_posix(). Valid. The PS tests run on Windows where str(tmp_path) uses backslashes, but the PS script normalizes output to forward slashes. Assertions like assert str(tmp_path) not in ctx become false negatives on Windows CI. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: use context manager for feature.json open() in bash heredoc * test: add PS coverage for absolute feature_directory outside project root * fix: guard null feature_directory, re-check empty after trailing-slash strip, fix blank line * test: add stale plan to absolute-path tests so feature.json preference is actually exercised * test: convert absolute paths to MSYS2 style for Git-for-Windows bash compatibility * fix: revert PS test to native path, fix bash outside-root assertion for Git bash * fix: use _to_bash_path in not-in assertion for Git bash Windows compat * fix: add ConvertFrom-Json fallback in PS script, write test config as JSON * fix: use OS-appropriate StringComparison in PS prefix-strip (matches common.ps1) * fix: emit project-relative POSIX path from mtime fallback; use upstream test helpers * fix: write config as JSON directly, drop _install_agent_context_config * fix: normalize backslashes to forward slashes in feature_directory before path ops * fix: treat drive-qualified paths (C:/...) as absolute after backslash normalization * fix: resolve symlinks when computing relative plan path; use UTF8 encoding in PS ConvertFrom-Yaml path * fix: use bash-side path for outside-root case to avoid WindowsPath backslashes * fix: use .as_posix() instead of PurePosixPath() to avoid backslashes on native Windows Python * fix: resolve ./.. segments in PS feature_directory via GetFullPath before relativizing * fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.1 StrictMode compat * fix: guard empty relDir to avoid leading slash in PlanPath when feature_directory is project root * fix: remove unused PurePosixPath import; fix stale PS comment after ConvertFrom-Json fallback was added * fix: use cand.as_posix() for outside-root path instead of raw bash-side argv --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../scripts/bash/update-agent-context.sh | 79 ++++++- .../powershell/update-agent-context.ps1 | 98 ++++++-- .../test_update_agent_context_feature_json.py | 211 ++++++++++++++++++ 3 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 tests/extensions/test_update_agent_context_feature_json.py diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 9d57b08cf5..64e1bae89b 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -10,9 +10,9 @@ # # Usage: update-agent-context.sh [plan_path] # -# When `plan_path` is omitted, the script picks the most recently modified -# `specs/*/plan.md` if any exist, otherwise emits the section without a -# concrete plan path. +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. set -euo pipefail @@ -202,23 +202,78 @@ unset _cf_parts _seg PLAN_PATH="${1:-}" if [[ -z "$PLAN_PATH" ]]; then - # Pick the most recently modified plan.md one level deep (specs//plan.md). - # Use find + sort by modification time to avoid ls/head fragility with - # spaces in paths or SIGPIPE from pipefail. - _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' -import sys, os + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + _feature_json="$PROJECT_ROOT/.specify/feature.json" + if [[ -f "$_feature_json" ]]; then + _feature_dir="$("$_python" - "$_feature_json" <<'PY' +import sys, json +try: + with open(sys.argv[1], encoding="utf-8") as fh: + d = json.load(fh) + val = d.get("feature_directory", "") + print(val if isinstance(val, str) else "") +except Exception: + print("") +PY +)" + # Normalize backslashes (written by PS on Windows) to forward slashes before path ops. + _feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')" + _feature_dir="${_feature_dir%/}" + if [[ -n "$_feature_dir" ]]; then + # feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT + # are preserved as-is by _persist_feature_json in common.sh). + # Also match drive-qualified paths (C:/...) written by PowerShell on Windows. + if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then + _candidate="$_feature_dir/plan.md" + else + _candidate="$PROJECT_ROOT/$_feature_dir/plan.md" + fi + if [[ -f "$_candidate" ]]; then + # Resolve symlinks before comparing so paths like /var/… vs /private/var/… + # (macOS) are treated as equivalent. Mirrors the mtime-fallback approach. + PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY' +import sys from pathlib import Path -specs = Path(sys.argv[1]) / "specs" +root = Path(sys.argv[1]).resolve() +cand = Path(sys.argv[2]).resolve() +try: + print(cand.relative_to(root).as_posix()) +except ValueError: + # Outside project root: emit the resolved path in POSIX form. + # as_posix() converts backslashes correctly on native Windows Python. + print(cand.as_posix()) +PY +)" + fi + fi + fi + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + # Python emits a project-relative POSIX path directly to avoid bash prefix-strip + # issues with backslash paths on Windows (Git bash / MSYS2). + if [[ -z "$PLAN_PATH" ]]; then + _plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys +from pathlib import Path +root = Path(sys.argv[1]).resolve() +specs = root / "specs" plans = sorted( specs.glob("*/plan.md"), key=lambda p: p.stat().st_mtime, reverse=True, ) -print(plans[0] if plans else "") +if plans: + try: + print(plans[0].relative_to(root).as_posix()) + except ValueError: + print("") +else: + print("") PY )" - if [[ -n "$_plan_abs" ]]; then - PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + if [[ -n "$_plan_rel" ]]; then + PLAN_PATH="$_plan_rel" + fi fi fi diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index d31fcd64c0..da9ff443cb 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -9,6 +9,10 @@ # .specify/extensions/agent-context/agent-context-config.yml # # Usage: update-agent-context.ps1 [plan_path] +# +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. [CmdletBinding()] param( @@ -126,14 +130,26 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) { $Options = $null if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { try { - $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + $Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop + } catch { + # fall through to ConvertFrom-Json fallback + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps, + # works when the config file is valid JSON, which is a subset of YAML). + try { + $raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 + $Options = $raw | ConvertFrom-Json -ErrorAction Stop + if (-not (Test-ConfigObject -Object $Options)) { $Options = $null } } catch { - # fall through to Python fallback + $Options = $null } } if ($null -eq $Options) { - # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. + # ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML. $pythonCmd = $null $pythonCandidates = @() if ($env:SPECKIT_PYTHON) { @@ -280,21 +296,69 @@ if ($cm) { } if (-not $PlanPath) { - # Discover plan.md exactly one level deep (specs//plan.md), - # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under - # $ErrorActionPreference = 'Stop' don't abort the script. - try { - $specsDir = Join-Path $ProjectRoot 'specs' - $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | - Where-Object { $_ } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + $FeatureJson = Join-Path $ProjectRoot '.specify/feature.json' + if (Test-Path -LiteralPath $FeatureJson) { + try { + $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json + $featureDir = $fj.feature_directory + if ($featureDir -isnot [string] -or -not $featureDir) { + $featureDir = $null + } else { + $featureDir = $featureDir.TrimEnd('\', '/') + } + if ($featureDir) { + # Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly. + if ([System.IO.Path]::IsPathRooted($featureDir)) { + $candidatePlan = Join-Path $featureDir 'plan.md' + } else { + $candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md' + } + if (Test-Path -LiteralPath $candidatePlan) { + # Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()). + # GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible). + $resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan) + $resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan) + $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($normDir.StartsWith($normRoot, $cmp)) { + $relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/') + $PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' } + } else { + $PlanPath = $resolvedPlan.Replace('\', '/') + } + } + } + } catch { + # Non-fatal: fall through to mtime heuristic. + } + } + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + if (-not $PlanPath) { + try { + $specsDir = Join-Path $ProjectRoot 'specs' + $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | + Where-Object { $_ } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + # GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat. + # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). + $fullPath = $candidate.FullName.Replace('\', '/') + $normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/' + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($fullPath.StartsWith($normRoot, $cmp)) { + $PlanPath = $fullPath.Substring($normRoot.Length) + } else { + $PlanPath = $fullPath + } + } + } catch { + # Non-fatal: continue without a plan path. } - } catch { - # Non-fatal: continue without a plan path. } } diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py new file mode 100644 index 0000000000..957415708c --- /dev/null +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -0,0 +1,211 @@ +"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash +from tests.extensions.test_extension_agent_context import ( + BASH, + POWERSHELL, + _bash_posix_path, + _run_bash_agent_context_script, + _run_powershell_agent_context_script, +) + + +def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: + """Write agent-context extension config as JSON. + + JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in + ConvertFrom-Json can parse it without needing powershell-yaml or Python. + Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json + fallback actually works on Windows CI. + """ + cfg_dir = root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + json.dumps({ + "context_file": context_file, + "context_markers": { + "start": "", + "end": "", + }, + }), + encoding="utf-8", + ) + + +def _write_feature_json(root: Path, feature_directory: str) -> None: + specify_dir = root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "feature.json").write_text( + json.dumps({"feature_directory": feature_directory}), + encoding="utf-8", + ) + + +def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path: + p = root / feature_dir / "plan.md" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return p + + +@requires_bash +def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """feature.json points to the active feature; that plan.md is injected.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/001-active") + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + + +@requires_bash +def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """An older spec's plan.md modified more recently must NOT win over feature.json.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None: + """No feature.json → mtime fallback selects the most recently modified plan.""" + _setup_project(tmp_path) + old = _make_plan(tmp_path, "specs/000-old") + newer = _make_plan(tmp_path, "specs/001-newer") + now = time.time() + os.utime(old, (now - 10, now - 10)) + os.utime(newer, (now, now)) + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-newer/plan.md" in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None: + """feature.json exists but plan.md not yet written → fall back to mtime.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/000-old") + _write_feature_json(tmp_path, "specs/001-new") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/000-old/plan.md" in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: + """Absolute feature_directory under PROJECT_ROOT → project-relative path in context.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Write POSIX absolute path — mtime would pick 000-stale without feature.json + _write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active")) + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert _bash_posix_path(tmp_path) not in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, _bash_posix_path(external)) + + result = _run_bash_agent_context_script(project) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert _bash_posix_path(external) + "/plan.md" in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory under project root is normalized to relative path.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form + _write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active")) + + result = _run_powershell_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "at specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert tmp_path.resolve().as_posix() not in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """PowerShell: stale plan touched more recently must not win over feature.json.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_powershell_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory outside project root → absolute path preserved.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, str(external)) + + result = _run_powershell_agent_context_script(project) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert external.resolve().as_posix() + "/plan.md" in ctx From c49966da4d680b01a92230d6c1b04e543086224b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:11:54 -0500 Subject: [PATCH 29/60] fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188) PR #2511 added `context: fork` + `agent: general-purpose` to the generated speckit-analyze SKILL.md on the assumption that its heavy reads collapse to a short summary. In practice /speckit-analyze returns a 300-500 line report that is injected back into the main conversation. In long sessions each subsequent fork inherits that growing context, compounding overhead until the chat freezes (#3185). Empty FORK_CONTEXT_COMMANDS so no command opts into context: fork, restoring direct in-session execution for analyze. The injection mechanism is retained so a command can be re-enabled once it genuinely returns a compact result. Fixes #3185 Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../integrations/claude/__init__.py | 18 ++-- tests/integrations/test_integration_claude.py | 93 ++++++++----------- 2 files changed, 51 insertions(+), 60 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 0df388172d..41d5b14b10 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -22,13 +22,17 @@ } # Per-command frontmatter overrides for skills that should run in a forked -# subagent context. Read-only analysis commands are good candidates: the -# heavy reads (spec/plan/tasks artefacts) collapse to a short summary, -# so isolating them keeps the main conversation context clean. -# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent -FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = { - "analyze": {"context": "fork", "agent": "general-purpose"}, -} +# subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent +# +# This is intentionally empty. ``analyze`` was previously forked (added in +# #2511) on the assumption that its heavy reads collapse to a short summary, +# but in practice ``/speckit-analyze`` returns a 300-500 line report that is +# injected back into the main conversation. In long sessions each subsequent +# fork inherits that growing context, compounding overhead until the chat +# freezes (#3185). Until a command genuinely returns a compact result, no +# command opts into ``context: fork``. The injection mechanism below stays in +# place so a future command can be added here when that holds true. +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {} class ClaudeIntegration(SkillsIntegration): diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index c7ecef95d0..8f96527b2f 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -539,8 +539,16 @@ def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_p class TestClaudeForkContext: """Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS.""" - def test_analyze_skill_runs_in_forked_subagent(self, tmp_path): - """speckit-analyze must opt into context: fork + agent.""" + def test_no_commands_fork_by_default(self): + """FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork. + + ``analyze`` was removed (#3185) because its verbose report defeated the + purpose of forking and compounded context overhead across repeated runs. + """ + assert FORK_CONTEXT_COMMANDS == {} + + def test_analyze_skill_does_not_fork(self, tmp_path): + """speckit-analyze must run in the main session, not a forked subagent (#3185).""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) i.setup(tmp_path, m, script_type="sh") @@ -549,10 +557,10 @@ def test_analyze_skill_runs_in_forked_subagent(self, tmp_path): content = analyze_skill.read_text(encoding="utf-8") parts = content.split("---", 2) parsed = yaml.safe_load(parts[1]) - assert parsed.get("context") == "fork" - assert parsed.get("agent") == "general-purpose" + assert "context" not in parsed + assert "agent" not in parsed - def test_other_skills_do_not_fork(self, tmp_path): + def test_no_skills_fork(self, tmp_path): """Skills not in FORK_CONTEXT_COMMANDS must not get context: fork.""" i = get_integration("claude") m = IntegrationManifest("claude", tmp_path) @@ -574,60 +582,39 @@ def test_other_skills_do_not_fork(self, tmp_path): f"{f.parent.name}: must not have agent frontmatter" ) - def test_fork_flags_inside_frontmatter(self, tmp_path): - """context/agent must appear in the frontmatter, not in the body.""" + def test_post_process_no_fork_for_skills(self): + """With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent.""" i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - i.setup(tmp_path, m, script_type="sh") - analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" - content = analyze_skill.read_text(encoding="utf-8") - parts = content.split("---", 2) - assert len(parts) >= 3 - frontmatter = parts[1] - body = parts[2] - assert "context: fork" in frontmatter - assert "agent: general-purpose" in frontmatter - assert "context: fork" not in body - assert "agent: general-purpose" not in body - - def test_fork_injection_idempotent(self, tmp_path): - """Re-running setup must not duplicate the fork frontmatter keys.""" - i = get_integration("claude") - m = IntegrationManifest("claude", tmp_path) - i.setup(tmp_path, m, script_type="sh") - i.setup(tmp_path, m, script_type="sh") - analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md" - content = analyze_skill.read_text(encoding="utf-8") - assert content.count("context: fork") == 1 - assert content.count("agent: general-purpose") == 1 - - def test_fork_context_injected_via_post_process(self): - """Preset/extension generators call post_process_skill_content directly, - bypassing setup(); fork context must be injected there too.""" + for name in ("speckit-analyze", "speckit-plan"): + content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n' + result = i.post_process_skill_content(content) + parsed = yaml.safe_load(result.split("---", 2)[1]) + assert "context" not in parsed + assert "agent" not in parsed + + def test_fork_mechanism_injects_when_configured(self, monkeypatch): + """The injection mechanism still works for any command added to + FORK_CONTEXT_COMMANDS, even though none ships enabled by default.""" + import specify_cli.integrations.claude as claude_mod + + monkeypatch.setitem( + claude_mod.FORK_CONTEXT_COMMANDS, + "analyze", + {"context": "fork", "agent": "general-purpose"}, + ) i = get_integration("claude") content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' result = i.post_process_skill_content(content) - parsed = yaml.safe_load(result.split("---", 2)[1]) + parts = result.split("---", 2) + parsed = yaml.safe_load(parts[1]) assert parsed.get("context") == "fork" assert parsed.get("agent") == "general-purpose" - assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"] - - def test_post_process_no_fork_for_other_skills(self): - """Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent.""" - i = get_integration("claude") - content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n' - result = i.post_process_skill_content(content) - parsed = yaml.safe_load(result.split("---", 2)[1]) - assert "context" not in parsed - assert "agent" not in parsed - - def test_post_process_fork_idempotent(self): - """Re-running post_process must not duplicate fork frontmatter keys.""" - i = get_integration("claude") - content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n' - once = i.post_process_skill_content(content) - twice = i.post_process_skill_content(once) - assert once == twice + # Flags must land in the frontmatter, not the body. + assert "context: fork" in parts[1] + assert "context: fork" not in parts[2] + # Re-running must not duplicate the injected keys. + twice = i.post_process_skill_content(result) + assert result == twice assert twice.count("context: fork") == 1 assert twice.count("agent: general-purpose") == 1 From 916e29b27bc367592ea9fd11041460a7495ac615 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 27 Jun 2026 00:20:51 +0700 Subject: [PATCH 30/60] Docs: document missing flags --force and --refresh-shared-infra (#3179) * Docs: document missing flags --force and --refresh-shared-infra Fixes #3177 * Address review: Reorder flags to match CLI help output --- docs/reference/extensions.md | 1 + docs/reference/integrations.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 923d0b9b82..90e5ab8747 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -26,6 +26,7 @@ specify extension add | --------------- | -------------------------------------------------------- | | `--dev` | Install from a local directory (for development) | | `--from ` | Install from a custom URL instead of the catalog | +| `--force` | Overwrite if already installed | | `--priority `| Resolution priority (default: 10; lower = higher precedence) | Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 5746382161..c52ea719ff 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -100,6 +100,7 @@ specify integration switch | ------------------------ | ------------------------------------------------------------------------ | | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | | `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default | +| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) | | `--integration-options` | Options for the target integration when it is not already installed | If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. From 465d29910e0e477200a58353084c2a51542f762c Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 27 Jun 2026 00:21:38 +0700 Subject: [PATCH 31/60] Docs: add cline and zcode to multi-install-safe table (#3180) Fixes #3175 --- docs/reference/integrations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index c52ea719ff..36829ec8be 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -185,6 +185,7 @@ The currently declared multi-install safe integrations are: | --- | --------- | | `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` | | `claude` | `.claude/skills`, `CLAUDE.md` | +| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` | | `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` | | `codex` | `.agents/skills`, `AGENTS.md` | | `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` | @@ -200,6 +201,7 @@ The currently declared multi-install safe integrations are: | `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` | | `trae` | `.trae/skills`, `.trae/rules/project_rules.md` | | `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` | +| `zcode` | `.zcode/skills`, `ZCODE.md` | Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`. From b540ff4e78a1654a05e8a6b564c929c07a74cd52 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:27:17 -0500 Subject: [PATCH 32/60] chore: release 0.11.9, begin 0.11.10.dev0 development (#3189) * chore: bump version to 0.11.9 * chore: begin 0.11.10.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10491ee0d7..3c036a1884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ +## [0.11.9] - 2026-06-26 + +### Changed + +- Docs: add cline and zcode to multi-install-safe table (#3180) +- Docs: document missing flags --force and --refresh-shared-infra (#3179) +- fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188) +- fix: derive plan path from feature.json in update-agent-context (#3069) +- fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954) +- chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173) +- Update SicarioSpec Core preset to v0.5.1 (#3165) +- fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157) +- Update preset composition strategy reference (#3143) +- fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129) +- fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901) +- Point sicario-core docs to preset README (#3120) +- chore: release 0.11.8, begin 0.11.9.dev0 development (#3156) + ## [0.11.8] - 2026-06-24 ### Changed diff --git a/pyproject.toml b/pyproject.toml index c3ba380349..c01db9a53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.9.dev0" +version = "0.11.10.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 3e97b10693d942135ef470baa5c7f1708cf964d1 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Sat, 27 Jun 2026 00:32:35 +0700 Subject: [PATCH 33/60] Docs: Document /speckit.converge command (#3181) * docs: document /speckit.converge command * docs: clarify converge and implement loop --- docs/guides/evolving-specs.md | 2 ++ docs/installation.md | 9 ++++++++- docs/quickstart.md | 12 ++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/guides/evolving-specs.md b/docs/guides/evolving-specs.md index feb2c88706..e2941f08b3 100644 --- a/docs/guides/evolving-specs.md +++ b/docs/guides/evolving-specs.md @@ -26,6 +26,7 @@ through the standard flow: 2. Run `/speckit.plan` to define the implementation approach. 3. Run `/speckit.tasks` to derive the work breakdown. 4. Run `/speckit.implement` and review the resulting code and artifact diffs. +5. Run `/speckit.converge` to verify completeness and generate tasks for remaining gaps. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete. The previous feature directory remains intact for audit, comparison, or explaining how the project reached its current state. Use clear feature names or @@ -50,6 +51,7 @@ spec: 5. Run `/speckit.analyze` before implementation resumes to catch gaps between the spec, plan, and tasks. 6. Run `/speckit.implement`, then review the code and artifact diffs together. +7. Run `/speckit.converge` to assess completion and append any remaining work to `tasks.md`. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete. Preserve important implementation rationale before replacing derived artifacts. If a plan or task list contains decisions that still matter, carry them forward diff --git a/docs/installation.md b/docs/installation.md index 0f4c9124ec..12708dc5d6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -94,8 +94,15 @@ This helps verify you are running the official Spec Kit build from GitHub, not a After initialization, you should see the following commands available in your coding agent: - `/speckit.specify` - Create specifications -- `/speckit.plan` - Generate implementation plans +- `/speckit.plan` - Generate implementation plans - `/speckit.tasks` - Break down into actionable tasks +- `/speckit.implement` - Execute implementation tasks +- `/speckit.analyze` - Validate cross-artifact consistency +- `/speckit.clarify` - Identify and resolve ambiguities +- `/speckit.checklist` - Generate quality checklists +- `/speckit.constitution` - Create or update project principles +- `/speckit.converge` - Assess codebase against artifacts and append remaining tasks +- `/speckit.taskstoissues` - Convert tasks to issues Scripts are installed into a variant subdirectory matching the chosen script type: diff --git a/docs/quickstart.md b/docs/quickstart.md index 964c1f1da4..d03808da5b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,10 +13,10 @@ This guide will help you get started with Spec-Driven Development using Spec Kit After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates: ```text -/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement +/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement -> /speckit.converge ``` -Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. +Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. Finally, run `/speckit.converge` after implementation to verify all planned work is complete and generate tasks for any remaining gaps. If `/speckit.converge` appends new tasks, run `/speckit.implement` again (and converge again) until it reports that the feature has converged. ### Step 1: Install Specify @@ -188,6 +188,14 @@ Finally, implement the solution: /speckit.implement ``` +### Step 8: Converge + +Run the `/speckit.converge` command after implementation to assess the current codebase against the feature's artifacts and append any remaining unbuilt work as new tasks to `tasks.md`. If the command appends new tasks, run `/speckit.implement` again to complete them, and repeat the converge step until the feature is fully complete. + +```bash +/speckit.converge +``` + > [!TIP] > **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage. From b7e67f55bf7a937aaa57dbe0a8198774e285de3a Mon Sep 17 00:00:00 2001 From: "siC@r10-mw" <129638058+SiCar10mw@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:56:34 -0500 Subject: [PATCH 34/60] Add community bundle submission path (#3162) * Add community bundle submission path * Address bundle submission review feedback * Align bundle submission triage label * Clarify bundle submission review scope * Clarify community bundle catalog listing --- .github/ISSUE_TEMPLATE/bundle_submission.yml | 293 +++++++++++++++++++ README.md | 9 +- docs/community/bundles.md | 53 ++++ docs/community/overview.md | 8 +- docs/reference/bundles.md | 6 + docs/toc.yml | 2 + 6 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bundle_submission.yml create mode 100644 docs/community/bundles.md diff --git a/.github/ISSUE_TEMPLATE/bundle_submission.yml b/.github/ISSUE_TEMPLATE/bundle_submission.yml new file mode 100644 index 0000000000..c2b928f3a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bundle_submission.yml @@ -0,0 +1,293 @@ +name: Bundle Submission +description: Submit your bundle metadata for community catalog validation +title: "[Bundle]: Add " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing a bundle! This template captures metadata for maintainers to validate formatting, links, component resolution, and installation evidence. Maintainers do not audit, endorse, or support bundle code or installed components. + + **Before submitting:** + - Review the [Bundles reference](https://github.com/github/spec-kit/blob/main/docs/reference/bundles.md) + - Ensure your bundle has a valid `bundle.yml` manifest + - Create a GitHub release with a versioned bundle artifact + - Test installation from a downloaded artifact: `specify bundle install ./your-bundle-1.0.0.zip` + - If you host a bundle catalog, test catalog installation with `specify bundle catalog add --id --policy install-allowed` and `specify bundle install ` + - If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project + + - type: input + id: bundle-id + attributes: + label: Bundle ID + description: Unique bundle identifier; must start and end with a lowercase letter or digit and may contain lowercase letters, digits, dots, underscores, and hyphens between + placeholder: "e.g., security-governance-stack" + validations: + required: true + + - type: input + id: bundle-name + attributes: + label: Bundle Name + description: Human-readable bundle name + placeholder: "e.g., Security Governance Stack" + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Semantic version number + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: input + id: role + attributes: + label: Role or Team + description: Primary role, team, or persona this bundle provisions + placeholder: "e.g., security-engineer, product-manager, platform-team" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of the stack this bundle installs + placeholder: Installs a security governance stack with compliance presets, review commands, and evidence workflows + validations: + required: true + + - type: input + id: author + attributes: + label: Author + description: Your name or organization + placeholder: "e.g., Jane Doe or Acme Corp" + validations: + required: true + + - type: input + id: repository + attributes: + label: Repository URL + description: GitHub repository URL for your bundle source + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle" + validations: + required: true + + - type: input + id: download-url + attributes: + label: Download URL + description: URL to the versioned bundle artifact generated by `specify bundle build` + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip" + validations: + required: true + + - type: input + id: documentation + attributes: + label: Documentation URL + description: Link to documentation that explains what the bundle installs and how to use it + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/blob/main/README.md" + validations: + required: true + + - type: input + id: license + attributes: + label: License + description: Open source license type + placeholder: "e.g., MIT, Apache-2.0" + validations: + required: true + + - type: input + id: speckit-version + attributes: + label: Required Spec Kit Version + description: Minimum Spec Kit version required by the bundle + placeholder: "e.g., >=0.9.0" + validations: + required: true + + - type: input + id: integration + attributes: + label: Integration Target (optional) + description: Integration ID if the bundle pins one; leave empty if integration-agnostic + placeholder: "e.g., claude, copilot, gemini" + + - type: textarea + id: components-provided + attributes: + label: Components Provided + description: List the extensions, presets, workflows, and steps this bundle installs + placeholder: | + - extensions: sicario-guard@0.5.1 + - presets: sicario-core@0.5.1, sicario-ai-governance@0.5.1 + - workflows: evidence-review@1.0.0 + - steps: threat-model + validations: + required: true + + - type: textarea + id: required-catalogs + attributes: + label: Required Component Catalogs + description: List any non-default catalogs users must add before this bundle can resolve its components; enter "None" if every component resolves from built-in or bundled catalogs + placeholder: | + - Presets: https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json + - Extensions: https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json + validations: + required: true + + - type: textarea + id: tags + attributes: + label: Tags + description: 2-5 relevant tags (lowercase, separated by commas) + placeholder: "security, governance, compliance" + validations: + required: true + + - type: textarea + id: features + attributes: + label: Key Features + description: List the main capabilities this bundle provides + placeholder: | + - Installs evidence-first security governance templates + - Adds automated bundle verification commands + - Pins all components to release-tested versions + validations: + required: true + + - type: checkboxes + id: testing + attributes: + label: Testing Checklist + description: Confirm that your bundle has been tested + options: + - label: Validation succeeds with `specify bundle validate --path ` + required: true + - label: Build succeeds with `specify bundle build --path ` and produces the submitted artifact + required: true + - label: Bundle installs successfully from the built artifact + required: true + - label: The submitted distribution path was tested end to end, including bundle-ID installation from an install-allowed catalog when a catalog entry is proposed + required: true + - label: Installation was tested in a clean Spec Kit project + required: true + - label: Required component catalogs are documented and were included in testing, or no extra catalogs are required + required: true + - label: Documentation is complete and accurate + required: true + + - type: checkboxes + id: requirements + attributes: + label: Submission Requirements + description: Verify your bundle meets all requirements + options: + - label: Valid `bundle.yml` manifest included + required: true + - label: README.md explains the bundle's intended role, installed components, and installation steps + required: true + - label: LICENSE file included + required: true + - label: GitHub release created with a version tag + required: true + - label: Bundle ID matches the manifest and follows naming conventions + required: true + - label: Every extension, preset, workflow, and step reference is pinned where the manifest requires a version + required: true + + - type: textarea + id: testing-details + attributes: + label: Testing Details + description: Describe how you tested your bundle + placeholder: | + **Tested on:** + - macOS 15 with Spec Kit v0.9.0 + - Ubuntu 24.04 with Spec Kit v0.9.0 + + **Test project:** [Link or description] + + **Test scenarios:** + 1. Added required catalogs + 2. Validated bundle manifest + 3. Built release artifact + 4. Installed bundle in a clean project + 5. Ran the installed commands or workflows + validations: + required: true + + - type: textarea + id: example-usage + attributes: + label: Example Usage + description: Provide a simple example of installing and using your bundle + render: markdown + placeholder: | + ```bash + # Add any required component catalogs first + specify preset catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json --name your-bundle --install-allowed + specify extension catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json --name your-bundle --install-allowed + + # Install the downloaded bundle artifact + curl -L -o your-bundle-1.0.0.zip https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip + specify bundle install ./your-bundle-1.0.0.zip + + # Or test through an install-allowed bundle catalog + specify bundle catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/bundles.json --id your-bundle-catalog --policy install-allowed + specify bundle install your-bundle + ``` + validations: + required: true + + - type: textarea + id: catalog-entry + attributes: + label: Proposed Catalog Entry + description: Provide the JSON entry that would appear under the top-level `bundles` object in a bundle catalog (helps reviewers) + render: json + placeholder: | + { + "your-bundle": { + "name": "Your Bundle", + "id": "your-bundle", + "version": "1.0.0", + "role": "security-engineer", + "description": "Brief description of the stack", + "author": "Your Name", + "license": "MIT", + "download_url": "https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip", + "repository": "https://github.com/your-org/your-bundle", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 2, + "steps": 0, + "workflows": 1 + }, + "tags": ["security", "governance"], + "verified": false + } + } + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that would help reviewers + placeholder: Screenshots, demo videos, links to related projects, dependency-resolution notes, etc. diff --git a/README.md b/README.md index 86d49da48f..e44faf654b 100644 --- a/README.md +++ b/README.md @@ -134,13 +134,14 @@ Explore community-contributed resources on the [Spec Kit docs site](https://gith - [Extensions](https://github.github.io/spec-kit/community/extensions.html) — commands, hooks, and capabilities - [Presets](https://github.github.io/spec-kit/community/presets.html) — template and terminology overrides +- [Bundles](https://github.github.io/spec-kit/community/bundles.html) — role and team stacks composed from existing components - [Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) — end-to-end SDD scenarios - [Friends](https://github.github.io/spec-kit/community/friends.html) — projects that extend or build on Spec Kit > [!NOTE] > Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion. -Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md). +Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md), the [Presets Publishing Guide](presets/PUBLISHING.md), or the [Community Bundles guide](docs/community/bundles.md). ## 🤖 Supported AI Coding Agent Integrations @@ -262,8 +263,10 @@ built-in). Each source carries an install policy: `install-allowed` sources can be installed from, while `discovery-only` sources are visible in `search`/`info` but refuse installation. Manage the stack with `specify bundle catalog list|add|remove`. -Authors validate and package bundles locally — there is no first-class publish; -distribution is hosting the built artifact and adding a catalog entry: +Authors validate and package bundles locally. Distribution is hosting the built +artifact and adding a catalog source; community bundle submissions use the +[Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) +issue template so required component catalogs and install evidence can be reviewed: ```bash specify bundle validate --path ./my-bundle # structural + reference checks diff --git a/docs/community/bundles.md b/docs/community/bundles.md new file mode 100644 index 0000000000..101013034d --- /dev/null +++ b/docs/community/bundles.md @@ -0,0 +1,53 @@ +# Community Bundles + +> [!NOTE] +> Community bundles are independently created and maintained by their respective authors. Maintainers only verify that submission metadata is complete and correctly formatted — they do **not review, audit, endorse, or support the bundle code or the components it installs**. Review bundle manifests, component catalogs, and source repositories before installation and use at your own discretion. + +Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands. + +Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue. + +## What to Submit + +A bundle submission should include: + +- A public repository with a valid `bundle.yml` manifest. +- A versioned GitHub release with a bundle artifact created by `specify bundle build`. +- Documentation that explains the intended role, installed components, required catalogs, and expected workflow. +- A proposed catalog entry with bundle metadata and component counts. +- Test evidence from a clean Spec Kit project. + +## Component Resolution + +A bundle catalog entry describes where to download the bundle artifact, but the bundle's component references still need to resolve when a user installs it. References can resolve from bundled components, already installed components, or active extension, preset, workflow, and step catalogs. + +If your bundle depends on components that are not available from the default Spec Kit catalogs, include the required catalog URLs in the submission and in your README. Test the full install path from a clean project with those catalogs added before submitting. + +For example: + +```bash +specify preset catalog add https://example.com/presets.json --name example-bundle --install-allowed +specify extension catalog add https://example.com/extensions.json --name example-bundle --install-allowed +curl -L -o example-bundle-1.0.0.zip https://example.com/example-bundle-1.0.0.zip +specify bundle install ./example-bundle-1.0.0.zip + +# Or install by id from an install-allowed bundle catalog. +specify bundle catalog add https://example.com/bundles.json --id example-bundle-catalog --policy install-allowed +specify bundle install example-bundle +``` + +## Review Scope + +Maintainers check that: + +- The submission fields are complete and correctly formatted. +- The release artifact and documentation URLs are reachable. +- The repository contains a `bundle.yml` manifest. +- The submission clearly identifies any required component catalogs. +- The proposed catalog entry uses the expected bundle catalog entry shape. + +Maintainers do not audit the behavior of installed extensions, presets, workflows, steps, or scripts. Users should review those components before installing a community bundle. + +## Updating a Bundle + +To update a submitted bundle, file another [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue with the new version, download URL, changed component list, and updated test evidence. Mention that the issue updates an existing bundle entry. diff --git a/docs/community/overview.md b/docs/community/overview.md index 99804be3c3..000c27bc69 100644 --- a/docs/community/overview.md +++ b/docs/community/overview.md @@ -1,6 +1,6 @@ # Community -The Spec Kit community builds extensions, presets, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors. +The Spec Kit community builds extensions, presets, bundles, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors. ## Extensions @@ -14,6 +14,12 @@ Presets customize how Spec Kit behaves — overriding templates, commands, and t [Browse community presets →](presets.md) +## Bundles + +Bundles compose extensions, presets, workflows, and steps into role or team stacks that can be installed together. + +[Browse community bundles →](bundles.md) + ## Walkthroughs Step-by-step guides that show Spec-Driven Development in action across different scenarios, languages, and frameworks. diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 2a7384cf6b..57f3c700b1 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -119,6 +119,12 @@ specify bundle build Produces a single versioned, distributable `.zip` artifact from a bundle directory. The artifact embeds the manifest and can be installed directly with `specify bundle install `. +## Publish a Bundle + +Bundle authors validate and package bundles locally, then host the generated artifact and catalog metadata where users can access it. A bundle catalog entry points at the bundle artifact, but the components declared inside `bundle.yml` still resolve through bundled components, installed components, or active extension, preset, workflow, and step catalogs. + +If your bundle references components from non-default catalogs, document those catalog URLs and test the install path from a clean project with those catalogs added. Community bundle submissions should include that dependency-resolution evidence in the [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue. + ## Manage Catalog Sources Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). diff --git a/docs/toc.yml b/docs/toc.yml index 711abb3375..1fb55dc83f 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -66,6 +66,8 @@ href: community/extensions.md - name: Presets href: community/presets.md + - name: Bundles + href: community/bundles.md - name: Walkthroughs href: community/walkthroughs.md - name: Friends From 537503417b0f1ad8a15f1676e264e9d0f661e89f Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 27 Jun 2026 01:02:07 +0200 Subject: [PATCH 35/60] test: rotate init coverage for manifest isolation Assisted-by: Codex (model: GPT-5, autonomous) --- tests/integrations/test_registry.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 9bcb4b2c44..e0bd208a8b 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -50,7 +50,15 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]: def _multi_install_safe_orders() -> list[list[str]]: safe_keys = _multi_install_safe_keys() - return [safe_keys, list(reversed(safe_keys))] + if len(safe_keys) < 2: + return [safe_keys] + return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))] + + +def _multi_install_safe_order_id(ordered_keys: list[str]) -> str: + if not ordered_keys: + return "no-safe-integrations" + return f"init-{ordered_keys[0]}" def _posix_path(value: str | None) -> str | None: @@ -238,7 +246,7 @@ def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, secon @pytest.mark.parametrize( "ordered_keys", _multi_install_safe_orders(), - ids=["forward", "reverse"], + ids=_multi_install_safe_order_id, ) def test_safe_integrations_have_disjoint_manifests( self, @@ -260,10 +268,9 @@ def test_safe_integrations_have_disjoint_manifests( # pairwise manifest isolation. Each safe integration writes only to its # own (disjoint) directories and always records what it writes, so a # manifest's contents are independent of install order and of which other - # integrations are co-installed. The two parametrized orders therefore - # produce the same manifests; their purpose is to route a different - # integration through the `init` path versus `integration install` - # (forward installs the first key via init, reverse the last). + # integrations are co-installed. The parametrized rotations keep the + # aggregate setup while placing each safe integration first once, so each + # one still exercises the `specify init --integration ...` path. original_cwd = os.getcwd() try: os.chdir(project_root) From fd185c1fd82d53153daa14d41dfc264a4d029537 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:52:22 +0500 Subject: [PATCH 36/60] fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196) Get-BranchName used `[long]$Number = 0` as both the default and the 'auto-detect' sentinel (`if ($Number -eq 0)`), so an explicitly-passed `-Number 0` was indistinguishable from 'not supplied' and silently auto-incremented. The bash twin keys off whether the value is non-empty (`[ -z "$BRANCH_NUMBER" ]`), so `--number 0` is honored and yields FEATURE_NUM 000 -- a cross-platform divergence for identical input. Use $PSBoundParameters.ContainsKey('Number') instead, so an explicit value (including 0) is honored and only a missing -Number auto-detects -- mirroring bash. This also aligns the -Timestamp+-Number warning, which bash emits for `--number 0 --timestamp` (non-empty check) but PowerShell previously skipped. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/powershell/create-new-feature.ps1 | 12 ++++++--- tests/test_timestamp_branches.py | 30 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 12f15ba312..5826815e01 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -142,8 +142,10 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } -# Warn if -Number and -Timestamp are both specified -if ($Timestamp -and $Number -ne 0) { +# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not +# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's +# `[ -n "$BRANCH_NUMBER" ]` check. +if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) { Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" $Number = 0 } @@ -153,8 +155,10 @@ if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $branchName = "$featureNum-$branchSuffix" } else { - # Determine branch number from existing feature directories - if ($Number -eq 0) { + # Determine branch number from existing feature directories. Auto-detect only + # when -Number was not supplied; an explicit value (including 0) is honored, + # matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check. + if (-not $PSBoundParameters.ContainsKey('Number')) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 93207746a1..6fe0c14ed5 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -275,6 +275,19 @@ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path): branch = line.split(":", 1)[1].strip() assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}" + def test_explicit_number_zero_is_honored(self, git_repo: Path): + """An explicit --number 0 is honored literally (FEATURE_NUM 000), not treated + as auto-detect, even when higher-numbered specs already exist. This pins the + canonical bash behavior the PowerShell twin must mirror.""" + (git_repo / "specs" / "003-existing").mkdir(parents=True) + r = run_script( + git_repo, "--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature", + ) + assert r.returncode == 0, r.stderr + data = json.loads(r.stdout) + assert data["FEATURE_NUM"] == "000" + assert data["BRANCH_NAME"] == "000-zero" + class TestSequentialBranchPowerShell: def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): @@ -302,6 +315,23 @@ def _run(desc: str) -> subprocess.CompletedProcess: assert r2.returncode == 0, r2.stderr assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_explicit_number_zero_is_honored_matching_bash(self, ps_git_repo: Path): + """An explicit -Number 0 must be honored (FEATURE_NUM 000) like the bash twin, + even when higher-numbered specs exist. Before the fix, PowerShell could not + distinguish -Number 0 from the default and silently auto-detected (e.g. 004).""" + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + (ps_git_repo / "specs" / "003-existing").mkdir(parents=True) + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), + "-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature"], + cwd=ps_git_repo, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["FEATURE_NUM"] == "000" + assert data["BRANCH_NAME"] == "000-zero" + # ── check_feature_branch Tests ─────────────────────────────────────────────── From 2a9db1d350b3c8a362d9009fb9fc9f1ac355511b Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:53:35 +0500 Subject: [PATCH 37/60] fix(workflows): make expression operator/literal parsing quote-aware (#3197) _evaluate_simple_expression split on operator keywords using naive str.find/split, so a keyword INSIDE a quoted operand was treated as an operator: `inputs.mode == 'read and write'` split on the inner ' and ' and evaluated as `(inputs.mode == 'read) and (write')`. The literal short-circuit was also too greedy -- `'a' == 'b'` matched startswith("'")/endswith("'") and was stripped to the garbage truthy string `a' == 'b`, so `'done' == 'failed'` evaluated truthy and gated the wrong branch. Add a quote/bracket-aware _find_top_level helper (mirroring the existing _split_top_level_commas) and use it for the and/or/comparison/in/not-in splits; tighten the literal short-circuit to fire only when the opening quote's match is the final char. The docstring already lists comparisons + and/or/not + in/not-in + string literals as supported, so this restores the documented contract. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/expressions.py | 72 +++++++++++++++++------- tests/test_workflows.py | 36 ++++++++++++ 2 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index b7ed17e801..6ea3a5f494 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -180,6 +180,35 @@ def _split_top_level_commas(text: str) -> list[str]: return parts +def _find_top_level(text: str, token: str) -> int: + """Return the index of the first occurrence of *token* in *text* that lies + outside any quoted string or nested bracket, or ``-1`` if there is none. + + Used so operator/keyword splitting (``and``/``or``/``in``/comparisons) does + not match a separator that appears *inside* a quoted operand -- e.g. the + ``and`` in ``mode == 'read and write'`` or the ``or`` in ``'approve or reject'``. + """ + quote: str | None = None + depth = 0 + i = 0 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch in "([{": + depth += 1 + elif ch in ")]}": + depth = max(0, depth - 1) + elif depth == 0 and text.startswith(token, i): + return i + i += 1 + return -1 + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -193,11 +222,12 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """ expr = expr.strip() - # String literal — check before pipes and operators so quoted strings - # containing | or operator keywords are not mis-parsed. - if (expr.startswith("'") and expr.endswith("'")) or ( - expr.startswith('"') and expr.endswith('"') - ): + # String literal — only when the WHOLE expression is one quoted string, + # i.e. the opening quote's matching close is the final character. Checking + # startswith/endswith alone would also grab `'a' == 'b'` and strip it to the + # garbage `a' == 'b`; a genuine single literal short-circuits here so quoted + # strings containing `|` or operator keywords are not mis-parsed downstream. + if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: return expr[1:-1] # Handle pipe filters @@ -262,29 +292,33 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: ) # Boolean operators — parse 'or' first (lower precedence) so that - # 'a or b and c' is evaluated as 'a or (b and c)'. - if " or " in expr: - parts = expr.split(" or ", 1) - left = _evaluate_simple_expression(parts[0].strip(), namespace) - right = _evaluate_simple_expression(parts[1].strip(), namespace) + # 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket + # aware so a keyword inside a quoted operand (e.g. the 'and' in + # 'read and write') is not mistaken for an operator. + or_idx = _find_top_level(expr, " or ") + if or_idx != -1: + left = _evaluate_simple_expression(expr[:or_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[or_idx + 4:].strip(), namespace) return bool(left) or bool(right) - if " and " in expr: - parts = expr.split(" and ", 1) - left = _evaluate_simple_expression(parts[0].strip(), namespace) - right = _evaluate_simple_expression(parts[1].strip(), namespace) + and_idx = _find_top_level(expr, " and ") + if and_idx != -1: + left = _evaluate_simple_expression(expr[:and_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[and_idx + 5:].strip(), namespace) return bool(left) and bool(right) if expr.startswith("not "): inner = _evaluate_simple_expression(expr[4:].strip(), namespace) return not bool(inner) - # Comparison operators (order matters — check multi-char ops first) + # Comparison operators (order matters — check multi-char ops first). Split at + # the first top-level occurrence so an operator inside a quoted operand is + # ignored. for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): - if op in expr: - parts = expr.split(op, 1) - left = _evaluate_simple_expression(parts[0].strip(), namespace) - right = _evaluate_simple_expression(parts[1].strip(), namespace) + op_idx = _find_top_level(expr, op) + if op_idx != -1: + left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[op_idx + len(op):].strip(), namespace) if op == "==": return left == right if op == "!=": diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 988730d783..b239cb9a4c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -286,6 +286,42 @@ def test_list_literal_preserves_quoted_commas(self): assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"] assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]] + def test_operator_splitting_is_quote_aware(self): + from specify_cli.workflows.expressions import ( + evaluate_condition, + evaluate_expression, + ) + from specify_cli.workflows.base import StepContext + + # An 'and'/'or'/'in' keyword INSIDE a quoted operand must not be treated + # as a boolean/membership operator: the comparison applies to the whole + # string literal. + ctx = StepContext(inputs={"mode": "read and write"}) + assert evaluate_expression("{{ inputs.mode == 'read and write' }}", ctx) is True + assert evaluate_expression("{{ inputs.mode == 'read or write' }}", ctx) is False + # ...also when the quoted literal is on the left of the operator. + left_ctx = StepContext(inputs={"x": "approve or reject"}) + assert evaluate_expression("{{ 'approve or reject' == inputs.x }}", left_ctx) is True + # membership against a literal that contains a keyword + assert evaluate_expression("{{ 'cat' in 'cat and dog' }}", StepContext()) is True + + # Literal-vs-literal equality no longer mis-strips to a garbage string + # (previously `'done' == 'failed'` short-circuited to the truthy string + # "done' == 'failed"). + assert evaluate_condition("{{ 'done' == 'failed' }}", StepContext()) is False + assert evaluate_condition("{{ 'done' == 'done' }}", StepContext()) is True + + # A single quoted literal that itself contains operator text is preserved. + assert evaluate_expression("{{ 'a == b' }}", StepContext()) == "a == b" + assert evaluate_expression("{{ 'x and y' }}", StepContext()) == "x and y" + + # Regression: ordinary (unquoted-keyword) parsing still works. + plain = StepContext(inputs={"a": 1, "b": 2, "mode": "read"}) + assert evaluate_expression("{{ inputs.mode == 'read' }}", plain) is True + assert evaluate_expression("{{ inputs.a == 1 and inputs.b == 2 }}", plain) is True + assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True + assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b" + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext From 96f73d192c1fccb88af95e58cc71c36bc0ac8745 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:54:38 +0500 Subject: [PATCH 38/60] fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198) setup-plan.sh prints 'Copied plan template to $IMPL_PLAN' after copying the template (to stderr in --json mode, stdout otherwise), but the PowerShell twin emitted nothing on the successful-copy path -- only the 'Plan already exists' skip message and the 'Plan template not found' warning existed. So the two scripts had a divergent status-output contract on a fresh run. Emit the same message after WriteAllText, routed like the sibling skip message ([Console]::Error.WriteLine in -Json so stdout stays pure JSON, Write-Output in text mode). Mirrors the bash wording and stream routing exactly. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/powershell/setup-plan.ps1 | 7 +++++++ tests/test_setup_plan_no_overwrite.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index e34de0fba8..e179f0d160 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -40,6 +40,13 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { $content = [System.IO.File]::ReadAllText($template) $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) + # Emit the copy status like the bash twin (setup-plan.sh); route to stderr + # in -Json mode so stdout stays pure JSON, matching the sibling messages. + if ($Json) { + [Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)") + } else { + Write-Output "Copied plan template to $($paths.IMPL_PLAN)" + } } else { Write-Warning "Plan template not found" # Create a basic plan file if template doesn't exist diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index c0db317263..ff19b1a0ac 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -224,3 +224,25 @@ def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: assert "IMPL_PLAN" in data # The skip message should be on stderr assert "already exists" in result.stderr + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_copied_message_on_stderr_in_json_mode(plan_repo: Path) -> None: + """First run in -Json mode must emit 'Copied plan template' on stderr (matching + the bash twin) while keeping stdout pure JSON. Before the fix the PowerShell + script emitted no copy status at all.""" + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # stdout stays parseable JSON; the status message goes to stderr. + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Copied plan template" in result.stderr From d37848569624452ea202d6b2aab8e115bd476da0 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:05:51 +0500 Subject: [PATCH 39/60] fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199) WorkflowEngine._coerce_input normalizes a whole-valued number to int via int(value). For an infinite float (e.g. a 'type: number' input with YAML 'default: .inf') int(inf) raises OverflowError, which is not in the except (ValueError, TypeError) tuple. validate_workflow eager-coerces declared defaults and is documented to RETURN a list of errors, but it only catches ValueError -- so the OverflowError escaped and validate_workflow raised instead of reporting, breaking its contract. (NaN already surfaced cleanly because int(nan) raises ValueError.) Add OverflowError to the except tuple so an infinite default surfaces as the same clean 'expected a number' ValueError as NaN, consistent with the function's existing fail-fast-on-authoring-mistakes design. Finite values (5.0 -> 5, 3.5 -> 3.5) are unaffected. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/engine.py | 7 ++++- tests/test_workflows.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index aff5e92e29..0e11a6b7d8 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -1010,7 +1010,12 @@ def _coerce_input( value = float(value) if value == int(value): value = int(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): + # OverflowError: `int(value)` raises it for an infinite float + # (e.g. a `default: .inf` authoring mistake), which would + # otherwise escape validate_workflow's `except ValueError` and + # break its "return errors, never raise" contract. Surface it as + # the same clean "expected a number" error as NaN does. msg = f"Input {name!r} expected a number, got {value!r}." raise ValueError(msg) from None elif input_type == "boolean": diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b239cb9a4c..cee02c46ba 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2846,6 +2846,47 @@ def test_validate_workflow_rejects_bool_default_for_number_type(self): errors = validate_workflow(definition) assert any("invalid default" in e for e in errors), errors + def test_coerce_number_input_rejects_infinity_cleanly(self): + """An infinite float must surface as a clean ValueError (like NaN), not + let ``int(inf)``'s OverflowError escape: ``int()`` of an infinity raises + OverflowError, which is not ValueError/TypeError. + """ + from specify_cli.workflows.engine import WorkflowEngine + + for value in (float("inf"), float("-inf"), "inf", "Infinity", "-inf"): + with pytest.raises(ValueError, match="expected a number"): + WorkflowEngine._coerce_input("count", value, {"type": "number"}) + # Finite values still coerce (whole floats normalize to int). + assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5 + assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5 + + def test_validate_workflow_rejects_infinite_default_for_number_type(self): + """``type: number`` with an infinite default (YAML ``.inf``) must be + reported as an error, not raise. ``int(inf)`` raises OverflowError during + coercion, which previously escaped validate_workflow's ValueError handler + and broke its "return a list of errors" contract. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "inf-as-number" + name: "Inf As Number" + version: "1.0.0" +inputs: + count: + type: number + default: .inf +steps: + - id: noop + type: gate + message: "noop" + options: [approve] +""") + errors = validate_workflow(definition) + assert any("invalid default" in e for e in errors), errors + def test_validate_workflow_rejects_non_string_default_for_string_type(self): """``type: string`` must require an actual string — a numeric YAML default like ``5`` would otherwise slip through unvalidated. From 9a40ed0b6eeb547fde7eb2a6f0a9574086439f1d Mon Sep 17 00:00:00 2001 From: WOLIKIMCHENG <35391914+WOLIKIMCHENG@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:26:10 +0800 Subject: [PATCH 40/60] fix: update CodeBuddy install docs URL (#3187) * fix: update CodeBuddy install docs URL * test: assert codebuddy integration is registered before checking install_url --------- Co-authored-by: root --- src/specify_cli/integrations/codebuddy/__init__.py | 2 +- tests/integrations/test_integration_codebuddy.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 980ac7fed7..c5b4503b6b 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -9,7 +9,7 @@ class CodebuddyIntegration(MarkdownIntegration): "name": "CodeBuddy", "folder": ".codebuddy/", "commands_subdir": "commands", - "install_url": "https://www.codebuddy.ai/cli", + "install_url": "https://www.codebuddy.cn/docs/cli/installation", "requires_cli": True, } registrar_config = { diff --git a/tests/integrations/test_integration_codebuddy.py b/tests/integrations/test_integration_codebuddy.py index dcc2153a7b..98aacf778d 100644 --- a/tests/integrations/test_integration_codebuddy.py +++ b/tests/integrations/test_integration_codebuddy.py @@ -1,5 +1,7 @@ """Tests for CodebuddyIntegration.""" +from specify_cli.integrations import get_integration + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +11,12 @@ class TestCodebuddyIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".codebuddy/commands" CONTEXT_FILE = "CODEBUDDY.md" + + def test_install_url_points_to_official_cli_install_docs(self): + integration = get_integration(self.KEY) + assert integration is not None + + assert ( + integration.config["install_url"] + == "https://www.codebuddy.cn/docs/cli/installation" + ) From 5bdcb4ad141d4c9517443afad797487d06d66b4a Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Mon, 29 Jun 2026 20:29:14 +0500 Subject: [PATCH 41/60] fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210) the shared CatalogStackBase validator and PresetCatalog validator checked parsed.netloc to enforce 'a valid URL with a host'. but netloc is truthy for host-less URLs like https://:8080 or https://user@, so those slipped through even though they have no host - contradicting the error message. the workflow, step, and bundler validators already check parsed.hostname (which is None in those cases); this aligns the two stragglers with that. add regression tests covering port-only and userinfo-only URLs. --- src/specify_cli/catalogs.py | 5 ++++- src/specify_cli/presets/__init__.py | 5 ++++- .../integrations/test_integration_catalog.py | 16 +++++++++++++++ tests/test_presets.py | 20 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/catalogs.py b/src/specify_cli/catalogs.py index 8bd3b2dc06..d14e8ec425 100644 --- a/src/specify_cli/catalogs.py +++ b/src/specify_cli/catalogs.py @@ -78,7 +78,10 @@ def _validate_catalog_url(cls, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.netloc: + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@", so the host guarantee this error + # promises would not actually hold. hostname is None in those cases. + if not parsed.hostname: raise cls._error("Catalog URL must be a valid URL with a host.") def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 8d5c044193..354cc82399 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -1861,7 +1861,10 @@ def _validate_catalog_url(self, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.netloc: + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@", so the host guarantee this error + # promises would not actually hold. hostname is None in those cases. + if not parsed.hostname: raise PresetValidationError( "Catalog URL must be a valid URL with a host." ) diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index fae9e32d23..6b6831e05c 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -67,6 +67,22 @@ def test_missing_host_rejected(self): with pytest.raises(IntegrationCatalogError, match="valid URL"): IntegrationCatalog._validate_catalog_url("https:///no-host") + @pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:0", # port only, no host + "https://user@", # userinfo only, no host + "https://user:pw@", # userinfo only, no host + ], + ) + def test_hostless_url_with_truthy_netloc_rejected(self, url): + # These have a truthy netloc (":8080", "user@") but no actual host, + # so a netloc-based check would wrongly accept them despite the + # "valid URL with a host" promise. hostname is None for all of them. + with pytest.raises(IntegrationCatalogError, match="valid URL"): + IntegrationCatalog._validate_catalog_url(url) + # --------------------------------------------------------------------------- # IntegrationCatalog — active catalogs diff --git a/tests/test_presets.py b/tests/test_presets.py index 0632fe3a89..58dcdc7119 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1424,6 +1424,26 @@ def test_validate_catalog_url_localhost_http_allowed(self, project_dir): catalog._validate_catalog_url("http://localhost:8080/catalog.json") catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") + @pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:0", # port only, no host + "https://user@", # userinfo only, no host + "https://user:pw@", # userinfo only, no host + ], + ) + def test_validate_catalog_url_hostless_rejected(self, project_dir, url): + """Reject host-less URLs whose netloc is truthy but hostname is None. + + ``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its + ``hostname`` is ``None``, so a netloc-based check would accept a URL + with no actual host, contradicting the "valid URL with a host" error. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="valid URL with a host"): + catalog._validate_catalog_url(url) + def test_env_var_catalog_url(self, project_dir, monkeypatch): """Test catalog URL from environment variable.""" monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") From ac47178f654d6e77a5e6d742da95808eab476579 Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:25:38 +0100 Subject: [PATCH 42/60] fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214) The @mariozechner/pi-coding-agent npm package is deprecated in favor of @earendil-works/pi-coding-agent. Pi Coding Agent is still active under the new org, so update the install_url rather than removing the integration. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .devcontainer/post-create.sh | 2 +- src/specify_cli/integrations/pi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index c1dbdd9458..5aa0a076c1 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -56,7 +56,7 @@ run_command "npm install -g @jetbrains/junie-cli@latest" echo "✅ Done" echo -e "\n🤖 Installing Pi Coding Agent..." -run_command "npm install -g @mariozechner/pi-coding-agent@latest" +run_command "npm install -g @earendil-works/pi-coding-agent@latest" echo "✅ Done" echo -e "\n🤖 Installing Kiro CLI..." diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py index 8a25f326ba..2cb738e04e 100644 --- a/src/specify_cli/integrations/pi/__init__.py +++ b/src/specify_cli/integrations/pi/__init__.py @@ -9,7 +9,7 @@ class PiIntegration(MarkdownIntegration): "name": "Pi Coding Agent", "folder": ".pi/", "commands_subdir": "prompts", - "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", + "install_url": "https://www.npmjs.com/package/@earendil-works/pi-coding-agent", "requires_cli": True, } registrar_config = { From bbc5f176e3dfe2597bb6b8cb3a6df8e9cb02e884 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:31:10 -0500 Subject: [PATCH 43/60] fix(extensions): apply GHES auth and resolve release assets for `extension add --from` (#3217) * fix(extensions): apply GHES auth and resolve release assets for --from The 'specify extension add --from ' path fetched ZIPs via a bare open_url with no GitHub release-asset resolution and no Accept header, diverging from the catalog download path. Against GHES it received an HTML login page and failed obscurely with zipfile.BadZipFile. Route --from through ExtensionCatalog so configured GHES credentials apply and release-download URLs resolve via /api/v3, and reject non-ZIP content with a clear error pointing at auth.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): use zipfile.is_zipfile for --from content guard Replace the weak zip_data.startswith(b"PK") prefix check with zipfile.is_zipfile() on a BytesIO so any non-ZIP payload (not just those lacking the PK magic) is rejected with the friendly error before install_from_zip can raise BadZipFile. Addresses PR review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/_commands.py | 30 ++++++- tests/test_extensions.py | 102 +++++++++++++++++++++++- 2 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 3b60b6d52d..6821419b30 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -482,6 +482,7 @@ def extension_add( elif from_url: # Install from URL (ZIP file) + import io import urllib.error console.print(f"Downloading from {safe_url}...") @@ -498,10 +499,33 @@ def extension_add( zip_path = Path(download_file.name) try: - from specify_cli.authentication.http import open_url as _open_url - - with _open_url(from_url, timeout=60) as response: + # Use the catalog's authenticated fetch so configured + # credentials (incl. GitHub Enterprise Server) are applied + # and GHES release-asset URLs resolve via /api/v3 — keeping + # --from consistent with catalog-based installs. + dl_catalog = ExtensionCatalog(project_root) + download_url = from_url + extra_headers = None + resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url) + if resolved_url: + download_url = resolved_url + extra_headers = {"Accept": "application/octet-stream"} + + with dl_catalog._open_url( + download_url, timeout=60, extra_headers=extra_headers + ) as response: zip_data = response.read() + + if not zipfile.is_zipfile(io.BytesIO(zip_data)): + console.print( + f"[red]Error:[/red] {safe_url} did not return a ZIP archive " + f"(got {len(zip_data)} bytes). This usually means the request " + f"was not authenticated and a login/HTML page was returned. " + f"Verify the URL is correct and that credentials for its host " + f"are configured in ~/.specify/auth.json." + ) + raise typer.Exit(1) + zip_path.write_bytes(zip_data) # Install from downloaded ZIP diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e8dc2b7beb..6260ad6abf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -40,6 +40,10 @@ version_satisfies, ) +# Minimal valid ZIP (empty end-of-central-directory record). Passes +# zipfile.is_zipfile() so --from download tests exercise the content guard. +_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 + def can_create_symlink(tmp_path: Path) -> bool: """Return True when the current platform/user can create file symlinks.""" @@ -5378,7 +5382,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch("typer.confirm", return_value=True), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ patch.object(ExtensionRegistry, "get", return_value={}): result = runner.invoke( @@ -5446,6 +5450,98 @@ def test_add_from_url_escapes_download_exception_markup(self, tmp_path): assert "https://example.com/[red]ext[/red].zip" in result.output assert "bad [red]download[/red]" in result.output + def test_add_from_url_rejects_non_zip_login_page(self, tmp_path): + """An HTML login page (unauthenticated fetch) must fail clearly, not BadZipFile.""" + import io + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch( + "specify_cli.authentication.http.open_url", + return_value=FakeResponse(b"Sign in"), + ), \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://raw.ghe.example/o/r/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "did not return a ZIP archive" in result.output + install.assert_not_called() + + def test_add_from_url_resolves_ghes_release_asset(self, tmp_path): + """A GHES release-download URL resolves to /api/v3 with octet-stream Accept.""" + import io + from types import SimpleNamespace + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import json + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + seen = {} + + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + if "/releases/tags/" in url: + body = json.dumps({ + "assets": [{ + "name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", + }] + }).encode() + return FakeResponse(body) + seen["url"] = url + seen["headers"] = extra_headers + return FakeResponse(_MINIMAL_ZIP_BYTES) + + def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False): + return SimpleNamespace( + id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[] + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch.object(ExtensionManager, "install_from_zip", fake_install): + result = runner.invoke( + app, + ["extension", "add", "x", "--from", + "https://ghes.example/org/repo/releases/download/v1.0/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "/api/v3/repos/org/repo/releases/assets/" in seen["url"] + assert seen["headers"] == {"Accept": "application/octet-stream"} + @pytest.mark.parametrize( ("exc_type", "label"), [ @@ -5523,7 +5619,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch("typer.confirm", return_value=True), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): result = runner.invoke( app, @@ -5532,7 +5628,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc ) assert result.exit_code == 0 - assert installed["zip_bytes"] == b"zip-bytes" + assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve()) assert installed["zip_path"].name.startswith("extension-url-download-") assert not installed["zip_path"].exists() From 92cb2699eb354eb8494e0efc7a6daf2a32216b21 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:35:22 -0500 Subject: [PATCH 44/60] chore: release 0.11.10, begin 0.11.11.dev0 development (#3240) * chore: bump version to 0.11.10 * chore: begin 0.11.11.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c036a1884..c3d9650e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ +## [0.11.10] - 2026-06-29 + +### Changed + +- fix(extensions): apply GHES auth and resolve release assets for `extension add --from` (#3217) +- fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214) +- fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210) +- fix: update CodeBuddy install docs URL (#3187) +- fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199) +- fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198) +- fix(workflows): make expression operator/literal parsing quote-aware (#3197) +- fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196) +- Add community bundle submission path (#3162) +- Docs: Document /speckit.converge command (#3181) +- chore: release 0.11.9, begin 0.11.10.dev0 development (#3189) + ## [0.11.9] - 2026-06-26 ### Changed diff --git a/pyproject.toml b/pyproject.toml index c01db9a53b..847bbf7b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.10.dev0" +version = "0.11.11.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From 7621e1ceba4db0266e5aeaed61e51ffea149018d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:42:30 -0500 Subject: [PATCH 45/60] Update Product Spec Extension to v1.0.1 (#3226) Update product extension submitted by @d0whc3r: - extensions/catalog.community.json (version, download_url, provides.commands) Closes #3200 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 01c4ea17bc..2d0a89f6cb 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-24T00:00:00Z", + "updated_at": "2026-06-29T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2501,8 +2501,8 @@ "id": "product", "description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.", "author": "d0whc3r", - "version": "0.8.3", - "download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip", + "version": "1.0.1", + "download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v1.0.1/product-1.0.1.zip", "repository": "https://github.com/d0whc3r/spec-kit-product", "homepage": "https://d0whc3r.github.io/spec-kit-product/", "documentation": "https://github.com/d0whc3r/spec-kit-product/wiki", @@ -2514,7 +2514,7 @@ "speckit_version": ">=0.2.0" }, "provides": { - "commands": 4, + "commands": 3, "hooks": 3 }, "tags": [ @@ -2538,7 +2538,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-26T00:00:00Z", - "updated_at": "2026-06-01T00:00:00Z" + "updated_at": "2026-06-29T00:00:00Z" }, "product-forge": { "name": "Product Forge", From 7b687d8bbda56afcaf45e4e68c54825b1571a409 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:36:17 +0500 Subject: [PATCH 46/60] fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195) * fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) create-new-feature-branch.ps1 emitted a HAS_GIT key in its JSON output and a 'HAS_GIT:' line in text output that the bash twin never emits. The bash output contract is {BRANCH_NAME, FEATURE_NUM} (+ DRY_RUN) only, so a tool parsing the machine-readable output got a different shape on Windows/PowerShell vs macOS/Linux -- a cross-platform contract divergence. $hasGit is still computed and used internally for branch-creation logic; only its two output emissions are removed, restoring parity. Added regression tests asserting neither the PS nor the bash output contains HAS_GIT (JSON and text). Noted as a follow-up in #3129. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: note DRY_RUN in the HAS_GIT-omission comment (parity) Address Copilot review: the comment described the output contract as {BRANCH_NAME, FEATURE_NUM} without mentioning that DRY_RUN is still conditionally added in JSON mode on dry runs. Clarify so the contract description is complete for future maintainers. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../powershell/create-new-feature-branch.ps1 | 5 +-- tests/extensions/git/test_git_extension.py | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 6a4417f8b9..597bdf40d2 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -400,8 +400,10 @@ if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName FEATURE_NUM = $featureNum - HAS_GIT = $hasGit } + # $hasGit is computed for branch-creation logic only; it is intentionally not + # emitted so this output contract matches the bash twin: BRANCH_NAME and + # FEATURE_NUM, plus DRY_RUN (added just below) on dry runs. if ($DryRun) { $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true } @@ -409,7 +411,6 @@ if ($Json) { } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "FEATURE_NUM: $featureNum" - Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 2017dae627..1a291f0308 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -298,6 +298,24 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert data["BRANCH_NAME"] == "001-user-auth" assert data["FEATURE_NUM"] == "001" + def test_output_omits_has_git_for_parity(self, tmp_path: Path): + """The bash output contract is {BRANCH_NAME, FEATURE_NUM} (+ DRY_RUN) in JSON + and a BRANCH_NAME:/FEATURE_NUM: text block -- no HAS_GIT key/line. This pins + the canonical contract the PowerShell twin must mirror.""" + project = _setup_project(tmp_path) + rj = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "parity", "Parity feature", + ) + assert rj.returncode == 0, rj.stderr + assert "HAS_GIT" not in json.loads(rj.stdout) + rt = _run_bash( + "create-new-feature-branch.sh", project, + "--dry-run", "--short-name", "parity", "Parity feature", + ) + assert rt.returncode == 0, rt.stderr + assert "HAS_GIT" not in rt.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """A short word is dropped from the derived branch name unless it appears as an acronym in UPPERCASE in the description (case-sensitive, must match the @@ -444,6 +462,24 @@ def test_creates_branch_sequential(self, tmp_path: Path): data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" + def test_output_omits_has_git_to_match_bash(self, tmp_path: Path): + """PowerShell must mirror the bash twin's output contract: neither JSON nor + text output may include HAS_GIT (it is computed internally for branch-creation + logic only). Fails before the fix (PS emitted HAS_GIT), passes after.""" + project = _setup_project(tmp_path) + rj = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "parity", "Parity feature", + ) + assert rj.returncode == 0, rj.stderr + assert "HAS_GIT" not in json.loads(rj.stdout) + rt = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-DryRun", "-ShortName", "parity", "Parity feature", + ) + assert rt.returncode == 0, rt.stderr + assert "HAS_GIT" not in rt.stdout + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): """PowerShell must match the bash twin: a short word is dropped unless it appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match).""" From a4972da71725001f1286ce8fd47b220dfa8f7bc4 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:38:39 +0500 Subject: [PATCH 47/60] fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scripts): count subdirectory-only dirs as non-empty in PowerShell Test-DirHasFiles (the documented PowerShell twin of bash check_dir) tested non-emptiness with `Get-ChildItem | Where-Object { -not $_.PSIsContainer }`, counting only top-level FILES and ignoring subdirectories. Bash check_dir (`-n $(ls -A ...)`) and the PowerShell JSON-path contracts checks (check-prerequisites.ps1 / setup-tasks.ps1, no PSIsContainer filter) both count ANY entry. So a contracts/ directory whose only contents are subdirectories (e.g. contracts/v1/openapi.yaml) was reported present by bash, by bash JSON, and by PowerShell JSON, but [FAIL]/absent by PowerShell text mode — the lone outlier. Drop the PSIsContainer filter so Test-DirHasFiles counts any entry, matching the other three code paths. Add bash + PowerShell parity tests asserting a subdir-only contracts/ dir is reported non-empty in both shells. Co-Authored-By: Claude Opus 4.8 (1M context) * review: accurate non-empty comment + drop doubled test prefix Address review feedback on Test-DirHasFiles parity fix: - Reword the common.ps1 comment so it no longer claims exact `ls -A` parity (Get-ChildItem omits hidden entries without -Force); it now points at the in-repo PowerShell JSON contracts checks as the matching reference and keeps the subdir-only-is-non-empty rationale. - Rename test_test_dir_has_files_ps_... -> test_dir_has_files_ps_... to drop the doubled 'test_' prefix. Co-Authored-By: Claude Opus 4.8 (1M context) * test: assert dir-non-emptiness via stdout marker, not exit code Address Copilot review: check_dir always exits 0 (it echoes the marker rather than setting an exit code) and Test-DirHasFiles returns a boolean (pwsh still exits 0 when it returns $false), so 'result.returncode == 0' validated nothing. Drop the misleading assertion and rely on the [OK]/checkmark marker in stdout, which is the actual behavioral signal; document why inline. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: keep common.ps1 ASCII-only (PowerShell 5.1 compatibility) My reworded Test-DirHasFiles comment introduced an em dash (U+2014), which tripped tests/test_ps1_encoding.py::test_ps1_file_is_ascii_only -- .ps1 files must stay ASCII for Windows PowerShell 5.1. Replace it with '--', matching the existing comment style in this file (e.g. the Resolve-SpecifyInitDir docstring). Co-Authored-By: Claude Opus 4.8 (1M context) * test: decode dir-parity subprocess output as UTF-8 explicitly Address Copilot review: check_dir echoes the non-ASCII markers ✓/✗, and subprocess.run with text=True but no encoding decodes via the platform locale (cp1252 on Windows), which can raise UnicodeDecodeError or mangle stdout. Pin encoding='utf-8' on both the bash and PowerShell dir-parity helpers so decoding is deterministic across CI runners. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/powershell/common.ps1 | 8 +++++- tests/test_setup_tasks.py | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index f56fc26577..8596e764da 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -209,7 +209,13 @@ function Test-FileExists { function Test-DirHasFiles { param([string]$Path, [string]$Description) - if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) { + # A directory counts as non-empty when Get-ChildItem returns any entry + # (files or subdirectories) -- matching the JSON contracts checks in + # check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose + # only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as + # non-empty like bash check_dir. Filtering out subdirectories would + # mis-report such a directory as empty. + if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) { Write-Output " [OK] $Description" return $true } else { diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 0e3fb85f41..47a284f8a0 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -840,3 +840,54 @@ def test_setup_tasks_ps_errors_without_feature_context( output = result.stderr + result.stdout assert result.returncode != 0 assert "Feature directory not found" in output + + +# --------------------------------------------------------------------------- +# Directory non-emptiness parity: a dir whose only contents are subdirectories +# (e.g. contracts/v1/openapi.yaml) must count as non-empty in both shells. +# --------------------------------------------------------------------------- + +def _run_bash_check_dir(repo: Path, target: Path) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "bash" / "common.sh" + return subprocess.run( + ["bash", "-c", 'source "$1"; check_dir "$2" "contracts/"', "bash", str(script), str(target)], + # check_dir echoes the non-ASCII markers ✓/✗; decode UTF-8 explicitly so + # the result does not depend on the platform locale (e.g. cp1252 on Windows). + cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(), + ) + + +def _run_powershell_test_dir(repo: Path, target: Path) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "powershell" / "common.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + return subprocess.run( + [exe, "-NoProfile", "-Command", + '& { param($common, $dir) . $common; Test-DirHasFiles -Path $dir -Description "contracts/" }', + str(script), str(target)], + cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(), + ) + + +@requires_bash +def test_check_dir_bash_counts_subdir_only_contracts(tasks_repo: Path) -> None: + """bash check_dir treats a dir containing only subdirectories as non-empty.""" + contracts = tasks_repo / "contracts" / "v1" + contracts.mkdir(parents=True) + (contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8") + result = _run_bash_check_dir(tasks_repo, tasks_repo / "contracts") + # check_dir always exits 0 (it echoes ✓/✗ instead of setting an exit code), + # so the ✓ marker in stdout — not the return code — is what proves non-emptiness. + assert "✓" in result.stdout and "✗" not in result.stdout, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_dir_has_files_ps_counts_subdir_only_contracts(tasks_repo: Path) -> None: + """Test-DirHasFiles must match bash: a subdir-only dir counts as non-empty.""" + contracts = tasks_repo / "contracts" / "v1" + contracts.mkdir(parents=True) + (contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8") + result = _run_powershell_test_dir(tasks_repo, tasks_repo / "contracts") + # Test-DirHasFiles returns a boolean and pwsh still exits 0 when it returns + # $false, so the [OK] marker in stdout — not the return code — is what proves + # non-emptiness. + assert "[OK]" in result.stdout and "[FAIL]" not in result.stdout, result.stderr + result.stdout From a473955e3e03cc8142f8e59c1eaafb6b935bc1b7 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:46:35 +0500 Subject: [PATCH 48/60] fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230) * fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) create-new-feature.sh prints 'Warning: Spec template not found; created empty spec file' to stderr when no spec template resolves, then touches an empty spec. The PowerShell twin created the empty file silently with no warning, so on Windows a missing/broken template tree gave no signal. Emit the same warning on stderr (keeps stdout/JSON pure), matching the bash wording and stream. Co-Authored-By: Claude Opus 4.8 (1M context) * test: assert create-new-feature.ps1 warns on missing spec template Regression test for the bash/PowerShell parity fix: with no resolvable spec template, the PowerShell script must emit 'Spec template not found' on stderr (matching bash) while keeping stdout parseable JSON and still creating the empty spec file. Gated on pwsh; decodes stdout/stderr as UTF-8. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/powershell/create-new-feature.ps1 | 4 ++++ tests/test_timestamp_branches.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 5826815e01..91b36bebdb 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -211,6 +211,10 @@ if (-not $DryRun) { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom) } else { + # Match the bash twin (create-new-feature.sh): warn on stderr that no + # spec template was found before creating an empty spec file, so the + # missing-template signal is not silently swallowed on Windows. + [Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file") New-Item -ItemType File -Path $specFile -Force | Out-Null } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 6fe0c14ed5..2a0a2ca696 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -332,6 +332,27 @@ def test_explicit_number_zero_is_honored_matching_bash(self, ps_git_repo: Path): assert data["FEATURE_NUM"] == "000" assert data["BRANCH_NAME"] == "000-zero" + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_missing_spec_template_warns_matching_bash(self, ps_git_repo: Path): + """When no spec template can be resolved, create-new-feature.ps1 must warn on + stderr (and still create an empty spec file), matching the bash twin's + 'Warning: Spec template not found; created empty spec file'. Before the fix + PowerShell created the empty file silently.""" + # Remove the template the fixture installs so resolution finds nothing. + (ps_git_repo / ".specify" / "templates" / "spec-template.md").unlink() + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), + "-Json", "-ShortName", "no-tmpl", "No template feature"], + cwd=ps_git_repo, capture_output=True, text=True, encoding="utf-8", + ) + assert result.returncode == 0, result.stderr + assert "Spec template not found" in result.stderr + # stdout stays parseable JSON and the empty spec file is still created. + data = json.loads(result.stdout) + spec_file = Path(data["SPEC_FILE"]) + assert spec_file.is_file() + # ── check_feature_branch Tests ─────────────────────────────────────────────── From 3036fe6954cc4d8e6669ab0a643e0c4b4272cab1 Mon Sep 17 00:00:00 2001 From: Huy Do Date: Tue, 30 Jun 2026 02:52:08 +0700 Subject: [PATCH 49/60] fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225) * fix(workflows): reject a fan-in wait_for that names an unknown step at validation * fix(workflows): reject fan-in wait_for self-reference and non-string entries Address review feedback on the fan-in wait_for validator: - A fan-in's own id is added to seen_ids before the wait_for check, so `wait_for: []` passed validation while producing a silent empty join at runtime. Reject self-references explicitly. - Non-string entries (e.g. YAML `wait_for: [123]`) were skipped by the isinstance(str) guard and validated even though they can never match a real step id. Flag them as wiring errors. Add coverage for both cases. --- src/specify_cli/workflows/engine.py | 34 ++++++++ tests/test_workflows.py | 122 ++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 0e11a6b7d8..23b5b0c5c0 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -296,6 +296,40 @@ def _validate_steps( f"boolean, got {type(coe).__name__}." ) + # Fan-in: every wait_for id must reference a step declared at or before + # this point. An id not yet seen is either a typo (unknown step) or a + # forward reference (the target runs after this fan-in, so its results + # cannot exist yet) — both are wiring errors that previously surfaced as + # a silent empty result + COMPLETED. A step that is declared but only + # conditionally executed (e.g. inside an if/switch branch) is still + # "seen" here, so a legitimately-empty result at runtime stays valid. + if step_type == "fan-in": + wait_for = step_config.get("wait_for") + if isinstance(wait_for, list): + for wid in wait_for: + if not isinstance(wid, str): + # A non-string entry (e.g. YAML `wait_for: [123]`) can + # never match a real step id, so the join is silently + # empty at runtime — surface it as a wiring error. + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' entries must " + f"be step-id strings, got {type(wid).__name__} " + f"({wid!r})." + ) + elif wid == step_id: + # The fan-in's own id is already in seen_ids by now, so + # a self-reference would pass the membership check below + # while still producing an empty join at runtime. + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' references " + f"itself; a fan-in cannot wait for its own results." + ) + elif wid not in seen_ids: + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' references " + f"unknown or not-yet-declared step id {wid!r}." + ) + # Recursively validate nested steps for nested_key in ("then", "else", "steps"): nested = step_config.get(nested_key) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index cee02c46ba..6ad4fe63bc 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -2007,6 +2007,128 @@ def test_validate_wait_for_not_list(self): assert any("non-empty list" in e for e in errors) +class TestFanInWaitForValidation: + """fan-in wait_for must reference a declared step (no silent empty join).""" + + @staticmethod + def _errors(yaml_text): + from specify_cli.workflows.engine import ( + WorkflowDefinition, + validate_workflow, + ) + + return validate_workflow(WorkflowDefinition.from_string(yaml_text)) + + def test_unknown_wait_for_id_is_rejected(self): + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [ghost] +""") + assert any( + "unknown or not-yet-declared step id 'ghost'" in e for e in errors + ) + + def test_wait_for_declared_earlier_step_passes(self): + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: produce + type: command + command: speckit.implement + - id: collect + type: fan-in + wait_for: [produce] +""") + assert not any("wait_for" in e for e in errors) + + def test_wait_for_conditionally_declared_step_passes(self): + # A step declared inside an if-branch may be skipped at runtime, but it is + # still "declared", so referencing it must validate — a legitimately-empty + # runtime join stays valid. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: maybe + type: if + condition: "{{ inputs.flag }}" + then: + - id: branch_task + type: command + command: speckit.implement + - id: collect + type: fan-in + wait_for: [branch_task] +""") + assert not any("wait_for" in e for e in errors) + + def test_forward_reference_is_rejected(self): + # wait_for points at a step declared AFTER the fan-in; its results cannot + # exist when the fan-in runs, so it is flagged. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [later] + - id: later + type: command + command: speckit.implement +""") + assert any( + "unknown or not-yet-declared step id 'later'" in e for e in errors + ) + + def test_self_reference_is_rejected(self): + # A fan-in's own id is in scope by the time it is validated, so a + # self-reference slips past the membership check while still producing + # an empty join at runtime. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [collect] +""") + assert any( + "references itself" in e and "collect" in e for e in errors + ) + + def test_non_string_wait_for_entry_is_rejected(self): + # A non-string entry (e.g. YAML `wait_for: [123]`) can never match a + # real step id, so it must be flagged rather than silently ignored. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [123] +""") + assert any( + "must be step-id strings" in e and "int" in e for e in errors + ) + + # ===== Workflow Definition Tests ===== class TestWorkflowDefinition: From 9ece347a776d8fcdce53092962241b6987c2a704 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:55:45 +0500 Subject: [PATCH 50/60] fix(workflows): make pipe-filter detection quote-aware in expressions (#3232) _evaluate_simple_expression used 'if "|" in expr' / expr.split("|", 1) to detect a filter pipe, so a literal '|' inside a quoted operand (e.g. inputs.x == 'a|b') was mistaken for a filter separator and raised a spurious ValueError ('unknown filter') instead of comparing the string. Use the existing quote/bracket-aware _find_top_level helper (added for the operator-splitting fix) so only a top-level pipe is treated as a filter separator. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/workflows/expressions.py | 12 +++++++----- tests/test_workflows.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 6ea3a5f494..6257930a5a 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -230,11 +230,13 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: return expr[1:-1] - # Handle pipe filters - if "|" in expr: - parts = expr.split("|", 1) - value = _evaluate_simple_expression(parts[0].strip(), namespace) - filter_expr = parts[1].strip() + # Handle pipe filters. Detect the pipe at the top level only, so a literal + # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is + # not mistaken for a filter separator — mirroring the operator parsing below. + pipe_idx = _find_top_level(expr, "|") + if pipe_idx != -1: + value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) + filter_expr = expr[pipe_idx + 1:].strip() # `from_json` is strict: it takes no arguments and tolerates no # trailing tokens. Match on the leading filter name and require the diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 6ad4fe63bc..8bf572439c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -322,6 +322,27 @@ def test_operator_splitting_is_quote_aware(self): assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b" + def test_pipe_detection_is_quote_aware(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # A literal '|' inside a quoted operand must not be treated as a filter + # pipe: the comparison applies to the whole string. + ctx = StepContext(inputs={"x": "a|b"}) + assert evaluate_expression("{{ inputs.x == 'a|b' }}", ctx) is True + assert evaluate_expression("{{ inputs.x == 'a|b' }}", StepContext(inputs={"x": "z"})) is False + # membership against a literal containing a pipe + assert evaluate_expression("{{ 'a|b' in inputs.s }}", StepContext(inputs={"s": "x a|b y"})) is True + # a single quoted literal containing pipes is preserved + assert evaluate_expression("{{ 'a|b|c' }}", StepContext()) == "a|b|c" + + # Regression: real filters still work, including a pipe inside a filter arg. + ctx2 = StepContext(inputs={"items": ["a", "b"], "s": "xabz"}) + assert evaluate_expression("{{ inputs.missing | default('y') }}", ctx2) == "y" + assert evaluate_expression('{{ inputs.items | join("-") }}', ctx2) == "a-b" + assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True + assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b" + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext From 876dca865946557beb0545efab5d136e87b63096 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:07:50 +0500 Subject: [PATCH 51/60] fix(workflows): gate validate() must not crash on non-string options (#3233) GateStep.validate() reports non-string options as an error, but then -- when on_reject is 'abort'/'retry' -- still runs the reject-choice check 'any(o.lower() in ... for o in options)'. For a non-string option (e.g. options: [123]) o.lower() raised AttributeError, which escaped validate() and broke validate_workflow's documented 'return a list of errors, never raise' contract. Guard the check so it only runs when every option is a string (the non-string case is already reported above). Co-authored-by: Claude Opus 4.8 (1M context) --- .../workflows/steps/gate/__init__.py | 9 ++++++++- tests/test_workflows.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index a2e473244e..e07b6ebd62 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -194,7 +194,14 @@ def validate(self, config: dict[str, Any]) -> list[str]: f"Gate step {config.get('id', '?')!r}: 'on_reject' must be " f"'abort', 'skip', or 'retry'." ) - if on_reject in ("abort", "retry") and isinstance(options, list): + # Only inspect option text when every option is a string; otherwise the + # `o.lower()` below would raise AttributeError on a non-string option + # (already reported above) and break validate_workflow's never-raise contract. + if ( + on_reject in ("abort", "retry") + and isinstance(options, list) + and all(isinstance(o, str) for o in options) + ): reject_choices = {"reject", "abort"} if not any(o.lower() in reject_choices for o in options): errors.append( diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 8bf572439c..eebc89fadd 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -1455,6 +1455,23 @@ def test_validate_invalid_on_reject(self): }) assert any("on_reject" in e for e in errors) + def test_validate_non_string_options_does_not_raise(self): + """Non-string options with on_reject=abort/retry must be REPORTED as an + error, not crash: the reject-choice check calls o.lower() on each option, + which previously raised AttributeError on a non-string option and broke + validate_workflow's 'return errors, never raise' contract.""" + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + # on_reject defaults to "abort", which triggers the option-text check. + errors = step.validate({"id": "test", "message": "Review", "options": [123]}) + assert any("must be strings" in e for e in errors) + # also with an explicit retry on_reject + errors = step.validate( + {"id": "test", "message": "Review", "options": [True], "on_reject": "retry"} + ) + assert any("must be strings" in e for e in errors) + def test_interactive_prompt_renders_show_file(self, tmp_path, monkeypatch, capsys): from specify_cli.workflows.steps.gate import GateStep from specify_cli.workflows.base import StepContext, StepStatus From 5367f69f6cfcb91f242dee2e630c25ff125a838a Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:08:49 +0500 Subject: [PATCH 52/60] docs(workflows): add the built-in 'init' step type to the Step Types table (#3234) The Step Types table in docs/reference/workflows.md listed command, prompt, shell, gate, if, switch, while, do-while, fan-out, and fan-in, but omitted 'init' -- which IS a registered built-in (workflows/__init__.py _register_builtin_steps registers InitStep) and is documented in steps/init/__init__.py as bootstrapping a project (equivalent to 'specify init'). Add the missing row so the reference matches the registry. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/reference/workflows.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index ffa25301e1..16bbe0893e 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -262,6 +262,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | | `prompt` | Send an arbitrary prompt to the AI coding agent | | `shell` | Execute a shell command and capture output | +| `init` | Bootstrap a project (like `specify init`) | | `gate` | Pause for human approval before continuing | | `if` | Conditional branching (then/else) | | `switch` | Multi-branch dispatch on an expression | From 53d9543355ad6a5b8f7bc8b96fe1ad041c0c6638 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:27:26 -0500 Subject: [PATCH 53/60] feat: make agent-context extension a full opt-in (#3097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 19 +- extensions/agent-context/README.md | 15 +- .../agent-context/agent-context-defaults.json | 42 + .../scripts/bash/update-agent-context.sh | 124 +- .../powershell/update-agent-context.ps1 | 90 ++ src/specify_cli/__init__.py | 78 +- src/specify_cli/agents.py | 31 - src/specify_cli/commands/init.py | 44 - src/specify_cli/integration_scaffold.py | 8 +- src/specify_cli/integrations/_helpers.py | 62 +- src/specify_cli/integrations/agy/__init__.py | 1 - src/specify_cli/integrations/amp/__init__.py | 1 - .../integrations/auggie/__init__.py | 1 - src/specify_cli/integrations/base.py | 565 +--------- src/specify_cli/integrations/bob/__init__.py | 1 - .../integrations/claude/__init__.py | 1 - .../integrations/cline/__init__.py | 1 - .../integrations/codebuddy/__init__.py | 1 - .../integrations/codex/__init__.py | 1 - .../integrations/copilot/__init__.py | 7 - .../integrations/cursor_agent/__init__.py | 1 - .../integrations/devin/__init__.py | 1 - .../integrations/firebender/__init__.py | 5 +- .../integrations/forge/__init__.py | 5 - .../integrations/gemini/__init__.py | 1 - .../integrations/generic/__init__.py | 5 - .../integrations/goose/__init__.py | 1 - .../integrations/hermes/__init__.py | 10 +- .../integrations/iflow/__init__.py | 1 - .../integrations/junie/__init__.py | 1 - .../integrations/kilocode/__init__.py | 1 - src/specify_cli/integrations/kimi/__init__.py | 124 +- .../integrations/kiro_cli/__init__.py | 1 - .../integrations/lingma/__init__.py | 1 - src/specify_cli/integrations/omp/__init__.py | 1 - .../integrations/opencode/__init__.py | 1 - src/specify_cli/integrations/pi/__init__.py | 1 - .../integrations/qodercli/__init__.py | 1 - src/specify_cli/integrations/qwen/__init__.py | 1 - src/specify_cli/integrations/roo/__init__.py | 1 - .../integrations/rovodev/__init__.py | 4 +- src/specify_cli/integrations/shai/__init__.py | 1 - .../integrations/tabnine/__init__.py | 1 - src/specify_cli/integrations/trae/__init__.py | 1 - src/specify_cli/integrations/vibe/__init__.py | 1 - .../integrations/windsurf/__init__.py | 1 - .../integrations/zcode/__init__.py | 1 - src/specify_cli/integrations/zed/__init__.py | 1 - templates/commands/plan.md | 7 +- .../extensions/test_agent_context_cli_free.py | 57 + .../test_extension_agent_context.py | 1004 ++++++----------- tests/integrations/conftest.py | 1 - tests/integrations/test_base.py | 1 - tests/integrations/test_cli.py | 17 +- tests/integrations/test_extra_args.py | 5 - tests/integrations/test_integration_agy.py | 1 - tests/integrations/test_integration_amp.py | 1 - tests/integrations/test_integration_auggie.py | 1 - .../test_integration_base_markdown.py | 101 +- .../test_integration_base_skills.py | 87 +- .../test_integration_base_toml.py | 100 +- .../test_integration_base_yaml.py | 100 +- tests/integrations/test_integration_bob.py | 1 - tests/integrations/test_integration_claude.py | 64 +- tests/integrations/test_integration_cline.py | 16 - .../test_integration_codebuddy.py | 1 - tests/integrations/test_integration_codex.py | 74 +- .../integrations/test_integration_copilot.py | 62 +- .../test_integration_cursor_agent.py | 78 -- tests/integrations/test_integration_devin.py | 1 - .../test_integration_firebender.py | 1 - tests/integrations/test_integration_forge.py | 20 +- tests/integrations/test_integration_gemini.py | 1 - .../integrations/test_integration_generic.py | 66 +- tests/integrations/test_integration_goose.py | 1 - tests/integrations/test_integration_hermes.py | 11 +- tests/integrations/test_integration_iflow.py | 1 - tests/integrations/test_integration_junie.py | 1 - .../integrations/test_integration_kilocode.py | 1 - tests/integrations/test_integration_kimi.py | 207 ---- .../integrations/test_integration_kiro_cli.py | 1 - tests/integrations/test_integration_lingma.py | 1 - tests/integrations/test_integration_omp.py | 1 - .../integrations/test_integration_opencode.py | 1 - tests/integrations/test_integration_pi.py | 1 - .../integrations/test_integration_qodercli.py | 1 - tests/integrations/test_integration_qwen.py | 1 - tests/integrations/test_integration_roo.py | 1 - .../integrations/test_integration_rovodev.py | 13 +- tests/integrations/test_integration_shai.py | 1 - .../integrations/test_integration_tabnine.py | 1 - tests/integrations/test_integration_trae.py | 1 - tests/integrations/test_integration_vibe.py | 1 - .../integrations/test_integration_windsurf.py | 1 - tests/integrations/test_integration_zcode.py | 1 - tests/integrations/test_integration_zed.py | 1 - tests/integrations/test_registry.py | 43 - 97 files changed, 857 insertions(+), 2572 deletions(-) create mode 100644 extensions/agent-context/agent-context-defaults.json create mode 100644 tests/extensions/test_agent_context_cli_free.py diff --git a/AGENTS.md b/AGENTS.md index 3d5ea32377..68d8641e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" ``` **TOML agent (Gemini):** @@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" ``` **Skills agent (Codex):** @@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration): | `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | | `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | | `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | -| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | **Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). @@ -175,9 +171,11 @@ def _register_builtins() -> None: ### 4. Context file behavior -Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. +The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling. -The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`: +Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file. + +The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`: ```yaml # Path to the coding agent context file managed by this extension @@ -189,10 +187,10 @@ context_markers: end: "" ``` -- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run. -- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth. +- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension. +- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly. -Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). +Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run. Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic. @@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 2. Extracts title and description from frontmatter 3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) 4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping -5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Branch Naming Convention @@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. -2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally. +2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 091e2b4802..adc13e31e2 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -6,15 +6,17 @@ It owns the lifecycle of the managed section delimited by the configurable start ## Why an extension? -Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users: +Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users: -- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. -- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file. +- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value. - **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. -- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). +- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). ## Commands +The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). + | Command | Description | |---------|-------------| | `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | @@ -40,7 +42,7 @@ context_markers: end: "" ``` -- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted. - `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. @@ -62,5 +64,4 @@ pip install pyyaml specify extension disable agent-context ``` -When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). -Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. +When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out. diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json new file mode 100644 index 0000000000..120c348acb --- /dev/null +++ b/extensions/agent-context/agent-context-defaults.json @@ -0,0 +1,42 @@ +{ + "_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.", + "agents": { + "agy": "AGENTS.md", + "amp": "AGENTS.md", + "auggie": ".augment/rules/specify-rules.md", + "bob": "AGENTS.md", + "claude": "CLAUDE.md", + "cline": ".clinerules/specify-rules.md", + "codebuddy": "CODEBUDDY.md", + "codex": "AGENTS.md", + "copilot": ".github/copilot-instructions.md", + "cursor-agent": ".cursor/rules/specify-rules.mdc", + "devin": "AGENTS.md", + "firebender": ".firebender/rules/specify-rules.mdc", + "forge": "AGENTS.md", + "gemini": "GEMINI.md", + "generic": "AGENTS.md", + "goose": "AGENTS.md", + "hermes": "AGENTS.md", + "iflow": "IFLOW.md", + "junie": ".junie/AGENTS.md", + "kilocode": ".kilocode/rules/specify-rules.md", + "kimi": "AGENTS.md", + "kiro-cli": "AGENTS.md", + "lingma": ".lingma/rules/specify-rules.md", + "omp": "AGENTS.md", + "opencode": "AGENTS.md", + "pi": "AGENTS.md", + "qodercli": "QODER.md", + "qwen": "QWEN.md", + "roo": ".roo/rules/specify-rules.md", + "rovodev": "AGENTS.md", + "shai": "SHAI.md", + "tabnine": "TABNINE.md", + "trae": ".trae/rules/project_rules.md", + "vibe": "AGENTS.md", + "windsurf": ".windsurf/rules/specify-rules.md", + "zcode": "ZCODE.md", + "zed": "AGENTS.md" + } +} diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 64e1bae89b..c3e5c2020e 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -59,7 +59,7 @@ case "$(uname -s 2>/dev/null || true)" in esac # Parse extension config once; emit context files as JSON, followed by marker strings. -if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY' +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY' import json import sys try: @@ -95,24 +95,67 @@ def get_str(obj, *keys): context_files = [] seen_context_files = set() case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin")) +def add_context_file(value): + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + key = candidate.casefold() if case_insensitive else candidate + if key in seen_context_files: + return + context_files.append(candidate) + seen_context_files.add(key) raw_files = data.get("context_files") if isinstance(raw_files, list): for value in raw_files: - if not isinstance(value, str): - continue - candidate = value.strip() - if not candidate: - continue - key = candidate.casefold() if case_insensitive else candidate - if key in seen_context_files: - continue - context_files.append(candidate) - seen_context_files.add(key) + add_context_file(value) if not context_files: - raw_file = get_str(data, "context_file") - candidate = raw_file.strip() - if candidate: - context_files.append(candidate) + add_context_file(get_str(data, "context_file")) +if not context_files: + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). This is independent of the Specify CLI by + # design — nothing here imports specify_cli. + project_root = sys.argv[3] if len(sys.argv) > 3 else "." + integration_key = "" + try: + with open( + f"{project_root}/.specify/init-options.json", "r", encoding="utf-8" + ) as fh: + opts = json.load(fh) + if isinstance(opts, dict): + value = opts.get("integration") or opts.get("ai") or "" + integration_key = value if isinstance(value, str) else "" + except Exception: + integration_key = "" + if integration_key: + defaults_path = ( + f"{project_root}/.specify/extensions/agent-context/" + "agent-context-defaults.json" + ) + mapping = {} + try: + with open(defaults_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {} + mapping = agents if isinstance(agents, dict) else {} + except Exception: + print( + "agent-context: unable to read %s; cannot self-seed the context " + "file. Set 'context_file' in the extension config." % defaults_path, + file=sys.stderr, + ) + mapping = {} + add_context_file(mapping.get(integration_key, "") or "") + if not context_files: + print( + "agent-context: no default context file is known for integration " + "'%s'. Set 'context_file' in the extension config to choose one." + % integration_key, + file=sys.stderr, + ) print(json.dumps(context_files)) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) @@ -295,11 +338,58 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do mkdir -p "$(dirname "$CTX_PATH")" "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' -import sys, os +import os +import re +import sys + ctx_path, start, end, section_path = sys.argv[1:5] with open(section_path, "r", encoding="utf-8") as fh: section = fh.read().rstrip("\n") + "\n" + +def ensure_mdc_frontmatter(content): + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with + ``alwaysApply: true``. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + """ + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + match = re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text): + return content + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + if os.path.exists(ctx_path): with open(ctx_path, "r", encoding="utf-8-sig") as fh: content = fh.read() @@ -329,6 +419,8 @@ else: new_content = section new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +if ctx_path.casefold().endswith(".mdc"): + new_content = ensure_mdc_frontmatter(new_content) with open(ctx_path, "wb") as fh: fh.write(new_content.encode("utf-8")) PY diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index da9ff443cb..98a55c55fd 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -20,6 +20,56 @@ param( [string]$PlanPath ) +function Add-MdcFrontmatter { + <# + Ensure .mdc content has YAML frontmatter with alwaysApply: true. + + Cursor only auto-loads .mdc rule files that carry frontmatter with + alwaysApply: true. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content) + + $leading = '' + $stripped = $Content + $m = [regex]::Match($Content, '^\s*') + if ($m.Success) { + $leading = $m.Value + $stripped = $Content.Substring($m.Length) + } + + if (-not $stripped.StartsWith('---')) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $fm = [regex]::Match($stripped, '^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)', [System.Text.RegularExpressions.RegexOptions]::Singleline) + if (-not $fm.Success) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $opening = $fm.Groups[1].Value + $fmText = $fm.Groups[2].Value + $closing = $fm.Groups[3].Value + $sep = $fm.Groups[4].Value + $rest = $fm.Groups[5].Value + $newline = if ($opening.Contains("`r`n")) { "`r`n" } else { "`n" } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$')) { + return $Content + } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:')) { + $alwaysApplyRegex = [regex]'(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$' + $fmText = $alwaysApplyRegex.Replace($fmText, '${1}alwaysApply: true${2}', 1) + } elseif ($fmText.Trim()) { + $fmText = $fmText + $newline + 'alwaysApply: true' + } else { + $fmText = 'alwaysApply: true' + } + + return "$leading$opening$fmText$closing$sep$rest" +} + function Get-ConfigValue { param( [AllowNull()][object]$Object, @@ -250,6 +300,43 @@ foreach ($ContextFile in $ContextFiles) { } } $ContextFiles = $dedupedContextFiles +if ($ContextFiles.Count -eq 0) { + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). Independent of the Specify CLI by design. + $initOptionsPath = Join-Path $ProjectRoot '.specify/init-options.json' + if (Test-Path -LiteralPath $initOptionsPath) { + try { + $initOpts = Get-Content -LiteralPath $initOptionsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $integrationKey = $null + if ($initOpts.PSObject.Properties['integration'] -and $initOpts.integration) { + $integrationKey = [string]$initOpts.integration + } elseif ($initOpts.PSObject.Properties['ai'] -and $initOpts.ai) { + $integrationKey = [string]$initOpts.ai + } + if ($integrationKey) { + $defaultsPath = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-defaults.json' + if (Test-Path -LiteralPath $defaultsPath) { + $defaults = Get-Content -LiteralPath $defaultsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $derived = $null + if ($defaults.PSObject.Properties['agents'] -and $defaults.agents.PSObject.Properties[$integrationKey]) { + $derived = [string]$defaults.agents.PSObject.Properties[$integrationKey].Value + } + if ($derived -and -not [string]::IsNullOrWhiteSpace($derived)) { + $ContextFiles += $derived.Trim() + } else { + Write-Warning ("agent-context: no default context file is known for integration '{0}'; set 'context_file' in the extension config to choose one." -f $integrationKey) + } + } else { + Write-Warning ("agent-context: unable to read {0}; cannot self-seed the context file. Set 'context_file' in the extension config." -f $defaultsPath) + } + } + } catch { + # Non-fatal: fall through to the nothing-to-do guard below. + } + } +} if ($ContextFiles.Count -eq 0) { Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.' exit 0 @@ -411,6 +498,9 @@ foreach ($ContextFile in $ContextFiles) { } $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") + if ($ContextFile -match '\.mdc$') { + $newContent = Add-MdcFrontmatter -Content $newContent + } [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) Write-Host "agent-context: updated $ContextFile" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6713549d35..5d5361cc8e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -262,85 +262,9 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = console.print(f" - {f}") # --------------------------------------------------------------------------- -# Agent-context extension config helpers +# Skills directory helpers # --------------------------------------------------------------------------- -_AGENT_CTX_EXT_CONFIG = ( - Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" -) - - -def _load_agent_context_config(project_root: Path) -> dict[str, Any]: - """Load the agent-context extension config, returning defaults on failure.""" - from .integrations.base import IntegrationBase - - defaults: dict[str, Any] = { - "context_file": "", - "context_files": [], - "context_markers": { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - }, - } - path = project_root / _AGENT_CTX_EXT_CONFIG - if not path.exists(): - return defaults - try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, yaml.YAMLError): - return defaults - if not isinstance(raw, dict): - return defaults - return raw - - -def _save_agent_context_config( - project_root: Path, config: dict[str, Any] -) -> None: - """Persist *config* to the agent-context extension config file.""" - path = project_root / _AGENT_CTX_EXT_CONFIG - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8") - - -def _update_agent_context_config_file( - project_root: Path, - context_file: str | None, - *, - preserve_markers: bool = True, - preserve_context_files: bool = True, -) -> None: - """Update the agent-context extension config with *context_file*. - - When *preserve_markers* is True (default), any existing - ``context_markers`` values are kept unchanged so user customisations - survive integration changes and reinit. When False, the default - markers are written unconditionally. - - When *preserve_context_files* is True (default), an existing - ``context_files`` list is kept unchanged, including an empty list. This - lets projects opt into updating multiple agent context files while still - preserving the legacy singular ``context_file`` value for compatibility. - """ - from .integrations.base import IntegrationBase - - cfg = _load_agent_context_config(project_root) - cfg["context_file"] = context_file or "" - existing_context_files = cfg.get("context_files") - if preserve_context_files: - cfg["context_files"] = ( - existing_context_files if isinstance(existing_context_files, list) else [] - ) - else: - cfg.pop("context_files", None) - if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): - cfg["context_markers"] = { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } - _save_agent_context_config(project_root, cfg) - - def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index da3ca49fa6..7864260a99 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -433,37 +433,6 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from the agent-context extension config. - # When disabled, ignore stale context_files but keep the singular - # context_file value so generated commands still point at the agent - # context file managed before the extension was disabled. - from .integrations.base import IntegrationBase - - # Local import: _load_agent_context_config lives in __init__.py which - # imports agents.py, so a top-level import would be circular. - from . import _load_agent_context_config - - ac_cfg = _load_agent_context_config(project_root) - extension_enabled = IntegrationBase._agent_context_extension_enabled( - project_root - ) - if extension_enabled: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - ) - else: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - include_context_files=False, - validate=False, - ) - context_file = IntegrationBase._format_context_file_values(context_files) - body = body.replace("__CONTEXT_FILE__", context_file) - return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fc82334da2..dd815b8c5d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -18,7 +18,6 @@ SCRIPT_TYPE_CHOICES, ) from .._assets import ( - _locate_bundled_extension, _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, @@ -171,7 +170,6 @@ def init( from .. import ( _install_shared_infra_or_exit, _print_cli_warning, - _update_agent_context_config_file, ensure_executable_scripts, save_init_options, ) @@ -376,7 +374,6 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("workflow", "Install bundled workflow"), - ("agent-context", "Install agent-context extension"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -507,47 +504,6 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) - # --- agent-context extension (bundled, auto-installed) --- - # Installed after init-options.json is written so that skill - # registration can read ai_skills + integration key. - try: - from ..extensions import ExtensionManager as _ExtMgr - - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: - ac_mgr = _ExtMgr(project_path) - if ac_mgr.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") - else: - ac_mgr.install_from_directory( - bundled_ac, get_speckit_version() - ) - tracker.complete("agent-context", "extension installed") - else: - from ..extensions import REINSTALL_COMMAND as _ac_reinstall - - tracker.error( - "agent-context", - f"bundled extension not found — installation may be " - f"incomplete. Run: {_ac_reinstall}", - ) - except Exception as ac_err: - sanitized_ac = str(ac_err).replace("\n", " ").strip() - tracker.error( - "agent-context", - f"extension install failed: {sanitized_ac[:120]}", - ) - - # Write context_file to the agent-context extension config - # AFTER the extension install (which copies the template config - # with an empty context_file). - if resolved_integration.context_file: - _update_agent_context_config_file( - project_path, - resolved_integration.context_file, - preserve_markers=True, - ) - ensure_executable_scripts(project_path, tracker=tracker) if preset: diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integration_scaffold.py index e4c4b83b3d..f0ed210332 100644 --- a/src/specify_cli/integration_scaffold.py +++ b/src/specify_cli/integration_scaffold.py @@ -117,11 +117,6 @@ class {class_name}({template.base_class}): "args": "{template.args}", "extension": "{template.extension}", }} - context_file = "AGENTS.md" - # Default to False so the generated boilerplate passes the registry - # contract out of the box: multi-install-safe integrations must each have a - # distinct context_file, and the placeholder above ("AGENTS.md") collides - # with the existing codex integration. Opt in once you pick a unique one. multi_install_safe = False ''' @@ -155,7 +150,6 @@ def test_metadata(): assert integration.registrar_config["format"] == "{template.registrar_format}" assert integration.registrar_config["args"] == "{template.args}" assert integration.registrar_config["extension"] == "{template.extension}" - assert integration.context_file == "AGENTS.md" assert integration.multi_install_safe is False ''' @@ -274,7 +268,7 @@ def scaffold_integration( next_steps = ( f"Register {class_name} in src/specify_cli/integrations/__init__.py.", - "Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.", + "Review config metadata, install_url, requires_cli, and multi_install_safe.", f"Run pytest tests/integrations/test_integration_{package_name}.py -v.", ) return IntegrationScaffoldResult( diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index f8a696a866..d1bf051f77 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -103,38 +103,17 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None: def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match. - - Also clears ``context_file`` from the agent-context extension config so - no stale path is left behind when the integration is uninstalled. - """ + """Clear active integration keys from init-options.json when they match.""" from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) opts = load_init_options(project_root) - has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts) - # Remove legacy fields that older versions may have written. - opts.pop("context_file", None) - opts.pop("context_markers", None) - if opts.get("integration") == integration_key or opts.get("ai") == integration_key: opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) save_init_options(project_root, opts) - # Clear context_file in the extension config if it already exists. - # Avoid creating the config (and parent dirs) in projects where the - # agent-context extension was never installed. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, "", preserve_markers=True, preserve_context_files=False - ) - elif has_legacy_context_keys: - save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: @@ -274,21 +253,13 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update init-options.json and the agent-context extension config to - reflect *integration* as the active one. - - ``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context - extension config (``.specify/extensions/agent-context/agent-context-config.yml``), - not in ``init-options.json``. Existing user-customised markers are - always preserved when the config already exists. Existing ``context_files`` - lists are also preserved so projects can keep multi-agent context anchors - during integration switches. Invalid marker values are - silently ignored at runtime by ``_resolve_context_markers()`` which falls - back to the class-level defaults. + """Update init-options.json to reflect *integration* as the active one. + + Agent context/instruction files are owned entirely by the opt-in + agent-context extension, so this function never touches the extension + or its config. """ from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) @@ -296,9 +267,6 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - # Remove legacy fields if they were written by an older version. - opts.pop("context_file", None) - opts.pop("context_markers", None) opts["speckit_version"] = _get_speckit_version() if script_type: opts["script"] = script_type @@ -307,24 +275,6 @@ def _update_init_options_for_integration( else: opts.pop("ai_skills", None) - # Update the agent-context extension config BEFORE init-options.json - # so a failure here doesn't leave init-options partially updated. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=True, - ) - elif integration.context_file: - # Extension config doesn't exist yet (extension not installed). - # Write defaults so scripts have something to read. - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=False, - ) - save_init_options(project_root, opts) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 6ed69e1e0e..33f8d17a91 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -42,7 +42,6 @@ class AgyIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @staticmethod def _inject_hook_command_note(content: str) -> str: diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 39df0a9bbf..5d9d14250d 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -18,4 +18,3 @@ class AmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 08e20fbc25..e6fd702fa3 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -18,5 +18,4 @@ class AuggieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".augment/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index be3ab7133d..c820fd4eed 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,14 +13,13 @@ from __future__ import annotations -import json import os import re import shlex import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path, PureWindowsPath +from pathlib import Path from typing import TYPE_CHECKING, Any import yaml @@ -91,13 +90,9 @@ class IntegrationBase(ABC): And may optionally set: - * ``context_file`` — path (relative to project root) of the agent - context/instructions file (e.g. ``"CLAUDE.md"``) - - Projects may additionally opt into managing multiple context files by - setting ``context_files`` in the agent-context extension config. The - integration class still declares one default ``context_file`` for backwards - compatibility and command-template rendering. + * ``invoke_separator`` — slash-command separator (defaults to ``"."``) + * ``multi_install_safe`` — declare the integration safe to install + alongside others (defaults to ``False``) """ # -- Must be set by every subclass ------------------------------------ @@ -113,9 +108,6 @@ class IntegrationBase(ABC): # -- Optional --------------------------------------------------------- - context_file: str | None = None - """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" - invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" @@ -125,16 +117,11 @@ class IntegrationBase(ABC): multi_install_safe: bool = False """Whether this integration is declared safe to install alongside others. - Safe integrations must use a static, unique agent root, command directory, - and context file. Registry tests enforce those invariants for every + Safe integrations must use a static, unique agent root and command + directory. Registry tests enforce those invariants for every integration that sets this flag. """ - # -- Markers for managed context section ------------------------------ - - CONTEXT_MARKER_START = "" - CONTEXT_MARKER_END = "" - # -- Public API ------------------------------------------------------- @classmethod @@ -533,498 +520,6 @@ def install_scripts( return created - # -- Agent context file management ------------------------------------ - - @staticmethod - def _ensure_mdc_frontmatter(content: str) -> str: - """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. - - If frontmatter is missing, prepend it. If frontmatter exists but - ``alwaysApply`` is absent or not ``true``, inject/fix it. - - Uses string/regex manipulation to preserve comments and formatting - in existing frontmatter. - """ - import re as _re - - leading_ws = len(content) - len(content.lstrip()) - leading = content[:leading_ws] - stripped = content[leading_ws:] - - if not stripped.startswith("---"): - return "---\nalwaysApply: true\n---\n\n" + content - - # Match frontmatter block: ---\n...\n--- - match = _re.match( - r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", - stripped, - _re.DOTALL, - ) - if not match: - return "---\nalwaysApply: true\n---\n\n" + content - - opening, fm_text, closing, sep, rest = match.groups() - newline = "\r\n" if "\r\n" in opening else "\n" - - # Already correct? - if _re.search( - r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text - ): - return content - - # alwaysApply exists but wrong value — fix in place while preserving - # indentation and any trailing inline comment. - if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): - fm_text = _re.sub( - r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", - r"\1alwaysApply: true\2", - fm_text, - count=1, - ) - elif fm_text.strip(): - fm_text = fm_text + newline + "alwaysApply: true" - else: - fm_text = "alwaysApply: true" - - return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" - - @staticmethod - def _build_context_section(plan_path: str = "") -> str: - """Build the content for the managed section between markers. - - *plan_path* is the project-relative path to the current plan - (e.g. ``"specs//plan.md"``). When empty, the section - contains only the generic directive without a concrete path. - """ - lines = [ - "For additional context about technologies to be used, project structure,", - "shell commands, and other important information, read the current plan", - ] - if plan_path: - lines.append(f"at {plan_path}") - return "\n".join(lines) - - @staticmethod - def _agent_context_extension_enabled(project_root: Path) -> bool: - """Return whether the bundled ``agent-context`` extension is enabled. - - The extension is the single source of truth for managing coding - agent context/instruction files (e.g. ``CLAUDE.md``, - ``.github/copilot-instructions.md``). - - Returns ``True`` (enabled) when: - - the extension registry does not exist (legacy project, backwards - compatibility), or - - the registry has no ``agent-context`` entry (older project layout - predating the extension), or - - the entry is present and not explicitly disabled. - - Returns ``False`` only when an entry exists with ``enabled: false``. - """ - registry_path = ( - project_root / ".specify" / "extensions" / ".registry" - ) - if not registry_path.exists(): - return True - try: - data = json.loads(registry_path.read_text(encoding="utf-8")) - except (OSError, ValueError, UnicodeError): - return True - if not isinstance(data, dict): - return True - extensions = data.get("extensions") - if not isinstance(extensions, dict): - return True - entry = extensions.get("agent-context") - if not isinstance(entry, dict): - return True - return entry.get("enabled", True) is not False - - @staticmethod - def _context_file_dedupe_key(path: str) -> str: - """Return the comparison key for context file de-duplication.""" - return path.casefold() if os.name == "nt" else path - - def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: - """Return the (start, end) context markers to use for *project_root*. - - Reads ``context_markers.start`` / ``context_markers.end`` from the - agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present. Falls back to the class-level constants - ``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is - missing, the section is absent, or the values are not non-empty - strings. - """ - from .._console import console # local import to avoid cycles - - start = self.CONTEXT_MARKER_START - end = self.CONTEXT_MARKER_END - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - return start, end - markers = cfg.get("context_markers") if isinstance(cfg, dict) else None - if isinstance(markers, dict): - cm_start = markers.get("start") - cm_end = markers.get("end") - s_valid = isinstance(cm_start, str) and cm_start - e_valid = isinstance(cm_end, str) and cm_end - if not s_valid and cm_start is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.start " - f"({cm_start!r}), using default[/yellow]" - ) - if not e_valid and cm_end is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.end " - f"({cm_end!r}), using default[/yellow]" - ) - if s_valid: - start = cm_start # type: ignore[assignment] - if e_valid: - end = cm_end # type: ignore[assignment] - return start, end - - @staticmethod - def _validate_context_file_path(project_root: Path, context_file: str) -> str: - """Return a safe project-relative context file path. - - The agent-context scripts reject paths that can escape the project - root; the Python integration path must apply the same guard before - setup or teardown touches context files. - """ - candidate = context_file.strip() - if not candidate: - raise ValueError("agent-context: context file path must not be empty") - - win_path = PureWindowsPath(candidate) - if Path(candidate).is_absolute() or win_path.drive or win_path.root: - raise ValueError( - "agent-context: context files must be project-relative paths; " - f"got {candidate!r}" - ) - if "\\" in candidate: - raise ValueError( - "agent-context: context files must not contain backslash " - f"separators; got {candidate!r}" - ) - - parts = [part for part in re.split(r"[\\/]+", candidate) if part] - if ".." in parts: - raise ValueError( - "agent-context: context files must not contain '..' path " - f"segments; got {candidate!r}" - ) - - root = project_root.resolve() - target = (root / candidate).resolve(strict=False) - try: - target.relative_to(root) - except ValueError as exc: - raise ValueError( - "agent-context: context file path resolves outside the project " - f"root; got {candidate!r}" - ) from exc - - return candidate - - @classmethod - def _resolve_context_file_values( - cls, - project_root: Path, - cfg: dict[str, Any] | None, - *, - fallback_context_file: Any = None, - legacy_context_file: Any = None, - include_context_files: bool = True, - validate: bool = True, - ) -> list[str]: - """Resolve context file config with shared precedence and de-duplication.""" - files: list[str] = [] - seen: set[str] = set() - - def add_context_file(value: Any) -> None: - if not isinstance(value, str): - return - candidate = value.strip() - if not candidate: - return - if validate: - candidate = cls._validate_context_file_path(project_root, candidate) - key = cls._context_file_dedupe_key(candidate) - if key in seen: - return - files.append(candidate) - seen.add(key) - - if isinstance(cfg, dict) and include_context_files: - configured = cfg.get("context_files") - if isinstance(configured, list): - for value in configured: - add_context_file(value) - if files: - return files - - if isinstance(cfg, dict): - add_context_file(cfg.get("context_file")) - if files: - return files - - add_context_file(fallback_context_file) - if files: - return files - - add_context_file(legacy_context_file) - return files - - @staticmethod - def _format_context_file_values(context_files: list[str]) -> str: - """Return context file targets as the template display string.""" - return ", ".join(context_files) - - def _resolve_context_files(self, project_root: Path) -> list[str]: - """Return project-relative context files managed for *project_root*. - - ``context_files`` in the agent-context extension config, when present - and non-empty, takes precedence over the config's singular - ``context_file``. The integration class default is used only when the - extension config has no context file target. - Raises ``ValueError`` when a configured path can escape the project - root. - """ - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - cfg = None - return self._resolve_context_file_values( - project_root, - cfg, - fallback_context_file=self.context_file, - ) - - def _context_file_display(self, project_root: Path) -> str: - """Return human-readable context file target(s) for templates.""" - if not self._agent_context_extension_enabled(project_root): - from .. import _load_agent_context_config - - context_files = self._resolve_context_file_values( - project_root, - _load_agent_context_config(project_root), - fallback_context_file=self.context_file, - include_context_files=False, - validate=False, - ) - return context_files[0] if context_files else "" - return self._format_context_file_values( - self._resolve_context_files(project_root) - ) - - @staticmethod - def _upsert_context_file( - ctx_path: Path, - section: str, - marker_start: str, - marker_end: str, - ) -> None: - """Create or update one managed context section.""" - if ctx_path.exists(): - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) - - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: - # Replace existing section (include the end marker + newline) - end_of_marker = end_idx + len(marker_end) - # Consume trailing line ending (CRLF or LF) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = content[:start_idx] + section + content[end_of_marker:] - elif start_idx != -1: - # Corrupted: start marker without end — replace from start through EOF - new_content = content[:start_idx] + section - elif end_idx != -1: - # Corrupted: end marker without start — replace BOF through end marker - end_of_marker = end_idx + len(marker_end) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = section + content[end_of_marker:] - else: - # No markers found — append - if content: - if not content.endswith("\n"): - content += "\n" - new_content = content + "\n" + section - else: - new_content = section - - # Ensure .mdc files have required YAML frontmatter - if ctx_path.suffix == ".mdc": - new_content = IntegrationBase._ensure_mdc_frontmatter(new_content) - else: - ctx_path.parent.mkdir(parents=True, exist_ok=True) - # Cursor .mdc files require YAML frontmatter to be loaded - if ctx_path.suffix == ".mdc": - new_content = IntegrationBase._ensure_mdc_frontmatter(section) - else: - new_content = section - - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - ctx_path.write_bytes(normalized.encode("utf-8")) - - def upsert_context_section( - self, - project_root: Path, - plan_path: str = "", - ) -> Path | None: - """Create or update the managed section in the agent context file. - - If the context file does not exist it is created with just the - managed section. If it exists, the content between the configured - start/end markers (default ```` / - ````) is replaced, or appended when no markers - are found. Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - - Returns the path to the first context file, or ``None`` when no context - files are configured or the ``agent-context`` extension is - disabled. - """ - if not self._agent_context_extension_enabled(project_root): - return None - - context_files = self._resolve_context_files(project_root) - if not context_files: - return None - - from .._console import console # local import to avoid cycles - - console.print( - "[yellow]Deprecation:[/yellow] Inline agent-context updates during " - "integration setup will be disabled in v0.12.0. Context file " - "management has moved to the bundled [bold]agent-context[/bold] " - "extension. Run [cyan]specify extension disable agent-context[/cyan] " - "to opt out early.", - highlight=False, - ) - - marker_start, marker_end = self._resolve_context_markers(project_root) - - section = ( - f"{marker_start}\n" - f"{self._build_context_section(plan_path)}\n" - f"{marker_end}\n" - ) - - first_path: Path | None = None - for context_file in context_files: - ctx_path = project_root / context_file - self._upsert_context_file(ctx_path, section, marker_start, marker_end) - if first_path is None: - first_path = ctx_path - return first_path - - def remove_context_section(self, project_root: Path) -> bool: - """Remove the managed section from the agent context file. - - Returns ``True`` if the section was found and removed. If the - file becomes empty (or whitespace-only) after removal it is deleted. - Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - """ - if not self._agent_context_extension_enabled(project_root): - return False - - context_files = self._resolve_context_files(project_root) - if not context_files: - return False - - marker_start, marker_end = self._resolve_context_markers(project_root) - removed_any = False - - for context_file in context_files: - ctx_path = project_root / context_file - if not ctx_path.exists(): - continue - - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) - - # Only remove a complete, well-ordered managed section. If either - # marker is missing, leave the file unchanged to avoid deleting - # unrelated user-authored content. - if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: - continue - - removal_start = start_idx - removal_end = end_idx + len(marker_end) - - # Consume trailing line ending (CRLF or LF) - if removal_end < len(content) and content[removal_end] == "\r": - removal_end += 1 - if removal_end < len(content) and content[removal_end] == "\n": - removal_end += 1 - - # Also strip a blank line before the section if present - if removal_start > 0 and content[removal_start - 1] == "\n": - if removal_start > 1 and content[removal_start - 2] == "\n": - removal_start -= 1 - - new_content = content[:removal_start] + content[removal_end:] - - # Normalize line endings before comparisons - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - - # For .mdc files, treat Speckit-generated frontmatter-only content as empty - if ctx_path.suffix == ".mdc": - import re - - # Delete the file if only YAML frontmatter remains (no body content) - frontmatter_only = re.match( - r"^---\n.*?\n---\s*$", normalized, re.DOTALL - ) - if not normalized.strip() or frontmatter_only: - ctx_path.unlink() - removed_any = True - continue - - if not normalized.strip(): - ctx_path.unlink() - else: - ctx_path.write_bytes(normalized.encode("utf-8")) - removed_any = True - - return removed_any - @staticmethod def resolve_command_refs(content: str, separator: str = ".") -> str: """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. @@ -1049,7 +544,6 @@ def process_template( agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", - context_file: str = "", invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -1060,9 +554,8 @@ def process_template( 3. Strip ``scripts:`` section from frontmatter 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* 5. Replace ``__AGENT__`` with *agent_name* - 6. Replace ``__CONTEXT_FILE__`` with *context_file* - 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. - 8. Replace ``__SPECKIT_COMMAND___`` with invocation strings + 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -1122,10 +615,7 @@ def process_template( # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) - # 6. Replace __CONTEXT_FILE__ - content = content.replace("__CONTEXT_FILE__", context_file) - - # 7. Rewrite paths — delegate to the shared implementation in + # 6. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar @@ -1180,8 +670,6 @@ def setup( self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1196,11 +684,9 @@ def teardown( Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). - Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ - self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -1234,12 +720,11 @@ def uninstall( class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. - Subclasses only need to set ``key``, ``config``, ``registrar_config`` - (and optionally ``context_file``). Everything else is inherited. + Subclasses only need to set ``key``, ``config``, ``registrar_config``. + Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the - managed context section into the agent context file. + ``{ARGS}``, ``__AGENT__``, rewriting paths). """ def build_exec_args( @@ -1294,13 +779,11 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1308,8 +791,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1323,8 +804,7 @@ class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. Mirrors ``MarkdownIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config`` (and optionally - ``context_file``). Everything else is inherited. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1500,14 +980,12 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1517,8 +995,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1532,8 +1008,7 @@ class YamlIntegration(IntegrationBase): """Concrete base for integrations that use YAML recipe format. Mirrors ``TomlIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config`` (and optionally - ``context_file``). Everything else is inherited. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1696,7 +1171,6 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1712,7 +1186,6 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1724,8 +1197,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1741,8 +1212,8 @@ class SkillsIntegration(IntegrationBase): Skills use the ``speckit-/SKILL.md`` directory layout following the `agentskills.io `_ spec. - Subclasses set ``key``, ``config``, ``registrar_config`` (and - optionally ``context_file``) like any integration. They may also + Subclasses set ``key``, ``config``, ``registrar_config`` like any + integration. They may also override ``options()`` to declare additional CLI flags (e.g. ``--skills``, ``--migrate-legacy``). @@ -1887,7 +1358,6 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1911,7 +1381,6 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. @@ -1958,7 +1427,5 @@ def _quote(v: str) -> str: ) created.append(dst) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 78f2df0379..b953151bd2 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -18,4 +18,3 @@ class BobIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 41d5b14b10..923a77607a 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -52,7 +52,6 @@ class ClaudeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" multi_install_safe = True @staticmethod diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c269a16042..ab839b9b56 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -70,7 +70,6 @@ class ClineIntegration(MarkdownIntegration): "format_name": format_cline_command_name, "invoke_separator": "-", } - context_file = ".clinerules/specify-rules.md" invoke_separator = "-" multi_install_safe = True diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index c5b4503b6b..1487096905 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -18,5 +18,4 @@ class CodebuddyIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "CODEBUDDY.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 4dd79da493..7d1ff86e27 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -26,7 +26,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" dev_no_symlink = True multi_install_safe = True diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 2659b3f252..5cc34d2b1d 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -4,7 +4,6 @@ - Commands use ``.agent.md`` extension (not ``.md``) - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations -- Context file lives at ``.github/copilot-instructions.md`` When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` @@ -79,7 +78,6 @@ class _CopilotSkillsHelper(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".github/copilot-instructions.md" class CopilotIntegration(IntegrationBase): @@ -108,7 +106,6 @@ class CopilotIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".agent.md", } - context_file = ".github/copilot-instructions.md" # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False @@ -354,14 +351,12 @@ def _setup_default( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - context_file_display = self._context_file_display(project_root) # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -396,8 +391,6 @@ def _setup_default( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index b83ee42e54..2c328b2fda 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -36,7 +36,6 @@ class CursorAgentIntegration(SkillsIntegration): "extension": "/SKILL.md", } - context_file = ".cursor/rules/specify-rules.mdc" multi_install_safe = True def build_exec_args( diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index b3b21b8526..18c1fc8d6d 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -30,7 +30,6 @@ class DevinIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/firebender/__init__.py b/src/specify_cli/integrations/firebender/__init__.py index b49140b1f8..eb0cec02d5 100644 --- a/src/specify_cli/integrations/firebender/__init__.py +++ b/src/specify_cli/integrations/firebender/__init__.py @@ -3,8 +3,8 @@ Firebender (https://firebender.com/) is an AI coding agent for Android Studio and IntelliJ. It reads project-local custom slash commands from ``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``, -so Spec Kit installs its command templates as ``.mdc`` command files and writes -the managed context section into a ``.firebender/rules/`` rule file. +so Spec Kit installs its command templates as ``.mdc`` command files. The managed +context section (when used) is owned by the ``agent-context`` extension. """ from ..base import MarkdownIntegration @@ -25,7 +25,6 @@ class FirebenderIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".mdc", } - context_file = ".firebender/rules/specify-rules.mdc" multi_install_safe = True def command_filename(self, template_name: str) -> str: diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index d1cd7a49a8..8c21353fec 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -89,7 +89,6 @@ class ForgeIntegration(MarkdownIntegration): "format_name": format_forge_command_name, # Custom name formatter "invoke_separator": "-", } - context_file = "AGENTS.md" invoke_separator = "-" def setup( @@ -128,14 +127,12 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{parameters}}") created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) @@ -152,8 +149,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 7c6fe159c7..9a459862af 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -18,5 +18,4 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 3d6dd19d44..d874273559 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,6 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -119,13 +118,11 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$ARGUMENTS" created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -133,7 +130,5 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..77d4e0f837 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -18,4 +18,3 @@ class GooseIntegration(YamlIntegration): "args": "{{args}}", "extension": ".yaml", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1d475c72e2..e094dcfcfe 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -50,7 +50,6 @@ class HermesIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- Helpers ----------------------------------------------------------- @@ -114,7 +113,6 @@ def setup( global_skills_dir.mkdir(parents=True, exist_ok=True) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -141,7 +139,6 @@ def setup( self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. @@ -183,8 +180,6 @@ def _quote(v: str) -> str: skill_file.write_bytes(normalized.encode("utf-8")) created.append(skill_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) # Create project-local marker directory so extension commands # (e.g. git) can detect Hermes as an active integration. @@ -204,8 +199,7 @@ def teardown( ) -> tuple[list[Path], list[Path]]: """Uninstall integration files including global Hermes skills. - Removes the managed context section from AGENTS.md, removes the - project-local marker directory (if empty), delegates to + Removes the project-local marker directory (if empty), delegates to ``manifest.uninstall()`` for project-local tracked files, and removes all ``speckit-*`` skills under ``~/.hermes/skills/``. @@ -213,8 +207,6 @@ def teardown( standard integration behaviour where all files created by the integration are removed on ``specify integration uninstall``. """ - # Remove managed context section from AGENTS.md - self.remove_context_section(project_root) # Delegate to manifest for project-local tracked files (scripts, # templates, context entries tracked in the manifest). diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py index 65d4d21c63..c6b5447bb1 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -18,5 +18,4 @@ class IflowIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "IFLOW.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 98d0494a8a..e1e8a9addb 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -18,5 +18,4 @@ class JunieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".junie/AGENTS.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index 11674dd9f1..0924843286 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -18,5 +18,4 @@ class KilocodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".kilocode/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 9c28855c02..3320935a03 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -5,8 +5,7 @@ Legacy migration covers projects created before Kimi Code CLI moved to this layout and handles two distinct changes: the directory move from -``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md`` -context file), and the dotted-to-hyphenated skill naming +``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming (``speckit.xxx`` → ``speckit-xxx``). """ @@ -16,7 +15,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationBase, IntegrationOption, SkillsIntegration +from ..base import IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -37,7 +36,6 @@ class KimiIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" multi_install_safe = False def build_command_invocation(self, command_name: str, args: str = "") -> str: @@ -79,9 +77,7 @@ def options(cls) -> list[IntegrationOption]: default=False, help=( "Migrate legacy Kimi installations: " - ".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, " - "and (when the agent-context extension is enabled) " - "KIMI.md user content → AGENTS.md" + ".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx" ), ), ] @@ -128,14 +124,6 @@ def setup( _is_safe_legacy_dir(new_skills_dir, project_root) ): _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) - # Mirror upsert/remove_context_section: a disabled agent-context - # extension is a full opt-out, so skip the KIMI.md → AGENTS.md - # migration entirely and leave both files untouched. - if self._agent_context_extension_enabled(project_root): - marker_start, marker_end = self._resolve_context_markers(project_root) - _migrate_legacy_kimi_context_file( - project_root, marker_start=marker_start, marker_end=marker_end - ) return created @@ -363,112 +351,6 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool: ) -def _migrate_legacy_kimi_context_file( - project_root: Path, - *, - marker_start: str = IntegrationBase.CONTEXT_MARKER_START, - marker_end: str = IntegrationBase.CONTEXT_MARKER_END, -) -> bool: - """Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``. - - The Speckit managed section is stripped from ``KIMI.md`` before the - remaining content is appended to ``AGENTS.md``. The legacy file is - deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was - migrated, ``False`` when the migration is skipped. - - The migration is skipped (leaving ``KIMI.md`` untouched) in any of these - cases, so a best-effort legacy cleanup never aborts ``setup()`` or - corrupts ``AGENTS.md``: - - - ``KIMI.md`` is a symlink, missing, or unreadable (its target could be - read from outside the project, or it may not be valid UTF-8). - - ``AGENTS.md`` is a symlink (it could redirect the write to a file - outside the project root), exists as a non-file (e.g. a directory), - or is unreadable/unwritable. - - ``KIMI.md`` has a corrupted managed section — only one marker is - present, or the end marker precedes the start. Stripping is only done - when both markers are present and well-ordered, so a partial managed - block is never copied into ``AGENTS.md``; the user repairs it manually. - """ - legacy_path = project_root / "KIMI.md" - if legacy_path.is_symlink() or not legacy_path.is_file(): - return False - - target_path = project_root / "AGENTS.md" - # Never follow a symlinked target, and never treat an existing non-file - # (e.g. a directory) as a writable context file. - if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): - return False - - try: - content = legacy_path.read_text(encoding="utf-8-sig") - except (OSError, UnicodeDecodeError): - return False - - marker_pairs = [(marker_start, marker_end)] - default_pair = ( - IntegrationBase.CONTEXT_MARKER_START, - IntegrationBase.CONTEXT_MARKER_END, - ) - if default_pair not in marker_pairs: - marker_pairs.append(default_pair) - - start_idx = -1 - end_idx = -1 - has_start = False - has_end = False - for s, e in marker_pairs: - s_idx = content.find(s) - e_idx = content.find(e, s_idx if s_idx != -1 else 0) - has_s = s_idx != -1 - has_e = e_idx != -1 - if not has_s and not has_e: - continue - # Refuse to migrate a corrupted managed section: exactly one marker, or - # an end marker that does not follow the start. - if has_s != has_e or e_idx <= s_idx: - return False - marker_start, marker_end = s, e - start_idx, end_idx = s_idx, e_idx - has_start = True - has_end = True - break - if has_start and has_end: - removal_start = start_idx - removal_end = end_idx + len(marker_end) - if removal_end < len(content) and content[removal_end] == "\r": - removal_end += 1 - if removal_end < len(content) and content[removal_end] == "\n": - removal_end += 1 - if removal_start > 0 and content[removal_start - 1] == "\n": - if removal_start > 1 and content[removal_start - 2] == "\n": - removal_start -= 1 - content = content[:removal_start] + content[removal_end:] - - user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() - if not user_content: - legacy_path.unlink() - return True - - try: - if target_path.is_file(): - existing = target_path.read_text(encoding="utf-8-sig") - existing = existing.replace("\r\n", "\n").replace("\r", "\n") - if not existing.endswith("\n"): - existing += "\n" - new_content = existing + "\n" + user_content + "\n" - else: - new_content = user_content + "\n" - - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_bytes(new_content.encode("utf-8")) - except (OSError, UnicodeDecodeError): - return False - - legacy_path.unlink() - return True - - def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: """Compatibility shim — migrate legacy dotted skill dirs in place. diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4571b54f90..4c176e5127 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -26,4 +26,3 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py index b5cd036033..2cb74b2192 100644 --- a/src/specify_cli/integrations/lingma/__init__.py +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".lingma/rules/specify-rules.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py index 73f95a4f2c..1565832989 100644 --- a/src/specify_cli/integrations/omp/__init__.py +++ b/src/specify_cli/integrations/omp/__init__.py @@ -20,7 +20,6 @@ class OmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index abd97ab2ae..0f734b7f41 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,7 +19,6 @@ class OpencodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py index 2cb738e04e..ceff628bdb 100644 --- a/src/specify_cli/integrations/pi/__init__.py +++ b/src/specify_cli/integrations/pi/__init__.py @@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index ee2d4b6255..13535203cf 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -18,5 +18,4 @@ class QodercliIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QODER.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 2506a57681..1e8c15bf91 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -18,5 +18,4 @@ class QwenIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QWEN.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index f610a3cc63..2042c09339 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -18,5 +18,4 @@ class RooIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".roo/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index f8879424ac..01aa870c66 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -39,7 +39,6 @@ class RovodevIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- CLI dispatch ------------------------------------------------------ @@ -228,8 +227,7 @@ def setup( ) -> list[Path]: """Install RovoDev skills, then generate prompt wrappers and manifest. - 1. ``SkillsIntegration.setup()`` generates skill files and - upserts the context section. + 1. ``SkillsIntegration.setup()`` generates the skill files. 2. Generates prompt wrappers and ``prompts.yml`` for each skill created in step 1. """ diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 123953da72..8be9596bf1 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -18,5 +18,4 @@ class ShaiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "SHAI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 0d0076bc56..9edf1e1607 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -18,5 +18,4 @@ class TabnineIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "TABNINE.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 4556487d07..03a628d422 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -26,7 +26,6 @@ class TraeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".trae/rules/project_rules.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 7922aa8418..136dec8674 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -28,7 +28,6 @@ class VibeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index ae5c3301f4..eba38fd1e5 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -18,5 +18,4 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py index ea47f31555..46d93c5ca2 100644 --- a/src/specify_cli/integrations/zcode/__init__.py +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -28,7 +28,6 @@ class ZcodeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "ZCODE.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 882d83cc59..441e9e36f9 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -27,7 +27,6 @@ class ZedIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 8e00e3ef9b..e82bd4b303 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -156,14 +156,11 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate - Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites - Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase -4. **Agent context update**: - - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) - -**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file +**Output**: data-model.md, /contracts/*, quickstart.md ## Key rules -- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation - ERROR on gate failures or unresolved clarifications ## Done When diff --git a/tests/extensions/test_agent_context_cli_free.py b/tests/extensions/test_agent_context_cli_free.py new file mode 100644 index 0000000000..9bba8087c0 --- /dev/null +++ b/tests/extensions/test_agent_context_cli_free.py @@ -0,0 +1,57 @@ +"""Static guard: the Specify CLI source must contain no agent-context lifecycle code. + +The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The +Python codebase (``src/specify_cli/**``) must therefore not reference any of the +removed context-section management helpers, the extension config helpers, the +context markers, or the obsolete deprecation message. + +Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli" + +FORBIDDEN_SYMBOLS = [ + "upsert_context_section", + "remove_context_section", + "_agent_context_extension_enabled", + "_resolve_context_markers", + "_resolve_context_files", + "_resolve_context_file_values", + "_build_context_section", + "_AGENT_CTX_EXT_CONFIG", + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "CONTEXT_MARKER_START", + "CONTEXT_MARKER_END", + "agent-context-config", + "agent_context_config", + "__CONTEXT_FILE__", + "_context_file_display", + "Inline agent-context updates", + "v0.12.0", +] + + +@pytest.fixture(scope="module") +def cli_source_texts() -> list[tuple[str, str]]: + """Read every CLI source file once, shared across all parametrized cases.""" + return [ + (str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8")) + for path in SRC_ROOT.rglob("*.py") + ] + + +@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS) +def test_symbol_absent_from_cli_source(symbol, cli_source_texts): + offenders = [rel for rel, text in cli_source_texts if symbol in text] + assert not offenders, ( + f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}" + ) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index ab4194efd8..f99d449401 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -13,14 +13,9 @@ import yaml from specify_cli import ( - _load_agent_context_config, - _save_agent_context_config, - load_init_options, save_init_options, ) from specify_cli.agents import CommandRegistrar -from specify_cli.integrations.base import IntegrationBase -from specify_cli.integrations.claude import ClaudeIntegration from tests.conftest import requires_bash @@ -33,19 +28,34 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None: - """Write a minimal agent-context extension config.""" + """Write a minimal agent-context extension config directly. + + The CLI no longer owns the extension config — the bundled extension does — + so tests write it themselves rather than going through any CLI helper. + """ cfg: dict = { "context_file": overrides.get("context_file", ""), "context_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, + "start": "", + "end": "", }, ), } - _save_agent_context_config(project_root, cfg) + path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) # ── Bundled extension layout ───────────────────────────────────────────────── @@ -120,19 +130,27 @@ def test_catalog_lists_agent_context_as_bundled(self): assert entry["author"] == "spec-kit-core" -# ── Marker resolution from extension config ────────────────────────────────── - - -class _CtxIntegration(ClaudeIntegration): - """Use Claude as a concrete integration with a context_file.""" - - -class _NoContextIntegration(IntegrationBase): - """Minimal integration with no context_file for base-class fallback tests.""" def _install_agent_context_config(project_root: Path, **overrides: object) -> None: _write_ext_config(project_root, **overrides) + # Mirror the real install layout: the extension ships its own + # agent->context-file defaults map alongside the config. Self-seeding + # tests depend on it, so require it to exist and always copy it rather + # than silently skipping when it is missing. + defaults_src = EXT_DIR / "agent-context-defaults.json" + assert defaults_src.is_file(), ( + f"bundled agent-context defaults map missing: {defaults_src}" + ) + defaults_dst = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json" + ) + defaults_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(defaults_src, defaults_dst) def _bash_posix_path(path: Path) -> str: @@ -305,484 +323,6 @@ def _run_powershell_agent_context_script_with_env( ) -class TestContextMarkerResolution: - def test_defaults_when_ext_config_missing(self, tmp_path): - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_defaults_when_markers_field_missing(self, tmp_path): - """Config file exists with context_file but no context_markers key.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" - ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_custom_markers_respected(self, tmp_path): - _write_ext_config( - tmp_path, - context_markers={"start": "", "end": ""}, - ) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == "" - assert end == "" - - def test_partial_override_falls_back_for_missing_side(self, tmp_path): - _write_ext_config(tmp_path, context_markers={"start": ""}) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == "" - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_invalid_markers_fall_back(self, tmp_path): - _write_ext_config(tmp_path, context_markers={"start": 42, "end": ""}) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - -# ── upsert_context_section / remove_context_section honor markers ─────────── - - -class TestUpsertWithCustomMarkers: - def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration: - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - **({"context_markers": markers} if markers is not None else {}), - ) - return _CtxIntegration() - - def test_upsert_uses_default_markers(self, tmp_path): - i = self._setup(tmp_path) - result = i.upsert_context_section(tmp_path) - assert result is not None - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert IntegrationBase.CONTEXT_MARKER_END in text - - def test_upsert_uses_custom_markers(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - i.upsert_context_section(tmp_path) - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert "" in text - assert "" in text - # Defaults must not appear - assert IntegrationBase.CONTEXT_MARKER_START not in text - assert IntegrationBase.CONTEXT_MARKER_END not in text - - def test_upsert_replaces_existing_custom_section(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - ctx = tmp_path / "CLAUDE.md" - ctx.write_text( - "# header\n\n\nold body\n\n\nfooter\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md") - text = ctx.read_text(encoding="utf-8") - assert "old body" not in text - assert "specs/001-foo/plan.md" in text - assert text.startswith("# header\n") - assert "footer" in text - - def test_upsert_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - assert result == tmp_path / "AGENTS.md" - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert "specs/001-foo/plan.md" in text - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md", "CLAUDE.md"] - - def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md"] - - def test_config_context_file_takes_precedence_over_class_default(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - ) - - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - - assert result == tmp_path / "AGENTS.md" - assert (tmp_path / "AGENTS.md").exists() - assert not (tmp_path / "CLAUDE.md").exists() - - def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="../outside.md", - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - _CtxIntegration()._resolve_context_files(tmp_path) - - def test_remove_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - for name in ("AGENTS.md", "CLAUDE.md"): - (tmp_path / name).write_text( - f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n", - encoding="utf-8", - ) - assert i.remove_context_section(tmp_path) is True - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert "body" not in text - assert "head" in text - assert "tail" in text - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.upsert_context_section(tmp_path) - - assert not (tmp_path / "AGENTS.md").exists() - assert not (tmp_path.parent / "outside.md").exists() - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - outside = tmp_path.parent / "outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.remove_context_section(tmp_path) - - assert "body" in outside.read_text(encoding="utf-8") - - def test_remove_uses_custom_markers(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - ctx = tmp_path / "CLAUDE.md" - ctx.write_text( - "preamble\n\n\nbody\n\nepilogue\n", - encoding="utf-8", - ) - removed = i.remove_context_section(tmp_path) - assert removed is True - remaining = ctx.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "body" not in remaining - assert "preamble" in remaining - assert "epilogue" in remaining - - def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path): - # Extension config absent → default markers used. File contains only - # custom markers — nothing should be removed. - i = _CtxIntegration() - ctx = tmp_path / "CLAUDE.md" - original = "x\n\nbody\n\n" - ctx.write_text(original, encoding="utf-8") - assert i.remove_context_section(tmp_path) is False - assert ctx.read_text(encoding="utf-8") == original - - -# ── Extension disabled gates setup/teardown ────────────────────────────────── - - -def _write_registry(project_root: Path, *, enabled: bool) -> None: - registry = project_root / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - json.dumps( - { - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": enabled, - } - }, - } - ), - encoding="utf-8", - ) - - -class TestExtensionEnabledGate: - def test_enabled_helper_default_when_no_registry(self, tmp_path): - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True - - def test_enabled_helper_when_entry_present(self, tmp_path): - _write_registry(tmp_path, enabled=True) - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True - - def test_disabled_helper_when_entry_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False - - def test_upsert_skipped_when_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - result = i.upsert_context_section(tmp_path) - assert result is None - assert not (tmp_path / "CLAUDE.md").exists() - - def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-upsert-outside.md"], - ) - i = _CtxIntegration() - assert i.upsert_context_section(tmp_path) is None - assert not (tmp_path.parent / "disabled-upsert-outside.md").exists() - - def test_remove_skipped_when_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - ctx = tmp_path / "CLAUDE.md" - original = ( - f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n" - ) - ctx.write_text(original, encoding="utf-8") - assert i.remove_context_section(tmp_path) is False - # File must be unchanged when extension is disabled - assert ctx.read_text(encoding="utf-8") == original - - def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-remove-outside.md"], - ) - outside = tmp_path.parent / "disabled-remove-outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - assert i.remove_context_section(tmp_path) is False - assert "body" in outside.read_text(encoding="utf-8") - - def test_context_file_display_disabled_uses_config_context_file( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - i = _CtxIntegration() - assert i._context_file_display(tmp_path) == "AGENTS.md" - - def test_context_file_display_disabled_without_context_file_returns_string( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - i = _NoContextIntegration() - assert i._context_file_display(tmp_path) == "" - - -class TestSkillPlaceholderContextValidation: - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", bad_path], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file=bad_path, - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_enabled_extension_rejects_invalid_legacy_init_options_path( - self, tmp_path - ): - save_init_options(tmp_path, {"context_file": "../outside.md"}) - - with pytest.raises(ValueError, match="must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_disabled_extension_ignores_invalid_context_files(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - save_init_options(tmp_path, {"context_file": "AGENTS.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_disabled_extension_uses_extension_context_file_before_init_options( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["CLAUDE.md"], - ) - save_init_options(tmp_path, {"context_file": "LEGACY.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md, CLAUDE.md" - - class TestBundledUpdaterPathValidation: def test_bundled_script_env_makes_yaml_importable(self, tmp_path): env = _bundled_script_env(tmp_path) @@ -1005,231 +545,329 @@ def test_powershell_script_rejects_junction_escape(self, tmp_path): assert not (outside / "out.md").exists() -# ── Extension config writers ───────────────────────────────────────────────── +# ── CLI does not resolve agent context placeholders ────────────────────────── -class TestExtensionConfigWriters: - def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): - from specify_cli import _clear_init_options_for_integration +class TestSkillPlaceholderContextResolution: + """The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder. - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, - ) - _write_ext_config(tmp_path, context_file="CLAUDE.md") - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" + Agent context files are owned entirely by the opt-in agent-context + extension, so the CLI neither reads integration metadata nor the + extension config when rendering commands/skills. + """ - def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( + def test_cli_does_not_resolve_context_placeholder(self, tmp_path): + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", tmp_path, - {"integration": "claude", "ai": "claude"}, ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - - def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( - self, tmp_path - ): - from specify_cli import _clear_init_options_for_integration + assert content == "Read __CONTEXT_FILE__" - save_init_options( + def test_extension_config_does_not_influence_resolution(self, tmp_path): + # Even a populated extension config must not influence resolution. + _write_ext_config( tmp_path, - { - "integration": "copilot", - "ai": "copilot", - "context_file": "CLAUDE.md", - "context_markers": {"start": "", "end": ""}, - }, + context_file="FROM_CONFIG.md", + context_files=["ALSO_CONFIG.md"], ) - _clear_init_options_for_integration(tmp_path, "claude") - opts = load_init_options(tmp_path) - assert opts["integration"] == "copilot" - assert opts["ai"] == "copilot" - assert "context_file" not in opts - assert "context_markers" not in opts - - def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - # Pre-create the extension config so _update_init_options_for_integration - # updates it (rather than skipping it when ext config doesn't exist yet). - _write_ext_config(tmp_path, context_file="") - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - # init-options.json must NOT have context_file or context_markers - opts = load_init_options(tmp_path) - assert "context_file" not in opts - assert "context_markers" not in opts - # Extension config must have them - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert "context_markers" in cfg - - def test_update_init_options_preserves_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( + content = CommandRegistrar.resolve_skill_placeholders( + "claude", + {}, + "Read __CONTEXT_FILE__", tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md"], ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content + assert content == "Read __CONTEXT_FILE__" - def test_update_init_options_preserves_empty_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], +# ── CLI no longer owns the agent-context extension config ──────────────────── + + +class TestCliDoesNotManageExtensionConfig: + """The Python codebase must not read or write the extension config.""" + + def test_config_helpers_are_removed(self): + import specify_cli + + for name in ( + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "_AGENT_CTX_EXT_CONFIG", + ): + assert not hasattr(specify_cli, name), name + + def test_no_agent_context_config_symbols_in_source(self): + src = PROJECT_ROOT / "src" / "specify_cli" + offenders = [] + for path in src.rglob("*.py"): + text = path.read_text(encoding="utf-8") + if "agent-context-config" in text or "agent_context_config" in text: + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert not offenders, offenders + + def test_update_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _update_init_options_for_integration, ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_update_init_options_normalizes_invalid_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - _write_ext_config(tmp_path, context_file="AGENTS.md") - cfg = _load_agent_context_config(tmp_path) - cfg["context_files"] = "AGENTS.md" - _save_agent_context_config(tmp_path, cfg) - - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_clear_init_options_clears_context_files(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, + + _update_init_options_for_integration( + tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh" ) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - assert "context_files" not in cfg + assert not cfg.exists() - def test_update_init_options_preserves_custom_markers(self, tmp_path): - from specify_cli import _update_init_options_for_integration + def test_clear_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + ) - _write_ext_config( - tmp_path, - context_file="", - context_markers={"start": "", "end": ""}, + save_init_options(tmp_path, {"integration": "claude", "ai": "claude"}) + _clear_init_options_for_integration(tmp_path, "claude") + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == {"start": "", "end": ""} + assert not cfg.exists() - def test_reinit_preserves_custom_markers(self, tmp_path): - """specify init (reinit) must not overwrite user-customised markers.""" - from specify_cli import _update_agent_context_config_file - # Simulate existing project with custom markers - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_markers={"start": "", "end": ""}, +# ── Extension self-seeds its target from the active integration ────────────── + + +class TestExtensionSelfSeed: + """When its own config declares no target, the bundled extension derives + the context file from the active integration using its OWN bundled + agent->context-file defaults map (no Specify CLI dependency).""" + + @requires_bash + def test_bash_script_self_seeds_from_active_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Config present but empty — no context_file / context_files. + _install_agent_context_config(project, context_file="", context_files=[]) + # Active integration recorded in init-options.json (codex -> AGENTS.md). + save_init_options(project, {"integration": "codex", "ai": "codex"}) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + assert "" in ( + project / "AGENTS.md" + ).read_text(encoding="utf-8") + + @requires_bash + def test_bash_script_nothing_to_do_without_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="", context_files=[]) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "nothing to do" in (result.stderr + result.stdout) + + +_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestMdcFrontmatter: + """Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter + so the rule file is auto-loaded; non-``.mdc`` targets must not gain any.""" + + @requires_bash + def test_bash_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @requires_bash + def test_bash_script_mdc_frontmatter_is_idempotent(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + _run_bash_agent_context_script(project) + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.count("alwaysApply: true") == 1 + + @requires_bash + def test_bash_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - # Re-running init updates context_file but must preserve markers - _update_agent_context_config_file( - tmp_path, "CLAUDE.md", preserve_markers=True + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + + @requires_bash + def test_bash_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == { - "start": "", - "end": "", - } + result = _run_powershell_agent_context_script(project) -# ── Deprecation warning on upsert ──────────────────────────────────────────── + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") -class TestDeprecationWarning: - def test_upsert_emits_deprecation_warning(self, tmp_path, capsys): - """upsert_context_section must emit a deprecation notice on stdout.""" - from tests.conftest import strip_ansi + result = _run_powershell_agent_context_script(project) - i = _CtxIntegration() - _write_ext_config(tmp_path, context_file="CLAUDE.md") - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - plain = strip_ansi(captured.out) - assert "Deprecation" in plain - assert "v0.12.0" in plain - assert "agent-context" in plain + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + +_LEGACY_CONTEXT = ( + "# CLAUDE.md\n\n" + "Some user notes.\n\n" + "\n" + "Legacy managed section written by an older Spec Kit version.\n" + "\n\n" + "More user notes.\n" +) - def test_upsert_no_warning_when_disabled(self, tmp_path, capsys): - """No deprecation warning when agent-context extension is disabled.""" - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - assert "Deprecation" not in captured.out +class TestBackwardCompatibility: + """Legacy projects must keep working; the CLI never touches their artifacts.""" -# ── Corrupt / invalid extension config ─────────────────────────────────────── + def _seed_legacy_project(self, project_root: Path) -> Path: + ctx = project_root / "CLAUDE.md" + ctx.write_text(_LEGACY_CONTEXT, encoding="utf-8") + _write_ext_config(project_root, context_file="CLAUDE.md") + save_init_options(project_root, {"integration": "claude", "ai": "claude"}) + return ctx + def test_integration_setup_leaves_legacy_artifacts_untouched(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.manifest import IntegrationManifest -class TestCorruptExtensionConfig: - def test_marker_resolution_with_corrupt_yaml(self, tmp_path): - """Corrupt YAML in agent-context-config.yml falls back to defaults.""" + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): - """upsert_context_section still works when config YAML is corrupt.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + integration = INTEGRATION_REGISTRY["claude"] + m = IntegrationManifest("claude", project) + integration.setup(project, m) + + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched( + self, tmp_path + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + _update_init_options_for_integration, ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - result = i.upsert_context_section(tmp_path) - assert result is not None - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert IntegrationBase.CONTEXT_MARKER_END in text - - def test_marker_resolution_with_non_dict_yaml(self, tmp_path): - """Config file containing a scalar (not a dict) falls back to defaults.""" + + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("just a string\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + # Switch to a different integration. + _update_init_options_for_integration( + project, INTEGRATION_REGISTRY["gemini"], script_type="sh" + ) + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + # Uninstall. + _clear_init_options_for_integration(project, "gemini") + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 54f59e23a7..833e272b27 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -20,4 +20,3 @@ class StubIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 47f9d09059..9ec7d236c1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -43,7 +43,6 @@ def test_key_and_config(self): assert i.key == "stub" assert i.config["name"] == "Stub Agent" assert i.registrar_config["format"] == "markdown" - assert i.context_file == "STUB.md" def test_options_default_empty(self): assert StubIntegration.options() == [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index be8aad2326..25d4a7c16a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -77,23 +77,17 @@ def test_integration_copilot_creates_files(self, tmp_path): opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" - # context_file lives in the agent-context extension config, not init-options.json + # init must not leave any legacy agent-context keys in init-options.json assert "context_file" not in opts - import yaml as _yaml + # agent-context is fully opt-in: init must not install it or write its config ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - assert ext_cfg_path.exists(), "agent-context extension config must be created on init" - ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) - assert ext_cfg["context_file"] == ".github/copilot-instructions.md" + assert not ext_cfg_path.exists(), "init must not create the agent-context extension config" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - # Context section should be upserted into the copilot instructions file - ctx_file = project / ".github" / "copilot-instructions.md" - assert ctx_file.exists() - ctx_content = ctx_file.read_text(encoding="utf-8") - assert "" in ctx_content - assert "" in ctx_content + # init must not create or manage the agent context file + assert not (project / ".github" / "copilot-instructions.md").exists() shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() @@ -1270,7 +1264,6 @@ class BrokenIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "BROKEN.md" def setup(self, project_root, manifest, **kwargs): raise OSError("setup exploded\nwith context") diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index d192e140fb..e329c88801 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -37,7 +37,6 @@ class _ClaudeStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" class _KiroCliStub(SkillsIntegration): @@ -58,7 +57,6 @@ class _KiroCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "KIRO.md" class _NoCliStub(SkillsIntegration): @@ -79,7 +77,6 @@ class _NoCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "NOCLI.md" class _MarkdownAgentStub(MarkdownIntegration): @@ -102,7 +99,6 @@ class _MarkdownAgentStub(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "MDAGENT.md" class _TomlAgentStub(TomlIntegration): @@ -124,7 +120,6 @@ class _TomlAgentStub(TomlIntegration): "args": "$ARGUMENTS", "extension": ".toml", } - context_file = "TOMLAGENT.md" @pytest.fixture(autouse=True) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b64a609e15..6ab66a0cbe 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -10,7 +10,6 @@ class TestAgyIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index a36dd47136..f0689c21f5 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -8,4 +8,3 @@ class TestAmpIntegration(MarkdownIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".agents/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py index e4033a23e8..3cf4d09bbc 100644 --- a/tests/integrations/test_integration_auggie.py +++ b/tests/integrations/test_integration_auggie.py @@ -8,4 +8,3 @@ class TestAuggieIntegration(MarkdownIntegrationTests): FOLDER = ".augment/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".augment/commands" - CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index b0b408a995..886dfb912f 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard MarkdownIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``MarkdownIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``MarkdownIntegrationTests``. """ import os @@ -21,14 +21,12 @@ class MarkdownIntegrationTests: FOLDER: str — e.g. ".claude/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".claude/commands" - CONTEXT_FILE: str — e.g. "CLAUDE.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -56,10 +54,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == ".md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -101,19 +95,18 @@ def test_templates_are_processed(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -149,35 +142,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - # Add user content around the section - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -225,35 +215,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] @@ -293,19 +258,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index e903d918e2..d88b786757 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard SkillsIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``SkillsIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``SkillsIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, adapted for the ``speckit-/SKILL.md`` skills layout. @@ -26,14 +26,12 @@ class SkillsIntegrationTests: FOLDER: str — e.g. ".agents/" COMMANDS_SUBDIR: str — e.g. "skills" REGISTRAR_DIR: str — e.g. ".agents/skills" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -222,19 +216,18 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference this integration's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The generated plan skill must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan skill must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) @@ -283,34 +276,32 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -356,9 +347,9 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml + def test_init_does_not_create_agent_context_config(self, tmp_path): + """agent-context is opt-in: init must not auto-install the extension + or write its config.""" from typer.testing import CliRunner from specify_cli import app @@ -375,11 +366,7 @@ def test_init_options_includes_context_file(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) + assert not ext_cfg_path.exists() # -- IntegrationOption ------------------------------------------------ @@ -406,8 +393,6 @@ def _expected_files(self, script_variant: str) -> list[str]: # Skill files (core commands) for cmd in self._SKILL_COMMANDS: files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") - # Extension-installed skill (agent-context) - files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md") # Integration metadata files += [ ".specify/init-options.json", @@ -446,18 +431,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index a9b933875a..68f5fd075a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard TomlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``TomlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``TomlIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` closely — same test structure, adapted for TOML output format. @@ -27,14 +27,12 @@ class TomlIntegrationTests: FOLDER: str — e.g. ".gemini/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".gemini/commands" - CONTEXT_FILE: str — e.g. "GEMINI.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -62,10 +60,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".toml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -311,19 +305,18 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -359,34 +352,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -454,35 +445,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -544,19 +510,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 646e21607d..74cdab2d7d 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard YamlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``YamlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``YamlIntegrationTests``. Mirrors ``TomlIntegrationTests`` closely — same test structure, adapted for YAML recipe output format. @@ -26,14 +26,12 @@ class YamlIntegrationTests: FOLDER: str — e.g. ".goose/" COMMANDS_SUBDIR: str — e.g. "recipes" REGISTRAR_DIR: str — e.g. ".goose/recipes" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".yaml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -190,19 +184,18 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -238,34 +231,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -333,35 +324,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -423,19 +389,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 1562f0100c..8e0e72f0bd 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -8,4 +8,3 @@ class TestBobIntegration(MarkdownIntegrationTests): FOLDER = ".bob/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".bob/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8f96527b2f..1b1b2308d7 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,6 +1,5 @@ """Tests for ClaudeIntegration.""" -import codecs import json import os from pathlib import Path @@ -34,10 +33,6 @@ def test_registrar_config_uses_skill_layout(self): assert integration.registrar_config["args"] == "$ARGUMENTS" assert integration.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - integration = get_integration("claude") - assert integration.context_file == "CLAUDE.md" - def test_setup_creates_skill_files(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) @@ -76,57 +71,30 @@ def test_render_skill_unicode(self): ) assert "Prüfe Konformität" in rendered - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") - ctx_path = tmp_path / integration.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text - def test_upsert_context_section_strips_bom(self, tmp_path): - """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) - bom = codecs.BOM_UTF8 - ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") - integration.upsert_context_section(tmp_path) - - result = ctx_path.read_bytes() - assert not result.startswith(bom), "BOM must be stripped after upsert" - content = result.decode("utf-8") - assert "" in content - assert "Some existing content." in content - - def test_remove_context_section_strips_bom(self, tmp_path): - """remove_context_section must clean BOM from context file on Windows-authored files.""" - integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - marker_content = ( - "# CLAUDE.md\n\n" - "\n" - "For additional context about technologies to be used, project structure,\n" - "shell commands, and other important information, read the current plan\n" - "\n" - ) - ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) - - result = integration.remove_context_section(tmp_path) + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) - assert result is True - assert ctx_path.exists(), "File should exist (non-empty content remains)" - remaining = ctx_path.read_bytes() - assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" - assert b"", - "end": "", - }, - }, - ) integration = get_integration("codex") manifest = IntegrationManifest("codex", target) @@ -53,43 +40,31 @@ def test_plan_skill_references_configured_context_files(self, tmp_path): plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" in content assert "__CONTEXT_FILE__" not in content - def test_plan_skill_ignores_context_files_when_agent_context_disabled( - self, tmp_path - ): - """Disabled agent-context must not leak stale context_files into commands.""" - from specify_cli import _save_agent_context_config + def test_plan_skill_ignores_extension_config(self, tmp_path): + """The extension config must not influence rendered commands: the CLI + no longer reads any context-file metadata when rendering.""" + import yaml target = tmp_path / "test-proj" target.mkdir() - registry = target / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - """ -{ - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": false - } - } -} -""".strip(), - encoding="utf-8", + ext_cfg = ( + target + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _save_agent_context_config( - target, - { - "context_file": "AGENTS.md", - "context_files": ["../outside.md", "CLAUDE.md"], - "context_markers": { - "start": "", - "end": "", - }, - }, + ext_cfg.parent.mkdir(parents=True, exist_ok=True) + ext_cfg.write_text( + yaml.safe_dump( + { + "context_file": "FROM_CONFIG.md", + "context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"], + } + ), + encoding="utf-8", ) integration = get_integration("codex") @@ -98,9 +73,8 @@ def test_plan_skill_ignores_context_files_when_agent_context_disabled( plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" not in content - assert "../outside.md" not in content - assert "AGENTS.md" in content + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content assert "__CONTEXT_FILE__" not in content diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 6b7cc7c13f..8a7c8ec995 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -17,7 +17,6 @@ def test_copilot_key_and_config(self): assert copilot.config["folder"] == ".github/" assert copilot.config["commands_subdir"] == "agents" assert copilot.registrar_config["extension"] == ".agent.md" - assert copilot.context_file == ".github/copilot-instructions.md" def test_command_filename_agent_md(self): copilot = get_integration("copilot") @@ -162,8 +161,9 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): assert "Copy `.specify/templates/spec-template.md`" not in content assert "Load `.specify/templates/spec-template.md`" not in content - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference copilot's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) @@ -171,9 +171,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content, ( - f"Plan command should reference {copilot.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): @@ -193,7 +190,6 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -204,7 +200,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -216,15 +211,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -265,7 +251,6 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -276,7 +261,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -288,15 +272,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -537,14 +512,14 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference copilot's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content assert "__CONTEXT_FILE__" not in content # -- Manifest tracking ------------------------------------------------ @@ -603,14 +578,13 @@ def test_build_command_invocation_default_mode(self): # -- Context section --------------------------------------------------- - def test_skills_setup_upserts_context_section(self, tmp_path): + def test_skills_setup_does_not_write_context_section(self, tmp_path): copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) - ctx_path = tmp_path / copilot.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text # -- CLI integration test --------------------------------------------- @@ -659,20 +633,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): assert result.exit_code == 0, f"init failed: {result.output}" actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - # Skill files (core + extension-installed agent-context command) + # Skill files (core commands) *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], - ".github/skills/speckit-agent-context-update/SKILL.md", - # Context file - ".github/copilot-instructions.md", - # Bundled agent-context extension - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 8165464655..32318dc90f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,10 +1,8 @@ """Tests for CursorAgentIntegration.""" -from pathlib import Path from urllib.parse import urlparse from specify_cli.integrations import get_integration -from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -14,82 +12,6 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): FOLDER = ".cursor/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".cursor/skills" - CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" - - -class TestCursorMdcFrontmatter: - """Verify .mdc frontmatter handling in upsert/remove context section.""" - - def _setup(self, tmp_path: Path): - i = get_integration("cursor-agent") - m = IntegrationManifest("cursor-agent", tmp_path) - return i, m - - def test_new_mdc_gets_frontmatter(self, tmp_path): - """A freshly created .mdc file includes alwaysApply: true.""" - i, m = self._setup(tmp_path) - i.setup(tmp_path, m) - ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert ctx.startswith("---\n") - assert "alwaysApply: true" in ctx - - def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): - """An existing .mdc without frontmatter gets it added.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text("# User rules\n", encoding="utf-8") - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert content.lstrip().startswith("---") - assert "alwaysApply: true" in content - assert "# User rules" in content - - def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): - """An existing .mdc with custom frontmatter is preserved.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "customKey: hello" in content - assert "" in content - - def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): - """An .mdc with alwaysApply: false gets corrected.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: false\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "alwaysApply: false" not in content - - def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): - """Repeated upserts don't duplicate frontmatter.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - i.upsert_context_section(tmp_path) - content = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert content.count("alwaysApply") == 1 - - def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): - """Removing the section from a Speckit-only .mdc deletes the file.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - i.remove_context_section(tmp_path) - assert not ctx_path.exists() class TestCursorAgentInitFlow: diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 4acbdac618..52c2981bf1 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -8,7 +8,6 @@ class TestDevinIntegration(SkillsIntegrationTests): FOLDER = ".devin/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".devin/skills" - CONTEXT_FILE = "AGENTS.md" class TestDevinBuildExecArgs: diff --git a/tests/integrations/test_integration_firebender.py b/tests/integrations/test_integration_firebender.py index b42d2fbf9d..6de66f4d07 100644 --- a/tests/integrations/test_integration_firebender.py +++ b/tests/integrations/test_integration_firebender.py @@ -11,7 +11,6 @@ class TestFirebenderIntegration(MarkdownIntegrationTests): FOLDER = ".firebender/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".firebender/commands" - CONTEXT_FILE = ".firebender/rules/specify-rules.mdc" # Firebender reads custom slash commands from ``.firebender/commands/*.mdc``, # so this integration uses the ``.mdc`` extension instead of the ``.md`` diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index f63afb71e2..26ac7a9931 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -55,7 +55,6 @@ def test_forge_key_and_config(self): assert forge.config["requires_cli"] is True assert forge.registrar_config["args"] == "{{parameters}}" assert forge.registrar_config["extension"] == ".md" - assert forge.context_file == "AGENTS.md" def test_command_filename_md(self): forge = get_integration("forge") @@ -73,16 +72,15 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) - ctx_path = tmp_path / forge.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -164,8 +162,9 @@ def test_templates_are_processed(self, tmp_path): "Forge requires hyphen notation (/speckit-) for ZSH compatibility" ) - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference forge's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -173,9 +172,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert forge.context_file in content, ( - f"Plan command should reference {forge.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py index 9be5985e29..1649b4f7c3 100644 --- a/tests/integrations/test_integration_gemini.py +++ b/tests/integrations/test_integration_gemini.py @@ -8,4 +8,3 @@ class TestGeminiIntegration(TomlIntegrationTests): FOLDER = ".gemini/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".gemini/commands" - CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index fe935cc98b..1c5edc2efc 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,10 +31,6 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_agents_md(self): - i = get_integration("generic") - assert i.context_file == "AGENTS.md" - # -- Options ---------------------------------------------------------- def test_options_include_commands_dir(self): @@ -161,28 +157,24 @@ def test_different_commands_dirs(self, tmp_path): # -- Context section --------------------------------------------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference generic's context file.""" + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_plan_defines_quickstart_as_validation_guide(self, tmp_path): @@ -256,28 +248,6 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # Generic requires --commands-dir via --integration-options assert result.exit_code != 0 - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the generic integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "opts-generic" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", "generic", - "--integration-options=--commands-dir .myagent/commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - assert ext_cfg.get("context_file") == "AGENTS.md" def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh.""" @@ -302,7 +272,6 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -313,14 +282,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -367,7 +328,6 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -378,14 +338,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 8415081d53..104b7188d0 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -12,7 +12,6 @@ class TestGooseIntegration(YamlIntegrationTests): FOLDER = ".goose/" COMMANDS_SUBDIR = "recipes" REGISTRAR_DIR = ".goose/recipes" - CONTEXT_FILE = "AGENTS.md" def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): # “If a generated Goose recipe uses {{args}} in its prompt, it diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 89e74c2b38..521a310cb8 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -30,7 +30,6 @@ class TestHermesIntegration(SkillsIntegrationTests): FOLDER = ".hermes/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = "~/.hermes/skills" - CONTEXT_FILE = "AGENTS.md" # -- Hermes-specific setup: skills go to ~/.hermes/skills/ ------------- @@ -72,23 +71,19 @@ def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): """Override: Hermes writes to global, not project-local.""" self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) - def test_plan_references_correct_context_file(self, tmp_path, monkeypatch): - """Plan skill goes to global dir, but we check it still references AGENTS.md.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" home = _fake_home(tmp_path) monkeypatch.setattr(Path, "home", lambda: home) i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) # Find the plan skill in global ~/.hermes/skills/ plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created globally" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py index ea2f5ef97a..89501f8edf 100644 --- a/tests/integrations/test_integration_iflow.py +++ b/tests/integrations/test_integration_iflow.py @@ -8,4 +8,3 @@ class TestIflowIntegration(MarkdownIntegrationTests): FOLDER = ".iflow/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".iflow/commands" - CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 2b924ce434..2226e3d544 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -8,4 +8,3 @@ class TestJunieIntegration(MarkdownIntegrationTests): FOLDER = ".junie/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".junie/commands" - CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py index 8e441c0833..86e6520a50 100644 --- a/tests/integrations/test_integration_kilocode.py +++ b/tests/integrations/test_integration_kilocode.py @@ -8,4 +8,3 @@ class TestKilocodeIntegration(MarkdownIntegrationTests): FOLDER = ".kilocode/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".kilocode/workflows" - CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 2f752f66e1..48e4daa553 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -6,7 +6,6 @@ from specify_cli.integrations import get_integration from specify_cli.integrations.kimi import ( - _migrate_legacy_kimi_context_file, _migrate_legacy_kimi_dotted_skills, _migrate_legacy_kimi_skills_dir, ) @@ -36,7 +35,6 @@ class TestKimiIntegration(SkillsIntegrationTests): FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".kimi-code/skills" - CONTEXT_FILE = "AGENTS.md" class TestKimiOptions: @@ -165,168 +163,6 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() -class TestKimiContextFileMigration: - """KIMI.md → AGENTS.md migration under --migrate-legacy.""" - - def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path): - i = get_integration("kimi") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "# Project context\n\n" - "\n" - "old managed section\n" - "\n\n" - "Keep this user note.\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - agents_md = tmp_path / "AGENTS.md" - assert agents_md.exists() - content = agents_md.read_text(encoding="utf-8") - assert "Keep this user note." in content - assert "old managed section" not in content - assert "" in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path): - i = get_integration("kimi") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "\n" - "only managed section\n" - "\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - assert (tmp_path / "AGENTS.md").exists() - assert not kimi_md.exists() - - def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): - i = get_integration("kimi") - - agents_md = tmp_path / "AGENTS.md" - agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n") - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - content = agents_md.read_text(encoding="utf-8") - assert "Existing note." in content - assert "Kimi-specific note." in content - assert "" in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): - """Migration respects context_markers from agent-context extension config.""" - i = get_integration("kimi") - - config_dir = tmp_path / ".specify" / "extensions" / "agent-context" - config_dir.mkdir(parents=True) - (config_dir / "agent-context-config.yml").write_text( - "context_file: AGENTS.md\n" - "context_markers:\n" - " start: ''\n" - " end: ''\n" - ) - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "# Project context\n\n" - "\n" - "old managed section\n" - "\n\n" - "Keep this user note.\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - agents_md = tmp_path / "AGENTS.md" - assert agents_md.exists() - content = agents_md.read_text(encoding="utf-8") - assert "Keep this user note." in content - assert "old managed section" not in content - assert "" in content - assert "" in content - assert "" not in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_skipped_when_agent_context_disabled( - self, tmp_path - ): - """A disabled agent-context extension opts out of KIMI.md migration.""" - i = get_integration("kimi") - - registry = tmp_path / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True) - registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}') - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text("# Kimi context\n\nKeep this user note.\n") - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - # Opted-out project: KIMI.md is left untouched and AGENTS.md is not - # created/modified by the migration. - assert kimi_md.is_file() - assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n" - assert not (tmp_path / "AGENTS.md").exists() - - def test_context_migration_skips_corrupted_single_marker(self, tmp_path): - """A KIMI.md with only a start marker is left untouched (no leak).""" - project = tmp_path - kimi_md = project / "KIMI.md" - kimi_md.write_text( - "# Notes\n\n" - "\n" - "dangling managed content\n" - ) - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # KIMI.md untouched; managed block never copied into AGENTS.md. - assert kimi_md.is_file() - assert "dangling managed content" in kimi_md.read_text() - assert not (project / "AGENTS.md").exists() - - def test_context_migration_skips_unreadable_kimi_md(self, tmp_path): - """Non-UTF-8 KIMI.md is skipped instead of raising during setup.""" - project = tmp_path - kimi_md = project / "KIMI.md" - kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n") - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - assert kimi_md.is_file() - assert not (project / "AGENTS.md").exists() - - def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path): - """An AGENTS.md that exists as a directory is skipped, not written to.""" - project = tmp_path - (project / "AGENTS.md").mkdir() - kimi_md = project / "KIMI.md" - kimi_md.write_text("# Notes\n\nKeep this.\n") - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # KIMI.md is preserved and the directory is untouched. - assert kimi_md.is_file() - assert (project / "AGENTS.md").is_dir() - - class TestKimiTeardownLegacyCleanup: """teardown() removes leftover legacy .kimi/skills/ directories.""" @@ -522,49 +358,6 @@ def test_migrate_skips_symlinked_target_dir(self, tmp_path): assert (legacy / "SKILL.md").exists() assert (outside / "SKILL.md").exists() - def test_context_migration_does_not_write_through_symlinked_agents_md( - self, tmp_path - ): - # A sensitive file outside the project that a malicious AGENTS.md - # symlink points at. Migration must never overwrite it. - outside = tmp_path / "outside" - outside.mkdir() - secret = outside / "secret.txt" - secret.write_text("original secret\n") - - project = tmp_path / "project" - project.mkdir() - _symlink_or_skip(project / "AGENTS.md", secret) - (project / "KIMI.md").write_text("# Notes\n\nKeep this.\n") - - result = _migrate_legacy_kimi_context_file(project) - - # The outside file must not be overwritten through the symlink. - assert secret.read_text() == "original secret\n" - # KIMI.md is preserved so the user can migrate manually. - assert (project / "KIMI.md").is_file() - assert result is False - - def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path): - # A symlinked KIMI.md (source) must not be followed/consumed. - outside = tmp_path / "outside" - outside.mkdir() - external = outside / "external.md" - external.write_text("# external\n") - - project = tmp_path / "project" - project.mkdir() - _symlink_or_skip(project / "KIMI.md", external) - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # The external file and the symlink are left intact. - assert external.read_text() == "# external\n" - assert (project / "KIMI.md").is_symlink() - assert not (project / "AGENTS.md").exists() - - class TestKimiNextSteps: """CLI output tests for kimi next-steps display.""" diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index c1a029a55f..29adb0a4a6 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -41,7 +41,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): FOLDER = ".kiro/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" - CONTEXT_FILE = "AGENTS.md" def test_registrar_config(self): """Override base assertion: kiro-cli uses a prose fallback for args diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py index 959de8d657..e3d338d540 100644 --- a/tests/integrations/test_integration_lingma.py +++ b/tests/integrations/test_integration_lingma.py @@ -8,4 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests): FOLDER = ".lingma/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".lingma/skills" - CONTEXT_FILE = ".lingma/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_omp.py b/tests/integrations/test_integration_omp.py index f0c5efa490..5b30b76075 100644 --- a/tests/integrations/test_integration_omp.py +++ b/tests/integrations/test_integration_omp.py @@ -10,7 +10,6 @@ class TestOmpIntegration(MarkdownIntegrationTests): FOLDER = ".omp/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".omp/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_omp_json_mode(self): i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index ba2d15711f..b9464fdea3 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -14,7 +14,6 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): FOLDER = ".opencode/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".opencode/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_run_command_dispatch(self): integration = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py index 5ac5676501..5dde4a4294 100644 --- a/tests/integrations/test_integration_pi.py +++ b/tests/integrations/test_integration_pi.py @@ -8,4 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests): FOLDER = ".pi/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".pi/prompts" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 1dbee480a0..29a6d16d29 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -8,4 +8,3 @@ class TestQodercliIntegration(MarkdownIntegrationTests): FOLDER = ".qoder/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qoder/commands" - CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py index 10a3c083f4..3de85d3888 100644 --- a/tests/integrations/test_integration_qwen.py +++ b/tests/integrations/test_integration_qwen.py @@ -8,4 +8,3 @@ class TestQwenIntegration(MarkdownIntegrationTests): FOLDER = ".qwen/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qwen/commands" - CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py index 69d859c42f..b713f96362 100644 --- a/tests/integrations/test_integration_roo.py +++ b/tests/integrations/test_integration_roo.py @@ -8,4 +8,3 @@ class TestRooIntegration(MarkdownIntegrationTests): FOLDER = ".roo/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".roo/commands" - CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 8e992476fb..5bdafc25f9 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -52,7 +52,6 @@ class TestRovodevIntegration: which violates the base mixin's pure-skills assumptions).""" KEY = "rovodev" - CONTEXT_FILE = "AGENTS.md" # -- ACLI dispatch ----------------------------------------------------- @@ -218,12 +217,8 @@ def test_init_inventory(self, rovodev_init_project): # Prompts: exactly the core template set. assert prompt_stems == core_skill_names - # Skills: core ∪ extension-installed. - assert core_skill_names.issubset(skill_names) - extension_skills = skill_names - core_skill_names - assert extension_skills, ( - "Expected at least one extension-installed skill (e.g. agent-context)" - ) + # Skills: exactly the core template set (no extension auto-install). + assert skill_names == core_skill_names # prompts.yml mirrors the prompt files exactly. prompts_manifest = project / ".rovodev" / "prompts.yml" @@ -266,10 +261,6 @@ def test_init_skill_files_well_formed(self, rovodev_init_project): f"{skill_file} body contains dot-notation /speckit. reference" ) - # The plan skill must reference the agent's context file. - plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8") - assert self.CONTEXT_FILE in plan_content - # -- Full-CLI init: integration metadata ------------------------------- def test_init_writes_integration_manifest_and_options(self, rovodev_init_project): diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py index 74f93396b1..fc2b60c3f2 100644 --- a/tests/integrations/test_integration_shai.py +++ b/tests/integrations/test_integration_shai.py @@ -8,4 +8,3 @@ class TestShaiIntegration(MarkdownIntegrationTests): FOLDER = ".shai/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".shai/commands" - CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py index 95eb47cc16..71bf398862 100644 --- a/tests/integrations/test_integration_tabnine.py +++ b/tests/integrations/test_integration_tabnine.py @@ -8,4 +8,3 @@ class TestTabnineIntegration(TomlIntegrationTests): FOLDER = ".tabnine/agent/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".tabnine/agent/commands" - CONTEXT_FILE = "TABNINE.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py index 74b8b41c3f..2805263b3d 100644 --- a/tests/integrations/test_integration_trae.py +++ b/tests/integrations/test_integration_trae.py @@ -8,4 +8,3 @@ class TestTraeIntegration(SkillsIntegrationTests): FOLDER = ".trae/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".trae/skills" - CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index bab4539f1e..98c9fdf06d 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -13,7 +13,6 @@ class TestVibeIntegration(SkillsIntegrationTests): FOLDER = ".vibe/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" - CONTEXT_FILE = "AGENTS.md" class TestVibeUserInvocable: diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py index fa8d1e622a..4cdfaa94a3 100644 --- a/tests/integrations/test_integration_windsurf.py +++ b/tests/integrations/test_integration_windsurf.py @@ -8,4 +8,3 @@ class TestWindsurfIntegration(MarkdownIntegrationTests): FOLDER = ".windsurf/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".windsurf/workflows" - CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index 3eb82ed4f2..f431d3e4a0 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -8,7 +8,6 @@ class TestZcodeIntegration(SkillsIntegrationTests): FOLDER = ".zcode/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" - CONTEXT_FILE = "ZCODE.md" class TestZcodeInvocation: diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 0172e6b275..739fdbf23b 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -14,7 +14,6 @@ class TestZedIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Not applicable to Zed — Zed is always skills-based with no --skills flag.""" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0110e19ec7..f22f7e1048 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -164,17 +164,12 @@ class TestMultiInstallSafeContracts: @pytest.mark.parametrize("key", _multi_install_safe_keys()) def test_safe_integrations_have_static_isolated_paths(self, key): - integration = INTEGRATION_REGISTRY[key] - assert _integration_root_dir(key), ( f"{key} is declared multi-install safe but has no static root directory" ) assert _integration_commands_dir(key), ( f"{key} is declared multi-install safe but has no static commands directory" ) - assert integration.context_file, ( - f"{key} is declared multi-install safe but has no context file" - ) @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_distinct_agent_roots(self, first, second): @@ -192,44 +187,6 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second): f"{_integration_commands_dir(second)!r}" ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_integrations_have_distinct_context_files(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert first_context != second_context, ( - f"{first} and {second} are declared multi-install safe but share " - f"context file {first_context!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_root_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"agent root {_integration_root_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_root_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"agent root {_integration_root_dir(first)!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_commands_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"commands directory {_integration_commands_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_commands_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"commands directory {_integration_commands_dir(first)!r}" - ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_disjoint_manifests( self, From 5a7d84311b9115e3168707aec375b7346d0551b3 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:46:35 -0500 Subject: [PATCH 54/60] chore: release 0.12.0, begin 0.12.1.dev0 development (#3243) * chore: bump version to 0.12.0 * chore: begin 0.12.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d9650e9e..48a1ac9936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.12.0] - 2026-06-29 + +### Changed + +- feat: make agent-context extension a full opt-in (#3097) +- docs(workflows): add the built-in 'init' step type to the Step Types table (#3234) +- fix(workflows): gate validate() must not crash on non-string options (#3233) +- fix(workflows): make pipe-filter detection quote-aware in expressions (#3232) +- fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225) +- fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230) +- fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137) +- fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195) +- Update Product Spec Extension to v1.0.1 (#3226) +- chore: release 0.11.10, begin 0.11.11.dev0 development (#3240) + ## [0.11.10] - 2026-06-29 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 847bbf7b8e..e5e81fadb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.11.dev0" +version = "0.12.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" From c5fb3dc86f3a6997f5d138b212e2858831d49618 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:46:56 +0500 Subject: [PATCH 55/60] fix(bundle): send command errors to stderr so --json stdout stays parseable (#3235) The bundle command group's _fail() helper is documented as printing 'to stderr', and the module contract is 'human logs go to stderr/console' while --json 'emits machine-readable data on stdout'. But it called console.print(), and the shared console writes to STDOUT, so every bundle error (every command routes through _fail) landed on stdout -- corrupting the JSON stream that --json consumers parse. Add a stderr-bound err_console to _console.py (its documented role as the single Console source) and use it in _fail. stdout now carries only the JSON payload. Co-authored-by: Claude Opus 4.8 (1M context) --- src/specify_cli/_console.py | 4 ++++ src/specify_cli/commands/bundle/__init__.py | 6 ++++-- tests/contract/test_bundle_cli.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 33bd70f77f..8d1216387f 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -34,6 +34,10 @@ console = Console(highlight=False) +# Stderr-bound console for error/diagnostic output, so human-facing messages +# never contaminate stdout (which carries machine-readable ``--json`` payloads). +err_console = Console(stderr=True, highlight=False) + class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 185e00acf6..afae9bcf84 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -13,7 +13,7 @@ import typer -from ..._console import console +from ..._console import console, err_console from ...bundler import BundlerError from ...bundler.lib.project import ( active_integration, @@ -41,7 +41,9 @@ def _fail(message: str) -> None: """Print an actionable error to stderr and exit non-zero.""" - console.print(f"[red]Error:[/red] {message}", style=None) + # Use the stderr console so the error never lands on stdout, which under + # ``--json`` carries the machine-readable payload and must stay parseable. + err_console.print(f"[red]Error:[/red] {message}", style=None) raise typer.Exit(code=1) diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 018b2bbec1..58a26fae91 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -62,6 +62,21 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch assert "Spec Kit project" in result.output +def test_fail_writes_error_to_stderr_not_stdout(capsys): + """_fail must write to stderr, not stdout: every bundle command routes errors + through it, and under --json the error would otherwise corrupt the JSON payload + that consumers read from stdout.""" + import typer + + from specify_cli.commands.bundle import _fail + + with pytest.raises(typer.Exit): + _fail("something broke") + captured = capsys.readouterr() + assert "something broke" in captured.err + assert "something broke" not in captured.out + + def test_search_works_without_a_project(tmp_path: Path, monkeypatch): # Discovery commands fall back to the built-in/user catalog stack and must # not require a Spec Kit project (matches README/quickstart examples). From 804e7329b848ee3010b359643e0888da04ca5dac Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:37:40 +0500 Subject: [PATCH 56/60] fix(scripts): route 'Plan template not found' per --json in setup-plan.ps1 (parity with bash) (#3241) The 'template not found' fallback used Write-Warning, which emits 'WARNING: Plan template not found' on the warning stream -- diverging from the bash twin (echo 'Warning: Plan template not found' to stderr in --json, stdout in text mode) in both wording and routing, and inconsistent with the sibling 'Copied plan template' message (#3198) in the same block. Route it the same way so the two scripts share one status-output contract. Co-authored-by: Claude Opus 4.8 (1M context) --- scripts/powershell/setup-plan.ps1 | 9 ++++++++- tests/test_setup_plan_no_overwrite.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index e179f0d160..0ebd591c87 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -48,7 +48,14 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { Write-Output "Copied plan template to $($paths.IMPL_PLAN)" } } else { - Write-Warning "Plan template not found" + # Match the bash twin's wording and stream routing (stderr in -Json so + # stdout stays pure JSON, stdout otherwise), consistent with the sibling + # "Copied plan template" message above. + if ($Json) { + [Console]::Error.WriteLine("Warning: Plan template not found") + } else { + Write-Output "Warning: Plan template not found" + } # Create a basic plan file if template doesn't exist New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index ff19b1a0ac..b965551f2d 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -246,3 +246,27 @@ def test_ps_setup_plan_copied_message_on_stderr_in_json_mode(plan_repo: Path) -> data = json.loads(result.stdout) assert "IMPL_PLAN" in data assert "Copied plan template" in result.stderr + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) -> None: + """When no plan template resolves, -Json mode must emit 'Warning: Plan template + not found' on stderr (matching the bash twin's wording and stream routing) while + keeping stdout pure JSON. Before the fix the PowerShell script used Write-Warning, + producing a different 'WARNING:' prefix on the warning stream instead.""" + # Remove the template the fixture installs so resolution finds nothing. + (plan_repo / ".specify" / "templates" / "plan-template.md").unlink() + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Warning: Plan template not found" in result.stderr From b1bd9180ca5b8dc760369240831a7617e6e9d5b8 Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:43:06 +0100 Subject: [PATCH 57/60] fix(goose): repoint install_url and docs to goose-docs.ai (#3171) (#3215) * fix(goose): repoint install_url and docs to goose-docs.ai (#3171) Goose moved to the Agentic AI Foundation; docs moved from block.github.io/goose to goose-docs.ai. Update install_url and the docs reference link. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(goose): restore table column alignment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 2 +- src/specify_cli/integrations/goose/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 36829ec8be..faecc39522 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -19,7 +19,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Forge](https://forgecode.dev/) | `forge` | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | | [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | -| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | | [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | | [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 77d4e0f837..0af569073e 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -1,4 +1,4 @@ -"""Goose integration — Block's open source AI agent.""" +"""Goose integration — open source AI agent (Agentic AI Foundation).""" from ..base import YamlIntegration @@ -9,7 +9,7 @@ class GooseIntegration(YamlIntegration): "name": "Goose", "folder": ".goose/", "commands_subdir": "recipes", - "install_url": "https://block.github.io/goose/docs/getting-started/installation", + "install_url": "https://goose-docs.ai/docs/getting-started/installation", "requires_cli": True, } registrar_config = { From 5a29e4b659ad7a9fcef3f4e0b55d8b47c750bb89 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 30 Jun 2026 02:50:55 +0500 Subject: [PATCH 58/60] docs: remove Cursor from `specify check` agent list (#3178) (#3193) * docs: remove Cursor from specify check agent list (#3178) Cursor is registered as an IDE-based integration (requires_cli=False), so `specify check` never probes for a "Cursor CLI". Listing it in the README's check description misled users into expecting a check that does not happen. Removed it from the list; the remaining entries all correspond to integrations with requires_cli=True. Fixes #3178. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e44faf654b..62514e5b0e 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ specify init . --force --integration copilot specify init --here --force --integration copilot ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools From 9dfef8629e68a59aa3827f514fee918d60c079d2 Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Tue, 30 Jun 2026 02:52:01 +0500 Subject: [PATCH 59/60] docs: document integration `search`/`info`/`scaffold` subcommands (#3174) (#3194) * docs: document integration search/info/scaffold subcommands (#3174) docs/reference/integrations.md omitted three subcommands that exist in code, breaking parity with the extension/preset/bundle/workflow references which all document their search/info equivalents. Added sections for: - `specify integration search [query]` (--tag, --author) - `specify integration info ` - `specify integration scaffold ` (--type: markdown/skills/toml/yaml) Content mirrors the command docstrings, arguments, and options in src/specify_cli/integrations/_query_commands.py and _scaffold_commands.py. Fixes #3174. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index faecc39522..c2fe1d8ea8 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -54,6 +54,27 @@ Shows all available integrations, which one is currently installed, and whether When multiple integrations are installed, the list marks the default integration separately from the other installed integrations. The list also shows whether each built-in integration is declared multi-install safe. +## Search Available Integrations + +```bash +specify integration search [query] +``` + +| Option | Description | +| ---------- | ------------------ | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches the active catalog stack for integrations matching the query. Without a query, lists all available integrations. Must be run inside a Spec Kit project. + +## Integration Info + +```bash +specify integration info +``` + +Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project. + ## Install an Integration ```bash @@ -167,6 +188,18 @@ Example: specify integration install generic --integration-options="--commands-dir .myagent/cmds" ``` +## Scaffold a New Integration + +```bash +specify integration scaffold +``` + +Creates a minimal built-in integration package and a matching test skeleton in the Spec Kit repository, then prints the next steps for wiring it up. Run this command from the Spec Kit repository root. The `` must be lowercase kebab-case (for example, `my-agent`). + +| Option | Description | +| -------- | ---------------------------------------------------------------- | +| `--type` | Scaffold template to use: `markdown` (default), `skills`, `toml`, or `yaml` | + ## FAQ ### Can I install multiple integrations in the same project? From 4badf3b5b18a299b722b70791b918c9e161c1876 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:56:06 +0500 Subject: [PATCH 60/60] fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin) (#3231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scripts): use ASCII [OK] marker in initialize-repo.sh (parity with PowerShell twin) initialize-repo.sh printed its success line with a Unicode checkmark ('✓ Git repository initialized'), while the PowerShell twin initialize-repo.ps1 and both auto-commit scripts use the ASCII marker '[OK]'. That is an output-text divergence across the bash/PowerShell twins and an inconsistency among sibling extension scripts. Use '[OK]' to match. Co-Authored-By: Claude Opus 4.8 (1M context) * test: assert full [OK] init line and surface stderr on failure Address Copilot review: assert the full success line '[OK] Git repository initialized' (not just the '[OK]' substring, which could pass if unrelated [OK] output is added later) and include result.stderr in the assertion message so a failure is debuggable. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- extensions/git/scripts/bash/initialize-repo.sh | 2 +- tests/extensions/git/test_git_extension.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/git/scripts/bash/initialize-repo.sh b/extensions/git/scripts/bash/initialize-repo.sh index 296e363b94..c10876efc6 100755 --- a/extensions/git/scripts/bash/initialize-repo.sh +++ b/extensions/git/scripts/bash/initialize-repo.sh @@ -51,4 +51,4 @@ _git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_ _git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } _git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } -echo "✓ Git repository initialized" >&2 +echo "[OK] Git repository initialized" >&2 diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 1a291f0308..2f53854d82 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -233,6 +233,10 @@ def test_initializes_git_repo(self, tmp_path: Path): result = _run_bash("initialize-repo.sh", project) assert result.returncode == 0, result.stderr + # Success marker is the full ASCII "[OK] ..." line (matching the PowerShell + # twin and the sibling auto-commit scripts), not a Unicode checkmark. + assert "[OK] Git repository initialized" in result.stderr, result.stderr + # Verify git repo exists assert (project / ".git").exists()