@klhapp/skillmux 1.0.0 → 1.1.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 +52 -0
- package/README.md +77 -28
- package/config.remote.example.toml +3 -3
- package/docs/configuration.md +39 -4
- package/docs/schema.json +13 -5
- package/package.json +1 -1
- package/src/adapters.ts +98 -18
- package/src/calibrate.ts +468 -178
- package/src/cli.ts +75 -815
- package/src/clients.ts +264 -48
- package/src/commands/config.ts +202 -0
- package/src/commands/core.ts +52 -0
- package/src/commands/project.ts +412 -0
- package/src/commands/shared.ts +45 -0
- package/src/commands/target.ts +110 -0
- package/src/config-mutation.ts +65 -0
- package/src/config-service.ts +25 -8
- package/src/config-watcher.ts +7 -0
- package/src/config.ts +106 -25
- package/src/decision.ts +4 -1
- package/src/doctor.ts +16 -5
- package/src/eval.ts +2 -1
- package/src/router-core.ts +73 -42
- package/src/server.ts +8 -5
- package/src/types.ts +3 -3
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { expandHome } from "../config";
|
|
4
|
+
import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
|
|
5
|
+
import {
|
|
6
|
+
parseManifest,
|
|
7
|
+
pinProject,
|
|
8
|
+
unpinProject,
|
|
9
|
+
updateProjectPaths,
|
|
10
|
+
updateProjectTargets,
|
|
11
|
+
upsertProject,
|
|
12
|
+
validateManifest,
|
|
13
|
+
writeManifestAtomic,
|
|
14
|
+
} from "../manifest";
|
|
15
|
+
import { resolveProjectDirectory, suggestProjectName } from "../project-setup";
|
|
16
|
+
import {
|
|
17
|
+
parseCommaList,
|
|
18
|
+
promptMultiSelect,
|
|
19
|
+
promptText,
|
|
20
|
+
shouldUseWizard,
|
|
21
|
+
} from "../prompts";
|
|
22
|
+
import { emitSuccess, isInteractive } from "../output";
|
|
23
|
+
import { confirmAction, confirmIfNeeded, loadManifestContext } from "./shared";
|
|
24
|
+
const PROJECT_INIT_USAGE =
|
|
25
|
+
"usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
|
|
26
|
+
|
|
27
|
+
interface ProjectInitArgs {
|
|
28
|
+
path: string;
|
|
29
|
+
name: string;
|
|
30
|
+
skills: string[];
|
|
31
|
+
clients: string[];
|
|
32
|
+
targets: string[];
|
|
33
|
+
yes: boolean;
|
|
34
|
+
sync: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function configuredTargetForSurface(
|
|
38
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
39
|
+
surface: { targetName: string; path: string },
|
|
40
|
+
): string | undefined {
|
|
41
|
+
if (manifest.targets[surface.targetName]) return surface.targetName;
|
|
42
|
+
return Object.entries(manifest.targets).find(
|
|
43
|
+
([, target]) => expandHome(target.dir) === surface.path,
|
|
44
|
+
)?.[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configuredTargetsForClients(
|
|
48
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
49
|
+
clients: readonly string[],
|
|
50
|
+
): string[] {
|
|
51
|
+
return planClientSurfaces(clients).surfaces.map((surface) => {
|
|
52
|
+
const target = configuredTargetForSurface(manifest, surface);
|
|
53
|
+
if (target) return target;
|
|
54
|
+
const client = surface.clients[0]!;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseProjectInitArgs(args: string[]): ProjectInitArgs {
|
|
62
|
+
let projectPath: string | undefined;
|
|
63
|
+
let name: string | undefined;
|
|
64
|
+
const skills: string[] = [];
|
|
65
|
+
const clients: string[] = [];
|
|
66
|
+
const targets: string[] = [];
|
|
67
|
+
let yes = false;
|
|
68
|
+
let sync = true;
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < args.length; i++) {
|
|
71
|
+
const arg = args[i]!;
|
|
72
|
+
if (arg === "--name") {
|
|
73
|
+
name = args[++i];
|
|
74
|
+
if (!name) throw new Error("--name requires a group name");
|
|
75
|
+
} else if (arg === "--skill") {
|
|
76
|
+
const skill = args[++i];
|
|
77
|
+
if (!skill) throw new Error("--skill requires a skill_id");
|
|
78
|
+
skills.push(skill);
|
|
79
|
+
} else if (arg === "--target") {
|
|
80
|
+
const target = args[++i];
|
|
81
|
+
if (!target) throw new Error("--target requires a name");
|
|
82
|
+
targets.push(target);
|
|
83
|
+
} else if (arg === "--client") {
|
|
84
|
+
const client = args[++i];
|
|
85
|
+
if (!client) throw new Error("--client requires a name");
|
|
86
|
+
clients.push(client);
|
|
87
|
+
} else if (arg === "--yes") {
|
|
88
|
+
yes = true;
|
|
89
|
+
} else if (arg === "--no-sync") {
|
|
90
|
+
sync = false;
|
|
91
|
+
} else if (
|
|
92
|
+
arg === "--dry-run" ||
|
|
93
|
+
arg === "--json" ||
|
|
94
|
+
arg === "--interactive"
|
|
95
|
+
) {
|
|
96
|
+
continue;
|
|
97
|
+
} else if (arg.startsWith("-")) {
|
|
98
|
+
throw new Error(`unknown project init option: ${arg}`);
|
|
99
|
+
} else if (projectPath) {
|
|
100
|
+
throw new Error(PROJECT_INIT_USAGE);
|
|
101
|
+
} else {
|
|
102
|
+
projectPath = arg;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const path = resolveProjectDirectory(
|
|
107
|
+
projectPath ? expandHome(projectPath) : undefined,
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
path,
|
|
111
|
+
name: name ?? suggestProjectName(basename(path)),
|
|
112
|
+
skills,
|
|
113
|
+
clients,
|
|
114
|
+
targets,
|
|
115
|
+
yes,
|
|
116
|
+
sync,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runProject(
|
|
121
|
+
subCommand: string,
|
|
122
|
+
args: string[],
|
|
123
|
+
options: {
|
|
124
|
+
isJson: boolean;
|
|
125
|
+
dryRun: boolean;
|
|
126
|
+
sync: (args: string[]) => Promise<void>;
|
|
127
|
+
},
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
130
|
+
const { manifest } = await loadManifestContext();
|
|
131
|
+
const names =
|
|
132
|
+
subCommand === "show"
|
|
133
|
+
? [args[0] ?? ""]
|
|
134
|
+
: Object.keys(manifest.project ?? {});
|
|
135
|
+
if (subCommand === "show" && !manifest.project?.[names[0]!]) {
|
|
136
|
+
throw new Error(`[project.${names[0]}] does not exist`);
|
|
137
|
+
}
|
|
138
|
+
const projects = names.map((name) => ({
|
|
139
|
+
name,
|
|
140
|
+
paths: manifest.project?.[name]!.paths ?? [],
|
|
141
|
+
skills: manifest.project?.[name]!.skills ?? [],
|
|
142
|
+
targets: Object.entries(manifest.targets)
|
|
143
|
+
.filter(([, target]) => target.project_groups.includes(name))
|
|
144
|
+
.map(([target]) => target),
|
|
145
|
+
}));
|
|
146
|
+
emitSuccess({ isJson: options.isJson }, { projects }, () => {
|
|
147
|
+
if (projects.length === 0) {
|
|
148
|
+
console.log("no project groups configured");
|
|
149
|
+
} else {
|
|
150
|
+
for (const project of projects) {
|
|
151
|
+
console.log(`${project.name}:`);
|
|
152
|
+
console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
|
|
153
|
+
console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
|
|
154
|
+
console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (subCommand === "add-path" || subCommand === "remove-path") {
|
|
161
|
+
const group = args[0];
|
|
162
|
+
if (!group)
|
|
163
|
+
throw new Error(
|
|
164
|
+
`usage: skillmux project ${subCommand} <group> [path] --yes`,
|
|
165
|
+
);
|
|
166
|
+
const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
|
|
167
|
+
const projectPath = resolveProjectDirectory(
|
|
168
|
+
rawPath ? expandHome(rawPath) : undefined,
|
|
169
|
+
);
|
|
170
|
+
const yes = args.includes("--yes");
|
|
171
|
+
if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
|
|
172
|
+
throw new Error(`project path is not a directory: ${projectPath}`);
|
|
173
|
+
}
|
|
174
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
175
|
+
await loadManifestContext();
|
|
176
|
+
const updated = updateProjectPaths(manifest, group, {
|
|
177
|
+
...(subCommand === "add-path"
|
|
178
|
+
? { add: [projectPath] }
|
|
179
|
+
: { remove: [projectPath] }),
|
|
180
|
+
});
|
|
181
|
+
validateManifest(
|
|
182
|
+
updated,
|
|
183
|
+
vaultPath,
|
|
184
|
+
config.local_vault_paths.map(expandHome),
|
|
185
|
+
);
|
|
186
|
+
if (options.dryRun) {
|
|
187
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
!(await confirmIfNeeded({
|
|
192
|
+
confirmed: yes,
|
|
193
|
+
isJson: options.isJson,
|
|
194
|
+
prompt: `${subCommand} ${projectPath} in [project.${group}]?`,
|
|
195
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
196
|
+
}))
|
|
197
|
+
)
|
|
198
|
+
return;
|
|
199
|
+
writeManifestAtomic(manifestPath, updated);
|
|
200
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (subCommand === "pin" || subCommand === "unpin") {
|
|
204
|
+
const group = args[0];
|
|
205
|
+
const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
|
|
206
|
+
if (!group || skills.length === 0) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const yes = args.includes("--yes");
|
|
212
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
213
|
+
await loadManifestContext();
|
|
214
|
+
let updated = manifest;
|
|
215
|
+
for (const skill of skills) {
|
|
216
|
+
updated =
|
|
217
|
+
subCommand === "pin"
|
|
218
|
+
? pinProject(updated, skill, group)
|
|
219
|
+
: unpinProject(updated, skill, group);
|
|
220
|
+
}
|
|
221
|
+
validateManifest(
|
|
222
|
+
updated,
|
|
223
|
+
vaultPath,
|
|
224
|
+
config.local_vault_paths.map(expandHome),
|
|
225
|
+
);
|
|
226
|
+
if (options.dryRun) {
|
|
227
|
+
console.log(
|
|
228
|
+
`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
!(await confirmIfNeeded({
|
|
234
|
+
confirmed: yes,
|
|
235
|
+
isJson: options.isJson,
|
|
236
|
+
prompt: `${subCommand} ${skills.join(", ")} in [project.${group}]?`,
|
|
237
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
238
|
+
}))
|
|
239
|
+
)
|
|
240
|
+
return;
|
|
241
|
+
writeManifestAtomic(manifestPath, updated);
|
|
242
|
+
console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (subCommand === "attach" || subCommand === "detach") {
|
|
246
|
+
const group = args[0];
|
|
247
|
+
if (!group)
|
|
248
|
+
throw new Error(
|
|
249
|
+
`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
|
|
250
|
+
);
|
|
251
|
+
const clients: string[] = [];
|
|
252
|
+
const requestedTargets: string[] = [];
|
|
253
|
+
for (let i = 1; i < args.length; i++) {
|
|
254
|
+
if (args[i] === "--client") {
|
|
255
|
+
const value = args[++i];
|
|
256
|
+
if (!value) throw new Error("--client requires a name");
|
|
257
|
+
clients.push(value);
|
|
258
|
+
} else if (args[i] === "--target") {
|
|
259
|
+
const value = args[++i];
|
|
260
|
+
if (!value) throw new Error("--target requires a name");
|
|
261
|
+
requestedTargets.push(value);
|
|
262
|
+
} else if (
|
|
263
|
+
args[i] !== "--yes" &&
|
|
264
|
+
args[i] !== "--dry-run" &&
|
|
265
|
+
args[i] !== "--json"
|
|
266
|
+
) {
|
|
267
|
+
throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
271
|
+
await loadManifestContext();
|
|
272
|
+
const clientTargets = configuredTargetsForClients(manifest, clients);
|
|
273
|
+
const targets = [...new Set([...requestedTargets, ...clientTargets])];
|
|
274
|
+
if (targets.length === 0) {
|
|
275
|
+
throw new Error(`project ${subCommand} requires --client or --target`);
|
|
276
|
+
}
|
|
277
|
+
const updated = updateProjectTargets(manifest, group, {
|
|
278
|
+
...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
|
|
279
|
+
});
|
|
280
|
+
validateManifest(
|
|
281
|
+
updated,
|
|
282
|
+
vaultPath,
|
|
283
|
+
config.local_vault_paths.map(expandHome),
|
|
284
|
+
);
|
|
285
|
+
if (options.dryRun) {
|
|
286
|
+
console.log(
|
|
287
|
+
`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (
|
|
292
|
+
!(await confirmIfNeeded({
|
|
293
|
+
confirmed: args.includes("--yes"),
|
|
294
|
+
isJson: options.isJson,
|
|
295
|
+
prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
|
|
296
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
297
|
+
}))
|
|
298
|
+
)
|
|
299
|
+
return;
|
|
300
|
+
writeManifestAtomic(manifestPath, updated);
|
|
301
|
+
console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
|
|
305
|
+
let request = parseProjectInitArgs(args);
|
|
306
|
+
const guided = shouldUseWizard(args, {
|
|
307
|
+
interactive: isInteractive(),
|
|
308
|
+
json: options.isJson,
|
|
309
|
+
dryRun: options.dryRun,
|
|
310
|
+
});
|
|
311
|
+
if (!existsSync(request.path))
|
|
312
|
+
throw new Error(`project path does not exist: ${request.path}`);
|
|
313
|
+
if (!lstatSync(request.path).isDirectory()) {
|
|
314
|
+
throw new Error(`project path is not a directory: ${request.path}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
318
|
+
await loadManifestContext();
|
|
319
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
320
|
+
if (guided) {
|
|
321
|
+
const name = await promptText("Project group", request.name);
|
|
322
|
+
const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
323
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
324
|
+
return (
|
|
325
|
+
surface !== undefined &&
|
|
326
|
+
configuredTargetForSurface(manifest, surface) !== undefined
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
const clients = await promptMultiSelect(
|
|
330
|
+
"Which clients should receive project skills?",
|
|
331
|
+
availableClients.map((client) => ({
|
|
332
|
+
value: client,
|
|
333
|
+
label: client,
|
|
334
|
+
selected:
|
|
335
|
+
request.clients.length === 0 || request.clients.includes(client),
|
|
336
|
+
})),
|
|
337
|
+
);
|
|
338
|
+
const skills = parseCommaList(
|
|
339
|
+
await promptText(
|
|
340
|
+
"Project skill IDs, comma-separated",
|
|
341
|
+
request.skills.join(","),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
request = { ...request, name, clients, skills };
|
|
345
|
+
}
|
|
346
|
+
const clientTargets = configuredTargetsForClients(manifest, request.clients);
|
|
347
|
+
const targets = [...new Set([...request.targets, ...clientTargets])];
|
|
348
|
+
const updated = upsertProject(manifest, {
|
|
349
|
+
name: request.name,
|
|
350
|
+
paths: [request.path],
|
|
351
|
+
skills: request.skills,
|
|
352
|
+
targets,
|
|
353
|
+
});
|
|
354
|
+
const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
|
|
355
|
+
const plan = {
|
|
356
|
+
mode: "project",
|
|
357
|
+
project: request.name,
|
|
358
|
+
path: request.path,
|
|
359
|
+
skills: request.skills,
|
|
360
|
+
clients: request.clients,
|
|
361
|
+
targets,
|
|
362
|
+
sync: request.sync,
|
|
363
|
+
notes,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
if (options.dryRun) {
|
|
367
|
+
emitSuccess({ isJson: options.isJson }, { plan }, () =>
|
|
368
|
+
console.log(`project plan: ${JSON.stringify(plan)}`),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!request.yes) {
|
|
373
|
+
if (!options.isJson && isInteractive()) {
|
|
374
|
+
if (guided) {
|
|
375
|
+
console.log("\nReview");
|
|
376
|
+
console.log(` project: ${request.name}`);
|
|
377
|
+
console.log(` path: ${request.path}`);
|
|
378
|
+
console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
|
|
379
|
+
console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
|
|
380
|
+
console.log(` sync: ${request.sync ? "yes" : "no"}`);
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
!(await confirmAction(
|
|
384
|
+
`Apply project setup for ${request.name} at ${request.path}?`,
|
|
385
|
+
))
|
|
386
|
+
) {
|
|
387
|
+
console.log("project setup cancelled");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"skillmux project init requires --yes when run non-interactively",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
writeManifestAtomic(manifestPath, updated);
|
|
398
|
+
if (request.sync) {
|
|
399
|
+
try {
|
|
400
|
+
await options.sync([]);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
|
|
404
|
+
error instanceof Error ? error.message : String(error)
|
|
405
|
+
}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
emitSuccess({ isJson: options.isJson }, { result: plan }, () =>
|
|
410
|
+
console.log(`project "${request.name}" ready at ${request.path}`),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { expandHome, loadConfig } from "../config";
|
|
3
|
+
import { parseManifest, resolveManifestPath } from "../manifest";
|
|
4
|
+
import { isInteractive } from "../output";
|
|
5
|
+
|
|
6
|
+
export async function confirmAction(prompt: string): Promise<boolean> {
|
|
7
|
+
const readline = createInterface({
|
|
8
|
+
input: process.stdin,
|
|
9
|
+
output: process.stdout,
|
|
10
|
+
});
|
|
11
|
+
try {
|
|
12
|
+
const answer = (await readline.question(`${prompt} [y/N] `))
|
|
13
|
+
.trim()
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
return answer === "y" || answer === "yes";
|
|
16
|
+
} finally {
|
|
17
|
+
readline.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadManifestContext() {
|
|
22
|
+
const config = await loadConfig();
|
|
23
|
+
const vaultPath = expandHome(config.vault_path);
|
|
24
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
25
|
+
if (!manifestPath) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`no skillmux.toml found at ${vaultPath}; run skillmux init first`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
31
|
+
return { config, vaultPath, manifestPath, manifest };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function confirmIfNeeded(opts: {
|
|
35
|
+
confirmed: boolean;
|
|
36
|
+
isJson: boolean;
|
|
37
|
+
prompt: string;
|
|
38
|
+
nonInteractiveError: string;
|
|
39
|
+
}): Promise<boolean> {
|
|
40
|
+
if (opts.confirmed) return true;
|
|
41
|
+
if (opts.isJson || !isInteractive()) {
|
|
42
|
+
throw new Error(opts.nonInteractiveError);
|
|
43
|
+
}
|
|
44
|
+
return confirmAction(opts.prompt);
|
|
45
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { expandHome } from "../config";
|
|
2
|
+
import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
|
|
3
|
+
import { planInitManifest, applyInit } from "../init";
|
|
4
|
+
import { writeManifestAtomic } from "../manifest";
|
|
5
|
+
import { emitSuccess } from "../output";
|
|
6
|
+
import { confirmIfNeeded, loadManifestContext } from "./shared";
|
|
7
|
+
export async function runTarget(
|
|
8
|
+
subCommand: string,
|
|
9
|
+
args: string[],
|
|
10
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const { vaultPath, manifestPath, manifest } = await loadManifestContext();
|
|
13
|
+
|
|
14
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
15
|
+
const names =
|
|
16
|
+
subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
|
|
17
|
+
if (subCommand === "show" && !manifest.targets[names[0]!]) {
|
|
18
|
+
throw new Error(`target "${names[0]}" does not exist`);
|
|
19
|
+
}
|
|
20
|
+
const targets = names.map((name) => {
|
|
21
|
+
const target = manifest.targets[name]!;
|
|
22
|
+
const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
23
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
24
|
+
return surface !== undefined && surface.path === expandHome(target.dir);
|
|
25
|
+
});
|
|
26
|
+
return { name, ...target, clients };
|
|
27
|
+
});
|
|
28
|
+
emitSuccess({ isJson: options.isJson }, { targets }, () => {
|
|
29
|
+
if (targets.length === 0) {
|
|
30
|
+
console.log("no targets configured");
|
|
31
|
+
} else {
|
|
32
|
+
for (const target of targets) {
|
|
33
|
+
console.log(`${target.name}:`);
|
|
34
|
+
console.log(` dir: ${target.dir}`);
|
|
35
|
+
console.log(` host: ${target.host ?? "(global)"}`);
|
|
36
|
+
console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
|
|
37
|
+
console.log(
|
|
38
|
+
` projects: ${target.project_groups.join(", ") || "(none)"}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (subCommand === "add") {
|
|
47
|
+
const name = args[0];
|
|
48
|
+
const dirIndex = args.indexOf("--dir");
|
|
49
|
+
const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
|
|
50
|
+
if (!name || !rawPath)
|
|
51
|
+
throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
|
|
52
|
+
const path = expandHome(rawPath);
|
|
53
|
+
if (options.dryRun) {
|
|
54
|
+
const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
|
|
55
|
+
emitSuccess(
|
|
56
|
+
{ isJson: options.isJson },
|
|
57
|
+
{ target: planned.targets[name] },
|
|
58
|
+
() => console.log(`target add: ${name} -> ${path} (dry-run)`),
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
!(await confirmIfNeeded({
|
|
64
|
+
confirmed: args.includes("--yes"),
|
|
65
|
+
isJson: options.isJson,
|
|
66
|
+
prompt: `Adopt target ${name} at ${path}?`,
|
|
67
|
+
nonInteractiveError:
|
|
68
|
+
"skillmux target add requires --yes when run non-interactively",
|
|
69
|
+
}))
|
|
70
|
+
)
|
|
71
|
+
return;
|
|
72
|
+
applyInit(vaultPath, [{ name, dir: path }]);
|
|
73
|
+
console.log(`target "${name}" added at ${path}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (subCommand === "remove") {
|
|
78
|
+
const name = args[0];
|
|
79
|
+
if (!name || !manifest.targets[name]) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
name
|
|
82
|
+
? `target "${name}" does not exist`
|
|
83
|
+
: "usage: skillmux target remove <name> --yes",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (options.dryRun) {
|
|
87
|
+
console.log(`target remove: ${name} (files preserved, dry-run)`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
!(await confirmIfNeeded({
|
|
92
|
+
confirmed: args.includes("--yes"),
|
|
93
|
+
isJson: options.isJson,
|
|
94
|
+
prompt: `Remove target ${name} from the manifest and preserve its files?`,
|
|
95
|
+
nonInteractiveError:
|
|
96
|
+
"skillmux target remove requires --yes when run non-interactively",
|
|
97
|
+
}))
|
|
98
|
+
)
|
|
99
|
+
return;
|
|
100
|
+
const targets = { ...manifest.targets };
|
|
101
|
+
delete targets[name];
|
|
102
|
+
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
103
|
+
console.log(
|
|
104
|
+
`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error("usage: skillmux target <list|show|add|remove>");
|
|
110
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface ThresholdsPatchOptions {
|
|
4
|
+
matchScore: number;
|
|
5
|
+
matchMargin: number;
|
|
6
|
+
candidateFloor: number;
|
|
7
|
+
runId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Remove a TOML section header and all lines until the next section header
|
|
12
|
+
* (or end of file). Matches exact header string at start of line.
|
|
13
|
+
*/
|
|
14
|
+
export function removeSectionBlock(source: string, header: string): string {
|
|
15
|
+
const lines = source.split("\n");
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
let skipping = false;
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trimEnd();
|
|
20
|
+
if (trimmed === header || trimmed.startsWith(header + " ")) {
|
|
21
|
+
skipping = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (skipping && line.trimStart().startsWith("[")) {
|
|
25
|
+
skipping = false;
|
|
26
|
+
}
|
|
27
|
+
if (!skipping) out.push(line);
|
|
28
|
+
}
|
|
29
|
+
return out.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Surgically patch a TOML string to set [inference.thresholds] values and
|
|
34
|
+
* [inference.calibration] run_id while preserving unrelated sections and comments.
|
|
35
|
+
*/
|
|
36
|
+
export function patchToml(
|
|
37
|
+
source: string,
|
|
38
|
+
opts: ThresholdsPatchOptions,
|
|
39
|
+
): string {
|
|
40
|
+
const thresholdsBlock = `[inference.thresholds]\nmatch_score = ${opts.matchScore}\nmatch_margin = ${opts.matchMargin}\ncandidate_floor = ${opts.candidateFloor}\n`;
|
|
41
|
+
const calibrationBlock = `[inference.calibration]\nrun_id = "${opts.runId}"\n`;
|
|
42
|
+
|
|
43
|
+
// Remove any existing [inference.thresholds] and [inference.calibration] sections
|
|
44
|
+
let result = removeSectionBlock(source, "[inference.thresholds]");
|
|
45
|
+
result = removeSectionBlock(result, "[inference.calibration]");
|
|
46
|
+
|
|
47
|
+
// Append both sections cleanly
|
|
48
|
+
result = result.trimEnd() + "\n\n" + thresholdsBlock + "\n" + calibrationBlock;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Atomically write patched TOML to disk via temp file write and renameSync.
|
|
54
|
+
*/
|
|
55
|
+
export async function patchTomlFile(
|
|
56
|
+
tomlPath: string,
|
|
57
|
+
opts: ThresholdsPatchOptions,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const existing = await Bun.file(tomlPath).text();
|
|
60
|
+
const patched = patchToml(existing, opts);
|
|
61
|
+
|
|
62
|
+
const tmpPath = `${tomlPath}.${process.pid}.tmp`;
|
|
63
|
+
writeFileSync(tmpPath, patched);
|
|
64
|
+
renameSync(tmpPath, tomlPath);
|
|
65
|
+
}
|
package/src/config-service.ts
CHANGED
|
@@ -44,6 +44,10 @@ export const RESTART_REQUIRED_KEYS = [
|
|
|
44
44
|
"inference.bundle",
|
|
45
45
|
"inference.models_dir",
|
|
46
46
|
"state_dir",
|
|
47
|
+
"inference.embedding.model",
|
|
48
|
+
"inference.embedding.dimension",
|
|
49
|
+
"inference.embedding.device",
|
|
50
|
+
"inference.embedding.dtype",
|
|
47
51
|
];
|
|
48
52
|
|
|
49
53
|
export const RELOADABLE_KEYS = [
|
|
@@ -54,12 +58,13 @@ export const RELOADABLE_KEYS = [
|
|
|
54
58
|
"thresholds.match_score",
|
|
55
59
|
"thresholds.match_margin",
|
|
56
60
|
"thresholds.candidate_floor",
|
|
57
|
-
"inference.embedding.
|
|
58
|
-
"inference.embedding.dimension",
|
|
59
|
-
"inference.embedding.device",
|
|
60
|
-
"inference.embedding.dtype",
|
|
61
|
-
"inference.embedding.base_url",
|
|
61
|
+
"inference.embedding.endpoint",
|
|
62
62
|
"inference.embedding.api_key_env",
|
|
63
|
+
"inference.reranker.adapter",
|
|
64
|
+
"inference.reranker.endpoint",
|
|
65
|
+
"inference.reranker.model",
|
|
66
|
+
"inference.reranker.api_key_env",
|
|
67
|
+
"inference.timeout_ms",
|
|
63
68
|
"server.rate_limit.enabled",
|
|
64
69
|
"server.rate_limit.requests_per_minute",
|
|
65
70
|
"server.rate_limit.trust_proxy",
|
|
@@ -146,8 +151,12 @@ export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
|
146
151
|
"inference.embedding.dimension",
|
|
147
152
|
"inference.embedding.device",
|
|
148
153
|
"inference.embedding.dtype",
|
|
149
|
-
"inference.embedding.
|
|
154
|
+
"inference.embedding.endpoint",
|
|
150
155
|
"inference.embedding.api_key_env",
|
|
156
|
+
"inference.reranker.adapter",
|
|
157
|
+
"inference.reranker.endpoint",
|
|
158
|
+
"inference.reranker.model",
|
|
159
|
+
"inference.reranker.api_key_env",
|
|
151
160
|
"inference.timeout_ms",
|
|
152
161
|
"server.auth_enabled",
|
|
153
162
|
"server.auth_token_env",
|
|
@@ -178,8 +187,12 @@ export function isEnvMasked(key: string): boolean {
|
|
|
178
187
|
if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
|
|
179
188
|
if (key === "inference.embedding.device" && process.env.EMBED_DEVICE) return true;
|
|
180
189
|
if (key === "inference.embedding.dtype" && process.env.EMBED_DTYPE) return true;
|
|
181
|
-
if (key === "inference.embedding.
|
|
190
|
+
if (key === "inference.embedding.endpoint" && (process.env.SKILLMUX_EMBED_ENDPOINT || process.env.EMBED_ENDPOINT)) return true;
|
|
182
191
|
if (key === "inference.embedding.model" && (process.env.SKILLMUX_EMBED_MODEL || process.env.EMBED_MODEL)) return true;
|
|
192
|
+
if (key === "inference.embedding.dimension" && (process.env.SKILLMUX_EMBED_DIMENSION || process.env.EMBED_DIMENSION)) return true;
|
|
193
|
+
if (key === "inference.reranker.adapter" && (process.env.SKILLMUX_RERANK_ADAPTER || process.env.RERANK_ADAPTER)) return true;
|
|
194
|
+
if (key === "inference.reranker.endpoint" && (process.env.SKILLMUX_RERANK_ENDPOINT || process.env.RERANK_ENDPOINT)) return true;
|
|
195
|
+
if (key === "inference.reranker.model" && (process.env.SKILLMUX_RERANK_MODEL || process.env.RERANK_MODEL)) return true;
|
|
183
196
|
if (key === "server.auth_enabled" && process.env.HTTP_AUTH_ENABLED) return true;
|
|
184
197
|
if (key === "server.auth_token_env" && process.env.HTTP_AUTH_TOKEN_ENV) return true;
|
|
185
198
|
if (key === "server.hostname" && process.env.HTTP_HOSTNAME) return true;
|
|
@@ -206,8 +219,12 @@ export function validateDottedKey(key: string): void {
|
|
|
206
219
|
"inference.embedding.dimension",
|
|
207
220
|
"inference.embedding.device",
|
|
208
221
|
"inference.embedding.dtype",
|
|
209
|
-
"inference.embedding.
|
|
222
|
+
"inference.embedding.endpoint",
|
|
210
223
|
"inference.embedding.api_key_env",
|
|
224
|
+
"inference.reranker.adapter",
|
|
225
|
+
"inference.reranker.endpoint",
|
|
226
|
+
"inference.reranker.model",
|
|
227
|
+
"inference.reranker.api_key_env",
|
|
211
228
|
"inference.timeout_ms",
|
|
212
229
|
"server.auth_enabled",
|
|
213
230
|
"server.auth_token_env",
|