monkeyshape 0.1.0 → 0.2.0

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What `mshape init` writes: the package's `template/` files, rendered, plus the JSON configs
3
+ * generated here. The configs are generated rather than copied because they pin versions, and
4
+ * because a `biome.json` inside `template/` would be a nested root config for biome itself.
5
+ * `.claude/settings.json` is rendered from the hook list in `hooks.ts`, the same as this repo's.
6
+ */
7
+
8
+ import { HOOKS, renderSettings, SETTINGS_FILE } from "./hooks.ts";
9
+
10
+ /** Lowercase words joined by hyphens: a package name, a directory and a beads prefix at once. */
11
+ export const REPO_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
12
+
13
+ /** The toolchain a new repo pins: this package's own, so the adopter runs what was tested. */
14
+ export interface Toolchain {
15
+ /** Bun's version, for `packageManager` (CI reads it from there). */
16
+ readonly bun: string;
17
+ /** The monkeyshape dependency spec: its exact version, or a tarball when testing a release. */
18
+ readonly monkeyshape: string;
19
+ readonly effect: string;
20
+ readonly biome: string;
21
+ readonly typescript: string;
22
+ readonly typesBun: string;
23
+ }
24
+
25
+ /**
26
+ * npm drops `.gitignore` from published packages, so the template stores it without the dot.
27
+ * Any other dotfile ships as is.
28
+ */
29
+ const RENAMED: Readonly<Record<string, string>> = { gitignore: ".gitignore" };
30
+
31
+ export const targetPath = (templatePath: string): string => RENAMED[templatePath] ?? templatePath;
32
+
33
+ const render = (text: string, name: string): string => text.replaceAll("{{name}}", name);
34
+
35
+ const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`;
36
+
37
+ const configs = (name: string, toolchain: Toolchain): Record<string, string> => ({
38
+ "package.json": json({
39
+ name,
40
+ version: "0.0.0",
41
+ private: true,
42
+ type: "module",
43
+ packageManager: `bun@${toolchain.bun}`,
44
+ scripts: {
45
+ typecheck: "tsc -p .",
46
+ lint: "biome ci --error-on-warnings .",
47
+ fix: "biome check --write .",
48
+ // How hooks and skills run mshape: D-run-mshape.
49
+ mshape: "mshape",
50
+ shape: "mshape check",
51
+ check: "bun run typecheck && bun run lint && bun run shape && bun test --pass-with-no-tests",
52
+ },
53
+ dependencies: { effect: toolchain.effect },
54
+ devDependencies: {
55
+ "@biomejs/biome": toolchain.biome,
56
+ "@types/bun": toolchain.typesBun,
57
+ monkeyshape: toolchain.monkeyshape,
58
+ typescript: toolchain.typescript,
59
+ },
60
+ }),
61
+ "tsconfig.json": json({
62
+ extends: "monkeyshape/tsconfig.base.json",
63
+ include: ["src", "test", "*.ts"],
64
+ }),
65
+ "biome.json": json({
66
+ root: true,
67
+ extends: ["monkeyshape/biome"],
68
+ files: { includes: ["src/**", "test/**", "*.json", "*.ts"] },
69
+ }),
70
+ [SETTINGS_FILE]: renderSettings(HOOKS),
71
+ });
72
+
73
+ /** Every file of the new repo, by repo-relative path. Generated configs win over the template. */
74
+ export const scaffold = (
75
+ name: string,
76
+ template: ReadonlyMap<string, string>,
77
+ toolchain: Toolchain,
78
+ ): ReadonlyMap<string, string> =>
79
+ new Map([
80
+ ...[...template].map(([path, text]) => [targetPath(path), render(text, name)] as const),
81
+ ...Object.entries(configs(name, toolchain)),
82
+ ]);
83
+
84
+ /** The last thing `init` prints: what to do now, and where the conventions are. */
85
+ export const nextStep = (name: string, workflowUrl: string): string =>
86
+ [
87
+ `Next: open a chat with your coding agent in ${name}/ and run /roadmap with a brain-dump of`,
88
+ "what you want to build. It writes docs/roadmap.md and the first epics.",
89
+ `The working loop, in full: ${workflowUrl}`,
90
+ ].join("\n");
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Shared skills: the package's `skills/<name>/` copied into a repo's `.agents/skills/<name>/`,
3
+ * which Claude Code reaches through the committed relative link `.claude/skills`
4
+ * (D-skills-link). Sync records the names it installed in a manifest; any other skill is the
5
+ * repo's own and is never touched, even when the package starts shipping one of that name. A
6
+ * shared skill is replaced whole, and one the package stops shipping is removed.
7
+ */
8
+ import type { ResolvedConfig } from "../schemas/config.ts";
9
+ import { CLAUDE_SKILLS_LINK, CLAUDE_SKILLS_TARGET, SKILLS_DIR, SKILLS_MANIFEST } from "./layout.ts";
10
+ import type { SkillFiles, Snapshot, Violation } from "./model.ts";
11
+ import { join } from "./paths.ts";
12
+
13
+ export interface SkillSync {
14
+ readonly added: ReadonlyArray<string>;
15
+ readonly updated: ReadonlyArray<string>;
16
+ readonly unchanged: ReadonlyArray<string>;
17
+ /** Shared skills the package no longer ships: removed whole. */
18
+ readonly retired: ReadonlyArray<string>;
19
+ /** Skills of the repo's own whose name the package now ships: left alone, and reported. */
20
+ readonly collisions: ReadonlyArray<string>;
21
+ /** Files to write, the manifest included when it changes. */
22
+ readonly write: SkillFiles;
23
+ /** Files of updated shared skills that the package no longer has. */
24
+ readonly remove: ReadonlyArray<string>;
25
+ }
26
+
27
+ const skillOf = (path: string): string => path.slice(0, path.indexOf("/"));
28
+
29
+ const bySkill = (files: SkillFiles): ReadonlyMap<string, SkillFiles> => {
30
+ const out = new Map<string, Map<string, Uint8Array>>();
31
+ for (const [path, bytes] of files) {
32
+ if (!path.includes("/")) continue;
33
+ const skill = skillOf(path);
34
+ out.set(skill, (out.get(skill) ?? new Map()).set(path, bytes));
35
+ }
36
+ return out;
37
+ };
38
+
39
+ const equalBytes = (a: Uint8Array | undefined, b: Uint8Array): boolean =>
40
+ a !== undefined && a.length === b.length && a.every((byte, i) => byte === b[i]);
41
+
42
+ const same = (a: SkillFiles, b: SkillFiles): boolean =>
43
+ a.size === b.size && [...b].every(([path, bytes]) => equalBytes(a.get(path), bytes));
44
+
45
+ /**
46
+ * What a shared skill's name may be: one directory, lowercase words joined by hyphens. Sync
47
+ * removes retired skills by name, so a manifest name like `..` or `../x` must never be read.
48
+ */
49
+ export const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
50
+
51
+ /**
52
+ * The shared skill names in an installed manifest; undefined when it isn't sync's format,
53
+ * including any name that isn't a single skill directory.
54
+ */
55
+ export const parseManifest = (bytes: Uint8Array): ReadonlyArray<string> | undefined => {
56
+ try {
57
+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
58
+ const shared =
59
+ typeof value === "object" && value !== null && "shared" in value ? value.shared : undefined;
60
+ return Array.isArray(shared) &&
61
+ shared.every((name) => typeof name === "string" && SKILL_NAME.test(name))
62
+ ? shared
63
+ : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ };
68
+
69
+ const manifest = (shared: ReadonlyArray<string>): Uint8Array =>
70
+ new TextEncoder().encode(
71
+ `${JSON.stringify(
72
+ {
73
+ about: "The shared skills `mshape skills sync` installed. Written by sync; don't edit.",
74
+ shared,
75
+ },
76
+ null,
77
+ 2,
78
+ )}\n`,
79
+ );
80
+
81
+ /** The installed manifest's names: none when there is no manifest or it can't be read. */
82
+ const sharedNames = (installed: SkillFiles): ReadonlyArray<string> => {
83
+ const bytes = installed.get(SKILLS_MANIFEST);
84
+ return bytes === undefined ? [] : (parseManifest(bytes) ?? []);
85
+ };
86
+
87
+ /** What it takes to bring the installed shared skills up to the package's. */
88
+ export const syncSkills = (shipped: SkillFiles, installed: SkillFiles): SkillSync => {
89
+ const installedBySkill = bySkill(installed);
90
+ const shippedBySkill = bySkill(shipped);
91
+ const shared = sharedNames(installed);
92
+ const added: Array<string> = [];
93
+ const updated: Array<string> = [];
94
+ const unchanged: Array<string> = [];
95
+ const collisions: Array<string> = [];
96
+ const remove: Array<string> = [];
97
+ for (const [skill, files] of [...shippedBySkill].sort(([a], [b]) => a.localeCompare(b))) {
98
+ const current = installedBySkill.get(skill);
99
+ if (current === undefined) added.push(skill);
100
+ else if (!shared.includes(skill)) collisions.push(skill);
101
+ else if (same(current, files)) unchanged.push(skill);
102
+ else {
103
+ updated.push(skill);
104
+ for (const path of current.keys()) if (!files.has(path)) remove.push(path);
105
+ }
106
+ }
107
+ // Only an installed skill directory can be retired: a stale name never becomes a delete.
108
+ const retired = shared
109
+ .filter((skill) => !shippedBySkill.has(skill) && installedBySkill.has(skill))
110
+ .sort();
111
+ const installing = new Set([...added, ...updated]);
112
+ const write = new Map([...shipped].filter(([path]) => installing.has(skillOf(path))));
113
+ const next = [...added, ...updated, ...unchanged].sort();
114
+ if (installed.get(SKILLS_MANIFEST) === undefined || next.join("\n") !== shared.join("\n"))
115
+ write.set(SKILLS_MANIFEST, manifest(next));
116
+ return { added, updated, unchanged, retired, collisions, write, remove };
117
+ };
118
+
119
+ export const formatSync = (sync: SkillSync): string => {
120
+ const changed = (
121
+ [
122
+ ["added", sync.added],
123
+ ["updated", sync.updated],
124
+ ["removed (no longer shipped)", sync.retired],
125
+ ] as const
126
+ ).filter(([, names]) => names.length > 0);
127
+ return changed.length === 0
128
+ ? `${SKILLS_DIR}: up to date (${sync.unchanged.length} shared)`
129
+ : `${SKILLS_DIR}: ${changed.map(([what, names]) => `${what} ${names.join(", ")}`).join("; ")}`;
130
+ };
131
+
132
+ const collision = (name: string): string =>
133
+ `is a skill of this repo's own, but monkeyshape now ships a shared skill named ${name}, so sync leaves yours alone and the shared one uninstalled. Rename yours: move ${SKILLS_DIR}/${name} and change \`name:\` in its SKILL.md, then run \`mshape skills sync\`.`;
134
+
135
+ /** What `mshape skills sync` prints about the skills it would not touch. */
136
+ export const formatCollisions = (sync: SkillSync): ReadonlyArray<string> =>
137
+ sync.collisions.map((name) => `${SKILLS_DIR}/${name} ${collision(name)}`);
138
+
139
+ const violation = (rule: string, file: string, message: string): Violation => ({
140
+ check: "skills",
141
+ rule: `skills/${rule}`,
142
+ file,
143
+ message,
144
+ });
145
+
146
+ const RELINK = `\`rm -r ${CLAUDE_SKILLS_LINK} && mshape skills sync\``;
147
+
148
+ /** Whether git lists `path` or an entry above it: a symlinked skill is a single entry. */
149
+ const listed = (files: ReadonlySet<string>, path: string): boolean =>
150
+ path.split("/").some((_, i, segments) => files.has(segments.slice(0, i + 1).join("/")));
151
+
152
+ const linkViolations = (snapshot: Snapshot, files: ReadonlySet<string>): Array<Violation> => {
153
+ const { link, installed, claudeDirectory } = snapshot.skills;
154
+ if (link.kind === "missing")
155
+ return [
156
+ violation(
157
+ "link",
158
+ CLAUDE_SKILLS_LINK,
159
+ `is missing, so Claude Code can't see the skills in ${SKILLS_DIR}. Run \`mshape skills sync\` to create the link, and commit it.`,
160
+ ),
161
+ ];
162
+ if (link.kind === "link") {
163
+ if (link.target.startsWith("/"))
164
+ return [
165
+ violation(
166
+ "link",
167
+ CLAUDE_SKILLS_LINK,
168
+ `links to the absolute path ${link.target}, which resolves only on this machine's checkout, not in other clones or worktrees. Replace it with the relative link: ${RELINK}.`,
169
+ ),
170
+ ];
171
+ // `../.agents/skills/` and `./../.agents/skills` resolve the same; only where it lands counts.
172
+ if (join(".claude", link.target) !== SKILLS_DIR)
173
+ return [
174
+ violation(
175
+ "link",
176
+ CLAUDE_SKILLS_LINK,
177
+ `links to ${link.target}, not ${CLAUDE_SKILLS_TARGET}. Replace it: ${RELINK}.`,
178
+ ),
179
+ ];
180
+ return files.has(CLAUDE_SKILLS_LINK)
181
+ ? []
182
+ : [
183
+ violation(
184
+ "untracked",
185
+ CLAUDE_SKILLS_LINK,
186
+ "is ignored by git, so clones and CI won't have it. If .gitignore has `.claude/`, change that line to `.claude/*` and add `!.claude/skills`; then `git add .claude/skills`.",
187
+ ),
188
+ ];
189
+ }
190
+ const out = [
191
+ violation(
192
+ "link",
193
+ CLAUDE_SKILLS_LINK,
194
+ `is a ${link.kind === "directory" ? "directory" : "file"}, not a link to ${CLAUDE_SKILLS_TARGET}, so Claude Code and other agents see different skills. The fix deletes it: first move or merge every skill in it into ${SKILLS_DIR}/, then ${RELINK}.`,
195
+ ),
196
+ ];
197
+ const installedBySkill = bySkill(installed);
198
+ for (const [name, copy] of bySkill(claudeDirectory)) {
199
+ const twin = installedBySkill.get(name);
200
+ if (twin === undefined)
201
+ out.push(
202
+ violation(
203
+ "unlinked",
204
+ `${CLAUDE_SKILLS_LINK}/${name}`,
205
+ `is only in ${CLAUDE_SKILLS_LINK}, so agents other than Claude Code can't see it. Move it to ${SKILLS_DIR}/${name}.`,
206
+ ),
207
+ );
208
+ else if (!same(twin, copy))
209
+ out.push(
210
+ violation(
211
+ "diverged",
212
+ `${CLAUDE_SKILLS_LINK}/${name}`,
213
+ `differs from ${SKILLS_DIR}/${name}. Diff the two and keep what you need in ${SKILLS_DIR}/${name} before relinking, which deletes this copy.`,
214
+ ),
215
+ );
216
+ }
217
+ return out;
218
+ };
219
+
220
+ /**
221
+ * The installed skills are what agents load, so they are checked, not trusted: the committed
222
+ * link Claude Code reads through, committed skills with a SKILL.md each, and shared skills
223
+ * matching the package. A repo with neither directory doesn't use skills, and passes.
224
+ */
225
+ export const checkSkills = (
226
+ _config: ResolvedConfig,
227
+ snapshot: Snapshot,
228
+ ): ReadonlyArray<Violation> => {
229
+ const { link, installed, shipped } = snapshot.skills;
230
+ if (link.kind === "missing" && installed.size === 0) return [];
231
+ const files = new Set(snapshot.files);
232
+ const violations = linkViolations(snapshot, files);
233
+
234
+ const ignored = new Set<string>();
235
+ for (const path of installed.keys())
236
+ if (!listed(files, `${SKILLS_DIR}/${path}`))
237
+ ignored.add(`${SKILLS_DIR}/${path.includes("/") ? skillOf(path) : path}`);
238
+ for (const path of [...ignored].sort())
239
+ violations.push(
240
+ violation(
241
+ "untracked",
242
+ path,
243
+ "is ignored by git, so clones and CI won't have it. Remove the .gitignore rule that matches it (`git check-ignore -v <path>` names it), then `git add` it.",
244
+ ),
245
+ );
246
+
247
+ for (const name of bySkill(installed).keys())
248
+ if (!installed.has(`${name}/SKILL.md`))
249
+ violations.push(
250
+ violation(
251
+ "skill-md",
252
+ `${SKILLS_DIR}/${name}`,
253
+ `has no SKILL.md, so no agent loads it. Add ${SKILLS_DIR}/${name}/SKILL.md, or delete the directory.`,
254
+ ),
255
+ );
256
+
257
+ const manifestBytes = installed.get(SKILLS_MANIFEST);
258
+ if (manifestBytes !== undefined && parseManifest(manifestBytes) === undefined)
259
+ violations.push(
260
+ violation(
261
+ "manifest",
262
+ `${SKILLS_DIR}/${SKILLS_MANIFEST}`,
263
+ `is not the list \`mshape skills sync\` writes (a JSON \`shared\` array of skill directory names), so shared skills can't be told from this repo's own, and sync refuses to run. Restore it with \`git checkout -- ${SKILLS_DIR}/${SKILLS_MANIFEST}\`.`,
264
+ ),
265
+ );
266
+
267
+ const sync = syncSkills(shipped, installed);
268
+ const due = (name: string, why: string) =>
269
+ violations.push(
270
+ violation(
271
+ "sync-due",
272
+ `${SKILLS_DIR}/${name}`,
273
+ `${why} Run \`mshape skills sync\` and commit the result.`,
274
+ ),
275
+ );
276
+ for (const name of sync.added)
277
+ due(name, "is a shared skill monkeyshape ships but this repo hasn't installed.");
278
+ for (const name of sync.updated)
279
+ due(
280
+ name,
281
+ "differs from the shared skill the installed monkeyshape ships; to change a shared skill locally, copy it under a name of your own.",
282
+ );
283
+ for (const name of sync.retired)
284
+ due(
285
+ name,
286
+ "is a shared skill monkeyshape no longer ships, so sync removes it; to keep it, copy it under a name of your own first.",
287
+ );
288
+ for (const name of sync.collisions)
289
+ violations.push(violation("collision", `${SKILLS_DIR}/${name}`, collision(name)));
290
+ return violations;
291
+ };