skillchain-compiler 0.3.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.
skillchain.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ skillchain — compose Claude Code skills into a validated, packaged chain.
4
+
5
+ A skillchain is an ordered list of steps. Two step kinds:
6
+ • skill — invoke a named skill (validated to exist on disk)
7
+ • cor — an Attention-Chain / Chain-of-Reasoning bridge between skills
8
+ (an explicit reasoning step that routes one skill's output into the next)
9
+
10
+ The compiler:
11
+ 1. indexes every skill on disk (~/.claude/skills + ~/.claude/plugins),
12
+ 2. validates every referenced skill EXISTS (errors listing any missing),
13
+ 3. wires the CoR bridges between steps,
14
+ 4. exports a skill PACKAGE (<out>/<name>/SKILL.md + chain.json) that, when
15
+ invoked, executes the chain in order.
16
+
17
+ Usage:
18
+ skillchain.py list
19
+ skillchain.py validate <spec.json|spec.chain>
20
+ skillchain.py compile <spec.json|spec.chain> [--out DIR] [--install]
21
+
22
+ Spec formats — see README.md. JSON is canonical; `.chain` is a light text DSL:
23
+ name: research_to_paper
24
+ description: research a question then write it up
25
+ > deep-research : <the question> # '>' = skill step (skill : args)
26
+ = research # '=' = capture last result as a var
27
+ ~ Given {research}, extract the 3 strongest claims and the gaps, then write an outline brief. # '~' = CoR bridge
28
+ > academic-paper : write the paper from the outline brief
29
+ """
30
+ import argparse, json, os, re, sys, pathlib
31
+
32
+ HOME = pathlib.Path.home()
33
+ SKILL_ROOTS = [HOME / ".claude" / "skills", HOME / ".claude" / "plugins"]
34
+
35
+
36
+ # ----------------------------------------------------------------------------- skill index
37
+ def _read(path):
38
+ try:
39
+ return path.read_text(encoding="utf-8", errors="replace")
40
+ except Exception:
41
+ return ""
42
+
43
+
44
+ def _frontmatter(path):
45
+ txt = _read(path)
46
+ name = None
47
+ desc = None
48
+ m = re.search(r"^\s*name:\s*(.+?)\s*$", txt, re.MULTILINE)
49
+ if m:
50
+ name = m.group(1).strip().strip("\"'")
51
+ m = re.search(r"^\s*description:\s*(.+?)\s*$", txt, re.MULTILINE)
52
+ if m:
53
+ desc = m.group(1).strip().strip("\"'")
54
+ return name, desc
55
+
56
+
57
+ def index_skills(roots=SKILL_ROOTS):
58
+ """Return {alias -> {'path':Path,'name':str,'desc':str}}. A skill is reachable
59
+ by its frontmatter name, its directory name, and (after a ':') the colon-suffix."""
60
+ idx = {}
61
+ for root in roots:
62
+ if not root.exists():
63
+ continue
64
+ for sk in root.rglob("SKILL.md"):
65
+ name, desc = _frontmatter(sk)
66
+ dirname = sk.parent.name
67
+ canonical = name or dirname
68
+ rec = {"path": sk, "name": canonical, "desc": desc or ""}
69
+ for alias in {name, dirname, (name or "").split(":")[-1], dirname.split(":")[-1]}:
70
+ if alias:
71
+ idx.setdefault(alias, rec)
72
+ return idx
73
+
74
+
75
+ def resolve(ref, idx):
76
+ """Resolve a skill reference against the index. Try exact, then colon-suffix."""
77
+ if ref in idx:
78
+ return idx[ref]
79
+ if ":" in ref and ref.split(":")[-1] in idx:
80
+ return idx[ref.split(":")[-1]]
81
+ return None
82
+
83
+
84
+ # ----------------------------------------------------------------------------- spec parsing
85
+ def parse_spec(path):
86
+ p = pathlib.Path(path)
87
+ text = _read(p)
88
+ if p.suffix == ".json" or text.lstrip().startswith("{"):
89
+ return json.loads(text)
90
+ return parse_chain_dsl(text)
91
+
92
+
93
+ def parse_chain_dsl(text):
94
+ spec = {"name": None, "description": "", "steps": []}
95
+ for raw in text.splitlines():
96
+ line = raw.strip()
97
+ if not line or line.startswith("#"):
98
+ continue
99
+ if line.lower().startswith("name:"):
100
+ spec["name"] = line.split(":", 1)[1].strip()
101
+ elif line.lower().startswith("description:"):
102
+ spec["description"] = line.split(":", 1)[1].strip()
103
+ elif line.startswith(">"): # skill step: > skill : args
104
+ body = line[1:].strip()
105
+ if ":" in body:
106
+ skill, args = body.split(":", 1)
107
+ spec["steps"].append({"skill": skill.strip(), "args": args.strip()})
108
+ else:
109
+ spec["steps"].append({"skill": body.strip(), "args": ""})
110
+ elif line.startswith("="): # capture last result: = varname
111
+ var = line[1:].strip()
112
+ if spec["steps"]:
113
+ spec["steps"][-1]["capture"] = var
114
+ elif line.startswith("~"): # CoR bridge
115
+ spec["steps"].append({"cor": line[1:].strip()})
116
+ return spec
117
+
118
+
119
+ # ----------------------------------------------------------------------------- validate
120
+ def validate(spec, idx):
121
+ """Return (ok, report). report lists every step with resolved path or MISSING."""
122
+ rows, missing = [], []
123
+ if not spec.get("name"):
124
+ missing.append("(spec has no 'name')")
125
+ for i, step in enumerate(spec.get("steps", []), 1):
126
+ if "skill" in step:
127
+ rec = resolve(step["skill"], idx)
128
+ if rec:
129
+ rows.append((i, "skill", step["skill"], "ok", str(rec["path"])))
130
+ else:
131
+ rows.append((i, "skill", step["skill"], "MISSING", ""))
132
+ missing.append(step["skill"])
133
+ elif "cor" in step:
134
+ rows.append((i, "cor", (step["cor"][:48] + "…") if len(step["cor"]) > 48 else step["cor"], "ok", ""))
135
+ else:
136
+ rows.append((i, "?", str(step), "BAD-STEP", ""))
137
+ missing.append(f"step {i} (unknown kind)")
138
+ return (len(missing) == 0), rows, missing
139
+
140
+
141
+ # ----------------------------------------------------------------------------- compile
142
+ def compile_package(spec, idx, out_dir):
143
+ name = spec["name"]
144
+ desc = spec.get("description", "") or f"Compiled skillchain: {name}"
145
+ steps = spec.get("steps", [])
146
+ deps = sorted({resolve(s["skill"], idx)["name"] for s in steps if "skill" in s})
147
+
148
+ body = []
149
+ body.append(f"# {name} — compiled skillchain\n")
150
+ body.append(
151
+ f"> Compiled by `skillchain.py`. "
152
+ f"Orchestrates {len(steps)} step(s) composing {len(deps)} skill(s).\n"
153
+ f"> **Dependencies (validated present at compile time):** "
154
+ + (", ".join(f"`{d}`" for d in deps) if deps else "none")
155
+ + "\n"
156
+ )
157
+ body.append(
158
+ "**Execute the steps below in order.** Carry each step's result forward; where a step "
159
+ "references `{var}`, substitute the captured value from the named earlier step.\n"
160
+ )
161
+ sidx = 0
162
+ for step in steps:
163
+ sidx += 1
164
+ if "skill" in step:
165
+ rec = resolve(step["skill"], idx)
166
+ args = step.get("args", "").strip()
167
+ cap = step.get("capture")
168
+ body.append(f"## Step {sidx} — invoke skill `{rec['name']}`")
169
+ if rec.get("desc"):
170
+ body.append(f"*{rec['desc']}*\n")
171
+ body.append(
172
+ f"Invoke the **{rec['name']}** skill"
173
+ + (f" with input: {args}" if args else " (no extra input)")
174
+ + (f"\n\n→ Capture its result as `{cap}`." if cap else "")
175
+ + "\n"
176
+ )
177
+ elif "cor" in step:
178
+ body.append(f"## Step {sidx} — reasoning bridge (CoR / AttentionChain)")
179
+ body.append(
180
+ "Reason explicitly, step by step:\n\n"
181
+ f"> {step['cor']}\n\n"
182
+ "State the conclusion, then carry it forward as the input to the next step.\n"
183
+ )
184
+ body.append("## Done")
185
+ body.append("Return the final step's output as the result of this chain.\n")
186
+
187
+ skill_md = f"---\nname: {name}\ndescription: {desc}\n---\n\n" + "\n".join(body)
188
+
189
+ pkg = pathlib.Path(out_dir) / name
190
+ pkg.mkdir(parents=True, exist_ok=True)
191
+ (pkg / "SKILL.md").write_text(skill_md, encoding="utf-8")
192
+ (pkg / "chain.json").write_text(json.dumps(spec, indent=2), encoding="utf-8")
193
+ return pkg, deps
194
+
195
+
196
+ # ----------------------------------------------------------------------------- cli
197
+ def _print_rows(rows):
198
+ for i, kind, what, status, path in rows:
199
+ mark = "✓" if status == "ok" else "✗"
200
+ line = f" {mark} [{i}] {kind:5} {what}"
201
+ if status != "ok":
202
+ line += f" <{status}>"
203
+ elif path:
204
+ line += f" → {path}"
205
+ print(line)
206
+
207
+
208
+ def main():
209
+ ap = argparse.ArgumentParser(prog="skillchain", description="compose skills into a validated, packaged chain")
210
+ sub = ap.add_subparsers(dest="cmd", required=True)
211
+ sub.add_parser("list", help="list indexed skills")
212
+ v = sub.add_parser("validate", help="check a chain spec's skills all exist")
213
+ v.add_argument("spec")
214
+ c = sub.add_parser("compile", help="validate + emit a skill package")
215
+ c.add_argument("spec")
216
+ c.add_argument("--out", default="dist", help="output dir (default: ./dist)")
217
+ c.add_argument("--install", action="store_true", help="emit into ~/.claude/skills instead of --out")
218
+ a = ap.parse_args()
219
+
220
+ idx = index_skills()
221
+
222
+ if a.cmd == "list":
223
+ names = sorted({rec["name"] for rec in idx.values()})
224
+ print(f"{len(names)} skills indexed under {', '.join(str(r) for r in SKILL_ROOTS if r.exists())}:")
225
+ for n in names:
226
+ print(f" - {n}")
227
+ return 0
228
+
229
+ spec = parse_spec(a.spec)
230
+ ok, rows, missing = validate(spec, idx)
231
+ print(f"chain: {spec.get('name')!r} ({len(spec.get('steps', []))} steps)")
232
+ _print_rows(rows)
233
+
234
+ if a.cmd == "validate":
235
+ if ok:
236
+ print("✓ valid — all skills resolve.")
237
+ return 0
238
+ print(f"✗ INVALID — {len(missing)} problem(s): {', '.join(missing)}")
239
+ return 1
240
+
241
+ # compile
242
+ if not ok:
243
+ print(f"✗ refusing to compile — {len(missing)} missing/bad: {', '.join(missing)}")
244
+ return 1
245
+ out = (HOME / ".claude" / "skills") if a.install else pathlib.Path(a.out)
246
+ pkg, deps = compile_package(spec, idx, out)
247
+ print(f"✓ compiled → {pkg}/SKILL.md (+ chain.json)")
248
+ print(f" deps: {', '.join(deps) if deps else 'none'}")
249
+ if a.install:
250
+ print(" installed into ~/.claude/skills — invokable as a skill after reload.")
251
+ return 0
252
+
253
+
254
+ if __name__ == "__main__":
255
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillchain-compiler
3
+ Version: 0.3.0
4
+ Summary: Compose Claude Code skills into a validated, packaged chain
5
+ Requires-Python: >=3.11
@@ -0,0 +1,6 @@
1
+ skillchain.py,sha256=0s3rYB4jj-2a19xBIL1bLZxp_HRHW_DgOGC5wG9KQnc,10104
2
+ skillchain_compiler-0.3.0.dist-info/METADATA,sha256=b1-teA2VdVE9g7DE90JL9XUfVX0qcS7rqYnL9Q7e1jQ,156
3
+ skillchain_compiler-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
4
+ skillchain_compiler-0.3.0.dist-info/entry_points.txt,sha256=aCDLR8zjr_e4fVb55jcJiDSaIMEJt6Y2545Y_fcol04,47
5
+ skillchain_compiler-0.3.0.dist-info/top_level.txt,sha256=ykPtWIjL1rXi5-VNQHWVb5YtpvegKp-SUosF0dj7sIE,11
6
+ skillchain_compiler-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillchain = skillchain:main
@@ -0,0 +1 @@
1
+ skillchain