Skip to content

Commit c44354f

Browse files
committed
Resolve script paths to absolute at install/link time
1 parent d760051 commit c44354f

File tree

2 files changed

+142
-1
lines changed

2 files changed

+142
-1
lines changed

scripts/generate_codex.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ def detect_script_deps(body: str, plugin_dir: Path) -> list[Path]:
135135
return unique
136136

137137

138+
def resolve_script_paths(content: str, scripts_dir: Path) -> str:
139+
"""Replace relative scripts/foo.py references with absolute paths."""
140+
return re.sub(r"scripts/(\S+\.py)", lambda m: str(scripts_dir / m.group(1)), content)
141+
142+
138143
def process_skill(plugin_name: str, skill_dir: Path, output_dir: Path) -> None:
139144
"""Transform one skill and write to output_dir."""
140145
skill_md = skill_dir / "SKILL.md"
@@ -251,6 +256,10 @@ def install(dest: Path, plugins: list[str]) -> None:
251256
else:
252257
shutil.rmtree(target)
253258
shutil.copytree(skill_dir, target)
259+
skill_md = target / "SKILL.md"
260+
if skill_md.exists() and (target / "scripts").is_dir():
261+
content = skill_md.read_text()
262+
skill_md.write_text(resolve_script_paths(content, target / "scripts"))
254263
print(f"Copied {namespaced}{target}")
255264

256265

@@ -271,7 +280,15 @@ def link(dest: Path, plugins: list[str]) -> None:
271280
target.unlink()
272281
else:
273282
shutil.rmtree(target)
274-
target.symlink_to(skill_dir.resolve())
283+
scripts_subdir = skill_dir / "scripts"
284+
if scripts_subdir.is_dir():
285+
target.mkdir(parents=True)
286+
skill_md = skill_dir / "SKILL.md"
287+
content = skill_md.read_text()
288+
(target / "SKILL.md").write_text(resolve_script_paths(content, scripts_subdir.resolve()))
289+
(target / "scripts").symlink_to(scripts_subdir.resolve())
290+
else:
291+
target.symlink_to(skill_dir.resolve())
275292
print(f"Linked {namespaced}{skill_dir.resolve()}")
276293

277294

tests/test_generate_codex.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pathlib import Path
77
from types import ModuleType
88

9+
import pytest
10+
911
# Load the module dynamically since it's not in a proper package
1012
_script_path = Path(__file__).parent.parent / "scripts" / "generate_codex.py"
1113
_spec = importlib.util.spec_from_file_location("generate_codex", _script_path)
@@ -24,6 +26,9 @@
2426
transform_body = generate_codex.transform_body
2527
detect_script_deps = generate_codex.detect_script_deps
2628
process_skill = generate_codex.process_skill
29+
resolve_script_paths = generate_codex.resolve_script_paths
30+
install = generate_codex.install
31+
link = generate_codex.link
2732

2833
PLUGINS_DIR = Path(__file__).parent.parent / "plugins"
2934

@@ -318,3 +323,122 @@ def test_triage_skill(self, tmp_path: Path) -> None:
318323
result = (tmp_path / "oxgh" / "triage" / "SKILL.md").read_text()
319324
assert "name: oxgh:triage" in result
320325
assert "Execute each step as a separate command." in result
326+
327+
328+
class TestResolveScriptPaths:
329+
def test_replaces_relative_with_absolute(self) -> None:
330+
content = "Run `python3 scripts/wait_for_ai_review.py 42`"
331+
scripts_dir = Path("/opt/plugins/oxgh/scripts")
332+
result = resolve_script_paths(content, scripts_dir)
333+
assert result == "Run `python3 /opt/plugins/oxgh/scripts/wait_for_ai_review.py 42`"
334+
335+
def test_replaces_multiple_scripts(self) -> None:
336+
content = "Run `scripts/a.py` then `scripts/b.py`"
337+
scripts_dir = Path("/abs")
338+
result = resolve_script_paths(content, scripts_dir)
339+
assert "/abs/a.py" in result
340+
assert "/abs/b.py" in result
341+
342+
def test_no_change_without_scripts(self) -> None:
343+
content = "Just some text"
344+
scripts_dir = Path("/abs")
345+
result = resolve_script_paths(content, scripts_dir)
346+
assert result == content
347+
348+
349+
class TestInstall:
350+
def _make_codex_skill(self, codex_dir: Path, plugin: str, skill: str, content: str) -> None:
351+
skill_dir = codex_dir / plugin / skill
352+
skill_dir.mkdir(parents=True)
353+
(skill_dir / "SKILL.md").write_text(content)
354+
355+
def test_resolves_script_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
356+
codex_dir = tmp_path / "codex"
357+
self._make_codex_skill(
358+
codex_dir,
359+
"oxgh",
360+
"wait-for-review",
361+
"---\nname: oxgh:wait-for-review\n---\nRun `python3 scripts/wait.py 42`\n",
362+
)
363+
scripts_dir = codex_dir / "oxgh" / "wait-for-review" / "scripts"
364+
scripts_dir.mkdir()
365+
(scripts_dir / "wait.py").write_text("# script")
366+
367+
monkeypatch.setattr(generate_codex, "OUTPUT_DIR", codex_dir)
368+
369+
dest = tmp_path / "dest"
370+
dest.mkdir()
371+
install(dest, ["oxgh"])
372+
373+
installed = (dest / "oxgh:wait-for-review" / "SKILL.md").read_text()
374+
abs_path = str(dest / "oxgh:wait-for-review" / "scripts" / "wait.py")
375+
assert abs_path in installed
376+
# No bare relative reference (every occurrence should be absolute)
377+
assert installed.count("scripts/wait.py") == installed.count(abs_path)
378+
379+
def test_no_resolution_without_scripts(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
380+
codex_dir = tmp_path / "codex"
381+
self._make_codex_skill(codex_dir, "ox", "commit", "---\nname: ox:commit\n---\nJust text\n")
382+
383+
monkeypatch.setattr(generate_codex, "OUTPUT_DIR", codex_dir)
384+
385+
dest = tmp_path / "dest"
386+
dest.mkdir()
387+
install(dest, ["ox"])
388+
389+
installed = (dest / "ox:commit" / "SKILL.md").read_text()
390+
assert installed == "---\nname: ox:commit\n---\nJust text\n"
391+
392+
393+
class TestLink:
394+
def _make_codex_skill(self, codex_dir: Path, plugin: str, skill: str, content: str) -> None:
395+
skill_dir = codex_dir / plugin / skill
396+
skill_dir.mkdir(parents=True)
397+
(skill_dir / "SKILL.md").write_text(content)
398+
399+
def test_resolves_script_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
400+
codex_dir = tmp_path / "codex"
401+
self._make_codex_skill(
402+
codex_dir,
403+
"oxgh",
404+
"wait-for-review",
405+
"---\nname: oxgh:wait-for-review\n---\nRun `python3 scripts/wait.py 42`\n",
406+
)
407+
scripts_dir = codex_dir / "oxgh" / "wait-for-review" / "scripts"
408+
scripts_dir.mkdir()
409+
(scripts_dir / "wait.py").write_text("# script")
410+
411+
monkeypatch.setattr(generate_codex, "OUTPUT_DIR", codex_dir)
412+
413+
dest = tmp_path / "dest"
414+
dest.mkdir()
415+
link(dest, ["oxgh"])
416+
417+
target = dest / "oxgh:wait-for-review"
418+
# Should be a real directory (not a symlink to the whole skill dir)
419+
assert target.is_dir()
420+
assert not target.is_symlink()
421+
422+
# SKILL.md should have absolute paths
423+
linked = (target / "SKILL.md").read_text()
424+
abs_path = str(codex_dir / "oxgh" / "wait-for-review" / "scripts" / "wait.py")
425+
assert abs_path in linked
426+
assert linked.count("scripts/wait.py") == linked.count(abs_path)
427+
428+
# scripts/ should be a symlink to the source scripts dir
429+
linked_scripts = target / "scripts"
430+
assert linked_scripts.is_symlink()
431+
assert linked_scripts.resolve() == scripts_dir.resolve()
432+
433+
def test_simple_skill_still_symlinked(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
434+
codex_dir = tmp_path / "codex"
435+
self._make_codex_skill(codex_dir, "ox", "commit", "---\nname: ox:commit\n---\nJust text\n")
436+
437+
monkeypatch.setattr(generate_codex, "OUTPUT_DIR", codex_dir)
438+
439+
dest = tmp_path / "dest"
440+
dest.mkdir()
441+
link(dest, ["ox"])
442+
443+
target = dest / "ox:commit"
444+
assert target.is_symlink()

0 commit comments

Comments
 (0)