@klhapp/skillmux 1.9.3 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +74 -3
- package/docs/concepts.md +1 -1
- package/docs/configuration.md +1 -1
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +1 -1
- package/docs/skill-management.md +6 -0
- package/package.json +1 -1
- package/src/adapters.ts +148 -2
- package/src/cli.ts +266 -1299
- package/src/commands/audit.ts +53 -56
- package/src/commands/config.ts +11 -12
- package/src/commands/context.ts +103 -0
- package/src/commands/core.ts +5 -1
- package/src/commands/doctor.ts +76 -0
- package/src/commands/eval.ts +10 -13
- package/src/commands/init.ts +621 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +2 -1
- package/src/commands/project.ts +37 -11
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/skill.ts +32 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +18 -6
- package/src/commands/update.ts +2 -1
- package/src/config-service.ts +1 -51
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -521
- package/src/global-flags.ts +46 -0
- package/src/logger.ts +26 -0
- package/src/output.ts +30 -5
- package/src/router-core.ts +8 -27
- package/src/server.ts +160 -13
- package/src/toml-writer.ts +51 -0
package/src/cli.ts
CHANGED
|
@@ -1,127 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import packageJson from "../package.json" with { type: "json" };
|
|
3
|
-
import {
|
|
4
|
-
import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
|
|
5
|
-
import { hostname } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
3
|
+
import { lstatSync, mkdirSync } from "node:fs";
|
|
7
4
|
|
|
8
5
|
import { createClients } from "./clients";
|
|
9
|
-
import {
|
|
10
|
-
expandHome,
|
|
11
|
-
loadConfig,
|
|
12
|
-
migrateLegacyPaths,
|
|
13
|
-
resolveConfigPath,
|
|
14
|
-
} from "./config";
|
|
6
|
+
import { loadConfig } from "./config";
|
|
15
7
|
import { openAudit } from "./db";
|
|
16
|
-
import { diagnose } from "./doctor";
|
|
17
8
|
import { getEffectiveConfig } from "./config-service";
|
|
18
9
|
import { buildRedactor } from "./redact";
|
|
19
10
|
import { evalVault } from "./eval";
|
|
20
|
-
import {
|
|
21
|
-
assessClientReadiness,
|
|
22
|
-
detectInstalledClients,
|
|
23
|
-
planClientSurfaces,
|
|
24
|
-
resolveBuiltInTarget,
|
|
25
|
-
SUPPORTED_CLIENT_IDS,
|
|
26
|
-
type ClientId,
|
|
27
|
-
type ReadinessAxis,
|
|
28
|
-
} from "./init-clients";
|
|
29
|
-
import {
|
|
30
|
-
applyInstructionPlan,
|
|
31
|
-
planInstructionSetup,
|
|
32
|
-
rollbackInstructionPlan,
|
|
33
|
-
} from "./init-instructions";
|
|
34
|
-
import {
|
|
35
|
-
applyInit,
|
|
36
|
-
deriveTargetName,
|
|
37
|
-
detectSurfaces,
|
|
38
|
-
planInitManifest,
|
|
39
|
-
printLastMile,
|
|
40
|
-
surfaceCandidates,
|
|
41
|
-
} from "./init";
|
|
42
|
-
import {
|
|
43
|
-
assertHostAllowed,
|
|
44
|
-
cloneToTemp,
|
|
45
|
-
deriveRepoName,
|
|
46
|
-
installIntoVault,
|
|
47
|
-
isLocalFileUrl,
|
|
48
|
-
resolveCloneCommit,
|
|
49
|
-
resolveRepoSource,
|
|
50
|
-
resolveSkillDir,
|
|
51
|
-
validateSkillCandidate,
|
|
52
|
-
} from "./install";
|
|
53
11
|
import { runOutdated } from "./commands/outdated";
|
|
54
12
|
import { runUpdate } from "./commands/update";
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
parseManifest,
|
|
58
|
-
resolveManifestPath,
|
|
59
|
-
serializeManifest,
|
|
60
|
-
validateManifest,
|
|
61
|
-
} from "./manifest";
|
|
62
|
-
import { downloadLocalModels } from "./models";
|
|
13
|
+
import { serializeManifest } from "./manifest";
|
|
63
14
|
|
|
64
|
-
import {
|
|
65
|
-
parseCommaList,
|
|
66
|
-
promptMultiSelect,
|
|
67
|
-
promptText,
|
|
68
|
-
shouldUseWizard,
|
|
69
|
-
} from "./prompts";
|
|
70
15
|
import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
|
|
71
|
-
import {
|
|
72
|
-
|
|
73
|
-
renderScanText,
|
|
74
|
-
scanExitCode,
|
|
75
|
-
scanPath,
|
|
76
|
-
type ScanSeverity,
|
|
77
|
-
} from "./scan";
|
|
78
|
-
import {
|
|
79
|
-
applyConfigInit,
|
|
80
|
-
inspectVault,
|
|
81
|
-
planConfigInit,
|
|
82
|
-
rollbackConfigInit,
|
|
83
|
-
type ConfigInitPlan,
|
|
84
|
-
} from "./setup";
|
|
85
|
-
import { getStats, renderStatsText, type StatsResponse } from "./stats";
|
|
86
|
-
import {
|
|
87
|
-
installPostMergeHook,
|
|
88
|
-
resolveProjectPinDir,
|
|
89
|
-
restoreMonolith as restoreMonolithTarget,
|
|
90
|
-
syncProjectTargets,
|
|
91
|
-
syncTarget,
|
|
92
|
-
writeLocalVaultMarker,
|
|
93
|
-
type ProjectGroupInput,
|
|
94
|
-
} from "./sync";
|
|
95
|
-
import { scanVault, vaultResolutionOrder } from "./vault";
|
|
16
|
+
import { type StatsResponse } from "./stats";
|
|
17
|
+
import { scanVault } from "./vault";
|
|
96
18
|
|
|
97
|
-
import {
|
|
98
|
-
addContext,
|
|
99
|
-
getCurrentContext,
|
|
100
|
-
listContexts,
|
|
101
|
-
removeContext,
|
|
102
|
-
resolveTarget,
|
|
103
|
-
useContext,
|
|
104
|
-
type ResolvedTarget,
|
|
105
|
-
} from "./context";
|
|
19
|
+
import { resolveContext, type ResolvedContext } from "./context";
|
|
106
20
|
import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
|
|
107
21
|
import {
|
|
108
22
|
emitSuccess,
|
|
109
23
|
CliError,
|
|
110
24
|
formatJsonEnvelope,
|
|
111
|
-
isInteractive,
|
|
112
25
|
mapExitCode,
|
|
113
|
-
|
|
114
|
-
renderTargetBanner,
|
|
26
|
+
red,
|
|
115
27
|
suggestCorrection,
|
|
28
|
+
warn,
|
|
116
29
|
} from "./output";
|
|
117
30
|
import { generateCompletions, type ShellType } from "./completions";
|
|
118
31
|
import { runAudit } from "./commands/audit";
|
|
119
32
|
import { handleConfigCommand } from "./commands/config";
|
|
33
|
+
import { handleContextCommand } from "./commands/context";
|
|
34
|
+
import { runDoctor } from "./commands/doctor";
|
|
120
35
|
import { runEvalPromote } from "./commands/eval";
|
|
36
|
+
import { runInstall } from "./commands/install";
|
|
121
37
|
import { runCore } from "./commands/core";
|
|
122
|
-
import {
|
|
123
|
-
import {
|
|
38
|
+
import { runLocalVaultInit } from "./commands/local-vault";
|
|
39
|
+
import { runModelDownload } from "./commands/models";
|
|
40
|
+
import { runProject } from "./commands/project";
|
|
41
|
+
import { runReport } from "./commands/report";
|
|
42
|
+
import { runScan } from "./commands/scan";
|
|
43
|
+
import { runSkill } from "./commands/skill";
|
|
124
44
|
import { runTarget } from "./commands/target";
|
|
45
|
+
import { runSync } from "./commands/sync";
|
|
46
|
+
import { runInit } from "./commands/init";
|
|
125
47
|
|
|
126
48
|
const KNOWN_COMMANDS = [
|
|
127
49
|
"context",
|
|
@@ -147,6 +69,43 @@ const KNOWN_COMMANDS = [
|
|
|
147
69
|
"local-vault",
|
|
148
70
|
];
|
|
149
71
|
|
|
72
|
+
const LOCAL_ONLY_COMMANDS = new Set([
|
|
73
|
+
"install",
|
|
74
|
+
"update",
|
|
75
|
+
"outdated",
|
|
76
|
+
"sync",
|
|
77
|
+
"core",
|
|
78
|
+
"project",
|
|
79
|
+
"target",
|
|
80
|
+
"local-vault",
|
|
81
|
+
"index",
|
|
82
|
+
"models",
|
|
83
|
+
"scan",
|
|
84
|
+
"init",
|
|
85
|
+
"serve",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function getLocalOnlyCommand(command: string, subCommand: string): string | null {
|
|
89
|
+
if (LOCAL_ONLY_COMMANDS.has(command)) {
|
|
90
|
+
return command;
|
|
91
|
+
}
|
|
92
|
+
if (command === "skill" && (subCommand === "which" || !subCommand)) {
|
|
93
|
+
return "skill which";
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function remoteContextUnsupported(rejectedCommand: string): CliError {
|
|
99
|
+
return new CliError(
|
|
100
|
+
`\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here`,
|
|
101
|
+
2,
|
|
102
|
+
"REMOTE_CONTEXT_UNSUPPORTED",
|
|
103
|
+
{
|
|
104
|
+
rejected_command: rejectedCommand,
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
150
109
|
function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
|
|
151
110
|
if (
|
|
152
111
|
[
|
|
@@ -167,7 +126,7 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
|
|
|
167
126
|
}
|
|
168
127
|
|
|
169
128
|
// eval promote only touches the mounted state_dir, unlike bare `eval`
|
|
170
|
-
// (vault ranking evaluation), which needs
|
|
129
|
+
// (vault ranking evaluation), which needs an embeddings client and the vault.
|
|
171
130
|
if (command === "eval" && subCommand !== "promote") return true;
|
|
172
131
|
|
|
173
132
|
return command === "config" && ["init", "set"].includes(subCommand);
|
|
@@ -237,7 +196,7 @@ async function main() {
|
|
|
237
196
|
else if (arg === "--server") flagServer = rawArgv[++i];
|
|
238
197
|
}
|
|
239
198
|
|
|
240
|
-
let resolvedTarget:
|
|
199
|
+
let resolvedTarget: ResolvedContext = { type: "local", name: "local" };
|
|
241
200
|
|
|
242
201
|
if (
|
|
243
202
|
process.env.RUNNING_IN_DOCKER === "true" &&
|
|
@@ -251,23 +210,31 @@ async function main() {
|
|
|
251
210
|
return;
|
|
252
211
|
}
|
|
253
212
|
|
|
254
|
-
// Only resolve target if command is target-aware or context/config
|
|
255
|
-
const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
|
|
256
213
|
if (
|
|
257
|
-
(
|
|
258
|
-
|
|
259
|
-
flagContext ||
|
|
260
|
-
flagServer
|
|
214
|
+
(rawArgv.includes("--help") || rawArgv.includes("-h")) &&
|
|
215
|
+
printCommandHelp(command)
|
|
261
216
|
) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
resolvedTarget = await resolveContext({
|
|
222
|
+
context: flagContext,
|
|
223
|
+
server: flagServer,
|
|
224
|
+
});
|
|
225
|
+
} catch (err: any) {
|
|
226
|
+
await handleError(err, { target: resolvedTarget, isJson, isVerbose });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const localOnlyCommand = getLocalOnlyCommand(command, subCommand);
|
|
231
|
+
if (localOnlyCommand && resolvedTarget.type === "remote") {
|
|
232
|
+
await handleError(remoteContextUnsupported(localOnlyCommand), {
|
|
233
|
+
target: resolvedTarget,
|
|
234
|
+
isJson,
|
|
235
|
+
isVerbose,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
271
238
|
}
|
|
272
239
|
|
|
273
240
|
const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
|
|
@@ -343,10 +310,16 @@ async function main() {
|
|
|
343
310
|
isJson,
|
|
344
311
|
target: resolvedTarget,
|
|
345
312
|
allowInsecure,
|
|
313
|
+
adapter,
|
|
346
314
|
});
|
|
347
315
|
break;
|
|
348
316
|
case "audit":
|
|
349
|
-
await runAudit(subCommand, commandArgs, {
|
|
317
|
+
await runAudit(subCommand, commandArgs, {
|
|
318
|
+
isJson,
|
|
319
|
+
dryRun: isDryRun,
|
|
320
|
+
target: resolvedTarget,
|
|
321
|
+
adapter,
|
|
322
|
+
});
|
|
350
323
|
break;
|
|
351
324
|
case "scan":
|
|
352
325
|
await runScan(rawArgv.slice(1), { isJson });
|
|
@@ -362,15 +335,15 @@ async function main() {
|
|
|
362
335
|
break;
|
|
363
336
|
case "eval":
|
|
364
337
|
if (subCommand === "promote") {
|
|
365
|
-
await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
|
|
338
|
+
await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun, adapter });
|
|
366
339
|
} else if (subCommand === "") {
|
|
367
|
-
await runEval({ isJson });
|
|
340
|
+
await runEval({ isJson, adapter });
|
|
368
341
|
} else {
|
|
369
342
|
throw new Error(`usage: skillmux eval [promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]]`);
|
|
370
343
|
}
|
|
371
344
|
break;
|
|
372
345
|
case "doctor":
|
|
373
|
-
await runDoctor({ isJson });
|
|
346
|
+
await runDoctor({ isJson, target: resolvedTarget, adapter });
|
|
374
347
|
break;
|
|
375
348
|
case "which":
|
|
376
349
|
throw new Error(
|
|
@@ -406,95 +379,7 @@ async function main() {
|
|
|
406
379
|
}
|
|
407
380
|
}
|
|
408
381
|
|
|
409
|
-
async function handleContextCommand(
|
|
410
|
-
sub: string,
|
|
411
|
-
args: string[],
|
|
412
|
-
ctx: { target: ResolvedTarget; isJson: boolean },
|
|
413
|
-
) {
|
|
414
|
-
if (sub === "list") {
|
|
415
|
-
const contexts = await listContexts();
|
|
416
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
|
|
417
|
-
renderTargetBanner(ctx.target);
|
|
418
|
-
renderTable(
|
|
419
|
-
[
|
|
420
|
-
{ key: "name", header: "NAME" },
|
|
421
|
-
{ key: "server", header: "SERVER" },
|
|
422
|
-
{ key: "token_env", header: "TOKEN_ENV" },
|
|
423
|
-
{ key: "isDefault", header: "DEFAULT" },
|
|
424
|
-
],
|
|
425
|
-
contexts.map((c) => ({
|
|
426
|
-
...c,
|
|
427
|
-
token_env: c.token_env ?? "-",
|
|
428
|
-
isDefault: c.isDefault ? "*" : "",
|
|
429
|
-
})),
|
|
430
|
-
);
|
|
431
|
-
});
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
if (sub === "current") {
|
|
436
|
-
const current = await getCurrentContext();
|
|
437
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
|
|
438
|
-
renderTargetBanner(ctx.target);
|
|
439
|
-
console.log(`Current context: ${current.name} (${current.server})`);
|
|
440
|
-
});
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (sub === "add") {
|
|
445
|
-
const name = args[0];
|
|
446
|
-
let server: string | undefined;
|
|
447
|
-
let tokenEnv: string | undefined;
|
|
448
|
-
for (let i = 1; i < args.length; i++) {
|
|
449
|
-
if (args[i] === "--server") server = args[++i];
|
|
450
|
-
else if (args[i] === "--token-env") tokenEnv = args[++i];
|
|
451
|
-
}
|
|
452
|
-
if (!name || !server) {
|
|
453
|
-
throw new Error(
|
|
454
|
-
"usage: skillmux context add <name> --server <url> [--token-env <env_name>]",
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
await addContext(name, { server, token_env: tokenEnv });
|
|
458
|
-
emitSuccess(
|
|
459
|
-
{ isJson: ctx.isJson, target: ctx.target },
|
|
460
|
-
{ name, server, token_env: tokenEnv },
|
|
461
|
-
() => {
|
|
462
|
-
console.log(`Added context "${name}" -> ${server}`);
|
|
463
|
-
},
|
|
464
|
-
);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (sub === "use") {
|
|
469
|
-
const name = args[0];
|
|
470
|
-
if (!name) throw new Error("usage: skillmux context use <name>");
|
|
471
|
-
await useContext(name);
|
|
472
|
-
emitSuccess(
|
|
473
|
-
{ isJson: ctx.isJson, target: ctx.target },
|
|
474
|
-
{ default_context: name },
|
|
475
|
-
() => {
|
|
476
|
-
console.log(`Switched default context to "${name}"`);
|
|
477
|
-
},
|
|
478
|
-
);
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (sub === "remove") {
|
|
483
|
-
const name = args[0];
|
|
484
|
-
if (!name) throw new Error("usage: skillmux context remove <name>");
|
|
485
|
-
await removeContext(name);
|
|
486
|
-
emitSuccess(
|
|
487
|
-
{ isJson: ctx.isJson, target: ctx.target },
|
|
488
|
-
{ removed: name },
|
|
489
|
-
() => {
|
|
490
|
-
console.log(`Removed context "${name}"`);
|
|
491
|
-
},
|
|
492
|
-
);
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
382
|
|
|
496
|
-
throw new Error("usage: skillmux context <add|list|current|use|remove>");
|
|
497
|
-
}
|
|
498
383
|
|
|
499
384
|
async function handleCompletionsCommand(shell: string) {
|
|
500
385
|
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
@@ -505,7 +390,7 @@ async function handleCompletionsCommand(shell: string) {
|
|
|
505
390
|
|
|
506
391
|
async function handleError(
|
|
507
392
|
err: any,
|
|
508
|
-
opts: { target:
|
|
393
|
+
opts: { target: ResolvedContext; isJson: boolean; isVerbose: boolean },
|
|
509
394
|
) {
|
|
510
395
|
const code = mapExitCode(err);
|
|
511
396
|
process.exitCode = code;
|
|
@@ -536,11 +421,13 @@ async function handleError(
|
|
|
536
421
|
console.log(JSON.stringify(env));
|
|
537
422
|
} else {
|
|
538
423
|
console.error(
|
|
539
|
-
|
|
540
|
-
msg.startsWith("
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
424
|
+
red(
|
|
425
|
+
msg.startsWith("usage:") ||
|
|
426
|
+
msg.startsWith("Unknown") ||
|
|
427
|
+
msg.startsWith("error:")
|
|
428
|
+
? msg
|
|
429
|
+
: `error: ${msg}`,
|
|
430
|
+
),
|
|
544
431
|
);
|
|
545
432
|
if (opts.isVerbose && err instanceof Error && err.stack) {
|
|
546
433
|
console.error(redact(err.stack));
|
|
@@ -548,6 +435,163 @@ async function handleError(
|
|
|
548
435
|
}
|
|
549
436
|
}
|
|
550
437
|
|
|
438
|
+
const COMMAND_HELP: Record<string, string> = {
|
|
439
|
+
context: `context: manage named CLI contexts for remote administration
|
|
440
|
+
|
|
441
|
+
usage:
|
|
442
|
+
skillmux context list
|
|
443
|
+
skillmux context current
|
|
444
|
+
skillmux context add <name> --server <url> [--token-env <env_name>]
|
|
445
|
+
skillmux context use <name>
|
|
446
|
+
skillmux context remove <name>`,
|
|
447
|
+
|
|
448
|
+
config: `config: inspect or update server/machine configuration
|
|
449
|
+
|
|
450
|
+
usage:
|
|
451
|
+
skillmux config init --vault <path> --yes
|
|
452
|
+
skillmux config show [--sources]
|
|
453
|
+
skillmux config get <key>
|
|
454
|
+
skillmux config set <key> <value> [--dry-run]
|
|
455
|
+
skillmux config validate
|
|
456
|
+
skillmux config diff
|
|
457
|
+
skillmux config status
|
|
458
|
+
|
|
459
|
+
Accepts --context <name> / --server <url> to target a remote deployment.`,
|
|
460
|
+
|
|
461
|
+
completions: `completions: generate a shell completion script
|
|
462
|
+
|
|
463
|
+
usage:
|
|
464
|
+
skillmux completions <bash|zsh|fish>`,
|
|
465
|
+
|
|
466
|
+
serve: `serve: start the MCP server
|
|
467
|
+
|
|
468
|
+
usage:
|
|
469
|
+
skillmux serve [--transport stdio|http] [--port <port>] [--stats-port <port>]
|
|
470
|
+
|
|
471
|
+
--transport defaults to stdio. --stats-port exposes GET /health and GET /stats
|
|
472
|
+
alongside a stdio transport without opening the full HTTP surface.`,
|
|
473
|
+
|
|
474
|
+
index: `index: rebuild the local retrieval index and backfill embeddings
|
|
475
|
+
|
|
476
|
+
usage:
|
|
477
|
+
skillmux index`,
|
|
478
|
+
|
|
479
|
+
sync: `sync: apply the manifest to native client target directories
|
|
480
|
+
|
|
481
|
+
usage:
|
|
482
|
+
skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]`,
|
|
483
|
+
|
|
484
|
+
init: `init: guided setup for native skill management
|
|
485
|
+
|
|
486
|
+
usage:
|
|
487
|
+
skillmux init [--client <name>...] [--target <name>...] [--dir <dir>]
|
|
488
|
+
[--vault <path>] [--core <skill_id>...]
|
|
489
|
+
[--migrate-full-vault] [--no-instructions] [--no-sync]
|
|
490
|
+
[--interactive|--yes|--dry-run] [--json]
|
|
491
|
+
|
|
492
|
+
clients: claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
493
|
+
antigravity, goose, hermes, skillmux-mcp
|
|
494
|
+
targets: agent-skills, claude-code, codex, custom`,
|
|
495
|
+
|
|
496
|
+
project: `project: manage project-scoped skill pins and sync groups
|
|
497
|
+
|
|
498
|
+
usage:
|
|
499
|
+
skillmux project init [path] [--name <group>] [--skill <skill_id>...]
|
|
500
|
+
[--client <name>...] [--target <name>...] [--no-sync]
|
|
501
|
+
[--interactive|--yes|--dry-run] [--json]
|
|
502
|
+
skillmux project list
|
|
503
|
+
skillmux project show <group>
|
|
504
|
+
skillmux project add-path <group> [path] --yes
|
|
505
|
+
skillmux project remove-path <group> [path] --yes
|
|
506
|
+
skillmux project pin <group> <skill_id>... --yes
|
|
507
|
+
skillmux project unpin <group> <skill_id>... --yes
|
|
508
|
+
skillmux project attach <group> (--client <id>... | --target <name>...) --yes
|
|
509
|
+
skillmux project detach <group> (--client <id>... | --target <name>...) --yes`,
|
|
510
|
+
|
|
511
|
+
target: `target: manage native sync target directories
|
|
512
|
+
|
|
513
|
+
usage:
|
|
514
|
+
skillmux target list
|
|
515
|
+
skillmux target show <name>
|
|
516
|
+
skillmux target add <name> --dir <dir> --yes
|
|
517
|
+
skillmux target remove <name> --yes`,
|
|
518
|
+
|
|
519
|
+
core: `core: pin or unpin core-tier skills
|
|
520
|
+
|
|
521
|
+
usage:
|
|
522
|
+
skillmux core pin <skill_id>... --yes
|
|
523
|
+
skillmux core unpin <skill_id>... --yes`,
|
|
524
|
+
|
|
525
|
+
report: `report: show routing/fetch-outcome audit statistics
|
|
526
|
+
|
|
527
|
+
usage:
|
|
528
|
+
skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]`,
|
|
529
|
+
|
|
530
|
+
audit: `audit: prune the audit database
|
|
531
|
+
|
|
532
|
+
usage:
|
|
533
|
+
skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
|
|
534
|
+
|
|
535
|
+
Accepts --context <name> / --server <url> to prune a remote deployment's audit db.`,
|
|
536
|
+
|
|
537
|
+
scan: `scan: check the vault for install-time or integrity issues
|
|
538
|
+
|
|
539
|
+
usage:
|
|
540
|
+
skillmux scan [path] [--format text|json] [--fail-on low|medium|high] [--json]`,
|
|
541
|
+
|
|
542
|
+
install: `install: install a skill from a git source
|
|
543
|
+
|
|
544
|
+
usage:
|
|
545
|
+
skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]`,
|
|
546
|
+
|
|
547
|
+
outdated: `outdated: list installed skills with a newer upstream version
|
|
548
|
+
|
|
549
|
+
usage:
|
|
550
|
+
skillmux outdated [--allow-local-source] [--json]`,
|
|
551
|
+
|
|
552
|
+
update: `update: update one or all skills to their latest source version
|
|
553
|
+
|
|
554
|
+
usage:
|
|
555
|
+
skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]`,
|
|
556
|
+
|
|
557
|
+
eval: `eval: run retrieval evaluation against the holdout set
|
|
558
|
+
|
|
559
|
+
usage:
|
|
560
|
+
skillmux eval [--json]
|
|
561
|
+
skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
|
|
562
|
+
|
|
563
|
+
Accepts --context <name> / --server <url> to evaluate a remote deployment.`,
|
|
564
|
+
|
|
565
|
+
doctor: `doctor: check server/environment readiness
|
|
566
|
+
|
|
567
|
+
usage:
|
|
568
|
+
skillmux doctor [--json]
|
|
569
|
+
|
|
570
|
+
Accepts --context <name> / --server <url> to check a remote deployment.`,
|
|
571
|
+
|
|
572
|
+
skill: `skill: inspect local vault skill resolution
|
|
573
|
+
|
|
574
|
+
usage:
|
|
575
|
+
skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)`,
|
|
576
|
+
|
|
577
|
+
"local-vault": `local-vault: register an additional local vault checkout
|
|
578
|
+
|
|
579
|
+
usage:
|
|
580
|
+
skillmux local-vault init <path> --yes`,
|
|
581
|
+
|
|
582
|
+
models: `models: manage local embedding model downloads
|
|
583
|
+
|
|
584
|
+
usage:
|
|
585
|
+
skillmux models download`,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
function printCommandHelp(command: string): boolean {
|
|
589
|
+
const help = COMMAND_HELP[command];
|
|
590
|
+
if (!help) return false;
|
|
591
|
+
console.log(help);
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
|
|
551
595
|
function printHelp(): void {
|
|
552
596
|
if (process.env.RUNNING_IN_DOCKER === "true") {
|
|
553
597
|
console.log(`Skillmux server image
|
|
@@ -580,7 +624,7 @@ Setup:
|
|
|
580
624
|
skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
|
|
581
625
|
skillmux target <list|show|add|remove>
|
|
582
626
|
skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
|
|
583
|
-
skillmux skill which <skill_id>
|
|
627
|
+
skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
|
|
584
628
|
|
|
585
629
|
Init clients:
|
|
586
630
|
claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
@@ -598,7 +642,9 @@ Operations:
|
|
|
598
642
|
|
|
599
643
|
Commands:
|
|
600
644
|
serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
|
|
601
|
-
eval, doctor, skill, local-vault, config, models, context, completions
|
|
645
|
+
eval, doctor, skill, local-vault, config, models, context, completions
|
|
646
|
+
|
|
647
|
+
Run "skillmux <command> --help" for a command's full usage.`);
|
|
602
648
|
}
|
|
603
649
|
|
|
604
650
|
// ---------------------------------------------------------------------------
|
|
@@ -649,9 +695,7 @@ async function runIndex(): Promise<void> {
|
|
|
649
695
|
const config = await loadConfig();
|
|
650
696
|
configure({ config, clients: createClients(config) });
|
|
651
697
|
const report = await rebuildIndex((skillId, error) => {
|
|
652
|
-
|
|
653
|
-
`warning: keeping previous index entry for ${skillId}: ${error}`,
|
|
654
|
-
);
|
|
698
|
+
warn(`keeping previous index entry for ${skillId}: ${error}`);
|
|
655
699
|
});
|
|
656
700
|
const retainedNote =
|
|
657
701
|
report.retained.length > 0
|
|
@@ -669,12 +713,14 @@ async function runIndex(): Promise<void> {
|
|
|
669
713
|
}
|
|
670
714
|
}
|
|
671
715
|
|
|
672
|
-
async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
716
|
+
async function runEval(options: { isJson: boolean; adapter: TargetAdapter }): Promise<void> {
|
|
673
717
|
const config = await loadConfig();
|
|
674
718
|
configure({ config, clients: createClients(config) });
|
|
675
719
|
|
|
676
|
-
const report = await
|
|
677
|
-
throw new Error(
|
|
720
|
+
const report = await options.adapter.evalRun().catch((error: unknown) => {
|
|
721
|
+
throw new Error(
|
|
722
|
+
`eval requires an embeddings client (local model or a configured remote endpoint): ${String(error)}`,
|
|
723
|
+
);
|
|
678
724
|
});
|
|
679
725
|
emitSuccess({ isJson: options.isJson }, report, () => {
|
|
680
726
|
console.log(`holdout queries: ${report.queries}`);
|
|
@@ -691,1085 +737,6 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
|
691
737
|
});
|
|
692
738
|
}
|
|
693
739
|
|
|
694
|
-
async function runDoctor(options: { isJson: boolean }): Promise<void> {
|
|
695
|
-
const effective = await getEffectiveConfig(resolveConfigPath());
|
|
696
|
-
const report = await diagnose(effective.effective, process.env, effective.sources);
|
|
697
|
-
emitSuccess({ isJson: options.isJson }, report, () => {
|
|
698
|
-
console.log(`version: ${report.version}`);
|
|
699
|
-
console.log(`runtime: ${report.runtime}`);
|
|
700
|
-
console.log(`image variant: ${report.image_variant ?? "none"}`);
|
|
701
|
-
console.log(`vault path: ${report.vault_path}`);
|
|
702
|
-
console.log(`state directory: ${report.state_dir}`);
|
|
703
|
-
console.log(`inference mode: ${report.mode}`);
|
|
704
|
-
console.log(`routing capability: ${report.capability}`);
|
|
705
|
-
console.log(`retrieval capability: ${report.retrieval_capability}`);
|
|
706
|
-
for (const check of report.checks)
|
|
707
|
-
console.log(
|
|
708
|
-
`${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
|
|
709
|
-
);
|
|
710
|
-
});
|
|
711
|
-
if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
async function runSkill(subCommand: string, args: string[]): Promise<void> {
|
|
715
|
-
if (subCommand !== "which") throw new Error("usage: skillmux skill <which>");
|
|
716
|
-
await runWhich(args);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
async function runWhich(args: string[]): Promise<void> {
|
|
720
|
-
const skillId = args[0];
|
|
721
|
-
if (!skillId) throw new Error("usage: skillmux skill which <skill_id>");
|
|
722
|
-
const config = await loadConfig();
|
|
723
|
-
const vaultPath = expandHome(config.vault_path);
|
|
724
|
-
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
725
|
-
const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
|
|
726
|
-
(root) => existsSync(join(root, skillId, "SKILL.md")),
|
|
727
|
-
);
|
|
728
|
-
if (roots.length === 0) {
|
|
729
|
-
console.log(`${skillId}: not found in vault_path or local_vault_paths`);
|
|
730
|
-
process.exitCode = 1;
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
console.log(`${skillId}: serving from ${roots[0]}`);
|
|
734
|
-
for (const shadowedRoot of roots.slice(1))
|
|
735
|
-
console.log(` shadows: ${shadowedRoot}`);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
async function runLocalVaultInit(
|
|
739
|
-
args: string[],
|
|
740
|
-
options: { isJson: boolean; dryRun: boolean },
|
|
741
|
-
): Promise<void> {
|
|
742
|
-
const path = args[0];
|
|
743
|
-
if (!path) throw new Error("usage: skillmux local-vault init <path> --yes");
|
|
744
|
-
const expanded = expandHome(path);
|
|
745
|
-
const config = await loadConfig();
|
|
746
|
-
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
747
|
-
if (!localVaultPaths.includes(expanded)) {
|
|
748
|
-
throw new Error(
|
|
749
|
-
`"${path}" is not one of the configured local_vault_paths — add it to config.toml first`,
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
|
|
753
|
-
const markerPath = join(expanded, ".skillmux");
|
|
754
|
-
if (options.dryRun) {
|
|
755
|
-
emitSuccess(
|
|
756
|
-
{ isJson: options.isJson },
|
|
757
|
-
{
|
|
758
|
-
marker_path: markerPath,
|
|
759
|
-
vault_path: expandHome(config.vault_path),
|
|
760
|
-
},
|
|
761
|
-
() =>
|
|
762
|
-
console.log(
|
|
763
|
-
`local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
|
|
764
|
-
),
|
|
765
|
-
);
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
if (
|
|
769
|
-
!(await confirmIfNeeded({
|
|
770
|
-
confirmed: args.includes("--yes"),
|
|
771
|
-
isJson: options.isJson,
|
|
772
|
-
prompt: `Mark ${expanded} as a local_vault (role: local_vault, vault_path: ${expandHome(config.vault_path)})?`,
|
|
773
|
-
nonInteractiveError:
|
|
774
|
-
"skillmux local-vault init requires --yes when run non-interactively",
|
|
775
|
-
}))
|
|
776
|
-
)
|
|
777
|
-
return;
|
|
778
|
-
writeLocalVaultMarker(expanded, expandHome(config.vault_path));
|
|
779
|
-
emitSuccess(
|
|
780
|
-
{ isJson: options.isJson },
|
|
781
|
-
{
|
|
782
|
-
marker_path: markerPath,
|
|
783
|
-
vault_path: expandHome(config.vault_path),
|
|
784
|
-
},
|
|
785
|
-
() =>
|
|
786
|
-
console.log(
|
|
787
|
-
`wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
|
|
788
|
-
),
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
async function runModelDownload(options: { isJson: boolean }): Promise<void> {
|
|
793
|
-
const cacheDir = await downloadLocalModels(await loadConfig());
|
|
794
|
-
emitSuccess({ isJson: options.isJson }, { cache_dir: cacheDir }, () =>
|
|
795
|
-
console.log(`models ready in ${cacheDir}`),
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function parseSyncArgs(args: string[]): {
|
|
800
|
-
dryRun: boolean;
|
|
801
|
-
restoreMonolith: boolean;
|
|
802
|
-
installHook: boolean;
|
|
803
|
-
yes: boolean;
|
|
804
|
-
} {
|
|
805
|
-
let dryRun = false;
|
|
806
|
-
let restoreMonolith = false;
|
|
807
|
-
let installHook = false;
|
|
808
|
-
let yes = false;
|
|
809
|
-
for (const arg of args) {
|
|
810
|
-
if (arg === "--dry-run") dryRun = true;
|
|
811
|
-
else if (arg === "--restore-monolith") restoreMonolith = true;
|
|
812
|
-
else if (arg === "--install-hook") installHook = true;
|
|
813
|
-
else if (arg === "--yes") yes = true;
|
|
814
|
-
else throw new Error(`unknown sync option: ${arg}`);
|
|
815
|
-
}
|
|
816
|
-
return { dryRun, restoreMonolith, installHook, yes };
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* A target directory that doesn't exist yet is about to be created by `sync`.
|
|
821
|
-
* `manifest.targets[*].dir` is vault content — readable and writable by whatever
|
|
822
|
-
* populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
|
|
823
|
-
* `sync` can run unattended via the `--install-hook` post-merge hook. Without this
|
|
824
|
-
* gate, a tampered manifest naming a brand-new path gets that directory silently
|
|
825
|
-
* created (and populated with symlinks) the next time anyone pulls. Creation for
|
|
826
|
-
* an as-yet-unseen directory therefore requires either `--yes` or an interactive
|
|
827
|
-
* confirmation; once the directory exists, later syncs never hit this path again.
|
|
828
|
-
*/
|
|
829
|
-
async function confirmNewSyncTarget(label: string, dir: string, yes: boolean): Promise<boolean> {
|
|
830
|
-
if (yes) return true;
|
|
831
|
-
if (!isInteractive()) {
|
|
832
|
-
console.log(
|
|
833
|
-
`${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
|
|
834
|
-
);
|
|
835
|
-
return false;
|
|
836
|
-
}
|
|
837
|
-
return confirmAction(`${label}: create new target directory ${dir}?`);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
async function runSync(args: string[]): Promise<void> {
|
|
841
|
-
const { dryRun, restoreMonolith, installHook, yes } = parseSyncArgs(args);
|
|
842
|
-
const config = await loadConfig();
|
|
843
|
-
const vaultPath = expandHome(config.vault_path);
|
|
844
|
-
|
|
845
|
-
if (installHook) {
|
|
846
|
-
const result = installPostMergeHook(vaultPath);
|
|
847
|
-
console.log(
|
|
848
|
-
result.installed
|
|
849
|
-
? "installed post-merge hook"
|
|
850
|
-
: "post-merge hook already installed",
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const manifestPath = resolveManifestPath(vaultPath);
|
|
855
|
-
if (!manifestPath) {
|
|
856
|
-
console.log("no skillmux.toml found at vault root — nothing to sync");
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
861
|
-
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
862
|
-
const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
|
|
863
|
-
for (const note of notes) console.log(`note: ${note}`);
|
|
864
|
-
|
|
865
|
-
const currentHost = hostname();
|
|
866
|
-
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
867
|
-
if (target.host !== undefined && target.host !== currentHost) {
|
|
868
|
-
console.log(
|
|
869
|
-
`${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
|
|
870
|
-
);
|
|
871
|
-
continue;
|
|
872
|
-
}
|
|
873
|
-
const targetDir = expandHome(target.dir);
|
|
874
|
-
|
|
875
|
-
if (restoreMonolith) {
|
|
876
|
-
const result = restoreMonolithTarget(targetDir, vaultPath);
|
|
877
|
-
console.log(
|
|
878
|
-
result.restored
|
|
879
|
-
? `${targetName}: restored to a vault symlink`
|
|
880
|
-
: `${targetName}: not owned by skillmux, left untouched`,
|
|
881
|
-
);
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
if (!dryRun && !existsSync(targetDir)) {
|
|
886
|
-
const approved = await confirmNewSyncTarget(targetName, targetDir, yes);
|
|
887
|
-
if (!approved) {
|
|
888
|
-
if (isInteractive()) {
|
|
889
|
-
console.log(`${targetName}: skipped — creating ${targetDir} was not approved`);
|
|
890
|
-
}
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const suffix = dryRun ? " (dry-run)" : "";
|
|
896
|
-
const result = syncTarget(
|
|
897
|
-
{
|
|
898
|
-
vaultPath,
|
|
899
|
-
targetDir,
|
|
900
|
-
targetName,
|
|
901
|
-
coreSkillIds: manifest.core.skills,
|
|
902
|
-
localVaultPaths,
|
|
903
|
-
},
|
|
904
|
-
{ dryRun },
|
|
905
|
-
);
|
|
906
|
-
console.log(
|
|
907
|
-
`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`,
|
|
908
|
-
);
|
|
909
|
-
if (result.skipped.length > 0) {
|
|
910
|
-
console.log(
|
|
911
|
-
` warning: refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`,
|
|
912
|
-
);
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
if (target.project_groups.length > 0) {
|
|
916
|
-
const allGroups = manifest.project ?? {};
|
|
917
|
-
const projectGroups: Record<string, ProjectGroupInput> = {};
|
|
918
|
-
for (const groupName of target.project_groups) {
|
|
919
|
-
const group = allGroups[groupName]!;
|
|
920
|
-
const approvedPaths: string[] = [];
|
|
921
|
-
for (const path of group.paths) {
|
|
922
|
-
// Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
|
|
923
|
-
// never prompt for a project path it would silently skip anyway.
|
|
924
|
-
if (!existsSync(path)) continue;
|
|
925
|
-
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
926
|
-
if (dryRun || existsSync(pinDir)) {
|
|
927
|
-
approvedPaths.push(path);
|
|
928
|
-
continue;
|
|
929
|
-
}
|
|
930
|
-
const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes);
|
|
931
|
-
if (approved) approvedPaths.push(path);
|
|
932
|
-
}
|
|
933
|
-
projectGroups[groupName] = { ...group, paths: approvedPaths };
|
|
934
|
-
}
|
|
935
|
-
const projectResults = syncProjectTargets(
|
|
936
|
-
{ vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
|
|
937
|
-
{ dryRun },
|
|
938
|
-
);
|
|
939
|
-
for (const projectResult of projectResults) {
|
|
940
|
-
console.log(
|
|
941
|
-
` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
|
|
942
|
-
);
|
|
943
|
-
if (projectResult.skipped.length > 0) {
|
|
944
|
-
console.log(
|
|
945
|
-
` warning: refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
|
|
946
|
-
);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function parseInitArgs(args: string[]): {
|
|
954
|
-
targets: string[];
|
|
955
|
-
clients: string[];
|
|
956
|
-
coreSkillIds: string[];
|
|
957
|
-
customPath?: string;
|
|
958
|
-
migrateFullVault: boolean;
|
|
959
|
-
skipInstructions: boolean;
|
|
960
|
-
sync: boolean;
|
|
961
|
-
vaultPath?: string;
|
|
962
|
-
yes: boolean;
|
|
963
|
-
} {
|
|
964
|
-
const targets: string[] = [];
|
|
965
|
-
const clients: string[] = [];
|
|
966
|
-
const coreSkillIds: string[] = [];
|
|
967
|
-
let customPath: string | undefined;
|
|
968
|
-
let migrateFullVault = false;
|
|
969
|
-
let skipInstructions = false;
|
|
970
|
-
let sync = true;
|
|
971
|
-
let vaultPath: string | undefined;
|
|
972
|
-
let yes = false;
|
|
973
|
-
for (let i = 0; i < args.length; i++) {
|
|
974
|
-
const option = args[i];
|
|
975
|
-
if (option === "--target") {
|
|
976
|
-
const value = args[i + 1];
|
|
977
|
-
if (!value) throw new Error("--target requires a name");
|
|
978
|
-
targets.push(value);
|
|
979
|
-
i++;
|
|
980
|
-
} else if (option === "--client") {
|
|
981
|
-
const value = args[i + 1];
|
|
982
|
-
if (!value) throw new Error("--client requires a name");
|
|
983
|
-
clients.push(value);
|
|
984
|
-
i++;
|
|
985
|
-
} else if (option === "--vault") {
|
|
986
|
-
const value = args[i + 1];
|
|
987
|
-
if (!value) throw new Error("--vault requires a path");
|
|
988
|
-
vaultPath = value;
|
|
989
|
-
i++;
|
|
990
|
-
} else if (option === "--dir") {
|
|
991
|
-
const value = args[i + 1];
|
|
992
|
-
if (!value) throw new Error("--dir requires a directory");
|
|
993
|
-
customPath = value;
|
|
994
|
-
i++;
|
|
995
|
-
} else if (option === "--core") {
|
|
996
|
-
const value = args[i + 1];
|
|
997
|
-
if (!value) throw new Error("--core requires a skill_id");
|
|
998
|
-
coreSkillIds.push(value);
|
|
999
|
-
i++;
|
|
1000
|
-
} else if (
|
|
1001
|
-
option === "--dry-run" ||
|
|
1002
|
-
option === "--json" ||
|
|
1003
|
-
option === "--interactive"
|
|
1004
|
-
) {
|
|
1005
|
-
continue;
|
|
1006
|
-
} else if (option === "--migrate-full-vault") {
|
|
1007
|
-
migrateFullVault = true;
|
|
1008
|
-
} else if (option === "--no-instructions") {
|
|
1009
|
-
skipInstructions = true;
|
|
1010
|
-
} else if (option === "--no-sync") {
|
|
1011
|
-
sync = false;
|
|
1012
|
-
} else if (option === "--yes") {
|
|
1013
|
-
yes = true;
|
|
1014
|
-
} else {
|
|
1015
|
-
throw new Error(`unknown init option: ${option}`);
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
return {
|
|
1019
|
-
targets,
|
|
1020
|
-
clients,
|
|
1021
|
-
coreSkillIds,
|
|
1022
|
-
customPath,
|
|
1023
|
-
migrateFullVault,
|
|
1024
|
-
skipInstructions,
|
|
1025
|
-
sync,
|
|
1026
|
-
vaultPath,
|
|
1027
|
-
yes,
|
|
1028
|
-
};
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
async function runInit(
|
|
1032
|
-
args: string[],
|
|
1033
|
-
options: { isJson: boolean; dryRun: boolean },
|
|
1034
|
-
): Promise<void> {
|
|
1035
|
-
const {
|
|
1036
|
-
targets: explicitTargets,
|
|
1037
|
-
clients: requestedClients,
|
|
1038
|
-
coreSkillIds,
|
|
1039
|
-
customPath,
|
|
1040
|
-
migrateFullVault,
|
|
1041
|
-
skipInstructions,
|
|
1042
|
-
sync,
|
|
1043
|
-
vaultPath: requestedVaultPath,
|
|
1044
|
-
yes,
|
|
1045
|
-
} = parseInitArgs(args);
|
|
1046
|
-
const guided = shouldUseWizard(args, {
|
|
1047
|
-
interactive: isInteractive(),
|
|
1048
|
-
json: options.isJson,
|
|
1049
|
-
dryRun: options.dryRun,
|
|
1050
|
-
});
|
|
1051
|
-
migrateLegacyPaths();
|
|
1052
|
-
const configPath = resolveConfigPath();
|
|
1053
|
-
let configPlan: ConfigInitPlan | undefined;
|
|
1054
|
-
let vaultPath: string;
|
|
1055
|
-
if (!existsSync(configPath)) {
|
|
1056
|
-
const bootstrapVaultPath =
|
|
1057
|
-
requestedVaultPath ??
|
|
1058
|
-
(!options.isJson && isInteractive() ? "~/skills" : undefined);
|
|
1059
|
-
if (!bootstrapVaultPath) {
|
|
1060
|
-
throw new Error(
|
|
1061
|
-
`machine config does not exist: ${configPath}; re-run with --vault <path>`,
|
|
1062
|
-
);
|
|
1063
|
-
}
|
|
1064
|
-
configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
|
|
1065
|
-
vaultPath = configPlan.vaultPath;
|
|
1066
|
-
if (!options.isJson) {
|
|
1067
|
-
console.log(`config create: ${configPath}`);
|
|
1068
|
-
}
|
|
1069
|
-
} else {
|
|
1070
|
-
const config = await loadConfig();
|
|
1071
|
-
vaultPath = expandHome(config.vault_path);
|
|
1072
|
-
if (requestedVaultPath && expandHome(requestedVaultPath) !== vaultPath) {
|
|
1073
|
-
throw new Error(
|
|
1074
|
-
`machine config already uses vault_path ${vaultPath}; --vault does not overwrite existing config`,
|
|
1075
|
-
);
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const vaultHealth = inspectVault(vaultPath);
|
|
1080
|
-
if (!vaultHealth.ok) {
|
|
1081
|
-
throw new Error(vaultHealth.message);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
let selectedClients = requestedClients;
|
|
1085
|
-
if (guided) {
|
|
1086
|
-
const detected = detectInstalledClients({
|
|
1087
|
-
codexHome: process.env.CODEX_HOME
|
|
1088
|
-
? expandHome(process.env.CODEX_HOME)
|
|
1089
|
-
: undefined,
|
|
1090
|
-
});
|
|
1091
|
-
const evidence = new Map(
|
|
1092
|
-
detected.map((item) => [item.client, item.evidence]),
|
|
1093
|
-
);
|
|
1094
|
-
selectedClients = await promptMultiSelect(
|
|
1095
|
-
"Which clients do you use?",
|
|
1096
|
-
SUPPORTED_CLIENT_IDS.map((client) => ({
|
|
1097
|
-
value: client,
|
|
1098
|
-
label: client,
|
|
1099
|
-
detail: evidence.has(client)
|
|
1100
|
-
? `detected: ${evidence.get(client)}`
|
|
1101
|
-
: undefined,
|
|
1102
|
-
selected: evidence.has(client) || requestedClients.includes(client),
|
|
1103
|
-
})),
|
|
1104
|
-
);
|
|
1105
|
-
}
|
|
1106
|
-
let selectedCoreSkillIds = coreSkillIds;
|
|
1107
|
-
if (guided) {
|
|
1108
|
-
selectedCoreSkillIds = parseCommaList(
|
|
1109
|
-
await promptText(
|
|
1110
|
-
"Core skill IDs to add, comma-separated",
|
|
1111
|
-
coreSkillIds.join(","),
|
|
1112
|
-
),
|
|
1113
|
-
);
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
const clientPlan = planClientSurfaces(selectedClients, {
|
|
1117
|
-
codexHome: process.env.CODEX_HOME
|
|
1118
|
-
? expandHome(process.env.CODEX_HOME)
|
|
1119
|
-
: undefined,
|
|
1120
|
-
});
|
|
1121
|
-
const instructionPlan = planInstructionSetup(
|
|
1122
|
-
skipInstructions ? [] : clientPlan.clients.map((client) => client.id),
|
|
1123
|
-
{
|
|
1124
|
-
codexHome: process.env.CODEX_HOME
|
|
1125
|
-
? expandHome(process.env.CODEX_HOME)
|
|
1126
|
-
: undefined,
|
|
1127
|
-
},
|
|
1128
|
-
);
|
|
1129
|
-
const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
|
|
1130
|
-
for (const change of instructionPlan.changes) {
|
|
1131
|
-
for (const client of change.clients) {
|
|
1132
|
-
instructionReadiness[client] = {
|
|
1133
|
-
status: change.status === "unchanged" ? "ready" : "planned",
|
|
1134
|
-
detail: change.path,
|
|
1135
|
-
};
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
for (const manual of instructionPlan.manual) {
|
|
1139
|
-
instructionReadiness[manual.client] = {
|
|
1140
|
-
status: "manual",
|
|
1141
|
-
detail: manual.reason,
|
|
1142
|
-
};
|
|
1143
|
-
}
|
|
1144
|
-
const builtInNames = new Set([
|
|
1145
|
-
"agent-skills",
|
|
1146
|
-
"claude-code",
|
|
1147
|
-
"codex",
|
|
1148
|
-
"custom",
|
|
1149
|
-
"agents",
|
|
1150
|
-
"claude",
|
|
1151
|
-
]);
|
|
1152
|
-
const explicitSurfaceTargets = explicitTargets
|
|
1153
|
-
.filter((name) => builtInNames.has(name))
|
|
1154
|
-
.map((name) =>
|
|
1155
|
-
resolveBuiltInTarget(name, {
|
|
1156
|
-
codexHome: process.env.CODEX_HOME
|
|
1157
|
-
? expandHome(process.env.CODEX_HOME)
|
|
1158
|
-
: undefined,
|
|
1159
|
-
customPath: customPath ? expandHome(customPath) : undefined,
|
|
1160
|
-
}),
|
|
1161
|
-
);
|
|
1162
|
-
if (customPath && !explicitTargets.includes("custom")) {
|
|
1163
|
-
throw new Error("--dir may only be used with --target custom");
|
|
1164
|
-
}
|
|
1165
|
-
for (const target of explicitSurfaceTargets) {
|
|
1166
|
-
if (target.warning) console.error(`warning: ${target.warning}`);
|
|
1167
|
-
}
|
|
1168
|
-
const targetByPath = new Map(
|
|
1169
|
-
explicitSurfaceTargets.map(
|
|
1170
|
-
(target) => [target.path, target.targetName] as const,
|
|
1171
|
-
),
|
|
1172
|
-
);
|
|
1173
|
-
const existingManifestPath = resolveManifestPath(vaultPath);
|
|
1174
|
-
const existingManifest = existingManifestPath
|
|
1175
|
-
? parseManifest(await Bun.file(existingManifestPath).text())
|
|
1176
|
-
: undefined;
|
|
1177
|
-
for (const surface of clientPlan.surfaces) {
|
|
1178
|
-
if (!targetByPath.has(surface.path)) {
|
|
1179
|
-
targetByPath.set(
|
|
1180
|
-
surface.path,
|
|
1181
|
-
existingManifest
|
|
1182
|
-
? (configuredTargetForSurface(existingManifest, surface) ??
|
|
1183
|
-
surface.targetName)
|
|
1184
|
-
: surface.targetName,
|
|
1185
|
-
);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
const candidatePaths = [
|
|
1189
|
-
...new Set([
|
|
1190
|
-
...surfaceCandidates().map(expandHome),
|
|
1191
|
-
...targetByPath.keys(),
|
|
1192
|
-
]),
|
|
1193
|
-
];
|
|
1194
|
-
const candidates = detectSurfaces(candidatePaths, vaultPath);
|
|
1195
|
-
if (!options.isJson) {
|
|
1196
|
-
for (const candidate of candidates) {
|
|
1197
|
-
const name =
|
|
1198
|
-
targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
|
|
1199
|
-
if (candidate.state === "missing") {
|
|
1200
|
-
console.log(`${name} (${candidate.path}): not found`);
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
if (candidate.state === "broken-symlink") {
|
|
1204
|
-
console.log(`${name} (${candidate.path}): broken symlink`);
|
|
1205
|
-
continue;
|
|
1206
|
-
}
|
|
1207
|
-
if (candidate.state === "full-vault") {
|
|
1208
|
-
console.log(
|
|
1209
|
-
`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`,
|
|
1210
|
-
);
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
if (candidate.state === "external-symlink") {
|
|
1214
|
-
console.log(
|
|
1215
|
-
`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`,
|
|
1216
|
-
);
|
|
1217
|
-
continue;
|
|
1218
|
-
}
|
|
1219
|
-
if (candidate.state === "unsupported") {
|
|
1220
|
-
console.log(
|
|
1221
|
-
`${name} (${candidate.path}): unsupported filesystem entry`,
|
|
1222
|
-
);
|
|
1223
|
-
continue;
|
|
1224
|
-
}
|
|
1225
|
-
const kind = "real dir";
|
|
1226
|
-
const marked = candidate.alreadyMarked
|
|
1227
|
-
? ", already skillmux-managed"
|
|
1228
|
-
: "";
|
|
1229
|
-
console.log(
|
|
1230
|
-
`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`,
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
for (const readiness of assessClientReadiness(
|
|
1234
|
-
clientPlan,
|
|
1235
|
-
instructionReadiness,
|
|
1236
|
-
)) {
|
|
1237
|
-
console.log(`\n${readiness.client} readiness:`);
|
|
1238
|
-
console.log(
|
|
1239
|
-
` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`,
|
|
1240
|
-
);
|
|
1241
|
-
console.log(
|
|
1242
|
-
` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`,
|
|
1243
|
-
);
|
|
1244
|
-
console.log(
|
|
1245
|
-
` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`,
|
|
1246
|
-
);
|
|
1247
|
-
}
|
|
1248
|
-
for (const change of instructionPlan.changes) {
|
|
1249
|
-
console.log(
|
|
1250
|
-
`instructions ${change.status}: ${change.path} (${change.clients.join(", ")})`,
|
|
1251
|
-
);
|
|
1252
|
-
}
|
|
1253
|
-
for (const manual of instructionPlan.manual) {
|
|
1254
|
-
console.log(`instructions manual: ${manual.client} — ${manual.reason}`);
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
const requestedTargets = [
|
|
1259
|
-
...new Set([
|
|
1260
|
-
...explicitTargets.filter((name) => !builtInNames.has(name)),
|
|
1261
|
-
...targetByPath.values(),
|
|
1262
|
-
]),
|
|
1263
|
-
];
|
|
1264
|
-
const hasInstructionWrites = instructionPlan.changes.some(
|
|
1265
|
-
(change) => change.status !== "unchanged",
|
|
1266
|
-
);
|
|
1267
|
-
const hasConfigWrite = configPlan?.action === "create";
|
|
1268
|
-
const hasChanges = !(
|
|
1269
|
-
requestedTargets.length === 0 &&
|
|
1270
|
-
!hasInstructionWrites &&
|
|
1271
|
-
selectedCoreSkillIds.length === 0 &&
|
|
1272
|
-
!hasConfigWrite
|
|
1273
|
-
);
|
|
1274
|
-
|
|
1275
|
-
const byName = new Map(
|
|
1276
|
-
candidates
|
|
1277
|
-
.filter(
|
|
1278
|
-
(candidate) =>
|
|
1279
|
-
candidate.deliveryMode === "managed-pins" ||
|
|
1280
|
-
(migrateFullVault && candidate.state === "full-vault"),
|
|
1281
|
-
)
|
|
1282
|
-
.map(
|
|
1283
|
-
(candidate) =>
|
|
1284
|
-
[
|
|
1285
|
-
targetByPath.get(candidate.path) ??
|
|
1286
|
-
deriveTargetName(candidate.path),
|
|
1287
|
-
candidate,
|
|
1288
|
-
] as const,
|
|
1289
|
-
),
|
|
1290
|
-
);
|
|
1291
|
-
const allCandidatesByName = new Map(
|
|
1292
|
-
candidates.map(
|
|
1293
|
-
(candidate) =>
|
|
1294
|
-
[
|
|
1295
|
-
targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
|
|
1296
|
-
candidate,
|
|
1297
|
-
] as const,
|
|
1298
|
-
),
|
|
1299
|
-
);
|
|
1300
|
-
for (const name of requestedTargets) {
|
|
1301
|
-
if (!byName.has(name)) {
|
|
1302
|
-
if (allCandidatesByName.get(name)?.state === "full-vault") {
|
|
1303
|
-
throw new Error(
|
|
1304
|
-
`target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
|
|
1305
|
-
);
|
|
1306
|
-
}
|
|
1307
|
-
throw new Error(
|
|
1308
|
-
`unknown --target "${name}": not among detected surfaces`,
|
|
1309
|
-
);
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
const confirmedTargets = requestedTargets.map((name) => {
|
|
1314
|
-
const candidate = byName.get(name)!;
|
|
1315
|
-
return {
|
|
1316
|
-
name,
|
|
1317
|
-
dir: candidate.path,
|
|
1318
|
-
...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
|
|
1319
|
-
};
|
|
1320
|
-
});
|
|
1321
|
-
const plannedManifest = planInitManifest(
|
|
1322
|
-
vaultPath,
|
|
1323
|
-
confirmedTargets,
|
|
1324
|
-
selectedCoreSkillIds,
|
|
1325
|
-
);
|
|
1326
|
-
const serializedPlan = {
|
|
1327
|
-
vault_path: vaultPath,
|
|
1328
|
-
config: configPlan
|
|
1329
|
-
? { path: configPlan.configPath, action: configPlan.action }
|
|
1330
|
-
: { path: configPath, action: "preserve" },
|
|
1331
|
-
clients: clientPlan.clients.map((client) => client.id),
|
|
1332
|
-
targets: confirmedTargets,
|
|
1333
|
-
core: plannedManifest.core.skills,
|
|
1334
|
-
instructions: instructionPlan.changes.map(({ path, clients, status }) => ({
|
|
1335
|
-
path,
|
|
1336
|
-
clients,
|
|
1337
|
-
status,
|
|
1338
|
-
})),
|
|
1339
|
-
manual: instructionPlan.manual,
|
|
1340
|
-
};
|
|
1341
|
-
if (!hasChanges) {
|
|
1342
|
-
if (options.isJson) {
|
|
1343
|
-
console.log(
|
|
1344
|
-
JSON.stringify({
|
|
1345
|
-
schema_version: 1,
|
|
1346
|
-
ok: true,
|
|
1347
|
-
command: "init",
|
|
1348
|
-
phase: "plan",
|
|
1349
|
-
dry_run: options.dryRun,
|
|
1350
|
-
applied: false,
|
|
1351
|
-
plan: serializedPlan,
|
|
1352
|
-
}),
|
|
1353
|
-
);
|
|
1354
|
-
} else {
|
|
1355
|
-
console.log("\nno managed-pins surface selected — nothing written.");
|
|
1356
|
-
}
|
|
1357
|
-
return;
|
|
1358
|
-
}
|
|
1359
|
-
if (!options.isJson) {
|
|
1360
|
-
for (const target of confirmedTargets.filter(
|
|
1361
|
-
(target) => target.migrateFullVault,
|
|
1362
|
-
)) {
|
|
1363
|
-
console.log(
|
|
1364
|
-
`full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
|
|
1365
|
-
`${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
|
|
1366
|
-
);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
if (options.dryRun) {
|
|
1370
|
-
if (options.isJson) {
|
|
1371
|
-
console.log(
|
|
1372
|
-
JSON.stringify({
|
|
1373
|
-
schema_version: 1,
|
|
1374
|
-
ok: true,
|
|
1375
|
-
command: "init",
|
|
1376
|
-
phase: "plan",
|
|
1377
|
-
dry_run: true,
|
|
1378
|
-
applied: false,
|
|
1379
|
-
plan: serializedPlan,
|
|
1380
|
-
}),
|
|
1381
|
-
);
|
|
1382
|
-
} else {
|
|
1383
|
-
console.log(
|
|
1384
|
-
`\ndry-run: ${confirmedTargets.length} target(s), ` +
|
|
1385
|
-
`${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
|
|
1386
|
-
`core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (!yes) {
|
|
1393
|
-
if (!options.isJson && isInteractive()) {
|
|
1394
|
-
if (guided) {
|
|
1395
|
-
console.log("\nReview");
|
|
1396
|
-
console.log(` clients: ${selectedClients.join(", ") || "(none)"}`);
|
|
1397
|
-
console.log(
|
|
1398
|
-
` targets: ${confirmedTargets.map((target) => `${target.name} -> ${target.dir}`).join(", ") || "(none)"}`,
|
|
1399
|
-
);
|
|
1400
|
-
console.log(
|
|
1401
|
-
` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
|
|
1402
|
-
);
|
|
1403
|
-
console.log(
|
|
1404
|
-
` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`,
|
|
1405
|
-
);
|
|
1406
|
-
console.log(` sync: ${sync ? "yes" : "no"}`);
|
|
1407
|
-
if (!(await confirmAction("Apply this setup plan?"))) {
|
|
1408
|
-
console.log("init cancelled");
|
|
1409
|
-
return;
|
|
1410
|
-
}
|
|
1411
|
-
} else {
|
|
1412
|
-
const prompts = [
|
|
1413
|
-
...confirmedTargets.map(
|
|
1414
|
-
(target) => `Adopt ${target.name} at ${target.dir}?`,
|
|
1415
|
-
),
|
|
1416
|
-
...instructionPlan.changes
|
|
1417
|
-
.filter((change) => change.status !== "unchanged")
|
|
1418
|
-
.map(
|
|
1419
|
-
(change) => `${change.status} instruction file ${change.path}?`,
|
|
1420
|
-
),
|
|
1421
|
-
...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
|
|
1422
|
-
...(selectedCoreSkillIds.length > 0
|
|
1423
|
-
? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
|
|
1424
|
-
: []),
|
|
1425
|
-
];
|
|
1426
|
-
for (const prompt of prompts) {
|
|
1427
|
-
if (!(await confirmAction(prompt))) {
|
|
1428
|
-
console.log("init cancelled; nothing written");
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1433
|
-
} else {
|
|
1434
|
-
throw new Error(
|
|
1435
|
-
"skillmux init requires --yes before applying target, instruction, or core changes non-interactively",
|
|
1436
|
-
);
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
let configCreated = false;
|
|
1441
|
-
let instructionsApplied = false;
|
|
1442
|
-
const applyAdditional = () => {
|
|
1443
|
-
try {
|
|
1444
|
-
if (configPlan?.action === "create") {
|
|
1445
|
-
configCreated = applyConfigInit(configPlan) === "created";
|
|
1446
|
-
}
|
|
1447
|
-
if (hasInstructionWrites) {
|
|
1448
|
-
applyInstructionPlan(instructionPlan);
|
|
1449
|
-
instructionsApplied = true;
|
|
1450
|
-
}
|
|
1451
|
-
} catch (error) {
|
|
1452
|
-
if (configCreated && configPlan) rollbackConfigInit(configPlan);
|
|
1453
|
-
configCreated = false;
|
|
1454
|
-
throw error;
|
|
1455
|
-
}
|
|
1456
|
-
};
|
|
1457
|
-
const rollbackAdditional = () => {
|
|
1458
|
-
if (instructionsApplied) rollbackInstructionPlan(instructionPlan);
|
|
1459
|
-
if (configCreated && configPlan) rollbackConfigInit(configPlan);
|
|
1460
|
-
};
|
|
1461
|
-
|
|
1462
|
-
if (confirmedTargets.length === 0 && selectedCoreSkillIds.length === 0) {
|
|
1463
|
-
applyAdditional();
|
|
1464
|
-
} else {
|
|
1465
|
-
applyInit(
|
|
1466
|
-
vaultPath,
|
|
1467
|
-
confirmedTargets,
|
|
1468
|
-
hasInstructionWrites || hasConfigWrite
|
|
1469
|
-
? {
|
|
1470
|
-
apply: applyAdditional,
|
|
1471
|
-
rollback: rollbackAdditional,
|
|
1472
|
-
}
|
|
1473
|
-
: undefined,
|
|
1474
|
-
selectedCoreSkillIds,
|
|
1475
|
-
);
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
if (options.isJson) {
|
|
1479
|
-
console.log(
|
|
1480
|
-
JSON.stringify({
|
|
1481
|
-
schema_version: 1,
|
|
1482
|
-
ok: true,
|
|
1483
|
-
command: "init",
|
|
1484
|
-
phase: "result",
|
|
1485
|
-
dry_run: false,
|
|
1486
|
-
applied: true,
|
|
1487
|
-
plan: serializedPlan,
|
|
1488
|
-
result: {
|
|
1489
|
-
config_created: configCreated,
|
|
1490
|
-
targets_adopted: confirmedTargets.map((target) => target.name),
|
|
1491
|
-
instructions_changed: instructionPlan.changes
|
|
1492
|
-
.filter((change) => change.status !== "unchanged")
|
|
1493
|
-
.map((change) => change.path),
|
|
1494
|
-
core: plannedManifest.core.skills,
|
|
1495
|
-
},
|
|
1496
|
-
}),
|
|
1497
|
-
);
|
|
1498
|
-
return;
|
|
1499
|
-
}
|
|
1500
|
-
if (configCreated) console.log(`created ${configPath}`);
|
|
1501
|
-
if (confirmedTargets.length > 0) {
|
|
1502
|
-
console.log(
|
|
1503
|
-
`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`,
|
|
1504
|
-
);
|
|
1505
|
-
} else if (selectedCoreSkillIds.length > 0) {
|
|
1506
|
-
console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
|
|
1507
|
-
}
|
|
1508
|
-
if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
|
|
1509
|
-
console.log("next: skillmux core pin <skill_id> --yes");
|
|
1510
|
-
}
|
|
1511
|
-
if (confirmedTargets.length > 0) console.log("next: skillmux sync");
|
|
1512
|
-
if (
|
|
1513
|
-
selectedClients.length === 0 ||
|
|
1514
|
-
selectedClients.includes("skillmux-mcp")
|
|
1515
|
-
) {
|
|
1516
|
-
console.log(`\n${printLastMile()}`);
|
|
1517
|
-
}
|
|
1518
|
-
// Reaching this point already required approval above (--yes, or an accepted
|
|
1519
|
-
// confirmAction naming these exact targets/dirs) — that approval covers whatever
|
|
1520
|
-
// new target directories this init just adopted, so runSync's own new-target
|
|
1521
|
-
// confirmation gate would just be a redundant (and non-interactively,
|
|
1522
|
-
// silently-skipping) re-ask.
|
|
1523
|
-
if (guided && sync && confirmedTargets.length > 0) await runSync(["--yes"]);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
function parseReportArgs(args: string[]): {
|
|
1527
|
-
db?: string;
|
|
1528
|
-
since?: string;
|
|
1529
|
-
} {
|
|
1530
|
-
let db: string | undefined;
|
|
1531
|
-
let since: string | undefined;
|
|
1532
|
-
for (let i = 0; i < args.length; i++) {
|
|
1533
|
-
const option = args[i];
|
|
1534
|
-
const value = args[i + 1];
|
|
1535
|
-
if (option === "--db") {
|
|
1536
|
-
if (!value) throw new Error("--db requires a path");
|
|
1537
|
-
db = value;
|
|
1538
|
-
i++;
|
|
1539
|
-
} else if (option === "--since") {
|
|
1540
|
-
if (!value) throw new Error("--since requires a window");
|
|
1541
|
-
since = value;
|
|
1542
|
-
i++;
|
|
1543
|
-
} else if (option === "--json") {
|
|
1544
|
-
// handled globally by main()'s isJson flag; recognized here so it isn't rejected
|
|
1545
|
-
} else if (option === "--server" || option === "--context") {
|
|
1546
|
-
// handled globally by main()'s resolveTarget(); recognized here so it isn't rejected
|
|
1547
|
-
i++;
|
|
1548
|
-
} else if (option === "--allow-insecure") {
|
|
1549
|
-
// handled globally by main()'s allowInsecure flag; recognized here so it isn't rejected
|
|
1550
|
-
} else {
|
|
1551
|
-
throw new Error(`unknown report option: ${option}`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
return { db, since };
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
async function runReport(
|
|
1558
|
-
args: string[],
|
|
1559
|
-
options: { isJson: boolean; target: ResolvedTarget; allowInsecure: boolean },
|
|
1560
|
-
): Promise<void> {
|
|
1561
|
-
const { db: dbPath, since } = parseReportArgs(args);
|
|
1562
|
-
if (!since)
|
|
1563
|
-
throw new Error(
|
|
1564
|
-
"usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
|
|
1565
|
-
);
|
|
1566
|
-
if (dbPath && options.target.type === "remote")
|
|
1567
|
-
throw new Error("--db and --context/--server are mutually exclusive");
|
|
1568
|
-
|
|
1569
|
-
if (options.target.type === "remote") {
|
|
1570
|
-
const { server, token_env } = options.target;
|
|
1571
|
-
const url = new URL(`${server.replace(/\/$/, "")}/stats`);
|
|
1572
|
-
url.searchParams.set("since", since);
|
|
1573
|
-
if (
|
|
1574
|
-
url.protocol === "http:" &&
|
|
1575
|
-
!isLoopbackHost(url.hostname) &&
|
|
1576
|
-
!options.allowInsecure
|
|
1577
|
-
) {
|
|
1578
|
-
throw new Error(
|
|
1579
|
-
`Plaintext HTTP report target not allowed for non-loopback server "${server}". Pass --allow-insecure to bypass.`,
|
|
1580
|
-
);
|
|
1581
|
-
}
|
|
1582
|
-
const headers: Record<string, string> = {};
|
|
1583
|
-
if (token_env) {
|
|
1584
|
-
const token = process.env[token_env];
|
|
1585
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
1586
|
-
}
|
|
1587
|
-
const res = await fetch(url, { headers });
|
|
1588
|
-
if (!res.ok)
|
|
1589
|
-
throw new Error(
|
|
1590
|
-
`skillmux report --server failed: ${res.status} ${await res.text()}`,
|
|
1591
|
-
);
|
|
1592
|
-
const stats = (await res.json()) as StatsResponse;
|
|
1593
|
-
emitSuccess({ isJson: options.isJson }, stats, () =>
|
|
1594
|
-
console.log(renderStatsText(stats)),
|
|
1595
|
-
);
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
const db = dbPath
|
|
1600
|
-
? new Database(dbPath, { readonly: true })
|
|
1601
|
-
: openAudit(expandHome((await loadConfig()).state_dir));
|
|
1602
|
-
const stats = getStats(db, since);
|
|
1603
|
-
emitSuccess({ isJson: options.isJson }, stats, () =>
|
|
1604
|
-
console.log(renderStatsText(stats)),
|
|
1605
|
-
);
|
|
1606
|
-
db.close();
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function parseScanArgs(args: string[]): {
|
|
1610
|
-
path?: string;
|
|
1611
|
-
format: "text" | "json";
|
|
1612
|
-
failOn?: ScanSeverity;
|
|
1613
|
-
} {
|
|
1614
|
-
let path: string | undefined;
|
|
1615
|
-
let format: "text" | "json" = "text";
|
|
1616
|
-
let failOn: ScanSeverity | undefined;
|
|
1617
|
-
for (let i = 0; i < args.length; i++) {
|
|
1618
|
-
const option = args[i];
|
|
1619
|
-
if (option === "--format") {
|
|
1620
|
-
const value = args[++i];
|
|
1621
|
-
if (value !== "text" && value !== "json")
|
|
1622
|
-
throw new Error("--format must be text or json");
|
|
1623
|
-
format = value;
|
|
1624
|
-
} else if (option === "--fail-on") {
|
|
1625
|
-
const value = args[++i];
|
|
1626
|
-
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
1627
|
-
throw new Error("--fail-on must be low, medium, or high");
|
|
1628
|
-
}
|
|
1629
|
-
failOn = value;
|
|
1630
|
-
} else if (option === "--json") {
|
|
1631
|
-
// handled globally by main()'s isJson flag; recognized here so it isn't rejected
|
|
1632
|
-
} else if (option?.startsWith("--")) {
|
|
1633
|
-
throw new Error(`unknown scan option: ${option}`);
|
|
1634
|
-
} else if (path !== undefined) {
|
|
1635
|
-
throw new Error("skillmux scan accepts at most one <path> argument");
|
|
1636
|
-
} else {
|
|
1637
|
-
path = option;
|
|
1638
|
-
}
|
|
1639
|
-
}
|
|
1640
|
-
return { path, format, failOn };
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
async function runScan(
|
|
1644
|
-
args: string[],
|
|
1645
|
-
options: { isJson: boolean },
|
|
1646
|
-
): Promise<void> {
|
|
1647
|
-
const { path, format, failOn } = parseScanArgs(args);
|
|
1648
|
-
const rootPath = path
|
|
1649
|
-
? expandHome(path)
|
|
1650
|
-
: expandHome((await loadConfig()).vault_path);
|
|
1651
|
-
const result = await scanPath(rootPath);
|
|
1652
|
-
emitSuccess({ isJson: options.isJson }, result, () => {
|
|
1653
|
-
console.log(
|
|
1654
|
-
format === "json" ? renderScanJson(result) : renderScanText(result),
|
|
1655
|
-
);
|
|
1656
|
-
});
|
|
1657
|
-
process.exitCode = scanExitCode(result.findings, failOn);
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
function parseInstallArgs(args: string[]): {
|
|
1661
|
-
repo?: string;
|
|
1662
|
-
force: boolean;
|
|
1663
|
-
dryRun: boolean;
|
|
1664
|
-
failOn?: ScanSeverity;
|
|
1665
|
-
allowLocalSource: boolean;
|
|
1666
|
-
} {
|
|
1667
|
-
let repo: string | undefined;
|
|
1668
|
-
let force = false;
|
|
1669
|
-
let dryRun = false;
|
|
1670
|
-
let failOn: ScanSeverity | undefined;
|
|
1671
|
-
let allowLocalSource = false;
|
|
1672
|
-
for (let i = 0; i < args.length; i++) {
|
|
1673
|
-
const option = args[i];
|
|
1674
|
-
if (option === "--force") force = true;
|
|
1675
|
-
else if (option === "--dry-run") dryRun = true;
|
|
1676
|
-
else if (option === "--allow-local-source") allowLocalSource = true;
|
|
1677
|
-
else if (option === "--fail-on") {
|
|
1678
|
-
const value = args[++i];
|
|
1679
|
-
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
1680
|
-
throw new Error("--fail-on must be low, medium, or high");
|
|
1681
|
-
}
|
|
1682
|
-
failOn = value;
|
|
1683
|
-
} else if (option === "--json" || option === "--verbose") {
|
|
1684
|
-
// handled globally by main()'s isJson/isVerbose flags; recognized here so they aren't rejected
|
|
1685
|
-
} else if (option?.startsWith("--")) {
|
|
1686
|
-
throw new Error(`unknown install option: ${option}`);
|
|
1687
|
-
} else if (repo !== undefined) {
|
|
1688
|
-
throw new Error("skillmux install accepts at most one <repo> argument");
|
|
1689
|
-
} else {
|
|
1690
|
-
repo = option;
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
return { repo, force, dryRun, failOn, allowLocalSource };
|
|
1694
|
-
}
|
|
1695
|
-
|
|
1696
|
-
async function runInstall(
|
|
1697
|
-
args: string[],
|
|
1698
|
-
options: { isJson: boolean },
|
|
1699
|
-
): Promise<void> {
|
|
1700
|
-
const { repo, force, dryRun, failOn, allowLocalSource } = parseInstallArgs(args);
|
|
1701
|
-
if (!repo) {
|
|
1702
|
-
throw new Error(
|
|
1703
|
-
"usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]",
|
|
1704
|
-
);
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
|
-
const source = resolveRepoSource(repo);
|
|
1708
|
-
if (!allowLocalSource && isLocalFileUrl(source.url)) {
|
|
1709
|
-
throw new Error(
|
|
1710
|
-
`"${repo}" is a local (file://) source — pass --allow-local-source to install from it`,
|
|
1711
|
-
);
|
|
1712
|
-
}
|
|
1713
|
-
const config = await loadConfig();
|
|
1714
|
-
assertHostAllowed(source.url, config.egress?.allowed_hosts);
|
|
1715
|
-
const cloneDir = await cloneToTemp(source.url);
|
|
1716
|
-
try {
|
|
1717
|
-
const resolved = resolveSkillDir(
|
|
1718
|
-
cloneDir,
|
|
1719
|
-
deriveRepoName(source.url),
|
|
1720
|
-
source.skillPath,
|
|
1721
|
-
);
|
|
1722
|
-
const { findings } = await validateSkillCandidate(
|
|
1723
|
-
resolved.skillId,
|
|
1724
|
-
resolved.dir,
|
|
1725
|
-
);
|
|
1726
|
-
if (!options.isJson) console.log(renderScanText({ scanned: 1, findings }));
|
|
1727
|
-
|
|
1728
|
-
if (scanExitCode(findings, failOn) !== 0) {
|
|
1729
|
-
process.exitCode = 1;
|
|
1730
|
-
console.error(
|
|
1731
|
-
`aborting install: a finding met the --fail-on ${failOn} threshold`,
|
|
1732
|
-
);
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
const vaultPath = expandHome(config.vault_path);
|
|
1737
|
-
if (dryRun) {
|
|
1738
|
-
const plannedPath = join(vaultPath, resolved.skillId);
|
|
1739
|
-
emitSuccess(
|
|
1740
|
-
{ isJson: options.isJson },
|
|
1741
|
-
{ skill_id: resolved.skillId, would_install_at: plannedPath },
|
|
1742
|
-
() =>
|
|
1743
|
-
console.log(
|
|
1744
|
-
`dry-run: would install "${resolved.skillId}" into ${plannedPath}`,
|
|
1745
|
-
),
|
|
1746
|
-
);
|
|
1747
|
-
return;
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
const commit = resolveCloneCommit(cloneDir);
|
|
1751
|
-
const targetDir = installIntoVault(
|
|
1752
|
-
vaultPath,
|
|
1753
|
-
resolved.skillId,
|
|
1754
|
-
resolved.dir,
|
|
1755
|
-
force,
|
|
1756
|
-
);
|
|
1757
|
-
writeSkillOrigin(targetDir, {
|
|
1758
|
-
source_url: source.url,
|
|
1759
|
-
skill_path: source.skillPath,
|
|
1760
|
-
commit,
|
|
1761
|
-
installed_at: new Date().toISOString(),
|
|
1762
|
-
content_hash: hashSkillContent(targetDir),
|
|
1763
|
-
});
|
|
1764
|
-
emitSuccess(
|
|
1765
|
-
{ isJson: options.isJson },
|
|
1766
|
-
{ skill_id: resolved.skillId, installed_at: targetDir },
|
|
1767
|
-
() => console.log(`installed "${resolved.skillId}" into ${targetDir}`),
|
|
1768
|
-
);
|
|
1769
|
-
} finally {
|
|
1770
|
-
rmSync(cloneDir, { recursive: true, force: true });
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
740
|
|
|
1774
741
|
if (import.meta.main) {
|
|
1775
742
|
await main();
|