Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/git/scripts/bash/auto-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,4 @@ fi
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }

echo " Changes committed ${_phase} ${_command_name}" >&2
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
2 changes: 1 addition & 1 deletion extensions/git/scripts/powershell/auto-commit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,4 @@ try {
exit 1
}

Write-Host " Changes committed $phase $commandName"
Write-Host "[OK] Changes committed $phase $commandName"
17 changes: 12 additions & 5 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ def _build_agent_configs() -> dict[str, Any]:
return configs


def post_process_skill(agent_key: str, content: str) -> str:
"""Delegate to the integration's post_process_skill_content if available."""
if not isinstance(agent_key, str) or not agent_key:
return content
from specify_cli.integrations import get_integration

integration = get_integration(agent_key)
if integration is not None and hasattr(integration, "post_process_skill_content"):
return integration.post_process_skill_content(content)
return content


class CommandRegistrar:
"""Handles registration of commands with AI agents.

Expand Down Expand Up @@ -317,11 +329,6 @@ def build_skill_frontmatter(
"source": source,
},
}
if agent_name == "claude":
# Claude skills should be user-invocable (accessible via /command)
# and only run when explicitly invoked (not auto-triggered by the model).
skill_frontmatter["user-invocable"] = True
skill_frontmatter["disable-model-invocation"] = True
return skill_frontmatter

@staticmethod
Expand Down
5 changes: 4 additions & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ def _register_extension_skills(
return []

from . import load_init_options
from .agents import CommandRegistrar
from .agents import CommandRegistrar, post_process_skill
import yaml

written: List[str] = []
Expand Down Expand Up @@ -857,6 +857,9 @@ def _register_extension_skills(
f"# {title_name} Skill\n\n"
f"{body}\n"
)
skill_content = post_process_skill(
selected_ai, skill_content
)

skill_file.write_text(skill_content, encoding="utf-8")
written.append(skill_name)
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,16 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
invocation = f"{invocation} {args}"
return invocation

def post_process_skill_content(self, content: str) -> str:
"""Post-process a SKILL.md file's content after generation.

Called by external skill generators (presets, extensions) to let
the integration inject agent-specific frontmatter or body
transformations. The default implementation returns *content*
unchanged. Subclasses may override — see ``ClaudeIntegration``.
"""
return content

def setup(
self,
project_root: Path,
Expand Down
55 changes: 49 additions & 6 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@
from pathlib import Path
from typing import Any

import re

import yaml

from ..base import SkillsIntegration
from ..manifest import IntegrationManifest

# Note injected into hook sections so Claude maps dot-notation command
# names (from extensions.yml) to the hyphenated skill names it uses.
_HOOK_COMMAND_NOTE = (
"- When constructing slash commands from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)

# 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] = {
Expand Down Expand Up @@ -148,14 +158,51 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

@staticmethod
def _inject_hook_command_note(content: str) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.

Targets the line ``- For each executable hook, output the following``
and inserts the note on the line before it, matching its indentation.
Skips if the note is already present.
"""
if "replace dots" in content:
return content

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
eol = m.group(3)
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ eol
+ indent
+ instruction
+ eol
)

return re.sub(
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
repl,
content,
)

def post_process_skill_content(self, content: str) -> str:
"""Inject Claude-specific frontmatter flags and hook notes."""
updated = self._inject_frontmatter_flag(content, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
updated = self._inject_hook_command_note(updated)
return updated

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
created = super().setup(project_root, manifest, parsed_options, **opts)

# Post-process generated skill files
Expand All @@ -173,11 +220,7 @@ def setup(
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")

# Inject user-invocable: true (Claude skills are accessible via /command)
updated = self._inject_frontmatter_flag(content, "user-invocable")

# Inject disable-model-invocation: true (Claude skills run only when invoked)
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation")
updated = self.post_process_skill_content(content)

# Inject argument-hint if available for this skill
skill_dir_name = path.parent.name # e.g. "speckit-plan"
Expand Down
13 changes: 11 additions & 2 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ def _register_skills(
return []

from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .agents import CommandRegistrar, post_process_skill

init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
Expand Down Expand Up @@ -789,6 +789,9 @@ def _register_skills(
f"# Speckit {skill_title} Skill\n\n"
f"{body}\n"
)
skill_content = post_process_skill(
selected_ai, skill_content
)

skill_file = skill_subdir / "SKILL.md"
skill_file.write_text(skill_content, encoding="utf-8")
Expand All @@ -815,7 +818,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
return

from . import SKILL_DESCRIPTIONS, load_init_options
from .agents import CommandRegistrar
from .agents import CommandRegistrar, post_process_skill

# Locate core command templates from the project's installed templates
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
Expand Down Expand Up @@ -877,6 +880,9 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
f"# Speckit {skill_title} Skill\n\n"
f"{body}\n"
)
skill_content = post_process_skill(
selected_ai, skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
continue

Expand Down Expand Up @@ -906,6 +912,9 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
f"# {title_name} Skill\n\n"
f"{body}\n"
)
skill_content = post_process_skill(
selected_ai, skill_content
)
skill_file.write_text(skill_content, encoding="utf-8")
else:
# No core or extension template — remove the skill entirely
Expand Down
56 changes: 56 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,34 @@ def test_requires_event_name_argument(self, tmp_path: Path):
result = _run_bash("auto-commit.sh", project)
assert result.returncode != 0

def test_success_message_uses_ok_prefix(self, tmp_path: Path):
"""auto-commit.sh success message uses [OK] (not Unicode)."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_specify")
assert result.returncode == 0
assert "[OK] Changes committed" in result.stderr

def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
"""auto-commit.sh must not use Unicode checkmark in output."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_plan:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
result = _run_bash("auto-commit.sh", project, "after_plan")
assert result.returncode == 0
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"


@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
Expand Down Expand Up @@ -523,6 +551,34 @@ def test_enabled_per_command(self, tmp_path: Path):
)
assert "ps commit" in log.stdout

def test_success_message_uses_ok_prefix(self, tmp_path: Path):
"""auto-commit.ps1 success message uses [OK] (not Unicode)."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
assert result.returncode == 0
assert "[OK] Changes committed" in result.stdout

def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
"""auto-commit.ps1 must not use Unicode checkmark in output."""
project = _setup_project(tmp_path)
_write_config(project, (
"auto_commit:\n"
" default: false\n"
" after_plan:\n"
" enabled: true\n"
))
(project / "new-file.txt").write_text("content")
result = _run_pwsh("auto-commit.ps1", project, "after_plan")
assert result.returncode == 0
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"


# ── git-common.sh Tests ──────────────────────────────────────────────────────

Expand Down
Loading
Loading