@klhapp/skillmux 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  import { Database } from "bun:sqlite";
3
3
  import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { hostname } from "node:os";
5
- import { basename, join } from "node:path";
6
- import { createInterface } from "node:readline/promises";
5
+ import { join } from "node:path";
7
6
  import { generateDataset } from "./dataset-generator";
8
7
 
9
8
  import { createClients } from "./clients";
@@ -48,20 +47,12 @@ import {
48
47
  } from "./install";
49
48
  import {
50
49
  parseManifest,
51
- pinCore,
52
- pinProject,
53
50
  resolveManifestPath,
54
51
  serializeManifest,
55
- unpinCore,
56
- unpinProject,
57
- upsertProject,
58
- updateProjectPaths,
59
- updateProjectTargets,
60
52
  validateManifest,
61
- writeManifestAtomic,
62
53
  } from "./manifest";
63
54
  import { downloadLocalModels } from "./models";
64
- import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
55
+
65
56
  import {
66
57
  parseCommaList,
67
58
  promptMultiSelect,
@@ -113,6 +104,11 @@ import {
113
104
  suggestCorrection,
114
105
  } from "./output";
115
106
  import { generateCompletions, type ShellType } from "./completions";
107
+ import { handleConfigCommand } from "./commands/config";
108
+ import { runCore } from "./commands/core";
109
+ import { configuredTargetForSurface, runProject } from "./commands/project";
110
+ import { confirmAction, confirmIfNeeded } from "./commands/shared";
111
+ import { runTarget } from "./commands/target";
116
112
 
117
113
  const KNOWN_COMMANDS = [
118
114
  "context",
@@ -244,7 +240,11 @@ async function main() {
244
240
  await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
245
241
  break;
246
242
  case "project":
247
- await runProject(subCommand, commandArgs, { isJson, dryRun: isDryRun });
243
+ await runProject(subCommand, commandArgs, {
244
+ isJson,
245
+ dryRun: isDryRun,
246
+ sync: runSync,
247
+ });
248
248
  break;
249
249
  case "target":
250
250
  await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
@@ -391,230 +391,60 @@ async function handleContextCommand(
391
391
  throw new Error("usage: skillmux context <add|list|current|use|remove>");
392
392
  }
393
393
 
394
- function emitConfigInitOutcome(
395
- ctx: { isJson: boolean },
396
- opts: {
397
- phase: "plan" | "result";
398
- dryRun: boolean;
399
- applied: boolean;
400
- plan: ConfigInitPlan;
401
- action: "create" | "preserve";
402
- text: string;
403
- },
404
- ): void {
405
- if (ctx.isJson) {
406
- console.log(
407
- JSON.stringify({
408
- schema_version: 1,
409
- ok: true,
410
- command: "config init",
411
- phase: opts.phase,
412
- dry_run: opts.dryRun,
413
- applied: opts.applied,
414
- plan: {
415
- config_path: opts.plan.configPath,
416
- vault_path: opts.plan.vaultPath,
417
- action: opts.action,
418
- },
419
- }),
420
- );
421
- return;
422
- }
423
- console.log(opts.text);
424
- }
425
-
426
- async function handleConfigCommand(
394
+ async function handleCalibrateCommand(
427
395
  adapter: TargetAdapter,
428
396
  sub: string,
429
397
  args: string[],
430
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
398
+ ctx: { target: ResolvedTarget; isJson: boolean },
431
399
  ) {
432
- if (sub === "init") {
433
- let vaultPath: string | undefined;
434
- let yes = false;
400
+ if (sub === "run") {
401
+ let datasetPath: string | undefined;
402
+ let minAutoMatchPrecision: number | undefined;
403
+ let minRetrievalRecallAtK: number | undefined;
404
+ let minDeliveredShortlistRecallAtK: number | undefined;
405
+ let minAutoMatchCount: number | undefined;
406
+ const readNumber = (flag: string, raw: string | undefined): number => {
407
+ if (raw === undefined) throw new Error(`${flag} requires a value`);
408
+ const value = Number(raw);
409
+ if (!Number.isFinite(value)) throw new Error(`${flag} must be a number`);
410
+ return value;
411
+ };
435
412
  for (let i = 0; i < args.length; i++) {
436
413
  const option = args[i];
437
- if (option === "--vault") {
438
- vaultPath = args[++i];
439
- if (!vaultPath)
440
- throw new Error("usage: skillmux config init --vault <path> --yes");
441
- } else if (option === "--yes") {
442
- yes = true;
443
- } else if (option === "--dry-run" || option === "--json") {
444
- continue;
445
- } else {
446
- throw new Error(`unknown config init option: ${option}`);
447
- }
448
- }
449
- if (!vaultPath) {
450
- if (isInteractive() && !ctx.isJson) {
451
- vaultPath = "~/skills";
452
- } else {
453
- throw new Error("usage: skillmux config init --vault <path> --yes");
454
- }
455
- }
456
-
457
- migrateLegacyPaths();
458
- const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
459
- if (plan.action === "preserve") {
460
- emitConfigInitOutcome(ctx, {
461
- phase: "result",
462
- dryRun: ctx.dryRun,
463
- applied: false,
464
- plan,
465
- action: "preserve",
466
- text: `preserved existing config: ${plan.configPath}`,
467
- });
468
- return;
469
- }
470
- if (ctx.dryRun) {
471
- emitConfigInitOutcome(ctx, {
472
- phase: "plan",
473
- dryRun: true,
474
- applied: false,
475
- plan,
476
- action: "create",
477
- text: `config create: ${plan.configPath} (dry-run)`,
478
- });
479
- return;
480
- }
481
- if (!yes) {
482
- if (!ctx.isJson && isInteractive()) {
483
- if (
484
- !(await confirmAction(
485
- `Create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
486
- ))
487
- ) {
488
- console.log("config init cancelled; nothing written");
489
- return;
414
+ if (option === "--dataset") {
415
+ datasetPath = args[++i];
416
+ if (!datasetPath) throw new Error("--dataset requires a path value");
417
+ } else if (option === "--min-auto-match-precision") {
418
+ minAutoMatchPrecision = readNumber(option, args[++i]);
419
+ } else if (option === "--min-retrieval-recall-at-k") {
420
+ minRetrievalRecallAtK = readNumber(option, args[++i]);
421
+ } else if (option === "--min-delivered-shortlist-recall-at-k") {
422
+ minDeliveredShortlistRecallAtK = readNumber(option, args[++i]);
423
+ } else if (option === "--min-auto-match-count") {
424
+ minAutoMatchCount = readNumber(option, args[++i]);
425
+ if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
426
+ throw new Error("--min-auto-match-count must be a positive integer");
490
427
  }
491
428
  } else {
492
- throw new Error(
493
- "config initialization requires --yes in noninteractive mode",
494
- );
429
+ throw new Error(`unknown calibrate run option: ${option}`);
495
430
  }
496
431
  }
497
-
498
- const result = applyConfigInit(plan);
499
- emitConfigInitOutcome(ctx, {
500
- phase: "result",
501
- dryRun: false,
502
- applied: result === "created",
503
- plan,
504
- action: plan.action,
505
- text:
506
- result === "created"
507
- ? `created ${plan.configPath}`
508
- : `preserved existing config: ${plan.configPath}`,
509
- });
510
- return;
511
- }
512
-
513
- if (sub === "show") {
514
- const data = await adapter.getConfigShow();
515
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
516
- renderTargetBanner(ctx.target);
517
- console.log(JSON.stringify(data.effective, null, 2));
518
- });
519
- return;
520
- }
521
-
522
- if (sub === "get") {
523
- const key = args[0];
524
- if (!key) throw new Error("usage: skillmux config get <key>");
525
- const val = await adapter.getConfigGet(key);
526
- emitSuccess(
527
- { isJson: ctx.isJson, target: ctx.target },
528
- { key, value: val },
529
- () => {
530
- console.log(
531
- typeof val === "object" ? JSON.stringify(val) : String(val),
532
- );
533
- },
534
- );
535
- return;
536
- }
537
-
538
- if (sub === "validate") {
539
- const res = await adapter.configValidate();
540
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
541
- console.log(
542
- res.valid ? "Configuration is valid." : "Configuration is invalid.",
543
- );
544
- });
545
- return;
546
- }
547
-
548
- if (sub === "diff") {
549
- const res = await adapter.configDiff();
550
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
551
- renderTargetBanner(ctx.target);
552
- console.log(JSON.stringify(res.diff, null, 2));
553
- });
554
- return;
555
- }
556
-
557
- if (sub === "set") {
558
- const key = args[0];
559
- const value = args[1];
560
- if (!key || value === undefined) {
561
- throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
432
+ for (const [flag, value] of [
433
+ ["--min-auto-match-precision", minAutoMatchPrecision],
434
+ ["--min-retrieval-recall-at-k", minRetrievalRecallAtK],
435
+ ["--min-delivered-shortlist-recall-at-k", minDeliveredShortlistRecallAtK],
436
+ ] as const) {
437
+ if (value !== undefined && (value < 0 || value > 1)) {
438
+ throw new Error(`${flag} must be between 0 and 1`);
439
+ }
562
440
  }
563
- const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
564
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
565
- renderTargetBanner(ctx.target);
566
- const prefix = ctx.dryRun ? "[dry-run] " : "";
567
- console.log(
568
- `${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
569
- );
570
- console.log(
571
- `Persistence: ${res.persistence}, Application: ${res.application}`,
572
- );
573
- });
574
- return;
575
- }
576
-
577
- if (sub === "status") {
578
- const res = await adapter.configStatus();
579
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
580
- renderTargetBanner(ctx.target);
581
- console.log(`Runtime: ${res.runtime}`);
582
- console.log(`Active revision: ${res.active_revision}`);
583
- console.log(`Readiness: ${res.readiness.status}`);
441
+ const res = await adapter.calibrateRun({
442
+ datasetPath,
443
+ minAutoMatchPrecision,
444
+ minRetrievalRecallAtK,
445
+ minDeliveredShortlistRecallAtK,
446
+ minAutoMatchCount,
584
447
  });
585
- return;
586
- }
587
-
588
- throw new Error("usage: skillmux config show");
589
- }
590
-
591
- async function confirmAction(prompt: string): Promise<boolean> {
592
- const readline = createInterface({
593
- input: process.stdin,
594
- output: process.stdout,
595
- });
596
- try {
597
- const answer = (await readline.question(`${prompt} [y/N] `))
598
- .trim()
599
- .toLowerCase();
600
- return answer === "y" || answer === "yes";
601
- } finally {
602
- readline.close();
603
- }
604
- }
605
-
606
- async function handleCalibrateCommand(
607
- adapter: TargetAdapter,
608
- sub: string,
609
- args: string[],
610
- ctx: { target: ResolvedTarget; isJson: boolean },
611
- ) {
612
- if (sub === "run") {
613
- let datasetPath: string | undefined;
614
- for (let i = 0; i < args.length; i++) {
615
- if (args[i] === "--dataset") datasetPath = args[++i];
616
- }
617
- const res = await adapter.calibrateRun({ datasetPath });
618
448
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
619
449
  renderTargetBanner(ctx.target);
620
450
  console.log(`Calibration run complete.`);
@@ -851,576 +681,6 @@ async function runWhich(args: string[]): Promise<void> {
851
681
  console.log(` shadows: ${shadowedRoot}`);
852
682
  }
853
683
 
854
- const PROJECT_INIT_USAGE =
855
- "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
856
-
857
- interface ProjectInitArgs {
858
- path: string;
859
- name: string;
860
- skills: string[];
861
- clients: string[];
862
- targets: string[];
863
- yes: boolean;
864
- sync: boolean;
865
- }
866
-
867
- function configuredTargetForSurface(
868
- manifest: ReturnType<typeof parseManifest>,
869
- surface: { targetName: string; path: string },
870
- ): string | undefined {
871
- if (manifest.targets[surface.targetName]) return surface.targetName;
872
- return Object.entries(manifest.targets).find(
873
- ([, target]) => expandHome(target.dir) === surface.path,
874
- )?.[0];
875
- }
876
-
877
- function configuredTargetsForClients(
878
- manifest: ReturnType<typeof parseManifest>,
879
- clients: readonly string[],
880
- ): string[] {
881
- return planClientSurfaces(clients).surfaces.map((surface) => {
882
- const target = configuredTargetForSurface(manifest, surface);
883
- if (target) return target;
884
- const client = surface.clients[0]!;
885
- throw new Error(
886
- `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
887
- );
888
- });
889
- }
890
-
891
- function parseProjectInitArgs(args: string[]): ProjectInitArgs {
892
- let projectPath: string | undefined;
893
- let name: string | undefined;
894
- const skills: string[] = [];
895
- const clients: string[] = [];
896
- const targets: string[] = [];
897
- let yes = false;
898
- let sync = true;
899
-
900
- for (let i = 0; i < args.length; i++) {
901
- const arg = args[i]!;
902
- if (arg === "--name") {
903
- name = args[++i];
904
- if (!name) throw new Error("--name requires a group name");
905
- } else if (arg === "--skill") {
906
- const skill = args[++i];
907
- if (!skill) throw new Error("--skill requires a skill_id");
908
- skills.push(skill);
909
- } else if (arg === "--target") {
910
- const target = args[++i];
911
- if (!target) throw new Error("--target requires a name");
912
- targets.push(target);
913
- } else if (arg === "--client") {
914
- const client = args[++i];
915
- if (!client) throw new Error("--client requires a name");
916
- clients.push(client);
917
- } else if (arg === "--yes") {
918
- yes = true;
919
- } else if (arg === "--no-sync") {
920
- sync = false;
921
- } else if (
922
- arg === "--dry-run" ||
923
- arg === "--json" ||
924
- arg === "--interactive"
925
- ) {
926
- continue;
927
- } else if (arg.startsWith("-")) {
928
- throw new Error(`unknown project init option: ${arg}`);
929
- } else if (projectPath) {
930
- throw new Error(PROJECT_INIT_USAGE);
931
- } else {
932
- projectPath = arg;
933
- }
934
- }
935
-
936
- const path = resolveProjectDirectory(
937
- projectPath ? expandHome(projectPath) : undefined,
938
- );
939
- return {
940
- path,
941
- name: name ?? suggestProjectName(basename(path)),
942
- skills,
943
- clients,
944
- targets,
945
- yes,
946
- sync,
947
- };
948
- }
949
-
950
- async function loadManifestContext() {
951
- const config = await loadConfig();
952
- const vaultPath = expandHome(config.vault_path);
953
- const manifestPath = resolveManifestPath(vaultPath);
954
- if (!manifestPath) {
955
- throw new Error(
956
- `no skillmux.toml found at ${vaultPath}; run skillmux init first`,
957
- );
958
- }
959
- const manifest = parseManifest(await Bun.file(manifestPath).text());
960
- return { config, vaultPath, manifestPath, manifest };
961
- }
962
-
963
- async function confirmIfNeeded(opts: {
964
- confirmed: boolean;
965
- isJson: boolean;
966
- prompt: string;
967
- nonInteractiveError: string;
968
- }): Promise<boolean> {
969
- if (opts.confirmed) return true;
970
- if (opts.isJson || !isInteractive()) {
971
- throw new Error(opts.nonInteractiveError);
972
- }
973
- return confirmAction(opts.prompt);
974
- }
975
-
976
- async function runProject(
977
- subCommand: string,
978
- args: string[],
979
- options: { isJson: boolean; dryRun: boolean },
980
- ): Promise<void> {
981
- if (subCommand === "list" || subCommand === "show") {
982
- const { manifest } = await loadManifestContext();
983
- const names =
984
- subCommand === "show"
985
- ? [args[0] ?? ""]
986
- : Object.keys(manifest.project ?? {});
987
- if (subCommand === "show" && !manifest.project?.[names[0]!]) {
988
- throw new Error(`[project.${names[0]}] does not exist`);
989
- }
990
- const projects = names.map((name) => ({
991
- name,
992
- paths: manifest.project?.[name]!.paths ?? [],
993
- skills: manifest.project?.[name]!.skills ?? [],
994
- targets: Object.entries(manifest.targets)
995
- .filter(([, target]) => target.project_groups.includes(name))
996
- .map(([target]) => target),
997
- }));
998
- if (options.isJson) {
999
- console.log(JSON.stringify({ schema_version: 1, projects }));
1000
- } else if (projects.length === 0) {
1001
- console.log("no project groups configured");
1002
- } else {
1003
- for (const project of projects) {
1004
- console.log(`${project.name}:`);
1005
- console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
1006
- console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
1007
- console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
1008
- }
1009
- }
1010
- return;
1011
- }
1012
- if (subCommand === "add-path" || subCommand === "remove-path") {
1013
- const group = args[0];
1014
- if (!group)
1015
- throw new Error(
1016
- `usage: skillmux project ${subCommand} <group> [path] --yes`,
1017
- );
1018
- const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
1019
- const projectPath = resolveProjectDirectory(
1020
- rawPath ? expandHome(rawPath) : undefined,
1021
- );
1022
- const yes = args.includes("--yes");
1023
- if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
1024
- throw new Error(`project path is not a directory: ${projectPath}`);
1025
- }
1026
- const { config, vaultPath, manifestPath, manifest } =
1027
- await loadManifestContext();
1028
- const updated = updateProjectPaths(manifest, group, {
1029
- ...(subCommand === "add-path"
1030
- ? { add: [projectPath] }
1031
- : { remove: [projectPath] }),
1032
- });
1033
- validateManifest(
1034
- updated,
1035
- vaultPath,
1036
- config.local_vault_paths.map(expandHome),
1037
- );
1038
- if (options.dryRun) {
1039
- console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
1040
- return;
1041
- }
1042
- if (
1043
- !(await confirmIfNeeded({
1044
- confirmed: yes,
1045
- isJson: options.isJson,
1046
- prompt: `${subCommand} ${projectPath} in [project.${group}]?`,
1047
- nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1048
- }))
1049
- )
1050
- return;
1051
- writeManifestAtomic(manifestPath, updated);
1052
- console.log(`${subCommand}: [project.${group}] ${projectPath}`);
1053
- return;
1054
- }
1055
- if (subCommand === "pin" || subCommand === "unpin") {
1056
- const group = args[0];
1057
- const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
1058
- if (!group || skills.length === 0) {
1059
- throw new Error(
1060
- `usage: skillmux project ${subCommand} <group> <skill_id>... --yes`,
1061
- );
1062
- }
1063
- const yes = args.includes("--yes");
1064
- const { config, vaultPath, manifestPath, manifest } =
1065
- await loadManifestContext();
1066
- let updated = manifest;
1067
- for (const skill of skills) {
1068
- updated =
1069
- subCommand === "pin"
1070
- ? pinProject(updated, skill, group)
1071
- : unpinProject(updated, skill, group);
1072
- }
1073
- validateManifest(
1074
- updated,
1075
- vaultPath,
1076
- config.local_vault_paths.map(expandHome),
1077
- );
1078
- if (options.dryRun) {
1079
- console.log(
1080
- `${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
1081
- );
1082
- return;
1083
- }
1084
- if (
1085
- !(await confirmIfNeeded({
1086
- confirmed: yes,
1087
- isJson: options.isJson,
1088
- prompt: `${subCommand} ${skills.join(", ")} in [project.${group}]?`,
1089
- nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1090
- }))
1091
- )
1092
- return;
1093
- writeManifestAtomic(manifestPath, updated);
1094
- console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
1095
- return;
1096
- }
1097
- if (subCommand === "attach" || subCommand === "detach") {
1098
- const group = args[0];
1099
- if (!group)
1100
- throw new Error(
1101
- `usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
1102
- );
1103
- const clients: string[] = [];
1104
- const requestedTargets: string[] = [];
1105
- for (let i = 1; i < args.length; i++) {
1106
- if (args[i] === "--client") {
1107
- const value = args[++i];
1108
- if (!value) throw new Error("--client requires a name");
1109
- clients.push(value);
1110
- } else if (args[i] === "--target") {
1111
- const value = args[++i];
1112
- if (!value) throw new Error("--target requires a name");
1113
- requestedTargets.push(value);
1114
- } else if (
1115
- args[i] !== "--yes" &&
1116
- args[i] !== "--dry-run" &&
1117
- args[i] !== "--json"
1118
- ) {
1119
- throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
1120
- }
1121
- }
1122
- const { config, vaultPath, manifestPath, manifest } =
1123
- await loadManifestContext();
1124
- const clientTargets = configuredTargetsForClients(manifest, clients);
1125
- const targets = [...new Set([...requestedTargets, ...clientTargets])];
1126
- if (targets.length === 0) {
1127
- throw new Error(`project ${subCommand} requires --client or --target`);
1128
- }
1129
- const updated = updateProjectTargets(manifest, group, {
1130
- ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
1131
- });
1132
- validateManifest(
1133
- updated,
1134
- vaultPath,
1135
- config.local_vault_paths.map(expandHome),
1136
- );
1137
- if (options.dryRun) {
1138
- console.log(
1139
- `${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
1140
- );
1141
- return;
1142
- }
1143
- if (
1144
- !(await confirmIfNeeded({
1145
- confirmed: args.includes("--yes"),
1146
- isJson: options.isJson,
1147
- prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
1148
- nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
1149
- }))
1150
- )
1151
- return;
1152
- writeManifestAtomic(manifestPath, updated);
1153
- console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
1154
- return;
1155
- }
1156
- if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
1157
- let request = parseProjectInitArgs(args);
1158
- const guided = shouldUseWizard(args, {
1159
- interactive: isInteractive(),
1160
- json: options.isJson,
1161
- dryRun: options.dryRun,
1162
- });
1163
- if (!existsSync(request.path))
1164
- throw new Error(`project path does not exist: ${request.path}`);
1165
- if (!lstatSync(request.path).isDirectory()) {
1166
- throw new Error(`project path is not a directory: ${request.path}`);
1167
- }
1168
-
1169
- const { config, vaultPath, manifestPath, manifest } =
1170
- await loadManifestContext();
1171
- const localVaultPaths = config.local_vault_paths.map(expandHome);
1172
- if (guided) {
1173
- const name = await promptText("Project group", request.name);
1174
- const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
1175
- const surface = planClientSurfaces([client]).surfaces[0];
1176
- return (
1177
- surface !== undefined &&
1178
- configuredTargetForSurface(manifest, surface) !== undefined
1179
- );
1180
- });
1181
- const clients = await promptMultiSelect(
1182
- "Which clients should receive project skills?",
1183
- availableClients.map((client) => ({
1184
- value: client,
1185
- label: client,
1186
- selected:
1187
- request.clients.length === 0 || request.clients.includes(client),
1188
- })),
1189
- );
1190
- const skills = parseCommaList(
1191
- await promptText(
1192
- "Project skill IDs, comma-separated",
1193
- request.skills.join(","),
1194
- ),
1195
- );
1196
- request = { ...request, name, clients, skills };
1197
- }
1198
- const clientTargets = configuredTargetsForClients(manifest, request.clients);
1199
- const targets = [...new Set([...request.targets, ...clientTargets])];
1200
- const updated = upsertProject(manifest, {
1201
- name: request.name,
1202
- paths: [request.path],
1203
- skills: request.skills,
1204
- targets,
1205
- });
1206
- const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
1207
- const plan = {
1208
- mode: "project",
1209
- project: request.name,
1210
- path: request.path,
1211
- skills: request.skills,
1212
- clients: request.clients,
1213
- targets,
1214
- sync: request.sync,
1215
- notes,
1216
- };
1217
-
1218
- if (options.dryRun) {
1219
- console.log(
1220
- options.isJson
1221
- ? JSON.stringify({ schema_version: 1, plan })
1222
- : `project plan: ${JSON.stringify(plan)}`,
1223
- );
1224
- return;
1225
- }
1226
- if (!request.yes) {
1227
- if (!options.isJson && isInteractive()) {
1228
- if (guided) {
1229
- console.log("\nReview");
1230
- console.log(` project: ${request.name}`);
1231
- console.log(` path: ${request.path}`);
1232
- console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
1233
- console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
1234
- console.log(` sync: ${request.sync ? "yes" : "no"}`);
1235
- }
1236
- if (
1237
- !(await confirmAction(
1238
- `Apply project setup for ${request.name} at ${request.path}?`,
1239
- ))
1240
- ) {
1241
- console.log("project setup cancelled");
1242
- return;
1243
- }
1244
- } else {
1245
- throw new Error(
1246
- "skillmux project init requires --yes when run non-interactively",
1247
- );
1248
- }
1249
- }
1250
-
1251
- writeManifestAtomic(manifestPath, updated);
1252
- if (request.sync) {
1253
- try {
1254
- await runSync([]);
1255
- } catch (error) {
1256
- throw new Error(
1257
- `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
1258
- error instanceof Error ? error.message : String(error)
1259
- }`,
1260
- );
1261
- }
1262
- }
1263
- if (options.isJson) {
1264
- console.log(JSON.stringify({ schema_version: 1, result: plan }));
1265
- } else {
1266
- console.log(`project "${request.name}" ready at ${request.path}`);
1267
- }
1268
- }
1269
-
1270
- async function runCore(
1271
- subCommand: string,
1272
- args: string[],
1273
- options: { isJson: boolean; dryRun: boolean },
1274
- ): Promise<void> {
1275
- if (subCommand !== "pin" && subCommand !== "unpin") {
1276
- throw new Error("usage: skillmux core <pin|unpin>");
1277
- }
1278
- const skillIds = args.filter((arg) => !arg.startsWith("-"));
1279
- if (skillIds.length === 0) {
1280
- throw new Error(`usage: skillmux core ${subCommand} <skill_id>... --yes`);
1281
- }
1282
- const yes = args.includes("--yes");
1283
- const { config, vaultPath, manifestPath, manifest } =
1284
- await loadManifestContext();
1285
- let updated = manifest;
1286
- for (const skillId of skillIds) {
1287
- updated =
1288
- subCommand === "pin"
1289
- ? pinCore(updated, skillId)
1290
- : unpinCore(updated, skillId);
1291
- }
1292
- validateManifest(
1293
- updated,
1294
- vaultPath,
1295
- config.local_vault_paths.map(expandHome),
1296
- );
1297
- if (options.dryRun) {
1298
- emitSuccess(
1299
- { isJson: options.isJson },
1300
- { subcommand: subCommand, skill_ids: skillIds },
1301
- () =>
1302
- console.log(`${subCommand}: [core] ${skillIds.join(", ")} (dry-run)`),
1303
- );
1304
- return;
1305
- }
1306
- if (
1307
- !(await confirmIfNeeded({
1308
- confirmed: yes,
1309
- isJson: options.isJson,
1310
- prompt: `${subCommand} ${skillIds.join(", ")} in [core]?`,
1311
- nonInteractiveError: `skillmux core ${subCommand} requires --yes when run non-interactively`,
1312
- }))
1313
- )
1314
- return;
1315
- writeManifestAtomic(manifestPath, updated);
1316
- console.log(`${subCommand}: [core] ${skillIds.join(", ")}`);
1317
- }
1318
-
1319
- async function runTarget(
1320
- subCommand: string,
1321
- args: string[],
1322
- options: { isJson: boolean; dryRun: boolean },
1323
- ): Promise<void> {
1324
- const { vaultPath, manifestPath, manifest } = await loadManifestContext();
1325
-
1326
- if (subCommand === "list" || subCommand === "show") {
1327
- const names =
1328
- subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1329
- if (subCommand === "show" && !manifest.targets[names[0]!]) {
1330
- throw new Error(`target "${names[0]}" does not exist`);
1331
- }
1332
- const targets = names.map((name) => {
1333
- const target = manifest.targets[name]!;
1334
- const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
1335
- const surface = planClientSurfaces([client]).surfaces[0];
1336
- return surface !== undefined && surface.path === expandHome(target.dir);
1337
- });
1338
- return { name, ...target, clients };
1339
- });
1340
- if (options.isJson) {
1341
- console.log(JSON.stringify({ schema_version: 1, targets }));
1342
- } else if (targets.length === 0) {
1343
- console.log("no targets configured");
1344
- } else {
1345
- for (const target of targets) {
1346
- console.log(`${target.name}:`);
1347
- console.log(` dir: ${target.dir}`);
1348
- console.log(` host: ${target.host ?? "(global)"}`);
1349
- console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
1350
- console.log(
1351
- ` projects: ${target.project_groups.join(", ") || "(none)"}`,
1352
- );
1353
- }
1354
- }
1355
- return;
1356
- }
1357
-
1358
- if (subCommand === "add") {
1359
- const name = args[0];
1360
- const dirIndex = args.indexOf("--dir");
1361
- const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
1362
- if (!name || !rawPath)
1363
- throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
1364
- const path = expandHome(rawPath);
1365
- if (options.dryRun) {
1366
- const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
1367
- console.log(
1368
- options.isJson
1369
- ? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
1370
- : `target add: ${name} -> ${path} (dry-run)`,
1371
- );
1372
- return;
1373
- }
1374
- if (
1375
- !(await confirmIfNeeded({
1376
- confirmed: args.includes("--yes"),
1377
- isJson: options.isJson,
1378
- prompt: `Adopt target ${name} at ${path}?`,
1379
- nonInteractiveError:
1380
- "skillmux target add requires --yes when run non-interactively",
1381
- }))
1382
- )
1383
- return;
1384
- applyInit(vaultPath, [{ name, dir: path }]);
1385
- console.log(`target "${name}" added at ${path}`);
1386
- return;
1387
- }
1388
-
1389
- if (subCommand === "remove") {
1390
- const name = args[0];
1391
- if (!name || !manifest.targets[name]) {
1392
- throw new Error(
1393
- name
1394
- ? `target "${name}" does not exist`
1395
- : "usage: skillmux target remove <name> --yes",
1396
- );
1397
- }
1398
- if (options.dryRun) {
1399
- console.log(`target remove: ${name} (files preserved, dry-run)`);
1400
- return;
1401
- }
1402
- if (
1403
- !(await confirmIfNeeded({
1404
- confirmed: args.includes("--yes"),
1405
- isJson: options.isJson,
1406
- prompt: `Remove target ${name} from the manifest and preserve its files?`,
1407
- nonInteractiveError:
1408
- "skillmux target remove requires --yes when run non-interactively",
1409
- }))
1410
- )
1411
- return;
1412
- const targets = { ...manifest.targets };
1413
- delete targets[name];
1414
- writeManifestAtomic(manifestPath, { ...manifest, targets });
1415
- console.log(
1416
- `target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
1417
- );
1418
- return;
1419
- }
1420
-
1421
- throw new Error("usage: skillmux target <list|show|add|remove>");
1422
- }
1423
-
1424
684
  async function runLocalVaultInit(
1425
685
  args: string[],
1426
686
  options: { isJson: boolean; dryRun: boolean },
@@ -1438,14 +698,16 @@ async function runLocalVaultInit(
1438
698
  if (!existsSync(expanded)) throw new Error(`"${path}" does not exist`);
1439
699
  const markerPath = join(expanded, ".skillmux");
1440
700
  if (options.dryRun) {
1441
- console.log(
1442
- options.isJson
1443
- ? JSON.stringify({
1444
- schema_version: 1,
1445
- marker_path: markerPath,
1446
- vault_path: expandHome(config.vault_path),
1447
- })
1448
- : `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
701
+ emitSuccess(
702
+ { isJson: options.isJson },
703
+ {
704
+ marker_path: markerPath,
705
+ vault_path: expandHome(config.vault_path),
706
+ },
707
+ () =>
708
+ console.log(
709
+ `local-vault init: ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)}) (dry-run)`,
710
+ ),
1449
711
  );
1450
712
  return;
1451
713
  }
@@ -1460,19 +722,17 @@ async function runLocalVaultInit(
1460
722
  )
1461
723
  return;
1462
724
  writeLocalVaultMarker(expanded, expandHome(config.vault_path));
1463
- if (options.isJson) {
1464
- console.log(
1465
- JSON.stringify({
1466
- schema_version: 1,
1467
- marker_path: markerPath,
1468
- vault_path: expandHome(config.vault_path),
1469
- }),
1470
- );
1471
- } else {
1472
- console.log(
1473
- `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
1474
- );
1475
- }
725
+ emitSuccess(
726
+ { isJson: options.isJson },
727
+ {
728
+ marker_path: markerPath,
729
+ vault_path: expandHome(config.vault_path),
730
+ },
731
+ () =>
732
+ console.log(
733
+ `wrote ${markerPath} (role: local_vault, vault_path: ${expandHome(config.vault_path)})`,
734
+ ),
735
+ );
1476
736
  }
1477
737
 
1478
738
  async function runModelDownload(options: { isJson: boolean }): Promise<void> {