master-skill 0.10.0 → 0.10.1

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 (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/ETHICS.md +23 -17
  5. package/README.md +18 -11
  6. package/README_EN.md +22 -13
  7. package/SKILL.md +5 -5
  8. package/bin/cli.mjs +309 -77
  9. package/gemini-extension.json +1 -1
  10. package/hooks/run-hook.cmd +18 -5
  11. package/hooks/tests/test_run_hook.sh +114 -0
  12. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  13. package/masters/.gitkeep +0 -0
  14. package/package.json +8 -2
  15. package/prebuilt/compare/SKILL.md +5 -5
  16. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  17. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  18. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  19. package/prebuilt/master-atisha/SKILL.md +13 -11
  20. package/prebuilt/master-atisha/meta.json +8 -0
  21. package/prebuilt/master-atisha/references/voice.md +1 -1
  22. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  23. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  24. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  25. package/prebuilt/master-fazang/SKILL.md +3 -3
  26. package/prebuilt/master-fazang/meta.json +8 -0
  27. package/prebuilt/master-huineng/SKILL.md +3 -3
  28. package/prebuilt/master-huineng/meta.json +8 -0
  29. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  30. package/prebuilt/master-kumarajiva/meta.json +8 -0
  31. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  32. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  33. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  34. package/prebuilt/master-milarepa/SKILL.md +13 -11
  35. package/prebuilt/master-milarepa/meta.json +8 -0
  36. package/prebuilt/master-milarepa/references/voice.md +1 -1
  37. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  38. package/prebuilt/master-nagarjuna/meta.json +8 -0
  39. package/prebuilt/master-ouyi/SKILL.md +3 -3
  40. package/prebuilt/master-ouyi/meta.json +8 -0
  41. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  42. package/prebuilt/master-tsongkhapa/meta.json +8 -0
  43. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  44. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  45. package/prebuilt/master-xuanzang/meta.json +8 -0
  46. package/prebuilt/master-xuyun/SKILL.md +3 -3
  47. package/prebuilt/master-xuyun/meta.json +8 -0
  48. package/prebuilt/master-yinguang/SKILL.md +3 -3
  49. package/prebuilt/master-yinguang/meta.json +8 -0
  50. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  51. package/prebuilt/master-zhiyi/meta.json +8 -0
  52. package/prompts/correction_handler.md +104 -0
  53. package/prompts/doctrine_reviewer.md +61 -0
  54. package/prompts/intake.md +62 -0
  55. package/prompts/merger.md +62 -0
  56. package/prompts/rag_instructions.md +54 -0
  57. package/prompts/sutra_analyzer.md +83 -0
  58. package/prompts/teaching_builder.md +41 -0
  59. package/prompts/voice_analyzer.md +92 -0
  60. package/prompts/voice_builder.md +48 -0
  61. package/prompts/voice_reviewer.md +66 -0
  62. package/references/README.md +12 -0
  63. package/references/ethics-runtime.md +112 -0
  64. package/references/fojin-api.md +223 -0
  65. package/references/source-conventions.md +129 -0
  66. package/references/teaching-modes.md +84 -0
  67. package/references/traditions.md +72 -0
  68. package/references/workflow-details.md +361 -0
  69. package/requirements.txt +6 -0
  70. package/scripts/select-fidelity-smoke.py +78 -0
  71. package/scripts/test-fidelity.py +19 -2
  72. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  73. package/scripts/tests/test_validate_citation_contract.py +408 -0
  74. package/scripts/tests/test_validate_workflow.py +265 -0
  75. package/scripts/validate-citation-contract.py +193 -0
  76. package/scripts/verify_citations.py +8 -1
  77. package/skill-catalog.json +147 -0
  78. package/tools/cross_reference.py +365 -0
  79. package/tools/fojin_bridge.py +146 -0
  80. package/tools/master_builder.py +341 -0
  81. package/tools/rag_query.py +336 -0
  82. package/tools/skill_writer.py +230 -0
  83. package/tools/sutra_collector.py +237 -0
  84. package/tools/verify_sources.py +512 -0
  85. package/tools/version_manager.py +88 -0
package/bin/cli.mjs CHANGED
@@ -8,8 +8,11 @@ import { fileURLToPath } from "url";
8
8
  // fileURLToPath (not new URL().pathname) — on Windows the URL pathname is
9
9
  // "/C:/…", which fs cannot resolve, so every command saw an empty prebuilt/.
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const PREBUILT = path.join(__dirname, "..", "prebuilt");
11
+ const PACKAGE_ROOT = path.join(__dirname, "..");
12
+ const PREBUILT = path.join(PACKAGE_ROOT, "prebuilt");
13
+ const CATALOG_PATH = path.join(PACKAGE_ROOT, "skill-catalog.json");
12
14
  const SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
15
+ const SKILL_KINDS = new Set(["persona", "teaching-mode", "generator"]);
13
16
 
14
17
  // --- helpers ---
15
18
 
@@ -27,7 +30,119 @@ function pkgVersion() {
27
30
  // Names feed into path.join + rmSync; reject separators / ".." so a typo
28
31
  // like "../foo" can never escape PREBUILT or SKILLS_DIR.
29
32
  function isSafeName(name) {
30
- return /^[A-Za-z0-9_-]+$/.test(name);
33
+ return typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name);
34
+ }
35
+
36
+ function isSafeRelativePath(value, { allowDot = false } = {}) {
37
+ if (typeof value !== "string" || value.length === 0) return false;
38
+ if (allowDot && value === ".") return true;
39
+ if (value === "." || value.includes("\\") || path.posix.isAbsolute(value)) return false;
40
+ if (/^[A-Za-z]:/.test(value)) return false;
41
+ const normalized = path.posix.normalize(value);
42
+ return normalized === value && normalized !== ".." && !normalized.startsWith("../");
43
+ }
44
+
45
+ function invalidCatalog(message) {
46
+ throw new Error(`Invalid skill catalog: ${message}`);
47
+ }
48
+
49
+ function loadCatalog() {
50
+ let catalog;
51
+ try {
52
+ catalog = JSON.parse(fs.readFileSync(CATALOG_PATH, "utf8"));
53
+ } catch (err) {
54
+ invalidCatalog(`cannot read ${path.basename(CATALOG_PATH)} (${err.message})`);
55
+ }
56
+
57
+ if (catalog?.version !== 1) invalidCatalog("version must be 1");
58
+ if (!Array.isArray(catalog.skills) || catalog.skills.length === 0) {
59
+ invalidCatalog("skills must be a non-empty array");
60
+ }
61
+
62
+ const uniqueFields = {
63
+ name: new Set(),
64
+ source: new Set(),
65
+ install_dir: new Set(),
66
+ };
67
+
68
+ for (const [index, skill] of catalog.skills.entries()) {
69
+ if (!skill || typeof skill !== "object" || Array.isArray(skill)) {
70
+ invalidCatalog(`skills[${index}] must be an object`);
71
+ }
72
+ if (!isSafeName(skill.name)) {
73
+ invalidCatalog(`skills[${index}].name must be a safe non-empty string`);
74
+ }
75
+ if (!SKILL_KINDS.has(skill.kind)) {
76
+ invalidCatalog(`skills[${index}].kind must be persona, teaching-mode, or generator`);
77
+ }
78
+ if (!isSafeRelativePath(skill.source, { allowDot: true })) {
79
+ invalidCatalog(`skills[${index}].source must be a safe relative path`);
80
+ }
81
+ if (!isSafeName(skill.install_dir)) {
82
+ invalidCatalog(`skills[${index}].install_dir must be a safe non-empty string`);
83
+ }
84
+ if (!Array.isArray(skill.aliases)) {
85
+ invalidCatalog(`skills[${index}].aliases must be an array`);
86
+ }
87
+
88
+ for (const field of Object.keys(uniqueFields)) {
89
+ if (uniqueFields[field].has(skill[field])) {
90
+ invalidCatalog(`duplicate ${field} "${skill[field]}"`);
91
+ }
92
+ uniqueFields[field].add(skill[field]);
93
+ }
94
+
95
+ const sourcePath = path.resolve(PACKAGE_ROOT, skill.source);
96
+ if (!fs.existsSync(sourcePath)) invalidCatalog(`source does not exist: ${skill.source}`);
97
+ if (!fs.statSync(sourcePath).isDirectory()) {
98
+ invalidCatalog(`source is not a directory: ${skill.source}`);
99
+ }
100
+
101
+ if (skill.kind === "generator") {
102
+ if (!Array.isArray(skill.bundle_paths) || skill.bundle_paths.length === 0) {
103
+ invalidCatalog(`generator "${skill.name}" must declare bundle_paths`);
104
+ }
105
+ const bundlePaths = new Set();
106
+ for (const bundlePath of skill.bundle_paths) {
107
+ if (!isSafeRelativePath(bundlePath)) {
108
+ invalidCatalog(`generator "${skill.name}" has an unsafe bundle path`);
109
+ }
110
+ if (bundlePaths.has(bundlePath)) {
111
+ invalidCatalog(`generator "${skill.name}" has duplicate bundle path "${bundlePath}"`);
112
+ }
113
+ bundlePaths.add(bundlePath);
114
+ if (!fs.existsSync(path.resolve(sourcePath, bundlePath))) {
115
+ invalidCatalog(`bundle path does not exist: ${bundlePath}`);
116
+ }
117
+ }
118
+ } else if (skill.bundle_paths !== undefined) {
119
+ invalidCatalog(`only generator skills may declare bundle_paths`);
120
+ }
121
+ }
122
+
123
+ // Register every canonical name before considering aliases so token
124
+ // ownership is independent of catalog record order. An alias may repeat its
125
+ // own canonical name, but it may never shadow another skill's canonical name.
126
+ const aliases = new Set();
127
+ const tokenOwners = new Map(
128
+ catalog.skills.map((skill) => [skill.name, skill.name])
129
+ );
130
+ for (const [index, skill] of catalog.skills.entries()) {
131
+ for (const alias of skill.aliases) {
132
+ if (!isSafeName(alias)) {
133
+ invalidCatalog(`skills[${index}].aliases contains an unsafe value`);
134
+ }
135
+ if (aliases.has(alias)) invalidCatalog(`duplicate alias "${alias}"`);
136
+ aliases.add(alias);
137
+ const existingOwner = tokenOwners.get(alias);
138
+ if (existingOwner && existingOwner !== skill.name) {
139
+ invalidCatalog(`alias "${alias}" conflicts with skill "${existingOwner}"`);
140
+ }
141
+ tokenOwners.set(alias, skill.name);
142
+ }
143
+ }
144
+
145
+ return catalog;
31
146
  }
32
147
 
33
148
  function cpR(src, dest) {
@@ -40,6 +155,65 @@ function cpR(src, dest) {
40
155
  }
41
156
  }
42
157
 
158
+ function copyGeneratorBundle(skill, src, dest) {
159
+ fs.mkdirSync(dest, { recursive: true });
160
+ for (const bundlePath of skill.bundle_paths) {
161
+ const bundleSource = path.join(src, bundlePath);
162
+ const bundleDest = path.join(dest, bundlePath);
163
+ if (fs.statSync(bundleSource).isDirectory()) {
164
+ cpR(bundleSource, bundleDest);
165
+ } else {
166
+ fs.mkdirSync(path.dirname(bundleDest), { recursive: true });
167
+ fs.copyFileSync(bundleSource, bundleDest);
168
+ }
169
+ }
170
+ }
171
+
172
+ function replaceGeneratorInstall(skill, src, dest) {
173
+ const staging = fs.mkdtempSync(
174
+ path.join(SKILLS_DIR, `.${skill.install_dir}-staging-`)
175
+ );
176
+ let backup = null;
177
+
178
+ try {
179
+ copyGeneratorBundle(skill, src, staging);
180
+
181
+ // Generated personas are user data, not package runtime. Merge them into
182
+ // the staged bundle before replacing the old install so reinstall/update
183
+ // can still remove stale runtime files without erasing masters/*.
184
+ const existingMasters = path.join(dest, "masters");
185
+ if (fs.existsSync(existingMasters)) {
186
+ cpR(existingMasters, path.join(staging, "masters"));
187
+ }
188
+
189
+ if (fs.existsSync(dest)) {
190
+ backup = fs.mkdtempSync(
191
+ path.join(SKILLS_DIR, `.${skill.install_dir}-backup-`)
192
+ );
193
+ fs.rmSync(backup, { recursive: true, force: true });
194
+ fs.renameSync(dest, backup);
195
+ }
196
+
197
+ try {
198
+ fs.renameSync(staging, dest);
199
+ } catch (err) {
200
+ if (backup && fs.existsSync(backup) && !fs.existsSync(dest)) {
201
+ fs.renameSync(backup, dest);
202
+ backup = null;
203
+ }
204
+ throw err;
205
+ }
206
+
207
+ if (backup) {
208
+ fs.rmSync(backup, { recursive: true, force: true });
209
+ backup = null;
210
+ }
211
+ } finally {
212
+ fs.rmSync(staging, { recursive: true, force: true });
213
+ if (backup) fs.rmSync(backup, { recursive: true, force: true });
214
+ }
215
+ }
216
+
43
217
  function parseFrontmatter(filepath) {
44
218
  // \r?\n: a CRLF checkout (git autocrlf on Windows) must not blank out
45
219
  // every description.
@@ -97,12 +271,47 @@ function printJson(payload) {
97
271
  console.log(JSON.stringify(payload, null, 2));
98
272
  }
99
273
 
274
+ let CATALOG;
275
+ try {
276
+ CATALOG = loadCatalog();
277
+ } catch (err) {
278
+ console.error(err.message);
279
+ process.exitCode = 1;
280
+ }
281
+
282
+ function catalogSkills() {
283
+ return CATALOG.skills.map((skill) => {
284
+ const skillMd = path.join(PACKAGE_ROOT, skill.source, "SKILL.md");
285
+ const fm = fs.existsSync(skillMd) ? parseFrontmatter(skillMd) : {};
286
+ return { ...skill, description: fm.description || "" };
287
+ });
288
+ }
289
+
290
+ function resolveSkill(input) {
291
+ return catalogSkills().find(
292
+ (skill) => skill.name === input || skill.aliases.includes(input)
293
+ ) || null;
294
+ }
295
+
100
296
  // --- commands ---
101
297
 
102
298
  function listData() {
103
299
  const masters = availableMasters();
300
+ const skills = catalogSkills();
301
+ const categoryCounts = skills.reduce((counts, skill) => {
302
+ counts[skill.kind] += 1;
303
+ return counts;
304
+ }, { persona: 0, "teaching-mode": 0, generator: 0 });
104
305
  return {
105
306
  count: masters.length,
307
+ skillCount: skills.length,
308
+ categoryCounts,
309
+ skills: skills.map(({ name, kind, install_dir, description }) => ({
310
+ name,
311
+ kind,
312
+ installDir: install_dir,
313
+ description,
314
+ })),
106
315
  masters: masters.map((m) => ({
107
316
  name: m.name,
108
317
  slug: m.name.replace(/^master-/, ""),
@@ -118,16 +327,27 @@ function cmdList({ json = false } = {}) {
118
327
  return;
119
328
  }
120
329
 
121
- const masters = data.masters;
122
- if (!masters.length) {
123
- console.log("No prebuilt masters found.");
330
+ const skills = data.skills;
331
+ if (!skills.length) {
332
+ console.log("No installable skills found.");
124
333
  return;
125
334
  }
126
- console.log(`\nAvailable masters (${data.count}):\n`);
127
- const nameW = Math.max(...masters.map((m) => m.name.length), 4);
128
- for (const m of masters) {
129
- const desc = m.description.length > 80 ? m.description.slice(0, 77) + "..." : m.description;
130
- console.log(` ${m.name.padEnd(nameW)} ${desc}`);
335
+ console.log(`\nAvailable masters (${data.count}); installable skills (${data.skillCount}):`);
336
+ const groups = [
337
+ ["persona", "Personas"],
338
+ ["teaching-mode", "Teaching modes"],
339
+ ["generator", "Generator"],
340
+ ];
341
+ const nameW = Math.max(...skills.map((skill) => skill.name.length), 4);
342
+ for (const [kind, label] of groups) {
343
+ const group = skills.filter((skill) => skill.kind === kind);
344
+ console.log(`\n${label} (${group.length}):`);
345
+ for (const skill of group) {
346
+ const desc = skill.description.length > 80
347
+ ? skill.description.slice(0, 77) + "..."
348
+ : skill.description;
349
+ console.log(` ${skill.name.padEnd(nameW)} ${desc}`);
350
+ }
131
351
  }
132
352
  console.log();
133
353
  }
@@ -153,30 +373,34 @@ function cmdInstall(names) {
153
373
  failed++;
154
374
  continue;
155
375
  }
156
- const src = resolveMasterDir(name);
157
- if (!src) {
158
- console.log(` ✗ ${name} — not found in prebuilt/ (tried "${name}" and "master-${name}")`);
376
+ const skill = resolveSkill(name);
377
+ if (!skill) {
378
+ console.log(` ✗ ${name} — not found in skill catalog`);
159
379
  failed++;
160
380
  continue;
161
381
  }
162
- const dirName = path.basename(src); // master-zhiyi
163
- const dest = path.join(SKILLS_DIR, dirName);
164
- // Clear any previous install first: files renamed or removed upstream
165
- // must not linger as stale skill content under ~/.claude/skills/.
166
- fs.rmSync(dest, { recursive: true, force: true });
167
- cpR(src, dest);
382
+ const src = path.join(PACKAGE_ROOT, skill.source);
383
+ const dest = path.join(SKILLS_DIR, skill.install_dir);
384
+ if (skill.kind === "generator") {
385
+ replaceGeneratorInstall(skill, src, dest);
386
+ } else {
387
+ // Clear any previous install first: files renamed or removed upstream
388
+ // must not linger as stale skill content under ~/.claude/skills/.
389
+ fs.rmSync(dest, { recursive: true, force: true });
390
+ cpR(src, dest);
391
+ }
168
392
  console.log(` ✓ ${name} → ${dest}`);
169
393
  }
170
394
  return failed;
171
395
  }
172
396
 
173
397
  function cmdInstallAll(label = "Installing") {
174
- const all = availableMasters().map((m) => m.name);
398
+ const all = catalogSkills().map((skill) => skill.name);
175
399
  if (!all.length) {
176
- console.log("No masters available.");
400
+ console.log("No skills available.");
177
401
  return 1;
178
402
  }
179
- console.log(`${label} all ${all.length} masters...\n`);
403
+ console.log(`${label} all ${all.length} skills...\n`);
180
404
  return cmdInstall(all);
181
405
  }
182
406
 
@@ -188,12 +412,16 @@ function cmdUninstall(names) {
188
412
  failed++;
189
413
  continue;
190
414
  }
191
- // Try both prefixed and bare directory names for backward compatibility
192
- // with any pre-v0.6 installs that may still sit at ~/.claude/skills/<slug>/.
193
- const candidates = [
194
- path.join(SKILLS_DIR, name), // exact: master-zhiyi
195
- path.join(SKILLS_DIR, `master-${name}`), // short: zhiyi → master-zhiyi
196
- ];
415
+ const skill = resolveSkill(name);
416
+ if (!skill) {
417
+ console.log(` ✗ ${name} not found in skill catalog`);
418
+ failed++;
419
+ continue;
420
+ }
421
+ // Keep removing legacy bare persona directories when they exist, while
422
+ // resolving every public name through the catalog's canonical target.
423
+ const candidates = [path.join(SKILLS_DIR, skill.install_dir)];
424
+ if (name !== skill.install_dir) candidates.push(path.join(SKILLS_DIR, name));
197
425
  const dest = candidates.find((p) => fs.existsSync(p));
198
426
  if (!dest) {
199
427
  console.log(` ✗ ${name} — not installed`);
@@ -341,25 +569,27 @@ function showHelp() {
341
569
  master-skill v${pkgVersion()} — Buddhist Master AI Skills installer
342
570
 
343
571
  Usage:
344
- master-skill install <name...> Install masters to ~/.claude/skills/
345
- master-skill install --all Install all available masters
346
- master-skill update --all Reinstall all masters, clearing stale files
347
- master-skill list List available masters
348
- master-skill list --json Print available masters as JSON
572
+ master-skill install <name...> Install skills to ~/.claude/skills/
573
+ master-skill install --all Install all 19 available skills
574
+ master-skill update --all Reinstall all skills, clearing stale files
575
+ master-skill list List available skills
576
+ master-skill list --json Print available skills as JSON
349
577
  master-skill inspect <name> Show source/runtime metadata for one master
350
578
  master-skill inspect <name> --json
351
579
  master-skill doctor Check local install and runtime paths
352
580
  master-skill doctor --json Print diagnostics as JSON
353
- master-skill uninstall <name...> Remove installed masters
581
+ master-skill uninstall <name...> Remove installed skills
354
582
  master-skill --version Print version
355
583
  master-skill --help Show this help
356
584
 
357
- Names accept both short (zhiyi) and full (master-zhiyi) forms.
358
- Slash commands are always /master-<slug> (e.g. /master-zhiyi).
585
+ Persona names accept both short (zhiyi) and full (master-zhiyi) forms.
586
+ Teaching modes and create-master use their public catalog names.
359
587
 
360
588
  Examples:
361
589
  npx master-skill install zhiyi fazang
362
590
  npx master-skill install master-milarepa master-tsongkhapa
591
+ npx master-skill install compare-masters
592
+ npx master-skill install create-master
363
593
  npx master-skill install --all
364
594
  npx master-skill update --all
365
595
  npx master-skill list
@@ -371,48 +601,50 @@ Examples:
371
601
 
372
602
  // --- main ---
373
603
 
374
- const args = process.argv.slice(2);
375
- const json = args.includes("--json");
376
- const positionalArgs = args.filter((arg) => arg !== "--json");
377
- const cmd = positionalArgs[0];
378
-
379
- if (!cmd || cmd === "--help" || cmd === "-h") {
380
- showHelp();
381
- } else if (cmd === "--version" || cmd === "-v") {
382
- console.log(pkgVersion());
383
- } else if (cmd === "list") {
384
- cmdList({ json });
385
- } else if (cmd === "doctor") {
386
- if (cmdDoctor({ json }) > 0) process.exitCode = 1;
387
- } else if (cmd === "inspect") {
388
- if (cmdInspect(positionalArgs[1], { json }) > 0) process.exitCode = 1;
389
- } else if (cmd === "install") {
390
- const rest = positionalArgs.slice(1);
391
- if (rest.includes("--all")) {
392
- if (cmdInstallAll("Installing") > 0) process.exitCode = 1;
393
- } else if (rest.length === 0) {
394
- console.log("Usage: master-skill install <name...> | --all");
395
- process.exitCode = 1;
396
- } else {
397
- if (cmdInstall(rest) > 0) process.exitCode = 1;
398
- }
399
- } else if (cmd === "update") {
400
- const rest = positionalArgs.slice(1);
401
- if (rest.length === 1 && rest[0] === "--all") {
402
- if (cmdInstallAll("Updating") > 0) process.exitCode = 1;
604
+ if (CATALOG) {
605
+ const args = process.argv.slice(2);
606
+ const json = args.includes("--json");
607
+ const positionalArgs = args.filter((arg) => arg !== "--json");
608
+ const cmd = positionalArgs[0];
609
+
610
+ if (!cmd || cmd === "--help" || cmd === "-h") {
611
+ showHelp();
612
+ } else if (cmd === "--version" || cmd === "-v") {
613
+ console.log(pkgVersion());
614
+ } else if (cmd === "list") {
615
+ cmdList({ json });
616
+ } else if (cmd === "doctor") {
617
+ if (cmdDoctor({ json }) > 0) process.exitCode = 1;
618
+ } else if (cmd === "inspect") {
619
+ if (cmdInspect(positionalArgs[1], { json }) > 0) process.exitCode = 1;
620
+ } else if (cmd === "install") {
621
+ const rest = positionalArgs.slice(1);
622
+ if (rest.includes("--all")) {
623
+ if (cmdInstallAll("Installing") > 0) process.exitCode = 1;
624
+ } else if (rest.length === 0) {
625
+ console.log("Usage: master-skill install <name...> | --all");
626
+ process.exitCode = 1;
627
+ } else {
628
+ if (cmdInstall(rest) > 0) process.exitCode = 1;
629
+ }
630
+ } else if (cmd === "update") {
631
+ const rest = positionalArgs.slice(1);
632
+ if (rest.length === 1 && rest[0] === "--all") {
633
+ if (cmdInstallAll("Updating") > 0) process.exitCode = 1;
634
+ } else {
635
+ console.log("Usage: master-skill update --all");
636
+ process.exitCode = 1;
637
+ }
638
+ } else if (cmd === "uninstall") {
639
+ const rest = positionalArgs.slice(1);
640
+ if (rest.length === 0) {
641
+ console.log("Usage: master-skill uninstall <name...>");
642
+ process.exitCode = 1;
643
+ } else {
644
+ if (cmdUninstall(rest) > 0) process.exitCode = 1;
645
+ }
403
646
  } else {
404
- console.log("Usage: master-skill update --all");
647
+ console.log(`Unknown command: ${cmd}\nRun master-skill --help for usage.`);
405
648
  process.exitCode = 1;
406
649
  }
407
- } else if (cmd === "uninstall") {
408
- const rest = positionalArgs.slice(1);
409
- if (rest.length === 0) {
410
- console.log("Usage: master-skill uninstall <name...>");
411
- process.exitCode = 1;
412
- } else {
413
- if (cmdUninstall(rest) > 0) process.exitCode = 1;
414
- }
415
- } else {
416
- console.log(`Unknown command: ${cmd}\nRun master-skill --help for usage.`);
417
- process.exitCode = 1;
418
650
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "master-skill",
3
3
  "description": "FoJin-powered Buddhist AI persona framework — source-grounded, boundary-aware, fidelity-tested, runtime-ready. 15 prebuilt masters across 印度/汉传/藏传/南传.",
4
- "version": "0.10.0",
4
+ "version": "0.10.1",
5
5
  "contextFileName": "GEMINI.md"
6
6
  }
@@ -1,6 +1,15 @@
1
- : ; # Polyglot wrapper — runs as cmd.exe on Windows, bash on Unix
2
- : ; exec bash "$0" "$@" 2>/dev/null
3
- : ; exit
1
+ : ; # Polyglot wrapper — runs as cmd.exe on Windows, bash on Unix.
2
+ : ; # Dispatch $1 by name. This line once read `exec bash "$0" "$@"`,
3
+ : ; # which re-exec'd this same file forever; hooks.json runs the wrapper
4
+ : ; # with "async": false, so every session start spun until the harness
5
+ : ; # hook timeout while 2>/dev/null hid the cause. Keep this half POSIX —
6
+ : ; # the file has no shebang, so /bin/sh interprets it, and that is dash
7
+ : ; # on Debian/Ubuntu where ${@:2} is a "Bad substitution" error.
8
+ : ; HOOK_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ : ; HOOK="${1:-}"
10
+ : ; [ "$#" -gt 0 ] && shift
11
+ : ; exec bash "$HOOK_DIR/$HOOK" "$@"
12
+ : ; exit 1
4
13
  @echo off
5
14
  setlocal enabledelayedexpansion
6
15
 
@@ -15,14 +24,18 @@ for %%B in (
15
24
  ) do (
16
25
  if exist %%B (
17
26
  %%B "%HOOK_DIR%%HOOK%" %2 %3 %4 %5
18
- exit /b %ERRORLEVEL%
27
+ rem !...!, not %...%: cmd.exe expands %ERRORLEVEL% while parsing the
28
+ rem whole parenthesized block, so it would freeze to the value from
29
+ rem before the loop and report every hook failure as success. This is
30
+ rem what `setlocal enabledelayedexpansion` above is for.
31
+ exit /b !ERRORLEVEL!
19
32
  )
20
33
  )
21
34
 
22
35
  :: Fallback: try bash from PATH
23
36
  where bash >nul 2>&1 && (
24
37
  bash "%HOOK_DIR%%HOOK%" %2 %3 %4 %5
25
- exit /b %ERRORLEVEL%
38
+ exit /b !ERRORLEVEL!
26
39
  )
27
40
 
28
41
  :: No bash found — exit silently
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for hooks/run-hook.cmd — the polyglot cmd.exe/Unix wrapper that
3
+ # hooks.json invokes for every SessionStart.
4
+ #
5
+ # The wrapper has no shebang: on Unix, execve returns ENOEXEC and the
6
+ # CALLING shell interprets it. That shell is /bin/sh (dash on Debian and
7
+ # Ubuntu), not necessarily bash — so every case below is driven through
8
+ # both `bash -c` and `sh -c`, mirroring how a real hook invocation lands.
9
+ #
10
+ # Regressions these cases pin down:
11
+ #
12
+ # 1. `exec bash "$0" "$@"` re-execs the wrapper itself, spinning forever.
13
+ # hooks.json runs this with "async": false, so every startup/clear/
14
+ # compact blocked until the harness hook timeout, with 2>/dev/null
15
+ # swallowing any sign of it.
16
+ # 2. `${@:2}` is a bashism. Under dash it is a "Bad substitution" error,
17
+ # trading the hang for a silent failure on half the installed base.
18
+ # 3. The wrapper must actually dispatch $1; an unknown hook name has to
19
+ # fail loudly rather than hang or report success.
20
+ #
21
+ # Exit non-zero on any failed assertion.
22
+
23
+ set -uo pipefail
24
+
25
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ WRAPPER="$SCRIPT_DIR/../run-hook.cmd"
27
+ TIMEOUT_SECS=10
28
+
29
+ if [ ! -f "$WRAPPER" ]; then
30
+ echo "FAIL: cannot find $WRAPPER" >&2
31
+ exit 1
32
+ fi
33
+
34
+ PASS=0
35
+ FAIL=0
36
+
37
+ # Run the wrapper under $1 (bash|sh) and report "<exit>|<stdout>".
38
+ # A timeout surfaces as exit 124, which is what the self-exec loop produces.
39
+ run_wrapper() {
40
+ local shell="$1"
41
+ shift
42
+ local out
43
+ local rc
44
+ out=$(timeout "$TIMEOUT_SECS" "$shell" -c "'$WRAPPER' $*" 2>/dev/null)
45
+ rc=$?
46
+ printf '%s|%s' "$rc" "$out"
47
+ }
48
+
49
+ # --- Cases 1-2: the hook runs to completion and emits valid JSON ---------
50
+ # Driven under both shells: bash alone would not catch the ${@:2} bashism,
51
+ # and sh alone would not catch a bash-only regression.
52
+ for shell in bash sh; do
53
+ if ! command -v "$shell" >/dev/null 2>&1; then
54
+ printf " SKIP %s not available\n" "$shell"
55
+ continue
56
+ fi
57
+
58
+ result=$(run_wrapper "$shell" session-start)
59
+ rc="${result%%|*}"
60
+ out="${result#*|}"
61
+
62
+ if [ "$rc" -eq 124 ]; then
63
+ printf " FAIL %s: wrapper hung (timed out after %ss — self-exec loop?)\n" \
64
+ "$shell" "$TIMEOUT_SECS"
65
+ FAIL=$((FAIL + 1))
66
+ continue
67
+ fi
68
+
69
+ if [ "$rc" -ne 0 ]; then
70
+ printf " FAIL %s: wrapper exited %s (expected 0)\n" "$shell" "$rc"
71
+ FAIL=$((FAIL + 1))
72
+ continue
73
+ fi
74
+
75
+ if printf '%s' "$out" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null; then
76
+ printf " PASS %s: session-start dispatched, emitted valid JSON\n" "$shell"
77
+ PASS=$((PASS + 1))
78
+ else
79
+ printf " FAIL %s: output is not valid JSON: %.60s\n" "$shell" "$out"
80
+ FAIL=$((FAIL + 1))
81
+ fi
82
+ done
83
+
84
+ # --- Case 3: the dispatched hook is really session-start, not something else
85
+ result=$(run_wrapper bash session-start)
86
+ out="${result#*|}"
87
+ case "$out" in
88
+ *"Master-skill plugin loaded"*)
89
+ echo " PASS dispatches the named hook (context payload present)"
90
+ PASS=$((PASS + 1))
91
+ ;;
92
+ *)
93
+ printf " FAIL dispatched hook did not produce session-start output: %.60s\n" "$out"
94
+ FAIL=$((FAIL + 1))
95
+ ;;
96
+ esac
97
+
98
+ # --- Case 4: an unknown hook name fails loudly, never hangs --------------
99
+ result=$(run_wrapper bash no-such-hook)
100
+ rc="${result%%|*}"
101
+ if [ "$rc" -eq 124 ]; then
102
+ echo " FAIL unknown hook name hung instead of failing"
103
+ FAIL=$((FAIL + 1))
104
+ elif [ "$rc" -eq 0 ]; then
105
+ echo " FAIL unknown hook name reported success"
106
+ FAIL=$((FAIL + 1))
107
+ else
108
+ printf " PASS unknown hook name fails loudly (exit %s)\n" "$rc"
109
+ PASS=$((PASS + 1))
110
+ fi
111
+
112
+ echo
113
+ printf "Summary: %d passed, %d failed\n" "$PASS" "$FAIL"
114
+ exit $([ "$FAIL" -eq 0 ] && echo 0 || echo 1)