@topmindspace/topmind-skills 4.13.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.
Files changed (68) hide show
  1. package/INSTALL.md +295 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +162 -0
  4. package/README.md +162 -0
  5. package/README.zh-CN.md +162 -0
  6. package/bin/install-skills.mjs +693 -0
  7. package/evals/evals.json +236 -0
  8. package/install-targets/claude-code.json +62 -0
  9. package/install-targets/codex.json +60 -0
  10. package/install-targets/hermes.json +60 -0
  11. package/install-targets/opencode.json +63 -0
  12. package/integrations/codex/README.md +20 -0
  13. package/integrations/hermes/README.md +19 -0
  14. package/integrations/opencode/README.md +33 -0
  15. package/integrations/opencode/commands/topmind-capture.md +15 -0
  16. package/integrations/opencode/commands/topmind-doctor.md +11 -0
  17. package/integrations/opencode/opencode.example.json +20 -0
  18. package/integrations/opencode/plugins/topmind-plugin.ts +17 -0
  19. package/integrations/opencode/tests/opencode-config-shape.test.mjs +63 -0
  20. package/package.json +70 -0
  21. package/scripts/build-pack.mjs +463 -0
  22. package/shared/capability-degradation.md +82 -0
  23. package/shared/document-ingest.md +117 -0
  24. package/shared/host-loading.md +140 -0
  25. package/shared/long-url-capture.md +60 -0
  26. package/shared/output-language.md +32 -0
  27. package/shared/project-model-brief.md +132 -0
  28. package/shared/trigger-disambiguation.md +58 -0
  29. package/shared/writeback-receipt.md +73 -0
  30. package/skills.md +39 -0
  31. package/topmind/SKILL.md +170 -0
  32. package/topmind/references/connector-resolution.md +11 -0
  33. package/topmind/references/multi-intent.md +17 -0
  34. package/topmind/references/template-categories.md +45 -0
  35. package/topmind-capture/SKILL.md +148 -0
  36. package/topmind-ledger/SKILL.md +86 -0
  37. package/topmind-loop/DESIGN.md +82 -0
  38. package/topmind-loop/SKILL.md +111 -0
  39. package/topmind-loop/references/scopes-and-walk.md +56 -0
  40. package/topmind-loop/references/state-file.md +52 -0
  41. package/topmind-maintain/SKILL.md +104 -0
  42. package/topmind-memory/SKILL.md +117 -0
  43. package/topmind-organize/SKILL.md +144 -0
  44. package/topmind-pack.json +418 -0
  45. package/topmind-wechat/DESIGN.md +50 -0
  46. package/topmind-wechat/SKILL.md +218 -0
  47. package/topmind-wechat/assets/themes/amber-review.json +35 -0
  48. package/topmind-wechat/assets/themes/graphite.json +35 -0
  49. package/topmind-wechat/assets/themes/minimal-ink.json +35 -0
  50. package/topmind-wechat/assets/themes/newsprint.json +35 -0
  51. package/topmind-wechat/assets/themes/tech-blue.json +35 -0
  52. package/topmind-wechat/references/element-spec.md +162 -0
  53. package/topmind-wechat/references/known-pits.md +86 -0
  54. package/topmind-wechat/references/theme-map.md +139 -0
  55. package/topmind-wechat/references/typography-rules.md +62 -0
  56. package/topmind-wechat/references/wechat-constraints.md +106 -0
  57. package/topmind-wechat/references/workflow.md +102 -0
  58. package/topmind-wechat/references/writing-quality.md +129 -0
  59. package/topmind-wechat/scripts/lint-wechat.py +446 -0
  60. package/topmind-wechat/scripts/md2wechat.py +1599 -0
  61. package/topmind-wechat/scripts/new-article.py +183 -0
  62. package/topmind-wechat/scripts/push-to-topstream.py +226 -0
  63. package/topmind-wechat/scripts/scan_ai_flavor.py +464 -0
  64. package/topmind-wechat/scripts/sync-mapping.py +220 -0
  65. package/topmind-wechat/scripts/sync-status.py +187 -0
  66. package/topmind-weread/SKILL.md +97 -0
  67. package/topmind-write/SKILL.md +116 -0
  68. package/topmind-x/SKILL.md +97 -0
@@ -0,0 +1,693 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * topmind skills install / update — same mental model as open skill registries:
4
+ *
5
+ * "Point at a repo (or local path) + optional skills subdir → install into a host skills root → update later."
6
+ *
7
+ * Compatible with the community CLI (installs individual SKILL.md trees):
8
+ *
9
+ * npx skills add topmindspace/topmind-skills -g -y
10
+ * npx skills update -g -y
11
+ *
12
+ * This script is pack-aware: it also copies shared/, topmind-pack.json, INSTALL.md, etc.
13
+ * so progressive-disclosure links like ../shared/*.md keep working.
14
+ *
15
+ * Commands:
16
+ * add <source> Install or reinstall from a source
17
+ * update Re-install from the receipt written at dest
18
+ * list Show what a source would install (no write)
19
+ *
20
+ * Source forms for `add`:
21
+ * topmindspace/topmind-skills GitHub owner/repo (default branch)
22
+ * topmindspace/topmind-skills@main owner/repo@ref
23
+ * https://github.com/topmindspace/topmind-skills.git git URL
24
+ * ./skills local pack root or monorepo skills/
25
+ * release:latest | release:v4.3.0 GitHub Release zip (topmind-skills-*)
26
+ *
27
+ * Options:
28
+ * --path <subdir> Subdir inside the repo that holds the pack (default: skills)
29
+ * --dest <dir> Host skills root to install into
30
+ * --host <name> Default dest: claude-code|codex|hermes|opencode|generic
31
+ * --mode copy|symlink copy (default) or symlink (local sources only)
32
+ * --skill <id> Only install these skill ids (repeatable / comma-separated)
33
+ * --global / -g Alias: --host claude-code (user-level ~/.claude/skills)
34
+ * --dry-run Print plan only
35
+ * --force Replace non-symlink destinations when using symlink mode
36
+ *
37
+ * Examples:
38
+ * # Like `npx skills add topmindspace/topmind-skills -g`
39
+ * node bin/install-skills.mjs add topmindspace/topmind-skills -g
40
+ *
41
+ * # Custom monorepo path: only the skills/ tree
42
+ * node bin/install-skills.mjs add topmindspace/topmind-skills --dest ~/.claude/skills
43
+ *
44
+ * # Install into a project-local skills dir
45
+ * node bin/install-skills.mjs add topmindspace/topmind-skills --dest ./.claude/skills
46
+ *
47
+ * # Upgrade later (reads .topmind-skills-install.json at dest)
48
+ * node bin/install-skills.mjs update --dest ~/.claude/skills
49
+ *
50
+ * # Local checkout, live symlink while developing
51
+ * node bin/install-skills.mjs add . --mode symlink --dest ~/.claude/skills
52
+ *
53
+ * Private GitHub: export GH_TOKEN or GITHUB_TOKEN.
54
+ */
55
+
56
+ import { promises as fs } from "node:fs";
57
+ import path from "node:path";
58
+ import os from "node:os";
59
+ import { fileURLToPath } from "node:url";
60
+ import { spawnSync } from "node:child_process";
61
+ import { createWriteStream } from "node:fs";
62
+ import { pipeline } from "node:stream/promises";
63
+ import { Readable } from "node:stream";
64
+
65
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
66
+ const REPO_ROOT = path.resolve(__dirname, "..");
67
+ const DEFAULT_OWNER_REPO = "topmindspace/topmind-skills";
68
+ const DEFAULT_GIT = `https://github.com/${DEFAULT_OWNER_REPO}.git`;
69
+ // Pack lives at repo root in topmind-skills (no monorepo skills/ subdir).
70
+ const DEFAULT_PATH = ".";
71
+ const RECEIPT_NAME = ".topmind-skills-install.json";
72
+
73
+ const HOST_DEFAULTS = {
74
+ "claude-code": () => path.join(os.homedir(), ".claude", "skills"),
75
+ codex: () => path.join(os.homedir(), ".codex", "skills"),
76
+ hermes: () => path.join(os.homedir(), ".hermes", "skills"),
77
+ opencode: () => path.join(process.cwd(), ".opencode", "skills"),
78
+ generic: () => path.join(process.cwd(), "topmind-skills"),
79
+ };
80
+
81
+ function log(msg) {
82
+ process.stdout.write(`[install-skills] ${msg}\n`);
83
+ }
84
+
85
+ function fail(msg, code = 1) {
86
+ process.stderr.write(`[install-skills] ERROR: ${msg}\n`);
87
+ process.exit(code);
88
+ }
89
+
90
+ function printHelp() {
91
+ process.stdout.write(`Usage:
92
+ node bin/install-skills.mjs add <source> [options]
93
+ node bin/install-skills.mjs update [--dest <dir>]
94
+ node bin/install-skills.mjs list <source> [options]
95
+
96
+ Source: owner/repo | owner/repo@ref | git-url | local-path | release:<tag>
97
+
98
+ Same idea as: npx skills add <owner/repo> -g
99
+ Pack-aware: also installs shared/ + topmind-pack.json so relative links work.
100
+
101
+ Options:
102
+ --locale <code> Install locale overlay (e.g. en-US); falls back to topmind_LOCALE env
103
+ --dest <dir> Host skills root to install into
104
+ --host <name> Default dest: claude-code|codex|hermes|opencode|generic
105
+ --mode copy|symlink copy (default) or symlink (local sources only)
106
+ --skill <id> Only install these skill ids (repeatable / comma-separated)
107
+ --global / -g Alias: --host claude-code (user-level ~/.claude/skills)
108
+ --dry-run Print plan only
109
+ --force Replace non-symlink destinations when using symlink mode
110
+
111
+ See INSTALL.md for full docs.
112
+ `);
113
+ }
114
+
115
+ // ─── args ───────────────────────────────────────────────────────────────────
116
+
117
+ function parseArgs(argv) {
118
+ const out = {
119
+ command: null,
120
+ source: null,
121
+ dest: null,
122
+ repoPath: DEFAULT_PATH,
123
+ mode: "copy",
124
+ host: "claude-code",
125
+ skillsFilter: null, // null = all, else Set of ids
126
+ dryRun: false,
127
+ force: false,
128
+ locale: null,
129
+ };
130
+
131
+ if (argv.length === 0) {
132
+ printHelp();
133
+ process.exit(0);
134
+ }
135
+
136
+ // Legacy flag-only form (no subcommand) → treat as add with flags
137
+ const first = argv[0];
138
+ if (first === "add" || first === "install" || first === "a") {
139
+ out.command = "add";
140
+ argv = argv.slice(1);
141
+ } else if (first === "update" || first === "upgrade" || first === "u") {
142
+ out.command = "update";
143
+ argv = argv.slice(1);
144
+ } else if (first === "list" || first === "ls") {
145
+ out.command = "list";
146
+ argv = argv.slice(1);
147
+ } else if (first === "--help" || first === "-h") {
148
+ printHelp();
149
+ process.exit(0);
150
+ } else if (first.startsWith("-")) {
151
+ // npm run skills:install -- --from-git (legacy)
152
+ out.command = "add";
153
+ } else {
154
+ // bare source: install-skills.mjs topmindspace/topmind-skills
155
+ out.command = "add";
156
+ }
157
+
158
+ for (let i = 0; i < argv.length; i++) {
159
+ const a = argv[i];
160
+ const next = () => {
161
+ const v = argv[++i];
162
+ if (v == null || (v.startsWith("-") && v !== "-")) fail(`missing value for ${a}`);
163
+ return v;
164
+ };
165
+
166
+ if (a === "--dest" || a === "-d") out.dest = next();
167
+ else if (a === "--path" || a === "--subdir") out.repoPath = next().replace(/^\/+|\/+$/g, "") || ".";
168
+ else if (a === "--source" || a === "-s") out.source = next();
169
+ else if (a === "--from-git") {
170
+ // legacy: --from-git [url]
171
+ const peek = argv[i + 1];
172
+ if (peek && !peek.startsWith("-")) out.source = next();
173
+ else out.source = DEFAULT_OWNER_REPO;
174
+ } else if (a === "--from-release") {
175
+ const peek = argv[i + 1];
176
+ out.source = peek && !peek.startsWith("-") ? `release:${next()}` : "release:latest";
177
+ } else if (a === "--mode" || a === "-m") out.mode = next();
178
+ else if (a === "--host") out.host = next();
179
+ else if (a === "--global" || a === "-g") {
180
+ out.host = "claude-code";
181
+ if (!out.dest) out.dest = HOST_DEFAULTS["claude-code"]();
182
+ } else if (a === "--skill") {
183
+ const raw = next();
184
+ out.skillsFilter = out.skillsFilter || new Set();
185
+ for (const part of raw.split(",")) {
186
+ const id = part.trim();
187
+ if (id) out.skillsFilter.add(id);
188
+ }
189
+ } else if (a === "--dry-run") out.dryRun = true;
190
+ else if (a === "--locale") out.locale = next();
191
+ else if (a === "--force" || a === "-f") out.force = true;
192
+ else if (a === "--update" || a === "-u") {
193
+ // npm run skills:update compatibility
194
+ out.command = "update";
195
+ } else if (a === "--help" || a === "-h") {
196
+ printHelp();
197
+ process.exit(0);
198
+ } else if (a.startsWith("-")) fail(`unknown option: ${a}`);
199
+ else if (!out.source) out.source = a;
200
+ else fail(`unexpected argument: ${a}`);
201
+ }
202
+
203
+ if (!["copy", "symlink"].includes(out.mode)) fail(`--mode must be copy|symlink`);
204
+ if (!out.dest) {
205
+ if (!HOST_DEFAULTS[out.host]) {
206
+ fail(`unknown --host ${out.host}; pass --dest or use: ${Object.keys(HOST_DEFAULTS).join("|")}`);
207
+ }
208
+ out.dest = HOST_DEFAULTS[out.host]();
209
+ }
210
+ out.dest = path.resolve(out.dest.replace(/^~(?=\/|$)/, os.homedir()));
211
+ // Resolve locale: --locale flag → topmind_LOCALE env → null (no overlay)
212
+ if (!out.locale) {
213
+ out.locale = process.env.topmind_LOCALE || null;
214
+ }
215
+ return out;
216
+ }
217
+
218
+ // ─── fs helpers ─────────────────────────────────────────────────────────────
219
+
220
+ async function pathExists(p) {
221
+ try {
222
+ await fs.access(p);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ async function isSkillsRoot(dir) {
230
+ if (!(await pathExists(dir))) return false;
231
+ const hasPack = await pathExists(path.join(dir, "topmind-pack.json"));
232
+ const hasRouter = await pathExists(path.join(dir, "topmind", "SKILL.md"));
233
+ if (hasPack && hasRouter) return true;
234
+ // Accept a bare Agent-Skills layout (dirs with SKILL.md) without pack json
235
+ try {
236
+ const entries = await fs.readdir(dir, { withFileTypes: true });
237
+ let n = 0;
238
+ for (const e of entries) {
239
+ if (e.isDirectory() && (await pathExists(path.join(dir, e.name, "SKILL.md")))) n++;
240
+ }
241
+ return n > 0;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ async function resolveSkillsRoot(dir) {
248
+ if (await isSkillsRoot(dir)) return dir;
249
+ if (await isSkillsRoot(path.join(dir, "skills"))) return path.join(dir, "skills");
250
+ return dir;
251
+ }
252
+
253
+ function run(cmd, args, opts = {}) {
254
+ const r = spawnSync(cmd, args, {
255
+ encoding: "utf8",
256
+ stdio: opts.silent ? "pipe" : "inherit",
257
+ env: { ...process.env, ...opts.env },
258
+ });
259
+ if (r.status !== 0) {
260
+ const err = (r.stderr || r.stdout || "").toString().slice(0, 500);
261
+ fail(`${cmd} ${args.join(" ")} failed (${r.status}): ${err}`);
262
+ }
263
+ return r;
264
+ }
265
+
266
+ function githubHeaders() {
267
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "";
268
+ const h = {
269
+ Accept: "application/vnd.github+json",
270
+ "User-Agent": "topmind-install-skills",
271
+ };
272
+ if (token) h.Authorization = `Bearer ${token}`;
273
+ return h;
274
+ }
275
+
276
+ async function fetchJson(url) {
277
+ const res = await fetch(url, { headers: githubHeaders() });
278
+ if (!res.ok) fail(`GET ${url} → ${res.status} ${res.statusText} (private repo needs GH_TOKEN)`);
279
+ return res.json();
280
+ }
281
+
282
+ async function downloadTo(url, destFile) {
283
+ const res = await fetch(url, { headers: githubHeaders(), redirect: "follow" });
284
+ if (!res.ok) fail(`download ${url} → ${res.status}`);
285
+ await fs.mkdir(path.dirname(destFile), { recursive: true });
286
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destFile));
287
+ }
288
+
289
+ // ─── source resolution ──────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Normalize user source string into a descriptor.
293
+ * @returns {{ kind: 'local'|'git'|'release', value: string, ref?: string, label: string }}
294
+ */
295
+ function parseSource(raw, repoPath) {
296
+ if (!raw || raw === ".") {
297
+ // topmind-skills: pack lives at repo root; monorepo fallback: skills/
298
+ const root = path.join(REPO_ROOT, "skills");
299
+ const value = REPO_ROOT; // resolveSkillsRoot will pick the right child
300
+ void root;
301
+ return { kind: "local", value, label: `local:${value}` };
302
+ }
303
+
304
+ // release:tag
305
+ if (raw.startsWith("release:")) {
306
+ return { kind: "release", value: raw.slice("release:".length) || "latest", label: raw };
307
+ }
308
+
309
+ // absolute / relative filesystem path
310
+ if (
311
+ raw.startsWith("/") ||
312
+ raw.startsWith("./") ||
313
+ raw.startsWith("../") ||
314
+ raw.startsWith("~") ||
315
+ raw === "skills"
316
+ ) {
317
+ const p = path.resolve(raw.replace(/^~(?=\/|$)/, os.homedir()));
318
+ return { kind: "local", value: p, label: `local:${p}` };
319
+ }
320
+
321
+ // git URL
322
+ if (/^(https?:\/\/|git@)/i.test(raw) || raw.endsWith(".git")) {
323
+ return { kind: "git", value: raw, ref: "HEAD", path: repoPath, label: `git:${raw}` };
324
+ }
325
+
326
+ // owner/repo or owner/repo@ref (skills-add style)
327
+ const m = raw.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:@([A-Za-z0-9_./-]+))?$/);
328
+ if (m) {
329
+ const repo = m[1];
330
+ const ref = m[2] || "HEAD";
331
+ const url = `https://github.com/${repo}.git`;
332
+ return { kind: "git", value: url, ref, path: repoPath, label: `github:${repo}${m[2] ? "@" + m[2] : ""}` };
333
+ }
334
+
335
+ fail(
336
+ `unrecognized source "${raw}". Use owner/repo, git URL, local path, or release:<tag>.\n` +
337
+ ` examples: topmindspace/topmind-skills . release:latest`,
338
+ );
339
+ }
340
+
341
+ async function materializeFromGit(gitUrl, ref, subPath, workDir) {
342
+ log(`clone ${gitUrl} (sparse: ${subPath || "."}${ref && ref !== "HEAD" ? ` @ ${ref}` : ""})`);
343
+ await fs.mkdir(workDir, { recursive: true });
344
+ const repoDir = path.join(workDir, "repo");
345
+ if (await pathExists(repoDir)) await fs.rm(repoDir, { recursive: true, force: true });
346
+
347
+ // depth-1 sparse clone; checkout ref if not default
348
+ const cloneArgs = ["clone", "--filter=blob:none", "--sparse"];
349
+ if (!ref || ref === "HEAD") cloneArgs.push("--depth", "1");
350
+ cloneArgs.push(gitUrl, repoDir);
351
+ run("git", cloneArgs);
352
+
353
+ if (ref && ref !== "HEAD") {
354
+ run("git", ["-C", repoDir, "fetch", "--depth", "1", "origin", ref]);
355
+ run("git", ["-C", repoDir, "checkout", "FETCH_HEAD"]);
356
+ }
357
+
358
+ if (subPath && subPath !== ".") {
359
+ run("git", ["-C", repoDir, "sparse-checkout", "set", subPath]);
360
+ const candidate = path.join(repoDir, subPath);
361
+ const root = await resolveSkillsRoot(candidate);
362
+ if (!(await isSkillsRoot(root))) {
363
+ fail(`no skills pack found under ${candidate} (looked for topmind-pack.json or */SKILL.md)`);
364
+ }
365
+ return root;
366
+ }
367
+
368
+ // Default: pack at repo root (topmind-skills) or monorepo skills/
369
+ run("git", ["-C", repoDir, "sparse-checkout", "set", "skills"]);
370
+ if (!(await pathExists(path.join(repoDir, "skills")))) {
371
+ run("git", ["-C", repoDir, "sparse-checkout", "disable"]);
372
+ }
373
+ const root = await resolveSkillsRoot(repoDir);
374
+ if (!(await isSkillsRoot(root))) {
375
+ fail(`no skills pack found under ${repoDir} (looked for topmind-pack.json or */SKILL.md)`);
376
+ }
377
+ return root;
378
+ }
379
+
380
+ async function materializeFromRelease(tag, workDir) {
381
+ log(`fetch release assets (tag=${tag}) from ${DEFAULT_OWNER_REPO}`);
382
+ let release;
383
+ if (tag === "latest") {
384
+ try {
385
+ release = await fetchJson(`https://api.github.com/repos/${DEFAULT_OWNER_REPO}/releases/latest`);
386
+ } catch {
387
+ const list = await fetchJson(`https://api.github.com/repos/${DEFAULT_OWNER_REPO}/releases?per_page=10`);
388
+ release = Array.isArray(list) ? list.find((r) => !r.draft) || list[0] : null;
389
+ }
390
+ } else {
391
+ release = await fetchJson(`https://api.github.com/repos/${DEFAULT_OWNER_REPO}/releases/tags/${tag}`);
392
+ }
393
+ if (!release) fail("no GitHub release found — use owner/repo source or publish pack:skills");
394
+ const assets = release.assets || [];
395
+ const zip =
396
+ assets.find((a) => /^topmind-skills-.*\.zip$/i.test(a.name)) ||
397
+ assets.find((a) => /skills.*\.zip$/i.test(a.name));
398
+ if (!zip) {
399
+ fail(`release ${release.tag_name || tag} has no topmind-skills-*.zip — use owner/repo instead`);
400
+ }
401
+ const zipPath = path.join(workDir, zip.name);
402
+ log(`download ${zip.name}`);
403
+ await downloadTo(zip.browser_download_url, zipPath);
404
+ const unpack = path.join(workDir, "unpack");
405
+ await fs.mkdir(unpack, { recursive: true });
406
+ const tar = spawnSync("tar", ["-xf", zipPath, "-C", unpack], { encoding: "utf8" });
407
+ if (tar.status !== 0) run("unzip", ["-q", zipPath, "-d", unpack]);
408
+
409
+ async function walk(dir, depth = 0) {
410
+ if (depth > 4) return null;
411
+ if (await isSkillsRoot(dir)) return resolveSkillsRoot(dir);
412
+ let entries;
413
+ try {
414
+ entries = await fs.readdir(dir, { withFileTypes: true });
415
+ } catch {
416
+ return null;
417
+ }
418
+ for (const e of entries) {
419
+ if (!e.isDirectory()) continue;
420
+ const hit = await walk(path.join(dir, e.name), depth + 1);
421
+ if (hit) return hit;
422
+ }
423
+ return null;
424
+ }
425
+ const root = await walk(unpack);
426
+ if (!root) fail(`could not find skills pack root inside ${zip.name}`);
427
+ return root;
428
+ }
429
+
430
+ async function materialize(sourceDesc, opts, workDir) {
431
+ if (sourceDesc.kind === "local") {
432
+ const root = await resolveSkillsRoot(sourceDesc.value);
433
+ if (!(await isSkillsRoot(root))) fail(`not a skills pack: ${root}`);
434
+ return root;
435
+ }
436
+ if (sourceDesc.kind === "git") {
437
+ return materializeFromGit(sourceDesc.value, sourceDesc.ref || "HEAD", sourceDesc.path || opts.repoPath, workDir);
438
+ }
439
+ if (sourceDesc.kind === "release") {
440
+ return materializeFromRelease(sourceDesc.value, workDir);
441
+ }
442
+ fail(`unknown source kind ${sourceDesc.kind}`);
443
+ }
444
+
445
+ // ─── install plan ───────────────────────────────────────────────────────────
446
+
447
+ async function listInstallEntries(skillsRoot, skillsFilter) {
448
+ const packPath = path.join(skillsRoot, "topmind-pack.json");
449
+ let version = "unknown";
450
+ let name = "skills";
451
+ let ids = [];
452
+
453
+ if (await pathExists(packPath)) {
454
+ const pack = JSON.parse(await fs.readFile(packPath, "utf8"));
455
+ version = pack.version || version;
456
+ name = pack.name || name;
457
+ ids = (pack.skills || []).map((s) => s.id || s.path).filter(Boolean);
458
+ } else {
459
+ // bare Agent Skills layout
460
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
461
+ for (const e of entries) {
462
+ if (e.isDirectory() && (await pathExists(path.join(skillsRoot, e.name, "SKILL.md")))) {
463
+ ids.push(e.name);
464
+ }
465
+ }
466
+ }
467
+
468
+ if (skillsFilter && skillsFilter.size) {
469
+ ids = ids.filter((id) => skillsFilter.has(id));
470
+ if (!ids.length) fail(`--skill filter matched nothing. available: ${(await listInstallEntries(skillsRoot, null)).entries.filter((e) => !e.includes(".")).join(", ")}`);
471
+ }
472
+
473
+ const entries = new Set(ids);
474
+ // Pack support files (needed for progressive disclosure)
475
+ if (!skillsFilter) {
476
+ for (const e of ["shared", "install-targets", "evals"]) {
477
+ if (await pathExists(path.join(skillsRoot, e))) entries.add(e);
478
+ }
479
+ for (const f of ["topmind-pack.json", "skills.md", "LICENSE", "README.md", "INSTALL.md"]) {
480
+ if (await pathExists(path.join(skillsRoot, f))) entries.add(f);
481
+ }
482
+ } else {
483
+ // still bring shared/ when filtering — SKILL.md links to it
484
+ if (await pathExists(path.join(skillsRoot, "shared"))) entries.add("shared");
485
+ if (await pathExists(path.join(skillsRoot, "topmind-pack.json"))) entries.add("topmind-pack.json");
486
+ }
487
+
488
+ return { version, name, entries: [...entries], skillIds: ids };
489
+ }
490
+
491
+ async function applyLocaleOverlay(overlayDir, targetDir) {
492
+ const entries = await fs.readdir(overlayDir, { withFileTypes: true });
493
+ for (const entry of entries) {
494
+ const srcPath = path.join(overlayDir, entry.name);
495
+ const dstPath = path.join(targetDir, entry.name);
496
+ if (entry.isDirectory()) {
497
+ await fs.mkdir(dstPath, { recursive: true });
498
+ await applyLocaleOverlay(srcPath, dstPath);
499
+ } else {
500
+ await fs.copyFile(srcPath, dstPath);
501
+ log(` ~ locale overlay: ${entry.name}`);
502
+ }
503
+ }
504
+ }
505
+
506
+ async function installCopy(skillsRoot, dest, entries, locale) {
507
+ await fs.mkdir(dest, { recursive: true });
508
+ for (const name of entries) {
509
+ const src = path.join(skillsRoot, name);
510
+ const dst = path.join(dest, name);
511
+ if (!(await pathExists(src))) continue;
512
+ const st = await fs.lstat(src);
513
+ if (await pathExists(dst)) await fs.rm(dst, { recursive: true, force: true });
514
+ if (st.isDirectory()) {
515
+ await fs.cp(src, dst, { recursive: true });
516
+ // Apply locale overlay if exists
517
+ if (locale) {
518
+ const overlayDir = path.join(src, "locales", locale);
519
+ if (await pathExists(overlayDir)) {
520
+ await applyLocaleOverlay(overlayDir, dst);
521
+ log(` locale: ${locale}`);
522
+ }
523
+ // Clean up locales/ directory from installed copy (not needed at runtime)
524
+ const localesInstalledDir = path.join(dst, "locales");
525
+ if (await pathExists(localesInstalledDir)) {
526
+ await fs.rm(localesInstalledDir, { recursive: true, force: true });
527
+ }
528
+ }
529
+ } else {
530
+ await fs.copyFile(src, dst);
531
+ }
532
+ log(` + ${name}`);
533
+ }
534
+ }
535
+
536
+ async function installSymlink(skillsRoot, dest, entries, { force }) {
537
+ await fs.mkdir(dest, { recursive: true });
538
+ for (const name of entries) {
539
+ const src = path.join(skillsRoot, name);
540
+ const dst = path.join(dest, name);
541
+ if (!(await pathExists(src))) continue;
542
+ if (await pathExists(dst)) {
543
+ const st = await fs.lstat(dst);
544
+ if (st.isSymbolicLink() || force) await fs.rm(dst, { recursive: true, force: true });
545
+ else fail(`${dst} exists and is not a symlink (pass --force)`);
546
+ }
547
+ await fs.symlink(src, dst, "junction");
548
+ log(` ~ ${name} → ${src}`);
549
+ }
550
+ }
551
+
552
+ async function writeInstallReceipt(dest, meta) {
553
+ const receipt = {
554
+ schema: 1,
555
+ package: meta.name || "topmind",
556
+ version: meta.version,
557
+ installed_at: new Date().toISOString(),
558
+ source: meta.sourceLabel,
559
+ source_raw: meta.sourceRaw,
560
+ repo_path: meta.repoPath,
561
+ mode: meta.mode,
562
+ host: meta.host,
563
+ skill_ids: meta.skillIds,
564
+ locale: meta.locale || null,
565
+ repository: `https://github.com/${DEFAULT_OWNER_REPO}`,
566
+ // Enough to re-run update without remembering flags
567
+ update: {
568
+ command: "add",
569
+ source: meta.sourceRaw,
570
+ path: meta.repoPath,
571
+ mode: meta.mode,
572
+ host: meta.host,
573
+ },
574
+ };
575
+ await fs.writeFile(path.join(dest, RECEIPT_NAME), JSON.stringify(receipt, null, 2) + "\n", "utf8");
576
+ }
577
+
578
+ async function readReceipt(dest) {
579
+ const p = path.join(dest, RECEIPT_NAME);
580
+ if (!(await pathExists(p))) return null;
581
+ try {
582
+ return JSON.parse(await fs.readFile(p, "utf8"));
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+
588
+ // ─── commands ───────────────────────────────────────────────────────────────
589
+
590
+ async function cmdAdd(opts, sourceOverride) {
591
+ const sourceRaw = sourceOverride || opts.source || DEFAULT_OWNER_REPO;
592
+ const sourceDesc = parseSource(sourceRaw, opts.repoPath);
593
+ const dest = opts.dest;
594
+
595
+ log(`add source=${sourceDesc.label}`);
596
+ log(` path=${opts.repoPath} mode=${opts.mode} dest=${dest}`);
597
+
598
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "topmind-skills-"));
599
+ try {
600
+ const skillsRoot = await materialize(sourceDesc, opts, tmpRoot);
601
+ const plan = await listInstallEntries(skillsRoot, opts.skillsFilter);
602
+ log(`pack ${plan.name}@${plan.version} · ${plan.skillIds.length} skill(s) · ${plan.entries.length} entries`);
603
+
604
+ if (opts.command === "list" || opts.dryRun) {
605
+ for (const e of plan.entries) log(` - ${e}`);
606
+ log(opts.dryRun ? `(dry-run) would install → ${dest}` : `(list only)`);
607
+ return;
608
+ }
609
+
610
+ if (opts.mode === "symlink") {
611
+ if (sourceDesc.kind !== "local") {
612
+ fail("symlink mode needs a durable local --source (git/release clones are temporary)");
613
+ }
614
+ await installSymlink(skillsRoot, dest, plan.entries, opts);
615
+ } else {
616
+ await installCopy(skillsRoot, dest, plan.entries, opts.locale);
617
+ }
618
+
619
+ await writeInstallReceipt(dest, {
620
+ name: plan.name,
621
+ version: plan.version,
622
+ sourceLabel: sourceDesc.label,
623
+ sourceRaw,
624
+ repoPath: opts.repoPath,
625
+ mode: opts.mode,
626
+ host: opts.host,
627
+ skillIds: plan.skillIds,
628
+ locale: opts.locale,
629
+ });
630
+
631
+ log(`done → ${dest}`);
632
+ log(`daily entry: ${path.join(dest, "topmind", "SKILL.md")}`);
633
+ log(`update later: node bin/install-skills.mjs update --dest ${dest}`);
634
+ log(`community CLI (optional): npx skills add ${sourceRaw.includes("/") && !sourceRaw.includes("://") ? sourceRaw.split("@")[0] : DEFAULT_OWNER_REPO} -g -y`);
635
+ } finally {
636
+ await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
637
+ }
638
+ }
639
+
640
+ async function cmdUpdate(opts) {
641
+ const dest = opts.dest;
642
+ const receipt = await readReceipt(dest);
643
+ if (!receipt) {
644
+ fail(
645
+ `no ${RECEIPT_NAME} in ${dest}.\n` +
646
+ ` First install: node bin/install-skills.mjs add ${DEFAULT_OWNER_REPO} --dest ${dest}\n` +
647
+ ` Or community: npx skills update -g -y`,
648
+ );
649
+ }
650
+ log(`update from receipt: ${receipt.source} @ ${receipt.version} → ${dest}`);
651
+ const next = {
652
+ ...opts,
653
+ command: "add",
654
+ source: receipt.source_raw || receipt.update?.source || DEFAULT_OWNER_REPO,
655
+ repoPath: receipt.repo_path || receipt.update?.path || DEFAULT_PATH,
656
+ mode: receipt.mode === "symlink" ? "symlink" : "copy",
657
+ host: receipt.host || opts.host,
658
+ locale: receipt.locale || opts.locale,
659
+ };
660
+ // symlink update only works if original local path still exists
661
+ if (next.mode === "symlink") {
662
+ const local = parseSource(next.source, next.repoPath);
663
+ if (local.kind !== "local" || !(await pathExists(local.value))) {
664
+ log("original symlink source gone — falling back to copy from default git source");
665
+ next.mode = "copy";
666
+ next.source = DEFAULT_OWNER_REPO;
667
+ }
668
+ }
669
+ await cmdAdd(next, next.source);
670
+ }
671
+
672
+ async function main() {
673
+ const opts = parseArgs(process.argv.slice(2));
674
+
675
+ if (opts.command === "list") {
676
+ opts.dryRun = true;
677
+ if (!opts.source) opts.source = DEFAULT_OWNER_REPO;
678
+ await cmdAdd({ ...opts, command: "list" });
679
+ return;
680
+ }
681
+ if (opts.command === "update") {
682
+ await cmdUpdate(opts);
683
+ return;
684
+ }
685
+ // add
686
+ if (!opts.source) opts.source = DEFAULT_OWNER_REPO;
687
+ await cmdAdd(opts);
688
+ }
689
+
690
+ main().catch((e) => {
691
+ process.stderr.write(`[install-skills] ${e.stack || e}\n`);
692
+ process.exit(1);
693
+ });