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
20 changes: 17 additions & 3 deletions design/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ demands them. Keep mode selection, invocation spelling, and registration in
sync. Agent-specific layouts and options belong in the integration code and
the [supported-integrations reference](../docs/reference/integrations.md).

Integration classes are also authoritative for command naming and invocation
rendering. Shared init, registration, extension, preset, hook, and runtime
callers resolve the active integration and use its naming and mode hooks rather
than maintaining agent-name lists. `registrar_config["format_name"]` controls
registered command names when present; skills otherwise use the shared
`speckit-<name>` convention. `invocation_prefix_for_mode()`,
`invoke_separator_for_mode()`, and `build_command_reference()` define displayed
and embedded command references. `build_hook_invocation()` preserves unrelated
custom hook commands unless an integration such as Cline, Forge, or Junie owns
their naming too. `build_command_invocation()` remains the runtime dispatch seam
for integrations such as Bob and Copilot whose executable behavior depends on
the installed layout.

Core templates that call scripts declare `sh`, `ps`, and `py` commands in
`scripts:` frontmatter; template processing replaces `{SCRIPT}` with the
selected variant. `py` is opt-in; non-interactive init defaults to `sh` on
Expand Down Expand Up @@ -157,9 +170,10 @@ directory must match `folder/commands_subdir`. Output paths
must be canonical, project-local, and outside `.git` and `.specify`.
Registration extensions must be plain dotted filename suffixes (such as
`.md`) or, for Markdown skills, `/SKILL.md`; path traversal is not allowed.
The public class attribute `invoke_separator` must be a non-empty string;
`dev_no_symlink` and `multi_install_safe` must be booleans, including when
registrar configuration supplies its own optional values.
The public class attributes `invoke_separator` and `invocation_prefix` must be
non-empty strings. `skills_invocation_only`, `dev_no_symlink`, and
`multi_install_safe` must be booleans, including when registrar configuration
supplies its own optional values.
An explicit `registrar_config.invoke_separator` takes precedence over the class
default. `dev_no_symlink` is enabled when either the class or registrar
configuration enables it. Healthy registration and persisted recovery metadata
Expand Down
93 changes: 0 additions & 93 deletions src/specify_cli/_invocation_style.py

This file was deleted.

75 changes: 44 additions & 31 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import yaml

from ._init_options import is_ai_skills_enabled, load_init_options
from ._invocation_style import get_invocation_prefix
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
Expand Down Expand Up @@ -582,18 +581,20 @@ def _compute_output_name(
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
format_name = agent_config.get("format_name")
if format_name:
return format_name(cmd_name)
return cmd_name
from specify_cli.integrations import get_integration

short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit.") :]
short_name = short_name.replace(".", "-")
integration = get_integration(agent_name)
if integration is not None:
return integration.format_registered_command_name(
cmd_name, agent_config
Comment thread
Copilot marked this conversation as resolved.
Comment on lines +586 to +589

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63db5158. Artifact resolution now executes the integration-owned filename lookup inside project_integrations(project_root). Extension update backup, preflight candidate tracking, and rollback cleanup all use a shared project-scoped naming helper, so a later same-key adapter load cannot combine project A's registrar snapshot with project B's naming hook.

Added two interleaved-project regressions: artifact resolution snapshots A, loads B, and still resolves A's materialized command; a failed extension update loads B after A's registrar snapshot, removes A's managed command, and verifies rollback restores the original A-named file.

Validation: complete external-adapter suite 685 passed; artifact resolution suites 160 passed; extension update transaction suites 56 passed; post-rebase focused regressions 16 passed.

AI disclosure: GitHub Copilot for the requester; GPT-5.6 Sol, autonomous implementation.

)

return f"speckit-{short_name}"
format_name = agent_config.get("format_name")
if callable(format_name):
return str(format_name(cmd_name))
if agent_config.get("extension") != "/SKILL.md":
return cmd_name
return f"speckit-{cmd_name.removeprefix('speckit.').replace('.', '-')}"

@staticmethod
def _ensure_inside(candidate: Path, base: Path) -> None:
Expand Down Expand Up @@ -738,15 +739,17 @@ def register_commands(
# ``.bob/commands``.
_sep = agent_config.get("invoke_separator", ".")
registrar_writes_skills = agent_config.get("extension") == "/SKILL.md"
_integration = None
try:
from specify_cli.integrations import get_integration # noqa: PLC0415

_integ = get_integration(agent_name)
if _integ is not None:
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
_integration = get_integration(agent_name)
if _integration is not None:
_sep = _integration.invoke_separator_for_mode(
registrar_writes_skills
)
except (ImportError, ValueError, KeyError):
pass
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)

for cmd_info in commands:
cmd_name = cmd_info["name"]
Expand Down Expand Up @@ -824,9 +827,15 @@ def register_commands(
frontmatter.pop(key, None)

if agent_config.get("inject_name") and not frontmatter.get("name"):
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
frontmatter["name"] = (
_integration.format_registered_command_name(
cmd_name, agent_config
)
if _integration is not None
else self._compute_output_name(
agent_name, cmd_name, agent_config
)
)

if is_cline_ext:
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
Expand All @@ -845,11 +854,16 @@ def register_commands(
# the project's persisted ai_skills state. Single-layout agents fall back
# to the static AGENT_CONFIGS value unchanged (invoke_separator_for_mode
# default).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
if _integration is not None:
body = _integration.resolve_command_refs_for_mode(
body,
skills_enabled=registrar_writes_skills,
)
else:
# Deferred import avoids a circular import at module load.
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415

body = IntegrationBase.resolve_command_refs(body, _sep, _prefix)
body = IntegrationBase.resolve_command_refs(body, _sep)

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

Expand Down Expand Up @@ -899,13 +913,7 @@ def register_commands(
raise ValueError(f"Unsupported format: {agent_config['format']}")

# -- Post-process for non-skills agents -----------------------
_integration = None
if agent_config["extension"] != "/SKILL.md":
from specify_cli.integrations import ( # noqa: PLC0415
get_integration,
)

_integration = get_integration(agent_name)
if _integration is not None:
output = _integration.post_process_command_content(output)

Expand Down Expand Up @@ -939,10 +947,14 @@ def register_commands(
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = (
format_name(alias) if format_name else alias
_integration.format_registered_command_name(
alias, agent_config
)
if _integration is not None
else self._compute_output_name(
agent_name, alias, agent_config
)
)

if agent_config["extension"] == "/SKILL.md":
Expand Down Expand Up @@ -1369,6 +1381,7 @@ def register_commands_for_non_skill_agents(
continue
return results

@project_registration
def unregister_commands(
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
Expand Down
12 changes: 8 additions & 4 deletions src/specify_cli/artifacts/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,10 @@ def _materialized_command_source_path(
except ImportError:
return None

from ..integrations.installer import IntegrationInstallError
from ..integrations.installer import (
IntegrationInstallError,
project_integrations,
)

try:
registrar = CommandRegistrar(project_root)
Expand All @@ -221,9 +224,10 @@ def _materialized_command_source_path(
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
if agent_config is None:
continue
output_name = registrar._compute_output_name(
agent_name, name, agent_config
)
with project_integrations(project_root):
output_name = registrar._compute_output_name(
agent_name, name, agent_config
)
command_path = (
registrar._resolve_agent_dir(agent_name, agent_config, project_root)
/ f"{output_name}{agent_config['extension']}"
Expand Down
13 changes: 5 additions & 8 deletions src/specify_cli/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1153,18 +1153,15 @@ def init(
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"

invoke_prefix = _invoke_prefix_for_integration(
resolved_integration,
resolved_integration.key,
integration_parsed_options or None,
project_path,
)
invoke_separator = resolved_integration.effective_invoke_separator(
invocation_skills_mode = resolved_integration.is_skills_mode(
integration_parsed_options or None, project_root=project_path
)

def _display_cmd(name: str) -> str:
return f"{invoke_prefix}speckit{invoke_separator}{name}"
return resolved_integration.build_command_reference(
name,
skills_enabled=invocation_skills_mode,
)

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