From 8e76ff3d5cd4796c4515bac84ed5c4479b2ab199 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Wed, 24 Jun 2026 20:05:21 +0200 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] [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 12/18] 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 13/18] 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 14/18] [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 15/18] 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 16/18] 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 17/18] 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 336e5f7b1432df3364b6874f1852f564ef968871 Mon Sep 17 00:00:00 2001 From: bigsmartben <30429295+bigsmartben@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:28:59 +0800 Subject: [PATCH 18/18] fix: satisfy shellcheck for arch validation Assisted-by: Codex (model: GPT-5, autonomous) --- extensions/arch/scripts/bash/validate-arch-artifacts.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/arch/scripts/bash/validate-arch-artifacts.sh b/extensions/arch/scripts/bash/validate-arch-artifacts.sh index e3b23b8258..8d95a17fb1 100755 --- a/extensions/arch/scripts/bash/validate-arch-artifacts.sh +++ b/extensions/arch/scripts/bash/validate-arch-artifacts.sh @@ -101,7 +101,7 @@ section_exists() { local section_id="$2" local heading heading="$(section_heading "$section_id")" - grep -Eq "^##[[:space:]]+$heading[[:space:]]*$" "$file" + grep -Eq "^##[[:space:]]+${heading}[[:space:]]*$" "$file" } section_has_content() {