@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/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 { join } from "node:path";
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 { applyInit, deriveTargetName, detectSurfaces, printLastMile, surfaceCandidates } from "./init";
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
- if (["context", "config", "calibrate"].includes(command) || flagContext || flagServer) {
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 <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]`);
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[]): { targets: string[]; yes: boolean } {
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 { targets, yes };
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(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;
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
- if (requestedTargets.length === 0) {
763
- console.log("\nno --target specified — nothing written. Re-run with --target <name> --yes to adopt a surface.");
764
- return;
1434
+ const vaultHealth = inspectVault(vaultPath);
1435
+ if (!vaultHealth.ok) {
1436
+ throw new Error(vaultHealth.message);
765
1437
  }
766
1438
 
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)",
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.filter((c) => c.exists).map((c) => [deriveTargetName(c.path), c] as const),
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)) throw new Error(`unknown --target "${name}": not among detected surfaces`);
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) => ({ name, dir: byName.get(name)!.path }));
781
- applyInit(vaultPath, confirmedTargets);
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
- 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());
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 } {