agent-skilltree 0.2.0__py3-none-any.whl
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.0.dist-info/METADATA +139 -0
- agent_skilltree-0.2.0.dist-info/RECORD +20 -0
- agent_skilltree-0.2.0.dist-info/WHEEL +5 -0
- agent_skilltree-0.2.0.dist-info/entry_points.txt +2 -0
- agent_skilltree-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_skilltree-0.2.0.dist-info/top_level.txt +1 -0
- skilltree/__init__.py +64 -0
- skilltree/cli.py +315 -0
- skilltree/cohere.py +463 -0
- skilltree/exchange.py +121 -0
- skilltree/federation.py +109 -0
- skilltree/forest.py +101 -0
- skilltree/mapper.py +117 -0
- skilltree/marketplace.py +86 -0
- skilltree/materialize.py +88 -0
- skilltree/model.py +114 -0
- skilltree/registry.py +120 -0
- skilltree/reports.py +95 -0
- skilltree/search.py +148 -0
- skilltree/validate.py +89 -0
skilltree/federation.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""P5.4 — walk the federation: a tree of marketplaces under a root.
|
|
2
|
+
|
|
3
|
+
A child marketplace joins by contributing a `registry`-kind entry to its parent's
|
|
4
|
+
registry.json (pointing at the child's own registry.json). Federation is then a
|
|
5
|
+
walk: from the root registry, resolve each `registry` entry to the child's
|
|
6
|
+
registry and recurse — a tree of marketplaces mirroring the tree of repos.
|
|
7
|
+
|
|
8
|
+
`resolve` is pluggable so this works offline: it maps a `registry` entry to a
|
|
9
|
+
registry dict (a local map for tests; a fetch-by-repo-ref for the real network).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from .registry import load_registry, validate_registry
|
|
19
|
+
|
|
20
|
+
Resolver = Callable[[dict], "dict | None"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def local_resolver(name_to_registry: dict[str, dict]) -> Resolver:
|
|
24
|
+
"""Resolve a `registry` entry to a child registry from an in-memory/local map."""
|
|
25
|
+
def _resolve(entry: dict) -> dict | None:
|
|
26
|
+
target = name_to_registry.get(entry["name"]) or name_to_registry.get(entry.get("repo", ""))
|
|
27
|
+
if isinstance(target, (str, Path)):
|
|
28
|
+
return load_registry(target)
|
|
29
|
+
return target
|
|
30
|
+
return _resolve
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _children(registry: dict) -> list[dict]:
|
|
34
|
+
return [e for e in registry.get("entries", []) if e.get("kind") == "registry"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def walk_federation(registry: dict, resolve: Resolver, *, _seen: frozenset[str] | None = None) -> dict:
|
|
38
|
+
"""Return the nested federation tree rooted at `registry`."""
|
|
39
|
+
_seen = _seen or frozenset()
|
|
40
|
+
name = registry.get("name")
|
|
41
|
+
node = {
|
|
42
|
+
"name": name,
|
|
43
|
+
"entries": [e for e in registry.get("entries", []) if e.get("kind") != "registry"],
|
|
44
|
+
"children": [],
|
|
45
|
+
}
|
|
46
|
+
if name in _seen: # cycle guard
|
|
47
|
+
node["cycle"] = True
|
|
48
|
+
return node
|
|
49
|
+
seen = _seen | {name}
|
|
50
|
+
for entry in _children(registry):
|
|
51
|
+
child = resolve(entry)
|
|
52
|
+
if child is None:
|
|
53
|
+
node["children"].append({"name": entry["name"], "unresolved": True})
|
|
54
|
+
else:
|
|
55
|
+
node["children"].append(walk_federation(child, resolve, _seen=seen))
|
|
56
|
+
return node
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def flatten_federation(registry: dict, resolve: Resolver) -> list[dict]:
|
|
60
|
+
"""Every leaf entry across the federation, tagged with the registry path it came through."""
|
|
61
|
+
out: list[dict] = []
|
|
62
|
+
|
|
63
|
+
def rec(node: dict, path: tuple[str, ...]) -> None:
|
|
64
|
+
here = path + (node["name"],)
|
|
65
|
+
for e in node.get("entries", []):
|
|
66
|
+
out.append({**e, "_path": list(here)})
|
|
67
|
+
for c in node.get("children", []):
|
|
68
|
+
if not c.get("unresolved") and not c.get("cycle"):
|
|
69
|
+
rec(c, here)
|
|
70
|
+
rec(walk_federation(registry, resolve), ())
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_federation(registry: dict, resolve: Resolver) -> list[str]:
|
|
75
|
+
"""Check the federation: child schemas valid, parent backrefs consistent, no cycles."""
|
|
76
|
+
errs: list[str] = []
|
|
77
|
+
|
|
78
|
+
def rec(reg: dict, seen: frozenset[str]) -> None:
|
|
79
|
+
errs.extend(f"{reg.get('name')}: {e}" for e in validate_registry(reg))
|
|
80
|
+
if reg.get("name") in seen:
|
|
81
|
+
errs.append(f"federation cycle at {reg.get('name')!r}")
|
|
82
|
+
return
|
|
83
|
+
s = seen | {reg.get("name")}
|
|
84
|
+
for entry in _children(reg):
|
|
85
|
+
child = resolve(entry)
|
|
86
|
+
if child is None:
|
|
87
|
+
errs.append(f"{reg.get('name')}: unresolved child registry {entry['name']!r}")
|
|
88
|
+
continue
|
|
89
|
+
if child.get("parent") not in (None, reg.get("name")) and "github" not in str(child.get("parent", "")):
|
|
90
|
+
errs.append(f"{child.get('name')!r}: parent backref {child.get('parent')!r} "
|
|
91
|
+
f"does not match {reg.get('name')!r}")
|
|
92
|
+
rec(child, s)
|
|
93
|
+
rec(registry, frozenset())
|
|
94
|
+
return errs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def register_child(parent_path: str | Path, name: str, repo: str, *,
|
|
98
|
+
manifest: str = "registry.json", by: str = "maintainer",
|
|
99
|
+
now: str | None = None) -> dict:
|
|
100
|
+
"""Add a `registry`-kind child to a parent registry (federation link, unverified)."""
|
|
101
|
+
p = Path(parent_path)
|
|
102
|
+
data = load_registry(p)
|
|
103
|
+
data.setdefault("entries", []).append({
|
|
104
|
+
"name": name, "kind": "registry", "repo": repo, "manifest": manifest,
|
|
105
|
+
"version": "0.1.0", "trust": "unverified",
|
|
106
|
+
"provenance": {"by": by, "at": now or _dt.datetime.now().isoformat(timespec="seconds")},
|
|
107
|
+
})
|
|
108
|
+
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
109
|
+
return data
|
skilltree/forest.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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
|
skilltree/mapper.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""skilltree map — render a flat folder into ONE coordinate-addressed CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
Folds the former `skillmap` seedling into skilltree as a module. It mirrors a folder's
|
|
4
|
+
directory nesting as a tree, gives every node a hierarchical coordinate, and writes a single
|
|
5
|
+
CLAUDE.md — a Folder Map + an addressable Index + branch summaries — so an agent can do
|
|
6
|
+
*progressive disclosure* over a flat pile (open a branch, descend to the coordinate it needs)
|
|
7
|
+
instead of loading all of it.
|
|
8
|
+
|
|
9
|
+
It uses skilltree's OWN coordinate code — `assign_coords` / `skill_name` / `compose_summary`
|
|
10
|
+
from `model.py`. There is no vendored copy: one source of truth for the `<coord>-<name>` scheme.
|
|
11
|
+
|
|
12
|
+
skilltree map ~/.claude/skills # print the map
|
|
13
|
+
skilltree map ~/.claude/skills --write # write CLAUDE.md into the folder
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .model import TreeNode, assign_coords, compose_summary, skill_name
|
|
21
|
+
|
|
22
|
+
_DESC = re.compile(r"^description:\s*(.+)$", re.I | re.M)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_description(d: Path) -> str:
|
|
26
|
+
"""Best-effort one-line description for a directory node: the `description:` frontmatter of
|
|
27
|
+
SKILL.md/README, else the first real line/heading of any markdown in the dir."""
|
|
28
|
+
for fn in ("SKILL.md", "README.md"):
|
|
29
|
+
f = d / fn
|
|
30
|
+
if f.is_file():
|
|
31
|
+
txt = f.read_text(encoding="utf-8", errors="ignore")
|
|
32
|
+
m = _DESC.search(txt)
|
|
33
|
+
if m:
|
|
34
|
+
return m.group(1).strip().strip("\"'")[:160]
|
|
35
|
+
for line in txt.splitlines():
|
|
36
|
+
s = line.strip()
|
|
37
|
+
if s and not s.startswith("---"):
|
|
38
|
+
return s.lstrip("# ").strip()[:160]
|
|
39
|
+
for f in sorted(d.glob("*.md")):
|
|
40
|
+
for line in f.read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
41
|
+
s = line.strip()
|
|
42
|
+
if s and not s.startswith("---"):
|
|
43
|
+
return s.lstrip("# ").strip()[:160]
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_tree(folder: str | Path) -> TreeNode:
|
|
48
|
+
"""Mirror the directory nesting as skilltree `TreeNode`s. A node = a subdirectory; the dir
|
|
49
|
+
path rides in `skill_src`. Hidden dirs (dotfiles) are skipped."""
|
|
50
|
+
root_path = Path(folder)
|
|
51
|
+
root = TreeNode(name=root_path.name, kind="sc", description="(root)", skill_src=str(root_path))
|
|
52
|
+
|
|
53
|
+
def add(parent: TreeNode) -> None:
|
|
54
|
+
for d in sorted(p for p in Path(parent.skill_src).iterdir()
|
|
55
|
+
if p.is_dir() and not p.name.startswith(".")):
|
|
56
|
+
child = TreeNode(name=d.name, kind="skill",
|
|
57
|
+
description=read_description(d) or None, skill_src=str(d))
|
|
58
|
+
parent.children.append(child)
|
|
59
|
+
add(child)
|
|
60
|
+
|
|
61
|
+
add(root)
|
|
62
|
+
return root
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _folder_map(root: TreeNode) -> str:
|
|
66
|
+
lines = [f"{root.coord} {root.name}/"]
|
|
67
|
+
|
|
68
|
+
def rec(node: TreeNode, prefix: str) -> None:
|
|
69
|
+
n = len(node.children)
|
|
70
|
+
for i, c in enumerate(node.children):
|
|
71
|
+
last = i == n - 1
|
|
72
|
+
branch = "└─ " if last else "├─ "
|
|
73
|
+
desc = f" — {c.description}" if c.description else ""
|
|
74
|
+
lines.append(f"{prefix}{branch}{c.coord} {c.name}{desc}")
|
|
75
|
+
rec(c, prefix + (" " if last else "│ "))
|
|
76
|
+
|
|
77
|
+
rec(root, "")
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _index(root: TreeNode) -> str:
|
|
82
|
+
rp = Path(root.skill_src)
|
|
83
|
+
rows = ["| Coord | Skill | Address | What it does | Path |", "|---|---|---|---|---|"]
|
|
84
|
+
for n in root.walk():
|
|
85
|
+
if n is root:
|
|
86
|
+
continue
|
|
87
|
+
rel = Path(n.skill_src).relative_to(rp)
|
|
88
|
+
rows.append(f"| `{n.coord}` | {n.name} | `{skill_name(n)}` | {n.description or '—'} | `{rel}/` |")
|
|
89
|
+
return "\n".join(rows)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _branches(root: TreeNode) -> str:
|
|
93
|
+
rows = [f"- `{n.coord}` **{n.name}** → {compose_summary(n)}"
|
|
94
|
+
for n in root.walk() if n.children]
|
|
95
|
+
return "\n".join(rows) or "_(flat — every skill is a leaf at depth 1)_"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_map(folder: str | Path) -> str:
|
|
99
|
+
"""Render the folder as ONE coordinate-addressed CLAUDE.md (Folder Map + Index + Branches)."""
|
|
100
|
+
root = assign_coords(build_tree(folder))
|
|
101
|
+
nodes = list(root.walk())
|
|
102
|
+
n_skills = len(nodes) - 1
|
|
103
|
+
depth = max((n.coord.count(".") for n in nodes), default=0)
|
|
104
|
+
return (f"# {root.name} — skill map\n\n"
|
|
105
|
+
f"*Auto-generated by `skilltree map`. {n_skills} skills, depth {depth}. Each skill has a "
|
|
106
|
+
f"coordinate address — open a branch, descend to the coordinate you need. Don't load the "
|
|
107
|
+
f"whole pile; navigate it.*\n\n"
|
|
108
|
+
f"## Folder Map\n\n```\n{_folder_map(root)}\n```\n\n"
|
|
109
|
+
f"## Index (addressable)\n\n{_index(root)}\n\n"
|
|
110
|
+
f"## Branches (what each opens to)\n\n{_branches(root)}\n")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def write_map(folder: str | Path) -> Path:
|
|
114
|
+
"""Write the map to `<folder>/CLAUDE.md`; returns the path."""
|
|
115
|
+
dest = Path(folder) / "CLAUDE.md"
|
|
116
|
+
dest.write_text(build_map(folder), encoding="utf-8")
|
|
117
|
+
return dest
|
skilltree/marketplace.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Marketplace — a programmatic registry for skills, trees, and exchanges.
|
|
2
|
+
|
|
3
|
+
The real, local half of the roadmap's marketplace: you `publish()` an artifact to
|
|
4
|
+
a registry (a JSON file), mark it `public` if you want, and `search()` it. The
|
|
5
|
+
registry GROWS as artifacts are added. Whenever something is published public, a
|
|
6
|
+
`notify` hook fires — that hook is where a hosted service ("our system") plugs in.
|
|
7
|
+
|
|
8
|
+
Aspirational (needs a backend, not built here): the hosted marketplace endpoint
|
|
9
|
+
the notify hook would POST to, and cross-user discovery.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
KINDS = ("skill", "tree", "exchange")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Entry:
|
|
24
|
+
name: str
|
|
25
|
+
kind: str
|
|
26
|
+
path: str
|
|
27
|
+
version: str = "0.1.0"
|
|
28
|
+
public: bool = False
|
|
29
|
+
published_at: str = ""
|
|
30
|
+
tags: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(registry: Path) -> list[dict]:
|
|
34
|
+
return json.loads(registry.read_text(encoding="utf-8")) if registry.is_file() else []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _save(registry: Path, rows: list[dict]) -> None:
|
|
38
|
+
registry.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
registry.write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def publish(
|
|
43
|
+
registry: str | Path,
|
|
44
|
+
name: str,
|
|
45
|
+
path: str | Path,
|
|
46
|
+
*,
|
|
47
|
+
kind: str = "skill",
|
|
48
|
+
public: bool = False,
|
|
49
|
+
version: str = "0.1.0",
|
|
50
|
+
tags: list[str] | None = None,
|
|
51
|
+
notify: Callable[[Entry], None] | None = None,
|
|
52
|
+
now: str | None = None,
|
|
53
|
+
) -> Entry:
|
|
54
|
+
"""Add (or update) an artifact in the registry. Fires `notify` if it's public."""
|
|
55
|
+
if kind not in KINDS:
|
|
56
|
+
raise ValueError(f"kind must be one of {KINDS}, got {kind!r}")
|
|
57
|
+
registry = Path(registry)
|
|
58
|
+
entry = Entry(name=name, kind=kind, path=str(Path(path)), version=version, public=public,
|
|
59
|
+
published_at=now or _dt.datetime.now().isoformat(timespec="seconds"),
|
|
60
|
+
tags=tags or [])
|
|
61
|
+
rows = [r for r in _load(registry) if r.get("name") != name] # upsert by name
|
|
62
|
+
rows.append(asdict(entry))
|
|
63
|
+
_save(registry, rows)
|
|
64
|
+
if public and notify is not None:
|
|
65
|
+
notify(entry) # ← where the hosted "notify our system" service plugs in
|
|
66
|
+
return entry
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def search(registry: str | Path, query: str | None = None, *, public_only: bool = False) -> list[dict]:
|
|
70
|
+
rows = _load(Path(registry))
|
|
71
|
+
if public_only:
|
|
72
|
+
rows = [r for r in rows if r.get("public")]
|
|
73
|
+
if query:
|
|
74
|
+
q = query.lower()
|
|
75
|
+
rows = [r for r in rows if q in r["name"].lower() or any(q in t.lower() for t in r.get("tags", []))]
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log_notify(log_path: str | Path) -> Callable[[Entry], None]:
|
|
80
|
+
"""A local stand-in for the hosted notifier: append public publishes to a log."""
|
|
81
|
+
def _notify(entry: Entry) -> None:
|
|
82
|
+
p = Path(log_path)
|
|
83
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
with p.open("a", encoding="utf-8") as fh:
|
|
85
|
+
fh.write(json.dumps(asdict(entry)) + "\n")
|
|
86
|
+
return _notify
|
skilltree/materialize.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Materialize a SkillTree as nested dirs wired by `cat`-breadcrumbs.
|
|
2
|
+
|
|
3
|
+
The auto-loader only ever loads the ROOT (it won't descend a nested `.claude`).
|
|
4
|
+
So each node's SKILL.md body carries the `cat` commands that tell you how to read
|
|
5
|
+
its children — the breadcrumb links. The tree is nested dirs; traversal is manual
|
|
6
|
+
`cat` following the breadcrumbs. Layout for a node N with children C1..Ck:
|
|
7
|
+
|
|
8
|
+
<N_dir>/.claude/skills/<N>/SKILL.md # N's skill + `cat` links to each child
|
|
9
|
+
<N_dir>/<C1>/.claude/skills/<C1>/SKILL.md # child dir is a SIBLING of N's .claude
|
|
10
|
+
<N_dir>/<C2>/...
|
|
11
|
+
|
|
12
|
+
The root node's dir IS the tree root; root skill at <root>/.claude/skills/<root>/.
|
|
13
|
+
Leaves carry the actual skill content (from skill_src); no further breadcrumbs.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import shutil
|
|
19
|
+
|
|
20
|
+
from .model import SkillTree, TreeNode, assign_coords, compose_summary, skill_name
|
|
21
|
+
|
|
22
|
+
# A breadcrumb line is parseable by validate.py: `- <name> (<kind>): Read `<abspath>``
|
|
23
|
+
# The verb is **Read** (the Read tool), NOT `cat`: only the Read tool injects a dir's
|
|
24
|
+
# .claude layer — a Bash `cat` reads the bytes but loads nothing (verified 2026-06-18).
|
|
25
|
+
_CRUMB = "- {name} ({kind}): Read `{path}`"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def node_skill_md(node_dir: Path, node_name: str) -> Path:
|
|
29
|
+
return node_dir / ".claude" / "skills" / node_name / "SKILL.md"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _front(name: str, description: str, body: str) -> str:
|
|
33
|
+
return f"---\nname: {name}\ndescription: {description}\n---\n\n{body.rstrip()}\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_node(node: TreeNode, node_dir: Path, root: Path) -> None:
|
|
37
|
+
sname = skill_name(node) # coord-prefixed identity (or plain name)
|
|
38
|
+
skill_md = node_skill_md(node_dir, sname)
|
|
39
|
+
skill_md.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# base body: the node's own skill content (from src) or a stub
|
|
42
|
+
if node.skill_src and (Path(node.skill_src) / "SKILL.md").exists():
|
|
43
|
+
src = (Path(node.skill_src) / "SKILL.md").read_text(encoding="utf-8")
|
|
44
|
+
# strip frontmatter from the source body; we re-emit our own
|
|
45
|
+
base = src.split("---", 2)[-1].strip() if src.lstrip().startswith("---") else src.strip()
|
|
46
|
+
desc = node.description or f"{node.kind} node {node.name}"
|
|
47
|
+
else:
|
|
48
|
+
base = f"SkillTree {node.kind} node `{node.name}`."
|
|
49
|
+
desc = node.description or f"{node.kind} node {node.name} in a SkillTree."
|
|
50
|
+
if node.children:
|
|
51
|
+
# index (branch) node: a deterministic subtree summary makes it retrievable
|
|
52
|
+
# by any descendant's terms (the RAPTOR win, no LLM).
|
|
53
|
+
if not node.description:
|
|
54
|
+
desc = compose_summary(node, full=False)
|
|
55
|
+
summary = compose_summary(node, full=True)
|
|
56
|
+
crumbs = [_CRUMB.format(name=skill_name(c), kind=c.kind,
|
|
57
|
+
path=node_skill_md(node_dir / c.name, skill_name(c)).resolve())
|
|
58
|
+
for c in node.children]
|
|
59
|
+
body = (f"{base}\n\n## Index summary\n{summary}\n\n"
|
|
60
|
+
f"## Descend — the next layer ({len(node.children)})\n"
|
|
61
|
+
"Only this layer is loaded now. To descend, use the **Read tool** on a child "
|
|
62
|
+
"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"
|
|
64
|
+
+ "\n".join(crumbs))
|
|
65
|
+
else:
|
|
66
|
+
body = base + "\n\n_(leaf — this is an actual skill.)_"
|
|
67
|
+
|
|
68
|
+
if node.coord:
|
|
69
|
+
desc = f"[{node.coord}] {desc}"
|
|
70
|
+
|
|
71
|
+
skill_md.write_text(_front(sname, desc, body), encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
# recurse: each child's dir is a sibling of this node's .claude (tree-path uses plain name)
|
|
74
|
+
for child in node.children:
|
|
75
|
+
_write_node(child, node_dir / child.name, root)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def materialize(tree: SkillTree, root: str | Path, *, overwrite: bool = True,
|
|
79
|
+
coords: bool = False, base: str = "0") -> Path:
|
|
80
|
+
root = Path(root)
|
|
81
|
+
if coords:
|
|
82
|
+
assign_coords(tree.root, base) # address every node before writing
|
|
83
|
+
if overwrite and root.exists():
|
|
84
|
+
shutil.rmtree(root)
|
|
85
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
_write_node(tree.root, root, root) # root node's dir IS the tree root
|
|
87
|
+
tree.save(root / "skilltree.json")
|
|
88
|
+
return root
|
skilltree/model.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""SkillTree model — a tree of skill dirs wired by LINKS, not nesting.
|
|
2
|
+
|
|
3
|
+
Every node is a skill dir (`<name>/SKILL.md`) of any type (ac/cor/sc/skill).
|
|
4
|
+
Edges are links: each node lives in its OWN `.claude/skills/` root, and a node's
|
|
5
|
+
direct children are symlinked into that root. Auto-load reaches a root + its
|
|
6
|
+
direct children only — never deeper (a skill dir won't auto-load a nested
|
|
7
|
+
`.claude`). To descend, you REDIRECT the active skills root to the child's root.
|
|
8
|
+
|
|
9
|
+
Because the platform won't traverse this, the tree's integrity is not guaranteed
|
|
10
|
+
by the filesystem — it must be validated programmatically (see validate.py).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
KINDS = ("ac", "cor", "sc", "skill")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TreeNode:
|
|
23
|
+
name: str # slug; also the tree-path dir name
|
|
24
|
+
kind: str = "skill" # ac | cor | sc | skill
|
|
25
|
+
skill_src: str | None = None # existing `<name>/SKILL.md` dir to carry as content (else a stub)
|
|
26
|
+
description: str | None = None # SKILL.md description (so it auto-loads / is searchable)
|
|
27
|
+
coord: str | None = None # hierarchical address (e.g. "0.1.2"); set by assign_coords
|
|
28
|
+
children: list["TreeNode"] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def walk(self):
|
|
31
|
+
yield self
|
|
32
|
+
for child in self.children:
|
|
33
|
+
yield from child.walk()
|
|
34
|
+
|
|
35
|
+
def edges(self):
|
|
36
|
+
for child in self.children:
|
|
37
|
+
yield (self.name, child.name)
|
|
38
|
+
yield from child.edges()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def skill_name(node: "TreeNode") -> str:
|
|
42
|
+
"""The skill's identity (frontmatter name + skill-dir name).
|
|
43
|
+
|
|
44
|
+
With a coord, it is `<coord>-<name>` — so the flat skills list is coord-sorted,
|
|
45
|
+
reveals the tree, and each node is addressable by its coordinate. The tree-PATH
|
|
46
|
+
dir stays the plain `name`; only the skill identity carries the coord.
|
|
47
|
+
"""
|
|
48
|
+
return f"{node.coord}-{node.name}" if node.coord else node.name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compose_summary(node: "TreeNode", *, full: bool = False) -> str:
|
|
52
|
+
"""A DETERMINISTIC semantic summary of an index (branch) node — a template
|
|
53
|
+
filled with its coordinate, children, and reachable descendants.
|
|
54
|
+
|
|
55
|
+
This is the RAPTOR "internal nodes carry a summary" retrieval-win, gotten for
|
|
56
|
+
free (no LLM): the branch's body becomes dense with its subtree's vocabulary,
|
|
57
|
+
so a query about any descendant matches the branch that leads to it. `full`
|
|
58
|
+
adds the flattened reachable-leaf list (for the indexed body); the short form
|
|
59
|
+
(for the description) lists just the immediate children.
|
|
60
|
+
"""
|
|
61
|
+
kids = ", ".join(f"{c.name} ({c.kind})" for c in node.children) or "(none)"
|
|
62
|
+
short = f"opens to {len(node.children)} branch(es): {kids}"
|
|
63
|
+
if not full:
|
|
64
|
+
return short
|
|
65
|
+
reachable = [d.name for d in node.walk() if d is not node]
|
|
66
|
+
return (f"[{node.coord or '?'}] {node.name} — a {node.kind} index node; {short}. "
|
|
67
|
+
f"Reachable below: {', '.join(reachable)}.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def assign_coords(root: "TreeNode", base: str = "0") -> "TreeNode":
|
|
71
|
+
"""Give every node a hierarchical coordinate: root=base, child i (1-based)=parent.i."""
|
|
72
|
+
def walk(n: "TreeNode", coord: str) -> None:
|
|
73
|
+
n.coord = coord
|
|
74
|
+
for i, child in enumerate(n.children, 1):
|
|
75
|
+
walk(child, f"{coord}.{i}")
|
|
76
|
+
walk(root, base)
|
|
77
|
+
return root
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class SkillTree:
|
|
82
|
+
root: TreeNode
|
|
83
|
+
|
|
84
|
+
# ---- manifest (source of truth for validation) ----
|
|
85
|
+
def to_manifest(self) -> dict:
|
|
86
|
+
def enc(n: TreeNode) -> dict:
|
|
87
|
+
return {"name": n.name, "kind": n.kind, "skill_src": n.skill_src,
|
|
88
|
+
"description": n.description, "coord": n.coord,
|
|
89
|
+
"children": [enc(c) for c in n.children]}
|
|
90
|
+
return {"skilltree": enc(self.root)}
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def from_manifest(data: dict) -> "SkillTree":
|
|
94
|
+
def dec(d: dict) -> TreeNode:
|
|
95
|
+
return TreeNode(name=d["name"], kind=d.get("kind", "skill"),
|
|
96
|
+
skill_src=d.get("skill_src"), description=d.get("description"),
|
|
97
|
+
coord=d.get("coord"),
|
|
98
|
+
children=[dec(c) for c in d.get("children", [])])
|
|
99
|
+
return SkillTree(dec(data["skilltree"]))
|
|
100
|
+
|
|
101
|
+
def save(self, path: str | Path) -> Path:
|
|
102
|
+
p = Path(path)
|
|
103
|
+
p.write_text(json.dumps(self.to_manifest(), indent=2), encoding="utf-8")
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def load(path: str | Path) -> "SkillTree":
|
|
108
|
+
return SkillTree.from_manifest(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
109
|
+
|
|
110
|
+
def nodes(self) -> list[TreeNode]:
|
|
111
|
+
return list(self.root.walk())
|
|
112
|
+
|
|
113
|
+
def edges(self) -> list[tuple[str, str]]:
|
|
114
|
+
return list(self.root.edges())
|