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
42 changes: 24 additions & 18 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ def init(
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"),
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
allow_model_invocation: bool = typer.Option(False, "--allow-model-invocation", help="Allow AI model to invoke speckit skills programmatically (sets disable-model-invocation: false)"),
):
"""
Initialize a new Specify project.
Expand Down Expand Up @@ -1125,6 +1126,27 @@ def init(
if ai_skills:
integration_parsed_options["skills"] = True

# Persist the CLI options BEFORE calling setup() so that integrations
# can read them during skill generation (e.g., allow_model_invocation).
init_opts = {
"ai": selected_ai,
"integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"here": here,
"preset": preset,
"script": selected_script,
"speckit_version": get_speckit_version(),
}
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist):
init_opts["ai_skills"] = True
# Persist allow_model_invocation flag if specified
if allow_model_invocation:
init_opts["allow_model_invocation"] = True
save_init_options(project_path, init_opts)

resolved_integration.setup(
project_path, manifest,
parsed_options=integration_parsed_options or None,
Expand Down Expand Up @@ -1172,24 +1194,8 @@ def init(
else:
tracker.skip("git", "--no-git flag")

# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
init_opts = {
"ai": selected_ai,
"integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"here": here,
"preset": preset,
"script": selected_script,
"speckit_version": get_speckit_version(),
}
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
# Note: init_opts were already saved before integration.setup() above
# so that integrations can read them during initialization.

# Install preset if specified
if preset:
Expand Down
33 changes: 30 additions & 3 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def render_skill_command(
skill_name,
description,
f"{source_id}:{source_file}",
project_root=project_root,
)
return self.render_frontmatter(skill_frontmatter) + "\n" + body

Expand All @@ -263,8 +264,21 @@ def build_skill_frontmatter(
skill_name: str,
description: str,
source: str,
project_root: Path | None = None,
) -> dict:
"""Build consistent SKILL.md frontmatter across all skill generators."""
"""Build consistent SKILL.md frontmatter across all skill generators.

Args:
agent_name: Agent identifier (e.g., "claude", "codex").
skill_name: Skill name for the frontmatter.
description: Skill description.
source: Source identifier for metadata.
project_root: Project root path to read init-options.json from.
If None, defaults apply.

Returns:
Frontmatter dict with appropriate agent-specific flags.
"""
skill_frontmatter = {
"name": skill_name,
"description": description,
Expand All @@ -276,9 +290,22 @@ def build_skill_frontmatter(
}
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

# Check if model invocation is allowed from init-options.json
allow_model_invocation = False
if project_root is not None:
try:
from . import load_init_options
opts = load_init_options(project_root)
if isinstance(opts, dict):
allow_model_invocation = opts.get("allow_model_invocation", False)
except (ImportError, OSError):
pass

# By default, skills run only when explicitly invoked (not auto-triggered).
# Set to False to allow model-driven invocation for agent orchestration.
skill_frontmatter["disable-model-invocation"] = not allow_model_invocation
return skill_frontmatter

@staticmethod
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,7 @@ def _register_extension_skills(
skill_name,
description,
f"extension:{manifest.id}",
project_root=self.project_root,
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()

Expand Down
16 changes: 12 additions & 4 deletions src/specify_cli/integrations/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,10 @@ def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: s
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"

def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
def _build_skill_fm(self, name: str, description: str, source: str, project_root: Path | None = None) -> dict:
from specify_cli.agents import CommandRegistrar
return CommandRegistrar.build_skill_frontmatter(
self.key, name, description, source
self.key, name, description, source, project_root=project_root
)

@staticmethod
Expand Down Expand Up @@ -158,6 +158,11 @@ def setup(
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
created = super().setup(project_root, manifest, parsed_options, **opts)

# Check if model invocation is allowed from init-options.json
from specify_cli import load_init_options
init_opts = load_init_options(project_root)
allow_model_invocation = init_opts.get("allow_model_invocation", False) if isinstance(init_opts, dict) else False

# Post-process generated skill files
skills_dir = self.skills_dest(project_root).resolve()

Expand All @@ -176,8 +181,11 @@ def setup(
# 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")
# Inject disable-model-invocation conditionally:
# - True (default): skills run only when user explicitly invokes them
# - False (--allow-model-invocation): skills can be auto-invoked by the model
disable_value = "false" if allow_model_invocation else "true"
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", disable_value)

# Inject argument-hint if available for this skill
skill_dir_name = path.parent.name # e.g. "speckit-plan"
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ def _register_skills(
target_skill_name,
enhanced_desc,
f"preset:{manifest.id}",
project_root=self.project_root,
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
Expand Down Expand Up @@ -867,6 +868,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_name,
enhanced_desc,
f"templates/commands/{short_name}.md",
project_root=self.project_root,
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_title = self._skill_title_from_command(short_name)
Expand Down Expand Up @@ -897,6 +899,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_name,
frontmatter.get("description", f"Extension command: {command_name}"),
extension_restore["source"],
project_root=self.project_root,
)
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
skill_content = (
Expand Down
166 changes: 166 additions & 0 deletions tests/integrations/test_integration_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,169 @@ def test_inject_argument_hint_skips_if_already_present(self):
lines = result.splitlines()
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
assert hint_count == 1


class TestAllowModelInvocation:
"""Tests for --allow-model-invocation flag."""

def test_default_disables_model_invocation(self, tmp_path):
"""By default, disable-model-invocation should be true."""
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / "default-disable"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)

assert result.exit_code == 0, result.output

skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "disable-model-invocation: true" in content

init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options.get("allow_model_invocation") is None

def test_allow_model_invocation_flag_enables_model_invocation(self, tmp_path):
"""With --allow-model-invocation, disable-model-invocation should be false."""
from typer.testing import CliRunner
from specify_cli import app

project = tmp_path / "allow-invocation"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
"claude",
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
"--allow-model-invocation",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)

assert result.exit_code == 0, result.output

skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "disable-model-invocation: false" in content

init_options = json.loads(
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
)
assert init_options["allow_model_invocation"] is True

def test_build_skill_frontmatter_respects_init_options(self, tmp_path):
"""build_skill_frontmatter should read allow_model_invocation from init-options.json."""
from specify_cli import save_init_options
from specify_cli.agents import CommandRegistrar

# Test with default (no flag set)
project_default = tmp_path / "default"
project_default.mkdir()
save_init_options(project_default, {"ai": "claude", "script": "sh"})

fm_default = CommandRegistrar.build_skill_frontmatter(
"claude", "test-skill", "Test description", "test", project_root=project_default
)
assert fm_default["disable-model-invocation"] is True

# Test with allow_model_invocation = true
project_allow = tmp_path / "allow"
project_allow.mkdir()
save_init_options(project_allow, {"ai": "claude", "script": "sh", "allow_model_invocation": True})

fm_allow = CommandRegistrar.build_skill_frontmatter(
"claude", "test-skill", "Test description", "test", project_root=project_allow
)
assert fm_allow["disable-model-invocation"] is False

def test_preset_install_respects_allow_model_invocation(self, tmp_path):
"""Preset skills should respect allow_model_invocation from init-options.json."""
from specify_cli import save_init_options
from specify_cli.presets import PresetManager

project = tmp_path / "preset-project"
project.mkdir()
save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh", "allow_model_invocation": True})

skills_dir = project / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)

# Create existing skill directory to trigger preset update
specify_skill = skills_dir / "speckit-specify"
specify_skill.mkdir()

preset_dir = tmp_path / "test-preset"
preset_dir.mkdir()
(preset_dir / "commands").mkdir()
(preset_dir / "commands" / "speckit.specify.md").write_text(
"---\n"
"description: Specify workflow\n"
"---\n\n"
"preset test\n"
)
manifest_data = {
"schema_version": "1.0",
"preset": {
"id": "test-preset",
"name": "Test Preset",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"templates": [
{
"type": "command",
"name": "speckit.specify",
"file": "commands/speckit.specify.md",
}
]
},
}
import yaml
with open(preset_dir / "preset.yml", "w") as f:
yaml.dump(manifest_data, f)

manager = PresetManager(project)
manager.install_from_directory(preset_dir, "0.1.5")

skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "disable-model-invocation: false" in content