Skip to content
49 changes: 49 additions & 0 deletions src/specify_cli/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,52 @@ def rewrite_project_relative_paths(
".specify.specify/", ".specify/"
)

@staticmethod
def rewrite_extension_paths(
text: str, extension_id: str, extension_dir: Path
) -> str:
"""Rewrite extension-relative paths to their installed locations.
Extension command bodies reference bundled files relative to the
extension root (e.g. ``agents/control/commander.md``). After install
those files live under ``.specify/extensions/<id>/``, so bare
references would resolve against the workspace root and never be
found (#2101).
Only directories that actually exist inside *extension_dir* are
rewritten, keeping the behaviour conservative and avoiding false
positives on prose. ``commands`` (slash-command sources), ``specs``
(user project artifacts) and dot-directories are never rewritten.
"""
if not isinstance(text, str) or not text:
return text

skip = {"commands", ".git", "specs"}
try:
subdirs = [
entry.name
for entry in extension_dir.iterdir()
if entry.is_dir()
and entry.name not in skip
and not entry.name.startswith(".")
]
except OSError:
return text

for subdir in subdirs:
# Only rewrite relative references (subdir/... or ./subdir/...);
# absolute paths like /subdir/... keep their meaning. Use a
# callable replacement: subdir/extension_id come from the
# filesystem and could contain backslashes or "\1"-like
# sequences, which would corrupt a string replacement template.
replacement = f".specify/extensions/{extension_id}/{subdir}/"
text = re.sub(
r'(^|[\s`"\'(])(?:\./)?' + re.escape(subdir) + "/",
lambda m: m.group(1) + replacement,
text,
)
return text

def render_markdown_command(
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
Expand Down Expand Up @@ -639,6 +685,9 @@ def register_commands(
frontmatter[key] = core_frontmatter[key]
frontmatter.pop("strategy", None)

if extension_id:
body = self.rewrite_extension_paths(body, extension_id, source_root)
Comment on lines +688 to +689

frontmatter = self._adjust_script_paths(
frontmatter, extension_id=extension_id
)
Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,11 @@ def _register_extension_skills(
frontmatter = registrar._adjust_script_paths(
frontmatter, extension_id=manifest.id
)
# Mirror the register_commands() rewrite (#2101): resolve
# extension-relative subdir references (agents/, knowledge-base/,
# etc.) to their installed .specify/extensions/<id>/ location
# before the generic placeholder/path resolution below.
body = registrar.rewrite_extension_paths(body, manifest.id, extension_dir)
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
Expand Down
42 changes: 40 additions & 2 deletions src/specify_cli/presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
matching_cmds, ext_id, ext_dir,
self.project_root,
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
extension_id=ext_id,
)
registered = True
except Exception:
Expand Down Expand Up @@ -1199,6 +1200,8 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"command_name": cmd_name,
"source_file": source_file,
"source": f"extension:{manifest.id}",
"extension_id": manifest.id,
"extension_dir": ext_root,
}
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
restore_index.setdefault(modern_skill_name, restore_info)
Expand Down Expand Up @@ -1463,6 +1466,17 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
if extension_restore:
content = extension_restore["source_file"].read_text(encoding="utf-8")
frontmatter, body = registrar.parse_frontmatter(content)
# Mirror the register-time rewrite (#2101): resolve
# extension-relative subdir references (agents/,
# knowledge-base/, etc.) to their installed location before
# the generic placeholder resolution below, otherwise
# restoring after a preset override removal would leave
# bare, unresolvable paths in the skill body.
body = registrar.rewrite_extension_paths(
body,
extension_restore["extension_id"],
extension_restore["extension_dir"],
)
if isinstance(selected_ai, str):
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
Expand Down Expand Up @@ -3038,6 +3052,8 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
"path": candidate,
"source": source,
"strategy": "replace",
"extension_id": ext_id,
"extension_dir": ext_dir,
})

# Priority 4: Core templates (always "replace")
Expand Down Expand Up @@ -3157,10 +3173,32 @@ def resolve_content(
if not layers:
return None

def _read_layer_content(layer: Dict[str, Any]) -> str:
"""Read a layer's raw text, rewriting extension-relative subdir
references (agents/, knowledge-base/, etc.) to their installed
location when the layer is extension-provided (#2101).

Extension layers are always inserted with strategy "replace"
(see collect_all_layers), so a layer only ever needs this
rewrite when it wins outright above or serves as the
composition base below — never as a mid-stack composing
(append/prepend/wrap) layer.
"""
text = layer["path"].read_text(encoding="utf-8")
extension_id = layer.get("extension_id")
extension_dir = layer.get("extension_dir")
if extension_id and extension_dir:
from ..agents import CommandRegistrar

text = CommandRegistrar.rewrite_extension_paths(
text, extension_id, extension_dir
)
return text

# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if layers[0]["strategy"] == "replace":
return layers[0]["path"].read_text(encoding="utf-8")
return _read_layer_content(layers[0])

# Composition: build content bottom-up from the effective base.
# The base is the nearest replace layer scanning from highest priority
Expand All @@ -3183,7 +3221,7 @@ def resolve_content(

# Convert to reversed_layers index
base_reversed_idx = len(layers) - 1 - base_layer_idx
content = layers[base_layer_idx]["path"].read_text(encoding="utf-8")
content = _read_layer_content(layers[base_layer_idx])
# Compose only the layers above the base (higher priority = lower index in layers,
# higher index in reversed_layers). Process bottom-up from base+1.
start_idx = base_reversed_idx + 1
Expand Down
59 changes: 59 additions & 0 deletions tests/test_extension_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,65 @@ def test_skill_registration_uses_extension_local_script_paths(self, project_dir,
assert ".specify/scripts/bash/resolve-skill.sh" not in content
assert ".specify/scripts/bash/ensure-skills.sh" not in content

def test_skill_registration_rewrites_extension_subdir_paths(self, project_dir, temp_dir):
"""Auto-registered skills should resolve extension-relative subdir
references (agents/, knowledge-base/) to their installed location,
matching the rewrite already applied by register_commands() (#2101)."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude")

ext_dir = temp_dir / "path-ext"
ext_dir.mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "path-ext",
"name": "Path Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.path-ext.run",
"file": "commands/run.md",
"description": "Run command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.safe_dump(manifest_data, f)

(ext_dir / "commands").mkdir()
(ext_dir / "agents" / "control").mkdir(parents=True)
(ext_dir / "agents" / "control" / "commander.md").write_text("# Commander\n")
(ext_dir / "knowledge-base").mkdir()
(ext_dir / "knowledge-base" / "agent-scores.yaml").write_text("scores: {}\n")
(ext_dir / "templates").mkdir()
(ext_dir / "templates" / "kill-report.md").write_text("# Kill Report\n")

(ext_dir / "commands" / "run.md").write_text(
"---\n"
"description: Run command\n"
"---\n\n"
"Read agents/control/commander.md and knowledge-base/agent-scores.yaml.\n"
"Use templates/kill-report.md as the report template.\n"
)

manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)

content = (skills_dir / "speckit-path-ext-run" / "SKILL.md").read_text()
assert ".specify/extensions/path-ext/agents/control/commander.md" in content
assert ".specify/extensions/path-ext/knowledge-base/agent-scores.yaml" in content
# extension's own templates/ dir must resolve under the extension,
# not the project-level .specify/templates/
assert ".specify/extensions/path-ext/templates/kill-report.md" in content
assert "Read agents/control" not in content
assert "and knowledge-base/" not in content

def test_missing_command_file_skipped(self, skills_project, temp_dir):
"""Commands with missing source files should be skipped gracefully."""
project_dir, skills_dir = skills_project
Expand Down
Loading