@klhapp/skillmux 1.12.0 → 1.13.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. package/src/vault.ts +0 -207
@@ -1,71 +0,0 @@
1
- import { expandHome, loadConfig } from "../config";
2
- import { emitSuccess, warn } from "../output";
3
- import {
4
- parseFailOn,
5
- renderScanJson,
6
- renderScanText,
7
- scanExitCode,
8
- scanPath,
9
- type ScanSeverity,
10
- } from "../scan";
11
- import { isGlobalFlag } from "../global-flags";
12
-
13
- function parseScanArgs(args: string[]): {
14
- path?: string;
15
- format: "text" | "json";
16
- formatExplicit: boolean;
17
- failOn?: ScanSeverity;
18
- } {
19
- let path: string | undefined;
20
- let format: "text" | "json" = "text";
21
- let formatExplicit = false;
22
- let failOn: ScanSeverity | undefined;
23
- for (let i = 0; i < args.length; i++) {
24
- const option = args[i];
25
- if (option === "--format") {
26
- const value = args[++i];
27
- if (value !== "text" && value !== "json")
28
- throw new Error("--format must be text or json");
29
- format = value;
30
- formatExplicit = true;
31
- } else if (option === "--fail-on") {
32
- // scan accepts "none" for symmetry with install/update, where it is the
33
- // opt-out. Here it is already the default: scan reports, and the caller
34
- // opts into a non-zero exit code.
35
- const parsed = parseFailOn(args[++i]);
36
- failOn = parsed === "none" ? undefined : parsed;
37
- } else if (isGlobalFlag(option, "--json")) {
38
- // handled globally by main()'s isJson flag; recognized here so it isn't rejected
39
- } else if (option?.startsWith("--")) {
40
- throw new Error(`unknown scan option: ${option}`);
41
- } else if (path !== undefined) {
42
- throw new Error("skillmux scan accepts at most one <path> argument");
43
- } else {
44
- path = option;
45
- }
46
- }
47
- return { path, format, formatExplicit, failOn };
48
- }
49
-
50
- export async function runScan(
51
- args: string[],
52
- options: { isJson: boolean },
53
- ): Promise<void> {
54
- const { path, format, formatExplicit, failOn } = parseScanArgs(args);
55
- if (formatExplicit) {
56
- // --format predates the shared --json envelope and is the last command
57
- // flag that emits JSON outside it. Kept working for existing callers;
58
- // the warning goes to stderr so stdout stays machine-parseable.
59
- warn("--format is deprecated and will be removed in a future 1.x release; use --json instead");
60
- }
61
- const rootPath = path
62
- ? expandHome(path)
63
- : expandHome((await loadConfig()).vault_path);
64
- const result = await scanPath(rootPath);
65
- emitSuccess({ isJson: options.isJson }, result, () => {
66
- console.log(
67
- format === "json" ? renderScanJson(result) : renderScanText(result),
68
- );
69
- });
70
- process.exitCode = scanExitCode(result.findings, failOn);
71
- }
@@ -1,38 +0,0 @@
1
- import { expandHome, loadConfig } from "../config";
2
- import { parseManifest, resolveManifestPath } from "../manifest";
3
- import { isInteractive } from "../output";
4
- import { askQuestion, type PromptIO } from "../prompts";
5
-
6
- export async function confirmAction(
7
- prompt: string,
8
- io: PromptIO = {},
9
- ): Promise<boolean> {
10
- const answer = (await askQuestion(`${prompt} [y/N] `, io)).trim().toLowerCase();
11
- return answer === "y" || answer === "yes";
12
- }
13
-
14
- export async function loadManifestContext() {
15
- const config = await loadConfig();
16
- const vaultPath = expandHome(config.vault_path);
17
- const manifestPath = resolveManifestPath(vaultPath);
18
- if (!manifestPath) {
19
- throw new Error(
20
- `no skillmux.toml found at ${vaultPath}; run skillmux init first`,
21
- );
22
- }
23
- const manifest = parseManifest(await Bun.file(manifestPath).text());
24
- return { config, vaultPath, manifestPath, manifest };
25
- }
26
-
27
- export async function confirmIfNeeded(opts: {
28
- confirmed: boolean;
29
- isJson: boolean;
30
- prompt: string;
31
- nonInteractiveError: string;
32
- }): Promise<boolean> {
33
- if (opts.confirmed) return true;
34
- if (opts.isJson || !isInteractive()) {
35
- throw new Error(opts.nonInteractiveError);
36
- }
37
- return confirmAction(opts.prompt);
38
- }
@@ -1,33 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { expandHome, loadConfig } from "../config";
4
- import { unknownSubcommandError } from "../output";
5
- import { vaultResolutionOrder } from "../vault";
6
-
7
- export async function runSkill(subCommand: string, args: string[]): Promise<void> {
8
- if (subCommand !== "which") throw unknownSubcommandError("skill", subCommand, ["which"]);
9
- await runWhich(args);
10
- }
11
-
12
- async function runWhich(args: string[]): Promise<void> {
13
- const skillId = args[0];
14
- if (!skillId) {
15
- throw new Error(
16
- "usage: skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)",
17
- );
18
- }
19
- const config = await loadConfig();
20
- const vaultPath = expandHome(config.vault_path);
21
- const localVaultPaths = config.local_vault_paths.map(expandHome);
22
- const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
23
- (root) => existsSync(join(root, skillId, "SKILL.md")),
24
- );
25
- if (roots.length === 0) {
26
- console.log(`${skillId}: not found in vault_path or local_vault_paths`);
27
- process.exitCode = 1;
28
- return;
29
- }
30
- console.log(`${skillId}: serving from ${roots[0]}`);
31
- for (const shadowedRoot of roots.slice(1))
32
- console.log(` shadows: ${shadowedRoot}`);
33
- }
@@ -1,233 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { hostname } from "node:os";
3
- import { expandHome, loadConfig } from "../config";
4
- import {
5
- parseManifest,
6
- resolveManifestPath,
7
- validateManifest,
8
- resolveTargetDir,
9
- } from "../manifest";
10
- import { emitSuccess, isInteractive, warn } from "../output";
11
- import {
12
- installPostMergeHook,
13
- resolveProjectPinDir,
14
- restoreMonolith as restoreMonolithTarget,
15
- syncProjectTargets,
16
- syncTarget,
17
- type ProjectGroupInput,
18
- } from "../sync";
19
- import { confirmAction } from "./shared";
20
-
21
- function parseSyncArgs(args: string[]): {
22
- dryRun: boolean;
23
- restoreMonolith: boolean;
24
- installHook: boolean;
25
- yes: boolean;
26
- isJson: boolean;
27
- } {
28
- let dryRun = false;
29
- let restoreMonolith = false;
30
- let installHook = false;
31
- let yes = false;
32
- let isJson = false;
33
- for (const arg of args) {
34
- if (arg === "--dry-run") dryRun = true;
35
- else if (arg === "--restore-monolith") restoreMonolith = true;
36
- else if (arg === "--install-hook") installHook = true;
37
- else if (arg === "--yes") yes = true;
38
- else if (arg === "--json") isJson = true;
39
- else throw new Error(`unknown sync option: ${arg}`);
40
- }
41
- return { dryRun, restoreMonolith, installHook, yes, isJson };
42
- }
43
-
44
- /**
45
- * A target directory that doesn't exist yet is about to be created by `sync`.
46
- * `manifest.targets[*].dir` is vault content — readable and writable by whatever
47
- * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
48
- * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
49
- * gate, a tampered manifest naming a brand-new path gets that directory silently
50
- * created (and populated with symlinks) the next time anyone pulls. Creation for
51
- * an as-yet-unseen directory therefore requires either `--yes` or an interactive
52
- * confirmation; once the directory exists, later syncs never hit this path again.
53
- */
54
- async function confirmNewSyncTarget(
55
- label: string,
56
- dir: string,
57
- yes: boolean,
58
- isJson: boolean,
59
- ): Promise<boolean> {
60
- if (yes) return true;
61
- if (!isInteractive()) {
62
- if (!isJson) {
63
- console.log(
64
- `${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
65
- );
66
- }
67
- return false;
68
- }
69
- return confirmAction(`${label}: create new target directory ${dir}?`);
70
- }
71
-
72
- interface SyncTargetSummary {
73
- target: string;
74
- status:
75
- | "synced"
76
- | "skipped_host_mismatch"
77
- | "restored"
78
- | "not_owned"
79
- | "skipped_not_approved";
80
- added?: string[];
81
- removed?: string[];
82
- skipped?: string[];
83
- projects?: {
84
- group: string;
85
- pin_dir: string;
86
- added: string[];
87
- removed: string[];
88
- skipped: string[];
89
- }[];
90
- }
91
-
92
- export async function runSync(args: string[]): Promise<void> {
93
- const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
94
- const config = await loadConfig();
95
- const vaultPath = expandHome(config.vault_path);
96
- const log = (line: string) => {
97
- if (!isJson) console.log(line);
98
- };
99
- const warnLine = (line: string) => {
100
- if (!isJson) warn(line);
101
- };
102
-
103
- let hookInstalled: boolean | undefined;
104
- if (installHook) {
105
- const result = installPostMergeHook(vaultPath);
106
- hookInstalled = result.installed;
107
- log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
108
- }
109
-
110
- const manifestPath = resolveManifestPath(vaultPath);
111
- if (!manifestPath) {
112
- emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
113
- console.log("no skillmux.toml found at vault root — nothing to sync"),
114
- );
115
- return;
116
- }
117
-
118
- const manifest = parseManifest(await Bun.file(manifestPath).text());
119
- const localVaultPaths = config.local_vault_paths.map(expandHome);
120
- const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
121
- for (const note of notes) log(`note: ${note}`);
122
-
123
- const currentHost = hostname();
124
- const targetSummaries: SyncTargetSummary[] = [];
125
- for (const [targetName, target] of Object.entries(manifest.targets)) {
126
- if (target.host !== undefined && target.host !== currentHost) {
127
- log(
128
- `${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
129
- );
130
- targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
131
- continue;
132
- }
133
- const targetDir = resolveTargetDir(targetName, target);
134
-
135
- if (restoreMonolith) {
136
- const result = restoreMonolithTarget(targetDir, vaultPath);
137
- log(
138
- result.restored
139
- ? `${targetName}: restored to a vault symlink`
140
- : `${targetName}: not owned by skillmux, left untouched`,
141
- );
142
- targetSummaries.push({
143
- target: targetName,
144
- status: result.restored ? "restored" : "not_owned",
145
- });
146
- continue;
147
- }
148
-
149
- if (!dryRun && !existsSync(targetDir)) {
150
- const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
151
- if (!approved) {
152
- if (isInteractive()) {
153
- log(`${targetName}: skipped — creating ${targetDir} was not approved`);
154
- }
155
- targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
156
- continue;
157
- }
158
- }
159
-
160
- const suffix = dryRun ? " (dry-run)" : "";
161
- const result = syncTarget(
162
- {
163
- vaultPath,
164
- targetDir,
165
- targetName,
166
- coreSkillIds: manifest.core.skills,
167
- localVaultPaths,
168
- },
169
- { dryRun },
170
- );
171
- log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
172
- if (result.skipped.length > 0) {
173
- warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
174
- }
175
- const summary: SyncTargetSummary = {
176
- target: targetName,
177
- status: "synced",
178
- added: result.added,
179
- removed: result.removed,
180
- skipped: result.skipped,
181
- };
182
-
183
- if (target.project_groups.length > 0) {
184
- const allGroups = manifest.project ?? {};
185
- const projectGroups: Record<string, ProjectGroupInput> = {};
186
- for (const groupName of target.project_groups) {
187
- const group = allGroups[groupName]!;
188
- const approvedPaths: string[] = [];
189
- for (const path of group.paths) {
190
- // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
191
- // never prompt for a project path it would silently skip anyway.
192
- if (!existsSync(path)) continue;
193
- const pinDir = resolveProjectPinDir(targetDir, path);
194
- if (dryRun || existsSync(pinDir)) {
195
- approvedPaths.push(path);
196
- continue;
197
- }
198
- const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
199
- if (approved) approvedPaths.push(path);
200
- }
201
- projectGroups[groupName] = { ...group, paths: approvedPaths };
202
- }
203
- const projectResults = syncProjectTargets(
204
- { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
205
- { dryRun },
206
- );
207
- summary.projects = projectResults.map((projectResult) => ({
208
- group: projectResult.group,
209
- pin_dir: projectResult.pinDir,
210
- added: projectResult.added,
211
- removed: projectResult.removed,
212
- skipped: projectResult.skipped,
213
- }));
214
- for (const projectResult of projectResults) {
215
- log(
216
- ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
217
- );
218
- if (projectResult.skipped.length > 0) {
219
- warnLine(
220
- `refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
221
- );
222
- }
223
- }
224
- }
225
- targetSummaries.push(summary);
226
- }
227
-
228
- emitSuccess(
229
- { isJson },
230
- { hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
231
- () => {},
232
- );
233
- }
@@ -1,225 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { hostname } from "node:os";
3
- import { expandHome } from "../config";
4
- import {
5
- BUILT_IN_TARGET_NAMES,
6
- planAgentSurfaces,
7
- resolveBuiltInTarget,
8
- SUPPORTED_AGENT_IDS,
9
- } from "../init-agents";
10
- import { planInitManifest, applyInit } from "../init";
11
- import { resolveTargetDir, writeManifestAtomic } from "../manifest";
12
- import { emitSuccess, unknownSubcommandError } from "../output";
13
- import { applyTargetMarkerRehome, planTargetMarkerRehome, resolveProjectPinDir } from "../sync";
14
- import { confirmIfNeeded, loadManifestContext } from "./shared";
15
-
16
- export async function runTarget(
17
- subCommand: string,
18
- args: string[],
19
- options: { isJson: boolean; dryRun: boolean },
20
- ): Promise<void> {
21
- const { config, vaultPath, manifestPath, manifest } = await loadManifestContext();
22
-
23
- if (subCommand === "list" || subCommand === "show") {
24
- const names =
25
- subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
26
- if (subCommand === "show" && !manifest.targets[names[0]!]) {
27
- throw new Error(`target "${names[0]}" does not exist`);
28
- }
29
- const targets = names.map((name) => {
30
- const target = manifest.targets[name]!;
31
- const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
32
- const surface = planAgentSurfaces([agent]).surfaces[0];
33
- return surface !== undefined && surface.path === resolveTargetDir(name, target);
34
- });
35
- return { name, ...target, dir: resolveTargetDir(name, target), agents };
36
- });
37
- emitSuccess({ isJson: options.isJson }, { targets }, () => {
38
- if (targets.length === 0) {
39
- console.log("no targets configured");
40
- } else {
41
- for (const target of targets) {
42
- console.log(`${target.name}:`);
43
- console.log(` dir: ${target.dir}`);
44
- console.log(` host: ${target.host ?? "(global)"}`);
45
- console.log(` agents: ${target.agents.join(", ") || "(custom)"}`);
46
- console.log(
47
- ` projects: ${target.project_groups.join(", ") || "(none)"}`,
48
- );
49
- }
50
- }
51
- });
52
- return;
53
- }
54
-
55
- if (subCommand === "add") {
56
- const name = args[0];
57
- const dirIndex = args.indexOf("--dir");
58
- const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
59
- if (!name)
60
- throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
61
-
62
- let path: string;
63
- if (rawPath) {
64
- path = expandHome(rawPath);
65
- } else if (BUILT_IN_TARGET_NAMES.has(name)) {
66
- path = resolveBuiltInTarget(name, {
67
- codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
68
- }).path;
69
- } else {
70
- throw new Error(
71
- "usage: skillmux target add <name> --dir <dir> --yes (--dir may be omitted for built-in target names: agent-skills, claude-code, codex)",
72
- );
73
- }
74
- if (options.dryRun) {
75
- const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
76
- emitSuccess(
77
- { isJson: options.isJson },
78
- { target: planned.targets[name] },
79
- () => console.log(`target add: ${name} -> ${path} (dry-run)`),
80
- );
81
- return;
82
- }
83
- if (
84
- !(await confirmIfNeeded({
85
- confirmed: args.includes("--yes"),
86
- isJson: options.isJson,
87
- prompt: `adopt target ${name} at ${path}?`,
88
- nonInteractiveError:
89
- "skillmux target add requires --yes when run non-interactively",
90
- }))
91
- )
92
- return;
93
- applyInit(vaultPath, [{ name, dir: path }]);
94
- emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
95
- console.log(`target "${name}" added at ${path}`),
96
- );
97
- return;
98
- }
99
-
100
- if (subCommand === "remove") {
101
- const name = args[0];
102
- if (!name || !manifest.targets[name]) {
103
- throw new Error(
104
- name
105
- ? `target "${name}" does not exist`
106
- : "usage: skillmux target remove <name> --yes",
107
- );
108
- }
109
- if (options.dryRun) {
110
- emitSuccess(
111
- { isJson: options.isJson },
112
- { name, preserved_dir: resolveTargetDir(name, manifest.targets[name]!) },
113
- () => console.log(`target remove: ${name} (files preserved, dry-run)`),
114
- );
115
- return;
116
- }
117
- if (
118
- !(await confirmIfNeeded({
119
- confirmed: args.includes("--yes"),
120
- isJson: options.isJson,
121
- prompt: `remove target ${name} from the manifest and preserve its files?`,
122
- nonInteractiveError:
123
- "skillmux target remove requires --yes when run non-interactively",
124
- }))
125
- )
126
- return;
127
- const targets = { ...manifest.targets };
128
- const removedDir = resolveTargetDir(name, manifest.targets[name]!);
129
- delete targets[name];
130
- writeManifestAtomic(manifestPath, { ...manifest, targets });
131
- emitSuccess(
132
- { isJson: options.isJson },
133
- { name, preserved_dir: removedDir },
134
- () =>
135
- console.log(
136
- `target "${name}" removed from the manifest; files preserved at ${removedDir}`,
137
- ),
138
- );
139
- return;
140
- }
141
-
142
- if (subCommand === "rehome") {
143
- const name = args[0];
144
- const target = name ? manifest.targets[name] : undefined;
145
- if (!name || !target) {
146
- throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target rehome <name> --yes");
147
- }
148
- if (target.host !== undefined && target.host !== hostname()) {
149
- throw new Error(`target "${name}" is scoped to host ${target.host}, not ${hostname()}`);
150
- }
151
-
152
- const targetDir = resolveTargetDir(name, target);
153
- if (!existsSync(targetDir)) throw new Error(`target "${name}" directory does not exist: ${targetDir}`);
154
- const dirs = [targetDir];
155
- for (const groupName of target.project_groups) {
156
- const group = manifest.project?.[groupName];
157
- if (!group) continue;
158
- for (const projectPath of group.paths) {
159
- if (!existsSync(projectPath)) continue;
160
- const pinDir = resolveProjectPinDir(targetDir, projectPath);
161
- if (existsSync(pinDir)) dirs.push(pinDir);
162
- }
163
- }
164
- const plans = dirs.map((dir) =>
165
- planTargetMarkerRehome(dir, name, vaultPath, config.local_vault_paths.map(expandHome)),
166
- );
167
- const markerPaths = plans.map((plan) => plan.markerPath);
168
- if (options.dryRun) {
169
- emitSuccess(
170
- { isJson: options.isJson },
171
- { name, marker_paths: markerPaths },
172
- () => console.log(`target rehome: ${name} (${markerPaths.length} markers, dry-run)`),
173
- );
174
- return;
175
- }
176
- if (
177
- !(await confirmIfNeeded({
178
- confirmed: args.includes("--yes"),
179
- isJson: options.isJson,
180
- prompt: `rehome ${markerPaths.length} ${name} marker(s) to ${vaultPath}?`,
181
- nonInteractiveError: "skillmux target rehome requires --yes when run non-interactively",
182
- }))
183
- )
184
- return;
185
- applyTargetMarkerRehome(plans, vaultPath);
186
- emitSuccess(
187
- { isJson: options.isJson },
188
- { name, marker_paths: markerPaths },
189
- () => console.log(`target "${name}" rehomed ${markerPaths.length} marker(s) to ${vaultPath}`),
190
- );
191
- return;
192
- }
193
-
194
- if (subCommand === "migrate") {
195
- const builtInTargets = Object.entries(manifest.targets).filter(([name]) =>
196
- BUILT_IN_TARGET_NAMES.has(name),
197
- );
198
- if (options.dryRun) {
199
- emitSuccess(
200
- { isJson: options.isJson },
201
- { migrated_targets: builtInTargets.map(([name]) => name) },
202
- () => console.log(`target migrate: ${builtInTargets.length} built-in target(s) (dry-run)`),
203
- );
204
- return;
205
- }
206
- if (
207
- !(await confirmIfNeeded({
208
- confirmed: args.includes("--yes"),
209
- isJson: options.isJson,
210
- prompt: `remove redundant dir fields from ${builtInTargets.length} built-in target(s)?`,
211
- nonInteractiveError: "skillmux target migrate requires --yes when run non-interactively",
212
- }))
213
- )
214
- return;
215
- writeManifestAtomic(manifestPath, manifest);
216
- emitSuccess(
217
- { isJson: options.isJson },
218
- { migrated_targets: builtInTargets.map(([name]) => name) },
219
- () => console.log(`target migrate: normalized ${builtInTargets.length} built-in target(s)`),
220
- );
221
- return;
222
- }
223
-
224
- throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove", "rehome", "migrate"]);
225
- }