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