agent-skilltree 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {agent_skilltree-0.3.0/src/agent_skilltree.egg-info → agent_skilltree-0.4.0}/PKG-INFO +10 -3
  2. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/README.md +9 -2
  3. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/pyproject.toml +1 -1
  4. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0/src/agent_skilltree.egg-info}/PKG-INFO +10 -3
  5. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/SOURCES.txt +1 -0
  6. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/cli.py +27 -6
  7. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/cohere.py +50 -8
  8. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/exchange.py +13 -7
  9. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/forest.py +9 -1
  10. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/materialize.py +29 -9
  11. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/model.py +37 -2
  12. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/validate.py +5 -2
  13. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_cohere.py +1 -1
  14. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_exchange.py +4 -2
  15. agent_skilltree-0.4.0/tests/test_portable.py +109 -0
  16. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_skilltree.py +4 -2
  17. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/LICENSE +0 -0
  18. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/setup.cfg +0 -0
  19. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/dependency_links.txt +0 -0
  20. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/entry_points.txt +0 -0
  21. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/requires.txt +0 -0
  22. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/agent_skilltree.egg-info/top_level.txt +0 -0
  23. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/__init__.py +0 -0
  24. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/federation.py +0 -0
  25. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/mapper.py +0 -0
  26. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/marketplace.py +0 -0
  27. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/registry.py +0 -0
  28. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/reports.py +0 -0
  29. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/search.py +0 -0
  30. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/src/skilltree/tome.py +0 -0
  31. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_coords.py +0 -0
  32. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_emit_roundtrip.py +0 -0
  33. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_federation.py +0 -0
  34. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_folder_search.py +0 -0
  35. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_forest.py +0 -0
  36. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_glyph_search.py +0 -0
  37. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_mapper.py +0 -0
  38. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_marketplace.py +0 -0
  39. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_notifications.py +0 -0
  40. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_registry.py +0 -0
  41. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_reports.py +0 -0
  42. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_search.py +0 -0
  43. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_summaries.py +0 -0
  44. {agent_skilltree-0.3.0 → agent_skilltree-0.4.0}/tests/test_tome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-skilltree
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation.
5
5
  Author: Isaac Wostrel-Rubin
6
6
  License: MIT
@@ -83,9 +83,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
83
83
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
84
84
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
85
85
  | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
86
- | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
86
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize). Projecting is an install, so it localizes the tree's breadcrumbs first |
87
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
87
88
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
88
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
89
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
89
90
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
90
91
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
91
92
 
@@ -130,6 +131,12 @@ Two papers document the project:
130
131
 
131
132
  ## Changelog
132
133
 
134
+ ### v0.4.0 — 2026-10-23
135
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
136
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
137
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
138
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
139
+
133
140
  ### v0.3.0 — 2026-07-09
134
141
  - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
135
142
  rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
@@ -69,9 +69,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
69
69
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
70
70
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
71
71
  | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
72
- | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
72
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize). Projecting is an install, so it localizes the tree's breadcrumbs first |
73
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
73
74
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
74
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
75
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
75
76
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
76
77
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
77
78
 
@@ -116,6 +117,12 @@ Two papers document the project:
116
117
 
117
118
  ## Changelog
118
119
 
120
+ ### v0.4.0 — 2026-10-23
121
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
122
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
123
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
124
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
125
+
119
126
  ### v0.3.0 — 2026-07-09
120
127
  - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
121
128
  rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-skilltree"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-skilltree
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A coordinate-addressed tree of agent skill dirs wired by Read-tool breadcrumbs, with programmatic validation.
5
5
  Author: Isaac Wostrel-Rubin
6
6
  License: MIT
@@ -83,9 +83,10 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
83
83
  | `map <folder> [--write]` | render a flat folder into **one** coordinate-addressed `CLAUDE.md` (Folder Map + addressable Index + branch summaries) — progressive disclosure over a flat pile |
84
84
  | `search <folder> <query> [--scope <coord>]` | FTS5/BM25 over **any** folder; `--scope` restricts to a coordinate subtree when the folder carries skilltree coordinates (a plain folder = coordinate-free search) |
85
85
  | `fold <framework_dir> --into <node> --tree <root>` | **bind** a framework (its skill dir) into a volume node's `## Frameworks` tome table — idempotent, one row per framework; the manifest is the source of truth and the node's SKILL.md is regenerated from it |
86
- | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize) |
86
+ | `project <root> <target> [--policy flat\|progressive] [--link absolute\|relative] [--node N]` | **deploy** a tree into a skills dir as symlinks: `progressive` surfaces root + first-layer branches (the former `link_tree`); `flat` projects a volume's tome rows + holder so every held framework auto-discovers top-level (the former nomicon-atomize). Projecting is an install, so it localizes the tree's breadcrumbs first |
87
+ | `relink <root> [--localize]` | rewrite only the breadcrumb paths: **portable** (the default and the shipped form — relative to each node's folder, no machine's path in any file) or `--localize` (absolute for this machine — the install-time step) |
87
88
  | `cohere <root>` | report drift between the on-disk tree and its coherent shape |
88
- | `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
89
+ | `emit <root> [--root-forest] [--localize]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled); breadcrumbs portable unless `--localize` |
89
90
  | `unemit <root>` | reverse the last `emit`, byte-for-byte |
90
91
  | `discover` · `validate` · `build` · `notify` · `watch` | read a tree · gate breadcrumb-resolvability · materialize from a manifest · write the notification rule · run the decoherence cron |
91
92
 
@@ -130,6 +131,12 @@ Two papers document the project:
130
131
 
131
132
  ## Changelog
132
133
 
134
+ ### v0.4.0 — 2026-10-23
135
+ - **Trees ship portable.** Every breadcrumb names its child relative to the node's folder (the folder holding the SKILL.md's `.claude/`), and the manifest stores `skill_src` relative to itself, so a materialized tree carries no machine's path and validates wherever it is copied — with no step. `materialize`, `emit` and `exchange build` take `localize=True` (`--localize`) to write absolute paths for one machine; the new `relink` rewrites only the paths, either way; `project` localizes the tree it projects (a projected tree is read through a symlink, where only an absolute path resolves). `validate` accepts both forms. 80 tests (7 new, in `tests/test_portable.py`).
136
+ - **Developed in one source home and published here.** This repository and PyPI `agent-skilltree` are the published output of SkillTree's source home; changes arrive as releases, each with a version and an entry here.
137
+ - Frontmatter quotes YAML-unsafe scalars (#1), merged after 0.3.0 and not yet released.
138
+ - The figure script writes beside itself instead of to a fixed folder; a test comment names the author by role.
139
+
133
140
  ### v0.3.0 — 2026-07-09
134
141
  - **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
135
142
  rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
@@ -33,6 +33,7 @@ tests/test_glyph_search.py
33
33
  tests/test_mapper.py
34
34
  tests/test_marketplace.py
35
35
  tests/test_notifications.py
36
+ tests/test_portable.py
36
37
  tests/test_registry.py
37
38
  tests/test_reports.py
38
39
  tests/test_search.py
@@ -55,11 +55,14 @@ def cohere_cmd(root: Path) -> None:
55
55
  @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
56
56
  @click.option("--root-forest", is_flag=True, help="tree-ify a bare forest (synthesize + save a root)")
57
57
  @click.option("--name", "forest_name", default=None, help="name for the synthesized root")
58
- def emit_cmd(root: Path, root_forest: bool, forest_name: str | None) -> None:
58
+ @click.option("--localize", is_flag=True,
59
+ help="write breadcrumbs absolute for THIS machine (install time); default is portable")
60
+ def emit_cmd(root: Path, root_forest: bool, forest_name: str | None, localize: bool) -> None:
59
61
  """Re-cohere IN PLACE: reassign coords + rewrite breadcrumbs/index from the
60
- canonical shape (non-destructive). With --root-forest, root a bare forest."""
62
+ canonical shape (non-destructive). With --root-forest, root a bare forest.
63
+ Breadcrumbs are portable (relative to each node's folder) unless --localize."""
61
64
  from .cohere import emit
62
- report = emit(root, root_forest=root_forest, forest_name=forest_name)
65
+ report = emit(root, root_forest=root_forest, forest_name=forest_name, localize=localize)
63
66
  if not report.get("ok"):
64
67
  click.echo(f"✗ {report.get('error')}")
65
68
  raise SystemExit(1)
@@ -70,6 +73,21 @@ def emit_cmd(root: Path, root_forest: bool, forest_name: str | None) -> None:
70
73
  click.echo(f" journal: {report['journal']} (reverse with `skilltree unemit`)")
71
74
 
72
75
 
76
+ @main.command(name="relink")
77
+ @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
78
+ @click.option("--localize", is_flag=True, help="absolute paths for THIS machine (install time)")
79
+ def relink_cmd(root: Path, localize: bool) -> None:
80
+ """Rewrite only the breadcrumb paths, in place: portable (the shipped form, relative to
81
+ each node's folder) or --localize (absolute, the install-time step). Nothing else moves."""
82
+ from .cohere import relink
83
+ report = relink(root, localize=localize)
84
+ if not report.get("ok"):
85
+ click.echo(f"✗ {report.get('error')}")
86
+ raise SystemExit(1)
87
+ form = "localized (absolute)" if localize else "portable (relative)"
88
+ click.echo(f"✓ relinked {report['nodes']} node(s): {form}")
89
+
90
+
73
91
  @main.command(name="unemit")
74
92
  @click.argument("root", type=click.Path(exists=True, file_okay=False, path_type=Path))
75
93
  def unemit_cmd(root: Path) -> None:
@@ -216,10 +234,12 @@ def reports_cmd(reports_path, kind):
216
234
  @main.command(name="build")
217
235
  @click.argument("manifest", type=click.Path(exists=True, dir_okay=False, path_type=Path))
218
236
  @click.argument("root", type=click.Path(path_type=Path))
219
- def build_cmd(manifest: Path, root: Path) -> None:
237
+ @click.option("--localize", is_flag=True,
238
+ help="write breadcrumbs absolute for THIS machine; default is portable")
239
+ def build_cmd(manifest: Path, root: Path, localize: bool) -> None:
220
240
  """Materialize a SkillTree from a JSON MANIFEST into ROOT, then validate."""
221
241
  tree = SkillTree.load(manifest)
222
- materialize(tree, root)
242
+ materialize(tree, root, localize=localize)
223
243
  issues = validate(root)
224
244
  errors = [v for v in issues if v.severity == "error"]
225
245
  click.echo(f"built {len(tree.nodes())} nodes at {root}")
@@ -276,7 +296,8 @@ def fold_cmd(framework_dir: Path, into: str, tree_root: Path, what_for: str | No
276
296
  @click.option("--node", default=None, help="(flat) the volume node to project (name or coord; default root)")
277
297
  def project_cmd(tree_root: Path, target: Path, policy: str, link: str, node: str | None) -> None:
278
298
  """Project TREE_ROOT into TARGET (a skills dir) as symlinks — progressive
279
- disclosure, or a volume's flat auto-discoverable deployment."""
299
+ disclosure, or a volume's flat auto-discoverable deployment. Projecting is an install:
300
+ TREE_ROOT's breadcrumbs are localized (absolute) for this machine first."""
280
301
  from .forest import project
281
302
  links = project(tree_root, target, policy=policy, link=link, node=node)
282
303
  click.echo(f"✓ projected {len(links)} link(s) ({policy}, {link}) into {target}")
@@ -24,7 +24,8 @@ import shutil
24
24
  from dataclasses import dataclass, field
25
25
  from pathlib import Path
26
26
 
27
- from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
27
+ from .model import (PORTABLE_NOTE, SkillTree, TreeNode, assign_coords, compose_summary, crumb_path,
28
+ skill_name, tome_table)
28
29
 
29
30
  # dirs that are never tree nodes
30
31
  _SKIP = {".claude", "kb", "__pycache__", ".git", "node_modules", ".aios.src"}
@@ -217,7 +218,7 @@ def cohere(root: str | Path) -> list[Finding]:
217
218
 
218
219
 
219
220
  # ── emit: rewrite coords + breadcrumbs + index IN PLACE (non-destructive) ─────
220
- def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
221
+ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path, localize: bool = False) -> None:
221
222
  """Refresh ONLY the coord prefix + Index/Descend sections of an existing
222
223
  SKILL.md, preserving the hand-written body above them. Creates the file (a
223
224
  stub) if absent (e.g. a freshly synthesized forest root)."""
@@ -245,13 +246,15 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
245
246
  if node.children:
246
247
  crumbs = [_CRUMB.format(
247
248
  name=skill_name(c), kind=c.kind,
248
- path=(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md").resolve())
249
+ path=crumb_path(node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md",
250
+ node_dir, localize=localize))
249
251
  for c in node.children]
250
252
  tail = (f"\n\n## Index summary\n{compose_summary(node, full=True)}\n\n"
251
253
  f"## Descend — the next layer ({len(node.children)})\n"
252
254
  "Only this layer is loaded now. To descend, use the **Read tool** on a child "
253
255
  "below — that injects the child's layer. A Bash `cat` reads the bytes but loads "
254
- "nothing; you must use the Read tool:\n\n" + "\n".join(crumbs))
256
+ "nothing; you must use the Read tool."
257
+ + ("" if localize else f" {PORTABLE_NOTE}") + "\n\n" + "\n".join(crumbs))
255
258
  else:
256
259
  tail = ""
257
260
  if node.frameworks: # regenerate the tome table from the model rows
@@ -260,11 +263,47 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
260
263
  smd.write_text(out, encoding="utf-8")
261
264
 
262
265
 
266
+ def relink(root: str | Path, *, localize: bool = False) -> dict:
267
+ """Rewrite ONLY the breadcrumb paths of a materialized tree, in place: PORTABLE (relative
268
+ to each node's folder — the shipped form) or LOCALIZED (absolute for this machine — the
269
+ install-time step). Coords, names, bodies and tome tables are untouched."""
270
+ root = Path(root)
271
+ manifest = root / "skilltree.json"
272
+ if not manifest.is_file():
273
+ return {"ok": False, "error": f"no skilltree.json manifest at {root}"}
274
+ tree = SkillTree.load(manifest)
275
+ n = 0
276
+
277
+ def walk(node: TreeNode, node_dir: Path) -> None:
278
+ nonlocal n
279
+ smd = node_dir / ".claude" / "skills" / skill_name(node) / "SKILL.md"
280
+ if node.children and smd.is_file():
281
+ text = smd.read_text(encoding="utf-8")
282
+ for c in node.children:
283
+ child_md = node_dir / c.name / ".claude" / "skills" / skill_name(c) / "SKILL.md"
284
+ pat = re.compile(r"^(- " + re.escape(skill_name(c)) + r" \(" + re.escape(c.kind)
285
+ + r"\): Read `)[^`]+(`)", re.M)
286
+ path = crumb_path(child_md, node_dir, localize=localize)
287
+ text = pat.sub(lambda m: m.group(1) + path + m.group(2), text)
288
+ text = text.replace("you must use the Read tool:", "you must use the Read tool.")
289
+ text = text.replace(" " + PORTABLE_NOTE, "")
290
+ if not localize:
291
+ text = text.replace("you must use the Read tool.",
292
+ "you must use the Read tool. " + PORTABLE_NOTE, 1)
293
+ smd.write_text(text, encoding="utf-8")
294
+ n += 1
295
+ for c in node.children:
296
+ walk(c, node_dir / c.name)
297
+
298
+ walk(tree.root, root)
299
+ return {"ok": True, "nodes": n, "localized": localize}
300
+
301
+
263
302
  JOURNAL = ".emit-journal.json" # the reversibility receipt written by a tree-ifying emit
264
303
 
265
304
 
266
305
  def emit(root: str | Path, *, root_forest: bool = False,
267
- forest_name: str | None = None) -> dict:
306
+ forest_name: str | None = None, localize: bool = False) -> dict:
268
307
  """Re-cohere the tree IN PLACE: assign coords + rewrite every node's
269
308
  breadcrumbs/index from the canonical shape, without destroying content.
270
309
 
@@ -272,7 +311,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
272
311
  a root, and **tree-ify it**: each flat leaf is MOVED WHOLESALE into its nested
273
312
  node-dir (all its files preserved — *not* a body-only re-render), so it stops
274
313
  auto-loading. Every move is JOURNALED to `.emit-journal.json` so `unemit` can
275
- reverse it exactly (your cache-for-reversibility requirement)."""
314
+ reverse it exactly (your cache-for-reversibility requirement).
315
+
316
+ Breadcrumbs are written PORTABLE (relative to each node's folder) unless `localize=True`,
317
+ which writes them absolute for the place the tree is installed — the install-time step."""
276
318
  root = Path(root)
277
319
  manifest = root / "skilltree.json"
278
320
  synthesized = False
@@ -311,10 +353,10 @@ def emit(root: str | Path, *, root_forest: bool = False,
311
353
  journal.append({"op": "move", "from": str(old_src), "to": str(target_dir),
312
354
  "skill_md": orig})
313
355
  shutil.move(str(old_src), str(target_dir))
314
- _rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
356
+ _rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
315
357
  else:
316
358
  existed = (target_dir / "SKILL.md").exists()
317
- _rewrite_node_md(target_dir / "SKILL.md", node, node_dir)
359
+ _rewrite_node_md(target_dir / "SKILL.md", node, node_dir, localize)
318
360
  if synthesized and not existed: # a freshly-created node (the synth root)
319
361
  journal.append({"op": "create", "path": str(target_dir)})
320
362
  n += 1
@@ -63,30 +63,35 @@ def _front(name: str, description: str, body: str) -> str:
63
63
  return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
64
64
 
65
65
 
66
- def build(exchange: Exchange, repo_dir: str | Path) -> Path:
67
- """Materialize every member tree + the master. Returns the master SKILL.md path."""
66
+ def build(exchange: Exchange, repo_dir: str | Path, *, localize: bool = False) -> Path:
67
+ """Materialize every member tree + the master. Returns the master SKILL.md path.
68
+ Breadcrumbs are portable (relative to the repo) unless `localize=True`."""
68
69
  repo = Path(repo_dir)
69
70
  members: list[tuple[str, Path]] = [] # (member name, member root SKILL.md)
70
71
  for m in exchange.trees:
71
72
  tree = SkillTree.load((exchange.base / m.manifest).resolve())
72
73
  tree_dir = repo / "trees" / m.name
73
- materialize(tree, tree_dir)
74
+ materialize(tree, tree_dir, localize=localize)
74
75
  members.append((m.name, node_skill_md(tree_dir, tree.root.name).resolve()))
75
76
 
76
77
  master_md = node_skill_md(repo, exchange.master)
77
78
  master_md.parent.mkdir(parents=True, exist_ok=True)
78
- crumbs = [f"- {name} (tree): Read `{root_md}`" for name, root_md in members]
79
+ from .model import PORTABLE_NOTE, crumb_path
80
+ crumbs = [f"- {name} (tree): Read `{crumb_path(root_md, repo, localize=localize)}`"
81
+ for name, root_md in members]
79
82
  body = (f"Master of the **{exchange.name}** exchange — {len(members)} skill tree(s).\n\n"
80
83
  "## Trees — pick one and **Read** (the Read tool) into its root\n"
81
84
  "Only this master is loaded. Read a tree's root below to load it (a Bash `cat` won't); "
82
- "each tree then walks its own breadcrumbs:\n\n"
85
+ "each tree then walks its own breadcrumbs." + ("" if localize else f" {PORTABLE_NOTE}")
86
+ + "\n\n"
83
87
  + "\n".join(crumbs))
84
88
  master_md.write_text(_front(exchange.master, f"master of the {exchange.name} exchange", body),
85
89
  encoding="utf-8")
86
90
 
87
91
  (repo / "exchange.lock.json").write_text(json.dumps(
88
92
  {"name": exchange.name, "master": exchange.master,
89
- "trees": [{"name": n, "root": str(r)} for n, r in members]}, indent=2), encoding="utf-8")
93
+ "trees": [{"name": n, "root": crumb_path(r, repo, localize=localize)} for n, r in members]},
94
+ indent=2), encoding="utf-8")
90
95
  return master_md
91
96
 
92
97
 
@@ -104,7 +109,8 @@ def validate(repo_dir: str | Path, exchange: Exchange) -> list[Violation]:
104
109
  return out
105
110
  if not _has_frontmatter(master_md):
106
111
  out.append(Violation("error", exchange.master, "master SKILL.md lacks frontmatter"))
107
- found = {Path(p).resolve() for p in _CRUMB_RE.findall(master_md.read_text(encoding="utf-8"))}
112
+ found = {(Path(p) if Path(p).is_absolute() else repo / p).resolve()
113
+ for p in _CRUMB_RE.findall(master_md.read_text(encoding="utf-8"))}
108
114
  expected = {
109
115
  node_skill_md(repo / "trees" / m.name, SkillTree.load((exchange.base / m.manifest).resolve()).root.name).resolve()
110
116
  for m in exchange.trees
@@ -65,6 +65,13 @@ def project(tree_dir: str | Path, target: str | Path, *,
65
65
  overwrites cleanly (idempotent). Self-clobber-guarded skips return no link.
66
66
  """
67
67
  tree_dir = Path(tree_dir)
68
+ # A projected tree is read THROUGH a symlink, where only an absolute breadcrumb resolves —
69
+ # so projecting is an install: localize the tree's breadcrumbs for this machine first.
70
+ # (A committed copy localized this way names this machine until `skilltree emit` is run
71
+ # again without --localize; the publishing gate refuses machine paths.)
72
+ if (tree_dir / "skilltree.json").is_file():
73
+ from .cohere import relink
74
+ relink(tree_dir, localize=True)
68
75
  tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
69
76
  target = Path(target)
70
77
  rel = link == "relative"
@@ -143,7 +150,8 @@ def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str |
143
150
  body = (f"Forest **{name}** — {len(members)} skill tree(s). "
144
151
  "Pick one and **Read** (the Read tool) into its root to load it, then follow its "
145
152
  "breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
146
- (fdir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {desc}\n---\n\n{body}\n", encoding="utf-8")
153
+ from .materialize import _yaml_scalar as _ys
154
+ (fdir / "SKILL.md").write_text(f"---\nname: {_ys(name)}\ndescription: {_ys(desc)}\n---\n\n{body}\n", encoding="utf-8")
147
155
  return _symlink(Path(user_skills_dir) / name, fdir)
148
156
 
149
157
 
@@ -14,12 +14,15 @@ Leaves carry the actual skill content (from skill_src); no further breadcrumbs.
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ import re
17
18
  from pathlib import Path
18
19
  import shutil
19
20
 
20
- from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
21
+ from .model import (PORTABLE_NOTE, SkillTree, TreeNode, assign_coords, compose_summary, crumb_path,
22
+ skill_name, tome_table)
21
23
 
22
- # A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<abspath>``
24
+ # A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<path>`` — the path
25
+ # portable (relative to the node's folder) or localized (absolute); see model.crumb_path.
23
26
  # The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
24
27
  # .claude layer — a Bash `cat` reads the bytes but loads nothing (verified 2026-06-18).
25
28
  _CRUMB = "- {name} ({kind}): Read `{path}`"
@@ -29,11 +32,24 @@ def node_skill_md(node_dir: Path, node_name: str) -> Path:
29
32
  return node_dir / ".claude" / "skills" / node_name / "SKILL.md"
30
33
 
31
34
 
35
+ def _yaml_scalar(v: str) -> str:
36
+ """Quote a frontmatter value when bare YAML would misparse it — e.g. a
37
+ coord-prefixed description `[0.8.4] …` reads as a flow sequence and
38
+ strict parsers reject the whole file (issue #1). json.dumps yields a
39
+ valid YAML double-quoted scalar."""
40
+ import json as _json
41
+ if re.match(r"[\[\{&*!|>%@`'\"]", v) or ": " in v or v.startswith("- "):
42
+ return _json.dumps(v)
43
+ return v
44
+
45
+
32
46
  def _front(name: str, description: str, body: str) -> str:
33
- return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
47
+ return (f"---\nname: {_yaml_scalar(name)}\n"
48
+ f"description: {_yaml_scalar(description)}\n---\n\n"
49
+ f"{body.rstrip()}\n")
34
50
 
35
51
 
36
- def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
52
+ def _write_node(node: TreeNode, node_dir: Path, root: Path, localize: bool = False) -> None:
37
53
  sname = skill_name(node) # coord-prefixed identity (or plain name)
38
54
  skill_md = node_skill_md(node_dir, sname)
39
55
  skill_md.parent.mkdir(parents=True, exist_ok=True)
@@ -54,13 +70,15 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
54
70
  desc = compose_summary(node, full=False)
55
71
  summary = compose_summary(node, full=True)
56
72
  crumbs = [_CRUMB.format(name=skill_name(c), kind=c.kind,
57
- path=node_skill_md(node_dir / c.name, skill_name(c)).resolve())
73
+ path=crumb_path(node_skill_md(node_dir / c.name, skill_name(c)),
74
+ node_dir, localize=localize))
58
75
  for c in node.children]
59
76
  body = (f"{base}\n\n## Index summary\n{summary}\n\n"
60
77
  f"## Descend — the next layer ({len(node.children)})\n"
61
78
  "Only this layer is loaded now. To descend, use the **Read tool** on a child "
62
79
  "below — that injects the child's layer (its persona + skills). A Bash `cat` "
63
- "reads the bytes but loads nothing; you must use the Read tool:\n\n"
80
+ "reads the bytes but loads nothing; you must use the Read tool."
81
+ + ("" if localize else f" {PORTABLE_NOTE}") + "\n\n"
64
82
  + "\n".join(crumbs))
65
83
  else:
66
84
  body = base + "\n\n_(leaf — this is an actual skill.)_"
@@ -78,17 +96,19 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
78
96
 
79
97
  # recurse: each child's dir is a sibling of this node's .claude (tree-path uses plain name)
80
98
  for child in node.children:
81
- _write_node(child, node_dir / child.name, root)
99
+ _write_node(child, node_dir / child.name, root, localize)
82
100
 
83
101
 
84
102
  def materialize(tree: SkillTree, root: str | Path, *, overwrite: bool = True,
85
- coords: bool = False, base: str = "0") -> Path:
103
+ coords: bool = False, base: str = "0", localize: bool = False) -> Path:
104
+ """Write the tree as nested dirs. Breadcrumbs are PORTABLE by default (relative to each
105
+ node's folder — no machine's path in any file); `localize=True` writes them absolute."""
86
106
  root = Path(root)
87
107
  if coords:
88
108
  assign_coords(tree.root, base) # address every node before writing
89
109
  if overwrite and root.exists():
90
110
  shutil.rmtree(root)
91
111
  root.mkdir(parents=True, exist_ok=True)
92
- _write_node(tree.root, root, root) # root node's dir IS the tree root
112
+ _write_node(tree.root, root, root, localize) # root node's dir IS the tree root
93
113
  tree.save(root / "skilltree.json")
94
114
  return root
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from dataclasses import dataclass, field
15
15
  import json
16
+ import os
16
17
  from pathlib import Path
17
18
 
18
19
  KINDS = ("ac", "cor", "sc", "skill")
@@ -71,6 +72,21 @@ def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
71
72
  f"Reachable below: {', '.join(reachable)}.")
72
73
 
73
74
 
75
+ # A breadcrumb names its child's SKILL.md one of two ways. PORTABLE (the default): relative to
76
+ # the node's folder — the folder that holds this SKILL.md's `.claude/` — so a tree carries no
77
+ # machine's path and validates wherever it is copied. LOCALIZED: absolute, for a tree that is
78
+ # installed or projected (read through a symlink, where only an absolute path resolves).
79
+ PORTABLE_NOTE = ("Each path below is relative to this node's folder — the folder that holds this "
80
+ "file's `.claude/` — so join it to that folder before you Read it.")
81
+
82
+
83
+ def crumb_path(child_md: Path, node_dir: Path, *, localize: bool = False) -> str:
84
+ """The path a breadcrumb names (see PORTABLE_NOTE)."""
85
+ if localize:
86
+ return str(Path(child_md).resolve())
87
+ return Path(os.path.relpath(os.path.abspath(child_md), os.path.abspath(node_dir))).as_posix()
88
+
89
+
74
90
  TOME_HEADING = "## Frameworks"
75
91
 
76
92
 
@@ -138,13 +154,32 @@ class SkillTree:
138
154
  return SkillTree(dec(data["skilltree"]))
139
155
 
140
156
  def save(self, path: str | Path) -> Path:
157
+ """Write the manifest. Every `skill_src` is stored RELATIVE to the manifest's own
158
+ folder, so a committed manifest names no machine's path; `load` resolves it back."""
141
159
  p = Path(path)
142
- p.write_text(json.dumps(self.to_manifest(), indent=2), encoding="utf-8")
160
+ data = self.to_manifest()
161
+ base = os.path.abspath(p.parent)
162
+
163
+ def rel(d: dict) -> None:
164
+ s = d.get("skill_src")
165
+ if s and os.path.isabs(s):
166
+ d["skill_src"] = Path(os.path.relpath(os.path.abspath(s), base)).as_posix()
167
+ for c in d.get("children", []):
168
+ rel(c)
169
+ rel(data["skilltree"])
170
+ p.write_text(json.dumps(data, indent=2), encoding="utf-8")
143
171
  return p
144
172
 
145
173
  @staticmethod
146
174
  def load(path: str | Path) -> "SkillTree":
147
- return SkillTree.from_manifest(json.loads(Path(path).read_text(encoding="utf-8")))
175
+ """Read a manifest; a relative `skill_src` resolves against the manifest's folder."""
176
+ p = Path(path)
177
+ tree = SkillTree.from_manifest(json.loads(p.read_text(encoding="utf-8")))
178
+ base = os.path.abspath(p.parent)
179
+ for n in tree.root.walk():
180
+ if n.skill_src and not os.path.isabs(n.skill_src):
181
+ n.skill_src = os.path.normpath(os.path.join(base, n.skill_src))
182
+ return tree
148
183
 
149
184
  def nodes(self) -> list[TreeNode]:
150
185
  return list(self.root.walk())
@@ -23,7 +23,8 @@ import re
23
23
 
24
24
  from .model import KINDS, TOME_HEADING, SkillTree, TreeNode, skill_name
25
25
 
26
- # a breadcrumb is `- <name> (<kind>): Read `<abspath>`` — match the backticked SKILL.md path
26
+ # a breadcrumb is `- <name> (<kind>): Read `<path>`` — match the backticked SKILL.md path;
27
+ # the path is relative to the node's folder (portable) or absolute (localized)
27
28
  # (verb-agnostic, so it survives wording tweaks; the verb is the Read tool, never `cat`)
28
29
  _CRUMB_RE = re.compile(r"`([^`]+/SKILL\.md)`")
29
30
  # a tome-table data row: `| name | what-for | name (path) |` (skip header/separator rows)
@@ -64,7 +65,9 @@ def _walk(node: TreeNode, node_dir: Path, out: list[Violation]) -> None:
64
65
 
65
66
  if node.children:
66
67
  text = skill_md.read_text(encoding="utf-8")
67
- found = {Path(m).resolve() for m in _CRUMB_RE.findall(text)}
68
+ # a portable breadcrumb is relative to the node's folder; a localized one is absolute
69
+ found = {(Path(m) if Path(m).is_absolute() else node_dir / m).resolve()
70
+ for m in _CRUMB_RE.findall(text)}
68
71
  expected = {_skill_md(node_dir / c.name, skill_name(c)).resolve() for c in node.children}
69
72
  for missing in expected - found:
70
73
  out.append(Violation("error", node.name, f"no Read breadcrumb for child → {missing}"))
@@ -107,7 +107,7 @@ def test_mixed_coord_flat_forest_is_still_a_bare_forest(tmp_path):
107
107
  def test_emit_is_lossless_and_reversible(tmp_path):
108
108
  """emit moves the WHOLE skill dir (baggage preserved, unlike the old body-only
109
109
  re-render) and journals it; unemit replays the journal backwards for an exact
110
- round-trip. (Isaac's 'if we move it we have to cache what happened' requirement.)"""
110
+ round-trip. (the author's 'if we move it we have to cache what happened' requirement.)"""
111
111
  d = tmp_path / "proj"
112
112
  cs = d / ".claude" / "skills"
113
113
  (cs / "alpha").mkdir(parents=True)
@@ -35,8 +35,10 @@ def test_build_exchange_master_and_members(tmp_path: Path):
35
35
  assert exchange_is_valid(repo, ex)
36
36
  # master breadcrumbs into each member tree's root
37
37
  body = master.read_text()
38
- assert str(node_skill_md(repo / "trees" / "cognition", "cognition").resolve()) in body
39
- assert str(node_skill_md(repo / "trees" / "writing", "writing").resolve()) in body
38
+ # portable by default: relative to the master's folder (the repo), no machine path
39
+ assert "trees/cognition/.claude/skills/cognition/SKILL.md" in body
40
+ assert "trees/writing/.claude/skills/writing/SKILL.md" in body
41
+ assert str(tmp_path) not in body
40
42
  # each member tree is materialized + valid on its own
41
43
  assert node_skill_md(repo / "trees" / "cognition" / "reason", "reason").is_file()
42
44
 
@@ -0,0 +1,109 @@
1
+ """A tree ships PORTABLE — no machine's path in any file — and is LOCALIZED at install.
2
+
3
+ Portable breadcrumbs are relative to each node's folder; the manifest stores `skill_src`
4
+ relative to itself. A portable tree validates wherever it is copied, with no step at all;
5
+ `localize=True` (materialize / emit) writes absolute breadcrumbs for one machine, and
6
+ projecting a tree (an install read through symlinks) localizes it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ from skilltree import SkillTree, TreeNode, materialize, validate
15
+ from skilltree.cohere import emit, relink
16
+ from skilltree.forest import project
17
+ from skilltree.materialize import node_skill_md
18
+
19
+
20
+ def _src(tmp_path: Path, name: str) -> Path:
21
+ d = tmp_path / "src" / name
22
+ d.mkdir(parents=True)
23
+ (d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: the {name} skill\n---\n\nbody of {name}\n")
24
+ return d
25
+
26
+
27
+ def _tree(tmp_path: Path) -> SkillTree:
28
+ return SkillTree(TreeNode("root", "sc", description="the root", children=[
29
+ TreeNode("a", "cor", description="branch a", children=[
30
+ TreeNode("a1", "ac", skill_src=str(_src(tmp_path, "a1")))]),
31
+ TreeNode("b", "skill", skill_src=str(_src(tmp_path, "b")))]))
32
+
33
+
34
+ def _all_text(root: Path) -> str:
35
+ return "\n".join(p.read_text(encoding="utf-8") for p in root.rglob("*")
36
+ if p.is_file() and p.suffix in (".md", ".json"))
37
+
38
+
39
+ def _errors(root: Path) -> list:
40
+ return [v for v in validate(root) if v.severity == "error"]
41
+
42
+
43
+ def test_default_tree_names_no_machine_path_and_validates(tmp_path: Path):
44
+ root = tmp_path / "tree"
45
+ materialize(_tree(tmp_path), root, coords=True)
46
+ assert str(tmp_path) not in _all_text(root) # no absolute path anywhere
47
+ assert _errors(root) == []
48
+
49
+
50
+ def test_portable_tree_validates_after_a_move_with_no_step(tmp_path: Path):
51
+ root = tmp_path / "tree"
52
+ materialize(_tree(tmp_path), root, coords=True)
53
+ moved = tmp_path / "elsewhere" / "deep" / "tree"
54
+ shutil.copytree(root, moved)
55
+ shutil.rmtree(root) # the original place is gone
56
+ assert _errors(moved) == []
57
+
58
+
59
+ def test_manifest_stores_skill_src_relative_and_load_resolves_it(tmp_path: Path):
60
+ root = tmp_path / "tree"
61
+ materialize(_tree(tmp_path), root)
62
+ raw = json.loads((root / "skilltree.json").read_text())
63
+ leaf = raw["skilltree"]["children"][1]
64
+ assert not Path(leaf["skill_src"]).is_absolute()
65
+ loaded = SkillTree.load(root / "skilltree.json")
66
+ assert Path(loaded.root.children[1].skill_src).resolve() == (tmp_path / "src" / "b").resolve()
67
+
68
+
69
+ def test_localize_writes_absolute_and_still_validates(tmp_path: Path):
70
+ root = tmp_path / "tree"
71
+ materialize(_tree(tmp_path), root, localize=True)
72
+ body = node_skill_md(root, "root").read_text()
73
+ assert str(node_skill_md(root / "a", "a").resolve()) in body
74
+ assert _errors(root) == []
75
+
76
+
77
+ def test_emit_switches_between_forms(tmp_path: Path):
78
+ root = tmp_path / "tree"
79
+ materialize(_tree(tmp_path), root, coords=True)
80
+ assert emit(root, localize=True)["ok"]
81
+ assert str(root.resolve()) in node_skill_md(root, "0-root").read_text()
82
+ assert _errors(root) == []
83
+ assert emit(root)["ok"] # default: portable again
84
+ assert str(tmp_path) not in _all_text(root)
85
+ assert _errors(root) == []
86
+
87
+
88
+ def test_project_localizes_the_projected_tree(tmp_path: Path):
89
+ root = tmp_path / "tree"
90
+ materialize(_tree(tmp_path), root, coords=True)
91
+ skills = tmp_path / "user" / "skills"
92
+ links = project(root, skills, policy="progressive")
93
+ assert links and all(p.exists() for p in links)
94
+ seen = (skills / "0-root" / "SKILL.md").read_text() # read THROUGH the symlink
95
+ crumb = str(node_skill_md(root / "a", "0.1-a").resolve())
96
+ assert crumb in seen and Path(crumb).is_file()
97
+
98
+
99
+ def test_relink_switches_paths_and_touches_nothing_else(tmp_path: Path):
100
+ root = tmp_path / "tree"
101
+ materialize(_tree(tmp_path), root) # no coords: names must stay plain
102
+ before = sorted(str(p.relative_to(root)) for p in root.rglob("SKILL.md"))
103
+ assert relink(root, localize=True)["ok"]
104
+ assert str(root.resolve()) in node_skill_md(root, "root").read_text()
105
+ assert _errors(root) == []
106
+ assert relink(root)["ok"]
107
+ assert str(tmp_path) not in _all_text(root)
108
+ assert sorted(str(p.relative_to(root)) for p in root.rglob("SKILL.md")) == before
109
+ assert _errors(root) == []
@@ -30,8 +30,10 @@ def test_root_body_has_cat_breadcrumbs_for_each_child(tmp_path: Path):
30
30
  materialize(_tree(), root)
31
31
  body = node_skill_md(root, "cc-skill-tree").read_text()
32
32
  assert "cat" in body
33
- assert str(node_skill_md(root / "a", "a").resolve()) in body
34
- assert str(node_skill_md(root / "b", "b").resolve()) in body
33
+ # portable by default: each child's path relative to this node's folder, no machine path
34
+ assert "Read `a/.claude/skills/a/SKILL.md`" in body
35
+ assert "Read `b/.claude/skills/b/SKILL.md`" in body
36
+ assert str(tmp_path) not in body
35
37
 
36
38
 
37
39
  def test_dead_breadcrumb_is_caught(tmp_path: Path):
File without changes