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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class CodexIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
user_command_prefix = "$"

@classmethod
def options(cls) -> list[IntegrationOption]:
Expand Down Expand Up @@ -223,6 +224,7 @@ The base classes handle most work automatically. Override only when the agent de
| Override | When to use | Example |
|---|---|---|
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
| `user_command_prefix` / `effective_user_command_prefix()` | User-facing command hints need a non-standard prefix | Codex → `$speckit-plan`, Kimi → `/skill:speckit-plan` |
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
Expand Down Expand Up @@ -357,6 +359,7 @@ via `--integration-options="--skills"`. When enabled:
- No `.vscode/settings.json` merge
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
- `build_user_command_invocation()` returns `/speckit-<stem>` for generated hints

The two modes are mutually exclusive — a project uses one or the other:

Expand Down
25 changes: 24 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from rich.align import Align
from rich.table import Table
from .integration_runtime import (
command_prefix_for_integration as _command_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
Expand Down Expand Up @@ -131,6 +132,7 @@ def _refresh_shared_templates(
project_path: Path,
*,
invoke_separator: str,
command_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
Expand All @@ -141,6 +143,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
command_prefix=command_prefix,
force=force,
)

Expand All @@ -151,6 +154,7 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
command_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
Expand All @@ -163,7 +167,8 @@ def _install_shared_infra(

Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
(``"."`` for markdown agents, ``"-"`` for skills agents).
and *command_prefix* (for example ``"."``/``"/"`` for markdown agents
or ``"-"``/``"$"`` for Codex skills).

Overwrite policy:

Expand Down Expand Up @@ -194,6 +199,7 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
command_prefix=command_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
Expand All @@ -205,6 +211,7 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
command_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
Expand All @@ -215,6 +222,7 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
command_prefix=command_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
Expand Down Expand Up @@ -846,6 +854,9 @@ def _set_default_integration(
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
command_prefix=_command_prefix_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=refresh_templates_force,
refresh_managed=True,
refresh_hint=refresh_hint,
Expand Down Expand Up @@ -1067,6 +1078,9 @@ def integration_install(
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
),
command_prefix=_command_prefix_for_integration(
infra_integration, current, infra_key, infra_parsed
),
)
if os.name != "nt":
ensure_executable_scripts(project_root)
Expand Down Expand Up @@ -1583,6 +1597,9 @@ def integration_switch(
invoke_separator=_invoke_separator_for_integration(
target_integration, current, target, parsed_options
),
command_prefix=_command_prefix_for_integration(
target_integration, current, target, parsed_options
),
refresh_hint=(
"To overwrite customizations, re-run with "
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
Expand Down Expand Up @@ -1767,6 +1784,9 @@ def integration_upgrade(
invoke_separator=_invoke_separator_for_integration(
infra_integration, current, infra_key, infra_parsed
),
command_prefix=_command_prefix_for_integration(
infra_integration, current, infra_key, infra_parsed
),
)
if os.name != "nt":
ensure_executable_scripts(project_root)
Expand Down Expand Up @@ -1799,6 +1819,9 @@ def integration_upgrade(
invoke_separator=_invoke_separator_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
command_prefix=_command_prefix_for_integration(
integration, {"integration_settings": settings}, key, parsed_options
),
force=force,
refresh_managed=True,
)
Expand Down
5 changes: 4 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ def _build_agent_configs() -> dict[str, Any]:
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
if "command_prefix" not in config:
config["command_prefix"] = integration.user_command_prefix
configs[key] = config
return configs

Expand Down Expand Up @@ -528,7 +530,8 @@ def register_commands(
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415

_sep = agent_config.get("invoke_separator", ".")
body = IntegrationBase.resolve_command_refs(body, _sep)
_prefix = agent_config.get("command_prefix", "/")
body = IntegrationBase.resolve_command_refs(body, _sep, prefix=_prefix)

output_name = self._compute_output_name(agent_name, cmd_name, agent_config)

Expand Down
24 changes: 14 additions & 10 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ def init(
ensure_executable_scripts,
save_init_options,
)
from ..integration_runtime import with_integration_setting as _with_integration_setting
from ..integration_runtime import (
command_prefix_for_integration as _command_prefix_for_integration,
with_integration_setting as _with_integration_setting,
)

show_banner()
ai_deprecation_warning: str | None = None
Expand Down Expand Up @@ -453,6 +456,12 @@ def init(
tracker=tracker,
force=force,
invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options),
command_prefix=_command_prefix_for_integration(
resolved_integration,
{"integration_settings": integration_settings},
resolved_integration.key,
integration_parsed_options,
),
)
tracker.complete("shared-infra", f"scripts ({selected_script}) + templates")

Expand Down Expand Up @@ -743,15 +752,10 @@ def init(
usage_label = "skills" if native_skill_mode else "slash commands"

def _display_cmd(name: str) -> str:
if codex_skill_mode or agy_skill_mode or trae_skill_mode:
return f"$speckit-{name}"
if claude_skill_mode:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"
return resolved_integration.build_user_command_invocation(
f"speckit.{name}",
parsed_options=integration_parsed_options or None,
)

steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:")

Expand Down
20 changes: 20 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2407,6 +2407,26 @@ def _render_hook_invocation(self, command: Any) -> str:
if not command_id:
return ""

if command_id.startswith("speckit."):
try:
from .integration_runtime import user_command_invocation_for_integration
from .integration_state import default_integration_key, try_read_integration_json
from .integrations import get_integration

state, _ = try_read_integration_json(self.project_root)
if state:
key = default_integration_key(state)
integration = get_integration(key) if key else None
if integration:
return user_command_invocation_for_integration(
integration,
state,
key,
command_id,
)
except Exception:
pass

init_options = self._load_init_options()
selected_ai = init_options.get("ai")
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
Expand Down
46 changes: 46 additions & 0 deletions src/specify_cli/integration_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def with_integration_setting(
current.pop("parsed_options", None)

current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
current["command_prefix"] = integration.effective_user_command_prefix(parsed_options)
settings[key] = current
return settings

Expand All @@ -88,3 +89,48 @@ def invoke_separator_for_integration(
return integration.effective_invoke_separator(stored_parsed)

return integration.effective_invoke_separator(None)


def command_prefix_for_integration(
integration: Any,
state: dict[str, Any],
key: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Resolve the user-facing command prefix for stored/default state."""
if parsed_options is not None:
return integration.effective_user_command_prefix(parsed_options)

setting = integration_setting(state, key)
stored_prefix = setting.get("command_prefix")
if isinstance(stored_prefix, str) and stored_prefix:
return stored_prefix

stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
return integration.effective_user_command_prefix(stored_parsed)

return integration.effective_user_command_prefix(None)


def user_command_invocation_for_integration(
integration: Any,
state: dict[str, Any],
key: str,
command_name: str,
args: str = "",
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Build a copy-pasteable command invocation for integration guidance."""
options = parsed_options
if options is None:
setting = integration_setting(state, key)
stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
options = stored_parsed

return integration.build_user_command_invocation(
command_name,
args,
parsed_options=options,
)
4 changes: 4 additions & 0 deletions src/specify_cli/integration_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]:
if isinstance(invoke_separator, str) and invoke_separator.strip():
clean["invoke_separator"] = invoke_separator.strip()

command_prefix = value.get("command_prefix")
if isinstance(command_prefix, str) and command_prefix.strip():
clean["command_prefix"] = command_prefix.strip()

if clean:
normalized[key.strip()] = clean

Expand Down
37 changes: 26 additions & 11 deletions src/specify_cli/integrations/agy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@

# Note injected into hook sections so agy maps dot-notation command
# names (from extensions.yml) to the hyphenated skill names it uses.
# Without this, agy emits ``/speckit.git.commit`` (which does not
# resolve) instead of ``/speckit-git-commit``.
# Without this, agy may emit ``/speckit.git.commit`` (which does not
# resolve) instead of ``$speckit-git-commit``.
_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"
"For example, `speckit.git.commit` → `$speckit-git-commit`.\n"
)


Expand All @@ -43,44 +43,59 @@ class AgyIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
user_command_prefix = "$"

@staticmethod
def _inject_hook_command_note(content: str) -> str:
@classmethod
def _inject_hook_command_note(cls, 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
note = cls._hook_command_note().rstrip("\n")

def repl(m: re.Match[str]) -> str:
indent = m.group(1)
instruction = m.group(2)
previous_lines = content[:m.start()].splitlines()
previous_line = previous_lines[-1] if previous_lines else ""
if previous_line == indent + note or (
previous_line.startswith(indent)
and (
"{command_invocation}" in previous_line
or cls.format_user_command_invocation(
"speckit.git.commit",
prefix=cls.user_command_prefix,
separator=cls.invoke_separator,
)
in previous_line
)
):
return m.group(0)
# ``eol`` is empty when the regex matched via ``$`` because the
# instruction was the final line of a file with no trailing
# newline. Default to ``\n`` so the note never collapses onto
# the same line as the instruction.
eol = m.group(3) or "\n"
return (
indent
+ _HOOK_COMMAND_NOTE.rstrip("\n")
+ note
+ eol
+ indent
+ instruction
+ eol
)

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

def post_process_skill_content(self, content: str) -> str:
"""Inject the dot-to-hyphen hook command note."""
return self._inject_hook_command_note(content)
return type(self)._inject_hook_command_note(content)

def build_exec_args(
self,
Expand Down
Loading