@klhapp/skillmux 0.4.4 → 0.5.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/src/cli.ts CHANGED
@@ -1,16 +1,36 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Database } from "bun:sqlite";
3
3
  import { existsSync, mkdirSync, rmSync } from "node:fs";
4
+ import { hostname } from "node:os";
4
5
  import { 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 { applyInit, deriveTargetName, detectSurfaces, printLastMile, surfaceCandidates } from "./init";
14
+ import {
15
+ assessClientReadiness,
16
+ planClientSurfaces,
17
+ resolveBuiltInTarget,
18
+ type ClientId,
19
+ type ReadinessAxis,
20
+ } from "./init-clients";
21
+ import {
22
+ applyInstructionPlan,
23
+ planInstructionSetup,
24
+ rollbackInstructionPlan,
25
+ } from "./init-instructions";
26
+ import {
27
+ applyInit,
28
+ deriveTargetName,
29
+ detectSurfaces,
30
+ planInitManifest,
31
+ printLastMile,
32
+ surfaceCandidates,
33
+ } from "./init";
14
34
  import {
15
35
  cloneToTemp,
16
36
  deriveRepoName,
@@ -32,6 +52,13 @@ import {
32
52
  import { downloadLocalModels } from "./models";
33
53
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
34
54
  import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
55
+ import {
56
+ applyConfigInit,
57
+ inspectVault,
58
+ planConfigInit,
59
+ rollbackConfigInit,
60
+ type ConfigInitPlan,
61
+ } from "./setup";
35
62
  import { getStats, renderStatsText, type StatsResponse } from "./stats";
36
63
  import {
37
64
  installPostMergeHook,
@@ -105,7 +132,8 @@ async function main() {
105
132
  let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
106
133
 
107
134
  // Only resolve target if command is target-aware or context/config/calibrate
108
- if (["context", "config", "calibrate"].includes(command) || flagContext || flagServer) {
135
+ const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
136
+ if ((["context", "config", "calibrate"].includes(command) && !isLocalConfigInit) || flagContext || flagServer) {
109
137
  try {
110
138
  resolvedTarget = await resolveTarget({ context: flagContext, server: flagServer });
111
139
  } catch (err: any) {
@@ -161,7 +189,7 @@ async function main() {
161
189
  await runSync(rawArgv.slice(1));
162
190
  break;
163
191
  case "init":
164
- await runInit(rawArgv.slice(1));
192
+ await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
165
193
  break;
166
194
  case "report":
167
195
  await runReport(rawArgv.slice(1));
@@ -293,6 +321,88 @@ async function handleConfigCommand(
293
321
  args: string[],
294
322
  ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean }
295
323
  ) {
324
+ if (sub === "init") {
325
+ let vaultPath: string | undefined;
326
+ let yes = false;
327
+ for (let i = 0; i < args.length; i++) {
328
+ const option = args[i];
329
+ if (option === "--vault") {
330
+ vaultPath = args[++i];
331
+ if (!vaultPath) throw new Error("usage: skillmux config init --vault <path> --yes");
332
+ } else if (option === "--yes") {
333
+ yes = true;
334
+ } else if (option === "--dry-run" || option === "--json") {
335
+ continue;
336
+ } else {
337
+ throw new Error(`unknown config init option: ${option}`);
338
+ }
339
+ }
340
+ if (!vaultPath) {
341
+ if (isInteractive() && !ctx.isJson) {
342
+ vaultPath = "~/skills";
343
+ } else {
344
+ throw new Error("usage: skillmux config init --vault <path> --yes");
345
+ }
346
+ }
347
+
348
+ migrateLegacyPaths();
349
+ const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
350
+ if (plan.action === "preserve") {
351
+ console.log(ctx.isJson
352
+ ? JSON.stringify({
353
+ schema_version: 1,
354
+ ok: true,
355
+ command: "config init",
356
+ phase: "result",
357
+ dry_run: ctx.dryRun,
358
+ applied: false,
359
+ plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "preserve" },
360
+ })
361
+ : `preserved existing config: ${plan.configPath}`);
362
+ return;
363
+ }
364
+ if (ctx.dryRun) {
365
+ console.log(ctx.isJson
366
+ ? JSON.stringify({
367
+ schema_version: 1,
368
+ ok: true,
369
+ command: "config init",
370
+ phase: "plan",
371
+ dry_run: true,
372
+ applied: false,
373
+ plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: "create" },
374
+ })
375
+ : `config create: ${plan.configPath} (dry-run)`);
376
+ return;
377
+ }
378
+ if (!yes) {
379
+ if (!ctx.isJson && isInteractive()) {
380
+ if (!(await confirmAction(`Create ${plan.configPath} with vault_path ${plan.vaultPath}?`))) {
381
+ console.log("config init cancelled; nothing written");
382
+ return;
383
+ }
384
+ } else {
385
+ throw new Error("config initialization requires --yes in noninteractive mode");
386
+ }
387
+ }
388
+
389
+ const result = applyConfigInit(plan);
390
+ console.log(ctx.isJson
391
+ ? JSON.stringify({
392
+ schema_version: 1,
393
+ ok: true,
394
+ command: "config init",
395
+ phase: "result",
396
+ dry_run: false,
397
+ applied: result === "created",
398
+ plan: { config_path: plan.configPath, vault_path: plan.vaultPath, action: plan.action },
399
+ })
400
+ : result === "created"
401
+ ? `created ${plan.configPath}`
402
+ : `preserved existing config: ${plan.configPath}`);
403
+ return;
404
+ }
405
+
296
406
  if (sub === "show") {
297
407
  const data = await adapter.getConfigShow();
298
408
  if (ctx.isJson) {
@@ -371,6 +481,16 @@ async function handleConfigCommand(
371
481
  throw new Error("usage: skillmux config show");
372
482
  }
373
483
 
484
+ async function confirmAction(prompt: string): Promise<boolean> {
485
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
486
+ try {
487
+ const answer = (await readline.question(`${prompt} [y/N] `)).trim().toLowerCase();
488
+ return answer === "y" || answer === "yes";
489
+ } finally {
490
+ readline.close();
491
+ }
492
+ }
493
+
374
494
  async function handleCalibrateCommand(
375
495
  adapter: TargetAdapter,
376
496
  sub: string,
@@ -477,7 +597,24 @@ function handleError(
477
597
  }
478
598
 
479
599
  function printHelp(): void {
480
- console.log(`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> [--transport stdio|http] [--port N] [--dry-run|--restore-monolith|--install-hook] [--target name --yes] [--server url|--db path] --since window [<path>] [--format text|json] [--fail-on low|medium|high] [<repo>[/path] [--force]] [--vault path] [--out file]`);
600
+ console.log(`usage: skillmux <command> [options]
601
+
602
+ Setup:
603
+ skillmux config init --vault <path> --yes
604
+ skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
605
+ [--vault <path>] [--core <skill_id>...]
606
+ [--migrate-full-vault] [--yes|--dry-run] [--json]
607
+
608
+ Init clients:
609
+ claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
610
+ antigravity, goose, hermes, skillmux-mcp
611
+
612
+ Init targets:
613
+ agent-skills, claude-code, codex, custom
614
+
615
+ Commands:
616
+ serve, index, sync, init, report, scan, install, eval, doctor, which,
617
+ manifest, local-vault, config, models, calibrate, context, completions`);
481
618
  }
482
619
 
483
620
  // ---------------------------------------------------------------------------
@@ -575,14 +712,14 @@ async function runWhich(args: string[]): Promise<void> {
575
712
  for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
576
713
  }
577
714
 
578
- const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--repo <path>...])";
715
+ const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
579
716
 
580
- function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; repos: string[] } {
717
+ function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
581
718
  const skillId = args[0];
582
719
  if (!skillId) throw new Error(MANIFEST_USAGE);
583
720
  let core = false;
584
721
  let project: string | undefined;
585
- const repos: string[] = [];
722
+ const paths: string[] = [];
586
723
  for (let i = 1; i < args.length; i++) {
587
724
  const arg = args[i];
588
725
  if (arg === "--core") core = true;
@@ -590,19 +727,19 @@ function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean;
590
727
  const value = args[++i];
591
728
  if (!value) throw new Error("--project requires a group name");
592
729
  project = value;
593
- } else if (arg === "--repo") {
730
+ } else if (arg === "--path") {
594
731
  const value = args[++i];
595
- if (!value) throw new Error("--repo requires a path");
596
- repos.push(value);
732
+ if (!value) throw new Error("--path requires a path");
733
+ paths.push(value);
597
734
  } else throw new Error(`unknown manifest option: ${arg}`);
598
735
  }
599
736
  if (core === (project !== undefined)) throw new Error(MANIFEST_USAGE);
600
- return { skillId, core, project, repos };
737
+ return { skillId, core, project, paths };
601
738
  }
602
739
 
603
740
  async function runManifest(subCommand: string, args: string[]): Promise<void> {
604
741
  if (subCommand !== "pin" && subCommand !== "unpin") throw new Error(MANIFEST_USAGE);
605
- const { skillId, core, project, repos } = parseManifestPinArgs(args);
742
+ const { skillId, core, project, paths } = parseManifestPinArgs(args);
606
743
  const config = await loadConfig();
607
744
  const vaultPath = expandHome(config.vault_path);
608
745
  const localVaultPaths = config.local_vault_paths.map(expandHome);
@@ -616,7 +753,7 @@ async function runManifest(subCommand: string, args: string[]): Promise<void> {
616
753
  } else {
617
754
  updated =
618
755
  subCommand === "pin"
619
- ? pinProject(manifest, skillId, project!, repos)
756
+ ? pinProject(manifest, skillId, project!, paths)
620
757
  : unpinProject(manifest, skillId, project!);
621
758
  }
622
759
  validateManifest(updated, vaultPath, localVaultPaths);
@@ -685,7 +822,14 @@ async function runSync(args: string[]): Promise<void> {
685
822
  const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
686
823
  for (const note of notes) console.log(`note: ${note}`);
687
824
 
825
+ const currentHost = hostname();
688
826
  for (const [targetName, target] of Object.entries(manifest.targets)) {
827
+ if (target.host !== undefined && target.host !== currentHost) {
828
+ console.log(
829
+ `${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
830
+ );
831
+ continue;
832
+ }
689
833
  const targetDir = expandHome(target.dir);
690
834
 
691
835
  if (restoreMonolith) {
@@ -723,8 +867,21 @@ async function runSync(args: string[]): Promise<void> {
723
867
  }
724
868
  }
725
869
 
726
- function parseInitArgs(args: string[]): { targets: string[]; yes: boolean } {
870
+ function parseInitArgs(args: string[]): {
871
+ targets: string[];
872
+ clients: string[];
873
+ coreSkillIds: string[];
874
+ customPath?: string;
875
+ migrateFullVault: boolean;
876
+ vaultPath?: string;
877
+ yes: boolean;
878
+ } {
727
879
  const targets: string[] = [];
880
+ const clients: string[] = [];
881
+ const coreSkillIds: string[] = [];
882
+ let customPath: string | undefined;
883
+ let migrateFullVault = false;
884
+ let vaultPath: string | undefined;
728
885
  let yes = false;
729
886
  for (let i = 0; i < args.length; i++) {
730
887
  const option = args[i];
@@ -733,56 +890,379 @@ function parseInitArgs(args: string[]): { targets: string[]; yes: boolean } {
733
890
  if (!value) throw new Error("--target requires a name");
734
891
  targets.push(value);
735
892
  i++;
893
+ } else if (option === "--client") {
894
+ const value = args[i + 1];
895
+ if (!value) throw new Error("--client requires a name");
896
+ clients.push(value);
897
+ i++;
898
+ } else if (option === "--vault") {
899
+ const value = args[i + 1];
900
+ if (!value) throw new Error("--vault requires a path");
901
+ vaultPath = value;
902
+ i++;
903
+ } else if (option === "--path") {
904
+ const value = args[i + 1];
905
+ if (!value) throw new Error("--path requires a directory");
906
+ customPath = value;
907
+ i++;
908
+ } else if (option === "--core") {
909
+ const value = args[i + 1];
910
+ if (!value) throw new Error("--core requires a skill_id");
911
+ coreSkillIds.push(value);
912
+ i++;
913
+ } else if (option === "--dry-run" || option === "--json") {
914
+ continue;
915
+ } else if (option === "--migrate-full-vault") {
916
+ migrateFullVault = true;
736
917
  } else if (option === "--yes") {
737
918
  yes = true;
738
919
  } else {
739
920
  throw new Error(`unknown init option: ${option}`);
740
921
  }
741
922
  }
742
- return { targets, yes };
923
+ return { targets, clients, coreSkillIds, customPath, migrateFullVault, vaultPath, yes };
743
924
  }
744
925
 
745
- async function runInit(args: string[]): Promise<void> {
746
- const { targets: requestedTargets, yes } = parseInitArgs(args);
747
- const config = await loadConfig();
748
- const vaultPath = expandHome(config.vault_path);
749
-
750
- const candidates = detectSurfaces(surfaceCandidates().map(expandHome));
751
- for (const candidate of candidates) {
752
- const name = deriveTargetName(candidate.path);
753
- if (!candidate.exists) {
754
- console.log(`${name} (${candidate.path}): not found`);
755
- continue;
926
+ async function runInit(
927
+ args: string[],
928
+ options: { isJson: boolean; dryRun: boolean },
929
+ ): Promise<void> {
930
+ const {
931
+ targets: explicitTargets,
932
+ clients: requestedClients,
933
+ coreSkillIds,
934
+ customPath,
935
+ migrateFullVault,
936
+ vaultPath: requestedVaultPath,
937
+ yes,
938
+ } = parseInitArgs(args);
939
+ migrateLegacyPaths();
940
+ const configPath = resolveConfigPath();
941
+ let configPlan: ConfigInitPlan | undefined;
942
+ let vaultPath: string;
943
+ if (!existsSync(configPath)) {
944
+ const bootstrapVaultPath = requestedVaultPath ??
945
+ (!options.isJson && isInteractive() ? "~/skills" : undefined);
946
+ if (!bootstrapVaultPath) {
947
+ throw new Error(`machine config does not exist: ${configPath}; re-run with --vault <path>`);
948
+ }
949
+ configPlan = planConfigInit(configPath, expandHome(bootstrapVaultPath));
950
+ vaultPath = configPlan.vaultPath;
951
+ if (!options.isJson) {
952
+ console.log(`config create: ${configPath}`);
953
+ }
954
+ } else {
955
+ const config = await loadConfig();
956
+ vaultPath = expandHome(config.vault_path);
957
+ if (requestedVaultPath && expandHome(requestedVaultPath) !== vaultPath) {
958
+ throw new Error(
959
+ `machine config already uses vault_path ${vaultPath}; --vault does not overwrite existing config`,
960
+ );
756
961
  }
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
962
  }
761
963
 
762
- if (requestedTargets.length === 0) {
763
- console.log("\nno --target specified — nothing written. Re-run with --target <name> --yes to adopt a surface.");
764
- return;
964
+ const vaultHealth = inspectVault(vaultPath);
965
+ if (!vaultHealth.ok) {
966
+ throw new Error(vaultHealth.message);
765
967
  }
766
968
 
767
- if (!yes) {
768
- throw new Error(
769
- "usage: skillmux init --target <name> [--target <name>...] --yes (interactive per-target confirm is not available non-interactively)",
969
+ const clientPlan = planClientSurfaces(requestedClients, {
970
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
971
+ });
972
+ const instructionPlan = planInstructionSetup(clientPlan.clients.map((client) => client.id), {
973
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
974
+ });
975
+ const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
976
+ for (const change of instructionPlan.changes) {
977
+ for (const client of change.clients) {
978
+ instructionReadiness[client] = {
979
+ status: change.status === "unchanged" ? "ready" : "planned",
980
+ detail: change.path,
981
+ };
982
+ }
983
+ }
984
+ for (const manual of instructionPlan.manual) {
985
+ instructionReadiness[manual.client] = { status: "manual", detail: manual.reason };
986
+ }
987
+ const builtInNames = new Set(["agent-skills", "claude-code", "codex", "custom", "agents", "claude"]);
988
+ const explicitSurfaceTargets = explicitTargets
989
+ .filter((name) => builtInNames.has(name))
990
+ .map((name) =>
991
+ resolveBuiltInTarget(name, {
992
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
993
+ customPath: customPath ? expandHome(customPath) : undefined,
994
+ }),
770
995
  );
996
+ if (customPath && !explicitTargets.includes("custom")) {
997
+ throw new Error("--path may only be used with --target custom");
998
+ }
999
+ for (const target of explicitSurfaceTargets) {
1000
+ if (target.warning) console.error(`warning: ${target.warning}`);
1001
+ }
1002
+ const targetByPath = new Map(
1003
+ explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
1004
+ );
1005
+ for (const surface of clientPlan.surfaces) {
1006
+ if (!targetByPath.has(surface.path)) targetByPath.set(surface.path, surface.targetName);
1007
+ }
1008
+ const candidatePaths = [
1009
+ ...new Set([
1010
+ ...surfaceCandidates().map(expandHome),
1011
+ ...targetByPath.keys(),
1012
+ ]),
1013
+ ];
1014
+ const candidates = detectSurfaces(candidatePaths, vaultPath);
1015
+ if (!options.isJson) {
1016
+ for (const candidate of candidates) {
1017
+ const name = targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path);
1018
+ if (candidate.state === "missing") {
1019
+ console.log(`${name} (${candidate.path}): not found`);
1020
+ continue;
1021
+ }
1022
+ if (candidate.state === "broken-symlink") {
1023
+ console.log(`${name} (${candidate.path}): broken symlink`);
1024
+ continue;
1025
+ }
1026
+ if (candidate.state === "full-vault") {
1027
+ console.log(`${name} (${candidate.path}): full-vault -> ${candidate.canonicalPath}`);
1028
+ continue;
1029
+ }
1030
+ if (candidate.state === "external-symlink") {
1031
+ console.log(`${name} (${candidate.path}): external symlink -> ${candidate.canonicalPath}`);
1032
+ continue;
1033
+ }
1034
+ if (candidate.state === "unsupported") {
1035
+ console.log(`${name} (${candidate.path}): unsupported filesystem entry`);
1036
+ continue;
1037
+ }
1038
+ const kind = "real dir";
1039
+ const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
1040
+ console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
1041
+ }
1042
+ for (const readiness of assessClientReadiness(clientPlan, instructionReadiness)) {
1043
+ console.log(`\n${readiness.client} readiness:`);
1044
+ console.log(` skill surface: ${readiness.skillSurface.status} — ${readiness.skillSurface.detail}`);
1045
+ console.log(` MCP registration: ${readiness.mcpRegistration.status} — ${readiness.mcpRegistration.detail}`);
1046
+ console.log(` instructions: ${readiness.instructionSetup.status} — ${readiness.instructionSetup.detail}`);
1047
+ }
1048
+ for (const change of instructionPlan.changes) {
1049
+ console.log(
1050
+ `instructions ${change.status}: ${change.path} (${change.clients.join(", ")})`,
1051
+ );
1052
+ }
1053
+ for (const manual of instructionPlan.manual) {
1054
+ console.log(`instructions manual: ${manual.client} — ${manual.reason}`);
1055
+ }
771
1056
  }
772
1057
 
1058
+ const requestedTargets = [
1059
+ ...new Set([
1060
+ ...explicitTargets.filter((name) => !builtInNames.has(name)),
1061
+ ...targetByPath.values(),
1062
+ ]),
1063
+ ];
1064
+ const hasInstructionWrites = instructionPlan.changes.some(
1065
+ (change) => change.status !== "unchanged",
1066
+ );
1067
+ const hasConfigWrite = configPlan?.action === "create";
1068
+ const hasChanges = !(
1069
+ requestedTargets.length === 0 &&
1070
+ !hasInstructionWrites &&
1071
+ coreSkillIds.length === 0 &&
1072
+ !hasConfigWrite
1073
+ );
1074
+
773
1075
  const byName = new Map(
774
- candidates.filter((c) => c.exists).map((c) => [deriveTargetName(c.path), c] as const),
1076
+ candidates
1077
+ .filter((candidate) =>
1078
+ candidate.deliveryMode === "managed-pins" ||
1079
+ (migrateFullVault && candidate.state === "full-vault"),
1080
+ )
1081
+ .map((candidate) => [
1082
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1083
+ candidate,
1084
+ ] as const),
1085
+ );
1086
+ const allCandidatesByName = new Map(
1087
+ candidates.map((candidate) => [
1088
+ targetByPath.get(candidate.path) ?? deriveTargetName(candidate.path),
1089
+ candidate,
1090
+ ] as const),
775
1091
  );
776
1092
  for (const name of requestedTargets) {
777
- if (!byName.has(name)) throw new Error(`unknown --target "${name}": not among detected surfaces`);
1093
+ if (!byName.has(name)) {
1094
+ if (allCandidatesByName.get(name)?.state === "full-vault") {
1095
+ throw new Error(
1096
+ `target "${name}" is a full-vault surface; re-run with --migrate-full-vault to convert it to managed pins`,
1097
+ );
1098
+ }
1099
+ throw new Error(`unknown --target "${name}": not among detected surfaces`);
1100
+ }
778
1101
  }
779
1102
 
780
- const confirmedTargets = requestedTargets.map((name) => ({ name, dir: byName.get(name)!.path }));
781
- applyInit(vaultPath, confirmedTargets);
1103
+ const confirmedTargets = requestedTargets.map((name) => {
1104
+ const candidate = byName.get(name)!;
1105
+ return {
1106
+ name,
1107
+ dir: candidate.path,
1108
+ ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1109
+ };
1110
+ });
1111
+ const plannedManifest = planInitManifest(vaultPath, confirmedTargets, coreSkillIds);
1112
+ const serializedPlan = {
1113
+ vault_path: vaultPath,
1114
+ config: configPlan
1115
+ ? { path: configPlan.configPath, action: configPlan.action }
1116
+ : { path: configPath, action: "preserve" },
1117
+ clients: clientPlan.clients.map((client) => client.id),
1118
+ targets: confirmedTargets,
1119
+ core: plannedManifest.core.skills,
1120
+ instructions: instructionPlan.changes.map(({ path, clients, status }) => ({
1121
+ path,
1122
+ clients,
1123
+ status,
1124
+ })),
1125
+ manual: instructionPlan.manual,
1126
+ };
1127
+ if (!hasChanges) {
1128
+ if (options.isJson) {
1129
+ console.log(JSON.stringify({
1130
+ schema_version: 1,
1131
+ ok: true,
1132
+ command: "init",
1133
+ phase: "plan",
1134
+ dry_run: options.dryRun,
1135
+ applied: false,
1136
+ plan: serializedPlan,
1137
+ }));
1138
+ } else {
1139
+ console.log("\nno managed-pins surface selected — nothing written.");
1140
+ }
1141
+ return;
1142
+ }
1143
+ if (!options.isJson) {
1144
+ for (const target of confirmedTargets.filter((target) => target.migrateFullVault)) {
1145
+ console.log(
1146
+ `full-vault migration ${target.name}: ${vaultHealth.skillCount} visible skills -> ` +
1147
+ `${plannedManifest.core.skills.length} core ${plannedManifest.core.skills.length === 1 ? "skill" : "skills"} after sync`,
1148
+ );
1149
+ }
1150
+ }
1151
+ if (options.dryRun) {
1152
+ if (options.isJson) {
1153
+ console.log(JSON.stringify({
1154
+ schema_version: 1,
1155
+ ok: true,
1156
+ command: "init",
1157
+ phase: "plan",
1158
+ dry_run: true,
1159
+ applied: false,
1160
+ plan: serializedPlan,
1161
+ }));
1162
+ } else {
1163
+ console.log(
1164
+ `\ndry-run: ${confirmedTargets.length} target(s), ` +
1165
+ `${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} instruction file(s), ` +
1166
+ `core: ${plannedManifest.core.skills.join(", ") || "(unchanged)"}`,
1167
+ );
1168
+ }
1169
+ return;
1170
+ }
782
1171
 
783
- console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
784
- console.log(`run "skillmux sync" next to materialize [core] skills into these targets.\n`);
785
- console.log(printLastMile());
1172
+ if (!yes) {
1173
+ if (!options.isJson && isInteractive()) {
1174
+ const prompts = [
1175
+ ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
1176
+ ...instructionPlan.changes
1177
+ .filter((change) => change.status !== "unchanged")
1178
+ .map((change) => `${change.status} instruction file ${change.path}?`),
1179
+ ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1180
+ ...(coreSkillIds.length > 0 ? [`Pin core skills: ${coreSkillIds.join(", ")}?`] : []),
1181
+ ];
1182
+ for (const prompt of prompts) {
1183
+ if (!(await confirmAction(prompt))) {
1184
+ console.log("init cancelled; nothing written");
1185
+ return;
1186
+ }
1187
+ }
1188
+ } else {
1189
+ throw new Error(
1190
+ "skillmux init requires --yes before applying target, instruction, or core changes non-interactively",
1191
+ );
1192
+ }
1193
+ }
1194
+
1195
+ let configCreated = false;
1196
+ let instructionsApplied = false;
1197
+ const applyAdditional = () => {
1198
+ try {
1199
+ if (configPlan?.action === "create") {
1200
+ configCreated = applyConfigInit(configPlan) === "created";
1201
+ }
1202
+ if (hasInstructionWrites) {
1203
+ applyInstructionPlan(instructionPlan);
1204
+ instructionsApplied = true;
1205
+ }
1206
+ } catch (error) {
1207
+ if (configCreated && configPlan) rollbackConfigInit(configPlan);
1208
+ configCreated = false;
1209
+ throw error;
1210
+ }
1211
+ };
1212
+ const rollbackAdditional = () => {
1213
+ if (instructionsApplied) rollbackInstructionPlan(instructionPlan);
1214
+ if (configCreated && configPlan) rollbackConfigInit(configPlan);
1215
+ };
1216
+
1217
+ if (confirmedTargets.length === 0 && coreSkillIds.length === 0) {
1218
+ applyAdditional();
1219
+ } else {
1220
+ applyInit(
1221
+ vaultPath,
1222
+ confirmedTargets,
1223
+ hasInstructionWrites || hasConfigWrite
1224
+ ? {
1225
+ apply: applyAdditional,
1226
+ rollback: rollbackAdditional,
1227
+ }
1228
+ : undefined,
1229
+ coreSkillIds,
1230
+ );
1231
+ }
1232
+
1233
+ if (options.isJson) {
1234
+ console.log(JSON.stringify({
1235
+ schema_version: 1,
1236
+ ok: true,
1237
+ command: "init",
1238
+ phase: "result",
1239
+ dry_run: false,
1240
+ applied: true,
1241
+ plan: serializedPlan,
1242
+ result: {
1243
+ config_created: configCreated,
1244
+ targets_adopted: confirmedTargets.map((target) => target.name),
1245
+ instructions_changed: instructionPlan.changes
1246
+ .filter((change) => change.status !== "unchanged")
1247
+ .map((change) => change.path),
1248
+ core: plannedManifest.core.skills,
1249
+ },
1250
+ }));
1251
+ return;
1252
+ }
1253
+ if (configCreated) console.log(`created ${configPath}`);
1254
+ if (confirmedTargets.length > 0) {
1255
+ console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
1256
+ } else if (coreSkillIds.length > 0) {
1257
+ console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1258
+ }
1259
+ if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1260
+ console.log("next: skillmux manifest pin <skill_id> --core");
1261
+ }
1262
+ if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1263
+ if (requestedClients.length === 0 || requestedClients.includes("skillmux-mcp")) {
1264
+ console.log(`\n${printLastMile()}`);
1265
+ }
786
1266
  }
787
1267
 
788
1268
  function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {