Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
25 changes: 24 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,30 @@ def render_skill_command(
description,
f"{source_id}:{source_file}",
)
return self.render_frontmatter(skill_frontmatter) + "\n" + body
skill_content = self.render_frontmatter(skill_frontmatter) + "\n" + body

from . import load_init_options
from .integrations import get_integration

init_options = load_init_options(project_root)
parsed_options = (
init_options.get("integration_parsed_options")
if isinstance(init_options, dict)
else None
)
if not isinstance(parsed_options, dict):
parsed_options = None
integration = get_integration(agent_name)
if integration is not None and hasattr(
integration,
"post_process_skill_content",
):
skill_content = integration.post_process_skill_content(
skill_content,
parsed_options=parsed_options,
)

return skill_content

@staticmethod
def build_skill_frontmatter(
Expand Down
4 changes: 4 additions & 0 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,10 @@ def init(
from ..integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
init_opts["ai_skills"] = True
if integration_options:
init_opts["integration_options"] = integration_options
if integration_parsed_options:
init_opts["integration_parsed_options"] = integration_parsed_options
save_init_options(project_path, init_opts)

# --- agent-context extension (bundled, auto-installed) ---
Expand Down
6 changes: 5 additions & 1 deletion src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,9 @@ def _register_extension_skills(
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return []
integration_parsed_options = opts.get("integration_parsed_options")
if not isinstance(integration_parsed_options, dict):
integration_parsed_options = None
registrar = CommandRegistrar()
integration = get_integration(selected_ai)

Expand Down Expand Up @@ -1009,7 +1012,8 @@ def _register_extension_skills(
)
if integration is not None and hasattr(integration, "post_process_skill_content"):
skill_content = integration.post_process_skill_content(
skill_content
skill_content,
parsed_options=integration_parsed_options,
)

if link_outputs:
Expand Down
24 changes: 23 additions & 1 deletion src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
write_integration_json as _write_integration_json_file,
)

_UNSET = object()


def _get_speckit_version() -> str:
"""Return the current Spec Kit version.
Expand Down Expand Up @@ -273,6 +275,8 @@ def _update_init_options_for_integration(
project_root: Path,
integration: Any,
script_type: str | None = None,
raw_options: str | None | object = _UNSET,
parsed_options: dict[str, Any] | None | object = _UNSET,
) -> None:
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
Expand Down Expand Up @@ -305,6 +309,18 @@ def _update_init_options_for_integration(
else:
opts.pop("ai_skills", None)

if raw_options is not _UNSET:
if isinstance(raw_options, str) and raw_options:
opts["integration_options"] = raw_options
else:
opts.pop("integration_options", None)

if parsed_options is not _UNSET:
if isinstance(parsed_options, dict) and parsed_options:
opts["integration_parsed_options"] = parsed_options
else:
opts.pop("integration_parsed_options", None)

# Update the agent-context extension config BEFORE init-options.json
# so a failure here doesn't leave init-options partially updated.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
Expand Down Expand Up @@ -374,7 +390,13 @@ def _set_default_integration(
) from exc

_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
_update_init_options_for_integration(
project_root,
integration,
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
)


def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
Expand Down
8 changes: 7 additions & 1 deletion src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,13 @@ def integration_install(
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root,
integration,
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
else:
_refresh_init_options_speckit_version(project_root)

Expand Down
8 changes: 7 additions & 1 deletion src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,13 @@ def integration_upgrade(
new_manifest.save()
_write_integration_json(project_root, installed_key, installed_keys, settings)
if installed_key == key:
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
_update_init_options_for_integration(
project_root,
integration,
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
)
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/agy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ def repl(m: re.Match[str]) -> str:
content,
)

def post_process_skill_content(self, content: str) -> str:
def post_process_skill_content(
self,
content: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Inject the dot-to-hyphen hook command note."""
return self._inject_hook_command_note(content)

Expand Down Expand Up @@ -119,7 +123,10 @@ def setup(
continue

content = path.read_bytes().decode("utf-8")
updated = self.post_process_skill_content(content)
updated = self.post_process_skill_content(
content,
parsed_options=parsed_options,
)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,11 @@ def repl(m: re.Match[str]) -> str:
content,
)

def post_process_skill_content(self, content: str) -> str:
def post_process_skill_content(
self,
content: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Post-process a SKILL.md file's content after generation.

Called by external skill generators (presets, extensions) to let
Expand Down Expand Up @@ -1756,7 +1760,10 @@ def _quote(v: str) -> str:
f"{processed_body}"
)

skill_content = self.post_process_skill_content(skill_content)
skill_content = self.post_process_skill_content(
skill_content,
parsed_options=parsed_options,
)

# Write speckit-<name>/SKILL.md
skill_dir = skills_dir / skill_name
Expand Down
63 changes: 59 additions & 4 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import yaml

from ..base import SkillsIntegration
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest

# Mapping of command template stem → argument-hint text shown inline
Expand Down Expand Up @@ -45,6 +45,17 @@ class ClaudeIntegration(SkillsIntegration):
context_file = "CLAUDE.md"
multi_install_safe = True

@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--no-model-invocation",
is_flag=True,
default=False,
help="Set generated Claude skills to user-invocable only",
),
]

@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
Expand Down Expand Up @@ -149,11 +160,55 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

def post_process_skill_content(self, content: str) -> str:
@staticmethod
def _set_frontmatter_flag(content: str, key: str, value: str) -> str:
"""Set ``key: value`` in frontmatter, inserting it when missing."""
lines = content.splitlines(keepends=True)
out: list[str] = []
dash_count = 0
replaced = False

for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
out.append(line)
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
if line.endswith("\r\n"):
eol = "\r\n"
elif line.endswith("\n"):
eol = "\n"
else:
eol = ""
out.append(f"{key}: {value}{eol}")
replaced = True
continue
out.append(line)

if replaced:
return "".join(out)
return ClaudeIntegration._inject_frontmatter_flag(content, key, value)

def post_process_skill_content(
self,
content: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Inject Claude-specific frontmatter flags and hook notes."""
updated = super().post_process_skill_content(content)
updated = super().post_process_skill_content(
content,
parsed_options=parsed_options,
)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
disable_model_invocation = bool(
parsed_options and parsed_options.get("no_model_invocation")
)
updated = self._set_frontmatter_flag(
updated,
"disable-model-invocation",
"true" if disable_model_invocation else "false",
)
return updated

def setup(
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,11 @@ def command_filename(self, template_name: str) -> str:
"""Copilot commands use ``.agent.md`` extension."""
return f"speckit.{template_name}.agent.md"

def post_process_skill_content(self, content: str) -> str:
def post_process_skill_content(
self,
content: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Inject shared hook guidance into Copilot skill content.

Delegates to :class:`_CopilotSkillsHelper` for shared post-processing.
Expand Down Expand Up @@ -413,7 +417,10 @@ def _setup_skills(
continue

content = path.read_text(encoding="utf-8")
updated = self.post_process_skill_content(content)
updated = self.post_process_skill_content(
content,
parsed_options=parsed_options,
)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)
Expand Down
5 changes: 4 additions & 1 deletion src/specify_cli/integrations/hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ def _quote(v: str) -> str:
f"{processed_body}"
)

skill_content = self.post_process_skill_content(skill_content)
skill_content = self.post_process_skill_content(
skill_content,
parsed_options=parsed_options,
)

# Write directly to global ~/.hermes/skills/speckit-<name>/SKILL.md
skill_dir = global_skills_dir / skill_name
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/integrations/vibe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,19 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str
out.append(line)
return "".join(out)

def post_process_skill_content(self, content: str) -> str:
def post_process_skill_content(
self,
content: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""
Inject shared hook guidance and Vibe-specific frontmatter flags:
- user-invocable: allows the skill to be invoked by the user (not just other agents)
"""
updated = super().post_process_skill_content(content)
updated = super().post_process_skill_content(
content,
parsed_options=parsed_options,
)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
return updated

Expand Down
Loading