leyline-code 0.8.1__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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,311 @@
1
+ """The skills that ship with Leyline: one folder each, with a SKILL.md that tells a coding agent how to do one job
2
+ with the map (answer a question, plan a change, review a pull request).
3
+
4
+ The folders under `skills/` at the top of the repository are the only copy kept by hand. A wheel carries them as
5
+ `leyline/skills/` (pyproject.toml's force-include), so an installed Leyline finds them beside this module; a checkout
6
+ finds the top-level folder. A new folder there is picked up by the CLI, the MCP server and the wheel with no other
7
+ edit.
8
+
9
+ `install` copies them into a repository's `.claude/skills/` or `.agents/skills/` and records each file's hash in
10
+ `.leyline-skills.json` there, so a later install updates a copy nobody edited and leaves an edited one alone."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ import shutil
18
+ import textwrap
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from . import __version__, store
24
+
25
+ HERE = Path(__file__).resolve().parent
26
+ MANIFEST = ".leyline-skills.json"
27
+ NAME = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
28
+ TARGETS = {"claude": ".claude/skills", "agents": ".agents/skills"}
29
+
30
+
31
+ def skills_dir() -> Optional[Path]:
32
+ """Where the skills are: beside this module in an installed wheel, else `skills/` at the top of a checkout."""
33
+ for d in (HERE / "skills", HERE.parents[1] / "skills"):
34
+ if d.is_dir() and any(d.glob("*/SKILL.md")):
35
+ return d
36
+ return None
37
+
38
+
39
+ def frontmatter(text: str) -> tuple[dict, str]:
40
+ """The `key: value` lines of a SKILL.md's frontmatter, and the text after it. Only the flat form skills use."""
41
+ if not text.startswith("---\n"):
42
+ return {}, text
43
+ end = text.find("\n---\n", 3)
44
+ if end < 0:
45
+ return {}, text
46
+ meta = {}
47
+ for line in text[4:end].splitlines():
48
+ if ":" in line and not line.startswith((" ", "\t", "#")):
49
+ key, value = line.split(":", 1)
50
+ value = value.strip()
51
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
52
+ value = value[1:-1]
53
+ meta[key.strip()] = value
54
+ return meta, text[end + 5:].lstrip("\n")
55
+
56
+
57
+ @dataclass
58
+ class Skill:
59
+ folder: Path
60
+ meta: dict
61
+ body: str
62
+
63
+ @property
64
+ def name(self) -> str:
65
+ return self.meta.get("name") or self.folder.name
66
+
67
+ @property
68
+ def description(self) -> str:
69
+ return self.meta.get("description", "")
70
+
71
+ @property
72
+ def text(self) -> str:
73
+ return (self.folder / "SKILL.md").read_text(encoding="utf-8")
74
+
75
+ def files(self) -> dict[str, bytes]:
76
+ """Every file of the skill, by its path inside the folder."""
77
+ return {p.relative_to(self.folder).as_posix(): p.read_bytes() for p in sorted(self.folder.rglob("*"))
78
+ if p.is_file() and "__pycache__" not in p.parts and p.name != MANIFEST}
79
+
80
+
81
+ def read(folder: Path) -> Skill:
82
+ meta, body = frontmatter((folder / "SKILL.md").read_text(encoding="utf-8"))
83
+ return Skill(folder, meta, body)
84
+
85
+
86
+ def available() -> list[Skill]:
87
+ d = skills_dir()
88
+ return [read(f.parent) for f in sorted(d.glob("*/SKILL.md"))] if d else []
89
+
90
+
91
+ def find(name: str) -> Optional[Skill]:
92
+ """A skill by its name, with or without the `leyline-` in front."""
93
+ by = {s.name: s for s in available()}
94
+ return by.get(name) or by.get("leyline-" + name)
95
+
96
+
97
+ def problems(s: Skill) -> list[str]:
98
+ """What is wrong with a skill's frontmatter, as an agent reads it: none when it is right."""
99
+ out = []
100
+ if not s.meta:
101
+ return ["SKILL.md does not start with frontmatter (--- name: ... description: ... ---)"]
102
+ name, desc = s.meta.get("name", ""), s.meta.get("description", "")
103
+ if not NAME.match(name) or len(name) > 64:
104
+ out.append(f"name {name!r} is not lower case words joined by hyphens, 64 characters at most")
105
+ if name != s.folder.name:
106
+ out.append(f"name {name!r} is not the folder's name, {s.folder.name!r}")
107
+ if not desc or len(desc) > 1024:
108
+ out.append("description is missing or longer than 1,024 characters")
109
+ if "<" in desc or ">" in desc:
110
+ out.append("description has < or >, which agents may read as markup")
111
+ if not s.body.startswith("# "):
112
+ out.append("the text after the frontmatter does not open with a # heading")
113
+ return out
114
+
115
+
116
+ # -- installing into a repository ------------------------------------------------------------------
117
+ def _sha(data: bytes) -> str:
118
+ return hashlib.sha256(data).hexdigest()
119
+
120
+
121
+ def repo_root(start: Path) -> Path:
122
+ """The repository around `start` (the nearest folder with .git), else `start` itself."""
123
+ start = start.resolve()
124
+ for d in (start, *start.parents):
125
+ if (d / ".git").exists():
126
+ return d
127
+ return start
128
+
129
+
130
+ def targets(root: Path, which: Optional[str]) -> list[dict]:
131
+ """The skills folders to install into: `claude`, `agents` or `both`; by default both when the repository has an
132
+ .agents folder, else .claude. A folder that is a link to another one listed is written once."""
133
+ which = which or ("both" if (root / ".agents").is_dir() else "claude")
134
+ out: list[dict] = []
135
+ for key in (("claude", "agents") if which == "both" else (which,)):
136
+ path = root / TARGETS[key]
137
+ same = next((t for t in out if t["path"].resolve() == path.resolve()), None)
138
+ if same and same["path"].is_symlink() and not path.is_symlink(): # name the real folder, and the link to it
139
+ same["also"].append(same["label"])
140
+ same["path"], same["label"] = path, TARGETS[key]
141
+ elif same:
142
+ same["also"].append(TARGETS[key])
143
+ else:
144
+ out.append({"path": path, "label": TARGETS[key], "also": []})
145
+ return out
146
+
147
+
148
+ def _manifest(folder: Path) -> dict:
149
+ try:
150
+ m = json.loads((folder / MANIFEST).read_text(encoding="utf-8"))
151
+ return m if isinstance(m, dict) and isinstance(m.get("skills"), dict) else {"skills": {}}
152
+ except (OSError, ValueError, RecursionError):
153
+ return {"skills": {}}
154
+
155
+
156
+ def _copy(s: Skill, dest: Path, shipped: dict[str, bytes], drop: list[str]) -> None:
157
+ if dest.is_symlink() or dest.is_file():
158
+ dest.unlink()
159
+ for rel in drop: # files an earlier version shipped and this one does not, left as installed
160
+ (dest / rel).unlink(missing_ok=True)
161
+ for rel, data in shipped.items():
162
+ (dest / rel).parent.mkdir(parents=True, exist_ok=True)
163
+ store.write_file(dest / rel, data)
164
+ shutil.copymode(s.folder / rel, dest / rel)
165
+
166
+
167
+ def install_one(s: Skill, folder: Path, manifest: dict, force: bool) -> dict:
168
+ """Install one skill into one skills folder: installed, updated, unchanged, replaced (--force) or kept (edited
169
+ here since it was installed, or not installed by Leyline)."""
170
+ dest = folder / s.name
171
+ shipped = s.files()
172
+ want = {rel: _sha(b) for rel, b in shipped.items()}
173
+ record = (manifest["skills"].get(s.name) or {}).get("files") or {}
174
+ if not dest.exists() and not dest.is_symlink():
175
+ state, drop = "installed", []
176
+ elif not dest.is_dir() or dest.is_symlink():
177
+ if not force:
178
+ return {"skill": s.name, "state": "kept", "why": f"{dest.name} here is not a folder; --force replaces it"}
179
+ state, drop = "replaced", []
180
+ else:
181
+ now = {rel: _sha((dest / rel).read_bytes()) if (dest / rel).is_file() else None for rel in {*want, *record}}
182
+ gone = [rel for rel in record if rel not in want and now.get(rel) is not None]
183
+ if all(now[rel] == want[rel] for rel in want) and not gone:
184
+ state, drop = "unchanged", []
185
+ else:
186
+ edited = sorted(rel for rel in {*want, *record} if now.get(rel) != record.get(rel)) if record else []
187
+ if record and not edited:
188
+ state, drop = "updated", gone
189
+ elif force:
190
+ state, drop = "replaced", gone
191
+ else:
192
+ why = (f"edited here since it was installed ({', '.join(edited)})" if record else
193
+ "a different copy is here that Leyline did not install")
194
+ return {"skill": s.name, "state": "kept", "why": why + "; --force replaces it"}
195
+ if state != "unchanged":
196
+ _copy(s, dest, shipped, drop)
197
+ manifest["skills"][s.name] = {"files": want, "version": __version__}
198
+ return {"skill": s.name, "state": state}
199
+
200
+
201
+ def install(root: Path, which: Optional[str] = None, force: bool = False, names: Optional[list[str]] = None) -> dict:
202
+ """Copy the skills into the repository at `root`. Returns, per skills folder, what happened to each skill."""
203
+ skills = available()
204
+ if names:
205
+ picked = [find(n) for n in names]
206
+ missing = [n for n, s in zip(names, picked) if s is None]
207
+ if missing:
208
+ return {"error": f"no skill named {', '.join(missing)}. `leyline skills list` names them."}
209
+ skills = [s for s in picked if s is not None]
210
+ if not skills:
211
+ return {"error": "this Leyline has no skills to install: its skills folder is missing."}
212
+ out = []
213
+ for t in targets(root, which):
214
+ t["path"].mkdir(parents=True, exist_ok=True)
215
+ manifest = _manifest(t["path"])
216
+ results = [install_one(s, t["path"], manifest, force) for s in skills]
217
+ manifest["leyline"] = __version__
218
+ store.write_file(t["path"] / MANIFEST, json.dumps(manifest, indent=2, sort_keys=True) + "\n")
219
+ out.append({"folder": t["label"], "path": str(t["path"]), "also": t["also"], "skills": results})
220
+ return {"root": str(root), "targets": out}
221
+
222
+
223
+ # -- text for a person -----------------------------------------------------------------------------
224
+ EXAMPLES = { # what a person might ask that each skill answers; the first four installed are shown after an install
225
+ "leyline-ask": "who calls the save function, and how sure is that?",
226
+ "leyline-explain-flow": "what happens when a user saves a file?",
227
+ "leyline-explore-module": "what is in editor/host, and where do I start?",
228
+ "leyline-quick-change": "make the retry count 3",
229
+ "leyline-pr-review": "review this pull request",
230
+ "leyline-spec": "plan adding an export button, with tests",
231
+ "leyline-change-impact": "what would changing the validator's output format affect?",
232
+ "leyline-adversarial-review": "stress-test the plan for loud-engine",
233
+ "leyline-tour": "write a tour of the payment code for a new hire",
234
+ }
235
+
236
+
237
+ def list_text() -> str:
238
+ skills = available()
239
+ if not skills:
240
+ return "This Leyline has no skills: its skills folder is missing."
241
+ L = []
242
+ for s in skills:
243
+ L.append(s.name)
244
+ L += textwrap.wrap(s.description, 100, initial_indent=" ", subsequent_indent=" ")
245
+ L += ["", "Read one: `leyline skills show <name>`. Give them to your coding agent: `leyline skills install` in the"
246
+ " repository."]
247
+ return "\n".join(L)
248
+
249
+
250
+ def install_text(r: dict) -> str:
251
+ words = {"installed": "installed", "updated": "updated", "unchanged": "up to date", "replaced": "replaced",
252
+ "kept": "kept"}
253
+ L = []
254
+ for t in r["targets"]:
255
+ also = f" ({', '.join(t['also'])} {'links' if len(t['also']) == 1 else 'link'} to it)" if t["also"] else ""
256
+ L.append(f"{t['folder']}{also}:")
257
+ for x in t["skills"]:
258
+ L.append(f" {words[x['state']]:<11} {x['skill']}" + (f": {x['why']}" if x.get("why") else ""))
259
+ kept = [x["skill"] for t in r["targets"] for x in t["skills"] if x["state"] == "kept"]
260
+ names = {x["skill"] for t in r["targets"] for x in t["skills"]}
261
+ ask = [f" \"{q}\" ({n})" for n, q in EXAMPLES.items() if n in names][:4]
262
+ folders = " and ".join(f for t in r["targets"] for f in (t["folder"], *t["also"]))
263
+ wrap = lambda s: textwrap.wrap(s, 100)
264
+ L += ["", *wrap(f"Your agent reads skills from {folders} when a session starts: start a new one if one is open."
265
+ " Then ask in plain words, and it picks the skill. For example:"), *ask,
266
+ "The skills use Leyline's MCP server. Connect it once, in the repository:",
267
+ " claude mcp add leyline -- leyline serve",
268
+ "Without it, they fall back to the leyline command.",
269
+ "Commit the folder if the rest of the team should have them too."]
270
+ if kept:
271
+ L.append(f"{len(kept)} kept as edited here; `leyline skills install --force` replaces them with Leyline's.")
272
+ return "\n".join(L)
273
+
274
+
275
+ def start_here() -> str:
276
+ """Where an agent connected to the server starts, for the server's instructions."""
277
+ names = [s.name for s in available()]
278
+ return ("\n\nStart here, by what the person wants: `map` once if nothing is mapped. To understand code, follow"
279
+ " the leyline-ask skill (how something runs: leyline-explain-flow; what a part holds: leyline-explore-module)."
280
+ " To change code: leyline-spec (`plan`) when there is a design to agree, leyline-quick-change (`quick`) for"
281
+ " a small fix, leyline-pr-review (`review_pr`) for code someone else wrote. Then leyline-adversarial-review"
282
+ " (`spec_review_facts`), the person decides each finding, the code is written, and `check` (or `quick` with"
283
+ " done) gives the verdict. Each skill is an MCP prompt of the same name; the `skills` tool lists them and"
284
+ " returns one's text." + (f" Skills: {', '.join(names)}." if names else ""))
285
+
286
+
287
+ def cli(args) -> int:
288
+ """`leyline skills list | show <name> | install [names] [--to DIR] [--claude|--agents|--both] [--force]`."""
289
+ import sys
290
+ if args.action == "list":
291
+ print(list_text())
292
+ return 0 if available() else 1
293
+ if args.action == "show":
294
+ s = find(args.names[0]) if args.names else None
295
+ if s is None:
296
+ print(f"leyline: no skill named {args.names[0] if args.names else '(none given)'}. `leyline skills list`"
297
+ " names them.", file=sys.stderr)
298
+ return 2
299
+ print(s.text, end="")
300
+ return 0
301
+ root = Path(args.to) if args.to else repo_root(Path.cwd())
302
+ if not root.is_dir():
303
+ print(f"leyline: {root} is not a directory. Name the repository with --to.", file=sys.stderr)
304
+ return 2
305
+ r = install(root, args.where, args.force, args.names or None)
306
+ if "error" in r:
307
+ print(f"leyline: {r['error']}", file=sys.stderr)
308
+ return 2
309
+ print(f"Leyline's skills, into {root}:")
310
+ print(install_text(r))
311
+ return 0