@klhapp/skillmux 0.2.1 → 0.4.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.
package/src/cli.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Database } from "bun:sqlite";
3
- import { existsSync, rmSync } from "node:fs";
3
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { generateDataset } from "./dataset-generator";
6
+
5
7
  import { createClients } from "./clients";
6
8
  import { loadConfig } from "./config";
7
9
  import { expandHome } from "./config";
@@ -17,7 +19,16 @@ import {
17
19
  resolveSkillDir,
18
20
  validateSkillCandidate,
19
21
  } from "./install";
20
- import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
22
+ import {
23
+ parseManifest,
24
+ pinCore,
25
+ pinProject,
26
+ resolveManifestPath,
27
+ serializeManifest,
28
+ unpinCore,
29
+ unpinProject,
30
+ validateManifest,
31
+ } from "./manifest";
21
32
  import { downloadLocalModels } from "./models";
22
33
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
23
34
  import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
@@ -27,456 +38,926 @@ import {
27
38
  restoreMonolith as restoreMonolithTarget,
28
39
  syncProjectTargets,
29
40
  syncTarget,
41
+ writeLocalVaultMarker,
30
42
  } from "./sync";
31
- import { scanVault } from "./vault";
43
+ import { scanVault, vaultResolutionOrder } from "./vault";
32
44
 
33
- const [command] = Bun.argv.slice(2);
34
- type Transport = "stdio" | "http";
45
+ import {
46
+ addContext,
47
+ getCurrentContext,
48
+ listContexts,
49
+ removeContext,
50
+ resolveTarget,
51
+ useContext,
52
+ type ResolvedTarget,
53
+ } from "./context";
54
+ import { createTargetAdapter, type TargetAdapter } from "./adapters";
55
+ import { formatJsonEnvelope, isInteractive, mapExitCode, renderTable, renderTargetBanner, suggestCorrection } from "./output";
56
+ import { generateCompletions, type ShellType } from "./completions";
57
+
58
+ const KNOWN_COMMANDS = [
59
+ "context",
60
+ "config",
61
+ "calibrate",
62
+ "completions",
63
+ "serve",
64
+ "index",
65
+ "sync",
66
+ "init",
67
+ "report",
68
+ "scan",
69
+ "install",
70
+ "eval",
71
+ "doctor",
72
+ "models",
73
+ "which",
74
+ "manifest",
75
+ "local-vault",
76
+ ];
77
+
78
+ async function main() {
79
+ const rawArgv = Bun.argv.slice(2);
80
+
81
+ let isJson = process.env.SKILLMUX_JSON === "true";
82
+ let allowInsecure = false;
83
+ let isVerbose = false;
84
+ let flagContext: string | undefined;
85
+ let flagServer: string | undefined;
86
+ let isDryRun = false;
87
+
88
+ const command = rawArgv[0];
89
+ if (!command || command === "--help" || command === "-h") {
90
+ printHelp();
91
+ return;
92
+ }
93
+
94
+ // Parse global flags for context/config/calibrate
95
+ for (let i = 0; i < rawArgv.length; i++) {
96
+ const arg = rawArgv[i];
97
+ if (arg === "--json") isJson = true;
98
+ else if (arg === "--allow-insecure") allowInsecure = true;
99
+ else if (arg === "--verbose") isVerbose = true;
100
+ else if (arg === "--dry-run") isDryRun = true;
101
+ else if (arg === "--context") flagContext = rawArgv[++i];
102
+ else if (arg === "--server") flagServer = rawArgv[++i];
103
+ }
104
+
105
+ let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
106
+
107
+ // Only resolve target if command is target-aware or context/config/calibrate
108
+ if (["context", "config", "calibrate"].includes(command) || flagContext || flagServer) {
109
+ try {
110
+ resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
111
+ } catch (err: any) {
112
+ handleError(err, { target: resolvedTarget, isJson, isVerbose });
113
+ return;
114
+ }
115
+ }
35
116
 
36
- function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
37
- let transport: Transport = "stdio";
38
- let port: number | undefined;
117
+ const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
118
+ const subCommand = rawArgv[1] ?? "";
119
+ const commandArgs = rawArgv.slice(2);
120
+
121
+ try {
122
+ switch (command) {
123
+ case "context":
124
+ await handleContextCommand(subCommand, commandArgs, { target: resolvedTarget, isJson });
125
+ break;
126
+ case "config":
127
+ await handleConfigCommand(adapter, subCommand, commandArgs, { target: resolvedTarget, isJson, dryRun: isDryRun });
128
+ break;
129
+ case "calibrate":
130
+ await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), { target: resolvedTarget, isJson });
131
+ break;
132
+ case "completions":
133
+ await handleCompletionsCommand(subCommand);
134
+ break;
135
+ case "serve": {
136
+ const { startServer } = await import("./server");
137
+ const { transport, port } = parseServeArgs(rawArgv.slice(1));
138
+ const handle = await startServer({ transport, port });
139
+ let stopping = false;
140
+ const shutdown = async () => {
141
+ if (stopping) return;
142
+ stopping = true;
143
+ const timeout = setTimeout(() => process.exit(1), 10_000);
144
+ timeout.unref();
145
+ await handle.stop();
146
+ clearTimeout(timeout);
147
+ process.exit(0);
148
+ };
149
+ process.once("SIGTERM", shutdown);
150
+ process.once("SIGINT", shutdown);
151
+ break;
152
+ }
153
+ case "index":
154
+ await runIndex();
155
+ break;
156
+ case "sync":
157
+ await runSync(rawArgv.slice(1));
158
+ break;
159
+ case "init":
160
+ await runInit(rawArgv.slice(1));
161
+ break;
162
+ case "report":
163
+ await runReport(rawArgv.slice(1));
164
+ break;
165
+ case "scan":
166
+ await runScan(rawArgv.slice(1));
167
+ break;
168
+ case "install":
169
+ await runInstall(rawArgv.slice(1));
170
+ break;
171
+ case "eval":
172
+ await runEval();
173
+ break;
174
+ case "doctor":
175
+ await runDoctor();
176
+ break;
177
+ case "which":
178
+ await runWhich(rawArgv.slice(1));
179
+ break;
180
+ case "manifest":
181
+ await runManifest(subCommand, commandArgs);
182
+ break;
183
+ case "local-vault":
184
+ if (subCommand !== "init") throw new Error("usage: skillmux local-vault init <path>");
185
+ await runLocalVaultInit(commandArgs);
186
+ break;
187
+ case "models":
188
+ if (subCommand !== "download") throw new Error("usage: skillmux models download");
189
+ await runModelDownload();
190
+ break;
191
+ default: {
192
+ const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
193
+ const msg = suggestion
194
+ ? `Unknown command "${command}". Did you mean "${suggestion}"?`
195
+ : `usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
196
+ throw new Error(msg);
197
+ }
198
+ }
199
+ } catch (err: any) {
200
+ handleError(err, { target: resolvedTarget, isJson, isVerbose });
201
+ }
202
+ }
203
+
204
+ async function handleContextCommand(
205
+ sub: string,
206
+ args: string[],
207
+ ctx: { target: ResolvedTarget; isJson: boolean }
208
+ ) {
209
+ if (sub === "list") {
210
+ const contexts = await listContexts();
211
+ if (ctx.isJson) {
212
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: contexts })));
213
+ } else {
214
+ renderTargetBanner(ctx.target);
215
+ renderTable(
216
+ [
217
+ { key: "name", header: "NAME" },
218
+ { key: "server", header: "SERVER" },
219
+ { key: "token_env", header: "TOKEN_ENV" },
220
+ { key: "isDefault", header: "DEFAULT" },
221
+ ],
222
+ contexts.map((c) => ({ ...c, token_env: c.token_env ?? "-", isDefault: c.isDefault ? "*" : "" }))
223
+ );
224
+ }
225
+ return;
226
+ }
227
+
228
+ if (sub === "current") {
229
+ const current = await getCurrentContext();
230
+ if (ctx.isJson) {
231
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: current })));
232
+ } else {
233
+ renderTargetBanner(ctx.target);
234
+ console.log(`Current context: ${current.name} (${current.server})`);
235
+ }
236
+ return;
237
+ }
238
+
239
+ if (sub === "add") {
240
+ const name = args[0];
241
+ let server: string | undefined;
242
+ let tokenEnv: string | undefined;
243
+ for (let i = 1; i < args.length; i++) {
244
+ if (args[i] === "--server") server = args[++i];
245
+ else if (args[i] === "--token-env") tokenEnv = args[++i];
246
+ }
247
+ if (!name || !server) {
248
+ throw new Error("usage: skillmux context add <name> --server <url> [--token-env <env_name>]");
249
+ }
250
+ await addContext(name, { server, token_env: tokenEnv });
251
+ if (ctx.isJson) {
252
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { name, server, token_env: tokenEnv } })));
253
+ } else {
254
+ console.log(`Added context "${name}" -> ${server}`);
255
+ }
256
+ return;
257
+ }
258
+
259
+ if (sub === "use") {
260
+ const name = args[0];
261
+ if (!name) throw new Error("usage: skillmux context use <name>");
262
+ await useContext(name);
263
+ if (ctx.isJson) {
264
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { default_context: name } })));
265
+ } else {
266
+ console.log(`Switched default context to "${name}"`);
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (sub === "remove") {
272
+ const name = args[0];
273
+ if (!name) throw new Error("usage: skillmux context remove <name>");
274
+ await removeContext(name);
275
+ if (ctx.isJson) {
276
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { removed: name } })));
277
+ } else {
278
+ console.log(`Removed context "${name}"`);
279
+ }
280
+ return;
281
+ }
282
+
283
+ throw new Error("usage: skillmux context <add|list|current|use|remove>");
284
+ }
285
+
286
+ async function handleConfigCommand(
287
+ adapter: TargetAdapter,
288
+ sub: string,
289
+ args: string[],
290
+ ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
291
+ ) {
292
+ if (sub === "show") {
293
+ const data = await adapter.getConfigShow();
294
+ if (ctx.isJson) {
295
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data })));
296
+ } else {
297
+ renderTargetBanner(ctx.target);
298
+ console.log(JSON.stringify(data.effective, null, 2));
299
+ }
300
+ return;
301
+ }
302
+
303
+ if (sub === "get") {
304
+ const key = args[0];
305
+ if (!key) throw new Error("usage: skillmux config get <key>");
306
+ const val = await adapter.getConfigGet(key);
307
+ if (ctx.isJson) {
308
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: { key, value: val } })));
309
+ } else {
310
+ console.log(typeof val === "object" ? JSON.stringify(val) : String(val));
311
+ }
312
+ return;
313
+ }
314
+
315
+ if (sub === "validate") {
316
+ const res = await adapter.configValidate();
317
+ if (ctx.isJson) {
318
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
319
+ } else {
320
+ console.log(res.valid ? "Configuration is valid." : "Configuration is invalid.");
321
+ }
322
+ return;
323
+ }
324
+
325
+ if (sub === "diff") {
326
+ const res = await adapter.configDiff();
327
+ if (ctx.isJson) {
328
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
329
+ } else {
330
+ renderTargetBanner(ctx.target);
331
+ console.log(JSON.stringify(res.diff, null, 2));
332
+ }
333
+ return;
334
+ }
335
+
336
+ if (sub === "set") {
337
+ const key = args[0];
338
+ const value = args[1];
339
+ if (!key || value === undefined) {
340
+ throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
341
+ }
342
+ const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
343
+ if (ctx.isJson) {
344
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
345
+ } else {
346
+ renderTargetBanner(ctx.target);
347
+ const prefix = ctx.dryRun ? "[dry-run] " : "";
348
+ console.log(`${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`);
349
+ console.log(`Persistence: ${res.persistence}, Application: ${res.application}`);
350
+ }
351
+ return;
352
+ }
353
+
354
+ if (sub === "status") {
355
+ const res = await adapter.configStatus();
356
+ if (ctx.isJson) {
357
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
358
+ } else {
359
+ renderTargetBanner(ctx.target);
360
+ console.log(`Runtime: ${res.runtime}`);
361
+ console.log(`Active revision: ${res.active_revision}`);
362
+ console.log(`Readiness: ${res.readiness.status}`);
363
+ }
364
+ return;
365
+ }
366
+
367
+ throw new Error("usage: skillmux config show");
368
+ }
369
+
370
+ async function handleCalibrateCommand(
371
+ adapter: TargetAdapter,
372
+ sub: string,
373
+ args: string[],
374
+ ctx: { target: ResolvedTarget; isJson: boolean }
375
+ ) {
376
+ if (sub === "run") {
377
+ let datasetPath: string | undefined;
39
378
  for (let i = 0; i < args.length; i++) {
40
- const option = args[i];
41
- const value = args[i + 1];
42
- if (option === "--transport") {
43
- if (value !== "stdio" && value !== "http") {
44
- throw new Error("--transport must be stdio or http");
45
- }
46
- transport = value;
47
- i++;
48
- } else if (option === "--port") {
49
- const parsed = Number(value);
50
- if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
51
- throw new Error("--port must be an integer between 0 and 65535");
52
- }
53
- port = parsed;
54
- i++;
55
- } else {
56
- throw new Error(`unknown serve option: ${option}`);
57
- }
58
- }
59
- return { transport, port };
379
+ if (args[i] === "--dataset") datasetPath = args[++i];
380
+ }
381
+ const res = await adapter.calibrateRun({ datasetPath });
382
+ if (ctx.isJson) {
383
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
384
+ } else {
385
+ renderTargetBanner(ctx.target);
386
+ console.log(`Calibration run complete.`);
387
+ if (res.result) console.log(JSON.stringify(res.result, null, 2));
388
+ }
389
+ return;
390
+ }
391
+
392
+ if (sub === "list") {
393
+ const res = await adapter.calibrateList();
394
+ if (ctx.isJson) {
395
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
396
+ } else {
397
+ renderTargetBanner(ctx.target);
398
+ renderTable(
399
+ [
400
+ { key: "run_id", header: "RUN_ID" },
401
+ { key: "created_at", header: "CREATED_AT" },
402
+ { key: "status", header: "STATUS" },
403
+ ],
404
+ res
405
+ );
406
+ }
407
+ return;
408
+ }
409
+
410
+ if (sub === "show") {
411
+ const runId = args[1];
412
+ if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
413
+ const res = await adapter.calibrateShow(runId);
414
+ if (ctx.isJson) {
415
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
416
+ } else {
417
+ renderTargetBanner(ctx.target);
418
+ console.log(JSON.stringify(res, null, 2));
419
+ }
420
+ return;
421
+ }
422
+
423
+ if (sub === "apply") {
424
+ const runId = args[1];
425
+ if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
426
+ const res = await adapter.calibrateApply(runId);
427
+ if (ctx.isJson) {
428
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target, data: res })));
429
+ } else {
430
+ renderTargetBanner(ctx.target);
431
+ console.log(`Applied calibration run "${runId}"`);
432
+ }
433
+ return;
434
+ }
435
+
436
+ if (sub === "generate-dataset") {
437
+ await runCalibrateGenerateDataset(args.slice(1));
438
+ return;
439
+ }
440
+
441
+ throw new Error("usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]");
60
442
  }
61
443
 
62
- async function runIndex(): Promise<void> {
63
- const config = await loadConfig();
64
- configure({ config, clients: createClients(config) });
444
+ async function handleCompletionsCommand(shell: string) {
445
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
446
+ throw new Error("usage: skillmux completions <bash|zsh|fish>");
447
+ }
448
+ console.log(generateCompletions(shell as ShellType));
449
+ }
65
450
 
66
- const report = await rebuildIndex((skillId, error) => {
67
- console.error(
68
- `warning: keeping previous index entry for ${skillId}: ${error}`,
69
- );
451
+ function handleError(
452
+ err: any,
453
+ opts: { target: ResolvedTarget; isJson: boolean; isVerbose: boolean }
454
+ ) {
455
+ const code = mapExitCode(err);
456
+ process.exitCode = code;
457
+
458
+ const msg = err instanceof Error ? err.message : String(err);
459
+
460
+ if (opts.isJson) {
461
+ const env = formatJsonEnvelope({
462
+ ok: false,
463
+ target: opts.target,
464
+ error: { code: `EXIT_${code}`, message: msg },
70
465
  });
71
- const retainedNote =
72
- report.retained.length > 0
73
- ? ` (${report.retained.length} retained after parse errors)`
74
- : "";
75
- console.log(`indexed ${report.indexed} skills${retainedNote}`);
466
+ console.log(JSON.stringify(env));
467
+ } else {
468
+ console.error(msg.startsWith("usage:") || msg.startsWith("Unknown") || msg.startsWith("error:") ? msg : `error: ${msg}`);
469
+ if (opts.isVerbose && err instanceof Error && err.stack) {
470
+ console.error(err.stack);
471
+ }
472
+ }
473
+ }
76
474
 
77
- try {
78
- const backfilled = await backfillEmbeddings();
79
- console.log(`embeddings: ${backfilled} backfilled`);
80
- } catch {
81
- console.log(
82
- "embeddings: skipped (endpoint unreachable; lexical-only recall until next index)",
83
- );
475
+ function printHelp(): void {
476
+ console.log(`usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset> [--transport stdio|http] [--port N] [--dry-run|--restore-monolith|--install-hook] [--target name --yes] [--server url|--db path] --since window [<path>] [--format text|json] [--fail-on low|medium|high] [<repo>[/path] [--force]] [--vault path] [--out file]`);
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Implementation of commands: serve, index, sync, init, report, scan, install, eval, doctor, models
481
+ // ---------------------------------------------------------------------------
482
+
483
+ type Transport = "stdio" | "http";
484
+
485
+ function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
486
+ let transport: Transport = "stdio";
487
+ let port: number | undefined;
488
+ for (let i = 0; i < args.length; i++) {
489
+ const option = args[i];
490
+ const value = args[i + 1];
491
+ if (option === "--transport") {
492
+ if (value !== "stdio" && value !== "http") {
493
+ throw new Error("--transport must be stdio or http");
494
+ }
495
+ transport = value;
496
+ i++;
497
+ } else if (option === "--port") {
498
+ const parsed = Number(value);
499
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
500
+ throw new Error("--port must be an integer between 0 and 65535");
501
+ }
502
+ port = parsed;
503
+ i++;
504
+ } else {
505
+ throw new Error(`unknown serve option: ${option}`);
84
506
  }
507
+ }
508
+ return { transport, port };
85
509
  }
86
510
 
87
- async function runEval(): Promise<void> {
88
- const config = await loadConfig();
89
- configure({ config, clients: createClients(config) });
511
+ async function runIndex(): Promise<void> {
512
+ const config = await loadConfig();
513
+ configure({ config, clients: createClients(config) });
514
+ const report = await rebuildIndex((skillId, error) => {
515
+ console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
516
+ });
517
+ const retainedNote =
518
+ report.retained.length > 0
519
+ ? ` (${report.retained.length} retained after parse errors)`
520
+ : "";
521
+ console.log(`indexed ${report.indexed} skills${retainedNote}`);
522
+
523
+ try {
524
+ const backfilled = await backfillEmbeddings();
525
+ console.log(`embeddings: ${backfilled} backfilled`);
526
+ } catch {
527
+ console.log("embeddings: skipped (endpoint unreachable; lexical-only recall until next index)");
528
+ }
529
+ }
90
530
 
91
- const report = await evalVault().catch((error: unknown) => {
92
- throw new Error(`eval requires local embeddings: ${String(error)}`);
93
- });
94
- console.log(`holdout queries: ${report.queries}`);
95
- console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
96
- console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
97
- console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
98
- console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
99
- console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
100
- console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
531
+ async function runEval(): Promise<void> {
532
+ const config = await loadConfig();
533
+ configure({ config, clients: createClients(config) });
534
+
535
+ const report = await evalVault().catch((error: unknown) => {
536
+ throw new Error(`eval requires local embeddings: ${String(error)}`);
537
+ });
538
+ console.log(`holdout queries: ${report.queries}`);
539
+ console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
540
+ console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
541
+ console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
542
+ console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
543
+ console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
544
+ console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
101
545
  }
102
546
 
103
547
  async function runDoctor(): Promise<void> {
104
- const report = await diagnose(await loadConfig());
105
- console.log(`inference mode: ${report.mode}`);
106
- console.log(`routing capability: ${report.capability}`);
107
- for (const check of report.checks)
108
- console.log(
109
- `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
110
- );
111
- if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
548
+ const report = await diagnose(await loadConfig());
549
+ console.log(`inference mode: ${report.mode}`);
550
+ console.log(`routing capability: ${report.capability}`);
551
+ for (const check of report.checks)
552
+ console.log(`${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`);
553
+ if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
112
554
  }
113
555
 
114
- async function showConfig(): Promise<void> {
115
- const config = await loadConfig();
116
- const inference =
117
- config.inference.mode === "local"
118
- ? {
119
- ...config.inference,
120
- models_dir: expandHome(config.inference.models_dir),
121
- }
122
- : config.inference;
123
- console.log(
124
- JSON.stringify(
125
- {
126
- ...config,
127
- vault_path: expandHome(config.vault_path),
128
- state_dir: expandHome(config.state_dir),
129
- inference,
130
- },
131
- null,
132
- 2,
133
- ),
134
- );
556
+ async function runWhich(args: string[]): Promise<void> {
557
+ const skillId = args[0];
558
+ if (!skillId) throw new Error("usage: skillmux which <skill_id>");
559
+ const config = await loadConfig();
560
+ const vaultPath = expandHome(config.vault_path);
561
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
562
+ const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter((root) =>
563
+ existsSync(join(root, skillId, "SKILL.md")),
564
+ );
565
+ if (roots.length === 0) {
566
+ console.log(`${skillId}: not found in vault_path or local_vault_paths`);
567
+ process.exitCode = 1;
568
+ return;
569
+ }
570
+ console.log(`${skillId}: serving from ${roots[0]}`);
571
+ for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
572
+ }
573
+
574
+ const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--repo <path>...])";
575
+
576
+ function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; repos: string[] } {
577
+ const skillId = args[0];
578
+ if (!skillId) throw new Error(MANIFEST_USAGE);
579
+ let core = false;
580
+ let project: string | undefined;
581
+ const repos: string[] = [];
582
+ for (let i = 1; i < args.length; i++) {
583
+ const arg = args[i];
584
+ if (arg === "--core") core = true;
585
+ else if (arg === "--project") {
586
+ const value = args[++i];
587
+ if (!value) throw new Error("--project requires a group name");
588
+ project = value;
589
+ } else if (arg === "--repo") {
590
+ const value = args[++i];
591
+ if (!value) throw new Error("--repo requires a path");
592
+ repos.push(value);
593
+ } else throw new Error(`unknown manifest option: ${arg}`);
594
+ }
595
+ if (core === (project !== undefined)) throw new Error(MANIFEST_USAGE);
596
+ return { skillId, core, project, repos };
597
+ }
598
+
599
+ async function runManifest(subCommand: string, args: string[]): Promise<void> {
600
+ if (subCommand !== "pin" && subCommand !== "unpin") throw new Error(MANIFEST_USAGE);
601
+ const { skillId, core, project, repos } = parseManifestPinArgs(args);
602
+ const config = await loadConfig();
603
+ const vaultPath = expandHome(config.vault_path);
604
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
605
+ const manifestPath = resolveManifestPath(vaultPath);
606
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}`);
607
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
608
+
609
+ let updated;
610
+ if (core) {
611
+ updated = subCommand === "pin" ? pinCore(manifest, skillId) : unpinCore(manifest, skillId);
612
+ } else {
613
+ updated =
614
+ subCommand === "pin"
615
+ ? pinProject(manifest, skillId, project!, repos)
616
+ : unpinProject(manifest, skillId, project!);
617
+ }
618
+ validateManifest(updated, vaultPath, localVaultPaths);
619
+ await Bun.write(manifestPath, serializeManifest(updated));
620
+ console.log(`${subCommand === "pin" ? "pinned" : "unpinned"} "${skillId}" ${core ? "[core]" : `[project.${project}]`}`);
621
+ }
622
+
623
+ async function runLocalVaultInit(args: string[]): Promise<void> {
624
+ const path = args[0];
625
+ if (!path) throw new Error("usage: skillmux local-vault init <path>");
626
+ const expanded = expandHome(path);
627
+ const config = await loadConfig();
628
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
629
+ if (!localVaultPaths.includes(expanded)) {
630
+ throw new Error(`"${path}" is not one of the configured local_vault_paths — add it to config.toml first`);
631
+ }
632
+ if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
633
+ writeLocalVaultMarker(expanded, expandHome(config.vault_path));
634
+ console.log(`wrote ${join(expanded, ".skillmux")} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`);
135
635
  }
136
636
 
137
637
  async function runModelDownload(): Promise<void> {
138
- const cacheDir = await downloadLocalModels(await loadConfig());
139
- console.log(`models ready in ${cacheDir}`);
638
+ const cacheDir = await downloadLocalModels(await loadConfig());
639
+ console.log(`models ready in ${cacheDir}`);
140
640
  }
141
641
 
142
642
  function parseSyncArgs(args: string[]): {
143
- dryRun: boolean;
144
- restoreMonolith: boolean;
145
- installHook: boolean;
643
+ dryRun: boolean;
644
+ restoreMonolith: boolean;
645
+ installHook: boolean;
146
646
  } {
147
- let dryRun = false;
148
- let restoreMonolith = false;
149
- let installHook = false;
150
- for (const arg of args) {
151
- if (arg === "--dry-run") dryRun = true;
152
- else if (arg === "--restore-monolith") restoreMonolith = true;
153
- else if (arg === "--install-hook") installHook = true;
154
- else throw new Error(`unknown sync option: ${arg}`);
155
- }
156
- return { dryRun, restoreMonolith, installHook };
647
+ let dryRun = false;
648
+ let restoreMonolith = false;
649
+ let installHook = false;
650
+ for (const arg of args) {
651
+ if (arg === "--dry-run") dryRun = true;
652
+ else if (arg === "--restore-monolith") restoreMonolith = true;
653
+ else if (arg === "--install-hook") installHook = true;
654
+ else throw new Error(`unknown sync option: ${arg}`);
655
+ }
656
+ return { dryRun, restoreMonolith, installHook };
157
657
  }
158
658
 
159
659
  async function runSync(args: string[]): Promise<void> {
160
- const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
161
- const config = await loadConfig();
162
- const vaultPath = expandHome(config.vault_path);
163
-
164
- if (installHook) {
165
- const result = installPostMergeHook(vaultPath);
166
- console.log(
167
- result.installed
168
- ? "installed post-merge hook"
169
- : "post-merge hook already installed",
170
- );
171
- }
660
+ const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
661
+ const config = await loadConfig();
662
+ const vaultPath = expandHome(config.vault_path);
172
663
 
173
- const manifestPath = resolveManifestPath(vaultPath);
174
- if (!manifestPath) {
175
- console.log("no skillmux.toml found at vault root — nothing to sync");
176
- return;
664
+ if (installHook) {
665
+ const result = installPostMergeHook(vaultPath);
666
+ console.log(
667
+ result.installed
668
+ ? "installed post-merge hook"
669
+ : "post-merge hook already installed",
670
+ );
671
+ }
672
+
673
+ const manifestPath = resolveManifestPath(vaultPath);
674
+ if (!manifestPath) {
675
+ console.log("no skillmux.toml found at vault root — nothing to sync");
676
+ return;
677
+ }
678
+
679
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
680
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
681
+ const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
682
+ for (const note of notes) console.log(`note: ${note}`);
683
+
684
+ for (const [targetName, target] of Object.entries(manifest.targets)) {
685
+ const targetDir = expandHome(target.dir);
686
+
687
+ if (restoreMonolith) {
688
+ const result = restoreMonolithTarget(targetDir, vaultPath);
689
+ console.log(
690
+ result.restored
691
+ ? `${targetName}: restored to a vault symlink`
692
+ : `${targetName}: not owned by skillmux, left untouched`,
693
+ );
694
+ continue;
177
695
  }
178
696
 
179
- const manifest = parseManifest(await Bun.file(manifestPath).text());
180
- const vaultSkillIds = new Set(
181
- (await scanVault(vaultPath)).map((skill) => skill.skill_id),
697
+ const suffix = dryRun ? " (dry-run)" : "";
698
+ const result = syncTarget(
699
+ { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills, localVaultPaths },
700
+ { dryRun },
182
701
  );
183
- const { notes } = validateManifest(manifest, vaultSkillIds);
184
- for (const note of notes) console.log(`note: ${note}`);
185
-
186
- for (const [targetName, target] of Object.entries(manifest.targets)) {
187
- const targetDir = expandHome(target.dir);
188
-
189
- if (restoreMonolith) {
190
- const result = restoreMonolithTarget(targetDir, vaultPath);
191
- console.log(
192
- result.restored
193
- ? `${targetName}: restored to a vault symlink`
194
- : `${targetName}: not owned by skillmux, left untouched`,
195
- );
196
- continue;
197
- }
198
-
199
- const suffix = dryRun ? " (dry-run)" : "";
200
- const result = syncTarget(
201
- { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills },
202
- { dryRun },
702
+ console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
703
+
704
+ if (target.project_groups.length > 0) {
705
+ const allGroups = manifest.project ?? {};
706
+ const projectGroups = Object.fromEntries(
707
+ target.project_groups.map((name) => [name, allGroups[name]!]),
708
+ );
709
+ const projectResults = syncProjectTargets(
710
+ { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
711
+ { dryRun },
712
+ );
713
+ for (const projectResult of projectResults) {
714
+ console.log(
715
+ ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
203
716
  );
204
- console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
205
-
206
- if (target.project) {
207
- const projectResults = syncProjectTargets(
208
- { vaultPath, targetDir, targetName, projectGroups: manifest.project ?? {} },
209
- { dryRun },
210
- );
211
- for (const projectResult of projectResults) {
212
- console.log(
213
- ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
214
- );
215
- }
216
- }
717
+ }
217
718
  }
719
+ }
218
720
  }
219
721
 
220
722
  function parseInitArgs(args: string[]): { targets: string[]; yes: boolean } {
221
- const targets: string[] = [];
222
- let yes = false;
223
- for (let i = 0; i < args.length; i++) {
224
- const option = args[i];
225
- if (option === "--target") {
226
- const value = args[i + 1];
227
- if (!value) throw new Error("--target requires a name");
228
- targets.push(value);
229
- i++;
230
- } else if (option === "--yes") {
231
- yes = true;
232
- } else {
233
- throw new Error(`unknown init option: ${option}`);
234
- }
235
- }
236
- return { targets, yes };
723
+ const targets: string[] = [];
724
+ let yes = false;
725
+ for (let i = 0; i < args.length; i++) {
726
+ const option = args[i];
727
+ if (option === "--target") {
728
+ const value = args[i + 1];
729
+ if (!value) throw new Error("--target requires a name");
730
+ targets.push(value);
731
+ i++;
732
+ } else if (option === "--yes") {
733
+ yes = true;
734
+ } else {
735
+ throw new Error(`unknown init option: ${option}`);
736
+ }
737
+ }
738
+ return { targets, yes };
237
739
  }
238
740
 
239
741
  async function runInit(args: string[]): Promise<void> {
240
- const { targets: requestedTargets, yes } = parseInitArgs(args);
241
- const config = await loadConfig();
242
- const vaultPath = expandHome(config.vault_path);
243
-
244
- const candidates = detectSurfaces(surfaceCandidates().map(expandHome));
245
- for (const candidate of candidates) {
246
- const name = deriveTargetName(candidate.path);
247
- if (!candidate.exists) {
248
- console.log(`${name} (${candidate.path}): not found`);
249
- continue;
250
- }
251
- const kind = candidate.isSymlink ? "symlink" : "real dir";
252
- const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
253
- console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
254
- }
255
-
256
- if (requestedTargets.length === 0) {
257
- console.log("\nno --target specified — nothing written. Re-run with --target <name> --yes to adopt a surface.");
258
- return;
259
- }
260
-
261
- if (!yes) {
262
- throw new Error(
263
- "usage: skillmux init --target <name> [--target <name>...] --yes (interactive per-target confirm is not available non-interactively)",
264
- );
742
+ const { targets: requestedTargets, yes } = parseInitArgs(args);
743
+ const config = await loadConfig();
744
+ const vaultPath = expandHome(config.vault_path);
745
+
746
+ const candidates = detectSurfaces(surfaceCandidates().map(expandHome));
747
+ for (const candidate of candidates) {
748
+ const name = deriveTargetName(candidate.path);
749
+ if (!candidate.exists) {
750
+ console.log(`${name} (${candidate.path}): not found`);
751
+ continue;
265
752
  }
266
-
267
- const byName = new Map(
268
- candidates.filter((c) => c.exists).map((c) => [deriveTargetName(c.path), c] as const),
753
+ const kind = candidate.isSymlink ? "symlink" : "real dir";
754
+ const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
755
+ console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
756
+ }
757
+
758
+ if (requestedTargets.length === 0) {
759
+ console.log("\nno --target specified — nothing written. Re-run with --target <name> --yes to adopt a surface.");
760
+ return;
761
+ }
762
+
763
+ if (!yes) {
764
+ throw new Error(
765
+ "usage: skillmux init --target <name> [--target <name>...] --yes (interactive per-target confirm is not available non-interactively)",
269
766
  );
270
- for (const name of requestedTargets) {
271
- if (!byName.has(name)) throw new Error(`unknown --target "${name}": not among detected surfaces`);
272
- }
767
+ }
273
768
 
274
- const confirmedTargets = requestedTargets.map((name) => ({ name, dir: byName.get(name)!.path }));
275
- applyInit(vaultPath, confirmedTargets);
769
+ const byName = new Map(
770
+ candidates.filter((c) => c.exists).map((c) => [deriveTargetName(c.path), c] as const),
771
+ );
772
+ for (const name of requestedTargets) {
773
+ if (!byName.has(name)) throw new Error(`unknown --target "${name}": not among detected surfaces`);
774
+ }
276
775
 
277
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
278
- console.log(`run "skillmux sync" next to materialize [core] skills into these targets.\n`);
279
- console.log(printLastMile());
776
+ const confirmedTargets = requestedTargets.map((name) => ({ name, dir: byName.get(name)!.path }));
777
+ applyInit(vaultPath, confirmedTargets);
778
+
779
+ console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
780
+ console.log(`run "skillmux sync" next to materialize [core] skills into these targets.\n`);
781
+ console.log(printLastMile());
280
782
  }
281
783
 
282
784
  function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
283
- let server: string | undefined;
284
- let db: string | undefined;
285
- let since: string | undefined;
286
- for (let i = 0; i < args.length; i++) {
287
- const option = args[i];
288
- const value = args[i + 1];
289
- if (option === "--server") {
290
- if (!value) throw new Error("--server requires a URL");
291
- server = value;
292
- i++;
293
- } else if (option === "--db") {
294
- if (!value) throw new Error("--db requires a path");
295
- db = value;
296
- i++;
297
- } else if (option === "--since") {
298
- if (!value) throw new Error("--since requires a window");
299
- since = value;
300
- i++;
301
- } else {
302
- throw new Error(`unknown report option: ${option}`);
303
- }
304
- }
305
- if (server && db) throw new Error("--server and --db are mutually exclusive");
306
- return { server, db, since };
785
+ let server: string | undefined;
786
+ let db: string | undefined;
787
+ let since: string | undefined;
788
+ for (let i = 0; i < args.length; i++) {
789
+ const option = args[i];
790
+ const value = args[i + 1];
791
+ if (option === "--server") {
792
+ if (!value) throw new Error("--server requires a URL");
793
+ server = value;
794
+ i++;
795
+ } else if (option === "--db") {
796
+ if (!value) throw new Error("--db requires a path");
797
+ db = value;
798
+ i++;
799
+ } else if (option === "--since") {
800
+ if (!value) throw new Error("--since requires a window");
801
+ since = value;
802
+ i++;
803
+ } else {
804
+ throw new Error(`unknown report option: ${option}`);
805
+ }
806
+ }
807
+ if (server && db) throw new Error("--server and --db are mutually exclusive");
808
+ return { server, db, since };
307
809
  }
308
810
 
309
811
  async function runReport(args: string[]): Promise<void> {
310
- const { server, db: dbPath, since } = parseReportArgs(args);
311
- if (!since) throw new Error("usage: skillmux report [--server <url> | --db <path>] --since <window>");
312
-
313
- if (server) {
314
- const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
315
- const res = await fetch(url);
316
- if (!res.ok) throw new Error(`skillmux report --server failed: ${res.status} ${await res.text()}`);
317
- console.log(renderStatsText((await res.json()) as StatsResponse));
318
- return;
319
- }
320
-
321
- const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
322
- console.log(renderStatsText(getStats(db, since)));
323
- db.close();
812
+ const { server, db: dbPath, since } = parseReportArgs(args);
813
+ if (!since) throw new Error("usage: skillmux report [--server <url> | --db <path>] --since <window>");
814
+
815
+ if (server) {
816
+ const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
817
+ const res = await fetch(url);
818
+ if (!res.ok) throw new Error(`skillmux report --server failed: ${res.status} ${await res.text()}`);
819
+ console.log(renderStatsText((await res.json()) as StatsResponse));
820
+ return;
821
+ }
822
+
823
+ const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
824
+ console.log(renderStatsText(getStats(db, since)));
825
+ db.close();
324
826
  }
325
827
 
326
828
  function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"; failOn?: ScanSeverity } {
327
- let path: string | undefined;
328
- let format: "text" | "json" = "text";
329
- let failOn: ScanSeverity | undefined;
330
- for (let i = 0; i < args.length; i++) {
331
- const option = args[i];
332
- if (option === "--format") {
333
- const value = args[++i];
334
- if (value !== "text" && value !== "json") throw new Error("--format must be text or json");
335
- format = value;
336
- } else if (option === "--fail-on") {
337
- const value = args[++i];
338
- if (value !== "low" && value !== "medium" && value !== "high") {
339
- throw new Error("--fail-on must be low, medium, or high");
340
- }
341
- failOn = value;
342
- } else if (option?.startsWith("--")) {
343
- throw new Error(`unknown scan option: ${option}`);
344
- } else if (path !== undefined) {
345
- throw new Error("skillmux scan accepts at most one <path> argument");
346
- } else {
347
- path = option;
348
- }
349
- }
350
- return { path, format, failOn };
829
+ let path: string | undefined;
830
+ let format: "text" | "json" = "text";
831
+ let failOn: ScanSeverity | undefined;
832
+ for (let i = 0; i < args.length; i++) {
833
+ const option = args[i];
834
+ if (option === "--format") {
835
+ const value = args[++i];
836
+ if (value !== "text" && value !== "json") throw new Error("--format must be text or json");
837
+ format = value;
838
+ } else if (option === "--fail-on") {
839
+ const value = args[++i];
840
+ if (value !== "low" && value !== "medium" && value !== "high") {
841
+ throw new Error("--fail-on must be low, medium, or high");
842
+ }
843
+ failOn = value;
844
+ } else if (option?.startsWith("--")) {
845
+ throw new Error(`unknown scan option: ${option}`);
846
+ } else if (path !== undefined) {
847
+ throw new Error("skillmux scan accepts at most one <path> argument");
848
+ } else {
849
+ path = option;
850
+ }
851
+ }
852
+ return { path, format, failOn };
351
853
  }
352
854
 
353
855
  async function runScan(args: string[]): Promise<void> {
354
- const { path, format, failOn } = parseScanArgs(args);
355
- const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
356
- const result = await scanPath(rootPath);
357
- console.log(format === "json" ? renderScanJson(result) : renderScanText(result));
358
- process.exitCode = scanExitCode(result.findings, failOn);
856
+ const { path, format, failOn } = parseScanArgs(args);
857
+ const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
858
+ const result = await scanPath(rootPath);
859
+ console.log(format === "json" ? renderScanJson(result) : renderScanText(result));
860
+ process.exitCode = scanExitCode(result.findings, failOn);
359
861
  }
360
862
 
361
863
  function parseInstallArgs(args: string[]): {
362
- repo?: string;
363
- force: boolean;
364
- dryRun: boolean;
365
- failOn?: ScanSeverity;
864
+ repo?: string;
865
+ force: boolean;
866
+ dryRun: boolean;
867
+ failOn?: ScanSeverity;
366
868
  } {
367
- let repo: string | undefined;
368
- let force = false;
369
- let dryRun = false;
370
- let failOn: ScanSeverity | undefined;
371
- for (let i = 0; i < args.length; i++) {
372
- const option = args[i];
373
- if (option === "--force") force = true;
374
- else if (option === "--dry-run") dryRun = true;
375
- else if (option === "--fail-on") {
376
- const value = args[++i];
377
- if (value !== "low" && value !== "medium" && value !== "high") {
378
- throw new Error("--fail-on must be low, medium, or high");
379
- }
380
- failOn = value;
381
- } else if (option?.startsWith("--")) {
382
- throw new Error(`unknown install option: ${option}`);
383
- } else if (repo !== undefined) {
384
- throw new Error("skillmux install accepts at most one <repo> argument");
385
- } else {
386
- repo = option;
387
- }
388
- }
389
- return { repo, force, dryRun, failOn };
869
+ let repo: string | undefined;
870
+ let force = false;
871
+ let dryRun = false;
872
+ let failOn: ScanSeverity | undefined;
873
+ for (let i = 0; i < args.length; i++) {
874
+ const option = args[i];
875
+ if (option === "--force") force = true;
876
+ else if (option === "--dry-run") dryRun = true;
877
+ else if (option === "--fail-on") {
878
+ const value = args[++i];
879
+ if (value !== "low" && value !== "medium" && value !== "high") {
880
+ throw new Error("--fail-on must be low, medium, or high");
881
+ }
882
+ failOn = value;
883
+ } else if (option?.startsWith("--")) {
884
+ throw new Error(`unknown install option: ${option}`);
885
+ } else if (repo !== undefined) {
886
+ throw new Error("skillmux install accepts at most one <repo> argument");
887
+ } else {
888
+ repo = option;
889
+ }
890
+ }
891
+ return { repo, force, dryRun, failOn };
390
892
  }
391
893
 
392
894
  async function runInstall(args: string[]): Promise<void> {
393
- const { repo, force, dryRun, failOn } = parseInstallArgs(args);
394
- if (!repo) {
395
- throw new Error("usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run]");
895
+ const { repo, force, dryRun, failOn } = parseInstallArgs(args);
896
+ if (!repo) {
897
+ throw new Error("usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run]");
898
+ }
899
+
900
+ const source = resolveRepoSource(repo);
901
+ const cloneDir = await cloneToTemp(source.url);
902
+ try {
903
+ const resolved = resolveSkillDir(cloneDir, deriveRepoName(source.url), source.skillPath);
904
+ const { findings } = await validateSkillCandidate(resolved.skillId, resolved.dir);
905
+ console.log(renderScanText({ scanned: 1, findings }));
906
+
907
+ if (scanExitCode(findings, failOn) !== 0) {
908
+ process.exitCode = 1;
909
+ console.error(`aborting install: a finding met the --fail-on ${failOn} threshold`);
910
+ return;
396
911
  }
397
912
 
398
- const source = resolveRepoSource(repo);
399
- const cloneDir = await cloneToTemp(source.url);
400
- try {
401
- const resolved = resolveSkillDir(cloneDir, deriveRepoName(source.url), source.skillPath);
402
- const { findings } = await validateSkillCandidate(resolved.skillId, resolved.dir);
403
- console.log(renderScanText({ scanned: 1, findings }));
404
-
405
- if (scanExitCode(findings, failOn) !== 0) {
406
- process.exitCode = 1;
407
- console.error(`aborting install: a finding met the --fail-on ${failOn} threshold`);
408
- return;
409
- }
410
-
411
- const vaultPath = expandHome((await loadConfig()).vault_path);
412
- if (dryRun) {
413
- console.log(`dry-run: would install "${resolved.skillId}" into ${join(vaultPath, resolved.skillId)}`);
414
- return;
415
- }
416
-
417
- const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
418
- console.log(`installed "${resolved.skillId}" into ${targetDir}`);
419
- } finally {
420
- rmSync(cloneDir, { recursive: true, force: true });
913
+ const vaultPath = expandHome((await loadConfig()).vault_path);
914
+ if (dryRun) {
915
+ console.log(`dry-run: would install "${resolved.skillId}" into ${join(vaultPath, resolved.skillId)}`);
916
+ return;
421
917
  }
918
+
919
+ const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
920
+ console.log(`installed "${resolved.skillId}" into ${targetDir}`);
921
+ } finally {
922
+ rmSync(cloneDir, { recursive: true, force: true });
923
+ }
422
924
  }
423
925
 
424
- switch (command) {
425
- case "serve": {
426
- const { startServer } = await import("./server");
427
- const { transport, port } = parseServeArgs(Bun.argv.slice(3));
428
- const handle = await startServer({ transport, port });
429
- let stopping = false;
430
- const shutdown = async () => {
431
- if (stopping) return;
432
- stopping = true;
433
- const timeout = setTimeout(() => process.exit(1), 10_000);
434
- timeout.unref();
435
- await handle.stop();
436
- clearTimeout(timeout);
437
- process.exit(0);
438
- };
439
- process.once("SIGTERM", shutdown);
440
- process.once("SIGINT", shutdown);
441
- break;
926
+ function parseCalibrateGenerateDatasetArgs(args: string[]): { vault?: string; out?: string } {
927
+ let vault: string | undefined;
928
+ let out: string | undefined;
929
+ for (let i = 0; i < args.length; i++) {
930
+ const option = args[i];
931
+ if (option === "--vault") {
932
+ const value = args[++i];
933
+ if (!value) throw new Error("--vault requires a path value");
934
+ vault = value;
935
+ } else if (option === "--out") {
936
+ const value = args[++i];
937
+ if (!value) throw new Error("--out requires a file path value");
938
+ out = value;
939
+ } else {
940
+ throw new Error(`unknown calibrate option: ${option}`);
442
941
  }
443
- case "index":
444
- await runIndex();
445
- break;
446
- case "sync":
447
- await runSync(Bun.argv.slice(3));
448
- break;
449
- case "init":
450
- await runInit(Bun.argv.slice(3));
451
- break;
452
- case "report":
453
- await runReport(Bun.argv.slice(3));
454
- break;
455
- case "scan":
456
- await runScan(Bun.argv.slice(3));
457
- break;
458
- case "install":
459
- await runInstall(Bun.argv.slice(3));
460
- break;
461
- case "eval":
462
- await runEval();
463
- break;
464
- case "doctor":
465
- await runDoctor();
466
- break;
467
- case "config":
468
- if (Bun.argv[3] !== "show")
469
- throw new Error("usage: skillmux config show");
470
- await showConfig();
471
- break;
472
- case "models":
473
- if (Bun.argv[3] !== "download")
474
- throw new Error("usage: skillmux models download");
475
- await runModelDownload();
476
- break;
477
- default:
478
- console.error(
479
- "usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|config show|models download> [--transport stdio|http] [--port N] [--dry-run|--restore-monolith|--install-hook] [--target name --yes] [--server url|--db path] --since window [<path>] [--format text|json] [--fail-on low|medium|high] [<repo>[/path] [--force]]",
480
- );
481
- process.exit(2);
942
+ }
943
+ return { vault, out };
944
+ }
945
+
946
+ async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
947
+ const { vault: vaultArg, out: outArg } = parseCalibrateGenerateDatasetArgs(args);
948
+ const config = await loadConfig();
949
+ const vaultPath = expandHome(vaultArg ?? config.vault_path);
950
+ const outPath = expandHome(outArg ?? join(config.state_dir, "queries.json"));
951
+
952
+ const skills = await scanVault(vaultPath);
953
+ const dataset = generateDataset(skills);
954
+
955
+ const parentDir = join(outPath, "..");
956
+ mkdirSync(parentDir, { recursive: true });
957
+ await Bun.write(outPath, JSON.stringify(dataset, null, 2) + "\n");
958
+ console.log(`generated synthetic dataset with ${dataset.length} cases at ${outPath}`);
959
+ }
960
+
961
+ if (import.meta.main) {
962
+ await main();
482
963
  }