@topmindspace/topmind-skills 4.13.1 → 4.13.3

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.
@@ -53,7 +53,7 @@
53
53
  * Private GitHub: export GH_TOKEN or GITHUB_TOKEN.
54
54
  */
55
55
 
56
- import { promises as fs } from "node:fs";
56
+ import { promises as fs, existsSync } from "node:fs";
57
57
  import path from "node:path";
58
58
  import os from "node:os";
59
59
  import { fileURLToPath } from "node:url";
@@ -70,14 +70,152 @@ const DEFAULT_GIT = `https://github.com/${DEFAULT_OWNER_REPO}.git`;
70
70
  const DEFAULT_PATH = ".";
71
71
  const RECEIPT_NAME = ".topmind-skills-install.json";
72
72
 
73
+ const AGENTS_SUB = path.join(".agents", "skills");
74
+
75
+ function homeDir() {
76
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
77
+ }
78
+
79
+ function configHome() {
80
+ return process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config");
81
+ }
82
+
83
+ /**
84
+ * Ecosystem agent matrix (aligned with `npx skills` / skills.sh):
85
+ * canonical <base>/.agents/skills (project base=cwd, global base=~)
86
+ * universal agents whose skillsDir is .agents/skills read it directly
87
+ * private others get relative symlink (default) or copy into their own root
88
+ */
89
+ function agentCatalog() {
90
+ const home = homeDir();
91
+ const cfg = configHome();
92
+ return {
93
+ universal: {
94
+ id: "universal",
95
+ label: "Universal (.agents/skills)",
96
+ projectDir: AGENTS_SUB,
97
+ globalDir: path.join(home, AGENTS_SUB),
98
+ detect: () => true,
99
+ universal: true,
100
+ },
101
+ "claude-code": {
102
+ id: "claude-code",
103
+ label: "Claude Code",
104
+ projectDir: path.join(".claude", "skills"),
105
+ globalDir: path.join(home, ".claude", "skills"),
106
+ detect: () => fsSyncExists(path.join(home, ".claude")),
107
+ universal: false,
108
+ },
109
+ mimocode: {
110
+ id: "mimocode",
111
+ label: "MiMoCode / MiMo Desktop",
112
+ projectDir: path.join(".mimocode", "skills"),
113
+ globalDir: path.join(cfg, "mimocode", "skills"),
114
+ detect: () => fsSyncExists(path.join(cfg, "mimocode")) || fsSyncExists(path.join(home, ".mimocode")),
115
+ universal: false,
116
+ },
117
+ codex: {
118
+ id: "codex",
119
+ label: "Codex",
120
+ projectDir: AGENTS_SUB,
121
+ globalDir: path.join(home, ".codex", "skills"),
122
+ detect: () => fsSyncExists(path.join(home, ".codex")),
123
+ universal: false,
124
+ },
125
+ cursor: {
126
+ id: "cursor",
127
+ label: "Cursor",
128
+ projectDir: AGENTS_SUB,
129
+ globalDir: path.join(home, ".cursor", "skills"),
130
+ detect: () => fsSyncExists(path.join(home, ".cursor")),
131
+ universal: false,
132
+ },
133
+ "gemini-cli": {
134
+ id: "gemini-cli",
135
+ label: "Gemini CLI",
136
+ projectDir: AGENTS_SUB,
137
+ globalDir: path.join(home, ".gemini", "skills"),
138
+ detect: () => fsSyncExists(path.join(home, ".gemini")),
139
+ universal: false,
140
+ },
141
+ opencode: {
142
+ id: "opencode",
143
+ label: "OpenCode",
144
+ projectDir: AGENTS_SUB,
145
+ globalDir: path.join(cfg, "opencode", "skills"),
146
+ detect: () => fsSyncExists(path.join(cfg, "opencode")) || fsSyncExists(path.join(home, ".opencode")),
147
+ universal: false,
148
+ },
149
+ hermes: {
150
+ id: "hermes",
151
+ label: "Hermes",
152
+ projectDir: path.join(".hermes", "skills"),
153
+ globalDir: path.join(home, ".hermes", "skills"),
154
+ detect: () => fsSyncExists(path.join(home, ".hermes")),
155
+ universal: false,
156
+ },
157
+ "github-copilot": {
158
+ id: "github-copilot",
159
+ label: "GitHub Copilot",
160
+ projectDir: AGENTS_SUB,
161
+ globalDir: path.join(home, ".copilot", "skills"),
162
+ detect: () => fsSyncExists(path.join(home, ".copilot")),
163
+ universal: false,
164
+ },
165
+ };
166
+ }
167
+
168
+ /** Legacy --host names → agent ids + single-dir default dest. */
73
169
  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"),
170
+ "claude-code": () => path.join(homeDir(), ".claude", "skills"),
171
+ codex: () => path.join(homeDir(), ".codex", "skills"),
172
+ hermes: () => path.join(homeDir(), ".hermes", "skills"),
77
173
  opencode: () => path.join(process.cwd(), ".opencode", "skills"),
78
174
  generic: () => path.join(process.cwd(), "topmind-skills"),
175
+ mimocode: () => path.join(configHome(), "mimocode", "skills"),
79
176
  };
80
177
 
178
+ function fsSyncExists(p) {
179
+ return existsSync(p);
180
+ }
181
+
182
+ function canonicalBase(global) {
183
+ return path.join(global ? homeDir() : process.cwd(), AGENTS_SUB);
184
+ }
185
+
186
+ function agentSkillsRoot(agent, global) {
187
+ return global ? agent.globalDir : path.join(process.cwd(), agent.projectDir);
188
+ }
189
+
190
+ function samePath(a, b) {
191
+ return path.resolve(a) === path.resolve(b);
192
+ }
193
+
194
+ function detectedAgentIds() {
195
+ return Object.values(agentCatalog())
196
+ .filter((a) => a.detect())
197
+ .map((a) => a.id);
198
+ }
199
+
200
+ function resolveAgentSelection(requested, global) {
201
+ const cat = agentCatalog();
202
+ const all = Object.keys(cat);
203
+ let ids;
204
+ if (!requested || !requested.length) ids = detectedAgentIds();
205
+ else if (requested.includes("*")) ids = all;
206
+ else ids = [...new Set(requested)];
207
+ for (const id of ids) {
208
+ if (!cat[id]) fail(`unknown agent: ${id}\nknown: ${all.join(", ")}`);
209
+ }
210
+ ids.sort((a, b) => (a === "universal" ? -1 : b === "universal" ? 1 : a.localeCompare(b)));
211
+ if (!ids.includes("universal")) ids.unshift("universal");
212
+ return ids.map((id) => {
213
+ const agent = cat[id];
214
+ const root = agentSkillsRoot(agent, global);
215
+ return { agent, root, sharesCanonical: samePath(root, canonicalBase(global)) };
216
+ });
217
+ }
218
+
81
219
  function log(msg) {
82
220
  process.stdout.write(`[install-skills] ${msg}\n`);
83
221
  }
@@ -92,21 +230,34 @@ function printHelp() {
92
230
  node bin/install-skills.mjs add <source> [options]
93
231
  node bin/install-skills.mjs update [--dest <dir>]
94
232
  node bin/install-skills.mjs list <source> [options]
233
+ node bin/install-skills.mjs agents | paths | doctor [--repair]
95
234
 
96
235
  Source: owner/repo | owner/repo@ref | git-url | local-path | release:<tag>
97
236
 
98
- Same idea as: npx skills add <owner/repo> -g
99
- Pack-aware: also installs shared/ + topmind-pack.json so relative links work.
237
+ Layout (aligned with npx skills / skills.sh):
238
+ canonical <base>/.agents/skills pack body lives here (one copy)
239
+ universal agents that read .agents/skills need nothing else
240
+ private Claude Code / MiMoCode / Hermes … get symlink (or --copy)
100
241
 
101
242
  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
243
+ -g, --global Global scope (canonical ~/.agents/skills + agent global roots)
244
+ -p, --project Project scope (canonical ./.agents/skills + agent project roots)
245
+ -a, --agent <ids> Target agents (repeatable / comma-separated; '*' = all)
246
+ --copy Copy into each private agent dir (default: symlink)
247
+ --dest <dir> Install ONLY into this skills root (single-dir / legacy)
248
+ --host <name> Legacy single-host dest: claude-code|codex|hermes|opencode|mimocode|generic
249
+ --mode copy|symlink Mode for --dest/--host single-dir installs (default: copy)
250
+ --skill <id> Only install these skill ids (repeatable / comma-separated)
251
+ --locale <code> Install locale overlay (e.g. en-US); falls back to topmind_LOCALE env
252
+ -n, --dry-run Print plan only
253
+ --force Replace non-symlink destinations when using symlink mode
254
+
255
+ Examples:
256
+ node bin/install-skills.mjs add topmindspace/topmind-skills -g
257
+ node bin/install-skills.mjs add topmindspace/topmind-skills -a claude-code -a mimocode
258
+ node bin/install-skills.mjs add . --dest ~/.claude/skills
259
+ node bin/install-skills.mjs update -g
260
+ node bin/install-skills.mjs doctor --repair
110
261
 
111
262
  See INSTALL.md for full docs.
112
263
  `);
@@ -121,11 +272,15 @@ function parseArgs(argv) {
121
272
  dest: null,
122
273
  repoPath: DEFAULT_PATH,
123
274
  mode: "copy",
124
- host: "claude-code",
125
- skillsFilter: null, // null = all, else Set of ids
275
+ host: null,
276
+ agents: [],
277
+ scope: null, // 'global' | 'project' | null
278
+ linkMode: "symlink", // for agent matrix: symlink | copy
279
+ skillsFilter: null,
126
280
  dryRun: false,
127
281
  force: false,
128
282
  locale: null,
283
+ repair: false,
129
284
  };
130
285
 
131
286
  if (argv.length === 0) {
@@ -133,7 +288,6 @@ function parseArgs(argv) {
133
288
  process.exit(0);
134
289
  }
135
290
 
136
- // Legacy flag-only form (no subcommand) → treat as add with flags
137
291
  const first = argv[0];
138
292
  if (first === "add" || first === "install" || first === "a") {
139
293
  out.command = "add";
@@ -144,14 +298,21 @@ function parseArgs(argv) {
144
298
  } else if (first === "list" || first === "ls") {
145
299
  out.command = "list";
146
300
  argv = argv.slice(1);
301
+ } else if (first === "agents") {
302
+ out.command = "agents";
303
+ argv = argv.slice(1);
304
+ } else if (first === "paths") {
305
+ out.command = "paths";
306
+ argv = argv.slice(1);
307
+ } else if (first === "doctor") {
308
+ out.command = "doctor";
309
+ argv = argv.slice(1);
147
310
  } else if (first === "--help" || first === "-h") {
148
311
  printHelp();
149
312
  process.exit(0);
150
313
  } else if (first.startsWith("-")) {
151
- // npm run skills:install -- --from-git (legacy)
152
314
  out.command = "add";
153
315
  } else {
154
- // bare source: install-skills.mjs topmindspace/topmind-skills
155
316
  out.command = "add";
156
317
  }
157
318
 
@@ -167,7 +328,6 @@ function parseArgs(argv) {
167
328
  else if (a === "--path" || a === "--subdir") out.repoPath = next().replace(/^\/+|\/+$/g, "") || ".";
168
329
  else if (a === "--source" || a === "-s") out.source = next();
169
330
  else if (a === "--from-git") {
170
- // legacy: --from-git [url]
171
331
  const peek = argv[i + 1];
172
332
  if (peek && !peek.startsWith("-")) out.source = next();
173
333
  else out.source = DEFAULT_OWNER_REPO;
@@ -176,23 +336,31 @@ function parseArgs(argv) {
176
336
  out.source = peek && !peek.startsWith("-") ? `release:${next()}` : "release:latest";
177
337
  } else if (a === "--mode" || a === "-m") out.mode = next();
178
338
  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") {
339
+ else if (a === "--global" || a === "-g") out.scope = "global";
340
+ else if (a === "--project" || a === "-p") out.scope = "project";
341
+ else if (a === "--agent" || a === "-a") {
342
+ const raw = next();
343
+ for (const part of raw.split(",")) {
344
+ const id = part.trim();
345
+ if (id) out.agents.push(id);
346
+ }
347
+ } else if (a === "--copy") {
348
+ out.linkMode = "copy";
349
+ out.mode = "copy";
350
+ } else if (a === "--link") out.linkMode = "symlink";
351
+ else if (a === "--skill") {
183
352
  const raw = next();
184
353
  out.skillsFilter = out.skillsFilter || new Set();
185
354
  for (const part of raw.split(",")) {
186
355
  const id = part.trim();
187
356
  if (id) out.skillsFilter.add(id);
188
357
  }
189
- } else if (a === "--dry-run") out.dryRun = true;
358
+ } else if (a === "--dry-run" || a === "-n") out.dryRun = true;
190
359
  else if (a === "--locale") out.locale = next();
191
360
  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") {
361
+ else if (a === "--repair") out.repair = true;
362
+ else if (a === "--update" || a === "-u") out.command = "update";
363
+ else if (a === "--help" || a === "-h") {
196
364
  printHelp();
197
365
  process.exit(0);
198
366
  } else if (a.startsWith("-")) fail(`unknown option: ${a}`);
@@ -201,17 +369,24 @@ function parseArgs(argv) {
201
369
  }
202
370
 
203
371
  if (!["copy", "symlink"].includes(out.mode)) fail(`--mode must be copy|symlink`);
204
- if (!out.dest) {
372
+ if (!["copy", "symlink"].includes(out.linkMode)) fail(`--copy/--link must be copy|symlink`);
373
+
374
+ // Scope default: project (matches npx skills); -g flips to global.
375
+ if (!out.scope) out.scope = "project";
376
+ out.global = out.scope === "global";
377
+
378
+ // Single-dir path: --dest or legacy --host (not the multi-agent matrix).
379
+ if (out.dest) {
380
+ out.dest = path.resolve(out.dest.replace(/^~(?=\/|$)/, os.homedir()));
381
+ } else if (out.host) {
205
382
  if (!HOST_DEFAULTS[out.host]) {
206
383
  fail(`unknown --host ${out.host}; pass --dest or use: ${Object.keys(HOST_DEFAULTS).join("|")}`);
207
384
  }
208
385
  out.dest = HOST_DEFAULTS[out.host]();
386
+ out.dest = path.resolve(out.dest.replace(/^~(?=\/|$)/, os.homedir()));
209
387
  }
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
- }
388
+
389
+ if (!out.locale) out.locale = process.env.topmind_LOCALE || null;
215
390
  return out;
216
391
  }
217
392
 
@@ -390,7 +565,7 @@ async function materializeFromRelease(tag, workDir) {
390
565
  } else {
391
566
  release = await fetchJson(`https://api.github.com/repos/${DEFAULT_OWNER_REPO}/releases/tags/${tag}`);
392
567
  }
393
- if (!release) fail("no GitHub release found — use owner/repo source or publish pack:skills");
568
+ if (!release) fail("no GitHub release found — use owner/repo source or publish a v* release tag");
394
569
  const assets = release.assets || [];
395
570
  const zip =
396
571
  assets.find((a) => /^topmind-skills-.*\.zip$/i.test(a.name)) ||
@@ -551,7 +726,7 @@ async function installSymlink(skillsRoot, dest, entries, { force }) {
551
726
 
552
727
  async function writeInstallReceipt(dest, meta) {
553
728
  const receipt = {
554
- schema: 1,
729
+ schema: 2,
555
730
  package: meta.name || "topmind",
556
731
  version: meta.version,
557
732
  installed_at: new Date().toISOString(),
@@ -559,22 +734,196 @@ async function writeInstallReceipt(dest, meta) {
559
734
  source_raw: meta.sourceRaw,
560
735
  repo_path: meta.repoPath,
561
736
  mode: meta.mode,
562
- host: meta.host,
737
+ host: meta.host || null,
738
+ scope: meta.scope || null,
739
+ agents: meta.agents || null,
563
740
  skill_ids: meta.skillIds,
564
741
  locale: meta.locale || null,
565
742
  repository: `https://github.com/${DEFAULT_OWNER_REPO}`,
566
- // Enough to re-run update without remembering flags
567
743
  update: {
568
744
  command: "add",
569
745
  source: meta.sourceRaw,
570
746
  path: meta.repoPath,
571
747
  mode: meta.mode,
572
- host: meta.host,
748
+ host: meta.host || null,
749
+ scope: meta.scope || null,
750
+ agents: meta.agents || null,
573
751
  },
574
752
  };
575
753
  await fs.writeFile(path.join(dest, RECEIPT_NAME), JSON.stringify(receipt, null, 2) + "\n", "utf8");
576
754
  }
577
755
 
756
+ /** Relative-symlink each pack entry from an agent root back to the canonical body. */
757
+ async function linkPackEntries(canonicalRoot, agentRoot, entryNames, { force }) {
758
+ await fs.mkdir(agentRoot, { recursive: true });
759
+ for (const name of entryNames) {
760
+ const src = path.join(canonicalRoot, name);
761
+ const dst = path.join(agentRoot, name);
762
+ if (!(await pathExists(src))) continue;
763
+ if (samePath(agentRoot, canonicalRoot)) continue;
764
+ if ((await pathExists(dst)) || (await isBrokenSymlink(dst))) {
765
+ const st = await fs.lstat(dst).catch(() => null);
766
+ if (st && st.isSymbolicLink()) await fs.rm(dst, { recursive: true, force: true });
767
+ else if (force) await fs.rm(dst, { recursive: true, force: true });
768
+ else {
769
+ log(` ! ${dst} exists (not symlink) — skip (pass --force to replace)`);
770
+ continue;
771
+ }
772
+ }
773
+ const rel = path.relative(agentRoot, src);
774
+ await fs.symlink(rel, dst, "junction");
775
+ log(` ~ ${path.join(agentRoot, name)} → ${rel}`);
776
+ }
777
+ }
778
+
779
+ async function isBrokenSymlink(p) {
780
+ let st;
781
+ try {
782
+ st = await fs.lstat(p);
783
+ } catch {
784
+ return false;
785
+ }
786
+ if (!st.isSymbolicLink()) return false;
787
+ try {
788
+ await fs.stat(p);
789
+ return false;
790
+ } catch {
791
+ return true;
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Install pack into canonical `.agents/skills`, then link/copy into agent-private roots.
797
+ * Returns the canonical dest (receipt lives there).
798
+ */
799
+ async function installViaMatrix(skillsRoot, plan, opts) {
800
+ const global = !!opts.global;
801
+ const canonical = canonicalBase(global);
802
+ const targets = resolveAgentSelection(opts.agents, global);
803
+ const linkMode = opts.linkMode || "symlink";
804
+
805
+ log(`matrix ${global ? "global" : "project"} · ${linkMode} · canonical ${canonical}`);
806
+
807
+ if (opts.dryRun) {
808
+ log(` · ${canonical} [canonical pack]`);
809
+ for (const t of targets) {
810
+ if (t.sharesCanonical) continue;
811
+ log(` · ${t.root} [${t.agent.label} → ${linkMode}]`);
812
+ }
813
+ log("(dry-run)");
814
+ return { canonical, targets, plan };
815
+ }
816
+
817
+ // 1) Full pack body → canonical
818
+ await installCopy(skillsRoot, canonical, plan.entries, opts.locale);
819
+
820
+ // 2) Private agent dirs: symlink entries (default) or copy
821
+ for (const t of targets) {
822
+ if (t.sharesCanonical) {
823
+ log(` · shared via .agents/skills: ${t.agent.id}`);
824
+ continue;
825
+ }
826
+ if (linkMode === "copy") {
827
+ await installCopy(skillsRoot, t.root, plan.entries, opts.locale);
828
+ } else {
829
+ await linkPackEntries(canonical, t.root, plan.entries, opts);
830
+ }
831
+ }
832
+
833
+ await writeInstallReceipt(canonical, {
834
+ name: plan.name,
835
+ version: plan.version,
836
+ sourceLabel: "matrix",
837
+ sourceRaw: opts.source,
838
+ repoPath: opts.repoPath,
839
+ mode: linkMode,
840
+ host: null,
841
+ scope: opts.scope,
842
+ agents: targets.map((t) => t.agent.id),
843
+ skillIds: plan.skillIds,
844
+ locale: opts.locale,
845
+ });
846
+
847
+ log(`done → ${canonical}`);
848
+ log(`daily entry: ${path.join(canonical, "topmind", "SKILL.md")}`);
849
+ log(`update later: node bin/install-skills.mjs update -g`);
850
+ return { canonical, targets, plan };
851
+ }
852
+
853
+ function cmdAgents() {
854
+ const cat = agentCatalog();
855
+ const det = new Set(detectedAgentIds());
856
+ log("Agents (id · kind · project · global · status):");
857
+ for (const a of Object.values(cat)) {
858
+ const kind = a.projectDir === AGENTS_SUB ? "universal-project" : "private-project";
859
+ const st = det.has(a.id) ? "detected" : "-";
860
+ log(` ${a.id.padEnd(16)} ${kind}`);
861
+ log(` ${"".padEnd(16)} project ${a.projectDir}`);
862
+ log(` ${"".padEnd(16)} global ${a.globalDir}`);
863
+ log(` ${"".padEnd(16)} ${st}`);
864
+ }
865
+ }
866
+
867
+ function cmdPaths(opts) {
868
+ const global = !!opts.global;
869
+ log(`Canonical (${global ? "global" : "project"}):`);
870
+ log(` ${canonicalBase(global)}`);
871
+ log("Agents:");
872
+ for (const a of Object.values(agentCatalog())) {
873
+ const root = agentSkillsRoot(a, global);
874
+ const share = samePath(root, canonicalBase(global)) ? " (shares .agents/skills)" : "";
875
+ const det = a.detect() ? "detected" : "not detected";
876
+ log(` ${a.id.padEnd(16)} ${root}${share} [${det}]`);
877
+ }
878
+ }
879
+
880
+ async function cmdDoctor(opts) {
881
+ const cat = agentCatalog();
882
+ let problems = 0;
883
+ log("Skill root health:");
884
+ for (const a of Object.values(cat)) {
885
+ for (const [scopeName, root] of [
886
+ ["project", path.join(process.cwd(), a.projectDir)],
887
+ ["global", a.globalDir],
888
+ ]) {
889
+ if (!(await pathExists(root)) && !(await pathExists(path.dirname(root)))) continue;
890
+ let st = null;
891
+ try {
892
+ st = await fs.lstat(root);
893
+ } catch {
894
+ st = null;
895
+ }
896
+ if (st && st.isSymbolicLink()) {
897
+ let ok = true;
898
+ try {
899
+ await fs.stat(root);
900
+ } catch {
901
+ ok = false;
902
+ }
903
+ if (!ok) {
904
+ problems++;
905
+ log(` ✗ ${root} [${a.label} · ${scopeName}] broken symlink`);
906
+ if (opts.repair) {
907
+ await fs.rm(root, { force: true });
908
+ await fs.mkdir(root, { recursive: true });
909
+ log(" repaired → real directory");
910
+ }
911
+ continue;
912
+ }
913
+ }
914
+ if (st && !st.isDirectory() && !(st.isSymbolicLink())) {
915
+ problems++;
916
+ log(` ✗ ${root} [${a.label} · ${scopeName}] not a directory`);
917
+ continue;
918
+ }
919
+ if (st) log(` ✓ ${root} [${a.label} · ${scopeName}]`);
920
+ }
921
+ }
922
+ if (!problems) log(" all clear");
923
+ else if (!opts.repair) log("Run `topmind-skills doctor --repair` to replace broken symlinks.");
924
+ if (problems && !opts.repair) process.exitCode = 1;
925
+ }
926
+
578
927
  async function readReceipt(dest) {
579
928
  const p = path.join(dest, RECEIPT_NAME);
580
929
  if (!(await pathExists(p))) return null;
@@ -590,10 +939,15 @@ async function readReceipt(dest) {
590
939
  async function cmdAdd(opts, sourceOverride) {
591
940
  const sourceRaw = sourceOverride || opts.source || DEFAULT_OWNER_REPO;
592
941
  const sourceDesc = parseSource(sourceRaw, opts.repoPath);
593
- const dest = opts.dest;
942
+ const useMatrix = !opts.dest;
943
+ const dest = opts.dest || canonicalBase(opts.global);
594
944
 
595
945
  log(`add source=${sourceDesc.label}`);
596
- log(` path=${opts.repoPath} mode=${opts.mode} dest=${dest}`);
946
+ log(
947
+ useMatrix
948
+ ? ` path=${opts.repoPath} scope=${opts.scope} link=${opts.linkMode} agents=${opts.agents.length ? opts.agents.join(",") : "detected"}`
949
+ : ` path=${opts.repoPath} mode=${opts.mode} dest=${dest}`,
950
+ );
597
951
 
598
952
  const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "topmind-skills-"));
599
953
  try {
@@ -601,9 +955,21 @@ async function cmdAdd(opts, sourceOverride) {
601
955
  const plan = await listInstallEntries(skillsRoot, opts.skillsFilter);
602
956
  log(`pack ${plan.name}@${plan.version} · ${plan.skillIds.length} skill(s) · ${plan.entries.length} entries`);
603
957
 
604
- if (opts.command === "list" || opts.dryRun) {
958
+ if (opts.command === "list") {
605
959
  for (const e of plan.entries) log(` - ${e}`);
606
- log(opts.dryRun ? `(dry-run) would install → ${dest}` : `(list only)`);
960
+ log("(list only)");
961
+ return;
962
+ }
963
+
964
+ if (useMatrix) {
965
+ await installViaMatrix(skillsRoot, plan, opts);
966
+ return;
967
+ }
968
+
969
+ // Legacy / explicit single-dir install
970
+ if (opts.dryRun) {
971
+ for (const e of plan.entries) log(` - ${e}`);
972
+ log(`(dry-run) would install → ${dest}`);
607
973
  return;
608
974
  }
609
975
 
@@ -624,6 +990,8 @@ async function cmdAdd(opts, sourceOverride) {
624
990
  repoPath: opts.repoPath,
625
991
  mode: opts.mode,
626
992
  host: opts.host,
993
+ scope: opts.scope,
994
+ agents: opts.agents,
627
995
  skillIds: plan.skillIds,
628
996
  locale: opts.locale,
629
997
  });
@@ -631,19 +999,29 @@ async function cmdAdd(opts, sourceOverride) {
631
999
  log(`done → ${dest}`);
632
1000
  log(`daily entry: ${path.join(dest, "topmind", "SKILL.md")}`);
633
1001
  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
1002
  } finally {
636
1003
  await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
637
1004
  }
638
1005
  }
639
1006
 
640
1007
  async function cmdUpdate(opts) {
641
- const dest = opts.dest;
642
- const receipt = await readReceipt(dest);
1008
+ // Prefer receipt at matrix canonical; fall back to explicit --dest receipt.
1009
+ const destCandidates = opts.dest
1010
+ ? [opts.dest]
1011
+ : [canonicalBase(opts.global), canonicalBase(!opts.global), HOST_DEFAULTS["claude-code"]()];
1012
+ let dest = null;
1013
+ let receipt = null;
1014
+ for (const d of destCandidates) {
1015
+ receipt = await readReceipt(d);
1016
+ if (receipt) {
1017
+ dest = d;
1018
+ break;
1019
+ }
1020
+ }
643
1021
  if (!receipt) {
644
1022
  fail(
645
- `no ${RECEIPT_NAME} in ${dest}.\n` +
646
- ` First install: node bin/install-skills.mjs add ${DEFAULT_OWNER_REPO} --dest ${dest}\n` +
1023
+ `no ${RECEIPT_NAME} in ${destCandidates.join(", ")}.\n` +
1024
+ ` First install: node bin/install-skills.mjs add ${DEFAULT_OWNER_REPO} -g\n` +
647
1025
  ` Or community: npx skills update -g -y`,
648
1026
  );
649
1027
  }
@@ -654,11 +1032,17 @@ async function cmdUpdate(opts) {
654
1032
  source: receipt.source_raw || receipt.update?.source || DEFAULT_OWNER_REPO,
655
1033
  repoPath: receipt.repo_path || receipt.update?.path || DEFAULT_PATH,
656
1034
  mode: receipt.mode === "symlink" ? "symlink" : "copy",
1035
+ linkMode: receipt.agents ? "symlink" : opts.linkMode,
657
1036
  host: receipt.host || opts.host,
1037
+ scope: receipt.scope || opts.scope,
1038
+ agents: receipt.agents || opts.agents,
658
1039
  locale: receipt.locale || opts.locale,
659
1040
  };
660
- // symlink update only works if original local path still exists
661
- if (next.mode === "symlink") {
1041
+ // Keep matrix mode when the receipt was a matrix install (no single dest host).
1042
+ if (receipt.agents) next.dest = null;
1043
+ else if (!next.dest) next.dest = dest;
1044
+
1045
+ if (next.mode === "symlink" && next.dest) {
662
1046
  const local = parseSource(next.source, next.repoPath);
663
1047
  if (local.kind !== "local" || !(await pathExists(local.value))) {
664
1048
  log("original symlink source gone — falling back to copy from default git source");
@@ -672,6 +1056,18 @@ async function cmdUpdate(opts) {
672
1056
  async function main() {
673
1057
  const opts = parseArgs(process.argv.slice(2));
674
1058
 
1059
+ if (opts.command === "agents") {
1060
+ cmdAgents();
1061
+ return;
1062
+ }
1063
+ if (opts.command === "paths") {
1064
+ cmdPaths(opts);
1065
+ return;
1066
+ }
1067
+ if (opts.command === "doctor") {
1068
+ await cmdDoctor(opts);
1069
+ return;
1070
+ }
675
1071
  if (opts.command === "list") {
676
1072
  opts.dryRun = true;
677
1073
  if (!opts.source) opts.source = DEFAULT_OWNER_REPO;
@@ -682,7 +1078,6 @@ async function main() {
682
1078
  await cmdUpdate(opts);
683
1079
  return;
684
1080
  }
685
- // add
686
1081
  if (!opts.source) opts.source = DEFAULT_OWNER_REPO;
687
1082
  await cmdAdd(opts);
688
1083
  }