@klhapp/skillmux 1.9.3 → 1.11.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 +46 -0
- package/README.md +19 -19
- package/docs/README.md +4 -4
- package/docs/assets/architecture-dark.svg +39 -32
- package/docs/assets/architecture-light.svg +25 -18
- package/docs/cli.md +147 -36
- package/docs/concepts.md +11 -11
- package/docs/configuration.md +7 -5
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +18 -14
- package/docs/mcp-routing.md +1 -1
- package/docs/skill-management.md +17 -11
- package/docs/troubleshooting.md +4 -4
- package/package.json +1 -1
- package/src/adapters.ts +157 -11
- package/src/cli.ts +396 -1319
- package/src/commands/audit.ts +53 -56
- package/src/commands/config.ts +33 -26
- package/src/commands/context.ts +104 -0
- package/src/commands/core.ts +7 -3
- package/src/commands/doctor.ts +97 -0
- package/src/commands/eval.ts +22 -15
- package/src/commands/init.ts +672 -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 +194 -51
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/shared.ts +7 -14
- package/src/commands/skill.ts +33 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +45 -15
- package/src/commands/update.ts +2 -1
- package/src/completions.ts +41 -15
- package/src/config-service.ts +4 -54
- 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/init-agents.ts +329 -0
- package/src/init-instructions.ts +47 -28
- package/src/logger.ts +26 -0
- package/src/mcp-registration.ts +89 -0
- package/src/output.ts +80 -18
- package/src/prompts.ts +75 -20
- package/src/router-core.ts +8 -27
- package/src/scan.ts +19 -19
- package/src/server.ts +161 -14
- package/src/toml-writer.ts +51 -0
- package/src/init-clients.ts +0 -220
package/src/cli.ts
CHANGED
|
@@ -1,129 +1,52 @@
|
|
|
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
|
-
|
|
99
|
-
getCurrentContext,
|
|
100
|
-
listContexts,
|
|
101
|
-
removeContext,
|
|
102
|
-
resolveTarget,
|
|
103
|
-
useContext,
|
|
104
|
-
type ResolvedTarget,
|
|
105
|
-
} from "./context";
|
|
106
|
-
import { createTargetAdapter, isLoopbackHost, type TargetAdapter } from "./adapters";
|
|
19
|
+
import { resolveContext, type ResolvedContext } from "./context";
|
|
20
|
+
import { createContextAdapter, isLoopbackHost, type ContextAdapter } 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";
|
|
31
|
+
import { SUPPORTED_AGENT_IDS } from "./init-agents";
|
|
118
32
|
import { runAudit } from "./commands/audit";
|
|
119
33
|
import { handleConfigCommand } from "./commands/config";
|
|
34
|
+
import { handleContextCommand } from "./commands/context";
|
|
35
|
+
import { runDoctor } from "./commands/doctor";
|
|
120
36
|
import { runEvalPromote } from "./commands/eval";
|
|
37
|
+
import { runInstall } from "./commands/install";
|
|
121
38
|
import { runCore } from "./commands/core";
|
|
122
|
-
import {
|
|
123
|
-
import {
|
|
39
|
+
import { runLocalVaultInit } from "./commands/local-vault";
|
|
40
|
+
import { runModelDownload } from "./commands/models";
|
|
41
|
+
import { runProject } from "./commands/project";
|
|
42
|
+
import { runReport } from "./commands/report";
|
|
43
|
+
import { runScan } from "./commands/scan";
|
|
44
|
+
import { runSkill } from "./commands/skill";
|
|
124
45
|
import { runTarget } from "./commands/target";
|
|
46
|
+
import { runSync } from "./commands/sync";
|
|
47
|
+
import { runInit } from "./commands/init";
|
|
125
48
|
|
|
126
|
-
const KNOWN_COMMANDS = [
|
|
49
|
+
export const KNOWN_COMMANDS = [
|
|
127
50
|
"context",
|
|
128
51
|
"config",
|
|
129
52
|
"completions",
|
|
@@ -147,6 +70,124 @@ const KNOWN_COMMANDS = [
|
|
|
147
70
|
"local-vault",
|
|
148
71
|
];
|
|
149
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Declared context support for every command in KNOWN_COMMANDS — the single
|
|
75
|
+
* source of truth getLocalOnlyCommand() enforces against. A command missing
|
|
76
|
+
* from here, or misclassified, is a bug: see tests/cli-context-support.test.ts,
|
|
77
|
+
* which fails the build rather than letting a command silently drift out of
|
|
78
|
+
* sync the way `config init` did (it was never rejected for a remote context,
|
|
79
|
+
* nor actually remote-capable — it just silently ran local logic that choked
|
|
80
|
+
* on an unrecognized --context/--server flag with a confusing error).
|
|
81
|
+
*
|
|
82
|
+
* - "local-only": operates on this machine's vault/filesystem/agents only;
|
|
83
|
+
* a remote context is rejected outright.
|
|
84
|
+
* - "remote-capable": routed through ContextAdapter — same command, backed by
|
|
85
|
+
* LocalAdapter or RemoteAdapter depending on the resolved context.
|
|
86
|
+
* - "context-agnostic": the resolved context isn't used to decide behavior at
|
|
87
|
+
* all (context management is inherently local; completions never touch
|
|
88
|
+
* vault/server state).
|
|
89
|
+
*
|
|
90
|
+
* Subcommand-level exceptions within an otherwise-classified command (e.g.
|
|
91
|
+
* `config init`, which bootstraps *this machine's* config file and so is
|
|
92
|
+
* local-only despite `config` overall being remote-capable) are handled in
|
|
93
|
+
* getLocalOnlyCommand() itself, not in this top-level map.
|
|
94
|
+
*/
|
|
95
|
+
export type CommandContextSupport = "local-only" | "remote-capable" | "context-agnostic";
|
|
96
|
+
|
|
97
|
+
export const COMMAND_CONTEXT_SUPPORT: Record<string, CommandContextSupport> = {
|
|
98
|
+
context: "context-agnostic",
|
|
99
|
+
config: "remote-capable",
|
|
100
|
+
completions: "context-agnostic",
|
|
101
|
+
serve: "local-only",
|
|
102
|
+
index: "local-only",
|
|
103
|
+
sync: "local-only",
|
|
104
|
+
init: "local-only",
|
|
105
|
+
project: "local-only",
|
|
106
|
+
target: "local-only",
|
|
107
|
+
core: "local-only",
|
|
108
|
+
report: "remote-capable",
|
|
109
|
+
audit: "remote-capable",
|
|
110
|
+
scan: "local-only",
|
|
111
|
+
install: "local-only",
|
|
112
|
+
outdated: "local-only",
|
|
113
|
+
update: "local-only",
|
|
114
|
+
eval: "remote-capable",
|
|
115
|
+
doctor: "remote-capable",
|
|
116
|
+
models: "local-only",
|
|
117
|
+
skill: "local-only",
|
|
118
|
+
"local-vault": "local-only",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const LOCAL_ONLY_COMMANDS = new Set(
|
|
122
|
+
Object.entries(COMMAND_CONTEXT_SUPPORT)
|
|
123
|
+
.filter(([, support]) => support === "local-only")
|
|
124
|
+
.map(([command]) => command),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
export function getLocalOnlyCommand(command: string, subCommand: string): string | null {
|
|
128
|
+
if (command === "skill" && (subCommand === "which" || !subCommand)) {
|
|
129
|
+
return "skill which";
|
|
130
|
+
}
|
|
131
|
+
if (command === "config" && subCommand === "init") {
|
|
132
|
+
return "config init";
|
|
133
|
+
}
|
|
134
|
+
if (LOCAL_ONLY_COMMANDS.has(command)) {
|
|
135
|
+
return command;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Why a local-only command can't take a remote context, keyed by the exact
|
|
142
|
+
* string getLocalOnlyCommand() returns. Drives the guidance sentence
|
|
143
|
+
* remoteContextUnsupported() appends, so the rejection points somewhere
|
|
144
|
+
* useful instead of just saying no.
|
|
145
|
+
*/
|
|
146
|
+
type LocalOnlyReason = "vault-content" | "native-delivery" | "local-runtime" | "local-config";
|
|
147
|
+
|
|
148
|
+
const LOCAL_ONLY_REASON: Record<string, LocalOnlyReason> = {
|
|
149
|
+
install: "vault-content",
|
|
150
|
+
update: "vault-content",
|
|
151
|
+
outdated: "vault-content",
|
|
152
|
+
scan: "vault-content",
|
|
153
|
+
init: "native-delivery",
|
|
154
|
+
sync: "native-delivery",
|
|
155
|
+
target: "native-delivery",
|
|
156
|
+
core: "native-delivery",
|
|
157
|
+
project: "native-delivery",
|
|
158
|
+
"local-vault": "native-delivery",
|
|
159
|
+
"skill which": "native-delivery",
|
|
160
|
+
serve: "local-runtime",
|
|
161
|
+
models: "local-runtime",
|
|
162
|
+
index: "local-runtime",
|
|
163
|
+
"config init": "local-config",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const LOCAL_ONLY_GUIDANCE: Record<LocalOnlyReason, string> = {
|
|
167
|
+
"vault-content":
|
|
168
|
+
"To change a remote deployment's vault contents, update its git-backed source and redeploy or pull on that host — skillmux doesn't replicate vault checkouts over the network.",
|
|
169
|
+
"native-delivery":
|
|
170
|
+
"This manages skill delivery into agent directories on the machine you run it from; there's no remote equivalent — run it on the machine that owns those directories.",
|
|
171
|
+
"local-runtime":
|
|
172
|
+
"This operates on the local runtime process on the machine you run it from.",
|
|
173
|
+
"local-config":
|
|
174
|
+
"This bootstraps this machine's own config file. To inspect or change a remote deployment's configuration, use \"skillmux config show/set --context <name>\" instead.",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function remoteContextUnsupported(rejectedCommand: string): CliError {
|
|
178
|
+
const reason = LOCAL_ONLY_REASON[rejectedCommand];
|
|
179
|
+
const guidance = reason ? ` ${LOCAL_ONLY_GUIDANCE[reason]}` : "";
|
|
180
|
+
return new CliError(
|
|
181
|
+
`\`${rejectedCommand}\` operates on the local vault only; --context/--server isn't supported here.${guidance}`,
|
|
182
|
+
2,
|
|
183
|
+
"REMOTE_CONTEXT_UNSUPPORTED",
|
|
184
|
+
{
|
|
185
|
+
rejected_command: rejectedCommand,
|
|
186
|
+
...(reason ? { reason } : {}),
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
150
191
|
function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
|
|
151
192
|
if (
|
|
152
193
|
[
|
|
@@ -167,7 +208,7 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
|
|
|
167
208
|
}
|
|
168
209
|
|
|
169
210
|
// eval promote only touches the mounted state_dir, unlike bare `eval`
|
|
170
|
-
// (vault ranking evaluation), which needs
|
|
211
|
+
// (vault ranking evaluation), which needs an embeddings client and the vault.
|
|
171
212
|
if (command === "eval" && subCommand !== "promote") return true;
|
|
172
213
|
|
|
173
214
|
return command === "config" && ["init", "set"].includes(subCommand);
|
|
@@ -237,52 +278,60 @@ async function main() {
|
|
|
237
278
|
else if (arg === "--server") flagServer = rawArgv[++i];
|
|
238
279
|
}
|
|
239
280
|
|
|
240
|
-
let
|
|
281
|
+
let resolvedContext: ResolvedContext = { type: "local", name: "local" };
|
|
241
282
|
|
|
242
283
|
if (
|
|
243
284
|
process.env.RUNNING_IN_DOCKER === "true" &&
|
|
244
285
|
isDockerHostManagementCommand(command, subCommand)
|
|
245
286
|
) {
|
|
246
287
|
await handleError(containerCommandUnsupported(command, subCommand), {
|
|
247
|
-
|
|
288
|
+
context: resolvedContext,
|
|
248
289
|
isJson,
|
|
249
290
|
isVerbose,
|
|
250
291
|
});
|
|
251
292
|
return;
|
|
252
293
|
}
|
|
253
294
|
|
|
254
|
-
// Only resolve target if command is target-aware or context/config
|
|
255
|
-
const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
|
|
256
295
|
if (
|
|
257
|
-
(
|
|
258
|
-
|
|
259
|
-
flagContext ||
|
|
260
|
-
flagServer
|
|
296
|
+
(rawArgv.includes("--help") || rawArgv.includes("-h")) &&
|
|
297
|
+
printCommandHelp(command)
|
|
261
298
|
) {
|
|
262
|
-
|
|
263
|
-
resolvedTarget = await resolveTarget({
|
|
264
|
-
context: flagContext,
|
|
265
|
-
server: flagServer,
|
|
266
|
-
});
|
|
267
|
-
} catch (err: any) {
|
|
268
|
-
await handleError(err, { target: resolvedTarget, isJson, isVerbose });
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
299
|
+
return;
|
|
271
300
|
}
|
|
272
301
|
|
|
273
|
-
|
|
302
|
+
try {
|
|
303
|
+
resolvedContext = await resolveContext({
|
|
304
|
+
context: flagContext,
|
|
305
|
+
server: flagServer,
|
|
306
|
+
});
|
|
307
|
+
} catch (err: any) {
|
|
308
|
+
await handleError(err, { context: resolvedContext, isJson, isVerbose });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const localOnlyCommand = getLocalOnlyCommand(command, subCommand);
|
|
313
|
+
if (localOnlyCommand && resolvedContext.type === "remote") {
|
|
314
|
+
await handleError(remoteContextUnsupported(localOnlyCommand), {
|
|
315
|
+
context: resolvedContext,
|
|
316
|
+
isJson,
|
|
317
|
+
isVerbose,
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const adapter = createContextAdapter(resolvedContext, { allowInsecure });
|
|
274
323
|
|
|
275
324
|
try {
|
|
276
325
|
switch (command) {
|
|
277
326
|
case "context":
|
|
278
327
|
await handleContextCommand(subCommand, commandArgs, {
|
|
279
|
-
|
|
328
|
+
context: resolvedContext,
|
|
280
329
|
isJson,
|
|
281
330
|
});
|
|
282
331
|
break;
|
|
283
332
|
case "config":
|
|
284
333
|
await handleConfigCommand(adapter, subCommand, commandArgs, {
|
|
285
|
-
|
|
334
|
+
context: resolvedContext,
|
|
286
335
|
isJson,
|
|
287
336
|
dryRun: isDryRun,
|
|
288
337
|
});
|
|
@@ -341,12 +390,18 @@ async function main() {
|
|
|
341
390
|
case "report":
|
|
342
391
|
await runReport(rawArgv.slice(1), {
|
|
343
392
|
isJson,
|
|
344
|
-
|
|
393
|
+
context: resolvedContext,
|
|
345
394
|
allowInsecure,
|
|
395
|
+
adapter,
|
|
346
396
|
});
|
|
347
397
|
break;
|
|
348
398
|
case "audit":
|
|
349
|
-
await runAudit(subCommand, commandArgs, {
|
|
399
|
+
await runAudit(subCommand, commandArgs, {
|
|
400
|
+
isJson,
|
|
401
|
+
dryRun: isDryRun,
|
|
402
|
+
context: resolvedContext,
|
|
403
|
+
adapter,
|
|
404
|
+
});
|
|
350
405
|
break;
|
|
351
406
|
case "scan":
|
|
352
407
|
await runScan(rawArgv.slice(1), { isJson });
|
|
@@ -362,15 +417,20 @@ async function main() {
|
|
|
362
417
|
break;
|
|
363
418
|
case "eval":
|
|
364
419
|
if (subCommand === "promote") {
|
|
365
|
-
await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
|
|
420
|
+
await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun, adapter });
|
|
366
421
|
} else if (subCommand === "") {
|
|
367
|
-
await runEval({ isJson });
|
|
422
|
+
await runEval({ isJson, adapter });
|
|
368
423
|
} else {
|
|
369
|
-
throw new Error(`usage: skillmux eval [promote --since <window> [--
|
|
424
|
+
throw new Error(`usage: skillmux eval [promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]]`);
|
|
370
425
|
}
|
|
371
426
|
break;
|
|
372
427
|
case "doctor":
|
|
373
|
-
await runDoctor({
|
|
428
|
+
await runDoctor({
|
|
429
|
+
isJson,
|
|
430
|
+
context: resolvedContext,
|
|
431
|
+
adapter,
|
|
432
|
+
args: rawArgv.slice(1),
|
|
433
|
+
});
|
|
374
434
|
break;
|
|
375
435
|
case "which":
|
|
376
436
|
throw new Error(
|
|
@@ -402,99 +462,11 @@ async function main() {
|
|
|
402
462
|
}
|
|
403
463
|
}
|
|
404
464
|
} catch (err: any) {
|
|
405
|
-
await handleError(err, {
|
|
465
|
+
await handleError(err, { context: resolvedContext, isJson, isVerbose });
|
|
406
466
|
}
|
|
407
467
|
}
|
|
408
468
|
|
|
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
469
|
|
|
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
|
-
|
|
496
|
-
throw new Error("usage: skillmux context <add|list|current|use|remove>");
|
|
497
|
-
}
|
|
498
470
|
|
|
499
471
|
async function handleCompletionsCommand(shell: string) {
|
|
500
472
|
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
@@ -505,7 +477,7 @@ async function handleCompletionsCommand(shell: string) {
|
|
|
505
477
|
|
|
506
478
|
async function handleError(
|
|
507
479
|
err: any,
|
|
508
|
-
opts: {
|
|
480
|
+
opts: { context: ResolvedContext; isJson: boolean; isVerbose: boolean },
|
|
509
481
|
) {
|
|
510
482
|
const code = mapExitCode(err);
|
|
511
483
|
process.exitCode = code;
|
|
@@ -526,7 +498,7 @@ async function handleError(
|
|
|
526
498
|
if (opts.isJson) {
|
|
527
499
|
const env = formatJsonEnvelope({
|
|
528
500
|
ok: false,
|
|
529
|
-
|
|
501
|
+
context: opts.context,
|
|
530
502
|
error: {
|
|
531
503
|
code: err instanceof CliError ? err.code : `EXIT_${code}`,
|
|
532
504
|
message: msg,
|
|
@@ -536,11 +508,13 @@ async function handleError(
|
|
|
536
508
|
console.log(JSON.stringify(env));
|
|
537
509
|
} else {
|
|
538
510
|
console.error(
|
|
539
|
-
|
|
540
|
-
msg.startsWith("
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
511
|
+
red(
|
|
512
|
+
msg.startsWith("usage:") ||
|
|
513
|
+
msg.startsWith("Unknown") ||
|
|
514
|
+
msg.startsWith("error:")
|
|
515
|
+
? msg
|
|
516
|
+
: `error: ${msg}`,
|
|
517
|
+
),
|
|
544
518
|
);
|
|
545
519
|
if (opts.isVerbose && err instanceof Error && err.stack) {
|
|
546
520
|
console.error(redact(err.stack));
|
|
@@ -548,6 +522,188 @@ async function handleError(
|
|
|
548
522
|
}
|
|
549
523
|
}
|
|
550
524
|
|
|
525
|
+
const COMMAND_HELP: Record<string, string> = {
|
|
526
|
+
context: `context: manage named CLI contexts for remote administration
|
|
527
|
+
|
|
528
|
+
usage:
|
|
529
|
+
skillmux context list
|
|
530
|
+
skillmux context current
|
|
531
|
+
skillmux context add <name> --server <url> [--token-env <env_name>]
|
|
532
|
+
skillmux context use <name>
|
|
533
|
+
skillmux context remove <name>`,
|
|
534
|
+
|
|
535
|
+
config: `config: inspect or update server/machine configuration
|
|
536
|
+
|
|
537
|
+
usage:
|
|
538
|
+
skillmux config init --vault <path> --yes
|
|
539
|
+
skillmux config show [--sources]
|
|
540
|
+
skillmux config get <key>
|
|
541
|
+
skillmux config set <key> <value> [--dry-run]
|
|
542
|
+
skillmux config validate
|
|
543
|
+
skillmux config diff
|
|
544
|
+
skillmux config status
|
|
545
|
+
|
|
546
|
+
Accepts --context <name> / --server <url> to target a remote deployment.`,
|
|
547
|
+
|
|
548
|
+
completions: `completions: generate a shell completion script
|
|
549
|
+
|
|
550
|
+
usage:
|
|
551
|
+
skillmux completions <bash|zsh|fish>`,
|
|
552
|
+
|
|
553
|
+
serve: `serve: start the MCP server
|
|
554
|
+
|
|
555
|
+
usage:
|
|
556
|
+
skillmux serve [--transport stdio|http] [--port <port>] [--stats-port <port>]
|
|
557
|
+
|
|
558
|
+
--transport defaults to stdio. --stats-port exposes GET /health and GET /stats
|
|
559
|
+
alongside a stdio transport without opening the full HTTP surface.`,
|
|
560
|
+
|
|
561
|
+
index: `index: rebuild the local retrieval index and backfill embeddings
|
|
562
|
+
|
|
563
|
+
usage:
|
|
564
|
+
skillmux index`,
|
|
565
|
+
|
|
566
|
+
sync: `sync: apply the manifest to native agent target directories
|
|
567
|
+
|
|
568
|
+
usage:
|
|
569
|
+
skillmux sync [--dry-run] [--restore-monolith] [--install-hook] [--yes] [--json]`,
|
|
570
|
+
|
|
571
|
+
init: `init: guided setup for native skill management
|
|
572
|
+
|
|
573
|
+
usage:
|
|
574
|
+
skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
|
|
575
|
+
[--migrate-full-vault] [--show-mcp-setup] [--register-mcp]
|
|
576
|
+
[--no-instructions] [--no-sync]
|
|
577
|
+
[--interactive|--yes|--dry-run] [--json]
|
|
578
|
+
|
|
579
|
+
agents: ${SUPPORTED_AGENT_IDS.join(", ")}
|
|
580
|
+
|
|
581
|
+
Native pins and MCP are independent — skip both of the flags below for
|
|
582
|
+
native-only setup, and init writes no instruction files (the managed
|
|
583
|
+
block only teaches resolve_skill/fetch_skill, which are MCP tools).
|
|
584
|
+
--show-mcp-setup prints the MCP registration snippet to copy in yourself,
|
|
585
|
+
for any agent, and also writes the instruction block for every selected
|
|
586
|
+
agent. --register-mcp instead runs that agent's own CLI to register
|
|
587
|
+
skillmux automatically, but only for claude-code and codex (the only
|
|
588
|
+
agents with a verified registration command), and writes the instruction
|
|
589
|
+
block just for those; interactively, init asks about this only when
|
|
590
|
+
you've selected one of those two. --no-instructions forces instruction
|
|
591
|
+
writes off even when an MCP flag is set. A tool not in the agents list
|
|
592
|
+
above isn't supported by init yet — add it to SUPPORTED_AGENT_IDS rather
|
|
593
|
+
than guessing a directory. To adopt an arbitrary existing directory
|
|
594
|
+
directly, use "skillmux target add <name> --dir <dir>" instead of init.`,
|
|
595
|
+
|
|
596
|
+
project: `project: manage project-scoped skill pins and sync groups
|
|
597
|
+
|
|
598
|
+
usage:
|
|
599
|
+
skillmux project init [path] [--name <group>] [--skill <skill_id>...]
|
|
600
|
+
[--agent <name>...] [--target <name>...] [--register-mcp]
|
|
601
|
+
[--no-sync] [--interactive|--yes|--dry-run] [--json]
|
|
602
|
+
skillmux project list
|
|
603
|
+
skillmux project show <group>
|
|
604
|
+
skillmux project add-path <group> [path] --yes
|
|
605
|
+
skillmux project remove-path <group> [path] --yes
|
|
606
|
+
skillmux project pin <group> <skill_id>... --yes
|
|
607
|
+
skillmux project unpin <group> <skill_id>... --yes
|
|
608
|
+
skillmux project attach <group> (--agent <id>... | --target <name>...) --yes
|
|
609
|
+
skillmux project detach <group> (--agent <id>... | --target <name>...) --yes
|
|
610
|
+
|
|
611
|
+
--register-mcp is the project-local counterpart to "skillmux init
|
|
612
|
+
--register-mcp": only for claude-code (the only agent whose own CLI has a
|
|
613
|
+
project MCP scope — codex's mcp add has no scope flag, so it's always
|
|
614
|
+
global). It runs "claude mcp add -s project" for this project directory,
|
|
615
|
+
which writes a committed .mcp.json shared with your team, and writes a
|
|
616
|
+
project-root CLAUDE.md with the resolve_skill/fetch_skill discovery
|
|
617
|
+
paragraph — same reasoning as init: no instruction file is written unless
|
|
618
|
+
MCP is actually being registered.`,
|
|
619
|
+
|
|
620
|
+
target: `target: manage native sync target directories
|
|
621
|
+
|
|
622
|
+
usage:
|
|
623
|
+
skillmux target list
|
|
624
|
+
skillmux target show <name>
|
|
625
|
+
skillmux target add <name> [--dir <dir>] --yes
|
|
626
|
+
skillmux target remove <name> --yes
|
|
627
|
+
|
|
628
|
+
--dir may be omitted when <name> is a built-in target with a deterministic
|
|
629
|
+
path: agent-skills, claude-code, codex. Any other <name> requires --dir.`,
|
|
630
|
+
|
|
631
|
+
core: `core: pin or unpin core-tier skills
|
|
632
|
+
|
|
633
|
+
usage:
|
|
634
|
+
skillmux core pin <skill_id>... --yes
|
|
635
|
+
skillmux core unpin <skill_id>... --yes`,
|
|
636
|
+
|
|
637
|
+
report: `report: show routing/fetch-outcome audit statistics
|
|
638
|
+
|
|
639
|
+
usage:
|
|
640
|
+
skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]`,
|
|
641
|
+
|
|
642
|
+
audit: `audit: prune the audit database
|
|
643
|
+
|
|
644
|
+
usage:
|
|
645
|
+
skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
|
|
646
|
+
|
|
647
|
+
Accepts --context <name> / --server <url> to prune a remote deployment's audit db.`,
|
|
648
|
+
|
|
649
|
+
scan: `scan: check the vault for install-time or integrity issues
|
|
650
|
+
|
|
651
|
+
usage:
|
|
652
|
+
skillmux scan [path] [--format text|json] [--fail-on low|medium|high] [--json]`,
|
|
653
|
+
|
|
654
|
+
install: `install: install a skill from a git source
|
|
655
|
+
|
|
656
|
+
usage:
|
|
657
|
+
skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run] [--allow-local-source] [--json]`,
|
|
658
|
+
|
|
659
|
+
outdated: `outdated: list installed skills with a newer upstream version
|
|
660
|
+
|
|
661
|
+
usage:
|
|
662
|
+
skillmux outdated [--allow-local-source] [--json]`,
|
|
663
|
+
|
|
664
|
+
update: `update: update one or all skills to their latest source version
|
|
665
|
+
|
|
666
|
+
usage:
|
|
667
|
+
skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]`,
|
|
668
|
+
|
|
669
|
+
eval: `eval: run retrieval evaluation against the holdout set
|
|
670
|
+
|
|
671
|
+
usage:
|
|
672
|
+
skillmux eval [--json]
|
|
673
|
+
skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
|
|
674
|
+
|
|
675
|
+
Accepts --context <name> / --server <url> to evaluate a remote deployment.`,
|
|
676
|
+
|
|
677
|
+
doctor: `doctor: check server/environment readiness
|
|
678
|
+
|
|
679
|
+
usage:
|
|
680
|
+
skillmux doctor [--json]
|
|
681
|
+
|
|
682
|
+
Accepts --context <name> / --server <url> to check a remote deployment.`,
|
|
683
|
+
|
|
684
|
+
skill: `skill: inspect local vault skill resolution
|
|
685
|
+
|
|
686
|
+
usage:
|
|
687
|
+
skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)`,
|
|
688
|
+
|
|
689
|
+
"local-vault": `local-vault: register an additional local vault checkout
|
|
690
|
+
|
|
691
|
+
usage:
|
|
692
|
+
skillmux local-vault init <path> --yes`,
|
|
693
|
+
|
|
694
|
+
models: `models: manage local embedding model downloads
|
|
695
|
+
|
|
696
|
+
usage:
|
|
697
|
+
skillmux models download`,
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
function printCommandHelp(command: string): boolean {
|
|
701
|
+
const help = COMMAND_HELP[command];
|
|
702
|
+
if (!help) return false;
|
|
703
|
+
console.log(help);
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
|
|
551
707
|
function printHelp(): void {
|
|
552
708
|
if (process.env.RUNNING_IN_DOCKER === "true") {
|
|
553
709
|
console.log(`Skillmux server image
|
|
@@ -570,35 +726,35 @@ See docs/deployment.md for server deployment examples.`);
|
|
|
570
726
|
|
|
571
727
|
Setup:
|
|
572
728
|
skillmux config init --vault <path> --yes
|
|
573
|
-
skillmux init [--
|
|
574
|
-
[--vault <path>] [--core <skill_id>...]
|
|
729
|
+
skillmux init [--agent <name>...] [--vault <path>] [--core <skill_id>...]
|
|
575
730
|
[--migrate-full-vault] [--no-instructions] [--no-sync]
|
|
576
731
|
[--interactive|--yes|--dry-run] [--json]
|
|
577
732
|
skillmux project init [path] [--name <group>] [--skill <skill_id>...]
|
|
578
|
-
[--
|
|
733
|
+
[--agent <name>...] [--target <name>...] [--no-sync]
|
|
579
734
|
[--interactive|--yes|--dry-run] [--json]
|
|
580
735
|
skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
|
|
581
736
|
skillmux target <list|show|add|remove>
|
|
582
737
|
skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
|
|
583
|
-
skillmux skill which <skill_id>
|
|
584
|
-
|
|
585
|
-
Init clients:
|
|
586
|
-
claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
587
|
-
antigravity, goose, hermes, skillmux-mcp
|
|
738
|
+
skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
|
|
588
739
|
|
|
589
|
-
Init
|
|
590
|
-
|
|
740
|
+
Init agents:
|
|
741
|
+
${SUPPORTED_AGENT_IDS.join(", ")}
|
|
742
|
+
("skillmux init --show-mcp-setup" also prints the MCP registration
|
|
743
|
+
snippet, independent of which agents you select. A tool not in this
|
|
744
|
+
list isn't supported by init yet — see "skillmux init --help".)
|
|
591
745
|
|
|
592
746
|
Operations:
|
|
593
747
|
skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]
|
|
594
748
|
skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
|
|
595
|
-
skillmux eval promote --since <window> [--
|
|
749
|
+
skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]
|
|
596
750
|
skillmux outdated [--allow-local-source] [--json]
|
|
597
751
|
skillmux update [skill-id] [--yes] [--dry-run] [--force] [--allow-local-source] [--fail-on low|medium|high] [--json]
|
|
598
752
|
|
|
599
753
|
Commands:
|
|
600
754
|
serve, index, sync, init, project, target, core, report, audit, scan, install, outdated, update,
|
|
601
|
-
eval, doctor, skill, local-vault, config, models, context, completions
|
|
755
|
+
eval, doctor, skill, local-vault, config, models, context, completions
|
|
756
|
+
|
|
757
|
+
Run "skillmux <command> --help" for a command's full usage.`);
|
|
602
758
|
}
|
|
603
759
|
|
|
604
760
|
// ---------------------------------------------------------------------------
|
|
@@ -649,9 +805,7 @@ async function runIndex(): Promise<void> {
|
|
|
649
805
|
const config = await loadConfig();
|
|
650
806
|
configure({ config, clients: createClients(config) });
|
|
651
807
|
const report = await rebuildIndex((skillId, error) => {
|
|
652
|
-
|
|
653
|
-
`warning: keeping previous index entry for ${skillId}: ${error}`,
|
|
654
|
-
);
|
|
808
|
+
warn(`keeping previous index entry for ${skillId}: ${error}`);
|
|
655
809
|
});
|
|
656
810
|
const retainedNote =
|
|
657
811
|
report.retained.length > 0
|
|
@@ -669,12 +823,14 @@ async function runIndex(): Promise<void> {
|
|
|
669
823
|
}
|
|
670
824
|
}
|
|
671
825
|
|
|
672
|
-
async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
826
|
+
async function runEval(options: { isJson: boolean; adapter: ContextAdapter }): Promise<void> {
|
|
673
827
|
const config = await loadConfig();
|
|
674
828
|
configure({ config, clients: createClients(config) });
|
|
675
829
|
|
|
676
|
-
const report = await
|
|
677
|
-
throw new Error(
|
|
830
|
+
const report = await options.adapter.evalRun().catch((error: unknown) => {
|
|
831
|
+
throw new Error(
|
|
832
|
+
`eval requires an embeddings client (local model or a configured remote endpoint): ${String(error)}`,
|
|
833
|
+
);
|
|
678
834
|
});
|
|
679
835
|
emitSuccess({ isJson: options.isJson }, report, () => {
|
|
680
836
|
console.log(`holdout queries: ${report.queries}`);
|
|
@@ -691,1085 +847,6 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
|
691
847
|
});
|
|
692
848
|
}
|
|
693
849
|
|
|
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
850
|
|
|
1774
851
|
if (import.meta.main) {
|
|
1775
852
|
await main();
|