From e8b21fed9fa02c29697ad57eb481975cb0a67e18 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Apr 2026 15:26:17 +0500 Subject: [PATCH 1/8] feat: add argument-hint frontmatter to Claude Code commands (#1951) Inject argument-hint into YAML frontmatter for Claude agent only during release package generation. Templates remain agent-agnostic; hints are added on the fly in generate_commands() when agent is "claude". Closes #1951 Co-Authored-By: Claude Sonnet 4.6 --- .../scripts/create-release-packages.ps1 | 19 ++++++++++++++++ .../scripts/create-release-packages.sh | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index 912dd00ecb..e2d60106e5 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -154,6 +154,25 @@ function Generate-Commands { $body = $outputLines -join "`n" + # Inject argument-hint for Claude Code commands (Claude-specific frontmatter) + if ($Agent -eq 'claude') { + $hint = switch ($name) { + 'specify' { 'Describe the feature you want to specify' } + 'plan' { 'Optional guidance for the planning phase' } + 'tasks' { 'Optional task generation constraints' } + 'implement' { 'Optional implementation guidance or task filter' } + 'analyze' { 'Optional focus areas for analysis' } + 'clarify' { 'Optional areas to clarify in the spec' } + 'constitution' { 'Principles or values for the project constitution' } + 'checklist' { 'Domain or focus area for the checklist' } + 'taskstoissues' { 'Optional filter or label for GitHub issues' } + default { '' } + } + if (-not [string]::IsNullOrEmpty($hint)) { + $body = $body -replace '(?m)^(description:.*)$', "`$1`nargument-hint: $hint" + } + } + # Apply other substitutions $body = $body -replace '\{ARGS\}', $ArgFormat $body = $body -replace '__AGENT__', $Agent diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index a83494c3a0..d058145a26 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -105,6 +105,28 @@ generate_commands() { { print } ') + # Inject argument-hint for Claude Code commands (Claude-specific frontmatter) + if [[ "$agent" == "claude" ]]; then + local hint="" + case "$name" in + specify) hint="Describe the feature you want to specify" ;; + plan) hint="Optional guidance for the planning phase" ;; + tasks) hint="Optional task generation constraints" ;; + implement) hint="Optional implementation guidance or task filter" ;; + analyze) hint="Optional focus areas for analysis" ;; + clarify) hint="Optional areas to clarify in the spec" ;; + constitution) hint="Principles or values for the project constitution" ;; + checklist) hint="Domain or focus area for the checklist" ;; + taskstoissues) hint="Optional filter or label for GitHub issues" ;; + esac + if [[ -n "$hint" ]]; then + body=$(printf '%s\n' "$body" | awk -v hint="$hint" ' + /^description:/ { print; print "argument-hint: " hint; next } + { print } + ') + fi + fi + # Apply other substitutions body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths) From e8d0a95145fab4eaa1228452f94bd7a95b423bc3 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Apr 2026 18:46:38 +0500 Subject: [PATCH 2/8] fix: scope argument-hint injection to YAML frontmatter only Addresses Copilot review: the awk/regex matched description: anywhere in the file. Now both bash and PowerShell track frontmatter boundaries (--- delimiters) and only inject argument-hint after the first description: inside the frontmatter block. Co-Authored-By: Claude Sonnet 4.6 --- .../scripts/create-release-packages.ps1 | 23 ++++++++++++++++++- .../scripts/create-release-packages.sh | 9 +++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 index e2d60106e5..9b3c373aa7 100644 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ b/.github/workflows/scripts/create-release-packages.ps1 @@ -169,7 +169,28 @@ function Generate-Commands { default { '' } } if (-not [string]::IsNullOrEmpty($hint)) { - $body = $body -replace '(?m)^(description:.*)$', "`$1`nargument-hint: $hint" + # Scope injection to YAML frontmatter only (between first pair of ---) + $bodyLines = $body -split "`n" + $resultLines = @() + $fmDashCount = 0 + $inFm = $false + $hintInjected = $false + foreach ($ln in $bodyLines) { + if ($ln -match '^---$') { + $resultLines += $ln + $fmDashCount++ + $inFm = ($fmDashCount -eq 1) + continue + } + if ($inFm -and -not $hintInjected -and $ln -match '^description:') { + $resultLines += $ln + $resultLines += "argument-hint: $hint" + $hintInjected = $true + continue + } + $resultLines += $ln + } + $body = $resultLines -join "`n" } } diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index d058145a26..18fa9dd124 100755 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -121,7 +121,14 @@ generate_commands() { esac if [[ -n "$hint" ]]; then body=$(printf '%s\n' "$body" | awk -v hint="$hint" ' - /^description:/ { print; print "argument-hint: " hint; next } + /^---$/ { + print + if (++dash_count == 1) { in_fm = 1 } else { in_fm = 0 } + next + } + in_fm && !injected && /^description:/ { + print; print "argument-hint: " hint; injected = 1; next + } { print } ') fi From ffa694638b9b46ce913e9234165298b215aec6b6 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Apr 2026 18:51:40 +0500 Subject: [PATCH 3/8] feat: add argument-hint to Claude integration + tests - Override setup() in ClaudeIntegration to inject argument-hint into YAML frontmatter after description: line, scoped to frontmatter only - Add ARGUMENT_HINTS mapping for all 9 commands - Add tests: hint presence, correct values, frontmatter scoping, ordering after description, and body-safety check Addresses maintainer feedback to cover the new integrations system in src/specify_cli/integrations/claude/__init__.py with tests in tests/integrations/test_integration_claude.py Co-Authored-By: Claude Sonnet 4.6 --- .../integrations/claude/__init__.py | 98 +++++++++++++++++++ tests/integrations/test_integration_claude.py | 95 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 00375ead51..3280a1f50b 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -1,7 +1,30 @@ """Claude Code integration.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, TYPE_CHECKING + from ..base import MarkdownIntegration +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + +# Mapping of command template stem → argument-hint text shown inline +# when a user invokes the slash command in Claude Code. +ARGUMENT_HINTS: dict[str, str] = { + "specify": "Describe the feature you want to specify", + "plan": "Optional guidance for the planning phase", + "tasks": "Optional task generation constraints", + "implement": "Optional implementation guidance or task filter", + "analyze": "Optional focus areas for analysis", + "clarify": "Optional areas to clarify in the spec", + "constitution": "Principles or values for the project constitution", + "checklist": "Domain or focus area for the checklist", + "taskstoissues": "Optional filter or label for GitHub issues", +} + class ClaudeIntegration(MarkdownIntegration): key = "claude" @@ -19,3 +42,78 @@ class ClaudeIntegration(MarkdownIntegration): "extension": ".md", } context_file = "CLAUDE.md" + + @staticmethod + def inject_argument_hint(content: str, hint: str) -> str: + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.""" + lines = content.splitlines(keepends=True) + out: list[str] = [] + in_fm = False + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + in_fm = dash_count == 1 + out.append(line) + continue + if in_fm and not injected and stripped.startswith("description:"): + out.append(line) + # Preserve the line-ending style of the file + eol = "\n" if line.endswith("\n") else "" + out.append(f"argument-hint: {hint}{eol}") + injected = True + continue + out.append(line) + return "".join(out) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + + # Inject argument-hint for Claude Code commands + hint = ARGUMENT_HINTS.get(src_file.stem, "") + if hint: + processed = self.inject_argument_hint(processed, hint) + + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 6867a295ea..9e95b1ebd3 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,5 +1,9 @@ """Tests for ClaudeIntegration.""" +from specify_cli.integrations import get_integration +from specify_cli.integrations.claude import ARGUMENT_HINTS +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +13,94 @@ class TestClaudeIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".claude/commands" CONTEXT_FILE = "CLAUDE.md" + + +class TestClaudeArgumentHints: + """Verify that argument-hint frontmatter is injected for Claude commands.""" + + def test_all_commands_have_hints(self, tmp_path): + """Every generated command file must contain an argument-hint line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "argument-hint:" in content, ( + f"{f.name} is missing argument-hint frontmatter" + ) + + def test_hints_match_expected_values(self, tmp_path): + """Each command's argument-hint must match the expected text.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + # Extract stem: speckit.plan.md -> plan + stem = f.name.replace("speckit.", "").replace(".md", "") + expected_hint = ARGUMENT_HINTS.get(stem) + assert expected_hint is not None, ( + f"No expected hint defined for command '{stem}'" + ) + content = f.read_text(encoding="utf-8") + assert f"argument-hint: {expected_hint}" in content, ( + f"{f.name}: expected hint '{expected_hint}' not found" + ) + + def test_hint_is_inside_frontmatter(self, tmp_path): + """argument-hint must appear between the --- delimiters, not in the body.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + assert len(parts) >= 3, f"No frontmatter in {f.name}" + frontmatter = parts[1] + body = parts[2] + assert "argument-hint:" in frontmatter, ( + f"{f.name}: argument-hint not in frontmatter section" + ) + assert "argument-hint:" not in body, ( + f"{f.name}: argument-hint leaked into body" + ) + + def test_hint_appears_after_description(self, tmp_path): + """argument-hint must immediately follow the description line.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + lines = content.splitlines() + for idx, line in enumerate(lines): + if line.startswith("description:"): + assert idx + 1 < len(lines), ( + f"{f.name}: description is last line" + ) + assert lines[idx + 1].startswith("argument-hint:"), ( + f"{f.name}: argument-hint does not follow description" + ) + break + + def test_inject_argument_hint_only_in_frontmatter(self): + """inject_argument_hint must not modify description: lines in the body.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + "---\n" + "\n" + "description: this is body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "Test hint") + lines = result.splitlines() + hint_count = sum(1 for l in lines if l.startswith("argument-hint:")) + assert hint_count == 1, ( + f"Expected exactly 1 argument-hint line, found {hint_count}" + ) From 6e5ba2727efa4272b48cb87d4dd95919cc4b7748 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Apr 2026 19:06:27 +0500 Subject: [PATCH 4/8] fix: address Copilot review feedback on Claude integration - Remove unused `import re` - Skip injection if argument-hint already exists in frontmatter - Add found_description assertion to test_hint_appears_after_description - Add test_inject_argument_hint_skips_if_already_present test Co-Authored-By: Claude Sonnet 4.6 --- .../integrations/claude/__init__.py | 20 +++++++++++++-- tests/integrations/test_integration_claude.py | 25 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 3280a1f50b..92998ce25a 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re from pathlib import Path from typing import Any, TYPE_CHECKING @@ -45,8 +44,25 @@ class ClaudeIntegration(MarkdownIntegration): @staticmethod def inject_argument_hint(content: str, hint: str) -> str: - """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.""" + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. + + Skips injection if ``argument-hint:`` already exists in the + frontmatter to avoid duplicate keys. + """ lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if argument-hint already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("argument-hint:"): + return content # already present + out: list[str] = [] in_fm = False dash_count = 0 diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 9e95b1ebd3..c943c8883f 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -77,8 +77,10 @@ def test_hint_appears_after_description(self, tmp_path): for f in cmd_files: content = f.read_text(encoding="utf-8") lines = content.splitlines() + found_description = False for idx, line in enumerate(lines): if line.startswith("description:"): + found_description = True assert idx + 1 < len(lines), ( f"{f.name}: description is last line" ) @@ -86,6 +88,9 @@ def test_hint_appears_after_description(self, tmp_path): f"{f.name}: argument-hint does not follow description" ) break + assert found_description, ( + f"{f.name}: no description: line found in output" + ) def test_inject_argument_hint_only_in_frontmatter(self): """inject_argument_hint must not modify description: lines in the body.""" @@ -100,7 +105,25 @@ def test_inject_argument_hint_only_in_frontmatter(self): ) result = ClaudeIntegration.inject_argument_hint(content, "Test hint") lines = result.splitlines() - hint_count = sum(1 for l in lines if l.startswith("argument-hint:")) + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1, ( f"Expected exactly 1 argument-hint line, found {hint_count}" ) + + def test_inject_argument_hint_skips_if_already_present(self): + """inject_argument_hint must not duplicate if argument-hint already exists.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\n" + "description: My command\n" + "argument-hint: Existing hint\n" + "---\n" + "\n" + "Body text\n" + ) + result = ClaudeIntegration.inject_argument_hint(content, "New hint") + assert result == content, "Content should be unchanged when hint already exists" + lines = result.splitlines() + hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) + assert hint_count == 1 From bdfab4543fa3329e71b915e696bd8c5dcf5efe6c Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 2 Apr 2026 19:19:23 +0500 Subject: [PATCH 5/8] refactor: delegate to super().setup() and post-process for hints - Eliminates setup() duplication by calling super().setup() then post-processing command files to inject argument-hint - Fixes EOL preservation to correctly detect \r\n vs \n - No drift risk if MarkdownIntegration.setup() changes Co-Authored-By: Claude Sonnet 4.6 --- .../integrations/claude/__init__.py | 80 +++++++++---------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 92998ce25a..6fe27a1ba4 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -76,8 +76,13 @@ def inject_argument_hint(content: str, hint: str) -> str: continue if in_fm and not injected and stripped.startswith("description:"): out.append(line) - # Preserve the line-ending style of the file - eol = "\n" if line.endswith("\n") else "" + # Preserve the exact line-ending style (\r\n vs \n) + if line.endswith("\r\n"): + eol = "\r\n" + elif line.endswith("\n"): + eol = "\n" + else: + eol = "" out.append(f"argument-hint: {hint}{eol}") injected = True continue @@ -91,45 +96,34 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - templates = self.list_command_templates() - if not templates: - return [] - - project_root_resolved = project_root.resolve() - if manifest.project_root != project_root_resolved: - raise ValueError( - f"manifest.project_root ({manifest.project_root}) does not match " - f"project_root ({project_root_resolved})" - ) - - dest = self.commands_dest(project_root).resolve() - try: - dest.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Integration destination {dest} escapes " - f"project root {project_root_resolved}" - ) from exc - dest.mkdir(parents=True, exist_ok=True) - - script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" - created: list[Path] = [] - - for src_file in templates: - raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) - - # Inject argument-hint for Claude Code commands - hint = ARGUMENT_HINTS.get(src_file.stem, "") - if hint: - processed = self.inject_argument_hint(processed, hint) - - dst_name = self.command_filename(src_file.stem) - dst_file = self.write_file_and_record( - processed, dest / dst_name, project_root, manifest - ) - created.append(dst_file) - - created.extend(self.install_scripts(project_root, manifest)) + """Run standard MarkdownIntegration setup, then inject argument-hint.""" + created = super().setup(project_root, manifest, parsed_options, **opts) + + # Post-process generated command files to add argument-hint + commands_dest = self.commands_dest(project_root).resolve() + ext = self.registrar_config.get("extension", ".md") if self.registrar_config else ".md" + + for path in created: + # Only touch command files, not scripts + try: + path.resolve().relative_to(commands_dest) + except ValueError: + continue + if path.suffix != ext: + continue + + # Extract template stem: speckit.plan.md -> plan + stem = path.stem # speckit.plan + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + + hint = ARGUMENT_HINTS.get(stem, "") + if not hint: + continue + + content = path.read_text(encoding="utf-8") + updated = self.inject_argument_hint(content, hint) + if updated != content: + path.write_text(updated, encoding="utf-8") + return created From c9d025c378edbdf3182834e86a7c872764301d84 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 3 Apr 2026 00:17:38 +0500 Subject: [PATCH 6/8] fix: use read_bytes/write_bytes for platform-stable EOL handling Address Copilot review: avoid platform newline translation by using read_bytes()/write_bytes() instead of read_text()/write_text() when post-processing SKILL.md files for argument-hint injection. --- src/specify_cli/integrations/claude/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index be017d7c70..61ee7a5663 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -142,9 +142,10 @@ def setup( if not hint: continue - content = path.read_text(encoding="utf-8") + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") updated = self.inject_argument_hint(content, hint) if updated != content: - path.write_text(updated, encoding="utf-8") + path.write_bytes(updated.encode("utf-8")) return created From fa2fa9c46bab097315b6542a920d3e77e9b90dba Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 3 Apr 2026 01:15:07 +0500 Subject: [PATCH 7/8] fix: re-record manifest hash after hint injection, quote hint values - Re-record file hash in manifest after writing argument-hint so check_modified()/uninstall stays in sync - Double-quote argument-hint values to match SKILL.md frontmatter style - Update tests to expect quoted hint values --- src/specify_cli/integrations/claude/__init__.py | 5 ++++- tests/integrations/test_integration_claude.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 61ee7a5663..a7c1cdff29 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -85,7 +85,8 @@ def inject_argument_hint(content: str, hint: str) -> str: eol = "\n" else: eol = "" - out.append(f"argument-hint: {hint}{eol}") + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') + out.append(f'argument-hint: "{escaped}"{eol}') injected = True continue out.append(line) @@ -147,5 +148,7 @@ def setup( updated = self.inject_argument_hint(content, hint) if updated != content: path.write_bytes(updated.encode("utf-8")) + # Re-record hash so manifest stays in sync for uninstall + self.record_file_in_manifest(path, project_root, manifest) return created diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 5314823740..fe50eecc70 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -314,7 +314,7 @@ def test_hints_match_expected_values(self, tmp_path): f"No expected hint defined for skill '{stem}'" ) content = f.read_text(encoding="utf-8") - assert f"argument-hint: {expected_hint}" in content, ( + assert f'argument-hint: "{expected_hint}"' in content, ( f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found" ) @@ -386,7 +386,7 @@ def test_inject_argument_hint_skips_if_already_present(self): content = ( "---\n" "description: My command\n" - "argument-hint: Existing hint\n" + 'argument-hint: "Existing hint"\n' "---\n" "\n" "Body text\n" From d9a5c782b2f143ff0c959fbdea0b447d404ab889 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 3 Apr 2026 05:32:17 +0500 Subject: [PATCH 8/8] fix: inject disable-model-invocation into Claude skill frontmatter --- .../integrations/claude/__init__.py | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index a7c1cdff29..9eb3214614 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -111,6 +111,38 @@ def _build_skill_fm(self, name: str, description: str, source: str) -> dict: self.key, name, description, source ) + @staticmethod + def _inject_disable_model_invocation(content: str) -> str: + """Insert ``disable-model-invocation: true`` before the closing ``---``.""" + lines = content.splitlines(keepends=True) + + # Pre-scan: bail out if already present in frontmatter + dash_count = 0 + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2: + break + continue + if dash_count == 1 and stripped.startswith("disable-model-invocation:"): + return content + + # Inject before the closing --- of frontmatter + out: list[str] = [] + dash_count = 0 + injected = False + for line in lines: + stripped = line.rstrip("\n\r") + if stripped == "---": + dash_count += 1 + if dash_count == 2 and not injected: + eol = "\r\n" if line.endswith("\r\n") else "\n" + out.append(f"disable-model-invocation: true{eol}") + injected = True + out.append(line) + return "".join(out) + def setup( self, project_root: Path, @@ -118,10 +150,10 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject argument-hint into each SKILL.md.""" + """Install Claude skills, then inject argument-hint and disable-model-invocation.""" created = super().setup(project_root, manifest, parsed_options, **opts) - # Post-process generated skill files to add argument-hint + # Post-process generated skill files skills_dir = self.skills_dest(project_root).resolve() for path in created: @@ -133,22 +165,23 @@ def setup( if path.name != "SKILL.md": continue - # Extract template stem from skill dir name: speckit-plan -> plan + content_bytes = path.read_bytes() + content = content_bytes.decode("utf-8") + + # Inject disable-model-invocation: true (Claude skills run only when invoked) + updated = self._inject_disable_model_invocation(content) + + # Inject argument-hint if available for this skill skill_dir_name = path.parent.name # e.g. "speckit-plan" stem = skill_dir_name if stem.startswith("speckit-"): stem = stem[len("speckit-"):] - hint = ARGUMENT_HINTS.get(stem, "") - if not hint: - continue + if hint: + updated = self.inject_argument_hint(updated, hint) - content_bytes = path.read_bytes() - content = content_bytes.decode("utf-8") - updated = self.inject_argument_hint(content, hint) if updated != content: path.write_bytes(updated.encode("utf-8")) - # Re-record hash so manifest stays in sync for uninstall self.record_file_in_manifest(path, project_root, manifest) return created