|
| 1 | +"""Claude Code skill post-processing (frontmatter flags and argument hints).""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import re |
| 6 | + |
| 7 | +from . import RendererExtension, SkillRenderContext |
| 8 | + |
| 9 | +# Note injected into hook sections so Claude maps dot-notation command |
| 10 | +# names (from extensions.yml) to the hyphenated skill names it uses. |
| 11 | +_HOOK_COMMAND_NOTE = ( |
| 12 | + "- When constructing slash commands from hook command names, " |
| 13 | + "replace dots (`.`) with hyphens (`-`). " |
| 14 | + "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" |
| 15 | +) |
| 16 | + |
| 17 | +# Mapping of command template stem → argument-hint text shown inline |
| 18 | +# when a user invokes the slash command in Claude Code. |
| 19 | +ARGUMENT_HINTS: dict[str, str] = { |
| 20 | + "specify": "Describe the feature you want to specify", |
| 21 | + "plan": "Optional guidance for the planning phase", |
| 22 | + "tasks": "Optional task generation constraints", |
| 23 | + "implement": "Optional implementation guidance or task filter", |
| 24 | + "analyze": "Optional focus areas for analysis", |
| 25 | + "clarify": "Optional areas to clarify in the spec", |
| 26 | + "constitution": "Principles or values for the project constitution", |
| 27 | + "checklist": "Domain or focus area for the checklist", |
| 28 | + "taskstoissues": "Optional filter or label for GitHub issues", |
| 29 | +} |
| 30 | + |
| 31 | + |
| 32 | +def inject_hook_command_note(content: str) -> str: |
| 33 | + """Insert a dot-to-hyphen note before each hook output instruction. |
| 34 | +
|
| 35 | + Targets the line ``- For each executable hook, output the following`` |
| 36 | + and inserts the note on the line before it, matching its indentation. |
| 37 | + Skips if the note is already present. |
| 38 | + """ |
| 39 | + if "replace dots" in content: |
| 40 | + return content |
| 41 | + |
| 42 | + def repl(m: re.Match[str]) -> str: |
| 43 | + indent = m.group(1) |
| 44 | + instruction = m.group(2) |
| 45 | + eol = m.group(3) |
| 46 | + return ( |
| 47 | + indent |
| 48 | + + _HOOK_COMMAND_NOTE.rstrip("\n") |
| 49 | + + eol |
| 50 | + + indent |
| 51 | + + instruction |
| 52 | + + eol |
| 53 | + ) |
| 54 | + |
| 55 | + return re.sub( |
| 56 | + r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)", |
| 57 | + repl, |
| 58 | + content, |
| 59 | + ) |
| 60 | + |
| 61 | + |
| 62 | +def inject_argument_hint(content: str, hint: str) -> str: |
| 63 | + """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter. |
| 64 | +
|
| 65 | + Skips injection if ``argument-hint:`` already exists in the |
| 66 | + frontmatter to avoid duplicate keys. |
| 67 | + """ |
| 68 | + lines = content.splitlines(keepends=True) |
| 69 | + |
| 70 | + dash_count = 0 |
| 71 | + for line in lines: |
| 72 | + stripped = line.rstrip("\n\r") |
| 73 | + if stripped == "---": |
| 74 | + dash_count += 1 |
| 75 | + if dash_count == 2: |
| 76 | + break |
| 77 | + continue |
| 78 | + if dash_count == 1 and stripped.startswith("argument-hint:"): |
| 79 | + return content |
| 80 | + |
| 81 | + out: list[str] = [] |
| 82 | + in_fm = False |
| 83 | + dash_count = 0 |
| 84 | + injected = False |
| 85 | + for line in lines: |
| 86 | + stripped = line.rstrip("\n\r") |
| 87 | + if stripped == "---": |
| 88 | + dash_count += 1 |
| 89 | + in_fm = dash_count == 1 |
| 90 | + out.append(line) |
| 91 | + continue |
| 92 | + if in_fm and not injected and stripped.startswith("description:"): |
| 93 | + out.append(line) |
| 94 | + if line.endswith("\r\n"): |
| 95 | + eol = "\r\n" |
| 96 | + elif line.endswith("\n"): |
| 97 | + eol = "\n" |
| 98 | + else: |
| 99 | + eol = "" |
| 100 | + escaped = hint.replace("\\", "\\\\").replace('"', '\\"') |
| 101 | + out.append(f'argument-hint: "{escaped}"{eol}') |
| 102 | + injected = True |
| 103 | + continue |
| 104 | + out.append(line) |
| 105 | + return "".join(out) |
| 106 | + |
| 107 | + |
| 108 | +def inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str: |
| 109 | + """Insert ``key: value`` before the closing ``---`` if not already present.""" |
| 110 | + lines = content.splitlines(keepends=True) |
| 111 | + |
| 112 | + dash_count = 0 |
| 113 | + for line in lines: |
| 114 | + stripped = line.rstrip("\n\r") |
| 115 | + if stripped == "---": |
| 116 | + dash_count += 1 |
| 117 | + if dash_count == 2: |
| 118 | + break |
| 119 | + continue |
| 120 | + if dash_count == 1 and stripped.startswith(f"{key}:"): |
| 121 | + return content |
| 122 | + |
| 123 | + out: list[str] = [] |
| 124 | + dash_count = 0 |
| 125 | + injected = False |
| 126 | + for line in lines: |
| 127 | + stripped = line.rstrip("\n\r") |
| 128 | + if stripped == "---": |
| 129 | + dash_count += 1 |
| 130 | + if dash_count == 2 and not injected: |
| 131 | + if line.endswith("\r\n"): |
| 132 | + eol = "\r\n" |
| 133 | + elif line.endswith("\n"): |
| 134 | + eol = "\n" |
| 135 | + else: |
| 136 | + eol = "" |
| 137 | + out.append(f"{key}: {value}{eol}") |
| 138 | + injected = True |
| 139 | + out.append(line) |
| 140 | + return "".join(out) |
| 141 | + |
| 142 | + |
| 143 | +class ClaudeSkillTransformExtension: |
| 144 | + """Inject Claude skill frontmatter flags, hook note, and optional argument-hint.""" |
| 145 | + |
| 146 | + def transform(self, content: str, ctx: SkillRenderContext) -> str: |
| 147 | + updated = inject_frontmatter_flag(content, "user-invocable") |
| 148 | + updated = inject_frontmatter_flag( |
| 149 | + updated, "disable-model-invocation", "false" |
| 150 | + ) |
| 151 | + updated = inject_hook_command_note(updated) |
| 152 | + |
| 153 | + skill_dir_name = ctx.skill_path.parent.name |
| 154 | + stem = skill_dir_name |
| 155 | + if stem.startswith("speckit-"): |
| 156 | + stem = stem[len("speckit-") :] |
| 157 | + hint = ARGUMENT_HINTS.get(stem, "") |
| 158 | + if hint: |
| 159 | + updated = inject_argument_hint(updated, hint) |
| 160 | + return updated |
0 commit comments