agent-skilltree 0.2.2__tar.gz → 0.3.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.
- {agent_skilltree-0.2.2/src/agent_skilltree.egg-info → agent_skilltree-0.3.0}/PKG-INFO +23 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/README.md +22 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/pyproject.toml +1 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0/src/agent_skilltree.egg-info}/PKG-INFO +23 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/SOURCES.txt +3 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/__init__.py +15 -4
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/cli.py +43 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/cohere.py +8 -3
- agent_skilltree-0.3.0/src/skilltree/forest.py +170 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/materialize.py +7 -1
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/model.py +39 -0
- agent_skilltree-0.3.0/src/skilltree/tome.py +86 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/validate.py +36 -1
- agent_skilltree-0.3.0/tests/test_tome.py +185 -0
- agent_skilltree-0.2.2/src/skilltree/forest.py +0 -101
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/LICENSE +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/setup.cfg +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/dependency_links.txt +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/entry_points.txt +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/requires.txt +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/top_level.txt +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/exchange.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/federation.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/mapper.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/marketplace.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/registry.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/reports.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/skilltree/search.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_cohere.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_coords.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_emit_roundtrip.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_exchange.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_federation.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_folder_search.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_forest.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_glyph_search.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_mapper.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_marketplace.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_notifications.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_registry.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_reports.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_search.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_skilltree.py +0 -0
- {agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/tests/test_summaries.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-skilltree
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -82,6 +82,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
82
82
|
| `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
|
|
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
|
+
| `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) |
|
|
85
87
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
86
88
|
| `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
|
|
87
89
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
@@ -128,6 +130,26 @@ Two papers document the project:
|
|
|
128
130
|
|
|
129
131
|
## Changelog
|
|
130
132
|
|
|
133
|
+
### v0.3.0 — 2026-07-09
|
|
134
|
+
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
135
|
+
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
136
|
+
What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
|
|
137
|
+
the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
|
|
138
|
+
**`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
|
|
139
|
+
row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
|
|
140
|
+
link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
|
|
141
|
+
(`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
|
|
142
|
+
frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
|
|
143
|
+
**tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
|
|
144
|
+
undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
|
|
145
|
+
tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
|
|
146
|
+
and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
|
|
147
|
+
Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
|
|
148
|
+
**70 passing** (+11 tome tests).
|
|
149
|
+
- Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
|
|
150
|
+
("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
|
|
151
|
+
keep working (verified against the reference atomizer end-to-end).
|
|
152
|
+
|
|
131
153
|
### v0.2.0 — 2026-06-18
|
|
132
154
|
- **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
|
|
133
155
|
|
|
@@ -68,6 +68,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
68
68
|
| `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
|
|
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
|
+
| `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) |
|
|
71
73
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
72
74
|
| `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
|
|
73
75
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
@@ -114,6 +116,26 @@ Two papers document the project:
|
|
|
114
116
|
|
|
115
117
|
## Changelog
|
|
116
118
|
|
|
119
|
+
### v0.3.0 — 2026-07-09
|
|
120
|
+
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
121
|
+
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
122
|
+
What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
|
|
123
|
+
the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
|
|
124
|
+
**`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
|
|
125
|
+
row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
|
|
126
|
+
link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
|
|
127
|
+
(`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
|
|
128
|
+
frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
|
|
129
|
+
**tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
|
|
130
|
+
undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
|
|
131
|
+
tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
|
|
132
|
+
and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
|
|
133
|
+
Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
|
|
134
|
+
**70 passing** (+11 tome tests).
|
|
135
|
+
- Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
|
|
136
|
+
("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
|
|
137
|
+
keep working (verified against the reference atomizer end-to-end).
|
|
138
|
+
|
|
117
139
|
### v0.2.0 — 2026-06-18
|
|
118
140
|
- **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
|
|
119
141
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-skilltree"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.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
|
+
Version: 0.3.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
|
|
@@ -82,6 +82,8 @@ One CLI for the whole skill-substrate toolkit — `skilltree <command> …`:
|
|
|
82
82
|
| `tree <root>` | show the coordinate tree on disk (every node by its `<coord>-<name>` address) |
|
|
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
|
+
| `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) |
|
|
85
87
|
| `cohere <root>` | report drift between the on-disk tree and its coherent shape |
|
|
86
88
|
| `emit <root> [--root-forest]` | re-cohere in place; `--root-forest` tree-ifies a bare forest (lossless, journaled) |
|
|
87
89
|
| `unemit <root>` | reverse the last `emit`, byte-for-byte |
|
|
@@ -128,6 +130,26 @@ Two papers document the project:
|
|
|
128
130
|
|
|
129
131
|
## Changelog
|
|
130
132
|
|
|
133
|
+
### v0.3.0 — 2026-07-09
|
|
134
|
+
- **The tome bind ops (SkillVolume/SkillTome).** A node can now HOLD frameworks: `TreeNode.frameworks`
|
|
135
|
+
rows live in the manifest and render as a generated `## Frameworks` pointer-table (`| Framework |
|
|
136
|
+
What it's for | Framework skill |`) on the node's SKILL.md — regenerated by both `materialize` and
|
|
137
|
+
the in-place re-cohere (`emit`), so a folded table can never be wiped or drift. New ops:
|
|
138
|
+
**`fold(framework_dir, into=..., tree_root=...)`** (bind a framework into a volume — idempotent, one
|
|
139
|
+
row per framework, surgical single-node rewrite) and **`project(tree, target, policy={progressive,flat},
|
|
140
|
+
link={absolute,relative}, node=...)`** — ONE deployment op unifying the top-level symlink surfacing
|
|
141
|
+
(`link_tree` is now a thin wrapper) with the flat auto-discoverable projection of a volume's held
|
|
142
|
+
frameworks (tome rows + holder, self-clobber-guarded, relative-link-capable). `validate` grew the
|
|
143
|
+
**tome-table round-trip**: manifest rows must appear in the table, every row's target must resolve,
|
|
144
|
+
undeclared table rows warn. CLI: `skilltree fold [--relative]`, `skilltree project`. **Repo-portable
|
|
145
|
+
tomes:** `fold(relative=True)` stores rows relative to the holder dir, so a committed tome validates
|
|
146
|
+
and projects from ANY checkout — the CI flow is `emit` (localize the breadcrumbs) then `validate`.
|
|
147
|
+
Fixed `--version` (the click probe needed the `agent-skilltree` distribution name). Suite →
|
|
148
|
+
**70 passing** (+11 tome tests).
|
|
149
|
+
- Vocabulary: a branch holding frameworks is a **SkillVolume**; the overall corpus is a **SkillTome**
|
|
150
|
+
("nomicon" is the legacy alias). The table heading stays `## Frameworks` — existing table parsers
|
|
151
|
+
keep working (verified against the reference atomizer end-to-end).
|
|
152
|
+
|
|
131
153
|
### v0.2.0 — 2026-06-18
|
|
132
154
|
- **Folded the `skillmap` / `foldersearch` / `skillsearch` seedlings into skilltree as subcommands — one product.** `skilltree map <folder> [--write]` renders a flat folder into one coordinate-addressed `CLAUDE.md`, using skilltree's **own** coordinate code (`assign_coords`/`skill_name` from `model.py`; the vendored copy is gone). `skilltree search <folder> <query> [--scope <coord>]` is **one** FTS5/BM25 engine over any folder — coordinate-free on a plain folder, coordinate-subtree-scoped when the folder carries skilltree coordinates (`foldersearch` + `skillsearch` unified; the `--scope` flag is the only difference). Added the `tree` view command. The `<coord>-<name>` frontmatter and the Read-breadcrumb format are preserved (backward compatible). Suite grew 56 → **62 tests**.
|
|
133
155
|
|
|
@@ -20,6 +20,7 @@ src/skilltree/model.py
|
|
|
20
20
|
src/skilltree/registry.py
|
|
21
21
|
src/skilltree/reports.py
|
|
22
22
|
src/skilltree/search.py
|
|
23
|
+
src/skilltree/tome.py
|
|
23
24
|
src/skilltree/validate.py
|
|
24
25
|
tests/test_cohere.py
|
|
25
26
|
tests/test_coords.py
|
|
@@ -36,4 +37,5 @@ tests/test_registry.py
|
|
|
36
37
|
tests/test_reports.py
|
|
37
38
|
tests/test_search.py
|
|
38
39
|
tests/test_skilltree.py
|
|
39
|
-
tests/test_summaries.py
|
|
40
|
+
tests/test_summaries.py
|
|
41
|
+
tests/test_tome.py
|
|
@@ -11,14 +11,23 @@ from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
|
11
11
|
try:
|
|
12
12
|
__version__ = _pkg_version("agent-skilltree")
|
|
13
13
|
except PackageNotFoundError: # running from a source checkout that isn't pip-installed
|
|
14
|
-
__version__ = "0.
|
|
14
|
+
__version__ = "0.3.0"
|
|
15
15
|
|
|
16
16
|
from .exchange import Exchange, Member, load_exchange
|
|
17
17
|
from .exchange import build as build_exchange
|
|
18
18
|
from .exchange import is_valid as exchange_is_valid
|
|
19
19
|
from .exchange import validate as validate_exchange
|
|
20
20
|
from .materialize import materialize
|
|
21
|
-
from .model import
|
|
21
|
+
from .model import (
|
|
22
|
+
TOME_HEADING,
|
|
23
|
+
SkillTree,
|
|
24
|
+
TreeNode,
|
|
25
|
+
assign_coords,
|
|
26
|
+
compose_summary,
|
|
27
|
+
find_node,
|
|
28
|
+
skill_name,
|
|
29
|
+
tome_table,
|
|
30
|
+
)
|
|
22
31
|
from .registry import (
|
|
23
32
|
Entry,
|
|
24
33
|
load_registry,
|
|
@@ -27,7 +36,8 @@ from .registry import (
|
|
|
27
36
|
validate_registry,
|
|
28
37
|
)
|
|
29
38
|
from .registry import search as registry_search
|
|
30
|
-
from .forest import build_forest, link_tree, list_links, unlink
|
|
39
|
+
from .forest import build_forest, link_tree, list_links, project, unlink
|
|
40
|
+
from .tome import fold
|
|
31
41
|
from .reports import (
|
|
32
42
|
list_reports,
|
|
33
43
|
mark_problem,
|
|
@@ -58,7 +68,8 @@ __all__ = [
|
|
|
58
68
|
"promote", "registry_search",
|
|
59
69
|
"walk_federation", "flatten_federation", "validate_federation",
|
|
60
70
|
"register_child", "local_resolver",
|
|
61
|
-
"link_tree", "build_forest", "list_links", "unlink",
|
|
71
|
+
"link_tree", "build_forest", "list_links", "unlink", "project",
|
|
72
|
+
"fold", "tome_table", "find_node", "TOME_HEADING",
|
|
62
73
|
"assign_coords", "skill_name",
|
|
63
74
|
"build_index", "search", "search_tree", "search_folder", "DEFAULT_EXTS",
|
|
64
75
|
"build_map", "write_map",
|
|
@@ -12,7 +12,7 @@ from .validate import validate
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@click.group()
|
|
15
|
-
@click.version_option(message="skilltree %(version)s")
|
|
15
|
+
@click.version_option(package_name="agent-skilltree", message="skilltree %(version)s")
|
|
16
16
|
def main() -> None:
|
|
17
17
|
"""SkillTree — a nested tree of skill dirs wired by `cat`-breadcrumbs (validated)."""
|
|
18
18
|
|
|
@@ -242,6 +242,48 @@ def validate_cmd(root: Path) -> None:
|
|
|
242
242
|
click.echo("✓ valid — every breadcrumb resolves.")
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
@main.command(name="fold")
|
|
246
|
+
@click.argument("framework_dir", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
247
|
+
@click.option("--into", required=True, help="the volume node to bind into (name or coord)")
|
|
248
|
+
@click.option("--tree", "tree_root", required=True,
|
|
249
|
+
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
250
|
+
help="the materialized tree root (holds skilltree.json)")
|
|
251
|
+
@click.option("--what-for", default=None, help="the row's purpose text (default: the framework's description)")
|
|
252
|
+
@click.option("--name", default=None, help="the row name (default: the framework's frontmatter name)")
|
|
253
|
+
@click.option("--relative", is_flag=True,
|
|
254
|
+
help="store the row path relative to the volume node (repo-portable: validates/projects from any checkout)")
|
|
255
|
+
def fold_cmd(framework_dir: Path, into: str, tree_root: Path, what_for: str | None,
|
|
256
|
+
name: str | None, relative: bool) -> None:
|
|
257
|
+
"""Fold a FRAMEWORK_DIR into a volume node's tome table (idempotent, one row
|
|
258
|
+
per framework). The manifest is updated and the node's SKILL.md regenerated."""
|
|
259
|
+
from .tome import fold
|
|
260
|
+
rep = fold(framework_dir, into=into, tree_root=tree_root, what_for=what_for, name=name,
|
|
261
|
+
relative=relative)
|
|
262
|
+
if not rep.get("ok"):
|
|
263
|
+
click.echo(f"✗ {rep.get('error')}")
|
|
264
|
+
raise SystemExit(1)
|
|
265
|
+
verb = "updated" if rep["updated"] else "added"
|
|
266
|
+
click.echo(f"✓ {verb} row {rep['row']['name']!r} on node {rep['node']!r} "
|
|
267
|
+
f"({rep['rows']} framework(s) held)")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@main.command(name="project")
|
|
271
|
+
@click.argument("tree_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
272
|
+
@click.argument("target", type=click.Path(path_type=Path))
|
|
273
|
+
@click.option("--policy", type=click.Choice(["progressive", "flat"]), default="progressive",
|
|
274
|
+
help="progressive = root + first-layer branches; flat = a volume's tome rows + holder")
|
|
275
|
+
@click.option("--link", type=click.Choice(["absolute", "relative"]), default="absolute")
|
|
276
|
+
@click.option("--node", default=None, help="(flat) the volume node to project (name or coord; default root)")
|
|
277
|
+
def project_cmd(tree_root: Path, target: Path, policy: str, link: str, node: str | None) -> None:
|
|
278
|
+
"""Project TREE_ROOT into TARGET (a skills dir) as symlinks — progressive
|
|
279
|
+
disclosure, or a volume's flat auto-discoverable deployment."""
|
|
280
|
+
from .forest import project
|
|
281
|
+
links = project(tree_root, target, policy=policy, link=link, node=node)
|
|
282
|
+
click.echo(f"✓ projected {len(links)} link(s) ({policy}, {link}) into {target}")
|
|
283
|
+
for p in links:
|
|
284
|
+
click.echo(f" - {p.name} -> {p.readlink()}")
|
|
285
|
+
|
|
286
|
+
|
|
245
287
|
@main.group()
|
|
246
288
|
def exchange() -> None:
|
|
247
289
|
"""Many skill trees in one repo, under a master."""
|
|
@@ -24,14 +24,17 @@ 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
|
|
27
|
+
from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
|
|
28
28
|
|
|
29
29
|
# dirs that are never tree nodes
|
|
30
30
|
_SKIP = {".claude", "kb", "__pycache__", ".git", "node_modules", ".aios.src"}
|
|
31
31
|
# a coord-prefixed skill identity: "0.1.2-some-name" → ("0.1.2", "some-name")
|
|
32
32
|
_COORD_RE = re.compile(r"^(\d+(?:\.\d+)*)-(.+)$")
|
|
33
|
-
# everything from the first generated section to EOF (materialize appends them last)
|
|
34
|
-
|
|
33
|
+
# everything from the first generated section to EOF (materialize appends them last).
|
|
34
|
+
# Generated sections, in emit order: Index summary → Descend (branches), then the
|
|
35
|
+
# `## Frameworks` tome table (any node with frameworks rows) — leftmost heading wins,
|
|
36
|
+
# so a leaf-with-table strips from Frameworks and a branch strips from Index summary.
|
|
37
|
+
_SECT_RE = re.compile(r"\n+## (?:Index summary|Frameworks)\b.*\Z", re.S)
|
|
35
38
|
_CRUMB_RE = re.compile(r"`([^`]+?)/SKILL\.md`") # the path-before-SKILL.md, verb-agnostic
|
|
36
39
|
_CRUMB = "- {name} ({kind}): Read `{path}`" # the verb is the Read TOOL, never `cat`
|
|
37
40
|
|
|
@@ -251,6 +254,8 @@ def _rewrite_node_md(smd: Path, node: TreeNode, node_dir: Path) -> None:
|
|
|
251
254
|
"nothing; you must use the Read tool:\n\n" + "\n".join(crumbs))
|
|
252
255
|
else:
|
|
253
256
|
tail = ""
|
|
257
|
+
if node.frameworks: # regenerate the tome table from the model rows
|
|
258
|
+
tail += f"\n\n{tome_table(node)}"
|
|
254
259
|
out = f"---\nname: {sname}\ndescription: {desc}\n---\n\n{base}{tail}\n"
|
|
255
260
|
smd.write_text(out, encoding="utf-8")
|
|
256
261
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Make a SkillTree (or a forest of them) visible from the TOP — `~/.claude/skills`.
|
|
2
|
+
|
|
3
|
+
The top-level user `.claude/skills` is scanned in every session, but the scan is
|
|
4
|
+
non-recursive WITHIN a `.claude/skills` (a skill can't contain auto-loadable
|
|
5
|
+
sub-skills). So to surface a tree from the top you SYMLINK its entry skill dirs
|
|
6
|
+
into `~/.claude/skills`, and the `cat`-breadcrumbs carry you down from there.
|
|
7
|
+
|
|
8
|
+
ONE projector, two policies (`project`):
|
|
9
|
+
- **progressive** → symlink the root (and its first-layer branches) up; deeper
|
|
10
|
+
levels stay behind Read-breadcrumbs (progressive disclosure). `link_tree` is
|
|
11
|
+
the thin compatibility wrapper.
|
|
12
|
+
- **flat** → project a volume node's tome-table rows (its held frameworks) +
|
|
13
|
+
the holder node itself as top-level skill dirs, so agents auto-discover every
|
|
14
|
+
framework (the nomicon-atomize semantics: the organized tree stays the source
|
|
15
|
+
of truth; the flat dir is only symlinks — edits propagate, nothing duplicates).
|
|
16
|
+
- many trees → `build_forest`: one forest-root skill that breadcrumbs to each
|
|
17
|
+
tree's root, symlinked up — a forest view over the top.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .model import SkillTree, find_node, skill_name
|
|
25
|
+
|
|
26
|
+
_CRUMB = "- {name} ({kind}): Read `{path}`" # the Read TOOL injects the layer, not `cat`
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _skill_dir(node_dir: Path, name: str) -> Path:
|
|
30
|
+
return Path(node_dir) / ".claude" / "skills" / name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _symlink(link: Path, target: Path, *, relative: bool = False) -> Path | None:
|
|
34
|
+
"""Idempotently (re)create `link` → `target` (a dir). Self-clobber guard: if the
|
|
35
|
+
link path IS the target (same path, symlinks not followed), do nothing and return
|
|
36
|
+
None — never unlink the source onto itself. `relative` makes the pair portable
|
|
37
|
+
when moved together (the doc-mirror vision-hyperedge pattern)."""
|
|
38
|
+
target_abs = Path(os.path.abspath(target))
|
|
39
|
+
if Path(os.path.abspath(link)) == target_abs:
|
|
40
|
+
return None
|
|
41
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
if link.is_symlink() or link.exists():
|
|
43
|
+
link.unlink()
|
|
44
|
+
dst = os.path.relpath(target_abs, os.path.abspath(link.parent)) if relative else target_abs
|
|
45
|
+
link.symlink_to(dst, target_is_directory=True)
|
|
46
|
+
return link
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def project(tree_dir: str | Path, target: str | Path, *,
|
|
50
|
+
policy: str = "progressive", link: str = "absolute",
|
|
51
|
+
node: str | None = None, manifest: str | Path | None = None,
|
|
52
|
+
include_root: bool = True, branches: bool = True) -> list[Path]:
|
|
53
|
+
"""Project a tree into a skills dir — the ONE deployment op (Move 1).
|
|
54
|
+
|
|
55
|
+
policy="progressive" surface the root (+ first-layer branches) as symlinks;
|
|
56
|
+
deeper levels stay behind Read-breadcrumbs.
|
|
57
|
+
policy="flat" project the selected node's tome-table rows (each held
|
|
58
|
+
framework's skill dir) + the holder node itself, flat —
|
|
59
|
+
every framework auto-discovers as a top-level skill
|
|
60
|
+
(née nomicon-atomize). `node` selects the volume node by
|
|
61
|
+
name or coord (default: the root).
|
|
62
|
+
link="absolute"|"relative" symlink form (relative = portable-if-moved-together).
|
|
63
|
+
|
|
64
|
+
The tree stays the source of truth; the target holds only symlinks. Re-running
|
|
65
|
+
overwrites cleanly (idempotent). Self-clobber-guarded skips return no link.
|
|
66
|
+
"""
|
|
67
|
+
tree_dir = Path(tree_dir)
|
|
68
|
+
tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
|
|
69
|
+
target = Path(target)
|
|
70
|
+
rel = link == "relative"
|
|
71
|
+
links: list[Path] = []
|
|
72
|
+
|
|
73
|
+
if policy == "progressive":
|
|
74
|
+
if include_root:
|
|
75
|
+
rn = skill_name(tree.root)
|
|
76
|
+
p = _symlink(target / rn, _skill_dir(tree_dir, rn), relative=rel)
|
|
77
|
+
if p:
|
|
78
|
+
links.append(p)
|
|
79
|
+
if branches:
|
|
80
|
+
for child in tree.root.children:
|
|
81
|
+
cn = skill_name(child)
|
|
82
|
+
p = _symlink(target / cn, _skill_dir(tree_dir / child.name, cn), relative=rel)
|
|
83
|
+
if p:
|
|
84
|
+
links.append(p)
|
|
85
|
+
return links
|
|
86
|
+
|
|
87
|
+
if policy != "flat":
|
|
88
|
+
raise ValueError(f"unknown policy {policy!r} (use 'flat' or 'progressive')")
|
|
89
|
+
|
|
90
|
+
hit = find_node(tree.root, node or tree.root.name)
|
|
91
|
+
if hit is None:
|
|
92
|
+
raise ValueError(f"no node named/coord {node!r} in the tree")
|
|
93
|
+
vol, path = hit
|
|
94
|
+
node_dir = tree_dir
|
|
95
|
+
for nm in path[1:]:
|
|
96
|
+
node_dir = node_dir / nm
|
|
97
|
+
|
|
98
|
+
holder = _skill_dir(node_dir, skill_name(vol))
|
|
99
|
+
for row in vol.frameworks: # each held framework, flat
|
|
100
|
+
rp = Path(row["skill_path"])
|
|
101
|
+
if not rp.is_absolute(): # relative rows resolve against the holder dir
|
|
102
|
+
rp = holder / rp
|
|
103
|
+
p = _symlink(target / row["name"], rp.parent, relative=rel)
|
|
104
|
+
if p:
|
|
105
|
+
links.append(p)
|
|
106
|
+
# the holder itself (the volume's search/router skill) — skip when the target
|
|
107
|
+
# sits inside the holder's own dir (a symlink loop; the atomize guard)
|
|
108
|
+
if not os.path.abspath(target).startswith(os.path.abspath(node_dir) + os.sep):
|
|
109
|
+
p = _symlink(target / skill_name(vol), _skill_dir(node_dir, skill_name(vol)),
|
|
110
|
+
relative=rel)
|
|
111
|
+
if p:
|
|
112
|
+
links.append(p)
|
|
113
|
+
return links
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def link_tree(tree_dir: str | Path, *, user_skills_dir: str | Path,
|
|
117
|
+
manifest: str | Path | None = None,
|
|
118
|
+
include_root: bool = True, branches: bool = True) -> list[Path]:
|
|
119
|
+
"""Symlink a single tree's entry points into the top-level user skills dir.
|
|
120
|
+
(Thin wrapper over `project(policy="progressive", link="absolute")`.)"""
|
|
121
|
+
return project(tree_dir, user_skills_dir, policy="progressive", link="absolute",
|
|
122
|
+
manifest=manifest, include_root=include_root, branches=branches)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str | Path,
|
|
126
|
+
forest_dir: str | Path, description: str | None = None) -> Path:
|
|
127
|
+
"""Build a forest-root skill over several trees and symlink it to the top.
|
|
128
|
+
|
|
129
|
+
`members` = tree dirs (each with a skilltree.json). The forest root's body
|
|
130
|
+
breadcrumbs to each tree's root; one top-level entry, progressive descent.
|
|
131
|
+
"""
|
|
132
|
+
fdir = _skill_dir(Path(forest_dir), name)
|
|
133
|
+
fdir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
crumbs, roots = [], []
|
|
135
|
+
for tdir in members:
|
|
136
|
+
tdir = Path(tdir)
|
|
137
|
+
tree = SkillTree.load(tdir / "skilltree.json")
|
|
138
|
+
rn = skill_name(tree.root)
|
|
139
|
+
md = _skill_dir(tdir, rn) / "SKILL.md"
|
|
140
|
+
crumbs.append(_CRUMB.format(name=rn, kind=tree.root.kind, path=md.resolve()))
|
|
141
|
+
roots.append(rn)
|
|
142
|
+
desc = description or f"Forest of {len(members)} skill tree(s): {', '.join(roots)}."
|
|
143
|
+
body = (f"Forest **{name}** — {len(members)} skill tree(s). "
|
|
144
|
+
"Pick one and **Read** (the Read tool) into its root to load it, then follow its "
|
|
145
|
+
"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")
|
|
147
|
+
return _symlink(Path(user_skills_dir) / name, fdir)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def list_links(user_skills_dir: str | Path) -> list[dict]:
|
|
151
|
+
"""List symlinks in the user skills dir and whether they still resolve."""
|
|
152
|
+
user = Path(user_skills_dir)
|
|
153
|
+
out: list[dict] = []
|
|
154
|
+
if user.exists():
|
|
155
|
+
for p in sorted(user.iterdir()):
|
|
156
|
+
if p.is_symlink():
|
|
157
|
+
out.append({"name": p.name, "target": str(p.readlink()), "resolves": p.exists()})
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def unlink(user_skills_dir: str | Path, *names: str) -> list[str]:
|
|
162
|
+
"""Remove named symlinks (only if they are symlinks — never deletes real dirs)."""
|
|
163
|
+
user = Path(user_skills_dir)
|
|
164
|
+
removed: list[str] = []
|
|
165
|
+
for n in names:
|
|
166
|
+
p = user / n
|
|
167
|
+
if p.is_symlink():
|
|
168
|
+
p.unlink()
|
|
169
|
+
removed.append(n)
|
|
170
|
+
return removed
|
|
@@ -17,7 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
import shutil
|
|
19
19
|
|
|
20
|
-
from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
|
|
20
|
+
from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name, tome_table
|
|
21
21
|
|
|
22
22
|
# A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<abspath>``
|
|
23
23
|
# The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
|
|
@@ -65,6 +65,12 @@ def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
|
|
|
65
65
|
else:
|
|
66
66
|
body = base + "\n\n_(leaf — this is an actual skill.)_"
|
|
67
67
|
|
|
68
|
+
# tome pointer-table (SkillVolume binding): generated LAST from the model rows, so
|
|
69
|
+
# cohere's in-place rewrite (which regenerates from the first generated heading to
|
|
70
|
+
# EOF) re-emits it instead of wiping it. See model.tome_table / tome.fold.
|
|
71
|
+
if node.frameworks:
|
|
72
|
+
body += f"\n\n{tome_table(node)}"
|
|
73
|
+
|
|
68
74
|
if node.coord:
|
|
69
75
|
desc = f"[{node.coord}] {desc}"
|
|
70
76
|
|
|
@@ -26,6 +26,10 @@ class TreeNode:
|
|
|
26
26
|
description: str | None = None # SKILL.md description (so it auto-loads / is searchable)
|
|
27
27
|
coord: str | None = None # hierarchical address (e.g. "0.1.2"); set by assign_coords
|
|
28
28
|
children: list["TreeNode"] = field(default_factory=list)
|
|
29
|
+
# tome pointer-table rows (the SkillVolume binding): each row registers a framework
|
|
30
|
+
# this node HOLDS — {"name", "what_for", "skill_path" (abs path to its SKILL.md)}.
|
|
31
|
+
# Rendered as the `## Frameworks` table by tome_table(); added by tome.fold().
|
|
32
|
+
frameworks: list[dict] = field(default_factory=list)
|
|
29
33
|
|
|
30
34
|
def walk(self):
|
|
31
35
|
yield self
|
|
@@ -67,6 +71,39 @@ def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
|
|
|
67
71
|
f"Reachable below: {', '.join(reachable)}.")
|
|
68
72
|
|
|
69
73
|
|
|
74
|
+
TOME_HEADING = "## Frameworks"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def tome_table(node: "TreeNode") -> str:
|
|
78
|
+
"""Render a node's tome pointer-table (the `## Frameworks` registry) from the model.
|
|
79
|
+
|
|
80
|
+
The table is GENERATED — the manifest's `frameworks` rows are the source of truth,
|
|
81
|
+
and both materialize and cohere's in-place rewrite re-emit it (so a re-cohere can
|
|
82
|
+
never wipe or drift a folded row). The 'Framework skill' cell is `name (<abs path
|
|
83
|
+
to SKILL.md>)` — the exact shape the flat projector (`project(policy="flat")`, née
|
|
84
|
+
nomicon-atomize) parses. Columns grow Plugin/Chapter in Move 2.
|
|
85
|
+
"""
|
|
86
|
+
lines = [TOME_HEADING, "",
|
|
87
|
+
"| Framework | What it's for | Framework skill |",
|
|
88
|
+
"|---|---|---|"]
|
|
89
|
+
for r in node.frameworks:
|
|
90
|
+
lines.append(f"| {r['name']} | {r.get('what_for', '')} | {r['name']} ({r['skill_path']}) |")
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_node(root: "TreeNode", selector: str) -> tuple["TreeNode", list[str]] | None:
|
|
95
|
+
"""Locate a node by NAME or COORD; returns (node, tree-path names root→node)."""
|
|
96
|
+
def walk(n: "TreeNode", path: list[str]):
|
|
97
|
+
if n.name == selector or (n.coord and n.coord == selector):
|
|
98
|
+
return n, path
|
|
99
|
+
for c in n.children:
|
|
100
|
+
hit = walk(c, path + [c.name])
|
|
101
|
+
if hit:
|
|
102
|
+
return hit
|
|
103
|
+
return None
|
|
104
|
+
return walk(root, [root.name])
|
|
105
|
+
|
|
106
|
+
|
|
70
107
|
def assign_coords(root: "TreeNode", base: str = "0") -> "TreeNode":
|
|
71
108
|
"""Give every node a hierarchical coordinate: root=base, child i (1-based)=parent.i."""
|
|
72
109
|
def walk(n: "TreeNode", coord: str) -> None:
|
|
@@ -86,6 +123,7 @@ class SkillTree:
|
|
|
86
123
|
def enc(n: TreeNode) -> dict:
|
|
87
124
|
return {"name": n.name, "kind": n.kind, "skill_src": n.skill_src,
|
|
88
125
|
"description": n.description, "coord": n.coord,
|
|
126
|
+
"frameworks": [dict(r) for r in n.frameworks],
|
|
89
127
|
"children": [enc(c) for c in n.children]}
|
|
90
128
|
return {"skilltree": enc(self.root)}
|
|
91
129
|
|
|
@@ -95,6 +133,7 @@ class SkillTree:
|
|
|
95
133
|
return TreeNode(name=d["name"], kind=d.get("kind", "skill"),
|
|
96
134
|
skill_src=d.get("skill_src"), description=d.get("description"),
|
|
97
135
|
coord=d.get("coord"),
|
|
136
|
+
frameworks=[dict(r) for r in d.get("frameworks", [])],
|
|
98
137
|
children=[dec(c) for c in d.get("children", [])])
|
|
99
138
|
return SkillTree(dec(data["skilltree"]))
|
|
100
139
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""tome.py — the SkillVolume/SkillTome bind ops (fold a framework into a volume).
|
|
2
|
+
|
|
3
|
+
A **SkillVolume** is a SkillTree branch that HOLDS frameworks: its node carries
|
|
4
|
+
`frameworks` rows in the manifest, rendered as the `## Frameworks` pointer-table
|
|
5
|
+
(see model.tome_table). A **SkillTome** is the overall corpus — the set of volumes
|
|
6
|
+
+ everything made with them. ("nomicon" is the legacy alias for both.)
|
|
7
|
+
|
|
8
|
+
`fold(framework_dir, into=...)` is the bind operator: register ONE framework
|
|
9
|
+
(a chapter — its skill dir) as a row on a volume node. Idempotent — one row per
|
|
10
|
+
framework name; re-folding the same framework updates its row in place. The
|
|
11
|
+
manifest is the source of truth; the node's SKILL.md table is regenerated from it
|
|
12
|
+
surgically (only that node is rewritten — never a destructive re-materialize).
|
|
13
|
+
|
|
14
|
+
The flat deployment of a folded volume is `forest.project(policy="flat")`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .model import SkillTree, find_node, skill_name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fold(framework_dir: str | Path, *, into: str, tree_root: str | Path,
|
|
25
|
+
what_for: str | None = None, name: str | None = None,
|
|
26
|
+
relative: bool = False) -> dict:
|
|
27
|
+
"""Fold a framework's skill dir into a volume node's tome table — one row,
|
|
28
|
+
idempotent by row name. Returns a report dict ({"ok": False, "error": ...} on
|
|
29
|
+
a bad target — no partial writes happen in that case).
|
|
30
|
+
|
|
31
|
+
framework_dir the framework's skill dir (must contain SKILL.md)
|
|
32
|
+
into the volume node, by name or coordinate
|
|
33
|
+
tree_root the materialized tree root (holds skilltree.json)
|
|
34
|
+
what_for the row's purpose text (default: the framework's description)
|
|
35
|
+
name the row name (default: the framework's frontmatter name, else dir name)
|
|
36
|
+
relative store the row's skill_path RELATIVE to the volume node's skill dir
|
|
37
|
+
(the holder SKILL.md's dir — the base every table parser normalizes
|
|
38
|
+
against). Use for a repo-committed tome: the tree then validates and
|
|
39
|
+
projects from ANY checkout location, not just this machine.
|
|
40
|
+
"""
|
|
41
|
+
from .cohere import _front, _rewrite_node_md # frontmatter parse + surgical rewrite
|
|
42
|
+
|
|
43
|
+
tree_root = Path(tree_root)
|
|
44
|
+
manifest = tree_root / "skilltree.json"
|
|
45
|
+
if not manifest.is_file():
|
|
46
|
+
return {"ok": False, "error": f"no skilltree.json manifest at {tree_root}"}
|
|
47
|
+
fdir = Path(framework_dir)
|
|
48
|
+
smd = fdir / "SKILL.md"
|
|
49
|
+
if not smd.is_file():
|
|
50
|
+
return {"ok": False, "error": f"framework dir has no SKILL.md: {fdir}"}
|
|
51
|
+
|
|
52
|
+
tree = SkillTree.load(manifest)
|
|
53
|
+
hit = find_node(tree.root, into)
|
|
54
|
+
if hit is None:
|
|
55
|
+
return {"ok": False, "error": f"no node named/coord {into!r} in {manifest}"}
|
|
56
|
+
node, path = hit
|
|
57
|
+
|
|
58
|
+
node_dir = tree_root
|
|
59
|
+
for nm in path[1:]:
|
|
60
|
+
node_dir = node_dir / nm
|
|
61
|
+
holder_dir = node_dir / ".claude" / "skills" / skill_name(node)
|
|
62
|
+
|
|
63
|
+
front = _front(smd)
|
|
64
|
+
row_name = name or front.get("name") or fdir.name
|
|
65
|
+
skill_path = (os.path.relpath(smd.resolve(), holder_dir.resolve()) if relative
|
|
66
|
+
else str(smd.resolve()))
|
|
67
|
+
row = {"name": row_name,
|
|
68
|
+
"what_for": what_for or front.get("description", ""),
|
|
69
|
+
"skill_path": skill_path}
|
|
70
|
+
updated = False
|
|
71
|
+
for i, r in enumerate(node.frameworks):
|
|
72
|
+
if r.get("name") == row_name: # idempotent: one row per framework
|
|
73
|
+
node.frameworks[i] = row
|
|
74
|
+
updated = True
|
|
75
|
+
break
|
|
76
|
+
if not updated:
|
|
77
|
+
node.frameworks.append(row)
|
|
78
|
+
|
|
79
|
+
tree.save(manifest)
|
|
80
|
+
|
|
81
|
+
# Surgical in-place rewrite of ONLY the folded node's SKILL.md (regenerates its
|
|
82
|
+
# generated tail — table included — from the model; hand-written body preserved).
|
|
83
|
+
_rewrite_node_md(holder_dir / "SKILL.md", node, node_dir)
|
|
84
|
+
|
|
85
|
+
return {"ok": True, "node": node.name, "coord": node.coord, "row": row,
|
|
86
|
+
"updated": updated, "rows": len(node.frameworks)}
|
|
@@ -8,6 +8,9 @@ the things the filesystem won't:
|
|
|
8
8
|
- every non-leaf node's body has a Read breadcrumb for EACH of its children
|
|
9
9
|
- every breadcrumb path actually resolves to a file (no dead ends)
|
|
10
10
|
- sibling names are unique (their dirs collide otherwise)
|
|
11
|
+
- tome-table round-trip: a node with `frameworks` rows carries the `## Frameworks`
|
|
12
|
+
table, every manifest row appears in it, and every row's target SKILL.md resolves
|
|
13
|
+
(an undeclared table row is a warning — the manifest is the source of truth)
|
|
11
14
|
|
|
12
15
|
A non-empty error list is a kill-criterion: someone walking the tree hits a dead
|
|
13
16
|
end the platform would never warn about.
|
|
@@ -18,11 +21,13 @@ from dataclasses import dataclass
|
|
|
18
21
|
from pathlib import Path
|
|
19
22
|
import re
|
|
20
23
|
|
|
21
|
-
from .model import KINDS, SkillTree, TreeNode, skill_name
|
|
24
|
+
from .model import KINDS, TOME_HEADING, SkillTree, TreeNode, skill_name
|
|
22
25
|
|
|
23
26
|
# a breadcrumb is `- <name> (<kind>): Read `<abspath>`` — match the backticked SKILL.md path
|
|
24
27
|
# (verb-agnostic, so it survives wording tweaks; the verb is the Read tool, never `cat`)
|
|
25
28
|
_CRUMB_RE = re.compile(r"`([^`]+/SKILL\.md)`")
|
|
29
|
+
# a tome-table data row: `| name | what-for | name (path) |` (skip header/separator rows)
|
|
30
|
+
_TOME_ROW_RE = re.compile(r"^\|\s*([^|]+?)\s*\|[^|]*\|[^|]*\|\s*$", re.M)
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
@dataclass
|
|
@@ -69,10 +74,40 @@ def _walk(node: TreeNode, node_dir: Path, out: list[Violation]) -> None:
|
|
|
69
74
|
elif p not in expected:
|
|
70
75
|
out.append(Violation("warning", node.name, f"breadcrumb `{p}` is not a declared child"))
|
|
71
76
|
|
|
77
|
+
if node.frameworks:
|
|
78
|
+
_check_tome(node, skill_md, out)
|
|
79
|
+
|
|
72
80
|
for child in node.children:
|
|
73
81
|
_walk(child, node_dir / child.name, out)
|
|
74
82
|
|
|
75
83
|
|
|
84
|
+
def _check_tome(node: TreeNode, skill_md: Path, out: list[Violation]) -> None:
|
|
85
|
+
"""Tome-table round-trip: the manifest rows are the source of truth; the table
|
|
86
|
+
in SKILL.md must carry every one, and every row's target must resolve. An
|
|
87
|
+
undeclared table row is only a warning (fix by folding it or re-emitting)."""
|
|
88
|
+
text = skill_md.read_text(encoding="utf-8")
|
|
89
|
+
if TOME_HEADING not in text:
|
|
90
|
+
out.append(Violation("error", node.name,
|
|
91
|
+
"frameworks rows declared but no ## Frameworks table in SKILL.md (re-emit the node)"))
|
|
92
|
+
return
|
|
93
|
+
section = re.split(r"\n#{1,6} ", text.split(TOME_HEADING, 1)[1])[0]
|
|
94
|
+
in_table = {n for n in _TOME_ROW_RE.findall(section)
|
|
95
|
+
if n != "Framework" and not re.fullmatch(r":?-+:?", n)}
|
|
96
|
+
for row in node.frameworks:
|
|
97
|
+
if row["name"] not in in_table:
|
|
98
|
+
out.append(Violation("error", node.name,
|
|
99
|
+
f"tome row {row['name']!r} declared in manifest but missing from the ## Frameworks table"))
|
|
100
|
+
target = Path(row.get("skill_path", ""))
|
|
101
|
+
if not target.is_absolute(): # relative rows resolve against the holder dir
|
|
102
|
+
target = skill_md.parent / target
|
|
103
|
+
if not target.is_file():
|
|
104
|
+
out.append(Violation("error", node.name,
|
|
105
|
+
f"tome row {row['name']!r}: target does not resolve ({target})"))
|
|
106
|
+
for extra in sorted(in_table - {r["name"] for r in node.frameworks}):
|
|
107
|
+
out.append(Violation("warning", node.name,
|
|
108
|
+
f"table row {extra!r} is not declared in the manifest"))
|
|
109
|
+
|
|
110
|
+
|
|
76
111
|
def validate(root: str | Path, tree: SkillTree | None = None) -> list[Violation]:
|
|
77
112
|
root = Path(root)
|
|
78
113
|
if tree is None:
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Tome bind-op tests (Move 1): fold, the generated ## Frameworks table, the
|
|
2
|
+
validate round-trip, and project(policy="flat"/"progressive")."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from skilltree import (
|
|
9
|
+
SkillTree,
|
|
10
|
+
TreeNode,
|
|
11
|
+
fold,
|
|
12
|
+
is_valid,
|
|
13
|
+
link_tree,
|
|
14
|
+
materialize,
|
|
15
|
+
project,
|
|
16
|
+
validate,
|
|
17
|
+
)
|
|
18
|
+
from skilltree.cohere import emit
|
|
19
|
+
from skilltree.materialize import node_skill_md
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _framework(tmp_path: Path, name: str, desc: str = "does the thing") -> Path:
|
|
23
|
+
d = tmp_path / f"fw-{name}"
|
|
24
|
+
d.mkdir()
|
|
25
|
+
(d / "SKILL.md").write_text(
|
|
26
|
+
f"---\nname: {name}\ndescription: {desc}\n---\n\nThe {name} framework.\n")
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _volume_tree(tmp_path: Path) -> Path:
|
|
31
|
+
tree = SkillTree(TreeNode("my-tome", "sc", description="the tome root", children=[
|
|
32
|
+
TreeNode("patterns", "sc", description="the pattern volume"),
|
|
33
|
+
]))
|
|
34
|
+
root = tmp_path / "tome_tree"
|
|
35
|
+
materialize(tree, root)
|
|
36
|
+
return root
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_fold_adds_row_and_emits_table(tmp_path: Path):
|
|
40
|
+
root = _volume_tree(tmp_path)
|
|
41
|
+
fw = _framework(tmp_path, "alpha", "greek things")
|
|
42
|
+
rep = fold(fw, into="patterns", tree_root=root)
|
|
43
|
+
assert rep["ok"] and not rep["updated"] and rep["rows"] == 1
|
|
44
|
+
# manifest carries the row
|
|
45
|
+
tree = SkillTree.load(root / "skilltree.json")
|
|
46
|
+
node = next(n for n in tree.nodes() if n.name == "patterns")
|
|
47
|
+
assert node.frameworks[0]["name"] == "alpha"
|
|
48
|
+
assert node.frameworks[0]["skill_path"].endswith("SKILL.md")
|
|
49
|
+
# SKILL.md carries the generated table with the nomicon-atomize-parseable cell
|
|
50
|
+
body = node_skill_md(root / "patterns", "patterns").read_text()
|
|
51
|
+
assert "## Frameworks" in body
|
|
52
|
+
assert "| Framework | What it's for | Framework skill |" in body
|
|
53
|
+
assert re.search(r"\| alpha \| greek things \| alpha \(/.*?/SKILL\.md\) \|", body)
|
|
54
|
+
assert is_valid(root)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_fold_is_idempotent_one_row_per_framework(tmp_path: Path):
|
|
58
|
+
root = _volume_tree(tmp_path)
|
|
59
|
+
fw = _framework(tmp_path, "alpha")
|
|
60
|
+
assert fold(fw, into="patterns", tree_root=root)["rows"] == 1
|
|
61
|
+
rep = fold(fw, into="patterns", tree_root=root, what_for="updated purpose")
|
|
62
|
+
assert rep["updated"] and rep["rows"] == 1 # updated in place, not duplicated
|
|
63
|
+
body = node_skill_md(root / "patterns", "patterns").read_text()
|
|
64
|
+
assert body.count("| alpha |") == 1
|
|
65
|
+
assert "updated purpose" in body
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_fold_bad_targets_are_reported(tmp_path: Path):
|
|
69
|
+
root = _volume_tree(tmp_path)
|
|
70
|
+
fw = _framework(tmp_path, "alpha")
|
|
71
|
+
assert not fold(fw, into="nope", tree_root=root)["ok"]
|
|
72
|
+
empty = tmp_path / "not-a-framework"
|
|
73
|
+
empty.mkdir()
|
|
74
|
+
assert not fold(empty, into="patterns", tree_root=root)["ok"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_emit_regenerates_table_from_model(tmp_path: Path):
|
|
78
|
+
# a re-cohere (emit) must RE-EMIT the folded table, never wipe it
|
|
79
|
+
root = _volume_tree(tmp_path)
|
|
80
|
+
fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
|
|
81
|
+
rep = emit(root)
|
|
82
|
+
assert rep["ok"]
|
|
83
|
+
tree = SkillTree.load(root / "skilltree.json")
|
|
84
|
+
node = next(n for n in tree.nodes() if n.name == "patterns")
|
|
85
|
+
smd = root / "patterns" / ".claude" / "skills" / f"{node.coord}-patterns" / "SKILL.md"
|
|
86
|
+
body = smd.read_text()
|
|
87
|
+
assert body.count("## Frameworks") == 1 # regenerated once, not doubled
|
|
88
|
+
assert "| alpha |" in body
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_validate_round_trip_catches_drift(tmp_path: Path):
|
|
92
|
+
root = _volume_tree(tmp_path)
|
|
93
|
+
fw = _framework(tmp_path, "alpha")
|
|
94
|
+
fold(fw, into="patterns", tree_root=root)
|
|
95
|
+
assert is_valid(root)
|
|
96
|
+
# dead target: the framework dir vanishes → error
|
|
97
|
+
(fw / "SKILL.md").rename(fw / "GONE.md")
|
|
98
|
+
errs = [v for v in validate(root) if v.severity == "error"]
|
|
99
|
+
assert any("does not resolve" in v.message for v in errs)
|
|
100
|
+
(fw / "GONE.md").rename(fw / "SKILL.md")
|
|
101
|
+
# missing table: SKILL.md hand-stripped of the section → error
|
|
102
|
+
smd = node_skill_md(root / "patterns", "patterns")
|
|
103
|
+
smd.write_text(re.sub(r"\n+## Frameworks\b.*\Z", "\n", smd.read_text(), flags=re.S))
|
|
104
|
+
errs = [v for v in validate(root) if v.severity == "error"]
|
|
105
|
+
assert any("no ## Frameworks table" in v.message for v in errs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_validate_warns_on_undeclared_table_row(tmp_path: Path):
|
|
109
|
+
root = _volume_tree(tmp_path)
|
|
110
|
+
fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
|
|
111
|
+
smd = node_skill_md(root / "patterns", "patterns")
|
|
112
|
+
smd.write_text(smd.read_text() + "| ghost | not in manifest | ghost (/nowhere/SKILL.md) |\n")
|
|
113
|
+
warns = [v for v in validate(root) if v.severity == "warning"]
|
|
114
|
+
assert any("ghost" in v.message and "not declared" in v.message for v in warns)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_project_flat_symlinks_rows_and_holder(tmp_path: Path):
|
|
118
|
+
root = _volume_tree(tmp_path)
|
|
119
|
+
fw_a = _framework(tmp_path, "alpha")
|
|
120
|
+
fw_b = _framework(tmp_path, "beta")
|
|
121
|
+
fold(fw_a, into="patterns", tree_root=root)
|
|
122
|
+
fold(fw_b, into="patterns", tree_root=root)
|
|
123
|
+
target = tmp_path / "flat_skills"
|
|
124
|
+
links = project(root, target, policy="flat", node="patterns")
|
|
125
|
+
names = {p.name for p in links}
|
|
126
|
+
assert names == {"alpha", "beta", "patterns"} # every row + the holder
|
|
127
|
+
for p in links:
|
|
128
|
+
assert p.is_symlink() and (p / "SKILL.md").is_file()
|
|
129
|
+
# the originals stay the source of truth (symlinks, not copies)
|
|
130
|
+
assert (target / "alpha").resolve() == fw_a.resolve()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_project_flat_relative_links_and_reruns_cleanly(tmp_path: Path):
|
|
134
|
+
root = _volume_tree(tmp_path)
|
|
135
|
+
fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
|
|
136
|
+
target = tmp_path / "flat_skills"
|
|
137
|
+
project(root, target, policy="flat", node="patterns", link="relative")
|
|
138
|
+
raw = (target / "alpha").readlink()
|
|
139
|
+
assert not raw.is_absolute() # relative = portable together
|
|
140
|
+
# idempotent re-run
|
|
141
|
+
links = project(root, target, policy="flat", node="patterns", link="relative")
|
|
142
|
+
assert {p.name for p in links} == {"alpha", "patterns"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_project_flat_self_clobber_guard(tmp_path: Path):
|
|
146
|
+
# projecting INTO the holder's own dir must never destroy the source
|
|
147
|
+
root = _volume_tree(tmp_path)
|
|
148
|
+
fold(_framework(tmp_path, "alpha"), into="patterns", tree_root=root)
|
|
149
|
+
inside = root / "patterns" / ".claude" / "skills"
|
|
150
|
+
links = project(root, inside, policy="flat", node="patterns")
|
|
151
|
+
assert "patterns" not in {p.name for p in links} # holder self-link skipped
|
|
152
|
+
assert node_skill_md(root / "patterns", "patterns").is_file() # source intact
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_relative_fold_survives_relocation(tmp_path: Path):
|
|
156
|
+
# a repo-committed tome: relative rows must validate + project from ANY checkout path
|
|
157
|
+
site_a = tmp_path / "checkout_a"
|
|
158
|
+
site_a.mkdir()
|
|
159
|
+
root = _volume_tree(site_a)
|
|
160
|
+
fw = _framework(site_a, "alpha")
|
|
161
|
+
rep = fold(fw, into="patterns", tree_root=root, relative=True)
|
|
162
|
+
assert rep["ok"] and not Path(rep["row"]["skill_path"]).is_absolute()
|
|
163
|
+
assert is_valid(root)
|
|
164
|
+
# relocate the WHOLE checkout (tree + framework move together, like a fresh CI clone)
|
|
165
|
+
site_b = tmp_path / "checkout_b"
|
|
166
|
+
site_a.rename(site_b)
|
|
167
|
+
root_b = site_b / "tome_tree"
|
|
168
|
+
# breadcrumbs are absolute by design → a moved tree needs the localize step first:
|
|
169
|
+
# emit re-coheres at the NEW location from the manifest (the portable canon). The
|
|
170
|
+
# relative tome rows need no rewrite — that is the point of --relative.
|
|
171
|
+
assert emit(root_b)["ok"]
|
|
172
|
+
assert is_valid(root_b) # emit + validate = the CI gate flow
|
|
173
|
+
links = project(root_b, tmp_path / "flat_b", policy="flat", node="patterns")
|
|
174
|
+
assert (tmp_path / "flat_b" / "alpha").resolve() == (site_b / "fw-alpha").resolve()
|
|
175
|
+
# emit coordinatized the tree, so the holder projects under its coord identity
|
|
176
|
+
assert {p.name for p in links} == {"alpha", "0.1-patterns"}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_project_progressive_matches_link_tree(tmp_path: Path):
|
|
180
|
+
root = _volume_tree(tmp_path)
|
|
181
|
+
a = tmp_path / "user_a"
|
|
182
|
+
b = tmp_path / "user_b"
|
|
183
|
+
via_project = {p.name for p in project(root, a, policy="progressive")}
|
|
184
|
+
via_link_tree = {p.name for p in link_tree(root, user_skills_dir=b)}
|
|
185
|
+
assert via_project == via_link_tree == {"my-tome", "patterns"}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""Make a SkillTree (or a forest of them) visible from the TOP — `~/.claude/skills`.
|
|
2
|
-
|
|
3
|
-
The top-level user `.claude/skills` is scanned in every session, but the scan is
|
|
4
|
-
non-recursive WITHIN a `.claude/skills` (a skill can't contain auto-loadable
|
|
5
|
-
sub-skills). So to surface a tree from the top you SYMLINK its entry skill dirs
|
|
6
|
-
into `~/.claude/skills`, and the `cat`-breadcrumbs carry you down from there.
|
|
7
|
-
|
|
8
|
-
- single tree → `link_tree`: symlink the root (and its first-layer branches) up
|
|
9
|
-
- many trees → `build_forest`: one forest-root skill that breadcrumbs to each
|
|
10
|
-
tree's root, symlinked up — a forest view over the top.
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
from .model import SkillTree, skill_name
|
|
17
|
-
|
|
18
|
-
_CRUMB = "- {name} ({kind}): Read `{path}`" # the Read TOOL injects the layer, not `cat`
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _skill_dir(node_dir: Path, name: str) -> Path:
|
|
22
|
-
return Path(node_dir) / ".claude" / "skills" / name
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _symlink(link: Path, target: Path) -> Path:
|
|
26
|
-
link.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
-
if link.is_symlink() or link.exists():
|
|
28
|
-
link.unlink()
|
|
29
|
-
link.symlink_to(Path(target).resolve(), target_is_directory=True)
|
|
30
|
-
return link
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def link_tree(tree_dir: str | Path, *, user_skills_dir: str | Path,
|
|
34
|
-
manifest: str | Path | None = None,
|
|
35
|
-
include_root: bool = True, branches: bool = True) -> list[Path]:
|
|
36
|
-
"""Symlink a single tree's entry points into the top-level user skills dir.
|
|
37
|
-
|
|
38
|
-
The root gives the overview + breadcrumbs; the branches (root's direct
|
|
39
|
-
children) are surfaced too so the first layer is reachable from the top.
|
|
40
|
-
Deeper levels stay behind `cat` (progressive disclosure).
|
|
41
|
-
"""
|
|
42
|
-
tree_dir = Path(tree_dir)
|
|
43
|
-
tree = SkillTree.load(manifest or tree_dir / "skilltree.json")
|
|
44
|
-
user = Path(user_skills_dir)
|
|
45
|
-
links: list[Path] = []
|
|
46
|
-
if include_root:
|
|
47
|
-
rn = skill_name(tree.root)
|
|
48
|
-
links.append(_symlink(user / rn, _skill_dir(tree_dir, rn)))
|
|
49
|
-
if branches:
|
|
50
|
-
for child in tree.root.children:
|
|
51
|
-
cn = skill_name(child)
|
|
52
|
-
links.append(_symlink(user / cn, _skill_dir(tree_dir / child.name, cn)))
|
|
53
|
-
return links
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def build_forest(name: str, members: list[str | Path], *, user_skills_dir: str | Path,
|
|
57
|
-
forest_dir: str | Path, description: str | None = None) -> Path:
|
|
58
|
-
"""Build a forest-root skill over several trees and symlink it to the top.
|
|
59
|
-
|
|
60
|
-
`members` = tree dirs (each with a skilltree.json). The forest root's body
|
|
61
|
-
breadcrumbs to each tree's root; one top-level entry, progressive descent.
|
|
62
|
-
"""
|
|
63
|
-
fdir = _skill_dir(Path(forest_dir), name)
|
|
64
|
-
fdir.mkdir(parents=True, exist_ok=True)
|
|
65
|
-
crumbs, roots = [], []
|
|
66
|
-
for tdir in members:
|
|
67
|
-
tdir = Path(tdir)
|
|
68
|
-
tree = SkillTree.load(tdir / "skilltree.json")
|
|
69
|
-
rn = skill_name(tree.root)
|
|
70
|
-
md = _skill_dir(tdir, rn) / "SKILL.md"
|
|
71
|
-
crumbs.append(_CRUMB.format(name=rn, kind=tree.root.kind, path=md.resolve()))
|
|
72
|
-
roots.append(rn)
|
|
73
|
-
desc = description or f"Forest of {len(members)} skill tree(s): {', '.join(roots)}."
|
|
74
|
-
body = (f"Forest **{name}** — {len(members)} skill tree(s). "
|
|
75
|
-
"Pick one and **Read** (the Read tool) into its root to load it, then follow its "
|
|
76
|
-
"breadcrumbs down (`cat` won't load it — only the Read tool does):\n\n" + "\n".join(crumbs))
|
|
77
|
-
(fdir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {desc}\n---\n\n{body}\n", encoding="utf-8")
|
|
78
|
-
return _symlink(Path(user_skills_dir) / name, fdir)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def list_links(user_skills_dir: str | Path) -> list[dict]:
|
|
82
|
-
"""List symlinks in the user skills dir and whether they still resolve."""
|
|
83
|
-
user = Path(user_skills_dir)
|
|
84
|
-
out: list[dict] = []
|
|
85
|
-
if user.exists():
|
|
86
|
-
for p in sorted(user.iterdir()):
|
|
87
|
-
if p.is_symlink():
|
|
88
|
-
out.append({"name": p.name, "target": str(p.readlink()), "resolves": p.exists()})
|
|
89
|
-
return out
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def unlink(user_skills_dir: str | Path, *names: str) -> list[str]:
|
|
93
|
-
"""Remove named symlinks (only if they are symlinks — never deletes real dirs)."""
|
|
94
|
-
user = Path(user_skills_dir)
|
|
95
|
-
removed: list[str] = []
|
|
96
|
-
for n in names:
|
|
97
|
-
p = user / n
|
|
98
|
-
if p.is_symlink():
|
|
99
|
-
p.unlink()
|
|
100
|
-
removed.append(n)
|
|
101
|
-
return removed
|
|
File without changes
|
|
File without changes
|
{agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{agent_skilltree-0.2.2 → agent_skilltree-0.3.0}/src/agent_skilltree.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|