@klhapp/skillmux 0.4.5 → 0.6.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 +47 -0
- package/README.md +85 -9
- package/docs/configuration.md +45 -7
- package/package.json +1 -1
- package/src/cli.ts +1042 -38
- package/src/completions.ts +79 -3
- package/src/config.ts +7 -3
- package/src/init-clients.ts +220 -0
- package/src/init-instructions.ts +173 -0
- package/src/init.ts +280 -17
- package/src/manifest.ts +105 -4
- package/src/output.ts +5 -6
- package/src/project-setup.ts +36 -0
- package/src/prompts.ts +69 -0
- package/src/setup.ts +145 -0
- package/src/sync.ts +128 -11
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
5
7
|
import { generateDataset } from "./dataset-generator";
|
|
6
8
|
|
|
7
9
|
import { createClients } from "./clients";
|
|
8
|
-
import { loadConfig } from "./config";
|
|
9
|
-
import { expandHome } from "./config";
|
|
10
|
+
import { expandHome, loadConfig, migrateLegacyPaths, resolveConfigPath } from "./config";
|
|
10
11
|
import { openIndex } from "./db";
|
|
11
12
|
import { diagnose } from "./doctor";
|
|
12
13
|
import { evalVault } from "./eval";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
assessClientReadiness,
|
|
16
|
+
detectInstalledClients,
|
|
17
|
+
planClientSurfaces,
|
|
18
|
+
resolveBuiltInTarget,
|
|
19
|
+
SUPPORTED_CLIENT_IDS,
|
|
20
|
+
type ClientId,
|
|
21
|
+
type ReadinessAxis,
|
|
22
|
+
} from "./init-clients";
|
|
23
|
+
import {
|
|
24
|
+
applyInstructionPlan,
|
|
25
|
+
planInstructionSetup,
|
|
26
|
+
rollbackInstructionPlan,
|
|
27
|
+
} from "./init-instructions";
|
|
28
|
+
import {
|
|
29
|
+
applyInit,
|
|
30
|
+
deriveTargetName,
|
|
31
|
+
detectSurfaces,
|
|
32
|
+
planInitManifest,
|
|
33
|
+
printLastMile,
|
|
34
|
+
surfaceCandidates,
|
|
35
|
+
} from "./init";
|
|
14
36
|
import {
|
|
15
37
|
cloneToTemp,
|
|
16
38
|
deriveRepoName,
|
|
@@ -27,11 +49,24 @@ import {
|
|
|
27
49
|
serializeManifest,
|
|
28
50
|
unpinCore,
|
|
29
51
|
unpinProject,
|
|
52
|
+
upsertProject,
|
|
53
|
+
updateProjectPaths,
|
|
54
|
+
updateProjectTargets,
|
|
30
55
|
validateManifest,
|
|
56
|
+
writeManifestAtomic,
|
|
31
57
|
} from "./manifest";
|
|
32
58
|
import { downloadLocalModels } from "./models";
|
|
59
|
+
import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
|
|
60
|
+
import { parseCommaList, promptMultiSelect, promptText, shouldUseWizard } from "./prompts";
|
|
33
61
|
import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
|
|
34
62
|
import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
|
|
63
|
+
import {
|
|
64
|
+
applyConfigInit,
|
|
65
|
+
inspectVault,
|
|
66
|
+
planConfigInit,
|
|
67
|
+
rollbackConfigInit,
|
|
68
|
+
type ConfigInitPlan,
|
|
69
|
+
} from "./setup";
|
|
35
70
|
import { getStats, renderStatsText, type StatsResponse } from "./stats";
|
|
36
71
|
import {
|
|
37
72
|
installPostMergeHook,
|
|
@@ -64,6 +99,8 @@ const KNOWN_COMMANDS = [
|
|
|
64
99
|
"index",
|
|
65
100
|
"sync",
|
|
66
101
|
"init",
|
|
102
|
+
"project",
|
|
103
|
+
"target",
|
|
67
104
|
"report",
|
|
68
105
|
"scan",
|
|
69
106
|
"install",
|
|
@@ -105,7 +142,8 @@ async function main() {
|
|
|
105
142
|
let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
|
|
106
143
|
|
|
107
144
|
// Only resolve target if command is target-aware or context/config/calibrate
|
|
108
|
-
|
|
145
|
+
const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
|
|
146
|
+
if ((["context", "config", "calibrate"].includes(command) && !isLocalConfigInit) || flagContext || flagServer) {
|
|
109
147
|
try {
|
|
110
148
|
resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
|
|
111
149
|
} catch (err: any) {
|
|
@@ -161,7 +199,13 @@ async function main() {
|
|
|
161
199
|
await runSync(rawArgv.slice(1));
|
|
162
200
|
break;
|
|
163
201
|
case "init":
|
|
164
|
-
await runInit(rawArgv.slice(1));
|
|
202
|
+
await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
|
|
203
|
+
break;
|
|
204
|
+
case "project":
|
|
205
|
+
await runProject(subCommand, commandArgs, { isJson, dryRun: isDryRun });
|
|
206
|
+
break;
|
|
207
|
+
case "target":
|
|
208
|
+
await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
|
|
165
209
|
break;
|
|
166
210
|
case "report":
|
|
167
211
|
await runReport(rawArgv.slice(1));
|
|
@@ -196,7 +240,7 @@ async function main() {
|
|
|
196
240
|
const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
|
|
197
241
|
const msg = suggestion
|
|
198
242
|
? `Unknown command "${command}". Did you mean "${suggestion}"?`
|
|
199
|
-
: `usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
|
|
243
|
+
: `usage: skillmux <serve|index|sync|init|project|target|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
|
|
200
244
|
throw new Error(msg);
|
|
201
245
|
}
|
|
202
246
|
}
|
|
@@ -293,6 +337,88 @@ async function handleConfigCommand(
|
|
|
293
337
|
args: string[],
|
|
294
338
|
ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
|
|
295
339
|
) {
|
|
340
|
+
if (sub === "init") {
|
|
341
|
+
let vaultPath: string | undefined;
|
|
342
|
+
let yes = false;
|
|
343
|
+
for (let i = 0; i < args.length; i++) {
|
|
344
|
+
const option = args[i];
|
|
345
|
+
if (option === "--vault") {
|
|
346
|
+
vaultPath = args[++i];
|
|
347
|
+
if (!vaultPath) throw new Error("usage: skillmux config init --vault <path> --yes");
|
|
348
|
+
} else if (option === "--yes") {
|
|
349
|
+
yes = true;
|
|
350
|
+
} else if (option === "--dry-run" || option === "--json") {
|
|
351
|
+
continue;
|
|
352
|
+
} else {
|
|
353
|
+
throw new Error(`unknown config init option: ${option}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!vaultPath) {
|
|
357
|
+
if (isInteractive() && !ctx.isJson) {
|
|
358
|
+
vaultPath = "~/skills";
|
|
359
|
+
} else {
|
|
360
|
+
throw new Error("usage: skillmux config init --vault <path> --yes");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
migrateLegacyPaths();
|
|
365
|
+
const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
|
|
366
|
+
if (plan.action === "preserve") {
|
|
367
|
+
console.log(ctx.isJson
|
|
368
|
+
? JSON.stringify({
|
|
369
|
+
schema_version: 1,
|
|
370
|
+
ok: true,
|
|
371
|
+
command: "config init",
|
|
372
|
+
phase: "result",
|
|
373
|
+
dry_run: ctx.dryRun,
|
|
374
|
+
applied: false,
|
|
375
|
+
plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "preserve" },
|
|
376
|
+
})
|
|
377
|
+
: `preserved existing config: ${plan.configPath}`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (ctx.dryRun) {
|
|
381
|
+
console.log(ctx.isJson
|
|
382
|
+
? JSON.stringify({
|
|
383
|
+
schema_version: 1,
|
|
384
|
+
ok: true,
|
|
385
|
+
command: "config init",
|
|
386
|
+
phase: "plan",
|
|
387
|
+
dry_run: true,
|
|
388
|
+
applied: false,
|
|
389
|
+
plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "create" },
|
|
390
|
+
})
|
|
391
|
+
: `config create: ${plan.configPath} (dry-run)`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!yes) {
|
|
395
|
+
if (!ctx.isJson && isInteractive()) {
|
|
396
|
+
if (!(await confirmAction(`Create ${plan.configPath} with vault_path ${plan.vaultPath}?`))) {
|
|
397
|
+
console.log("config init cancelled; nothing written");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
throw new Error("config initialization requires --yes in noninteractive mode");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const result = applyConfigInit(plan);
|
|
406
|
+
console.log(ctx.isJson
|
|
407
|
+
? JSON.stringify({
|
|
408
|
+
schema_version: 1,
|
|
409
|
+
ok: true,
|
|
410
|
+
command: "config init",
|
|
411
|
+
phase: "result",
|
|
412
|
+
dry_run: false,
|
|
413
|
+
applied: result === "created",
|
|
414
|
+
plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: plan.action },
|
|
415
|
+
})
|
|
416
|
+
: result === "created"
|
|
417
|
+
? `created ${plan.configPath}`
|
|
418
|
+
: `preserved existing config: ${plan.configPath}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
296
422
|
if (sub === "show") {
|
|
297
423
|
const data = await adapter.getConfigShow();
|
|
298
424
|
if (ctx.isJson) {
|
|
@@ -371,6 +497,16 @@ async function handleConfigCommand(
|
|
|
371
497
|
throw new Error("usage: skillmux config show");
|
|
372
498
|
}
|
|
373
499
|
|
|
500
|
+
async function confirmAction(prompt: string): Promise<boolean> {
|
|
501
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
502
|
+
try {
|
|
503
|
+
const answer = (await readline.question(`${prompt} [y/N] `)).trim().toLowerCase();
|
|
504
|
+
return answer === "y" || answer === "yes";
|
|
505
|
+
} finally {
|
|
506
|
+
readline.close();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
374
510
|
async function handleCalibrateCommand(
|
|
375
511
|
adapter: TargetAdapter,
|
|
376
512
|
sub: string,
|
|
@@ -477,7 +613,30 @@ function handleError(
|
|
|
477
613
|
}
|
|
478
614
|
|
|
479
615
|
function printHelp(): void {
|
|
480
|
-
console.log(`usage: skillmux <
|
|
616
|
+
console.log(`usage: skillmux <command> [options]
|
|
617
|
+
|
|
618
|
+
Setup:
|
|
619
|
+
skillmux config init --vault <path> --yes
|
|
620
|
+
skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
|
|
621
|
+
[--vault <path>] [--core <skill_id>...]
|
|
622
|
+
[--migrate-full-vault] [--no-instructions] [--no-sync]
|
|
623
|
+
[--interactive|--yes|--dry-run] [--json]
|
|
624
|
+
skillmux project init [path] [--name <group>] [--skill <skill_id>...]
|
|
625
|
+
[--client <name>...] [--target <name>...] [--no-sync]
|
|
626
|
+
[--interactive|--yes|--dry-run] [--json]
|
|
627
|
+
skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
|
|
628
|
+
skillmux target <list|show|add|remove>
|
|
629
|
+
|
|
630
|
+
Init clients:
|
|
631
|
+
claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
632
|
+
antigravity, goose, hermes, skillmux-mcp
|
|
633
|
+
|
|
634
|
+
Init targets:
|
|
635
|
+
agent-skills, claude-code, codex, custom
|
|
636
|
+
|
|
637
|
+
Commands:
|
|
638
|
+
serve, index, sync, init, project, target, report, scan, install, eval, doctor, which,
|
|
639
|
+
manifest, local-vault, config, models, calibrate, context, completions`);
|
|
481
640
|
}
|
|
482
641
|
|
|
483
642
|
// ---------------------------------------------------------------------------
|
|
@@ -576,6 +735,429 @@ async function runWhich(args: string[]): Promise<void> {
|
|
|
576
735
|
}
|
|
577
736
|
|
|
578
737
|
const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
|
|
738
|
+
const PROJECT_INIT_USAGE =
|
|
739
|
+
"usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
|
|
740
|
+
|
|
741
|
+
interface ProjectInitArgs {
|
|
742
|
+
path: string;
|
|
743
|
+
name: string;
|
|
744
|
+
skills: string[];
|
|
745
|
+
clients: string[];
|
|
746
|
+
targets: string[];
|
|
747
|
+
yes: boolean;
|
|
748
|
+
sync: boolean;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function configuredTargetForSurface(
|
|
752
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
753
|
+
surface: { targetName: string; path: string },
|
|
754
|
+
): string | undefined {
|
|
755
|
+
if (manifest.targets[surface.targetName]) return surface.targetName;
|
|
756
|
+
return Object.entries(manifest.targets).find(
|
|
757
|
+
([, target]) => expandHome(target.dir) === surface.path,
|
|
758
|
+
)?.[0];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function configuredTargetsForClients(
|
|
762
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
763
|
+
clients: readonly string[],
|
|
764
|
+
): string[] {
|
|
765
|
+
return planClientSurfaces(clients).surfaces.map((surface) => {
|
|
766
|
+
const target = configuredTargetForSurface(manifest, surface);
|
|
767
|
+
if (target) return target;
|
|
768
|
+
const client = surface.clients[0]!;
|
|
769
|
+
throw new Error(
|
|
770
|
+
`client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
|
|
771
|
+
);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function parseProjectInitArgs(args: string[]): ProjectInitArgs {
|
|
776
|
+
let projectPath: string | undefined;
|
|
777
|
+
let name: string | undefined;
|
|
778
|
+
const skills: string[] = [];
|
|
779
|
+
const clients: string[] = [];
|
|
780
|
+
const targets: string[] = [];
|
|
781
|
+
let yes = false;
|
|
782
|
+
let sync = true;
|
|
783
|
+
|
|
784
|
+
for (let i = 0; i < args.length; i++) {
|
|
785
|
+
const arg = args[i]!;
|
|
786
|
+
if (arg === "--name") {
|
|
787
|
+
name = args[++i];
|
|
788
|
+
if (!name) throw new Error("--name requires a group name");
|
|
789
|
+
} else if (arg === "--skill") {
|
|
790
|
+
const skill = args[++i];
|
|
791
|
+
if (!skill) throw new Error("--skill requires a skill_id");
|
|
792
|
+
skills.push(skill);
|
|
793
|
+
} else if (arg === "--target") {
|
|
794
|
+
const target = args[++i];
|
|
795
|
+
if (!target) throw new Error("--target requires a name");
|
|
796
|
+
targets.push(target);
|
|
797
|
+
} else if (arg === "--client") {
|
|
798
|
+
const client = args[++i];
|
|
799
|
+
if (!client) throw new Error("--client requires a name");
|
|
800
|
+
clients.push(client);
|
|
801
|
+
} else if (arg === "--yes") {
|
|
802
|
+
yes = true;
|
|
803
|
+
} else if (arg === "--no-sync") {
|
|
804
|
+
sync = false;
|
|
805
|
+
} else if (arg === "--dry-run" || arg === "--json" || arg === "--interactive") {
|
|
806
|
+
continue;
|
|
807
|
+
} else if (arg.startsWith("-")) {
|
|
808
|
+
throw new Error(`unknown project init option: ${arg}`);
|
|
809
|
+
} else if (projectPath) {
|
|
810
|
+
throw new Error(PROJECT_INIT_USAGE);
|
|
811
|
+
} else {
|
|
812
|
+
projectPath = arg;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const path = resolveProjectDirectory(projectPath ? expandHome(projectPath) : undefined);
|
|
817
|
+
return { path, name: name ?? suggestProjectName(basename(path)), skills, clients, targets, yes, sync };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function runProject(
|
|
821
|
+
subCommand: string,
|
|
822
|
+
args: string[],
|
|
823
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
826
|
+
const config = await loadConfig();
|
|
827
|
+
const vaultPath = expandHome(config.vault_path);
|
|
828
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
829
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
830
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
831
|
+
const names = subCommand === "show"
|
|
832
|
+
? [args[0] ?? ""]
|
|
833
|
+
: Object.keys(manifest.project ?? {});
|
|
834
|
+
if (subCommand === "show" && !manifest.project?.[names[0]!]) {
|
|
835
|
+
throw new Error(`[project.${names[0]}] does not exist`);
|
|
836
|
+
}
|
|
837
|
+
const projects = names.map((name) => ({
|
|
838
|
+
name,
|
|
839
|
+
paths: manifest.project?.[name]!.paths ?? [],
|
|
840
|
+
skills: manifest.project?.[name]!.skills ?? [],
|
|
841
|
+
targets: Object.entries(manifest.targets)
|
|
842
|
+
.filter(([, target]) => target.project_groups.includes(name))
|
|
843
|
+
.map(([target]) => target),
|
|
844
|
+
}));
|
|
845
|
+
if (options.isJson) {
|
|
846
|
+
console.log(JSON.stringify({ schema_version: 1, projects }));
|
|
847
|
+
} else if (projects.length === 0) {
|
|
848
|
+
console.log("no project groups configured");
|
|
849
|
+
} else {
|
|
850
|
+
for (const project of projects) {
|
|
851
|
+
console.log(`${project.name}:`);
|
|
852
|
+
console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
|
|
853
|
+
console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
|
|
854
|
+
console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (subCommand === "add-path" || subCommand === "remove-path") {
|
|
860
|
+
const group = args[0];
|
|
861
|
+
if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> [path] --yes`);
|
|
862
|
+
const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
|
|
863
|
+
const projectPath = resolveProjectDirectory(rawPath ? expandHome(rawPath) : undefined);
|
|
864
|
+
const yes = args.includes("--yes");
|
|
865
|
+
if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
|
|
866
|
+
throw new Error(`project path is not a directory: ${projectPath}`);
|
|
867
|
+
}
|
|
868
|
+
const config = await loadConfig();
|
|
869
|
+
const vaultPath = expandHome(config.vault_path);
|
|
870
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
871
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
872
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
873
|
+
const updated = updateProjectPaths(manifest, group, {
|
|
874
|
+
...(subCommand === "add-path" ? { add: [projectPath] } : { remove: [projectPath] }),
|
|
875
|
+
});
|
|
876
|
+
validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
|
|
877
|
+
if (options.dryRun) {
|
|
878
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (!yes) {
|
|
882
|
+
if (!options.isJson && isInteractive()) {
|
|
883
|
+
if (!(await confirmAction(`${subCommand} ${projectPath} in [project.${group}]?`))) return;
|
|
884
|
+
} else {
|
|
885
|
+
throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
writeManifestAtomic(manifestPath, updated);
|
|
889
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath}`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (subCommand === "pin" || subCommand === "unpin") {
|
|
893
|
+
const group = args[0];
|
|
894
|
+
const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
|
|
895
|
+
if (!group || skills.length === 0) {
|
|
896
|
+
throw new Error(`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`);
|
|
897
|
+
}
|
|
898
|
+
const yes = args.includes("--yes");
|
|
899
|
+
const config = await loadConfig();
|
|
900
|
+
const vaultPath = expandHome(config.vault_path);
|
|
901
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
902
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
903
|
+
let updated = parseManifest(await Bun.file(manifestPath).text());
|
|
904
|
+
for (const skill of skills) {
|
|
905
|
+
updated = subCommand === "pin"
|
|
906
|
+
? pinProject(updated, skill, group)
|
|
907
|
+
: unpinProject(updated, skill, group);
|
|
908
|
+
}
|
|
909
|
+
validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
|
|
910
|
+
if (options.dryRun) {
|
|
911
|
+
console.log(`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (!yes) {
|
|
915
|
+
if (!options.isJson && isInteractive()) {
|
|
916
|
+
if (!(await confirmAction(`${subCommand} ${skills.join(", ")} in [project.${group}]?`))) return;
|
|
917
|
+
} else {
|
|
918
|
+
throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
writeManifestAtomic(manifestPath, updated);
|
|
922
|
+
console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (subCommand === "attach" || subCommand === "detach") {
|
|
926
|
+
const group = args[0];
|
|
927
|
+
if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`);
|
|
928
|
+
const clients: string[] = [];
|
|
929
|
+
const requestedTargets: string[] = [];
|
|
930
|
+
for (let i = 1; i < args.length; i++) {
|
|
931
|
+
if (args[i] === "--client") {
|
|
932
|
+
const value = args[++i];
|
|
933
|
+
if (!value) throw new Error("--client requires a name");
|
|
934
|
+
clients.push(value);
|
|
935
|
+
} else if (args[i] === "--target") {
|
|
936
|
+
const value = args[++i];
|
|
937
|
+
if (!value) throw new Error("--target requires a name");
|
|
938
|
+
requestedTargets.push(value);
|
|
939
|
+
} else if (args[i] !== "--yes" && args[i] !== "--dry-run" && args[i] !== "--json") {
|
|
940
|
+
throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const config = await loadConfig();
|
|
944
|
+
const vaultPath = expandHome(config.vault_path);
|
|
945
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
946
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
947
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
948
|
+
const clientTargets = configuredTargetsForClients(manifest, clients);
|
|
949
|
+
const targets = [...new Set([...requestedTargets, ...clientTargets])];
|
|
950
|
+
if (targets.length === 0) {
|
|
951
|
+
throw new Error(`project ${subCommand} requires --client or --target`);
|
|
952
|
+
}
|
|
953
|
+
const updated = updateProjectTargets(manifest, group, {
|
|
954
|
+
...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
|
|
955
|
+
});
|
|
956
|
+
validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
|
|
957
|
+
if (options.dryRun) {
|
|
958
|
+
console.log(`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (!args.includes("--yes")) {
|
|
962
|
+
if (!options.isJson && isInteractive()) {
|
|
963
|
+
if (!(await confirmAction(`${subCommand} [project.${group}] to ${targets.join(", ")}?`))) return;
|
|
964
|
+
} else {
|
|
965
|
+
throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
writeManifestAtomic(manifestPath, updated);
|
|
969
|
+
console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
|
|
973
|
+
let request = parseProjectInitArgs(args);
|
|
974
|
+
const guided = shouldUseWizard(args, {
|
|
975
|
+
interactive: isInteractive(),
|
|
976
|
+
json: options.isJson,
|
|
977
|
+
dryRun: options.dryRun,
|
|
978
|
+
});
|
|
979
|
+
if (!existsSync(request.path)) throw new Error(`project path does not exist: ${request.path}`);
|
|
980
|
+
if (!lstatSync(request.path).isDirectory()) {
|
|
981
|
+
throw new Error(`project path is not a directory: ${request.path}`);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const config = await loadConfig();
|
|
985
|
+
const vaultPath = expandHome(config.vault_path);
|
|
986
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
987
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
988
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
989
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
990
|
+
if (guided) {
|
|
991
|
+
const name = await promptText("Project group", request.name);
|
|
992
|
+
const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
993
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
994
|
+
return surface !== undefined && configuredTargetForSurface(manifest, surface) !== undefined;
|
|
995
|
+
});
|
|
996
|
+
const clients = await promptMultiSelect(
|
|
997
|
+
"Which clients should receive project skills?",
|
|
998
|
+
availableClients.map((client) => ({
|
|
999
|
+
value: client,
|
|
1000
|
+
label: client,
|
|
1001
|
+
selected: request.clients.length === 0 || request.clients.includes(client),
|
|
1002
|
+
})),
|
|
1003
|
+
);
|
|
1004
|
+
const skills = parseCommaList(
|
|
1005
|
+
await promptText("Project skill IDs, comma-separated", request.skills.join(",")),
|
|
1006
|
+
);
|
|
1007
|
+
request = { ...request, name, clients, skills };
|
|
1008
|
+
}
|
|
1009
|
+
const clientTargets = configuredTargetsForClients(manifest, request.clients);
|
|
1010
|
+
const targets = [...new Set([...request.targets, ...clientTargets])];
|
|
1011
|
+
const updated = upsertProject(manifest, {
|
|
1012
|
+
name: request.name,
|
|
1013
|
+
paths: [request.path],
|
|
1014
|
+
skills: request.skills,
|
|
1015
|
+
targets,
|
|
1016
|
+
});
|
|
1017
|
+
const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
|
|
1018
|
+
const plan = {
|
|
1019
|
+
mode: "project",
|
|
1020
|
+
project: request.name,
|
|
1021
|
+
path: request.path,
|
|
1022
|
+
skills: request.skills,
|
|
1023
|
+
clients: request.clients,
|
|
1024
|
+
targets,
|
|
1025
|
+
sync: request.sync,
|
|
1026
|
+
notes,
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
if (options.dryRun) {
|
|
1030
|
+
console.log(options.isJson ? JSON.stringify({ schema_version: 1, plan }) : `project plan: ${JSON.stringify(plan)}`);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (!request.yes) {
|
|
1034
|
+
if (!options.isJson && isInteractive()) {
|
|
1035
|
+
if (guided) {
|
|
1036
|
+
console.log("\nReview");
|
|
1037
|
+
console.log(` project: ${request.name}`);
|
|
1038
|
+
console.log(` path: ${request.path}`);
|
|
1039
|
+
console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
|
|
1040
|
+
console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
|
|
1041
|
+
console.log(` sync: ${request.sync ? "yes" : "no"}`);
|
|
1042
|
+
}
|
|
1043
|
+
if (!(await confirmAction(`Apply project setup for ${request.name} at ${request.path}?`))) {
|
|
1044
|
+
console.log("project setup cancelled");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
} else {
|
|
1048
|
+
throw new Error("skillmux project init requires --yes when run non-interactively");
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
writeManifestAtomic(manifestPath, updated);
|
|
1053
|
+
if (request.sync) {
|
|
1054
|
+
try {
|
|
1055
|
+
await runSync([]);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
`project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
|
|
1059
|
+
error instanceof Error ? error.message : String(error)
|
|
1060
|
+
}`,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (options.isJson) {
|
|
1065
|
+
console.log(JSON.stringify({ schema_version: 1, result: plan }));
|
|
1066
|
+
} else {
|
|
1067
|
+
console.log(`project "${request.name}" ready at ${request.path}`);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function runTarget(
|
|
1072
|
+
subCommand: string,
|
|
1073
|
+
args: string[],
|
|
1074
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
1075
|
+
): Promise<void> {
|
|
1076
|
+
const config = await loadConfig();
|
|
1077
|
+
const vaultPath = expandHome(config.vault_path);
|
|
1078
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
1079
|
+
if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
|
|
1080
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
1081
|
+
|
|
1082
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
1083
|
+
const names = subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
|
|
1084
|
+
if (subCommand === "show" && !manifest.targets[names[0]!]) {
|
|
1085
|
+
throw new Error(`target "${names[0]}" does not exist`);
|
|
1086
|
+
}
|
|
1087
|
+
const targets = names.map((name) => {
|
|
1088
|
+
const target = manifest.targets[name]!;
|
|
1089
|
+
const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
1090
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
1091
|
+
return surface !== undefined && surface.path === expandHome(target.dir);
|
|
1092
|
+
});
|
|
1093
|
+
return { name, ...target, clients };
|
|
1094
|
+
});
|
|
1095
|
+
if (options.isJson) {
|
|
1096
|
+
console.log(JSON.stringify({ schema_version: 1, targets }));
|
|
1097
|
+
} else if (targets.length === 0) {
|
|
1098
|
+
console.log("no targets configured");
|
|
1099
|
+
} else {
|
|
1100
|
+
for (const target of targets) {
|
|
1101
|
+
console.log(`${target.name}:`);
|
|
1102
|
+
console.log(` dir: ${target.dir}`);
|
|
1103
|
+
console.log(` host: ${target.host ?? "(global)"}`);
|
|
1104
|
+
console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
|
|
1105
|
+
console.log(` projects: ${target.project_groups.join(", ") || "(none)"}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (subCommand === "add") {
|
|
1112
|
+
const name = args[0];
|
|
1113
|
+
const pathIndex = args.indexOf("--path");
|
|
1114
|
+
const rawPath = pathIndex === -1 ? undefined : args[pathIndex + 1];
|
|
1115
|
+
if (!name || !rawPath) throw new Error("usage: skillmux target add <name> --path <dir> --yes");
|
|
1116
|
+
const path = expandHome(rawPath);
|
|
1117
|
+
if (options.dryRun) {
|
|
1118
|
+
const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
|
|
1119
|
+
console.log(options.isJson
|
|
1120
|
+
? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
|
|
1121
|
+
: `target add: ${name} -> ${path} (dry-run)`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (!args.includes("--yes")) {
|
|
1125
|
+
if (!options.isJson && isInteractive()) {
|
|
1126
|
+
if (!(await confirmAction(`Adopt target ${name} at ${path}?`))) return;
|
|
1127
|
+
} else {
|
|
1128
|
+
throw new Error("skillmux target add requires --yes when run non-interactively");
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
applyInit(vaultPath, [{ name, dir: path }]);
|
|
1132
|
+
console.log(`target "${name}" added at ${path}`);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (subCommand === "remove") {
|
|
1137
|
+
const name = args[0];
|
|
1138
|
+
if (!name || !manifest.targets[name]) {
|
|
1139
|
+
throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target remove <name> --yes");
|
|
1140
|
+
}
|
|
1141
|
+
if (options.dryRun) {
|
|
1142
|
+
console.log(`target remove: ${name} (files preserved, dry-run)`);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (!args.includes("--yes")) {
|
|
1146
|
+
if (!options.isJson && isInteractive()) {
|
|
1147
|
+
if (!(await confirmAction(`Remove target ${name} from the manifest and preserve its files?`))) return;
|
|
1148
|
+
} else {
|
|
1149
|
+
throw new Error("skillmux target remove requires --yes when run non-interactively");
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
const targets = { ...manifest.targets };
|
|
1153
|
+
delete targets[name];
|
|
1154
|
+
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
1155
|
+
console.log(`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
throw new Error("usage: skillmux target <list|show|add|remove>");
|
|
1160
|
+
}
|
|
579
1161
|
|
|
580
1162
|
function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
|
|
581
1163
|
const skillId = args[0];
|
|
@@ -685,7 +1267,14 @@ async function runSync(args: string[]): Promise<void> {
|
|
|
685
1267
|
const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
|
|
686
1268
|
for (const note of notes) console.log(`note: ${note}`);
|
|
687
1269
|
|
|
1270
|
+
const currentHost = hostname();
|
|
688
1271
|
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
1272
|
+
if (target.host !== undefined && target.host !== currentHost) {
|
|
1273
|
+
console.log(
|
|
1274
|
+
`${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
|
|
1275
|
+
);
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
689
1278
|
const targetDir = expandHome(target.dir);
|
|
690
1279
|
|
|
691
1280
|
if (restoreMonolith) {
|
|
@@ -723,8 +1312,25 @@ async function runSync(args: string[]): Promise<void> {
|
|
|
723
1312
|
}
|
|
724
1313
|
}
|
|
725
1314
|
|
|
726
|
-
function parseInitArgs(args: string[]): {
|
|
1315
|
+
function parseInitArgs(args: string[]): {
|
|
1316
|
+
targets: string[];
|
|
1317
|
+
clients: string[];
|
|
1318
|
+
coreSkillIds: string[];
|
|
1319
|
+
customPath?: string;
|
|
1320
|
+
migrateFullVault: boolean;
|
|
1321
|
+
skipInstructions: boolean;
|
|
1322
|
+
sync: boolean;
|
|
1323
|
+
vaultPath?: string;
|
|
1324
|
+
yes: boolean;
|
|
1325
|
+
} {
|
|
727
1326
|
const targets: string[] = [];
|
|
1327
|
+
const clients: string[] = [];
|
|
1328
|
+
const coreSkillIds: string[] = [];
|
|
1329
|
+
let customPath: string | undefined;
|
|
1330
|
+
let migrateFullVault = false;
|
|
1331
|
+
let skipInstructions = false;
|
|
1332
|
+
let sync = true;
|
|
1333
|
+
let vaultPath: string | undefined;
|
|
728
1334
|
let yes = false;
|
|
729
1335
|
for (let i = 0; i < args.length; i++) {
|
|
730
1336
|
const option = args[i];
|
|
@@ -733,56 +1339,454 @@ function parseInitArgs(args: string[]): { targets: string[]; yes: boolean } {
|
|
|
733
1339
|
if (!value) throw new Error("--target requires a name");
|
|
734
1340
|
targets.push(value);
|
|
735
1341
|
i++;
|
|
1342
|
+
} else if (option === "--client") {
|
|
1343
|
+
const value = args[i + 1];
|
|
1344
|
+
if (!value) throw new Error("--client requires a name");
|
|
1345
|
+
clients.push(value);
|
|
1346
|
+
i++;
|
|
1347
|
+
} else if (option === "--vault") {
|
|
1348
|
+
const value = args[i + 1];
|
|
1349
|
+
if (!value) throw new Error("--vault requires a path");
|
|
1350
|
+
vaultPath = value;
|
|
1351
|
+
i++;
|
|
1352
|
+
} else if (option === "--path") {
|
|
1353
|
+
const value = args[i + 1];
|
|
1354
|
+
if (!value) throw new Error("--path requires a directory");
|
|
1355
|
+
customPath = value;
|
|
1356
|
+
i++;
|
|
1357
|
+
} else if (option === "--core") {
|
|
1358
|
+
const value = args[i + 1];
|
|
1359
|
+
if (!value) throw new Error("--core requires a skill_id");
|
|
1360
|
+
coreSkillIds.push(value);
|
|
1361
|
+
i++;
|
|
1362
|
+
} else if (option === "--dry-run" || option === "--json" || option === "--interactive") {
|
|
1363
|
+
continue;
|
|
1364
|
+
} else if (option === "--migrate-full-vault") {
|
|
1365
|
+
migrateFullVault = true;
|
|
1366
|
+
} else if (option === "--no-instructions") {
|
|
1367
|
+
skipInstructions = true;
|
|
1368
|
+
} else if (option === "--no-sync") {
|
|
1369
|
+
sync = false;
|
|
736
1370
|
} else if (option === "--yes") {
|
|
737
1371
|
yes = true;
|
|
738
1372
|
} else {
|
|
739
1373
|
throw new Error(`unknown init option: ${option}`);
|
|
740
1374
|
}
|
|
741
1375
|
}
|
|
742
|
-
return {
|
|
1376
|
+
return {
|
|
1377
|
+
targets,
|
|
1378
|
+
clients,
|
|
1379
|
+
coreSkillIds,
|
|
1380
|
+
customPath,
|
|
1381
|
+
migrateFullVault,
|
|
1382
|
+
skipInstructions,
|
|
1383
|
+
sync,
|
|
1384
|
+
vaultPath,
|
|
1385
|
+
yes,
|
|
1386
|
+
};
|
|
743
1387
|
}
|
|
744
1388
|
|
|
745
|
-
async function runInit(
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1389
|
+
async function runInit(
|
|
1390
|
+
args: string[],
|
|
1391
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
1392
|
+
): Promise<void> {
|
|
1393
|
+
const {
|
|
1394
|
+
targets: explicitTargets,
|
|
1395
|
+
clients: requestedClients,
|
|
1396
|
+
coreSkillIds,
|
|
1397
|
+
customPath,
|
|
1398
|
+
migrateFullVault,
|
|
1399
|
+
skipInstructions,
|
|
1400
|
+
sync,
|
|
1401
|
+
vaultPath: requestedVaultPath,
|
|
1402
|
+
yes,
|
|
1403
|
+
} = parseInitArgs(args);
|
|
1404
|
+
const guided = shouldUseWizard(args, {
|
|
1405
|
+
interactive: isInteractive(),
|
|
1406
|
+
json: options.isJson,
|
|
1407
|
+
dryRun: options.dryRun,
|
|
1408
|
+
});
|
|
1409
|
+
migrateLegacyPaths();
|
|
1410
|
+
const configPath = resolveConfigPath();
|
|
1411
|
+
let configPlan: ConfigInitPlan | undefined;
|
|
1412
|
+
let vaultPath: string;
|
|
1413
|
+
if (!existsSync(configPath)) {
|
|
1414
|
+
const bootstrapVaultPath = requestedVaultPath ??
|
|
1415
|
+
(!options.isJson && isInteractive() ? "~/skills" : undefined);
|
|
1416
|
+
if (!bootstrapVaultPath) {
|
|
1417
|
+
throw new Error(`machine config does not exist: ${configPath}; re-run with --vault <path>`);
|
|
1418
|
+
}
|
|
1419
|
+
configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
|
|
1420
|
+
vaultPath = configPlan.vaultPath;
|
|
1421
|
+
if (!options.isJson) {
|
|
1422
|
+
console.log(`config create: ${configPath}`);
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
const config = await loadConfig();
|
|
1426
|
+
vaultPath = expandHome(config.vault_path);
|
|
1427
|
+
if (requestedVaultPath && expandHome(requestedVaultPath) !== vaultPath) {
|
|
1428
|
+
throw new Error(
|
|
1429
|
+
`machine config already uses vault_path ${vaultPath}; --vault does not overwrite existing config`,
|
|
1430
|
+
);
|
|
756
1431
|
}
|
|
757
|
-
const kind = candidate.isSymlink ? "symlink" : "real dir";
|
|
758
|
-
const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
|
|
759
|
-
console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
|
|
760
1432
|
}
|
|
761
1433
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1434
|
+
const vaultHealth = inspectVault(vaultPath);
|
|
1435
|
+
if (!vaultHealth.ok) {
|
|
1436
|
+
throw new Error(vaultHealth.message);
|
|
765
1437
|
}
|
|
766
1438
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1439
|
+
let selectedClients = requestedClients;
|
|
1440
|
+
if (guided) {
|
|
1441
|
+
const detected = detectInstalledClients({
|
|
1442
|
+
codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
|
|
1443
|
+
});
|
|
1444
|
+
const evidence = new Map(detected.map((item) => [item.client, item.evidence]));
|
|
1445
|
+
selectedClients = await promptMultiSelect(
|
|
1446
|
+
"Which clients do you use?",
|
|
1447
|
+
SUPPORTED_CLIENT_IDS.map((client) => ({
|
|
1448
|
+
value: client,
|
|
1449
|
+
label: client,
|
|
1450
|
+
detail: evidence.has(client) ? `detected: ${evidence.get(client)}` : undefined,
|
|
1451
|
+
selected: evidence.has(client) || requestedClients.includes(client),
|
|
1452
|
+
})),
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
let selectedCoreSkillIds = coreSkillIds;
|
|
1456
|
+
if (guided) {
|
|
1457
|
+
selectedCoreSkillIds = parseCommaList(
|
|
1458
|
+
await promptText("Core skill IDs to add, comma-separated", coreSkillIds.join(",")),
|
|
770
1459
|
);
|
|
771
1460
|
}
|
|
772
1461
|
|
|
1462
|
+
const clientPlan = planClientSurfaces(selectedClients, {
|
|
1463
|
+
codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
|
|
1464
|
+
});
|
|
1465
|
+
const instructionPlan = planInstructionSetup(skipInstructions ? [] : clientPlan.clients.map((client) => client.id), {
|
|
1466
|
+
codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
|
|
1467
|
+
});
|
|
1468
|
+
const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
|
|
1469
|
+
for (const change of instructionPlan.changes) {
|
|
1470
|
+
for (const client of change.clients) {
|
|
1471
|
+
instructionReadiness[client] = {
|
|
1472
|
+
status: change.status === "unchanged" ? "ready" : "planned",
|
|
1473
|
+
detail: change.path,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
for (const manual of instructionPlan.manual) {
|
|
1478
|
+
instructionReadiness[manual.client] = { status: "manual", detail: manual.reason };
|
|
1479
|
+
}
|
|
1480
|
+
const builtInNames = new Set(["agent-skills", "claude-code", "codex", "custom", "agents", "claude"]);
|
|
1481
|
+
const explicitSurfaceTargets = explicitTargets
|
|
1482
|
+
.filter((name) => builtInNames.has(name))
|
|
1483
|
+
.map((name) =>
|
|
1484
|
+
resolveBuiltInTarget(name, {
|
|
1485
|
+
codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
|
|
1486
|
+
customPath: customPath ? expandHome(customPath) : undefined,
|
|
1487
|
+
}),
|
|
1488
|
+
);
|
|
1489
|
+
if (customPath && !explicitTargets.includes("custom")) {
|
|
1490
|
+
throw new Error("--path may only be used with --target custom");
|
|
1491
|
+
}
|
|
1492
|
+
for (const target of explicitSurfaceTargets) {
|
|
1493
|
+
if (target.warning) console.error(`warning: ${target.warning}`);
|
|
1494
|
+
}
|
|
1495
|
+
const targetByPath = new Map(
|
|
1496
|
+
explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
|
|
1497
|
+
);
|
|
1498
|
+
const existingManifestPath = resolveManifestPath(vaultPath);
|
|
1499
|
+
const existingManifest = existingManifestPath
|
|
1500
|
+
? parseManifest(await Bun.file(existingManifestPath).text())
|
|
1501
|
+
: undefined;
|
|
1502
|
+
for (const surface of clientPlan.surfaces) {
|
|
1503
|
+
if (!targetByPath.has(surface.path)) {
|
|
1504
|
+
targetByPath.set(
|
|
1505
|
+
surface.path,
|
|
1506
|
+
existingManifest
|
|
1507
|
+
? configuredTargetForSurface(existingManifest, surface) ?? surface.targetName
|
|
1508
|
+
: surface.targetName,
|
|
1509
|
+
);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const candidatePaths = [
|
|
1513
|
+
...new Set([
|
|
1514
|
+
...surfaceCandidates().map(expandHome),
|
|
1515
|
+
...targetByPath.keys(),
|
|
1516
|
+
]),
|
|
1517
|
+
];
|
|
1518
|
+
const candidates = detectSurfaces(candidatePaths, vaultPath);
|
|
1519
|
+
if (!options.isJson) {
|
|
1520
|
+
for (const candidate of candidates) {
|
|
1521
|
+
const name = targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
|
|
1522
|
+
if (candidate.state === "missing") {
|
|
1523
|
+
console.log(`${name} (${candidate.path}): not found`);
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (candidate.state === "broken-symlink") {
|
|
1527
|
+
console.log(`${name} (${candidate.path}): broken symlink`);
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (candidate.state === "full-vault") {
|
|
1531
|
+
console.log(`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (candidate.state === "external-symlink") {
|
|
1535
|
+
console.log(`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`);
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (candidate.state === "unsupported") {
|
|
1539
|
+
console.log(`${name} (${candidate.path}): unsupported filesystem entry`);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
const kind = "real dir";
|
|
1543
|
+
const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
|
|
1544
|
+
console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
|
|
1545
|
+
}
|
|
1546
|
+
for (const readiness of assessClientReadiness(clientPlan, instructionReadiness)) {
|
|
1547
|
+
console.log(`\n${readiness.client} readiness:`);
|
|
1548
|
+
console.log(` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`);
|
|
1549
|
+
console.log(` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`);
|
|
1550
|
+
console.log(` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`);
|
|
1551
|
+
}
|
|
1552
|
+
for (const change of instructionPlan.changes) {
|
|
1553
|
+
console.log(
|
|
1554
|
+
`instructions ${change.status}: ${change.path} (${change.clients.join(", ")})`,
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
for (const manual of instructionPlan.manual) {
|
|
1558
|
+
console.log(`instructions manual: ${manual.client} — ${manual.reason}`);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const requestedTargets = [
|
|
1563
|
+
...new Set([
|
|
1564
|
+
...explicitTargets.filter((name) => !builtInNames.has(name)),
|
|
1565
|
+
...targetByPath.values(),
|
|
1566
|
+
]),
|
|
1567
|
+
];
|
|
1568
|
+
const hasInstructionWrites = instructionPlan.changes.some(
|
|
1569
|
+
(change) => change.status !== "unchanged",
|
|
1570
|
+
);
|
|
1571
|
+
const hasConfigWrite = configPlan?.action === "create";
|
|
1572
|
+
const hasChanges = !(
|
|
1573
|
+
requestedTargets.length === 0 &&
|
|
1574
|
+
!hasInstructionWrites &&
|
|
1575
|
+
selectedCoreSkillIds.length === 0 &&
|
|
1576
|
+
!hasConfigWrite
|
|
1577
|
+
);
|
|
1578
|
+
|
|
773
1579
|
const byName = new Map(
|
|
774
|
-
candidates
|
|
1580
|
+
candidates
|
|
1581
|
+
.filter((candidate) =>
|
|
1582
|
+
candidate.deliveryMode === "managed-pins" ||
|
|
1583
|
+
(migrateFullVault && candidate.state === "full-vault"),
|
|
1584
|
+
)
|
|
1585
|
+
.map((candidate) => [
|
|
1586
|
+
targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
|
|
1587
|
+
candidate,
|
|
1588
|
+
] as const),
|
|
1589
|
+
);
|
|
1590
|
+
const allCandidatesByName = new Map(
|
|
1591
|
+
candidates.map((candidate) => [
|
|
1592
|
+
targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
|
|
1593
|
+
candidate,
|
|
1594
|
+
] as const),
|
|
775
1595
|
);
|
|
776
1596
|
for (const name of requestedTargets) {
|
|
777
|
-
if (!byName.has(name))
|
|
1597
|
+
if (!byName.has(name)) {
|
|
1598
|
+
if (allCandidatesByName.get(name)?.state === "full-vault") {
|
|
1599
|
+
throw new Error(
|
|
1600
|
+
`target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
throw new Error(`unknown --target "${name}": not among detected surfaces`);
|
|
1604
|
+
}
|
|
778
1605
|
}
|
|
779
1606
|
|
|
780
|
-
const confirmedTargets = requestedTargets.map((name) =>
|
|
781
|
-
|
|
1607
|
+
const confirmedTargets = requestedTargets.map((name) => {
|
|
1608
|
+
const candidate = byName.get(name)!;
|
|
1609
|
+
return {
|
|
1610
|
+
name,
|
|
1611
|
+
dir: candidate.path,
|
|
1612
|
+
...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
|
|
1613
|
+
};
|
|
1614
|
+
});
|
|
1615
|
+
const plannedManifest = planInitManifest(vaultPath, confirmedTargets, selectedCoreSkillIds);
|
|
1616
|
+
const serializedPlan = {
|
|
1617
|
+
vault_path: vaultPath,
|
|
1618
|
+
config: configPlan
|
|
1619
|
+
? { path: configPlan.configPath, action: configPlan.action }
|
|
1620
|
+
: { path: configPath, action: "preserve" },
|
|
1621
|
+
clients: clientPlan.clients.map((client) => client.id),
|
|
1622
|
+
targets: confirmedTargets,
|
|
1623
|
+
core: plannedManifest.core.skills,
|
|
1624
|
+
instructions: instructionPlan.changes.map(({ path, clients, status }) => ({
|
|
1625
|
+
path,
|
|
1626
|
+
clients,
|
|
1627
|
+
status,
|
|
1628
|
+
})),
|
|
1629
|
+
manual: instructionPlan.manual,
|
|
1630
|
+
};
|
|
1631
|
+
if (!hasChanges) {
|
|
1632
|
+
if (options.isJson) {
|
|
1633
|
+
console.log(JSON.stringify({
|
|
1634
|
+
schema_version: 1,
|
|
1635
|
+
ok: true,
|
|
1636
|
+
command: "init",
|
|
1637
|
+
phase: "plan",
|
|
1638
|
+
dry_run: options.dryRun,
|
|
1639
|
+
applied: false,
|
|
1640
|
+
plan: serializedPlan,
|
|
1641
|
+
}));
|
|
1642
|
+
} else {
|
|
1643
|
+
console.log("\nno managed-pins surface selected — nothing written.");
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (!options.isJson) {
|
|
1648
|
+
for (const target of confirmedTargets.filter((target) => target.migrateFullVault)) {
|
|
1649
|
+
console.log(
|
|
1650
|
+
`full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
|
|
1651
|
+
`${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
if (options.dryRun) {
|
|
1656
|
+
if (options.isJson) {
|
|
1657
|
+
console.log(JSON.stringify({
|
|
1658
|
+
schema_version: 1,
|
|
1659
|
+
ok: true,
|
|
1660
|
+
command: "init",
|
|
1661
|
+
phase: "plan",
|
|
1662
|
+
dry_run: true,
|
|
1663
|
+
applied: false,
|
|
1664
|
+
plan: serializedPlan,
|
|
1665
|
+
}));
|
|
1666
|
+
} else {
|
|
1667
|
+
console.log(
|
|
1668
|
+
`\ndry-run: ${confirmedTargets.length} target(s), ` +
|
|
1669
|
+
`${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
|
|
1670
|
+
`core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (!yes) {
|
|
1677
|
+
if (!options.isJson && isInteractive()) {
|
|
1678
|
+
if (guided) {
|
|
1679
|
+
console.log("\nReview");
|
|
1680
|
+
console.log(` clients: ${selectedClients.join(", ") || "(none)"}`);
|
|
1681
|
+
console.log(
|
|
1682
|
+
` targets: ${confirmedTargets.map((target) => `${target.name} -> ${target.dir}`).join(", ") || "(none)"}`,
|
|
1683
|
+
);
|
|
1684
|
+
console.log(
|
|
1685
|
+
` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
|
|
1686
|
+
);
|
|
1687
|
+
console.log(` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`);
|
|
1688
|
+
console.log(` sync: ${sync ? "yes" : "no"}`);
|
|
1689
|
+
if (!(await confirmAction("Apply this setup plan?"))) {
|
|
1690
|
+
console.log("init cancelled");
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
} else {
|
|
1694
|
+
const prompts = [
|
|
1695
|
+
...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
|
|
1696
|
+
...instructionPlan.changes
|
|
1697
|
+
.filter((change) => change.status !== "unchanged")
|
|
1698
|
+
.map((change) => `${change.status} instruction file ${change.path}?`),
|
|
1699
|
+
...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
|
|
1700
|
+
...(selectedCoreSkillIds.length > 0
|
|
1701
|
+
? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
|
|
1702
|
+
: []),
|
|
1703
|
+
];
|
|
1704
|
+
for (const prompt of prompts) {
|
|
1705
|
+
if (!(await confirmAction(prompt))) {
|
|
1706
|
+
console.log("init cancelled; nothing written");
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
} else {
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
"skillmux init requires --yes before applying target, instruction, or core changes non-interactively",
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
782
1717
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1718
|
+
let configCreated = false;
|
|
1719
|
+
let instructionsApplied = false;
|
|
1720
|
+
const applyAdditional = () => {
|
|
1721
|
+
try {
|
|
1722
|
+
if (configPlan?.action === "create") {
|
|
1723
|
+
configCreated = applyConfigInit(configPlan) === "created";
|
|
1724
|
+
}
|
|
1725
|
+
if (hasInstructionWrites) {
|
|
1726
|
+
applyInstructionPlan(instructionPlan);
|
|
1727
|
+
instructionsApplied = true;
|
|
1728
|
+
}
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
if (configCreated && configPlan) rollbackConfigInit(configPlan);
|
|
1731
|
+
configCreated = false;
|
|
1732
|
+
throw error;
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
const rollbackAdditional = () => {
|
|
1736
|
+
if (instructionsApplied) rollbackInstructionPlan(instructionPlan);
|
|
1737
|
+
if (configCreated && configPlan) rollbackConfigInit(configPlan);
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
if (confirmedTargets.length === 0 && selectedCoreSkillIds.length === 0) {
|
|
1741
|
+
applyAdditional();
|
|
1742
|
+
} else {
|
|
1743
|
+
applyInit(
|
|
1744
|
+
vaultPath,
|
|
1745
|
+
confirmedTargets,
|
|
1746
|
+
hasInstructionWrites || hasConfigWrite
|
|
1747
|
+
? {
|
|
1748
|
+
apply: applyAdditional,
|
|
1749
|
+
rollback: rollbackAdditional,
|
|
1750
|
+
}
|
|
1751
|
+
: undefined,
|
|
1752
|
+
selectedCoreSkillIds,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (options.isJson) {
|
|
1757
|
+
console.log(JSON.stringify({
|
|
1758
|
+
schema_version: 1,
|
|
1759
|
+
ok: true,
|
|
1760
|
+
command: "init",
|
|
1761
|
+
phase: "result",
|
|
1762
|
+
dry_run: false,
|
|
1763
|
+
applied: true,
|
|
1764
|
+
plan: serializedPlan,
|
|
1765
|
+
result: {
|
|
1766
|
+
config_created: configCreated,
|
|
1767
|
+
targets_adopted: confirmedTargets.map((target) => target.name),
|
|
1768
|
+
instructions_changed: instructionPlan.changes
|
|
1769
|
+
.filter((change) => change.status !== "unchanged")
|
|
1770
|
+
.map((change) => change.path),
|
|
1771
|
+
core: plannedManifest.core.skills,
|
|
1772
|
+
},
|
|
1773
|
+
}));
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (configCreated) console.log(`created ${configPath}`);
|
|
1777
|
+
if (confirmedTargets.length > 0) {
|
|
1778
|
+
console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
|
|
1779
|
+
} else if (selectedCoreSkillIds.length > 0) {
|
|
1780
|
+
console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
|
|
1781
|
+
}
|
|
1782
|
+
if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
|
|
1783
|
+
console.log("next: skillmux manifest pin <skill_id> --core");
|
|
1784
|
+
}
|
|
1785
|
+
if (confirmedTargets.length > 0) console.log("next: skillmux sync");
|
|
1786
|
+
if (selectedClients.length === 0 || selectedClients.includes("skillmux-mcp")) {
|
|
1787
|
+
console.log(`\n${printLastMile()}`);
|
|
1788
|
+
}
|
|
1789
|
+
if (guided && sync && confirmedTargets.length > 0) await runSync([]);
|
|
786
1790
|
}
|
|
787
1791
|
|
|
788
1792
|
function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
|