@klhapp/skillmux 1.9.3 → 1.11.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 (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +19 -19
  3. package/docs/README.md +4 -4
  4. package/docs/assets/architecture-dark.svg +39 -32
  5. package/docs/assets/architecture-light.svg +25 -18
  6. package/docs/cli.md +147 -36
  7. package/docs/concepts.md +11 -11
  8. package/docs/configuration.md +7 -5
  9. package/docs/deployment.md +10 -6
  10. package/docs/getting-started.md +18 -14
  11. package/docs/mcp-routing.md +1 -1
  12. package/docs/skill-management.md +17 -11
  13. package/docs/troubleshooting.md +4 -4
  14. package/package.json +1 -1
  15. package/src/adapters.ts +157 -11
  16. package/src/cli.ts +396 -1319
  17. package/src/commands/audit.ts +53 -56
  18. package/src/commands/config.ts +33 -26
  19. package/src/commands/context.ts +104 -0
  20. package/src/commands/core.ts +7 -3
  21. package/src/commands/doctor.ts +97 -0
  22. package/src/commands/eval.ts +22 -15
  23. package/src/commands/init.ts +672 -0
  24. package/src/commands/install.ts +132 -0
  25. package/src/commands/local-vault.ts +60 -0
  26. package/src/commands/models.ts +10 -0
  27. package/src/commands/outdated.ts +2 -1
  28. package/src/commands/project.ts +194 -51
  29. package/src/commands/report.ts +66 -0
  30. package/src/commands/scan.ts +61 -0
  31. package/src/commands/shared.ts +7 -14
  32. package/src/commands/skill.ts +33 -0
  33. package/src/commands/sync.ts +232 -0
  34. package/src/commands/target.ts +45 -15
  35. package/src/commands/update.ts +2 -1
  36. package/src/completions.ts +41 -15
  37. package/src/config-service.ts +4 -54
  38. package/src/context.ts +8 -3
  39. package/src/db-audit.ts +286 -0
  40. package/src/db-index.ts +238 -0
  41. package/src/db.ts +3 -521
  42. package/src/global-flags.ts +46 -0
  43. package/src/init-agents.ts +329 -0
  44. package/src/init-instructions.ts +47 -28
  45. package/src/logger.ts +26 -0
  46. package/src/mcp-registration.ts +89 -0
  47. package/src/output.ts +80 -18
  48. package/src/prompts.ts +75 -20
  49. package/src/router-core.ts +8 -27
  50. package/src/scan.ts +19 -19
  51. package/src/server.ts +161 -14
  52. package/src/toml-writer.ts +51 -0
  53. package/src/init-clients.ts +0 -220
@@ -0,0 +1,66 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { ContextAdapter } from "../adapters";
3
+ import type { ResolvedContext } from "../context";
4
+ import { emitSuccess } from "../output";
5
+ import { getStats, renderStatsText } from "../stats";
6
+ import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
7
+
8
+ function parseReportArgs(args: string[]): {
9
+ db?: string;
10
+ since?: string;
11
+ } {
12
+ let db: string | undefined;
13
+ let since: string | undefined;
14
+ for (let i = 0; i < args.length; i++) {
15
+ const option = args[i];
16
+ const value = args[i + 1];
17
+ if (option === "--db") {
18
+ if (!value) throw new Error("--db requires a path");
19
+ db = value;
20
+ i++;
21
+ } else if (option === "--since") {
22
+ if (!value) throw new Error("--since requires a window");
23
+ since = value;
24
+ i++;
25
+ } else if (isGlobalFlag(option, "--json", "--allow-insecure")) {
26
+ // handled globally by main()'s isJson/allowInsecure flags; recognized here so it isn't rejected
27
+ } else if (isGlobalFlagWithValue(option)) {
28
+ // handled globally by main()'s resolveContext(); recognized here so it isn't rejected
29
+ i++;
30
+ } else {
31
+ throw new Error(`unknown report option: ${option}`);
32
+ }
33
+ }
34
+ return { db, since };
35
+ }
36
+
37
+ export async function runReport(
38
+ args: string[],
39
+ options: { isJson: boolean; context: ResolvedContext; allowInsecure: boolean; adapter: ContextAdapter },
40
+ ): Promise<void> {
41
+ const { db: dbPath, since } = parseReportArgs(args);
42
+ if (!since)
43
+ throw new Error(
44
+ "usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
45
+ );
46
+ if (dbPath && options.context.type === "remote")
47
+ throw new Error("--db and --context/--server are mutually exclusive");
48
+
49
+ if (dbPath) {
50
+ const db = new Database(dbPath, { readonly: true });
51
+ try {
52
+ const stats = getStats(db, since);
53
+ emitSuccess({ isJson: options.isJson }, stats, () =>
54
+ console.log(renderStatsText(stats)),
55
+ );
56
+ } finally {
57
+ db.close();
58
+ }
59
+ return;
60
+ }
61
+
62
+ const stats = await options.adapter.getStats(since);
63
+ emitSuccess({ isJson: options.isJson }, stats, () =>
64
+ console.log(renderStatsText(stats)),
65
+ );
66
+ }
@@ -0,0 +1,61 @@
1
+ import { expandHome, loadConfig } from "../config";
2
+ import { emitSuccess } from "../output";
3
+ import {
4
+ renderScanJson,
5
+ renderScanText,
6
+ scanExitCode,
7
+ scanPath,
8
+ type ScanSeverity,
9
+ } from "../scan";
10
+ import { isGlobalFlag } from "../global-flags";
11
+
12
+ function parseScanArgs(args: string[]): {
13
+ path?: string;
14
+ format: "text" | "json";
15
+ failOn?: ScanSeverity;
16
+ } {
17
+ let path: string | undefined;
18
+ let format: "text" | "json" = "text";
19
+ let failOn: ScanSeverity | undefined;
20
+ for (let i = 0; i < args.length; i++) {
21
+ const option = args[i];
22
+ if (option === "--format") {
23
+ const value = args[++i];
24
+ if (value !== "text" && value !== "json")
25
+ throw new Error("--format must be text or json");
26
+ format = value;
27
+ } else if (option === "--fail-on") {
28
+ const value = args[++i];
29
+ if (value !== "low" && value !== "medium" && value !== "high") {
30
+ throw new Error("--fail-on must be low, medium, or high");
31
+ }
32
+ failOn = value;
33
+ } else if (isGlobalFlag(option, "--json")) {
34
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
35
+ } else if (option?.startsWith("--")) {
36
+ throw new Error(`unknown scan option: ${option}`);
37
+ } else if (path !== undefined) {
38
+ throw new Error("skillmux scan accepts at most one <path> argument");
39
+ } else {
40
+ path = option;
41
+ }
42
+ }
43
+ return { path, format, failOn };
44
+ }
45
+
46
+ export async function runScan(
47
+ args: string[],
48
+ options: { isJson: boolean },
49
+ ): Promise<void> {
50
+ const { path, format, failOn } = parseScanArgs(args);
51
+ const rootPath = path
52
+ ? expandHome(path)
53
+ : expandHome((await loadConfig()).vault_path);
54
+ const result = await scanPath(rootPath);
55
+ emitSuccess({ isJson: options.isJson }, result, () => {
56
+ console.log(
57
+ format === "json" ? renderScanJson(result) : renderScanText(result),
58
+ );
59
+ });
60
+ process.exitCode = scanExitCode(result.findings, failOn);
61
+ }
@@ -1,21 +1,14 @@
1
- import { createInterface } from "node:readline/promises";
2
1
  import { expandHome, loadConfig } from "../config";
3
2
  import { parseManifest, resolveManifestPath } from "../manifest";
4
3
  import { isInteractive } from "../output";
4
+ import { askQuestion, type PromptIO } from "../prompts";
5
5
 
6
- export async function confirmAction(prompt: string): Promise<boolean> {
7
- const readline = createInterface({
8
- input: process.stdin,
9
- output: process.stdout,
10
- });
11
- try {
12
- const answer = (await readline.question(`${prompt} [y/N] `))
13
- .trim()
14
- .toLowerCase();
15
- return answer === "y" || answer === "yes";
16
- } finally {
17
- readline.close();
18
- }
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";
19
12
  }
20
13
 
21
14
  export async function loadManifestContext() {
@@ -0,0 +1,33 @@
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
+ }
@@ -0,0 +1,232 @@
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
+ } from "../manifest";
9
+ import { emitSuccess, isInteractive, warn } from "../output";
10
+ import {
11
+ installPostMergeHook,
12
+ resolveProjectPinDir,
13
+ restoreMonolith as restoreMonolithTarget,
14
+ syncProjectTargets,
15
+ syncTarget,
16
+ type ProjectGroupInput,
17
+ } from "../sync";
18
+ import { confirmAction } from "./shared";
19
+
20
+ function parseSyncArgs(args: string[]): {
21
+ dryRun: boolean;
22
+ restoreMonolith: boolean;
23
+ installHook: boolean;
24
+ yes: boolean;
25
+ isJson: boolean;
26
+ } {
27
+ let dryRun = false;
28
+ let restoreMonolith = false;
29
+ let installHook = false;
30
+ let yes = false;
31
+ let isJson = false;
32
+ for (const arg of args) {
33
+ if (arg === "--dry-run") dryRun = true;
34
+ else if (arg === "--restore-monolith") restoreMonolith = true;
35
+ else if (arg === "--install-hook") installHook = true;
36
+ else if (arg === "--yes") yes = true;
37
+ else if (arg === "--json") isJson = true;
38
+ else throw new Error(`unknown sync option: ${arg}`);
39
+ }
40
+ return { dryRun, restoreMonolith, installHook, yes, isJson };
41
+ }
42
+
43
+ /**
44
+ * A target directory that doesn't exist yet is about to be created by `sync`.
45
+ * `manifest.targets[*].dir` is vault content — readable and writable by whatever
46
+ * populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
47
+ * `sync` can run unattended via the `--install-hook` post-merge hook. Without this
48
+ * gate, a tampered manifest naming a brand-new path gets that directory silently
49
+ * created (and populated with symlinks) the next time anyone pulls. Creation for
50
+ * an as-yet-unseen directory therefore requires either `--yes` or an interactive
51
+ * confirmation; once the directory exists, later syncs never hit this path again.
52
+ */
53
+ async function confirmNewSyncTarget(
54
+ label: string,
55
+ dir: string,
56
+ yes: boolean,
57
+ isJson: boolean,
58
+ ): Promise<boolean> {
59
+ if (yes) return true;
60
+ if (!isInteractive()) {
61
+ if (!isJson) {
62
+ console.log(
63
+ `${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.`,
64
+ );
65
+ }
66
+ return false;
67
+ }
68
+ return confirmAction(`${label}: create new target directory ${dir}?`);
69
+ }
70
+
71
+ interface SyncTargetSummary {
72
+ target: string;
73
+ status:
74
+ | "synced"
75
+ | "skipped_host_mismatch"
76
+ | "restored"
77
+ | "not_owned"
78
+ | "skipped_not_approved";
79
+ added?: string[];
80
+ removed?: string[];
81
+ skipped?: string[];
82
+ projects?: {
83
+ group: string;
84
+ pin_dir: string;
85
+ added: string[];
86
+ removed: string[];
87
+ skipped: string[];
88
+ }[];
89
+ }
90
+
91
+ export async function runSync(args: string[]): Promise<void> {
92
+ const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
93
+ const config = await loadConfig();
94
+ const vaultPath = expandHome(config.vault_path);
95
+ const log = (line: string) => {
96
+ if (!isJson) console.log(line);
97
+ };
98
+ const warnLine = (line: string) => {
99
+ if (!isJson) warn(line);
100
+ };
101
+
102
+ let hookInstalled: boolean | undefined;
103
+ if (installHook) {
104
+ const result = installPostMergeHook(vaultPath);
105
+ hookInstalled = result.installed;
106
+ log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
107
+ }
108
+
109
+ const manifestPath = resolveManifestPath(vaultPath);
110
+ if (!manifestPath) {
111
+ emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
112
+ console.log("no skillmux.toml found at vault root — nothing to sync"),
113
+ );
114
+ return;
115
+ }
116
+
117
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
118
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
119
+ const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
120
+ for (const note of notes) log(`note: ${note}`);
121
+
122
+ const currentHost = hostname();
123
+ const targetSummaries: SyncTargetSummary[] = [];
124
+ for (const [targetName, target] of Object.entries(manifest.targets)) {
125
+ if (target.host !== undefined && target.host !== currentHost) {
126
+ log(
127
+ `${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
128
+ );
129
+ targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
130
+ continue;
131
+ }
132
+ const targetDir = expandHome(target.dir);
133
+
134
+ if (restoreMonolith) {
135
+ const result = restoreMonolithTarget(targetDir, vaultPath);
136
+ log(
137
+ result.restored
138
+ ? `${targetName}: restored to a vault symlink`
139
+ : `${targetName}: not owned by skillmux, left untouched`,
140
+ );
141
+ targetSummaries.push({
142
+ target: targetName,
143
+ status: result.restored ? "restored" : "not_owned",
144
+ });
145
+ continue;
146
+ }
147
+
148
+ if (!dryRun && !existsSync(targetDir)) {
149
+ const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
150
+ if (!approved) {
151
+ if (isInteractive()) {
152
+ log(`${targetName}: skipped — creating ${targetDir} was not approved`);
153
+ }
154
+ targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
155
+ continue;
156
+ }
157
+ }
158
+
159
+ const suffix = dryRun ? " (dry-run)" : "";
160
+ const result = syncTarget(
161
+ {
162
+ vaultPath,
163
+ targetDir,
164
+ targetName,
165
+ coreSkillIds: manifest.core.skills,
166
+ localVaultPaths,
167
+ },
168
+ { dryRun },
169
+ );
170
+ log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
171
+ if (result.skipped.length > 0) {
172
+ warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
173
+ }
174
+ const summary: SyncTargetSummary = {
175
+ target: targetName,
176
+ status: "synced",
177
+ added: result.added,
178
+ removed: result.removed,
179
+ skipped: result.skipped,
180
+ };
181
+
182
+ if (target.project_groups.length > 0) {
183
+ const allGroups = manifest.project ?? {};
184
+ const projectGroups: Record<string, ProjectGroupInput> = {};
185
+ for (const groupName of target.project_groups) {
186
+ const group = allGroups[groupName]!;
187
+ const approvedPaths: string[] = [];
188
+ for (const path of group.paths) {
189
+ // Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
190
+ // never prompt for a project path it would silently skip anyway.
191
+ if (!existsSync(path)) continue;
192
+ const pinDir = resolveProjectPinDir(targetDir, path);
193
+ if (dryRun || existsSync(pinDir)) {
194
+ approvedPaths.push(path);
195
+ continue;
196
+ }
197
+ const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
198
+ if (approved) approvedPaths.push(path);
199
+ }
200
+ projectGroups[groupName] = { ...group, paths: approvedPaths };
201
+ }
202
+ const projectResults = syncProjectTargets(
203
+ { vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
204
+ { dryRun },
205
+ );
206
+ summary.projects = projectResults.map((projectResult) => ({
207
+ group: projectResult.group,
208
+ pin_dir: projectResult.pinDir,
209
+ added: projectResult.added,
210
+ removed: projectResult.removed,
211
+ skipped: projectResult.skipped,
212
+ }));
213
+ for (const projectResult of projectResults) {
214
+ log(
215
+ ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
216
+ );
217
+ if (projectResult.skipped.length > 0) {
218
+ warnLine(
219
+ `refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
220
+ );
221
+ }
222
+ }
223
+ }
224
+ targetSummaries.push(summary);
225
+ }
226
+
227
+ emitSuccess(
228
+ { isJson },
229
+ { hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
230
+ () => {},
231
+ );
232
+ }
@@ -1,9 +1,15 @@
1
1
  import { expandHome } from "../config";
2
- import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
2
+ import {
3
+ BUILT_IN_TARGET_NAMES,
4
+ planAgentSurfaces,
5
+ resolveBuiltInTarget,
6
+ SUPPORTED_AGENT_IDS,
7
+ } from "../init-agents";
3
8
  import { planInitManifest, applyInit } from "../init";
4
9
  import { writeManifestAtomic } from "../manifest";
5
- import { emitSuccess } from "../output";
10
+ import { emitSuccess, unknownSubcommandError } from "../output";
6
11
  import { confirmIfNeeded, loadManifestContext } from "./shared";
12
+
7
13
  export async function runTarget(
8
14
  subCommand: string,
9
15
  args: string[],
@@ -19,11 +25,11 @@ export async function runTarget(
19
25
  }
20
26
  const targets = names.map((name) => {
21
27
  const target = manifest.targets[name]!;
22
- const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
23
- const surface = planClientSurfaces([client]).surfaces[0];
28
+ const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
29
+ const surface = planAgentSurfaces([agent]).surfaces[0];
24
30
  return surface !== undefined && surface.path === expandHome(target.dir);
25
31
  });
26
- return { name, ...target, clients };
32
+ return { name, ...target, agents };
27
33
  });
28
34
  emitSuccess({ isJson: options.isJson }, { targets }, () => {
29
35
  if (targets.length === 0) {
@@ -33,7 +39,7 @@ export async function runTarget(
33
39
  console.log(`${target.name}:`);
34
40
  console.log(` dir: ${target.dir}`);
35
41
  console.log(` host: ${target.host ?? "(global)"}`);
36
- console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
42
+ console.log(` agents: ${target.agents.join(", ") || "(custom)"}`);
37
43
  console.log(
38
44
  ` projects: ${target.project_groups.join(", ") || "(none)"}`,
39
45
  );
@@ -47,9 +53,21 @@ export async function runTarget(
47
53
  const name = args[0];
48
54
  const dirIndex = args.indexOf("--dir");
49
55
  const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
50
- if (!name || !rawPath)
56
+ if (!name)
51
57
  throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
52
- const path = expandHome(rawPath);
58
+
59
+ let path: string;
60
+ if (rawPath) {
61
+ path = expandHome(rawPath);
62
+ } else if (BUILT_IN_TARGET_NAMES.has(name)) {
63
+ path = resolveBuiltInTarget(name, {
64
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
65
+ }).path;
66
+ } else {
67
+ throw new Error(
68
+ "usage: skillmux target add <name> --dir <dir> --yes (--dir may be omitted for built-in target names: agent-skills, claude-code, codex)",
69
+ );
70
+ }
53
71
  if (options.dryRun) {
54
72
  const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
55
73
  emitSuccess(
@@ -63,14 +81,16 @@ export async function runTarget(
63
81
  !(await confirmIfNeeded({
64
82
  confirmed: args.includes("--yes"),
65
83
  isJson: options.isJson,
66
- prompt: `Adopt target ${name} at ${path}?`,
84
+ prompt: `adopt target ${name} at ${path}?`,
67
85
  nonInteractiveError:
68
86
  "skillmux target add requires --yes when run non-interactively",
69
87
  }))
70
88
  )
71
89
  return;
72
90
  applyInit(vaultPath, [{ name, dir: path }]);
73
- console.log(`target "${name}" added at ${path}`);
91
+ emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
92
+ console.log(`target "${name}" added at ${path}`),
93
+ );
74
94
  return;
75
95
  }
76
96
 
@@ -84,27 +104,37 @@ export async function runTarget(
84
104
  );
85
105
  }
86
106
  if (options.dryRun) {
87
- console.log(`target remove: ${name} (files preserved, dry-run)`);
107
+ emitSuccess(
108
+ { isJson: options.isJson },
109
+ { name, preserved_dir: manifest.targets[name]!.dir },
110
+ () => console.log(`target remove: ${name} (files preserved, dry-run)`),
111
+ );
88
112
  return;
89
113
  }
90
114
  if (
91
115
  !(await confirmIfNeeded({
92
116
  confirmed: args.includes("--yes"),
93
117
  isJson: options.isJson,
94
- prompt: `Remove target ${name} from the manifest and preserve its files?`,
118
+ prompt: `remove target ${name} from the manifest and preserve its files?`,
95
119
  nonInteractiveError:
96
120
  "skillmux target remove requires --yes when run non-interactively",
97
121
  }))
98
122
  )
99
123
  return;
100
124
  const targets = { ...manifest.targets };
125
+ const removedDir = manifest.targets[name]!.dir;
101
126
  delete targets[name];
102
127
  writeManifestAtomic(manifestPath, { ...manifest, targets });
103
- console.log(
104
- `target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
128
+ emitSuccess(
129
+ { isJson: options.isJson },
130
+ { name, preserved_dir: removedDir },
131
+ () =>
132
+ console.log(
133
+ `target "${name}" removed from the manifest; files preserved at ${removedDir}`,
134
+ ),
105
135
  );
106
136
  return;
107
137
  }
108
138
 
109
- throw new Error("usage: skillmux target <list|show|add|remove>");
139
+ throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove"]);
110
140
  }
@@ -18,6 +18,7 @@ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
18
18
  import { SKILL_ID_PATTERN } from "../vault";
19
19
  import { confirmIfNeeded } from "./shared";
20
20
  import { checkOutdated } from "./outdated";
21
+ import { isGlobalFlag } from "../global-flags";
21
22
 
22
23
  type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
23
24
 
@@ -195,7 +196,7 @@ function parseUpdateArgs(args: string[]): {
195
196
  throw new Error("--fail-on must be low, medium, or high");
196
197
  }
197
198
  failOn = value;
198
- } else if (arg === "--json") {
199
+ } else if (isGlobalFlag(arg, "--json")) {
199
200
  // handled globally
200
201
  } else if (arg?.startsWith("--")) {
201
202
  throw new Error(`unknown update option: ${arg}`);