From 94c7ec288fd34221e62fef50f3212f8a899eda95 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Jul 2026 17:42:34 +0500 Subject: [PATCH 01/16] fix(toml): escape control characters so generated command files parse (#3341) * fix(toml): escape control characters so generated command files parse both toml renderers (TomlIntegration._render_toml_string for gemini/tabnine and CommandRegistrar.render_toml_command for extension/preset commands) wrote control characters raw into a multiline or basic string. toml forbids literal control chars (U+0000-U+001F except tab/newline, and U+007F) in every string form, and a bare CR that is not part of a CRLF pair, so a description or body containing one produced a .toml file that fails to parse. route any value with such a character to a fully-escaped basic string that emits the leftover control chars as \uXXXX. added regression tests that round-trip through tomllib. * refactor(toml): centralize control-char escaping in one shared helper the control-char detection and basic-string escaping added for both toml renderers were copy-pasted into agents.py and integrations/base.py. move the two functions into specify_cli/_toml_string.py and have both renderers delegate to it, so the escaping rules can't drift apart later. no behavior change; both renderers now reference the same implementation. --- src/specify_cli/_toml_string.py | 60 +++++++++++++++++++ src/specify_cli/agents.py | 25 ++++---- src/specify_cli/integrations/base.py | 27 +++++---- .../test_integration_base_toml.py | 12 ++++ tests/test_extensions.py | 19 ++++++ 5 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 src/specify_cli/_toml_string.py diff --git a/src/specify_cli/_toml_string.py b/src/specify_cli/_toml_string.py new file mode 100644 index 000000000..c9b024245 --- /dev/null +++ b/src/specify_cli/_toml_string.py @@ -0,0 +1,60 @@ +"""Shared TOML string-escaping helpers. + +Both TOML command renderers — ``TomlIntegration`` (gemini, tabnine) in +``specify_cli.integrations.base`` and ``CommandRegistrar.render_toml_command`` +(extension/preset commands) in ``specify_cli.agents`` — need the same rules for +detecting characters TOML forbids literally and for emitting a fully-escaped +basic string. Keeping one implementation here avoids the two drifting apart if +the escaping rules change again. +""" +from __future__ import annotations + + +def has_illegal_toml_control(value: str) -> bool: + """True when *value* contains a character TOML forbids literally. + + TOML basic/literal strings (single- or multi-line) allow tab and, in the + multiline forms, newlines — but every other control character + (``U+0000``–``U+001F`` and ``U+007F``) must be ``\\u``-escaped, which only a + basic string can do. A bare carriage return counts too: a multiline basic + string treats ``\\r`` as a newline only when paired into ``\\r\\n``; a lone + ``\\r`` is an illegal control character. + """ + length = len(value) + for i, ch in enumerate(value): + code = ord(ch) + if ch == "\r": + # Only a CR that is part of a CRLF newline is allowed literally. + if i + 1 < length and value[i + 1] == "\n": + continue + return True + if (code < 0x20 and ch not in ("\t", "\n")) or code == 0x7F: + return True + return False + + +def escape_toml_basic(value: str) -> str: + """Render *value* as a single-line basic string, escaping everything. + + Always valid TOML: backslash/quote are escaped, the common control chars + use their short escapes, and any remaining control character is emitted as + a ``\\uXXXX`` sequence. + """ + out: list[str] = [] + for ch in value: + code = ord(ch) + if ch == "\\": + out.append("\\\\") + elif ch == '"': + out.append('\\"') + elif ch == "\n": + out.append("\\n") + elif ch == "\r": + out.append("\\r") + elif ch == "\t": + out.append("\\t") + elif code < 0x20 or code == 0x7F: + out.append(f"\\u{code:04x}") + else: + out.append(ch) + return '"' + "".join(out) + '"' diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c127407d4..391015394 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -16,6 +16,8 @@ from typing import Any, Dict, List, Optional import yaml from ._init_options import is_ai_skills_enabled, load_init_options +from ._toml_string import escape_toml_basic as _escape_toml_basic +from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control from ._utils import relative_extension_path_violation @@ -258,7 +260,12 @@ class CommandRegistrar: # ``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: + # Control characters (U+0000–U+001F except tab/newline, U+007F) and a bare + # CR are illegal in every TOML string form, so a body containing them must + # go to the escaped basic string regardless of which delimiters it uses. + if self._has_illegal_toml_control(body): + toml_lines.append(f"prompt = {self._render_basic_toml_string(body)}") + elif '"""' not in body and "\\" not in body: toml_lines.append('prompt = """') toml_lines.append(body) toml_lines.append('"""') @@ -271,17 +278,11 @@ class CommandRegistrar: return "\n".join(toml_lines) - @staticmethod - def _render_basic_toml_string(value: str) -> str: - """Render *value* as a TOML basic string literal.""" - escaped = ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) - return f'"{escaped}"' + # Control-char detection and basic-string escaping are shared with the + # gemini/tabnine renderer in ``specify_cli.integrations.base`` via + # ``specify_cli._toml_string`` so the two never drift apart. + _has_illegal_toml_control = staticmethod(_has_illegal_toml_control) + _render_basic_toml_string = staticmethod(_escape_toml_basic) def render_yaml_command( self, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 706a3cb5d..4eb9bb19a 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -25,6 +25,9 @@ from typing import TYPE_CHECKING, Any import yaml +from .._toml_string import escape_toml_basic as _escape_toml_basic +from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control + if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -953,6 +956,12 @@ class TomlIntegration(IntegrationBase): body = "".join(lines[frontmatter_end + 1 :]) return frontmatter, body + # Control-char detection and basic-string escaping are shared with the + # extension/preset renderer in ``specify_cli.agents`` via + # ``specify_cli._toml_string`` so the two never drift apart. + _has_illegal_toml_control = staticmethod(_has_illegal_toml_control) + _escape_toml_basic = staticmethod(_escape_toml_basic) + @staticmethod def _render_toml_string(value: str) -> str: """Render *value* as a TOML string literal. @@ -962,6 +971,12 @@ class TomlIntegration(IntegrationBase): literal string or escaped basic string when delimiters appear in the content. """ + # Control characters other than tab/newline (and a bare CR) cannot + # appear literally in any TOML string; route them to a fully-escaped + # basic string so the generated file stays parseable. + if TomlIntegration._has_illegal_toml_control(value): + return TomlIntegration._escape_toml_basic(value) + if "\n" not in value and "\r" not in value: escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' @@ -974,17 +989,7 @@ class TomlIntegration(IntegrationBase): if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" - return ( - '"' - + ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) - + '"' - ) + return TomlIntegration._escape_toml_basic(value) @staticmethod def _render_toml(description: str, body: str) -> str: diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 68f5fd075..37bad8a60 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -291,6 +291,18 @@ class TomlIntegrationTests: "closing delimiter should be inline when body does not end with a quote" ) + def test_toml_string_escapes_control_characters(self): + """A value with control chars / a bare CR must render as parseable TOML. + + TOML forbids literal control characters (U+0000–U+001F except tab and + newline, plus U+007F) in every string form, and a bare CR that is not + part of a CRLF pair. The renderer used to emit these raw into a basic or + ``\"\"\"`` multiline string, producing a config file that fails to parse.""" + value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end" + rendered = TomlIntegration._render_toml_string(value) + parsed = tomllib.loads(f"prompt = {rendered}") + assert parsed["prompt"] == value + def test_toml_is_valid(self, tmp_path): """Every generated TOML file must parse without errors.""" i = get_integration(self.KEY) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3118ccdef..288518036 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1795,6 +1795,25 @@ $ARGUMENTS assert parsed["description"] == "first line\nsecond line\n" + def test_render_toml_command_escapes_control_characters(self): + """Control characters and a lone CR must be escaped so the TOML parses. + + TOML forbids literal control characters (U+0000–U+001F except tab and + newline, plus U+007F) in any string, and treats a bare CR outside a + CRLF pair as illegal. The renderer used to emit these raw — into a + basic string (single-line) or a ``\"\"\"`` multiline string (for a lone + CR) — producing a command file that fails to parse.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + body = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end" + output = registrar.render_toml_command( + {"description": "d"}, body, "extension:test-ext" + ) + + parsed = tomllib.loads(output) + assert parsed["prompt"] == body + def test_render_toml_command_preserves_backslashes_in_body(self): """A backslash in the body (e.g. a Windows path) must not break TOML. From 5a901a698bc535c9a1ca934030a86e7fd523c93f Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Jul 2026 17:45:01 +0500 Subject: [PATCH 02/16] fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312) * fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser read_feature_json_feature_directory picked its json parser by availability (if jq / elif python3 / else grep-sed). on windows `python3` usually resolves to the microsoft store app execution alias stub: it satisfies `command -v` but fails at runtime (exit 49). the elif selected it, the runtime failure was swallowed to _fd='', and the grep/sed last resort was never reached - so a valid .specify/feature.json read as empty and every setup-plan / setup-tasks / check-prerequisites call errored with "Feature directory not found" right after a successful `specify init --script sh`. change selection from availability to parse success: try jq, then python3 only if still empty, then grep/sed only if still empty. a parser that exists but produces nothing now falls through instead of terminating the chain. the write path (_persist_feature_json) already uses jq-or-printf with no python3, so it was unaffected; only the read path needed this. add a regression test that puts a python3 stub (exit 49, like the store alias) first on PATH and asserts setup-plan.sh still resolves the feature via the grep/sed fallback. fixes #3304 * test: shadow jq so the broken-python3 fallback test actually exercises it the test claimed it dropped jq so the parser chain would reach python3 and then grep/sed, but it only prepended the python3 stub dir to PATH. on a runner with jq installed, read_feature_json_feature_directory parses via jq and never reaches the fallback the test is meant to cover. add a failing jq stub alongside the python3 stub so the chain is forced through jq -> python3 -> grep/sed regardless of what the runner has installed. --- scripts/bash/common.sh | 13 +++++- tests/test_setup_plan_feature_json.py | 65 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 609729cbf..fd2f05f82 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -97,17 +97,26 @@ read_feature_json_feature_directory() { local fj="$repo_root/.specify/feature.json" [[ -f "$fj" ]] || { printf '%s' ''; return 0; } + # Try parsers in order (jq -> python3 -> grep/sed), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App Execution + # Alias stub, which passes `command -v` but fails at runtime (exit 49), so + # an availability-gated `elif` would pick python3, swallow its failure, and + # never reach the grep/sed fallback -- leaving feature.json unreadable even + # though it is valid (issue #3304). local _fd='' if command -v jq >/dev/null 2>&1; then if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then _fd='' fi - elif command -v python3 >/dev/null 2>&1; then + fi + if [[ -z "$_fd" ]] && command -v python3 >/dev/null 2>&1; then # Use Python so pretty-printed/multi-line JSON still parses correctly. if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then _fd='' fi - else + fi + if [[ -z "$_fd" ]]; then # Last-resort single-line grep/sed fallback. The `|| true` guards against # grep returning 1 (no match) aborting under `set -e` / `pipefail`. _fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \ diff --git a/tests/test_setup_plan_feature_json.py b/tests/test_setup_plan_feature_json.py index 09710e5d1..ded0c8a5f 100644 --- a/tests/test_setup_plan_feature_json.py +++ b/tests/test_setup_plan_feature_json.py @@ -126,6 +126,71 @@ def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None: assert "Feature directory not found" in result.stderr +@requires_bash +def test_setup_plan_survives_broken_python3_stub(plan_repo: Path) -> None: + """A `python3` on PATH that exists but fails at runtime must not defeat + feature.json parsing. + + On Windows `python3` typically resolves to the Microsoft Store App Execution + Alias stub: it satisfies `command -v python3` yet exits non-zero at runtime. + The parser must fall through to the grep/sed fallback on that failure instead + of selecting python3 by mere availability and swallowing its error (#3304). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + feat = plan_repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + _write_feature_json(plan_repo, "specs/001-tiny-notes-app") + + # A stub python3 that mimics the Windows Store alias: on PATH, exits 49. + stub_dir = plan_repo / "_stubbin" + stub_dir.mkdir() + stub = stub_dir / "python3" + stub.write_text( + "#!/bin/sh\n" + 'echo "Python was not found; run without arguments to install from the ' + 'Microsoft Store" >&2\n' + "exit 49\n", + encoding="utf-8", + ) + stub.chmod(0o755) + + # A stub jq that shadows any real jq on PATH and also fails, so the parser + # cannot short-circuit on jq and must reach the broken python3 stub and then + # fall through to grep/sed. Without this, a runner that has jq installed + # would parse feature.json via jq and never exercise the fallback this test + # is meant to cover. + jq_stub = stub_dir / "jq" + jq_stub.write_text( + "#!/bin/sh\n" + 'echo "jq: simulated failure" >&2\n' + "exit 1\n", + encoding="utf-8", + ) + jq_stub.chmod(0o755) + + env = _clean_env() + # Prepend the stub dir so the failing jq and python3 stubs take precedence + # over any real ones; PATH still needs the real bash utilities for grep/sed. + env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}" + + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + assert result.returncode == 0, result.stderr + result.stdout + assert (feat / "plan.md").is_file() + + @requires_bash def test_setup_plan_numbered_branch_works_with_feature_json( plan_repo: Path, From 295eb221e37e490303b32ab758287864e1c7cea1 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:46:29 +0200 Subject: [PATCH 03/16] feat(extensions): port update-agent-context to Python (#3387) * feat(extensions): port update-agent-context to Python Ports the agent-context extension updater to a single Python script, per #3281 and the check-prerequisites PoC pattern from #3302. The bash version already ran its core logic through embedded Python heredocs, so the port lifts that logic into a standalone script. Parity tests run bash and Python side by side and compare output and resulting context-file bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(extensions): match bash case-insensitivity on MSYS, test unparseable config gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/python/update_agent_context.py | 352 +++++++++++++ ...test_update_agent_context_python_parity.py | 481 ++++++++++++++++++ 2 files changed, 833 insertions(+) create mode 100644 extensions/agent-context/scripts/python/update_agent_context.py create mode 100644 tests/extensions/test_update_agent_context_python_parity.py diff --git a/extensions/agent-context/scripts/python/update_agent_context.py b/extensions/agent-context/scripts/python/update_agent_context.py new file mode 100644 index 000000000..15c0dceef --- /dev/null +++ b/extensions/agent-context/scripts/python/update_agent_context.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Refresh the managed Spec Kit section in the coding agent's context file(s). + +Python port of ``update-agent-context.sh`` / ``update-agent-context.ps1``. + +Reads ``context_files`` or ``context_file``, plus ``context_markers.{start,end}``, +from the agent-context extension config: + .specify/extensions/agent-context/agent-context-config.yml + +Usage: update_agent_context.py [plan_path] + +When ``plan_path`` is omitted, the script derives it from +``.specify/feature.json`` (written by /speckit-specify). Falls back to the most +recently modified ``specs/*/plan.md`` only when feature.json is absent or its +plan does not exist yet. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +DEFAULT_START = "" +DEFAULT_END = "" + + +def _err(message: str) -> None: + print(message, file=sys.stderr) + + +def _get_str(obj: object, *keys: str) -> str: + node = obj + for key in keys: + if isinstance(node, dict) and key in node: + node = node[key] + else: + return "" + return node if isinstance(node, str) else "" + + +def _collect_context_files(data: dict, project_root: str) -> list[str]: + """Resolve the managed context files from config, mirroring the bash logic.""" + context_files: list[str] = [] + seen: set[str] = set() + case_insensitive = sys.platform.startswith(("win32", "cygwin", "msys")) + + def add(value: object) -> None: + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + key = candidate.casefold() if case_insensitive else candidate + if key in seen: + return + context_files.append(candidate) + seen.add(key) + + raw_files = data.get("context_files") + if isinstance(raw_files, list): + for value in raw_files: + add(value) + if not context_files: + add(_get_str(data, "context_file")) + if not context_files: + # Self-seed: when the config declares no target, derive one from the + # active integration recorded in init-options.json, mapped through the + # bundled agent-context-defaults.json file. Independent of the Specify + # CLI by design. + integration_key = "" + try: + with open( + f"{project_root}/.specify/init-options.json", "r", encoding="utf-8" + ) as fh: + opts = json.load(fh) + if isinstance(opts, dict): + value = opts.get("integration") or opts.get("ai") or "" + integration_key = value if isinstance(value, str) else "" + except Exception: + integration_key = "" + if integration_key: + defaults_path = ( + f"{project_root}/.specify/extensions/agent-context/" + "agent-context-defaults.json" + ) + mapping = {} + try: + with open(defaults_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {} + mapping = agents if isinstance(agents, dict) else {} + except Exception: + _err( + "agent-context: unable to read %s; cannot self-seed the context " + "file. Set context_file in the extension config." % defaults_path + ) + mapping = {} + add(mapping.get(integration_key, "") or "") + if not context_files: + _err( + "agent-context: no default context file is known for integration " + "%s. Set context_file in the extension config to choose one." + % integration_key + ) + return context_files + + +def _validate_context_file(project_root: str, context_file: str) -> str | None: + """Return an error message when the path escapes the project root.""" + if context_file.startswith("/") or re.match(r"^[A-Za-z]:", context_file): + return ( + "agent-context: context files must be project-relative paths; " + f"got '{context_file}'." + ) + if "\\" in context_file: + return ( + "agent-context: context files must not contain backslash separators; " + f"got '{context_file}'." + ) + if ".." in context_file.split("/"): + return ( + "agent-context: context files must not contain '..' path segments; " + f"got '{context_file}'." + ) + root = Path(project_root).resolve() + target = (root / context_file).resolve() + try: + target.relative_to(root) + except ValueError: + return ( + "agent-context: context file path resolves outside the project root; " + f"got '{context_file}'." + ) + return None + + +def _resolve_plan_path(project_root: str) -> str: + """Derive the plan path: feature.json first, then the mtime fallback.""" + plan_path = "" + feature_json = Path(project_root) / ".specify" / "feature.json" + if feature_json.is_file(): + feature_dir = "" + try: + with open(feature_json, "r", encoding="utf-8") as fh: + data = json.load(fh) + value = data.get("feature_directory", "") + feature_dir = value if isinstance(value, str) else "" + except Exception: + feature_dir = "" + # Normalize backslashes (written by PS on Windows) before path ops. + feature_dir = feature_dir.replace("\\", "/").rstrip("/") + if feature_dir: + # feature_directory may be relative or absolute (absolute paths + # outside the project root are preserved as-is), including + # drive-qualified paths (C:/...) written by PowerShell on Windows. + if feature_dir.startswith("/") or re.match(r"^[A-Za-z]:/", feature_dir): + candidate = Path(feature_dir) / "plan.md" + else: + candidate = Path(project_root) / feature_dir / "plan.md" + if candidate.is_file(): + # Resolve symlinks before comparing so paths like /var/… vs + # /private/var/… (macOS) are treated as equivalent. + root = Path(project_root).resolve() + resolved = candidate.resolve() + try: + plan_path = resolved.relative_to(root).as_posix() + except ValueError: + plan_path = resolved.as_posix() + + if not plan_path: + root = Path(project_root).resolve() + plans = sorted( + (root / "specs").glob("*/plan.md"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if plans: + try: + plan_path = plans[0].relative_to(root).as_posix() + except ValueError: + plan_path = "" + return plan_path + + +def _build_section(marker_start: str, marker_end: str, plan_path: str) -> str: + lines = [ + marker_start, + "For additional context about technologies to be used, project structure,", + "shell commands, and other important information, read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + lines.append(marker_end) + return "\n".join(lines) + "\n" + + +def ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with + ``alwaysApply: true``. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + """ + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + match = re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text): + return content + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + +def _upsert_section( + ctx_path: str, marker_start: str, marker_end: str, section: str +) -> None: + """Insert or replace the managed section, then normalize and write.""" + if os.path.exists(ctx_path): + with open(ctx_path, "r", encoding="utf-8-sig") as fh: + content = fh.read() + s = content.find(marker_start) + e = content.find(marker_end, s if s != -1 else 0) + if s != -1 and e != -1 and e > s: + end_of_marker = e + len(marker_end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:s] + section + content[end_of_marker:] + elif s != -1: + new_content = content[:s] + section + elif e != -1: + end_of_marker = e + len(marker_end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + if content and not content.endswith("\n"): + content += "\n" + new_content = (content + "\n" + section) if content else section + else: + new_content = section + + new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") + if ctx_path.casefold().endswith(".mdc"): + new_content = ensure_mdc_frontmatter(new_content) + with open(ctx_path, "wb") as fh: + fh.write(new_content.encode("utf-8")) + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + project_root = os.getcwd() + ext_config = ( + f"{project_root}/.specify/extensions/agent-context/agent-context-config.yml" + ) + + if not os.path.isfile(ext_config): + _err(f"agent-context: {ext_config} not found; nothing to do.") + return 0 + + try: + import yaml + except ImportError: + _err( + "agent-context: PyYAML is required to parse extension config but is " + "not available in the current Python environment.\n" + " To resolve: pip install pyyaml (or install it into the environment " + "used by python3).\n" + " Context file will not be updated until PyYAML is importable." + ) + _err("agent-context: skipping update (see above for details).") + return 0 + + try: + with open(ext_config, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) + except Exception as exc: + _err( + f"agent-context: unable to parse {ext_config} ({exc}); " + "cannot update context." + ) + _err("agent-context: skipping update (see above for details).") + return 0 + if not isinstance(data, dict): + data = {} + + context_files = _collect_context_files(data, project_root) + if not context_files: + _err( + "agent-context: context_files/context_file not set in extension config; " + "nothing to do." + ) + return 0 + + for context_file in context_files: + error = _validate_context_file(project_root, context_file) + if error: + _err(error) + return 1 + + marker_start = _get_str(data, "context_markers", "start") or DEFAULT_START + marker_end = _get_str(data, "context_markers", "end") or DEFAULT_END + + plan_path = args[0] if args else "" + if not plan_path: + plan_path = _resolve_plan_path(project_root) + + section = _build_section(marker_start, marker_end, plan_path) + + for context_file in context_files: + ctx_path = os.path.join(project_root, context_file) + os.makedirs(os.path.dirname(ctx_path) or ".", exist_ok=True) + _upsert_section(ctx_path, marker_start, marker_end, section) + print(f"agent-context: updated {context_file}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/extensions/test_update_agent_context_python_parity.py b/tests/extensions/test_update_agent_context_python_parity.py new file mode 100644 index 000000000..92e8e20f8 --- /dev/null +++ b/tests/extensions/test_update_agent_context_python_parity.py @@ -0,0 +1,481 @@ +"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1. + +Each test prepares two identical project trees, runs the bash script in one +and the Python port in the other, then compares exit codes, output (with +project roots normalized) and the resulting context-file bytes. PowerShell +tests compare the resulting file content only and are skipped when ``pwsh`` +is unavailable. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from tests.extensions.test_extension_agent_context import ( + BASH, + EXT_DIR, + POWERSHELL, + _bundled_script_env, +) + +PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py" +BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh" +PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1" + +requires_posix_bash = pytest.mark.skipif( + not BASH or os.name == "nt", + reason="POSIX bash required for side-by-side parity runs", +) + + +def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [BASH, str(BASH_SCRIPT), *args], + cwd=project_root, + env=_bundled_script_env(project_root, for_bash=True), + capture_output=True, + text=True, + timeout=30, + ) + + +def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(PY_SCRIPT), *args], + cwd=project_root, + env=_bundled_script_env(project_root), + capture_output=True, + text=True, + timeout=30, + ) + + +def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(PS_SCRIPT), + *args, + ], + cwd=project_root, + env=_bundled_script_env(project_root), + capture_output=True, + text=True, + timeout=30, + ) + + +def normalize(text: str, project_root: Path) -> str: + return text.replace(str(project_root.resolve()), "__ROOT__").replace( + str(project_root), "__ROOT__" + ) + + +def write_config(project_root: Path, **overrides: object) -> None: + """Write the extension config as JSON (valid YAML, PS-parseable too).""" + cfg: dict = { + "context_file": overrides.get("context_file", ""), + "context_files": overrides.get("context_files", []), + "context_markers": overrides.get( + "context_markers", + {"start": "", "end": ""}, + ), + } + cfg_dir = project_root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + json.dumps(cfg), encoding="utf-8" + ) + + +def make_project(root: Path, **config: object) -> Path: + root.mkdir(parents=True, exist_ok=True) + write_config(root, **config) + return root + + +def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None: + plan = project_root / feature_dir / "plan.md" + plan.parent.mkdir(parents=True, exist_ok=True) + plan.write_text("# plan\n", encoding="utf-8") + (project_root / ".specify").mkdir(parents=True, exist_ok=True) + (project_root / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": feature_dir}), encoding="utf-8" + ) + + +def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]: + return ( + make_project(tmp_path / "proj-a", **config), + make_project(tmp_path / "proj-b", **config), + ) + + +def assert_parity( + bash: subprocess.CompletedProcess, + py: subprocess.CompletedProcess, + repo_a: Path, + repo_b: Path, +) -> None: + assert py.returncode == bash.returncode, py.stderr + bash.stderr + assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a) + assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a) + + +# ── Fresh file and upsert behavior ─────────────────────────────────────────── + + +@requires_posix_bash +def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + add_plan(repo_a) + add_plan(repo_b) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content_a = (repo_a / "AGENTS.md").read_bytes() + content_b = (repo_b / "AGENTS.md").read_bytes() + assert content_a == content_b + assert b"at specs/001-demo/plan.md" in content_b + + +@requires_posix_bash +def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + existing = ( + "# My project\n\n" + "\nstale section\n\n" + "\nTrailing prose stays.\n" + ) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_text(encoding="utf-8") + assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8") + assert "stale section" not in content + assert content.startswith("# My project\n") + assert "Trailing prose stays." in content + + +@requires_posix_bash +@pytest.mark.parametrize( + "existing", + [ + "# Doc\n\ndangling start\n", + "dangling end\n\nrest\n", + "no markers at all", + ], + ids=["start-only", "end-only", "no-markers-no-newline"], +) +def test_python_handles_partial_markers_matching_bash( + tmp_path: Path, existing: str +) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + + +@requires_posix_bash +def test_python_custom_markers_matching_bash(tmp_path: Path) -> None: + markers = {"start": "", "end": ""} + repo_a, repo_b = twin_projects( + tmp_path, context_file="AGENTS.md", context_markers=markers + ) + existing = "intro\n\nold\n\noutro\n" + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_text(encoding="utf-8") + assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8") + assert "" in content + assert "old" not in content + + +@requires_posix_bash +def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None: + files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"] + repo_a, repo_b = twin_projects(tmp_path, context_files=files) + add_plan(repo_a) + add_plan(repo_b) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert bash.stdout.count("agent-context: updated") == 2 + for name in ("AGENTS.md", "docs/CONTEXT.md"): + assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes() + + +@requires_posix_bash +def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + existing = b"# Doc\r\n\r\n\r\nold\r\n\r\ntail\r\n" + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_bytes(existing) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"\r" not in content + + +@requires_posix_bash +def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None: + mdc = ".cursor/rules/specify-rules.mdc" + cases = { + "missing": "# Rules\n", + "false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n", + "no-key": "---\ndescription: rules\n---\n\n# Rules\n", + } + for name, existing in cases.items(): + repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc) + repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc) + for repo in (repo_a, repo_b): + add_plan(repo) + target = repo / mdc + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(existing, encoding="utf-8") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / mdc).read_text(encoding="utf-8") + assert content == (repo_a / mdc).read_text(encoding="utf-8"), name + assert "alwaysApply: true" in content, name + + +# ── Plan-path resolution ───────────────────────────────────────────────────── + + +@requires_posix_bash +def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + + bash = run_bash(repo_a, "specs/009-explicit/plan.md") + py = run_python(repo_b, "specs/009-explicit/plan.md") + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/009-explicit/plan.md" in content + + +@requires_posix_bash +def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + now = time.time() + for repo in (repo_a, repo_b): + for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)): + plan = repo / feature / "plan.md" + plan.parent.mkdir(parents=True, exist_ok=True) + plan.write_text("# plan\n", encoding="utf-8") + os.utime(plan, (now - age, now - age)) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/001-new/plan.md" in content + + +@requires_posix_bash +def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + now = time.time() + for repo in (repo_a, repo_b): + add_plan(repo, "specs/001-active") + stale = repo / "specs" / "000-stale" / "plan.md" + stale.parent.mkdir(parents=True, exist_ok=True) + stale.write_text("# plan\n", encoding="utf-8") + os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10)) + os.utime(stale, (now, now)) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"at specs/001-active/plan.md" in content + + +@requires_posix_bash +def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md") + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + content = (repo_b / "AGENTS.md").read_bytes() + assert content == (repo_a / "AGENTS.md").read_bytes() + assert b"\nat " not in content + + +# ── Config gates and path validation ───────────────────────────────────────── + + +@requires_posix_bash +def test_python_missing_config_matching_bash(tmp_path: Path) -> None: + repo_a = tmp_path / "proj-a" + repo_b = tmp_path / "proj-b" + repo_a.mkdir() + repo_b.mkdir() + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "not found; nothing to do." in py.stderr + + +@requires_posix_bash +def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None: + repo_a = tmp_path / "proj-a" + repo_b = tmp_path / "proj-b" + for repo in (repo_a, repo_b): + cfg_dir = repo / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True) + (cfg_dir / "agent-context-config.yml").write_text( + "context_file: [unclosed\n", encoding="utf-8" + ) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "cannot update context." in py.stderr + assert "agent-context: skipping update (see above for details)." in py.stderr + + +@requires_posix_bash +def test_python_empty_config_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 0 + assert "context_files/context_file not set" in py.stderr + + +@requires_posix_bash +def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None: + repo_a, repo_b = twin_projects(tmp_path) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / ".specify" / "init-options.json").write_text( + json.dumps({"integration": "claude"}), encoding="utf-8" + ) + shutil.copy( + EXT_DIR / "agent-context-defaults.json", + repo + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json", + ) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes() + + +@requires_posix_bash +@pytest.mark.parametrize( + "bad_path", + ["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"], + ids=["absolute", "backslash", "dotdot", "nested-dotdot"], +) +def test_python_rejects_escaping_paths_matching_bash( + tmp_path: Path, bad_path: str +) -> None: + repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path) + + bash = run_bash(repo_a) + py = run_python(repo_b) + + assert_parity(bash, py, repo_a, repo_b) + assert py.returncode == 1 + assert not (repo_b / "AGENTS.md").exists() + + +# ── PowerShell parity (content only) ───────────────────────────────────────── + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None: + repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md") + repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md") + add_plan(repo_a) + add_plan(repo_b) + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_python_upsert_matches_powershell(tmp_path: Path) -> None: + repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md") + repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md") + existing = ( + "# My project\n\n" + "\nstale\n\n" + "\ntail\n" + ) + for repo in (repo_a, repo_b): + add_plan(repo) + (repo / "AGENTS.md").write_text(existing, encoding="utf-8") + + ps = run_powershell(repo_a) + py = run_python(repo_b) + + assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr + assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes() From ba1ce366b7192fe82691a88931f063b82ebb3472 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 8 Jul 2026 19:48:08 +0700 Subject: [PATCH 04/16] Docs: Remove Cursor from CLI check list in README (#3184) * docs: reword CLI check behavior to remove exhaustive list of tools * docs: clarify conditional CLI tool installation check --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 62514e5b0..c759f6e05 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ specify init . --force --integration copilot specify init --here --force --integration copilot ``` -The CLI will check that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI checks that the selected integration's required CLI tool is installed on your machine when that integration has `requires_cli: True`. If you do not have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools From 13d2cca1549bd1d5678501160f021015e5598d92 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 8 Jul 2026 19:49:42 +0700 Subject: [PATCH 05/16] Docs: Document missing CLI flags and integrations (#3182) * docs: document missing flags and integrations * docs: remove invalid --refresh-shared-infra from upgrade command * docs: address PR feedback for extension and integration flags * docs: reorder extension add options to match CLI help --- docs/reference/extensions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 90e5ab874..30f33eca0 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -26,7 +26,7 @@ specify extension add | --------------- | -------------------------------------------------------- | | `--dev` | Install from a local directory (for development) | | `--from ` | Install from a custom URL instead of the catalog | -| `--force` | Overwrite if already installed | +| `--force` | Overwrite if the extension is already installed | | `--priority `| Resolution priority (default: 10; lower = higher precedence) | Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. From 0da969df14fc67feec44a53e30f78a486d5a652e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:29:02 -0500 Subject: [PATCH 06/16] [extension] Add LLM Wiki extension to community catalog (#3361) * Add LLM Wiki extension to community catalog Add wiki extension submitted by @formin to: - extensions/catalog.community.json (alphabetical order) - docs/community/extensions.md community extensions table Closes #3319 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: limit catalog.community.json changes to wiki entry + timestamps only Reverts the unintended reordering and reformatting of existing extensions (aide, checkpoint, critique, threatmodel, etc.) and companion's tools array. Only the new wiki entry and updated_at timestamps are now changed. 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 | 34 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index d02dea703..7ccb2b5c8 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -67,6 +67,7 @@ The following community-contributed extensions are available in [`catalog.commun | Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) | | Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) | | Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) | +| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) | | Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) | | MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) | | MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e571c7acf..8ca9c4b7d 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -4375,6 +4375,40 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "wiki": { + "name": "LLM Wiki", + "id": "wiki", + "description": "LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting", + "author": "formin", + "version": "1.0.0", + "download_url": "https://github.com/formin/spec-kit-wiki/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/formin/spec-kit-wiki", + "homepage": "https://github.com/formin/spec-kit-wiki", + "documentation": "https://github.com/formin/spec-kit-wiki/blob/main/README.md", + "changelog": "https://github.com/formin/spec-kit-wiki/blob/main/CHANGELOG.md", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 5, + "hooks": 2 + }, + "tags": [ + "wiki", + "knowledge-base", + "docs", + "memory", + "context-management" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-07-06T00:00:00Z", + "updated_at": "2026-07-06T00:00:00Z" + }, "wireframe": { "name": "Wireframe Visual Feedback Loop", "id": "wireframe", From a7b439174f1cbd773cea7011dfe40e248965005e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:39:56 -0500 Subject: [PATCH 07/16] chore: release 0.12.8, begin 0.12.9.dev0 development (#3410) * chore: bump version to 0.12.8 * chore: begin 0.12.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 0ac8bb4e3..43079d8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.12.8] - 2026-07-08 + +### Changed + +- [extension] Add LLM Wiki extension to community catalog (#3361) +- Docs: Document missing CLI flags and integrations (#3182) +- Docs: Remove Cursor from CLI check list in README (#3184) +- feat(extensions): port update-agent-context to Python (#3387) +- fix(scripts): fall through to grep/sed when python3 is a broken stub in feature.json parser (#3312) +- fix(toml): escape control characters so generated command files parse (#3341) +- fix(cli): exit cleanly on malformed IPv6 URLs in `extension`/`preset`/`workflow add` (#3369) +- fix(github-http): return None on malformed GHES port instead of raising (#3379) +- fix(integrations): guard _sha256 against unreadable managed files (#3376) +- chore: release 0.12.7, begin 0.12.8.dev0 development (#3398) + ## [0.12.7] - 2026-07-07 ### Changed diff --git a/pyproject.toml b/pyproject.toml index bff15afa3..3ee0b11a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.12.8.dev0" +version = "0.12.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 643f73a1d71eebbd31ff4f96966e74dc053f806e Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Thu, 9 Jul 2026 14:35:38 +0200 Subject: [PATCH 08/16] test: isolate integration test home (#3144) * test: isolate integration test home Assisted-by: Codex (model: GPT-5, autonomous) * test: assert integration home isolation Assisted-by: Codex (model: GPT-5, autonomous) * test: extend integration home isolation to module-scoped setup Address Copilot review on #3144. Add a session-scoped autouse fixture so HOME/USERPROFILE/XDG are redirected for setup that runs outside a test function (e.g. the module-scoped status_* fixtures in test_integration_subcommand.py that run `specify init` before any per-test isolation applies). The function-scoped fixture still overrides HOME per test. Also assert Path.home() resolves to the isolated home, since most integrations (Hermes, catalog) read the home via that API rather than the env vars directly. --- tests/integrations/conftest.py | 28 +++++++++++++++++++---- tests/integrations/test_home_isolation.py | 4 ++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 467187235..4b7806c4a 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -5,10 +5,8 @@ import pytest from specify_cli.integrations.base import MarkdownIntegration -@pytest.fixture(autouse=True) -def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path): - """Keep integration tests from reading or writing the real user home.""" - home = tmp_path / "home" +def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None: + """Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory.""" for path in (home, home / ".cache", home / ".config", home / ".local" / "share"): path.mkdir(parents=True, exist_ok=True) @@ -19,6 +17,28 @@ def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path): monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) +@pytest.fixture(scope="session", autouse=True) +def _isolate_integration_home_session(tmp_path_factory): + """Isolate the user home for setup that runs outside a test function. + + The per-test fixture below re-points HOME for each test, but function-scoped + fixtures do not apply to module-/session-scoped fixtures. Some of those (e.g. + the ``status_*_template`` fixtures in ``test_integration_subcommand.py``) run + ``specify init`` during setup, before any per-test isolation takes effect. + A standalone ``MonkeyPatch`` gives them an isolated home too. + """ + monkeypatch = pytest.MonkeyPatch() + _redirect_home(monkeypatch, tmp_path_factory.mktemp("session-home")) + yield + monkeypatch.undo() + + +@pytest.fixture(autouse=True) +def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path): + """Keep integration tests from reading or writing the real user home.""" + _redirect_home(monkeypatch, tmp_path / "home") + + class StubIntegration(MarkdownIntegration): """Minimal concrete integration for testing.""" diff --git a/tests/integrations/test_home_isolation.py b/tests/integrations/test_home_isolation.py index e79ff5dff..c862b1686 100644 --- a/tests/integrations/test_home_isolation.py +++ b/tests/integrations/test_home_isolation.py @@ -15,6 +15,10 @@ def test_integration_tests_use_tmp_home(tmp_path: Path) -> None: assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config" assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share" + # Most integrations resolve the user home via Path.home() (e.g. Hermes, + # catalog), so the isolation has to reach that API, not just the env vars. + assert Path.home() == home + assert home.is_dir() assert (home / ".cache").is_dir() assert (home / ".config").is_dir() From c5fdae752bebf35ade36b6fa1d0b9e76133e8436 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:39:04 -0500 Subject: [PATCH 09/16] Update Golden Demo extension to v0.3.0 (#3394) Update golden-demo extension submitted by @jasstt: - extensions/catalog.community.json (version, download_url, description, provides, tags, updated_at) - docs/community/extensions.md community extensions table Closes #3360 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 | 2 +- extensions/catalog.community.json | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/community/extensions.md b/docs/community/extensions.md index 7ccb2b5c8..ae953b3ce 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -58,7 +58,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) | +| Golden Demo | Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes. | `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, HTML SSOT, 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 8ca9c4b7d..e84d677e6 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-06T00:00:00Z", + "updated_at": "2026-07-07T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1398,10 +1398,10 @@ "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.", + "description": "Deterministic behavioral drift oracle. Extracts acceptance criteria, generates fuzz test vectors (seed=42), compares golden Python implementations against real code in any language. CI/CD gatekeeper with warn/strict modes.", "author": "jasstt", - "version": "0.1.1", - "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip", + "version": "0.3.0", + "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.3.0.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", @@ -1412,13 +1412,16 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 2, + "commands": 3, "hooks": 2 }, "tags": [ "testing", "drift-detection", "behavioral-oracle", + "fuzzing", + "ci-cd", + "cross-language", "tdd", "quality" ], @@ -1426,7 +1429,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-06-24T00:00:00Z", - "updated_at": "2026-06-24T00:00:00Z" + "updated_at": "2026-07-07T00:00:00Z" }, "harness": { "name": "Research Harness", From 7b1065d8579a850a98faa747999439f03dcf8f80 Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:46:22 +0500 Subject: [PATCH 10/16] fix(bundler): enforce version pin on bundled preset/extension installs (#3377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bundler): enforce version pin on bundled preset/extension installs The bundled install branch of _PresetKindManager/_ExtensionKindManager called install_from_directory and returned before _assert_pinned_version, so a bundle manifest pinning e.g. 2.0.0 would silently install the bundled asset's own version (1.0.0) — the pin was only enforced on the catalog path. _WorkflowKindManager already enforces it unconditionally. Read the bundled asset's declared version from its manifest (best-effort; None => cannot enforce, matching the catalog 'advertises no version' escape hatch) and call _assert_pinned_version before install, in both bundled branches. Co-Authored-By: Claude Fable 5 * fix(bundler): address review on bundled version-pin check - Make _assert_pinned_version's error source-agnostic ('resolved version' / 'the source') so bundled preset.yml/extension.yml mismatches read correctly, not just catalog ones. - Type-guard _bundled_manifest_version: only a non-empty string version is usable; missing/non-string/whitespace -> None ('cannot enforce'). - Add bundled-preset success-path test (matching pin + version=None both proceed to install_from_directory), mirroring the extension test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../bundler/services/primitives.py | 55 ++++++++++-- tests/unit/test_bundler_primitives.py | 84 +++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/bundler/services/primitives.py b/src/specify_cli/bundler/services/primitives.py index bd0d8ddee..229fb6137 100644 --- a/src/specify_cli/bundler/services/primitives.py +++ b/src/specify_cli/bundler/services/primitives.py @@ -33,12 +33,13 @@ DEFAULT_PRIORITY = 10 def _assert_pinned_version( kind: str, component_id: str, pinned: str | None, advertised: object ) -> None: - """Refuse to install when the catalog version differs from the manifest pin. + """Refuse to install when the resolved version differs from the manifest pin. Bundle manifests pin component versions for reproducibility; installing - whatever the active catalog currently serves would silently violate the - pin. When the catalog advertises no version we cannot enforce the pin, so - installation proceeds (the catalog, not the bundler, owns that gap). + whatever the resolved source (catalog *or* bundled asset) provides would + silently violate the pin. When the source advertises no version we cannot + enforce the pin, so installation proceeds (the source, not the bundler, + owns that gap). """ if not pinned or advertised is None: return @@ -54,11 +55,35 @@ def _assert_pinned_version( if not matches: raise BundlerError( f"{kind} '{component_id}' is pinned to version {pinned} in the bundle " - f"manifest, but the active catalog serves {actual}. Update the bundle's " - "pinned version or the catalog before installing." + f"manifest, but the resolved version is {actual}. Update the bundle's " + "pinned version or the source before installing." ) +def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None: + """Best-effort read of a bundled asset's declared version from its manifest. + + Returns ``None`` when the manifest is missing/unreadable/invalid, which + ``_assert_pinned_version`` treats as "cannot enforce" (proceed) — matching + the catalog "advertises no version" escape hatch. + """ + try: + import yaml + + data = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + section = data.get(root_key) + if isinstance(section, dict): + version = section.get("version") + # Only a non-empty string is a usable version; anything else + # (missing / non-string / whitespace) means "cannot enforce". + if isinstance(version, str) and version.strip(): + return version + except Exception: # noqa: BLE001 - unreadable/invalid manifest: skip pin + return None + return None + + class _KindManager(Protocol): def is_installed(self, component: ComponentRef) -> bool: ... @@ -134,6 +159,15 @@ class _PresetKindManager: bundled = _locate_bundled_preset(component.id) if bundled is not None: + # Enforce the manifest pin against the bundled asset's own version, + # mirroring the catalog path below (the bundled path previously + # skipped the pin entirely). + _assert_pinned_version( + "Preset", + component.id, + component.version, + _bundled_manifest_version(bundled / "preset.yml", "preset"), + ) self._manager.install_from_directory(bundled, speckit_version, priority) return @@ -198,6 +232,15 @@ class _ExtensionKindManager: bundled = _locate_bundled_extension(component.id) if bundled is not None: + # Enforce the manifest pin against the bundled asset's own version, + # mirroring the catalog path below (the bundled path previously + # skipped the pin entirely). + _assert_pinned_version( + "Extension", + component.id, + component.version, + _bundled_manifest_version(bundled / "extension.yml", "extension"), + ) self._manager.install_from_directory( bundled, speckit_version, priority=priority ) diff --git a/tests/unit/test_bundler_primitives.py b/tests/unit/test_bundler_primitives.py index f662d22fc..9891e6f77 100644 --- a/tests/unit/test_bundler_primitives.py +++ b/tests/unit/test_bundler_primitives.py @@ -131,3 +131,87 @@ def test_preset_install_preserves_explicit_zero_priority(tmp_path: Path, monkeyp # An explicit priority of 0 must be passed through, not replaced by default. assert calls["priority"] == 0 + + +def _write_manifest(path: Path, root_key: str, version: str) -> Path: + path.mkdir(parents=True, exist_ok=True) + (path / f"{root_key}.yml").write_text( + f"{root_key}:\n id: x\n version: {version}\n", encoding="utf-8" + ) + return path + + +def test_bundled_extension_pin_mismatch_refuses(tmp_path: Path, monkeypatch): + """A bundled extension whose version != the manifest pin must be refused + (the bundled path previously skipped the pin the catalog path enforces).""" + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionManager + + bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) + called: list = [] + monkeypatch.setattr( + ExtensionManager, "install_from_directory", + lambda self, *a, **k: called.append(a), + ) + + manager = primitive_manager("extensions", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="pinned to version 2.0.0"): + manager.install(ComponentRef(kind="extensions", id="my-ext", version="2.0.0")) + assert called == [] # install must not proceed + + +def test_bundled_extension_pin_match_installs(tmp_path: Path, monkeypatch): + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionManager + + bundled = _write_manifest(tmp_path / "ext", "extension", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: bundled) + called: list = [] + monkeypatch.setattr( + ExtensionManager, "install_from_directory", + lambda self, *a, **k: called.append(a), + ) + + manager = primitive_manager("extensions", tmp_path, allow_network=False) + # matching pin, and unpinned, both install cleanly + manager.install(ComponentRef(kind="extensions", id="my-ext", version="1.0.0")) + manager.install(ComponentRef(kind="extensions", id="my-ext", version=None)) + assert len(called) == 2 + + +def test_bundled_preset_pin_mismatch_refuses(tmp_path: Path, monkeypatch): + import specify_cli._assets as assets + from specify_cli.presets import PresetManager + + bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled) + called: list = [] + monkeypatch.setattr( + PresetManager, "install_from_directory", + lambda self, *a, **k: called.append(a), + ) + + manager = primitive_manager("presets", tmp_path, allow_network=False) + with pytest.raises(BundlerError, match="pinned to version 2.0.0"): + manager.install(ComponentRef(kind="presets", id="my-preset", version="2.0.0")) + assert called == [] + + +def test_bundled_preset_pin_match_installs(tmp_path: Path, monkeypatch): + import specify_cli._assets as assets + from specify_cli.presets import PresetManager + + bundled = _write_manifest(tmp_path / "preset", "preset", "1.0.0") + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda cid: bundled) + called: list = [] + monkeypatch.setattr( + PresetManager, "install_from_directory", + lambda self, *a, **k: called.append(a), + ) + + manager = primitive_manager("presets", tmp_path, allow_network=False) + # matching pin, and unpinned, both proceed to install + manager.install(ComponentRef(kind="presets", id="my-preset", version="1.0.0")) + manager.install(ComponentRef(kind="presets", id="my-preset", version=None)) + assert len(called) == 2 From 54ed736479455cb428d803de1d71852ce0ded7eb Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:48:38 +0500 Subject: [PATCH 11/16] fix(agents): resolve skill placeholders in Goose (yaml) command output (#3374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): resolve skill placeholders in Goose (yaml) command output CommandRegistrar.register_commands resolves {SCRIPT}/__AGENT__ and the $ARGUMENTS placeholder in the markdown and toml branches, but the yaml branch (Goose recipes) called render_yaml_command directly, skipping both. So extension/preset command bodies installed for Goose kept literal {SCRIPT}, __AGENT__, and repo-relative script paths in the generated .goose/recipes/*.yaml prompt. Mirror the markdown/toml branches: run resolve_skill_placeholders + _convert_argument_placeholder on the body before render_yaml_command. Co-Authored-By: Claude Fable 5 * test(goose): assert positive placeholder replacements in recipe prompt Per review: parse the generated recipe with yaml.safe_load and assert the prompt contains the resolved values (.specify/scripts/, 'agent goose', {{args}}), not merely that the literal tokens are absent — a wrong output that happens to omit the exact strings would otherwise pass. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/specify_cli/agents.py | 6 +++ tests/integrations/test_integration_goose.py | 47 ++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 391015394..c53586912 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -701,6 +701,12 @@ class CommandRegistrar: ) output = self.render_toml_command(frontmatter, body, source_id) elif agent_config["format"] == "yaml": + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) output = self.render_yaml_command( frontmatter, body, source_id, cmd_name ) diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 104b7188d..300b056c4 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -36,3 +36,50 @@ class TestGooseIntegration(YamlIntegrationTests): param.get("key") == "args" for param in data.get("parameters", []) ), f"{recipe_file} uses {{{{args}}}} but does not declare args" + + +class TestGooseCommandPlaceholderResolution: + """register_commands must resolve skill placeholders for the yaml branch. + + The yaml (Goose recipe) branch previously skipped + resolve_skill_placeholders / _convert_argument_placeholder that the + markdown and toml branches apply, so extension/preset command bodies + kept literal {SCRIPT} / __AGENT__ / repo-relative paths. + """ + + def test_register_commands_resolves_placeholders_in_recipe(self, tmp_path): + from specify_cli.agents import CommandRegistrar + + ext_dir = tmp_path / "extension" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True) + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Placeholder command\n" + "scripts:\n" + " sh: scripts/bash/do.sh\n" + " ps: scripts/powershell/do.ps1\n" + "---\n\n" + "Run {SCRIPT} for agent __AGENT__ with $ARGUMENTS.\n", + encoding="utf-8", + ) + + registrar = CommandRegistrar() + commands = [{"name": "speckit.example", "file": "commands/example.md"}] + registrar.register_commands("goose", commands, "test-ext", ext_dir, tmp_path) + + recipe = tmp_path / ".goose" / "recipes" / "speckit.example.yaml" + assert recipe.exists(), "goose recipe should be generated" + # Parse the recipe and assert the prompt actually got the correct + # replacements — not merely that the literal tokens are absent (which + # a wrong-but-token-free output could also satisfy). + data = yaml.safe_load(recipe.read_text(encoding="utf-8")) + prompt = data["prompt"] + assert ".specify/scripts/" in prompt # {SCRIPT} -> resolved script path + assert "agent goose" in prompt # __AGENT__ -> agent name + assert "{{args}}" in prompt # $ARGUMENTS -> goose args token + # And the raw placeholders must not survive. + assert "{SCRIPT}" not in prompt + assert "__AGENT__" not in prompt + assert "$ARGUMENTS" not in prompt From 892dd656f25ab34204d2516e3555d51be10b898f Mon Sep 17 00:00:00 2001 From: Ali jawwad <33836051+jawwad-ali@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:49:29 +0500 Subject: [PATCH 12/16] fix(shared-infra): refresh_shared_templates preserves recovered user files (#3378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(shared-infra): refresh_shared_templates preserves recovered user files refresh_shared_templates skipped a shared template only when it was untracked or modified, ignoring the manifest's is_recovered marker that install_shared_infra already honors. So a pre-existing user template (adopted via record_existing(recovered=True), hence tracked and hash-unmodified) was silently overwritten with bundled content on refresh — the exact data-loss class that #2918 fixed for install_shared_infra. Add the is_recovered check to the skip predicate. Co-Authored-By: Claude Fable 5 * docs(shared-infra): include recovered files in refresh skip warning The skip predicate now also skips recovered (pre-existing user) files, so the warning saying only 'modified or untracked' could mislead a user into thinking they edited a file that was simply recorded as recovered. Reword to 'modified, untracked, or preserved (recovered)'. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/specify_cli/shared_infra.py | 7 ++++-- tests/integrations/test_cli.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 0685b6c9b..581abfb51 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -328,7 +328,10 @@ def refresh_shared_templates( _ensure_safe_shared_destination(project_path, dst) rel = dst.relative_to(project_path).as_posix() if dst.exists() and not force: - if rel not in tracked_files or rel in modified: + if rel not in tracked_files or rel in modified or manifest.is_recovered(rel): + # Never overwrite a recovered (pre-existing user) file without + # --force, matching install_shared_infra's is_recovered gate + # (#2918). Without this, refresh clobbers user content. skipped_files.append(rel) continue @@ -344,7 +347,7 @@ def refresh_shared_templates( if skipped_files: console.print( - f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:" + f"[yellow]⚠[/yellow] {len(skipped_files)} modified, untracked, or preserved (recovered) shared template file(s) were not updated:" ) for rel in skipped_files: console.print(f" {rel}") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed978cbb5..cb2d136e6 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -2073,3 +2073,44 @@ class TestIntegrationCatalogDiscoveryCLI: assert listing.exit_code == 0, listing.output assert "default" in listing.output assert "community" in listing.output + + +def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path): + """refresh_shared_templates must not overwrite a recovered (pre-existing + user) template without --force, matching install_shared_infra's gate (#2918). + """ + from specify_cli.shared_infra import ( + load_speckit_manifest, + refresh_shared_templates, + ) + + project = tmp_path / "proj" + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + user_file = templates_dir / "spec-template.md" + user_file.write_text("# USER CUSTOM CONTENT\n", encoding="utf-8") + + # Record the pre-existing file as recovered (its hash was adopted, not written). + manifest = load_speckit_manifest(project, version="test", console=_NoopConsole()) + rel = ".specify/templates/spec-template.md" + manifest.record_existing(rel, recovered=True) + manifest.save() + + # Bundled source ships a different body for the same template. + core_pack = tmp_path / "core-pack" + src = core_pack / "templates" + src.mkdir(parents=True) + (src / "spec-template.md").write_text("# BUNDLED CONTENT v2\n", encoding="utf-8") + + refresh_shared_templates( + project, + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + invoke_separator=".", + force=False, + ) + + # Recovered user content must survive (fail-before: replaced by bundled body). + assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n" From 8eadcd762446e58bd7f5f00601a1c128fecd090b Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 9 Jul 2026 18:08:58 +0500 Subject: [PATCH 13/16] fix(scripts): resolve invoke_separator by parse success, not python3 availability (#3304) (#3320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scripts): resolve invoke_separator by parse success, add awk fallback (#3304) `get_invoke_separator` in common.sh selected its JSON parser by tool *availability* (`command -v python3`) rather than parse *success*, and had no text fallback after the python3 branch. On stock Windows + Git Bash — no jq, and `python3` resolving to the Microsoft Store App Execution Alias stub that passes `command -v` but exits 49 at runtime — it silently fell back to "." even for `-`-separator integrations (e.g. forge, cline). The observable result was wrong command hints in error messages, such as `/speckit.plan` instead of `/speckit-plan`, in check-prerequisites.sh and setup-tasks.sh. This is the same existence-vs-runtime pattern fixed for the feature.json parser in #3304's primary report; the reporter explicitly asked that other python3 call sites be checked. The two remaining sites (resolve_template, resolve_template_content) already fall through on failure and are unaffected. - Restructure the jq -> python3 chain to fall through on parse failure, gated on a `parsed` success flag rather than exclusive elif branches. - Make the python3 branch signal failure (sys.exit(1)) instead of printing "." so a stub failure falls through instead of being accepted. - Add an awk text fallback (portable, no gawk-only whole-file slurp) that reads the active integration key and its invoke_separator, handling both pretty-printed (the written form) and compact JSON. Malformed input safely defaults to ".". Regression test simulates the broken-stub environment (jq + python3 stubs that exit 49 on PATH) and asserts the `-` separator is still recovered. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(scripts): close awk END block so invoke_separator fallback works The awk text fallback in get_invoke_separator was missing the closing brace for its END block, so awk aborted with a syntax error on every invocation and the separator silently stayed at the default '.'. On environments with neither jq nor a working python3 (stock Windows + Git Bash, the exact case this fallback exists for) a '-'-separator integration like forge produced a wrong command hint. The bug escaped CI because the test that exercises it is gated on working bash and skips on the Windows dev shell. Also harden the fallback per review: use the portable '[-.]' character class (a leading '-' inside '[]' can be read as an ill-defined range on some awk builds), and correct the test docstring, which said 'with no jq' though the helper installs a present-but-failing jq stub. Addresses Copilot review feedback on PR #3320. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/bash/common.sh | 77 +++++++++++++++++++++++++++++++++------ tests/test_setup_tasks.py | 62 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index fd2f05f82..dc60f9ff5 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -244,21 +244,29 @@ get_invoke_separator() { local integration_json="$repo_root/.specify/integration.json" local separator="." - local parsed_with_jq=0 + local parsed=0 if [[ -f "$integration_json" ]]; then + # Try parsers in order (jq -> python3 -> awk), falling through on + # failure. Selection is by *parse success*, not mere availability: on + # Windows `python3` commonly resolves to the Microsoft Store App + # Execution Alias stub, which passes `command -v` but fails at runtime + # (exit 49). An availability-gated branch would pick python3, swallow + # its failure, and — because this function historically had no text + # fallback — silently return "." even for `-`-separator integrations + # (e.g. forge, cline), yielding wrong command hints (issue #3304). if command -v jq >/dev/null 2>&1; then local jq_separator if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then - parsed_with_jq=1 case "$jq_separator" in - "."|"-") separator="$jq_separator" ;; + "."|"-") separator="$jq_separator"; parsed=1 ;; esac fi fi - if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then - if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null + if [[ "$parsed" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then + local py_separator + if py_separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null import json import sys @@ -274,17 +282,64 @@ try: separator = entry["invoke_separator"] print(separator) except Exception: - print(".") + sys.exit(1) PY ); then - case "$separator" in - "."|"-") ;; - *) separator="." ;; + case "$py_separator" in + "."|"-") separator="$py_separator"; parsed=1 ;; esac - else - separator="." fi fi + + if [[ "$parsed" -eq 0 ]]; then + # Last-resort text fallback for environments with neither jq nor a + # working python3 (e.g. stock Windows + Git Bash). Reads the active + # integration key (default_integration, else integration) and its + # invoke_separator from within the integration_settings object. + # Handles both pretty-printed (the written form) and compact JSON. + # Accumulate all lines into one buffer in END rather than using + # gawk-only whole-file slurp (RS="^$"), so this stays portable to + # the BSD awk on macOS. + local awk_separator + awk_separator=$(awk ' + function keyval(d, name, v) { + if (match(d, "\"" name "\"[ \t\r\n]*:[ \t\r\n]*\"[^\"]*\"")) { + v=substr(d,RSTART,RLENGTH); sub(/^.*:[ \t\r\n]*"/,"",v); sub(/"$/,"",v); return v + } + return "" + } + { doc = doc $0 "\n" } + END { + key=keyval(doc,"default_integration"); if (key=="") key=keyval(doc,"integration") + sep="." + if (key!="") { + settings=doc + if (match(doc, /"integration_settings"[ \t\r\n]*:[ \t\r\n]*[{]/)) { + settings=substr(doc, RSTART+RLENGTH-1) + } + if (match(settings, "\"" key "\"[ \t\r\n]*:[ \t\r\n]*[{]")) { + start=RSTART+RLENGTH-1 + depth=0 + obj="" + for (i=start; i<=length(settings); i++) { + c=substr(settings,i,1) + obj=obj c + if (c=="{") depth++ + else if (c=="}") { depth--; if (depth==0) break } + } + if (match(obj, /"invoke_separator"[ \t\r\n]*:[ \t\r\n]*"[-.]"/)) { + tok=substr(obj,RSTART,RLENGTH); s=substr(tok,length(tok)-1,1) + if (s=="." || s=="-") sep=s + } + } + } + print sep + } + ' "$integration_json" 2>/dev/null) + case "$awk_separator" in + "."|"-") separator="$awk_separator" ;; + esac + fi fi _SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root" diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 47a284f8a..4551b5c29 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -466,6 +466,68 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) - assert result.stdout.strip() == "/speckit.jira.sync-status" +def _install_broken_json_tool_stubs(repo: Path) -> Path: + """Create a bin dir with `jq` and `python3` stubs that exist but fail. + + Mimics stock Windows + Git Bash, where a JSON tool may be missing or broken + and `python3` resolves to the Microsoft Store App Execution Alias stub: both + satisfy `command -v` yet fail at runtime (the alias exits 49). Prepending + this to PATH forces the invoke-separator parser past jq and python3 to its + awk text fallback (#3304). + """ + stub_dir = repo / "_broken_bin" + stub_dir.mkdir(exist_ok=True) + for name in ("jq", "python3"): + stub = stub_dir / name + stub.write_text( + "#!/bin/sh\n" + 'echo "simulated broken interpreter/tool" >&2\n' + "exit 49\n", + encoding="utf-8", + newline="\n", + ) + stub.chmod(0o755) + return stub_dir + + +@requires_bash +def test_bash_command_hint_falls_back_to_awk_when_jq_and_python3_broken( + tasks_repo: Path, +) -> None: + """Separator resolution survives broken jq and python3 stubs (#3304). + + `get_invoke_separator` historically selected python3 by availability and + had no text fallback, so a Windows Store python3 stub made it silently + return "." even for `-`-separator integrations (e.g. forge), yielding a + wrong hint like `/speckit.plan`. The awk fallback must recover `-`. + """ + _write_integration_state(tasks_repo, "forge", "-") + stub_dir = _install_broken_json_tool_stubs(tasks_repo) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh" + env = _clean_env() + env["PATH"] = f"{stub_dir}{os.pathsep}{env.get('PATH', '')}" + + result = subprocess.run( + [ + "bash", + "-c", + 'source "$1"; format_speckit_command "$2" "$PWD"', + "bash", + str(script), + "plan", + ], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=env, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "/speckit-plan" + + @requires_bash def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None: _write_integration_state(tasks_repo, "claude", "-") From a4d94309e01b3577ecebb301ee03561cde2f571b Mon Sep 17 00:00:00 2001 From: Noor ul ain Date: Thu, 9 Jul 2026 18:10:43 +0500 Subject: [PATCH 14/16] fix(workflows): apply chained expression filters left-to-right (#3339) * fix(workflows): apply chained expression filters left-to-right The pipe-filter parser in `_evaluate_simple_expression` split the expression only at the *first* top-level `|` and treated the whole remainder as a single filter. So a filter chain like `{{ inputs.rows | map('name') | join(', ') }}` handed `map('name') | join(', ')` to one filter, where the `(\w+)\((.+)\)` regex mangled it and raised `ValueError`. This broke the canonical use of `map`: it returns a list, and `join` is the only filter that renders a list to a string, so the two are meant to be chained. Chaining was impossible for every registered filter. Split the pipe segments at the top level (quote/bracket aware, so a literal `|` inside a quoted argument like `join(' | ')` is preserved) and fold each filter over the running value. The single-filter logic is extracted verbatim into `_apply_filter`, so all existing strict handling (`from_json` arity, unsupported-form vs unknown-filter messages) is unchanged and now applies to every link in the chain. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/expressions.py | 148 ++++++++++++++--------- tests/test_workflows.py | 67 ++++++++++ 2 files changed, 158 insertions(+), 57 deletions(-) diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 2b7ceca15..d6736bc32 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -242,6 +242,26 @@ def _interpolate_expressions(template: str, namespace: dict[str, Any]) -> str: return "".join(out) +def _split_top_level(text: str, sep: str) -> list[str]: + """Split *text* on each occurrence of *sep* that lies outside any quoted + string or nested brackets. + + Used to break a filter chain (``a | map('x') | join(',')``) into its + individual filter segments without splitting on a ``|`` that appears inside + a quoted argument. Each returned segment is a slice at a top-level + boundary, so the quote/bracket scan restarts cleanly on the remainder. + """ + parts: list[str] = [] + start = 0 + while True: + idx = _find_top_level(text[start:], sep) + if idx == -1: + parts.append(text[start:]) + return parts + parts.append(text[start:start + idx]) + start += idx + len(sep) + + def _split_top_level_commas(text: str) -> list[str]: """Split *text* on commas that are not inside quotes or nested brackets. @@ -305,6 +325,68 @@ def _find_top_level(text: str, token: str) -> int: return -1 +def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> Any: + """Apply a single pipe filter segment to *value*. + + *filter_expr* is one link of a filter chain — the text between two + top-level ``|`` separators, already stripped (e.g. ``map('name')``, + ``default('x')``, ``from_json``). Returns the filtered value so the caller + can feed it into the next link. + + Raises ``ValueError`` on any mis-wired or unknown filter rather than + silently returning *value* unchanged: a passthrough would turn a mistyped + or unsupported filter into a wrong result with no signal. + """ + # `from_json` is strict: it takes no arguments and tolerates no trailing + # tokens. Match on the leading filter name and require the whole filter to + # be exactly `from_json`, so every mis-wired form (`from_json()`, + # `from_json('x')`, `from_json)`, `from_json extra`) fails loudly instead of + # silently falling through to the unknown-filter path. + leading = re.match(r"\w+", filter_expr) + if leading and leading.group(0) == "from_json": + if filter_expr != "from_json": + raise ValueError( + "from_json: expected '| from_json' with no arguments or " + f"trailing tokens, got '| {filter_expr}'" + ) + return _filter_from_json(value) + + # Parse filter name and argument + filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) + if filter_match: + fname = filter_match.group(1) + farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) + if fname == "default": + return _filter_default(value, farg) + if fname == "join": + return _filter_join(value, farg) + if fname == "map": + return _filter_map(value, farg) + if fname == "contains": + return _filter_contains(value, farg) + # Filter without args + if filter_expr == "default": + return _filter_default(value) + # No recognized filter matched. Fail loudly rather than silently returning + # the unfiltered value. Distinguish a *registered* filter used in an + # unsupported form (e.g. `| join` or `| map` with no argument) from a + # genuinely unknown filter name, so the message names the real problem + # instead of calling a known filter "unknown". + name = leading.group(0) if leading else filter_expr + expected = ( + "expected one of default or default('x'), join('sep'), " + "map('attr'), contains('s'), or from_json" + ) + if name in _REGISTERED_FILTERS: + raise ValueError( + f"filter '{name}' used in an unsupported form (got " + f"'| {filter_expr}'): {expected}" + ) + raise ValueError( + f"unknown filter '{name}': {expected} (got '| {filter_expr}')" + ) + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -329,65 +411,17 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: # Handle pipe filters. Detect the pipe at the top level only, so a literal # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is # not mistaken for a filter separator — mirroring the operator parsing below. + # Filters chain left-to-right: `list | map('name') | join(', ')` feeds each + # filter's result into the next, so `map` (which yields a list) can be + # rendered by `join`. Splitting only at the first pipe would hand the whole + # tail to one filter and mangle any later `|`. pipe_idx = _find_top_level(expr, "|") if pipe_idx != -1: - value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) - filter_expr = expr[pipe_idx + 1:].strip() - - # `from_json` is strict: it takes no arguments and tolerates no - # trailing tokens. Match on the leading filter name and require the - # whole filter to be exactly `from_json`, so every mis-wired form - # (`from_json()`, `from_json('x')`, `from_json)`, `from_json extra`) - # fails loudly instead of silently falling through to the - # unknown-filter path and returning the unparsed value. (filter_expr - # is already stripped above.) - leading = re.match(r"\w+", filter_expr) - if leading and leading.group(0) == "from_json": - if filter_expr != "from_json": - raise ValueError( - "from_json: expected '| from_json' with no arguments or " - f"trailing tokens, got '| {filter_expr}'" - ) - return _filter_from_json(value) - - # Parse filter name and argument - filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) - if filter_match: - fname = filter_match.group(1) - farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) - if fname == "default": - return _filter_default(value, farg) - if fname == "join": - return _filter_join(value, farg) - if fname == "map": - return _filter_map(value, farg) - if fname == "contains": - return _filter_contains(value, farg) - # Filter without args - filter_name = filter_expr.strip() - if filter_name == "default": - return _filter_default(value) - # No recognized filter matched. Fail loudly rather than silently - # returning the unfiltered value: a passthrough turns a mis-typed or - # unsupported filter into a wrong result with no signal. Mirrors the - # strict `from_json` handling above. Distinguish a *registered* filter - # used in an unsupported form (e.g. `| join` or `| map` with no - # argument) from a genuinely unknown filter name, so the message names - # the real problem instead of calling a known filter "unknown". - leading_name = re.match(r"\w+", filter_expr) - name = leading_name.group(0) if leading_name else filter_expr - expected = ( - "expected one of default or default('x'), join('sep'), " - "map('attr'), contains('s'), or from_json" - ) - if name in _REGISTERED_FILTERS: - raise ValueError( - f"filter '{name}' used in an unsupported form (got " - f"'| {filter_expr}'): {expected}" - ) - raise ValueError( - f"unknown filter '{name}': {expected} (got '| {filter_expr}')" - ) + segments = _split_top_level(expr, "|") + value = _evaluate_simple_expression(segments[0].strip(), namespace) + for segment in segments[1:]: + value = _apply_filter(value, segment.strip(), namespace) + return value # Boolean operators — parse 'or' first (lower precedence) so that # 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 41522d3e8..9341bdec4 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -601,6 +601,73 @@ class TestExpressions: ): evaluate_expression("{{ inputs.tags | map }}", ctx) + def test_chained_filters_apply_left_to_right(self): + # Filters chain: each filter's result feeds the next. `map` yields a + # list and `join` is the only filter that renders a list to a string, + # so `map('name') | join(', ')` is the canonical pairing — it must not + # raise. Previously the pipe parser split only at the first `|` and + # handed the whole tail (`map('name') | join(', ')`) to one filter, + # which the `name(arg)` regex mangled into a ValueError. + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + inputs={ + "rows": [{"name": "a"}, {"name": "b"}], + "tags": ["x", "y"], + "missing": None, + } + ) + assert ( + evaluate_expression( + "{{ inputs.rows | map('name') | join(', ') }}", ctx + ) + == "a, b" + ) + # A three-link chain: map -> join -> contains. + assert ( + evaluate_expression( + "{{ inputs.rows | map('name') | join(', ') | contains('a') }}", + ctx, + ) + is True + ) + # default's fallback then flows into the next filter. + assert ( + evaluate_expression( + "{{ inputs.missing | default('x') | contains('x') }}", ctx + ) + is True + ) + + def test_chained_filter_error_in_later_link_raises(self): + # A mis-wired filter anywhere in the chain must fail loudly, not just + # the first link. + import pytest + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"rows": [{"name": "a"}]}) + with pytest.raises(ValueError, match="unknown filter 'bogus'"): + evaluate_expression( + "{{ inputs.rows | map('name') | bogus }}", ctx + ) + + def test_pipe_in_quoted_arg_is_not_a_filter_separator(self): + # A literal `|` inside a quoted operand or filter argument must not be + # mistaken for a filter-chain separator — the top-level split has to + # respect quotes. + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"mode": "a|b", "tags": ["a|b", "c"]}) + assert evaluate_expression("{{ inputs.mode == 'a|b' }}", ctx) is True + # `|` inside a filter argument stays part of the argument. + assert ( + evaluate_expression("{{ inputs.tags | join(' | ') }}", ctx) + == "a|b | c" + ) + def test_condition_evaluation(self): from specify_cli.workflows.expressions import evaluate_condition from specify_cli.workflows.base import StepContext From 8e2e2d2f251f786d6fb4aa5a65980fb1254167d3 Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:13:27 +0200 Subject: [PATCH 15/16] fix(integrations): escape control characters in SKILL.md frontmatter (#3399) yaml.safe_dump with default_style='"' replaces the hand-rolled quote helpers in base.py and hermes, so newlines and control characters in template descriptions round-trip instead of producing unparseable YAML. Fixes #3391 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 32 +++-- .../integrations/hermes/__init__.py | 20 ++-- .../test_skill_frontmatter_quoting.py | 111 ++++++++++++++++++ 3 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 tests/integrations/test_skill_frontmatter_quoting.py diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 4eb9bb19a..8cc318064 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -54,6 +54,18 @@ _CORE_COMMAND_TEMPLATE_RANK = { } +def yaml_quote(value: str) -> str: + """Emit *value* as a double-quoted YAML scalar on a single line. + + A hand-rolled quote cannot carry raw newlines (YAML folds them to + spaces) or control characters (the reader rejects them), so let the + YAML emitter produce the escapes. + """ + return yaml.safe_dump( + str(value), default_style='"', allow_unicode=True, width=sys.maxsize + ).strip() + + # --------------------------------------------------------------------------- # IntegrationOption # --------------------------------------------------------------------------- @@ -1480,21 +1492,17 @@ class SkillsIntegration(IntegrationBase): if not description: description = f"Spec Kit: {command_name} workflow" - # Build SKILL.md with manually formatted frontmatter to match - # the release packaging script output exactly (double-quoted - # values, no yaml.safe_dump quoting differences). - def _quote(v: str) -> str: - escaped = v.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - + # Build SKILL.md with manually formatted frontmatter (stable + # double-quoted values). yaml_quote escapes newlines and control + # characters that a plain quoted f-string cannot carry. skill_content = ( f"---\n" - f"name: {_quote(skill_name)}\n" - f"description: {_quote(description)}\n" - f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"name: {yaml_quote(skill_name)}\n" + f"description: {yaml_quote(description)}\n" + f"compatibility: {yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"metadata:\n" - f" author: {_quote('github-spec-kit')}\n" - f" source: {_quote('templates/commands/' + src_file.name)}\n" + f" author: {yaml_quote('github-spec-kit')}\n" + f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" f"---\n" f"{processed_body}" ) diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 5d1f3a261..63ea5f998 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -18,7 +18,7 @@ from typing import Any import yaml -from ..base import IntegrationOption, SkillsIntegration +from ..base import IntegrationOption, SkillsIntegration, yaml_quote from ..manifest import IntegrationManifest @@ -153,20 +153,18 @@ class HermesIntegration(SkillsIntegration): if not description: description = f"Spec Kit: {command_name} workflow" - # Build SKILL.md with manually formatted frontmatter - def _quote(v: str) -> str: - escaped = v.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - + # Build SKILL.md with manually formatted frontmatter. yaml_quote + # escapes newlines and control characters that a plain quoted + # f-string cannot carry. skill_content = ( f"---\n" - f"name: {_quote(skill_name)}\n" - f"description: {_quote(description)}\n" + f"name: {yaml_quote(skill_name)}\n" + f"description: {yaml_quote(description)}\n" f"compatibility: " - f"{_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"{yaml_quote('Requires spec-kit project structure with .specify/ directory')}\n" f"metadata:\n" - f" author: {_quote('github-spec-kit')}\n" - f" source: {_quote('templates/commands/' + src_file.name)}\n" + f" author: {yaml_quote('github-spec-kit')}\n" + f" source: {yaml_quote('templates/commands/' + src_file.name)}\n" f"---\n" f"{processed_body}" ) diff --git a/tests/integrations/test_skill_frontmatter_quoting.py b/tests/integrations/test_skill_frontmatter_quoting.py new file mode 100644 index 000000000..739039ea3 --- /dev/null +++ b/tests/integrations/test_skill_frontmatter_quoting.py @@ -0,0 +1,111 @@ +"""Regression tests for SKILL.md frontmatter quoting (#3391). + +The skills setup path builds SKILL.md frontmatter by hand with +double-quoted values. A double-quoted YAML scalar cannot carry a raw +newline (the parser folds it to a space) or a control character (the +reader rejects the document), so descriptions taken from template +frontmatter must be escaped by the YAML emitter. +""" + +from pathlib import Path + +import yaml + +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import yaml_quote +from specify_cli.integrations.manifest import IntegrationManifest + +MULTILINE = "first line\nsecond line\n" +CONTROL = "ding\aling" + +HOSTILE_TEMPLATE = """--- +description: | + first line + second line +--- + +Body of the command. +""" + +CONTROL_TEMPLATE = """--- +description: "ding\\aling" +--- + +Body of the command. +""" + + +def _parse_frontmatter(skill_file: Path) -> dict: + content = skill_file.read_text(encoding="utf-8") + assert content.startswith("---\n") + return yaml.safe_load(content.split("---", 2)[1]) + + +def _fake_templates(tmp_path: Path, body: str) -> Path: + templates = tmp_path / "templates" + templates.mkdir(exist_ok=True) + (templates / "plan.md").write_text(body, encoding="utf-8") + return templates + + +class TestYamlQuote: + def test_simple_value_keeps_plain_double_quoted_form(self): + assert yaml_quote("speckit-plan") == '"speckit-plan"' + assert yaml_quote('say "hi"') == '"say \\"hi\\""' + assert yaml_quote("back\\slash") == '"back\\\\slash"' + + def test_multiline_value_round_trips(self): + quoted = yaml_quote(MULTILINE) + assert "\n" not in quoted + assert yaml.safe_load(quoted) == MULTILINE + + def test_control_character_round_trips(self): + quoted = yaml_quote(CONTROL) + assert "\a" not in quoted + assert yaml.safe_load(quoted) == CONTROL + + +class TestSkillFrontmatterQuoting: + def _generate(self, tmp_path, monkeypatch, template: str) -> Path: + integration = get_integration("agy") + monkeypatch.setattr( + integration, + "shared_commands_dir", + lambda: _fake_templates(tmp_path, template), + ) + manifest = IntegrationManifest("agy", tmp_path) + created = integration.setup(tmp_path, manifest) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) == 1 + return skill_files[0] + + def test_multiline_description_survives(self, tmp_path, monkeypatch): + skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE) + fm = _parse_frontmatter(skill_file) + assert fm["description"] == MULTILINE + + def test_control_character_description_parses(self, tmp_path, monkeypatch): + skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE) + fm = _parse_frontmatter(skill_file) + assert fm["description"] == CONTROL + + +class TestHermesSkillFrontmatterQuoting: + def test_multiline_description_survives(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir(exist_ok=True) + monkeypatch.setattr(Path, "home", lambda: home) + + integration = get_integration("hermes") + monkeypatch.setattr( + integration, + "shared_commands_dir", + lambda: _fake_templates(tmp_path, HOSTILE_TEMPLATE), + ) + manifest = IntegrationManifest("hermes", tmp_path) + created = integration.setup(tmp_path, manifest) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) == 1 + + fm = _parse_frontmatter(skill_files[0]) + assert fm["description"] == MULTILINE From 15ac745e8d7034bea1a95fe56b461a7e6952d88a Mon Sep 17 00:00:00 2001 From: Marsel Safin <179933638+marcelsafin@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:29 +0200 Subject: [PATCH 16/16] fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter (#3385) * fix(integrations): skip Windows Store python3 alias stub in resolve_python_interpreter On stock Windows, python3 on PATH is the Microsoft Store App Execution Alias stub: it exists but only prints an installer hint and exits non-zero, so generated {SCRIPT} invocations for the py script type were broken. Verify the found interpreter actually runs before accepting it, on Windows only, mirroring the parse-success-not-availability approach of #3312/#3320 for the sh scripts. POSIX keeps the plain existence check. sys.executable remains the fallback and is always live. Fixes #3383 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(integrations): probe interpreter isolated and without site, discard I/O Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: pin POSIX platform in PATH-resolution tests The tests fake shutil.which with POSIX paths; on Windows CI the real sys.platform made the stub probe run against those fake paths and fall through to sys.executable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/base.py | 38 +++++++++++++-- tests/integrations/test_base.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 8cc318064..bfbb81b85 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -17,6 +17,7 @@ import os import re import shlex import shutil +import subprocess import sys from abc import ABC from dataclasses import dataclass @@ -604,10 +605,42 @@ class IntegrationBase(ABC): if candidate.exists(): return relative for name in ("python3", "python"): - if shutil.which(name): - return name + found = shutil.which(name) + if not found: + continue + # On Windows, python3/python on PATH may be the Microsoft + # Store App Execution Alias stub: it exists but only prints + # an installer hint and exits non-zero, so existence is not + # enough (see #3304 for the same defect in the sh scripts). + if sys.platform == "win32" and not IntegrationBase._interpreter_runs( + found + ): + continue + return name return sys.executable or "python3" + @staticmethod + def _interpreter_runs(path: str) -> bool: + """Return True when *path* executes as a Python interpreter. + + Runs isolated (``-I``) without ``site`` (``-S``) and discards + I/O so the probe is a fast liveness check that cannot trigger + ``sitecustomize``/user startup hooks. + """ + try: + return ( + subprocess.run( + [path, "-I", "-S", "-c", ""], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + @staticmethod def process_template( content: str, @@ -1089,7 +1122,6 @@ class TomlIntegration(IntegrationBase): # YamlIntegration — YAML-format agents (Goose) # --------------------------------------------------------------------------- - class YamlIntegration(IntegrationBase): """Concrete base for integrations that use YAML recipe format. diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index fe531e624..d03ea0cb2 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -306,9 +306,12 @@ class TestResolveCommandRefs: class TestResolvePythonInterpreter: def test_returns_python_on_path(self, monkeypatch): # Positive: when python3 is on PATH it is preferred over python. + # Pin a POSIX platform so the Windows stub probe (tested separately + # below) does not reject the fake PATH entries on Windows CI. def fake_which(name): return f"/usr/bin/{name}" if name in ("python3", "python") else None + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) @@ -318,6 +321,7 @@ class TestResolvePythonInterpreter: def fake_which(name): return "/usr/bin/python" if name == "python" else None + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", fake_which ) @@ -369,12 +373,79 @@ class TestResolvePythonInterpreter: def test_ignores_missing_venv(self, monkeypatch, tmp_path): # Negative: no venv directory -> PATH resolution is used instead. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None, ) assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3" + def test_windows_skips_store_alias_stub(self, monkeypatch): + # On Windows, python3 on PATH may be the Microsoft Store App + # Execution Alias stub: it exists but only prints an installer + # hint and exits non-zero. Existence is not enough; the + # interpreter must actually run (mirrors #3304 for the CLI). + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\WindowsApps\\{name}.exe" + if name in ("python3", "python") + else None, + ) + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False) + ) + monkeypatch.setattr( + "specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe" + ) + result = IntegrationBase.resolve_python_interpreter() + assert result == "C:\\Python\\python.exe" + + def test_windows_keeps_working_interpreter(self, monkeypatch): + # Positive: a real python3 on Windows PATH passes the run check. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None, + ) + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True) + ) + assert IntegrationBase.resolve_python_interpreter() == "python3" + + def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch): + # python3 is the stub but python is a real install: pick python. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: f"C:\\somewhere\\{name}.exe" + if name in ("python3", "python") + else None, + ) + monkeypatch.setattr( + IntegrationBase, + "_interpreter_runs", + staticmethod(lambda path: path.endswith("python.exe")), + ) + assert IntegrationBase.resolve_python_interpreter() == "python" + + def test_posix_does_not_spawn_run_check(self, monkeypatch): + # Non-Windows platforms have no App Execution Alias; existence + # on PATH stays sufficient and no subprocess is spawned. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") + monkeypatch.setattr( + "specify_cli.integrations.base.shutil.which", + lambda name: "/usr/bin/python3" if name == "python3" else None, + ) + + def boom(path): + raise AssertionError("run check must not execute on POSIX") + + monkeypatch.setattr( + IntegrationBase, "_interpreter_runs", staticmethod(boom) + ) + assert IntegrationBase.resolve_python_interpreter() == "python3" + class TestProcessTemplatePyScriptType: CONTENT = ( @@ -390,6 +461,7 @@ class TestProcessTemplatePyScriptType: def test_py_prefixes_interpreter(self, monkeypatch): # Positive: py script type prefixes a resolved interpreter and the # script path is rewritten to the .specify location. + monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux") monkeypatch.setattr( "specify_cli.integrations.base.shutil.which", lambda name: "/usr/bin/python3" if name == "python3" else None,