diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1d5a34073c..0454b6923f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -114,6 +114,7 @@ def _refresh_shared_templates( project_path: Path, *, invoke_separator: str, + invoke_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -124,6 +125,7 @@ def _refresh_shared_templates( repo_root=_repo_root(), console=console, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, force=force, ) @@ -134,6 +136,7 @@ def _install_shared_infra( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -177,6 +180,7 @@ def _install_shared_infra( console=console, force=force, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) @@ -188,6 +192,7 @@ def _install_shared_infra_or_exit( tracker: StepTracker | None = None, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -198,6 +203,7 @@ def _install_shared_infra_or_exit( tracker=tracker, force=force, invoke_separator=invoke_separator, + invoke_prefix=invoke_prefix, refresh_managed=refresh_managed, refresh_hint=refresh_hint, ) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 09429f7a69..260fbca9ff 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,7 @@ import yaml from ._init_options import is_ai_skills_enabled, load_init_options +from ._invocation_style import is_dollar_skills_agent from ._toml_string import escape_toml_basic as _escape_toml_basic from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control from ._utils import relative_extension_path_violation @@ -659,17 +660,20 @@ def register_commands( # correct when a stale ``.bob/skills`` directory coexists with # ``.bob/commands``. _sep = agent_config.get("invoke_separator", ".") + registrar_writes_skills = agent_config.get("extension") == "/SKILL.md" try: from specify_cli.integrations import get_integration # noqa: PLC0415 _integ = get_integration(agent_name) if _integ is not None: - registrar_writes_skills = ( - agent_config.get("extension") == "/SKILL.md" - ) _sep = _integ.invoke_separator_for_mode(registrar_writes_skills) except Exception: pass + _prefix = ( + "$" + if is_dollar_skills_agent(agent_name, registrar_writes_skills) + else "/" + ) for cmd_info in commands: cmd_name = cmd_info["name"] @@ -755,7 +759,7 @@ def register_commands( # (base.py itself imports CommandRegistrar lazily). from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 - body = IntegrationBase.resolve_command_refs(body, _sep) + body = IntegrationBase.resolve_command_refs(body, _sep, _prefix) output_name = self._compute_output_name(agent_name, cmd_name, agent_config) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 00cf741539..df187d56a1 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -23,6 +23,7 @@ get_speckit_version, ) from .._console import StepTracker, console, select_with_arrows, show_banner +from .._invocation_style import is_dollar_skills_agent from .._utils import check_tool @@ -481,6 +482,16 @@ def init( invoke_separator=resolved_integration.effective_invoke_separator( integration_parsed_options, project_root=project_path ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + resolved_integration.key, + resolved_integration.is_skills_mode( + integration_parsed_options, project_path + ), + ) + else "/" + ), ) tracker.complete( "shared-infra", f"scripts ({selected_script}) + templates" diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index d27ca7ef4d..7e1f43116b 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -9,6 +9,7 @@ from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console +from .._invocation_style import is_dollar_skills_agent from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -332,6 +333,13 @@ def _set_default_integration( integration, {"integration_settings": settings}, key, parsed_options, project_root=project_root, ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + key, integration.is_skills_mode(parsed_options, project_root) + ) + else "/" + ), force=refresh_templates_force, refresh_managed=True, refresh_hint=refresh_hint, diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 4d7bb44f9b..36af302f07 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -6,6 +6,7 @@ import typer from .._console import console +from .._invocation_style import is_dollar_skills_agent from .._utils import _display_project_path from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, @@ -130,6 +131,14 @@ def integration_install( infra_integration, current, infra_key, infra_parsed, project_root=project_root, ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + infra_key, + infra_integration.is_skills_mode(infra_parsed, project_root), + ) + else "/" + ), ) if os.name != "nt": from .. import ensure_executable_scripts diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 7116a744c4..cacbc8b2fa 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -8,6 +8,7 @@ import typer from .._console import console +from .._invocation_style import is_dollar_skills_agent from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, with_integration_setting as _with_integration_setting, @@ -327,6 +328,13 @@ def integration_switch( target_integration, current, target, parsed_options, project_root=project_root, ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + target, target_integration.is_skills_mode(parsed_options, project_root) + ) + else "/" + ), refresh_hint=( "To overwrite customizations, re-run with " "[cyan]specify integration switch ... --refresh-shared-infra[/cyan]." @@ -557,6 +565,14 @@ def integration_upgrade( infra_integration, current, infra_key, infra_parsed, project_root=project_root, ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + infra_key, + infra_integration.is_skills_mode(infra_parsed, project_root), + ) + else "/" + ), ) if os.name != "nt": from .. import ensure_executable_scripts @@ -592,6 +608,13 @@ def integration_upgrade( integration, {"integration_settings": settings}, key, parsed_options, project_root=project_root, ), + invoke_prefix=( + "$" + if is_dollar_skills_agent( + key, integration.is_skills_mode(parsed_options, project_root) + ) + else "/" + ), force=force, refresh_managed=True, ) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index ccc8587709..2923695d74 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -27,6 +27,7 @@ import yaml +from .._invocation_style import is_dollar_skills_agent from .._toml_string import escape_toml_basic as _escape_toml_basic from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control @@ -34,7 +35,7 @@ from .manifest import IntegrationManifest _HOOK_COMMAND_NOTE = ( - "- When constructing slash commands from hook command names, " + "- When constructing command invocations from hook command names, " "replace dots (`.`) with hyphens (`-`). " "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" ) @@ -601,7 +602,9 @@ def install_scripts( return created @staticmethod - def resolve_command_refs(content: str, separator: str = ".") -> str: + def resolve_command_refs( + content: str, separator: str = ".", prefix: str = "/" + ) -> str: """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. Each placeholder encodes a command name in upper-case with @@ -611,10 +614,16 @@ def resolve_command_refs(content: str, separator: str = ".") -> str: * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + + *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose + native skills invocation uses dollar-prefixed chat commands. """ return re.sub( r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", - lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + lambda m: prefix + + "speckit" + + separator + + m.group(1).lower().replace("_", separator), content, ) @@ -838,7 +847,14 @@ def process_template( content = CommandRegistrar.rewrite_project_relative_paths(content) # 8. Replace __SPECKIT_COMMAND___ with invocation strings - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + invocation_prefix = ( + "$" + if is_dollar_skills_agent(agent_name, invoke_separator == "-") + else "/" + ) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invocation_prefix + ) return content @@ -1520,18 +1536,21 @@ def skills_dest(self, project_root: Path) -> Path: return project_root / folder / subdir def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Skills use ``/speckit-`` (hyphenated directory name).""" + """Build the agent's native invocation for a hyphenated skill name.""" stem = command_name if stem.startswith("speckit."): stem = stem[len("speckit."):] - invocation = "/speckit-" + stem.replace(".", "-") + prefix = "$" if is_dollar_skills_agent(self.key, True) else "/" + invocation = prefix + "speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @staticmethod - def _inject_hook_command_note(content: str) -> str: + def _inject_hook_command_note( + content: str, invocation_prefix: str = "/" + ) -> str: """Insert a dot-to-hyphen note before each hook output instruction. Targets the line ``- For each executable hook, output the following`` @@ -1540,6 +1559,11 @@ def _inject_hook_command_note(content: str) -> str: above them. """ note = _HOOK_COMMAND_NOTE.rstrip("\n") + if invocation_prefix != "/": + note = note.replace( + "`/speckit-git-commit`", + f"`{invocation_prefix}speckit-git-commit`", + ) def repl(m: re.Match[str]) -> str: indent = m.group(1) @@ -1576,7 +1600,8 @@ def post_process_skill_content(self, content: str) -> str: guidance for converting dotted hook command names to hyphenated slash commands. Subclasses may override — see ``ClaudeIntegration``. """ - return self._inject_hook_command_note(content) + invocation_prefix = "$" if is_dollar_skills_agent(self.key, True) else "/" + return self._inject_hook_command_note(content, invocation_prefix) def setup( self, diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 52494fede7..d52e295376 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -29,6 +29,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority from .._init_options import is_ai_skills_enabled +from .._invocation_style import is_dollar_skills_agent from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter, version_satisfies from ..shared_infra import ( @@ -1288,7 +1289,8 @@ def _resolve_skill_command_refs( separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get( "invoke_separator", "." ) - return IntegrationBase.resolve_command_refs(body, separator) + prefix = "$" if is_dollar_skills_agent(selected_ai, separator == "-") else "/" + return IntegrationBase.resolve_command_refs(body, separator, prefix) def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: """Index extension-backed skill restore data by skill directory name.""" diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index d99cca4e85..1c8d727d73 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -272,27 +272,56 @@ def _write_shared_bytes( _POWERSHELL_FORMAT_COMMAND_RE = re.compile( r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?" ) +_PYTHON_FORMAT_COMMAND_RETURN_RE = re.compile( + r'return f"/speckit\{separator\}\{name\}"' +) +_BASH_FORMATTER_RETURN_RE = re.compile( + r'''printf '/speckit%s%s\\n' "\$separator" "\$command_name"''' +) +_POWERSHELL_FORMATTER_RETURN_RE = re.compile( + r'return "/speckit\$separator\$name"' +) -def _format_speckit_command(command_name: str, separator: str) -> str: +def _format_speckit_command( + command_name: str, separator: str, prefix: str = "/" +) -> str: name = command_name.strip().lstrip("/") if name.startswith("speckit."): name = name[len("speckit.") :] elif name.startswith("speckit-"): name = name[len("speckit-") :] name = name.replace(".", separator) - return f"/speckit{separator}{name}" + return f"{prefix}speckit{separator}{name}" -def _resolve_dynamic_command_refs(content: str, separator: str) -> str: +def _resolve_dynamic_command_refs( + content: str, separator: str, prefix: str = "/" +) -> str: """Render script runtime command helpers for managed shared infra copies.""" + bash_prefix = r"\$" if prefix == "$" else prefix content = _BASH_FORMAT_COMMAND_RE.sub( - lambda match: _format_speckit_command(match.group(2), separator), + lambda match: _format_speckit_command( + match.group(2), separator, bash_prefix + ), + content, + ) + content = _POWERSHELL_FORMAT_COMMAND_RE.sub( + lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'", + content, + ) + content = _BASH_FORMATTER_RETURN_RE.sub( + f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''', content, ) - return _POWERSHELL_FORMAT_COMMAND_RE.sub( - lambda match: f"'{_format_speckit_command(match.group(2), separator)}'", + powershell_prefix = "`$" if prefix == "$" else prefix + content = _POWERSHELL_FORMATTER_RETURN_RE.sub( + f'return "{powershell_prefix}speckit$separator$name"', + content, + ) + return _PYTHON_FORMAT_COMMAND_RETURN_RE.sub( + f'return f"{prefix}speckit{{separator}}{{name}}"', content, ) @@ -305,6 +334,7 @@ def refresh_shared_templates( repo_root: Path, console: Any, invoke_separator: str, + invoke_prefix: str = "/", force: bool = False, ) -> None: """Refresh default-sensitive shared templates without touching scripts.""" @@ -336,7 +366,9 @@ def refresh_shared_templates( continue content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) planned_updates.append((dst, rel, content)) for dst, rel, content in planned_updates: @@ -363,6 +395,7 @@ def install_shared_infra( console: Any, force: bool = False, invoke_separator: str = ".", + invoke_prefix: str = "/", refresh_managed: bool = False, refresh_hint: str | None = None, ) -> bool: @@ -516,8 +549,12 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if not _ensure_or_bucket_dir(dst_path.parent): continue content = src_path.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) - content = _resolve_dynamic_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) + content = _resolve_dynamic_command_refs( + content, invoke_separator, invoke_prefix + ) planned_copies.append( ( dst_path, @@ -566,7 +603,9 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: continue content = src.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) + content = IntegrationBase.resolve_command_refs( + content, invoke_separator, invoke_prefix + ) planned_templates.append((dst, rel, content)) for dst_path, rel, content, mode in planned_copies: diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 9d2d4ccc1a..941af534e3 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -14,6 +14,29 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Scope Guard + +This command updates project governance only. It may modify: + +- `.specify/memory/constitution.md` +- dependent templates under `.specify/templates/` +- installed Spec Kit command files when required to keep them consistent with the + amended constitution + +It **MUST NOT** create or modify application source code, feature specifications, plans, +tasks, tests, or other implementation artifacts. + +If the user input includes feature requests, implementation instructions, or other +non-governance work: + +1. Extract that content as out-of-scope intent. +2. Do not execute it during this command. +3. Complete the constitution update using only the governance-related input. +4. Report the out-of-scope intent under **Suggested Follow-ups**, with the appropriate + next command (typically `__SPECKIT_COMMAND_SPECIFY__ `). + +Never invoke a suggested follow-up command automatically. + ## Pre-Execution Checks **Check for extension hooks (before constitution update)**: @@ -82,7 +105,6 @@ Follow this execution flow: - Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints. - Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline). - Read each installed Spec Kit command file for your agent (including this one) — named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory — to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required. - - Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed. 5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update): - Version change: old → new @@ -103,6 +125,9 @@ Follow this execution flow: 8. Output a final summary to the user with: - New version and bump rationale. - Any files flagged for manual follow-up. + - A **Suggested Follow-ups** section listing any out-of-scope intent captured by the + Scope Guard and its recommended command. Omit this section when there is no + out-of-scope intent. - Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`). Formatting & Style Requirements: diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 92d51487a2..a357b87ac4 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -204,19 +204,37 @@ def test_base_extension_command_bare(self): def test_skills_core_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.plan") == "/speckit-plan" - assert i.build_command_invocation("plan") == "/speckit-plan" + assert i.build_command_invocation("speckit.plan") == "$speckit-plan" + assert i.build_command_invocation("plan") == "$speckit-plan" def test_skills_extension_command(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit" - assert i.build_command_invocation("git.commit") == "/speckit-git-commit" + assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit" + assert i.build_command_invocation("git.commit") == "$speckit-git-commit" def test_skills_extension_command_with_args(self): from specify_cli.integrations import get_integration i = get_integration("codex") - assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo" + assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo" + + @pytest.mark.parametrize("integration_key", ["codex", "zcode"]) + def test_dollar_skill_post_processing_is_idempotent(self, integration_key): + from specify_cli.integrations import get_integration + + content = ( + "---\nname: test\n---\n\n" + "Literal slash invocation: /speckit-plan\n" + "- For each executable hook, output the following based on its flag:\n" + ) + integration = get_integration(integration_key) + once = integration.post_process_skill_content(content) + twice = integration.post_process_skill_content(once) + + assert twice == once + assert once.count("replace dots (`.`) with hyphens") == 1 + assert "$speckit-git-commit" in once + assert "/speckit-plan" in once def test_forge_core_command_hyphenated(self): """Forge installs hyphenated slash-commands (/speckit-), so the @@ -250,6 +268,11 @@ def test_hyphen_separator_core_command(self): result = IntegrationBase.resolve_command_refs(text, "-") assert result == "Run `/speckit-plan` to plan." + def test_dollar_prefix_core_command(self): + text = "Run `__SPECKIT_COMMAND_PLAN__` to plan." + result = IntegrationBase.resolve_command_refs(text, "-", "$") + assert result == "Run `$speckit-plan` to plan." + def test_multiple_placeholders(self): text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__" result = IntegrationBase.resolve_command_refs(text, ".") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 6899a5105a..f6c08d0a4e 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,6 +3,7 @@ import io import json import os +import runpy import pytest import yaml @@ -1180,6 +1181,23 @@ def test_hyphen_separator_in_page_templates(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content assert "/speckit-tasks" in content + def test_dollar_prefix_in_page_templates(self, tmp_path): + """Dollar-style skills agents get $speckit- in page templates.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "dollar-test" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra( + project, "sh", invoke_separator="-", invoke_prefix="$" + ) + + plan = project / ".specify" / "templates" / "plan-template.md" + content = plan.read_text(encoding="utf-8") + assert "$speckit-plan" in content + assert "/speckit-plan" not in content + @pytest.mark.parametrize("script_type", ["sh", "ps"]) def test_dot_separator_in_shared_scripts(self, tmp_path, script_type): """Markdown agents get /speckit. in shared script hints.""" @@ -1220,6 +1238,48 @@ def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type): assert "/speckit.plan" not in content assert "/speckit.tasks" not in content + @pytest.mark.parametrize("script_type", ["sh", "ps", "py"]) + def test_dollar_prefix_in_shared_scripts(self, tmp_path, script_type): + """Dollar-style skills agents get native prefixes in shared script hints.""" + from specify_cli import _install_shared_infra + + project = tmp_path / f"dollar-script-{script_type}" + project.mkdir() + (project / ".specify").mkdir() + + _install_shared_infra( + project, script_type, invoke_separator="-", invoke_prefix="$" + ) + + if script_type == "py": + state = { + "integration": "codex", + "integration_settings": { + "codex": {"invoke_separator": "-"}, + }, + } + (project / ".specify" / "integration.json").write_text( + json.dumps(state), encoding="utf-8" + ) + common = project / ".specify" / "scripts" / "python" / "common.py" + namespace = runpy.run_path(str(common)) + assert namespace["format_speckit_command"]("plan", project) == ( + "$speckit-plan" + ) + return + + content = self._combined_script_content(project, script_type) + assert "$speckit-specify" in content + assert "$speckit-plan" in content + assert "$speckit-tasks" in content + assert "/speckit-specify" not in content + assert "/speckit-plan" not in content + assert "/speckit-tasks" not in content + if script_type == "sh": + assert r"\$speckit-specify" in content + assert r"\$speckit-plan" in content + assert r"\$speckit-tasks" in content + def test_full_init_claude_resolves_page_templates(self, tmp_path): """Full CLI init with Claude (skills agent) produces hyphen refs in page templates.""" from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 79c6d3fb50..25551e1dc7 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -191,7 +191,7 @@ def test_hook_note_injected_for_each_instruction_independently(self): "---\n" "name: test\n" "---\n\n" - "- When constructing slash commands from hook command names, " + "- When constructing command invocations from hook command names, " "replace dots (`.`) with hyphens (`-`). " "For example, `speckit.git.commit` → `/speckit-git-commit`.\n" "- For each executable hook, output the following first block:\n" diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index ac32dd7250..d91620894a 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -12,6 +12,16 @@ class TestCodexIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" + def test_constitution_skill_uses_dollar_follow_up(self, tmp_path): + integration = get_integration("codex") + manifest = IntegrationManifest("codex", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + skill = tmp_path / ".agents/skills/speckit-constitution/SKILL.md" + content = skill.read_text(encoding="utf-8") + assert "$speckit-specify " in content + assert "/speckit-specify " not in content + class TestCodexInitFlow: """--integration codex creates expected files.""" @@ -98,6 +108,8 @@ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): assert "replace dots" in content, ( "speckit-specify should have dot-to-hyphen hook note" ) + assert "constructing command invocations" in content + assert "constructing slash commands" not in content def test_hook_note_not_in_skills_without_hooks(self): """Skills without hook sections should not get the note.""" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 3f079c438d..e295643505 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -201,6 +201,26 @@ def test_implement_loads_constitution_context(self, tmp_path): content = implement_file.read_text(encoding="utf-8") assert ".specify/memory/constitution.md" in content + def test_constitution_command_rejects_implementation_scope(self, tmp_path): + """The constitution command must defer non-governance work.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + constitution_file = ( + tmp_path / ".custom" / "cmds" / "speckit.constitution.md" + ) + assert constitution_file.exists() + content = constitution_file.read_text(encoding="utf-8") + + assert "## Scope Guard" in content + assert "MUST NOT" in content + assert "application source code" in content + assert "runtime guidance documents" not in content + assert "README.md" not in content + assert "Do not execute it during this command." in content + assert "Never invoke a suggested follow-up command automatically." in content + assert "/speckit.specify " in content + @pytest.mark.parametrize( "command_stem", [ diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 1a7d55d13e..9d93f6fa9e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1256,6 +1256,24 @@ def test_install_bare_project_gets_shared_infra(self, tmp_path): assert "/speckit-specify" in script_content assert "/speckit.specify" not in script_content + def test_install_dollar_skill_into_bare_project_gets_native_shared_refs( + self, tmp_path + ): + """A dollar-style integration supplies its prefix without a default.""" + project = tmp_path / "bare-codex" + project.mkdir() + (project / ".specify").mkdir() + + result = _run_in_project( + project, ["integration", "install", "codex", "--script", "sh"] + ) + + assert result.exit_code == 0, result.output + plan = project / ".specify" / "templates" / "plan-template.md" + plan_content = plan.read_text(encoding="utf-8") + assert "$speckit-plan" in plan_content + assert "/speckit-plan" not in plan_content + def test_install_defers_extension_commands_until_use(self, tmp_path): """Installing a second integration does not register enabled extensions. @@ -2289,7 +2307,7 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): assert opts["ai"] == "codex" template = project / ".specify" / "templates" / "plan-template.md" - assert "/speckit-plan" in template.read_text(encoding="utf-8") + assert "$speckit-plan" in template.read_text(encoding="utf-8") class TestIntegrationUpgrade: diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index f431d3e4a0..74b63f9997 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -1,5 +1,8 @@ """Tests for ZcodeIntegration — skills-based integration (Z.AI).""" +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_skills import SkillsIntegrationTests @@ -9,6 +12,16 @@ class TestZcodeIntegration(SkillsIntegrationTests): COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" + def test_constitution_skill_uses_dollar_follow_up(self, tmp_path): + integration = get_integration("zcode") + manifest = IntegrationManifest("zcode", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + skill = tmp_path / ".zcode/skills/speckit-constitution/SKILL.md" + content = skill.read_text(encoding="utf-8") + assert "$speckit-specify " in content + assert "/speckit-specify " not in content + class TestZcodeInvocation: """ZCode renders $speckit-* chat invocations (like Codex).""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 08831c3ae5..89ba5af90c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2812,6 +2812,29 @@ def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, assert "source: test-ext:commands/hello.md" in content assert "