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
9 changes: 8 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,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 @@ -132,6 +133,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
command_prefix=command_prefix,
force=force,
)

Expand All @@ -142,6 +144,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 @@ -154,7 +157,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 @@ -185,6 +189,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 @@ -196,6 +201,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 @@ -206,6 +212,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
5 changes: 4 additions & 1 deletion src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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 @@ -591,7 +593,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
25 changes: 14 additions & 11 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,10 @@ def init(
_parse_integration_options,
_write_integration_json,
)
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 @@ -455,6 +458,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 @@ -728,7 +737,6 @@ def init(
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
cline_skill_mode = selected_ai == "cline"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode

if codex_skill_mode and not ai_skills:
Expand All @@ -746,15 +754,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 or cline_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
62 changes: 48 additions & 14 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2574,24 +2574,58 @@ 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:
# Hook rendering must keep working for projects with older or
# unreadable integration metadata; the init-options fallback
# below preserves the legacy behavior in that case.
pass
Comment thread
nike-17 marked this conversation as resolved.

init_options = self._load_init_options()
skill_name = self._skill_name_from_command(command_id)
selected_ai = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
cline_mode = selected_ai == "cline"
if skill_name and isinstance(selected_ai, str):
try:
from .integrations import get_integration
from .integrations.base import SkillsIntegration

skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
return f"${skill_name}"
if claude_skill_mode and skill_name:
return f"/{skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
integration = get_integration(selected_ai)
except Exception:
integration = None

if integration is not None:
parsed_options = {"skills": True} if ai_skills_enabled else None
if isinstance(integration, SkillsIntegration) and (
ai_skills_enabled or selected_ai == "kimi"
):
return integration.build_user_command_invocation(
command_id,
parsed_options=parsed_options,
)
if selected_ai == "copilot" and ai_skills_enabled:
return integration.build_user_command_invocation(
command_id,
parsed_options=parsed_options,
)

cline_mode = selected_ai == "cline"
if cline_mode:
from .integrations.cline import format_cline_command_name

Expand Down
40 changes: 40 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,42 @@ 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."""
return integration.format_user_command_invocation(
command_name,
args,
prefix=command_prefix_for_integration(integration, state, key, parsed_options),
separator=invoke_separator_for_integration(integration, state, key, parsed_options),
)
Comment thread
nike-17 marked this conversation as resolved.
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
4 changes: 4 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
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 @@ -364,6 +365,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
4 changes: 4 additions & 0 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
command_prefix_for_integration as _command_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
Expand Down Expand Up @@ -129,6 +130,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":
from .. import ensure_executable_scripts
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from .._console import console
from ..integration_runtime import (
command_prefix_for_integration as _command_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
Expand Down Expand Up @@ -235,6 +236,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 @@ -421,6 +425,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":
from .. import ensure_executable_scripts
Expand Down Expand Up @@ -454,6 +461,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
Loading