argsbarg 4.0.4 → 4.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +5 -5
  3. package/docs/ai-skills.md +8 -5
  4. package/docs/cli-program.md +4 -4
  5. package/docs/config-schema.md +1 -4
  6. package/docs/developing.md +1 -1
  7. package/docs/install.md +160 -39
  8. package/docs/mcp.md +21 -9
  9. package/docs/templates/cursor/rules/cli-program.mdc +1 -1
  10. package/examples/config-app/program.ts +0 -3
  11. package/examples/consumer-app/README.md +0 -1
  12. package/examples/consumer-app/src/program.ts +3 -3
  13. package/examples/mcp-test.ts +8 -24
  14. package/index.d.ts +60 -5
  15. package/package.json +1 -1
  16. package/src/builtins/builtins.test.ts +20 -4
  17. package/src/builtins/config.test.ts +31 -25
  18. package/src/builtins/config.ts +4 -3
  19. package/src/builtins/install.ts +50 -57
  20. package/src/builtins/mcp.ts +1 -1
  21. package/src/capabilities.ts +4 -4
  22. package/src/cli.ts +2 -0
  23. package/src/config/bootstrap.ts +167 -66
  24. package/src/config/context.test.ts +22 -36
  25. package/src/config/context.ts +5 -4
  26. package/src/config/file.test.ts +66 -56
  27. package/src/config/file.ts +33 -25
  28. package/src/config/resolve.test.ts +25 -1
  29. package/src/config/resolve.ts +43 -8
  30. package/src/config.integration.test.ts +17 -10
  31. package/src/docs/api-guide.test.ts +1 -1
  32. package/src/docs/docs.test.ts +1 -1
  33. package/src/docs/mcp-guide.ts +1 -1
  34. package/src/hidden-mcpb.test.ts +41 -1
  35. package/src/index.ts +4 -0
  36. package/src/install/{binary.ts → app.ts} +13 -13
  37. package/src/install/bootstrap.ts +22 -0
  38. package/src/install/detect-installed.ts +2 -97
  39. package/src/install/index.ts +187 -110
  40. package/src/install/install-validate.test.ts +61 -0
  41. package/src/install/install.test.ts +138 -42
  42. package/src/install/mcp-openclaw.test.ts +40 -0
  43. package/src/install/mcp-openclaw.ts +106 -0
  44. package/src/install/normalize.ts +35 -0
  45. package/src/install/paths.ts +27 -13
  46. package/src/install/plan.ts +30 -259
  47. package/src/install/shell.ts +2 -2
  48. package/src/install/status.test.ts +85 -0
  49. package/src/install/status.ts +22 -9
  50. package/src/install/target-base.ts +93 -0
  51. package/src/install/target-detect.ts +20 -0
  52. package/src/install/target-effective.ts +131 -0
  53. package/src/install/target-mcp-cli.ts +149 -0
  54. package/src/install/target-mcp-json.ts +130 -0
  55. package/src/install/target-plan-build.ts +67 -0
  56. package/src/install/target-registry.ts +57 -0
  57. package/src/install/target-scope.ts +266 -0
  58. package/src/install/target-skill.ts +104 -0
  59. package/src/install/target-types.ts +145 -0
  60. package/src/install/targets/app.ts +69 -0
  61. package/src/install/targets/chatgpt-mcp.ts +12 -0
  62. package/src/install/targets/claude-code-mcp.ts +15 -0
  63. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  64. package/src/install/targets/claude-skill.ts +16 -0
  65. package/src/install/targets/codex-mcp.ts +25 -0
  66. package/src/install/targets/codex-skill.ts +14 -0
  67. package/src/install/targets/completions.ts +133 -0
  68. package/src/install/targets/configure.ts +59 -0
  69. package/src/install/targets/cursor-mcp.ts +15 -0
  70. package/src/install/targets/cursor-skill.ts +16 -0
  71. package/src/install/targets/index.ts +53 -0
  72. package/src/install/targets/openclaw-mcp.ts +25 -0
  73. package/src/install/targets/openclaw-skill.ts +17 -0
  74. package/src/install/targets/opencode-mcp.ts +101 -0
  75. package/src/install/targets/opencode-skill.ts +15 -0
  76. package/src/install/targets.test.ts +136 -0
  77. package/src/install/uninstall.ts +16 -152
  78. package/src/install/update.test.ts +17 -2
  79. package/src/install/update.ts +2 -5
  80. package/src/invoke.test.ts +7 -1
  81. package/src/mcp/bundle.ts +16 -4
  82. package/src/mcp/claude.test.ts +14 -1
  83. package/src/mcp/claude.ts +11 -4
  84. package/src/mcp/zip.test.ts +17 -0
  85. package/src/mcp/zip.ts +62 -9
  86. package/src/mcp.integration.test.ts +1 -1
  87. package/src/parse.test.ts +12 -1
  88. package/src/paths/host.ts +11 -11
  89. package/src/paths/remove-empty-dir.ts +13 -0
  90. package/src/skill/generate.ts +18 -4
  91. package/src/skill/install.ts +33 -6
  92. package/src/skill/naming.ts +28 -0
  93. package/src/types.ts +65 -4
  94. package/src/validate.ts +70 -0
@@ -0,0 +1,22 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { CliProgram } from "../types.ts";
3
+ import { resolveAppDir } from "./paths.ts";
4
+
5
+ /** Rewrites empty argv to `install` when the app is not yet installed (TTY only). */
6
+ export function maybeBootstrapInstallArgv(argv: string[], program: CliProgram): string[] {
7
+ if (argv.length !== 0) {
8
+ return argv;
9
+ }
10
+ if (program.install?.enabled === false) {
11
+ return argv;
12
+ }
13
+ if (!process.stdin.isTTY) {
14
+ return argv;
15
+ }
16
+ const appDir = resolveAppDir();
17
+ const appPath = `${appDir}/${program.key}`;
18
+ if (existsSync(appPath)) {
19
+ return argv;
20
+ }
21
+ return ["install"];
22
+ }
@@ -1,97 +1,2 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { codexMcpHasServer } from "./mcp-codex.ts";
3
- import { detectOpenCodeMcpConfigPath } from "./mcp-opencode.ts";
4
- import { type InstallPaths, userHome } from "./paths.ts";
5
-
6
- export interface InstalledArtifacts {
7
- binary: boolean;
8
- bashCompletion: boolean;
9
- zshCompletion: boolean;
10
- fishCompletion: boolean;
11
- cursorSkill: boolean;
12
- claudeSkill: boolean;
13
- cursorMcp: boolean;
14
- claudeMcp: boolean;
15
- claudeDesktopMcp: boolean;
16
- opencodeMcp: boolean;
17
- codexMcp: boolean;
18
- chatGptMcp: boolean;
19
- bashRcPath: boolean;
20
- zshRcFpath: boolean;
21
- }
22
-
23
- function mcpConfigHasServer(path: string, name: string): boolean {
24
- if (!existsSync(path)) return false;
25
- try {
26
- const data = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: Record<string, unknown> };
27
- return data.mcpServers?.[name] !== undefined;
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- /** Detects which install artifacts are currently present. */
34
- export function detectInstalledArtifacts(paths: InstallPaths): InstalledArtifacts {
35
- return {
36
- binary: existsSync(paths.binaryPath),
37
- bashCompletion: existsSync(paths.bashCompletion),
38
- zshCompletion: existsSync(paths.zshCompletion),
39
- fishCompletion: existsSync(paths.fishCompletion),
40
- cursorSkill: existsSync(paths.cursorSkillDir),
41
- claudeSkill: existsSync(paths.claudeSkillDir),
42
- cursorMcp: mcpConfigHasServer(paths.cursorMcpPath, paths.mcpName),
43
- claudeMcp: mcpConfigHasServer(paths.claudeMcpPath, paths.mcpName),
44
- claudeDesktopMcp: mcpConfigHasServer(paths.claudeDesktopMcpPath, paths.mcpName),
45
- opencodeMcp: detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) !== undefined,
46
- codexMcp: codexMcpHasServer(userHome(), paths.mcpName),
47
- chatGptMcp: mcpConfigHasServer(paths.chatGptMcpPath, paths.mcpName),
48
- bashRcPath: false,
49
- zshRcFpath: false,
50
- };
51
- }
52
-
53
- export interface InstallStatus {
54
- binary?: string;
55
- bashCompletion?: string;
56
- zshCompletion?: string;
57
- fishCompletion?: string;
58
- cursorSkill?: string;
59
- claudeSkill?: string;
60
- cursorMcp?: string;
61
- claudeMcp?: string;
62
- claudeDesktopMcp?: string;
63
- opencodeMcp?: string;
64
- codexMcp?: string;
65
- chatGptMcp?: string;
66
- }
67
-
68
- /** Builds a status inventory from detected artifacts. */
69
- export function buildInstallStatus(
70
- paths: InstallPaths,
71
- detected: InstalledArtifacts,
72
- ): InstallStatus {
73
- const status: InstallStatus = {};
74
- if (detected.binary) status.binary = paths.binaryPath;
75
- if (detected.bashCompletion) status.bashCompletion = paths.bashCompletion;
76
- if (detected.zshCompletion) status.zshCompletion = paths.zshCompletion;
77
- if (detected.fishCompletion) status.fishCompletion = paths.fishCompletion;
78
- if (detected.cursorSkill) status.cursorSkill = `${paths.cursorSkillDir}/`;
79
- if (detected.claudeSkill) status.claudeSkill = `${paths.claudeSkillDir}/`;
80
- if (detected.cursorMcp) status.cursorMcp = `${paths.cursorMcpPath} (server "${paths.mcpName}")`;
81
- if (detected.claudeMcp) status.claudeMcp = `${paths.claudeMcpPath} (server "${paths.mcpName}")`;
82
- if (detected.claudeDesktopMcp) {
83
- status.claudeDesktopMcp = `${paths.claudeDesktopMcpPath} (server "${paths.mcpName}")`;
84
- }
85
- if (detected.opencodeMcp) {
86
- const openCodePath =
87
- detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) ?? paths.opencodeMcpPath;
88
- status.opencodeMcp = `${openCodePath} (server "${paths.mcpName}")`;
89
- }
90
- if (detected.codexMcp) {
91
- status.codexMcp = `${paths.codexConfigPath} (server "${paths.mcpName}")`;
92
- }
93
- if (detected.chatGptMcp) {
94
- status.chatGptMcp = `${paths.chatGptMcpPath} (server "${paths.mcpName}")`;
95
- }
96
- return status;
97
- }
1
+ export { buildInstallStatus, detectInstalledArtifacts } from "./target-detect.ts";
2
+ export type { InstalledArtifacts, InstallStatus } from "./target-types.ts";
@@ -1,22 +1,34 @@
1
1
  import { readSync } from "node:fs";
2
2
  import { resolveCapabilities } from "../capabilities.ts";
3
3
  import { displayAppConfigPath, runInstallConfigure } from "../config/bootstrap.ts";
4
- import { cliSkillInstall } from "../skill/install.ts";
4
+ import { cliSkillInstall, skillTargetFromActionKind } from "../skill/install.ts";
5
5
  import type { CliProgram } from "../types.ts";
6
- import { checkCodexMcpConflict } from "./mcp-codex.ts";
7
- import { checkMcpConflict, expectedMcpEntry } from "./mcp-config.ts";
8
- import { checkOpenCodeMcpConflict, expectedOpenCodeMcpEntry } from "./mcp-opencode.ts";
9
- import { resolveInstallPaths, userHome } from "./paths.ts";
6
+ import { normalizeInstallRawOpts } from "./normalize.ts";
7
+ import { resolveInstallPaths } from "./paths.ts";
10
8
  import { buildInstallPlan, buildUpdatePlan, type InstallAction, type InstallOpts } from "./plan.ts";
11
- import { installErr, installInfo, installOut, printInstallStatus } from "./status.ts";
12
- import { buildUninstallPlan, type UninstallAction, uninstallSkillDir } from "./uninstall.ts";
9
+ import {
10
+ installErr,
11
+ installInfo,
12
+ installOut,
13
+ printInstallStatus,
14
+ writeInteractiveInstallIntro,
15
+ } from "./status.ts";
16
+ import { resolveEffectiveInstallTargets } from "./target-effective.ts";
17
+ import { runTargetPreflight } from "./target-plan-build.ts";
18
+ import type { InstallActionKind } from "./target-types.ts";
19
+ import {
20
+ buildUninstallPlan,
21
+ skillDirFromUninstallSummary,
22
+ type UninstallAction,
23
+ uninstallSkillDir,
24
+ } from "./uninstall.ts";
13
25
  import { cliUpdate } from "./update.ts";
14
26
 
15
27
  export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
16
28
  const flag = (name: string) => raw[name] === "1";
17
29
  return {
18
30
  all: flag("all"),
19
- bin: flag("bin"),
31
+ app: flag("app"),
20
32
  completions: flag("completions"),
21
33
  skill: flag("skill"),
22
34
  mcp: flag("mcp"),
@@ -26,44 +38,33 @@ export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
26
38
  status: flag("status"),
27
39
  uninstall: flag("uninstall"),
28
40
  configure: flag("configure"),
29
- config: flag("config"),
30
41
  yes: flag("yes"),
31
42
  dry: flag("dry"),
32
43
  json: flag("json"),
33
- quiet: flag("quiet"),
34
- prefix: raw.prefix,
35
44
  };
36
45
  }
37
46
 
38
47
  export function validateInstallOpts(opts: InstallOpts): string | null {
39
- if (opts.configure) {
40
- if (
41
- opts.all ||
42
- opts.bin ||
43
- opts.completions ||
44
- opts.skill ||
45
- opts.mcp ||
46
- opts.config ||
47
- opts.reinstall ||
48
- opts.update ||
49
- opts.uninstall ||
50
- opts.status
51
- ) {
52
- return "--configure cannot be combined with other install flags.";
53
- }
48
+ const configureOnlyInstall =
49
+ opts.configure &&
50
+ !opts.uninstall &&
51
+ !opts.all &&
52
+ !opts.app &&
53
+ !opts.completions &&
54
+ !opts.skill &&
55
+ !opts.mcp &&
56
+ !opts.reinstall &&
57
+ !opts.update &&
58
+ !opts.status;
59
+
60
+ if (configureOnlyInstall) {
54
61
  return null;
55
62
  }
56
- if (opts.quiet && opts.dry) {
57
- return "--quiet cannot be combined with --dry.";
58
- }
59
- if (opts.quiet && !opts.yes && !opts.json && !opts.reinstall && !opts.update) {
60
- return "--quiet requires --yes (or --json / --reinstall / --update).";
61
- }
63
+
62
64
  if (opts.json) {
63
65
  opts.yes = true;
64
66
  }
65
67
  if (opts.reinstall) {
66
- opts.bin = true;
67
68
  opts.yes = true;
68
69
  }
69
70
  if (opts.update) {
@@ -72,13 +73,14 @@ export function validateInstallOpts(opts: InstallOpts): string | null {
72
73
 
73
74
  const mutationFlags =
74
75
  opts.all ||
75
- opts.bin ||
76
+ opts.app ||
76
77
  opts.completions ||
77
78
  opts.skill ||
78
79
  opts.mcp ||
79
80
  opts.reinstall ||
80
81
  opts.update ||
81
- opts.uninstall;
82
+ opts.uninstall ||
83
+ opts.configure;
82
84
  if (opts.status && mutationFlags) {
83
85
  return "--status is mutually exclusive with install/reinstall/uninstall targets.";
84
86
  }
@@ -90,20 +92,22 @@ export function validateInstallOpts(opts: InstallOpts): string | null {
90
92
  opts.mcp ||
91
93
  opts.uninstall ||
92
94
  opts.status ||
93
- opts.update)
95
+ opts.update ||
96
+ opts.configure)
94
97
  ) {
95
98
  return "--reinstall cannot be combined with other target flags.";
96
99
  }
97
100
  if (
98
101
  opts.update &&
99
102
  (opts.all ||
100
- opts.bin ||
103
+ opts.app ||
101
104
  opts.completions ||
102
105
  opts.skill ||
103
106
  opts.mcp ||
104
107
  opts.uninstall ||
105
108
  opts.status ||
106
- opts.reinstall)
109
+ opts.reinstall ||
110
+ opts.configure)
107
111
  ) {
108
112
  return "--update cannot be combined with other target flags.";
109
113
  }
@@ -112,35 +116,81 @@ export function validateInstallOpts(opts: InstallOpts): string | null {
112
116
  }
113
117
  if (opts.uninstall) {
114
118
  const hasUninstallTarget =
115
- opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.config;
119
+ opts.all || opts.app || opts.completions || opts.skill || opts.mcp || opts.configure;
116
120
  if (!hasUninstallTarget) {
117
- return "Specify at least one target: --all, --bin, --completions, --skill, --mcp, or --config.";
121
+ return "Specify at least one target: --all, --app, --completions, --skill, --mcp, or --configure.";
118
122
  }
119
123
  }
120
124
  if (!opts.status && !opts.reinstall && !opts.update && !opts.uninstall) {
121
125
  const hasTarget =
122
- opts.all || opts.bin || opts.completions || opts.skill || opts.mcp || opts.config;
126
+ opts.all || opts.app || opts.completions || opts.skill || opts.mcp || opts.configure;
123
127
  if (!hasTarget) {
124
- return "Specify at least one target: --all, --bin, --completions, --skill, --mcp, or --config.";
128
+ return "Specify at least one target: --all, --app, --completions, --skill, --mcp, or --configure.";
125
129
  }
126
130
  }
127
131
  return null;
128
132
  }
129
133
 
130
- function promptConfirm(): boolean {
131
- process.stderr.write("Continue? [y/N] ");
134
+ function parseSelectionIndices(input: string, max: number): number[] | null {
135
+ const trimmed = input.trim();
136
+ if (!trimmed) return null;
137
+ const parts = trimmed.split(/[\s,]+/).filter(Boolean);
138
+ const indices: number[] = [];
139
+ for (const part of parts) {
140
+ const n = Number(part);
141
+ if (!Number.isInteger(n) || n < 1 || n > max) {
142
+ return null;
143
+ }
144
+ if (!indices.includes(n)) {
145
+ indices.push(n);
146
+ }
147
+ }
148
+ return indices.sort((a, b) => a - b);
149
+ }
150
+
151
+ /** Interactive install: item 1 is the app when the plan leads with `app`. */
152
+ export function interactiveSelectionAssumesApp(
153
+ actions: Array<InstallAction | UninstallAction>,
154
+ uninstall: boolean,
155
+ ): boolean {
156
+ if (uninstall) return false;
157
+ const first = actions[0];
158
+ return first !== undefined && "kind" in first && first.kind === "app";
159
+ }
160
+
161
+ export function mergeInteractiveSelection(
162
+ indices: number[],
163
+ count: number,
164
+ alwaysIncludeFirst: boolean,
165
+ ): number[] {
166
+ if (!alwaysIncludeFirst || count === 0 || indices.includes(1)) {
167
+ return indices;
168
+ }
169
+ return [1, ...indices].sort((a, b) => a - b);
170
+ }
171
+
172
+ function promptSelectiveConfirm(count: number, alwaysIncludeFirst: boolean): number[] | null {
173
+ const hint = alwaysIncludeFirst
174
+ ? "Proceed [y/N], or numbers to include (e.g. 2,3): "
175
+ : "Proceed [y/N], or numbers to include (e.g. 1,3): ";
176
+ process.stderr.write(hint);
132
177
  const buf = Buffer.alloc(256);
133
178
  const n = readSync(0, buf, { length: 256 });
134
179
  const ans = buf.toString("utf8", 0, n).trim();
135
- return ans === "y" || ans === "Y";
180
+ if (ans === "y" || ans === "Y") {
181
+ return Array.from({ length: count }, (_, i) => i + 1);
182
+ }
183
+ if (ans === "n" || ans === "N" || ans === "") {
184
+ return null;
185
+ }
186
+ const parsed = parseSelectionIndices(ans, count);
187
+ if (!parsed) return null;
188
+ return mergeInteractiveSelection(parsed, count, alwaysIncludeFirst);
136
189
  }
137
190
 
138
- function runSkillAction(
139
- root: CliProgram,
140
- kind: "cursor-skill" | "claude-skill",
141
- opts: InstallOpts,
142
- ): string[] {
143
- const target = kind === "cursor-skill" ? "cursor" : "claude";
191
+ function runSkillAction(root: CliProgram, kind: InstallActionKind, opts: InstallOpts): string[] {
192
+ const target = skillTargetFromActionKind(kind);
193
+ if (!target) return [];
144
194
  return cliSkillInstall(root, target, {
145
195
  global: true,
146
196
  rimraf: true,
@@ -152,29 +202,44 @@ function executePlan(
152
202
  root: CliProgram,
153
203
  actions: Array<InstallAction | UninstallAction>,
154
204
  opts: InstallOpts,
205
+ showProgress: boolean,
155
206
  ): string[] {
156
207
  const changed: string[] = [];
208
+ const paths = resolveInstallPaths(root);
157
209
  for (const action of actions) {
158
- installInfo(action.message, opts);
159
- if ("kind" in action && (action.kind === "cursor-skill" || action.kind === "claude-skill")) {
160
- changed.push(...runSkillAction(root, action.kind, opts));
161
- continue;
210
+ if (showProgress) {
211
+ installInfo(action.message, opts);
162
212
  }
163
- if (!("kind" in action) && action.summary.startsWith("cursor skill")) {
164
- const paths = resolveInstallPaths(root, opts);
165
- changed.push(...uninstallSkillDir(paths.cursorSkillDir, !!opts.dry));
166
- continue;
213
+ if ("kind" in action && action.kind) {
214
+ const skillTarget = skillTargetFromActionKind(action.kind);
215
+ if (skillTarget) {
216
+ changed.push(...runSkillAction(root, action.kind as InstallActionKind, opts));
217
+ continue;
218
+ }
167
219
  }
168
- if (!("kind" in action) && action.summary.startsWith("claude skill")) {
169
- const paths = resolveInstallPaths(root, opts);
170
- changed.push(...uninstallSkillDir(paths.claudeSkillDir, !!opts.dry));
171
- continue;
220
+ if (!("kind" in action) || !action.kind) {
221
+ const skillDir = skillDirFromUninstallSummary(action.summary, paths);
222
+ if (skillDir) {
223
+ changed.push(...uninstallSkillDir(skillDir, !!opts.dry));
224
+ continue;
225
+ }
172
226
  }
173
227
  changed.push(...action.run());
174
228
  }
175
229
  return changed;
176
230
  }
177
231
 
232
+ function shouldRunConfigureWizardAfterInstall(root: CliProgram, opts: InstallOpts): boolean {
233
+ if (opts.uninstall || opts.reinstall || opts.update || opts.status || !root.appConfig) {
234
+ return false;
235
+ }
236
+ if (!opts.all && !opts.app && !opts.completions && !opts.skill && !opts.mcp) {
237
+ return false;
238
+ }
239
+ const effective = resolveEffectiveInstallTargets(root.install, root);
240
+ return effective.configure.includedInAll && effective.configure.enabled;
241
+ }
242
+
178
243
  /** Runs install/reinstall/uninstall mutations without exiting the process. */
179
244
  export async function runInstallMutation(
180
245
  root: CliProgram,
@@ -184,49 +249,20 @@ export async function runInstallMutation(
184
249
  opts: InstallOpts;
185
250
  paths: ReturnType<typeof resolveInstallPaths>;
186
251
  }> {
187
- const opts = parseInstallOpts(rawOpts);
252
+ const normalized = normalizeInstallRawOpts(rawOpts);
253
+ const opts = parseInstallOpts(normalized);
188
254
  const err = validateInstallOpts(opts);
189
255
  if (err) {
190
256
  throw new Error(err);
191
257
  }
192
258
 
193
- const paths = resolveInstallPaths(root, opts);
259
+ const paths = resolveInstallPaths(root);
194
260
 
195
261
  if (opts.status) {
196
262
  printInstallStatus(root, opts);
197
263
  return { changed: [], opts, paths };
198
264
  }
199
265
 
200
- if (!opts.uninstall && resolveCapabilities(root).mcp && (opts.all || opts.mcp)) {
201
- const entry = expectedMcpEntry(root);
202
- const openCodeEntry = expectedOpenCodeMcpEntry(root);
203
- const yes = !!opts.yes;
204
- for (const p of [
205
- paths.cursorMcpPath,
206
- paths.claudeMcpPath,
207
- paths.claudeDesktopMcpPath,
208
- paths.chatGptMcpPath,
209
- ]) {
210
- const conflict = checkMcpConflict(p, paths.mcpName, entry, yes);
211
- if (conflict) {
212
- throw new Error(conflict);
213
- }
214
- }
215
- const openCodeConflict = checkOpenCodeMcpConflict(
216
- paths.opencodeMcpPath,
217
- paths.mcpName,
218
- openCodeEntry,
219
- yes,
220
- );
221
- if (openCodeConflict) {
222
- throw new Error(openCodeConflict);
223
- }
224
- const codexConflict = checkCodexMcpConflict(userHome(), paths.mcpName, entry, yes);
225
- if (codexConflict) {
226
- throw new Error(codexConflict);
227
- }
228
- }
229
-
230
266
  let actions: Array<InstallAction | UninstallAction>;
231
267
  if (opts.uninstall) {
232
268
  actions = buildUninstallPlan(root, paths, opts);
@@ -236,31 +272,48 @@ export async function runInstallMutation(
236
272
  actions = buildInstallPlan(root, paths, opts);
237
273
  }
238
274
 
275
+ const installActions = actions.filter(
276
+ (a): a is InstallAction => "kind" in a && typeof a.kind === "string" && a.kind.includes("-mcp"),
277
+ );
278
+ if (!opts.uninstall && resolveCapabilities(root).mcp && installActions.length > 0) {
279
+ runTargetPreflight(root, paths, opts, installActions);
280
+ }
281
+
282
+ let selectedActions = actions;
283
+ const autoYes = !!(opts.yes || opts.json || opts.reinstall || opts.update || opts.dry);
284
+
285
+ if (!autoYes && !opts.json && process.stdin.isTTY) {
286
+ writeInteractiveInstallIntro(root);
287
+ }
288
+
239
289
  if (actions.length === 0) {
240
290
  return { changed: [], opts, paths };
241
291
  }
242
292
 
243
- if (!opts.quiet && !opts.json) {
293
+ if (!autoYes) {
244
294
  installOut(
245
295
  `About to ${opts.uninstall ? "remove" : opts.reinstall ? "reinstall" : "install"}:`,
246
296
  opts,
247
297
  );
248
- for (const a of actions) {
249
- installOut(` - ${a.summary}`, opts);
298
+ for (let i = 0; i < actions.length; i++) {
299
+ installOut(` ${i + 1}. ${actions[i]?.summary}`, opts);
250
300
  }
251
301
  }
252
302
 
253
- const autoYes = opts.yes || opts.json || opts.reinstall;
254
303
  if (!autoYes) {
255
304
  if (!process.stdin.isTTY) {
256
305
  throw new Error("Refusing to proceed without --yes (stdin is not a TTY).");
257
306
  }
258
- if (!promptConfirm()) {
307
+ const assumeApp = interactiveSelectionAssumesApp(actions, !!opts.uninstall);
308
+ const selection = promptSelectiveConfirm(actions.length, assumeApp);
309
+ if (!selection) {
259
310
  throw new Error("Aborted.");
260
311
  }
312
+ process.stderr.write("Done.\n");
313
+ selectedActions = selection.map((i) => actions[i - 1]).filter(Boolean) as typeof actions;
261
314
  }
262
315
 
263
- const changed = executePlan(root, actions, opts);
316
+ const changed = executePlan(root, selectedActions, opts, autoYes);
264
317
  return { changed, opts, paths };
265
318
  }
266
319
 
@@ -269,14 +322,27 @@ export async function cliInstall(
269
322
  root: CliProgram,
270
323
  rawOpts: Record<string, string>,
271
324
  ): Promise<never> {
272
- const opts = parseInstallOpts(rawOpts);
325
+ const normalized = normalizeInstallRawOpts(rawOpts);
326
+ const opts = parseInstallOpts(normalized);
273
327
 
274
- if (opts.configure) {
328
+ const configureOnly =
329
+ opts.configure &&
330
+ !opts.uninstall &&
331
+ !opts.all &&
332
+ !opts.app &&
333
+ !opts.completions &&
334
+ !opts.skill &&
335
+ !opts.mcp;
336
+
337
+ if (configureOnly) {
275
338
  if (!root.appConfig) {
276
- installErr("install --configure requires program.appConfig on the program root.");
339
+ installErr("This app does not support --configure.");
277
340
  process.exit(1);
278
341
  }
279
- const result = runInstallConfigure(root);
342
+ if (process.stdin.isTTY) {
343
+ writeInteractiveInstallIntro(root);
344
+ }
345
+ const result = runInstallConfigure(root, { context: "standalone" });
280
346
  if (result.changed) {
281
347
  installOut(`Wrote config: ${displayAppConfigPath(root)}`, opts);
282
348
  } else {
@@ -293,7 +359,7 @@ export async function cliInstall(
293
359
 
294
360
  if (opts.update) {
295
361
  if (!resolveCapabilities(root).update) {
296
- installErr("install --update requires install.updateGetLatest on the program root.");
362
+ installErr("Remote updates are not supported by this app.");
297
363
  process.exit(1);
298
364
  }
299
365
  await cliUpdate(root);
@@ -301,7 +367,7 @@ export async function cliInstall(
301
367
 
302
368
  let result: Awaited<ReturnType<typeof runInstallMutation>>;
303
369
  try {
304
- result = await runInstallMutation(root, rawOpts);
370
+ result = await runInstallMutation(root, normalized);
305
371
  } catch (err) {
306
372
  installErr(err instanceof Error ? err.message : String(err));
307
373
  process.exit(1);
@@ -313,12 +379,23 @@ export async function cliInstall(
313
379
  process.exit(0);
314
380
  }
315
381
 
382
+ if (
383
+ shouldRunConfigureWizardAfterInstall(root, mutationOpts) &&
384
+ !mutationOpts.uninstall &&
385
+ !mutationOpts.dry
386
+ ) {
387
+ const configResult = runInstallConfigure(root, { context: "after-install" });
388
+ if (configResult.changed) {
389
+ installOut(`Wrote config: ${displayAppConfigPath(root)}`, mutationOpts);
390
+ }
391
+ }
392
+
316
393
  if (mutationOpts.json) {
317
394
  process.stdout.write(`${JSON.stringify(changed, null, 2)}\n`);
318
395
  process.exit(0);
319
396
  }
320
397
 
321
- if (!mutationOpts.quiet && changed.length > 0) {
398
+ if (changed.length > 0) {
322
399
  const verb = mutationOpts.uninstall
323
400
  ? "Removed"
324
401
  : mutationOpts.reinstall
@@ -327,8 +404,8 @@ export async function cliInstall(
327
404
  installOut(`${verb} ${changed.length} file(s).`, mutationOpts);
328
405
  if (
329
406
  !mutationOpts.uninstall &&
330
- (mutationOpts.all || mutationOpts.bin) &&
331
- changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.binaryPath)
407
+ (mutationOpts.all || mutationOpts.app) &&
408
+ changed.some((p) => p === paths.bashRc || p === paths.zshRc || p === paths.appPath)
332
409
  ) {
333
410
  installOut("Open a new shell, or run: hash -r (bash) / rehash (zsh)", mutationOpts);
334
411
  }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { CliProgram } from "../types.ts";
3
+ import { CliSchemaValidationError } from "../types.ts";
4
+ import { cliValidateProgram } from "../validate.ts";
5
+
6
+ const base: CliProgram = {
7
+ key: "app",
8
+ version: "1.0.0",
9
+ description: "Test",
10
+ handler: () => {},
11
+ };
12
+
13
+ describe("validateInstallConfig", () => {
14
+ test("accepts empty install config", () => {
15
+ expect(() => cliValidateProgram(base)).not.toThrow();
16
+ });
17
+
18
+ test("rejects removed allSkills shorthand", () => {
19
+ const program = {
20
+ ...base,
21
+ install: { targets: { allSkills: true } },
22
+ } as CliProgram;
23
+ expect(() => cliValidateProgram(program)).toThrow(CliSchemaValidationError);
24
+ expect(() => cliValidateProgram(program)).toThrow(/allSkills/);
25
+ });
26
+
27
+ test("rejects both sides of pair without both integration", () => {
28
+ const program: CliProgram = {
29
+ ...base,
30
+ mcpServer: { enabled: true },
31
+ install: {
32
+ agentIntegration: "mcp",
33
+ targets: { cursorMcp: true, cursorSkill: true },
34
+ },
35
+ };
36
+ expect(() => cliValidateProgram(program)).toThrow(/both/);
37
+ });
38
+
39
+ test("rejects explicit MCP target in skill integration mode", () => {
40
+ const program: CliProgram = {
41
+ ...base,
42
+ install: {
43
+ agentIntegration: "skill",
44
+ targets: { cursorMcp: true },
45
+ },
46
+ };
47
+ expect(() => cliValidateProgram(program)).toThrow(/cursorMcp/);
48
+ });
49
+
50
+ test("allows explicit pair targets with both integration", () => {
51
+ const program: CliProgram = {
52
+ ...base,
53
+ mcpServer: { enabled: true },
54
+ install: {
55
+ agentIntegration: "both",
56
+ targets: { cursorMcp: true, cursorSkill: true },
57
+ },
58
+ };
59
+ expect(() => cliValidateProgram(program)).not.toThrow();
60
+ });
61
+ });