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
7 changes: 6 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 integration.dev_no_symlink:
config["dev_no_symlink"] = True
configs[key] = config
return configs

Expand Down Expand Up @@ -641,6 +643,7 @@ def register_commands(
output_name,
agent_config["extension"],
link_outputs,
agent_config,
)

if agent_name == "copilot":
Expand Down Expand Up @@ -715,6 +718,7 @@ def register_commands(
alias_output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
Expand All @@ -731,9 +735,10 @@ def _write_registered_output(
output_name: str,
extension: str,
link_outputs: bool,
agent_config: dict[str, Any] | None = None,
) -> None:
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
if not link_outputs:
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
dest_file.write_text(content, encoding="utf-8")
return

Expand Down
6 changes: 4 additions & 2 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,7 @@ def _register_extension_skills(
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)

for cmd_info in manifest.commands:
Expand Down Expand Up @@ -1012,13 +1013,14 @@ def _register_extension_skills(
skill_file = skill_subdir / "SKILL.md"
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install.
if not (
link_outputs
use_dev_symlink
and self._is_expected_dev_symlink(skill_file, cache_file)
):
continue
Expand Down Expand Up @@ -1089,7 +1091,7 @@ def _register_extension_skills(
skill_content
)

if link_outputs:
if use_dev_symlink:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(skill_content, encoding="utf-8")
Expand Down
3 changes: 3 additions & 0 deletions src/specify_cli/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""

dev_no_symlink: bool = False
"""Whether dev-mode registration should write files instead of symlinks."""

multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.

Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/integrations/codex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True

def build_exec_args(
Expand Down
6 changes: 6 additions & 0 deletions tests/test_agent_config_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)

def test_codex_dev_no_symlink_policy_in_agent_config(self):
"""Codex dev installs must expose the no-symlink policy as metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS

assert cfg["codex"].get("dev_no_symlink") is True

def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).
Expand Down
36 changes: 36 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4619,6 +4619,42 @@ def test_add_dev_links_copilot_agent_when_supported(
else:
assert not agent_file.is_symlink()

def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
"""Codex dev skills should be written as files so Codex can load them."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app

init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)

runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)

assert result.exit_code == 0, result.output

skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
assert skill_file.exists()
assert not skill_file.is_symlink()

content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content

def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
self, extension_dir, project_dir, monkeypatch
):
Expand Down