argsbarg 4.1.1 → 5.0.1

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 (62) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +5 -5
  3. package/docs/README.md +3 -3
  4. package/docs/ai-skills.md +9 -9
  5. package/docs/bundled-docs.md +4 -4
  6. package/docs/cli-program.md +8 -8
  7. package/docs/config-schema.md +2 -2
  8. package/docs/configure.md +177 -0
  9. package/docs/developing.md +1 -1
  10. package/docs/distribution-homebrew.md +10 -9
  11. package/docs/mcp.md +9 -9
  12. package/examples/full-example/README.md +3 -3
  13. package/examples/full-example/justfile +6 -6
  14. package/examples/full-example/scripts/formula-shared.ts +2 -2
  15. package/examples/full-example/src/program.ts +3 -3
  16. package/examples/nested.ts +1 -1
  17. package/index.d.ts +23 -16
  18. package/package.json +1 -1
  19. package/src/builtins/builtins.test.ts +82 -61
  20. package/src/builtins/completion-group.ts +4 -6
  21. package/src/builtins/configure-copy.ts +86 -0
  22. package/src/builtins/configure.ts +70 -0
  23. package/src/builtins/dispatch.ts +13 -33
  24. package/src/builtins/index.ts +1 -1
  25. package/src/builtins/mcp.ts +2 -2
  26. package/src/builtins/registry.ts +6 -6
  27. package/src/capabilities.ts +22 -13
  28. package/src/cli-tool/cli-smoke.test.ts +13 -3
  29. package/src/cli-tool/create.test.ts +23 -1
  30. package/src/cli-tool/create.ts +26 -4
  31. package/src/cli-tool/full-example-capabilities.test.ts +2 -2
  32. package/src/cli-tool/program.ts +20 -5
  33. package/src/cli-tool/run-create.ts +8 -19
  34. package/src/config/bootstrap.ts +11 -9
  35. package/src/config/file.test.ts +1 -1
  36. package/src/config/resolve.ts +2 -2
  37. package/src/configure/configure.test.ts +148 -0
  38. package/src/configure/index.ts +284 -0
  39. package/src/configure/prompt.ts +40 -0
  40. package/src/docs/builtin.ts +3 -5
  41. package/src/docs/docs.test.ts +5 -5
  42. package/src/docs/mcp-guide.ts +4 -4
  43. package/src/index.ts +2 -2
  44. package/src/install/install-validate.test.ts +5 -5
  45. package/src/install/opts.ts +17 -0
  46. package/src/install/target-effective.ts +8 -8
  47. package/src/install/target-scope.ts +11 -8
  48. package/src/install/targets/configure.ts +1 -1
  49. package/src/install/targets.test.ts +4 -4
  50. package/src/invoke.test.ts +1 -1
  51. package/src/mcp/tools.ts +1 -1
  52. package/src/mcp.integration.test.ts +4 -4
  53. package/src/parse.test.ts +11 -13
  54. package/src/schema.ts +1 -9
  55. package/src/skill/hint.ts +2 -2
  56. package/src/types.ts +22 -14
  57. package/src/validate.ts +17 -17
  58. package/docs/install.md +0 -206
  59. package/src/builtins/install.ts +0 -106
  60. package/src/builtins/uninstall.ts +0 -80
  61. package/src/install/index.ts +0 -409
  62. package/src/install/install.test.ts +0 -317
@@ -3,9 +3,8 @@ import { cliBuiltinDocsGroupIfEnabled } from "../docs/builtin.ts";
3
3
  import type { CliNode, CliProgram } from "../types.ts";
4
4
  import { cliBuiltinCompletionGroup } from "./completion-group.ts";
5
5
  import { cliBuiltinConfigGroupIfEnabled } from "./config.ts";
6
- import { cliBuiltinInstallCommand } from "./install.ts";
6
+ import { cliBuiltinConfigureCommand } from "./configure.ts";
7
7
  import { cliBuiltinMcpCommand } from "./mcp.ts";
8
- import { cliBuiltinUninstallCommand } from "./uninstall.ts";
9
8
  import { cliBuiltinVersionCommand } from "./version.ts";
10
9
 
11
10
  type BuiltinFactory = (program: CliProgram) => CliNode | null;
@@ -27,11 +26,12 @@ function pushBuiltin(
27
26
  /** Capability-gated built-in command nodes in stable order (parse, help, export). */
28
27
  export function resolveBuiltins(program: CliProgram, caps: CliCapabilities): CliNode[] {
29
28
  const builtins: CliNode[] = [];
30
- pushBuiltin(builtins, program, (p) => cliBuiltinCompletionGroup(p));
29
+ if (caps.completion) {
30
+ pushBuiltin(builtins, program, (p) => cliBuiltinCompletionGroup(p));
31
+ }
31
32
  pushBuiltin(builtins, program, () => cliBuiltinVersionCommand());
32
- if (caps.install) {
33
- pushBuiltin(builtins, program, (p) => cliBuiltinInstallCommand(p));
34
- pushBuiltin(builtins, program, (p) => cliBuiltinUninstallCommand(p));
33
+ if (caps.configure) {
34
+ pushBuiltin(builtins, program, (p) => cliBuiltinConfigureCommand(p));
35
35
  }
36
36
  pushBuiltin(builtins, program, (p) => cliBuiltinDocsGroupIfEnabled(p) ?? null);
37
37
  if (caps.mcp) {
@@ -8,20 +8,20 @@ import type { CliProgram } from "./types.ts";
8
8
 
9
9
  /** Platform builtins derived from program config and runtime. */
10
10
  export interface CliCapabilities {
11
- completion: true;
11
+ completion: boolean;
12
12
  mcp: boolean;
13
- install: boolean;
13
+ configure: boolean;
14
14
  docs: boolean;
15
15
  configCommands: boolean;
16
16
  }
17
17
 
18
18
  /** Resolves which capabilities are enabled for a program. */
19
19
  export function resolveCapabilities(program: CliProgram): CliCapabilities {
20
- const install = program.install?.enabled !== false;
20
+ const configure = program.configure?.enabled !== false;
21
21
  return {
22
- completion: true,
22
+ completion: program.completion?.enabled !== false,
23
23
  mcp: program.mcpServer?.enabled === true,
24
- install,
24
+ configure,
25
25
  docs: program.docs?.enabled === true,
26
26
  configCommands: configCommandsEnabled(program),
27
27
  };
@@ -29,9 +29,12 @@ export function resolveCapabilities(program: CliProgram): CliCapabilities {
29
29
 
30
30
  /** Reserved top-level command names for the given capabilities. */
31
31
  export function reservedCommandNames(caps: CliCapabilities): string[] {
32
- const names = ["completion", "version"];
33
- if (caps.install) {
34
- names.push("install", "uninstall");
32
+ const names = ["version"];
33
+ if (caps.completion) {
34
+ names.unshift("completion");
35
+ }
36
+ if (caps.configure) {
37
+ names.push("configure");
35
38
  }
36
39
  if (caps.docs) {
37
40
  names.push("docs");
@@ -57,15 +60,17 @@ export function skipsRequiredAppConfigExit(path: string[], caps: CliCapabilities
57
60
  return false;
58
61
  }
59
62
 
60
- export type CapabilityFeature = "mcp" | "install" | "docs" | "config";
63
+ export type CapabilityFeature = "mcp" | "configure" | "docs" | "config" | "completion";
61
64
 
62
65
  /** Stderr message when a disabled built-in is invoked from the CLI. */
63
66
  export function capabilityDeniedMessage(feature: CapabilityFeature): string {
64
67
  switch (feature) {
68
+ case "completion":
69
+ return "Shell completion is not available for this app.\n";
65
70
  case "mcp":
66
71
  return "MCP is not available for this app.\n";
67
- case "install":
68
- return "Install is not available for this app.\n";
72
+ case "configure":
73
+ return "Configure is not available for this app.\n";
69
74
  case "docs":
70
75
  return "Documentation commands are not available for this app.\n";
71
76
  case "config":
@@ -79,12 +84,16 @@ export function assertBuiltinAllowed(argv: string[], caps: CliCapabilities): voi
79
84
  return;
80
85
  }
81
86
  const first = argv[0];
87
+ if (first === "completion" && !caps.completion) {
88
+ process.stderr.write(capabilityDeniedMessage("completion"));
89
+ process.exit(1);
90
+ }
82
91
  if (first === "mcp" && !caps.mcp) {
83
92
  process.stderr.write(capabilityDeniedMessage("mcp"));
84
93
  process.exit(1);
85
94
  }
86
- if (first === "install" && !caps.install) {
87
- process.stderr.write(capabilityDeniedMessage("install"));
95
+ if (first === "configure" && !caps.configure) {
96
+ process.stderr.write(capabilityDeniedMessage("configure"));
88
97
  process.exit(1);
89
98
  }
90
99
  if (first === "docs" && !caps.docs) {
@@ -11,9 +11,19 @@ describe("argsbarg cli-tool", () => {
11
11
  expect(proc.stdout.trim().length).toBeGreaterThan(0);
12
12
  });
13
13
 
14
- test("completion subcommand emits bash script", () => {
15
- const proc = spawnSync("bun", [main, "completion", "bash"], { encoding: "utf8" });
14
+ test("help lists create and version only (no install, completion, mcp)", () => {
15
+ const proc = spawnSync("bun", [main, "--help"], { encoding: "utf8" });
16
16
  expect(proc.status).toBe(0);
17
- expect(proc.stdout).toContain("complete");
17
+ expect(proc.stdout).toContain("create");
18
+ expect(proc.stdout).toContain("version");
19
+ expect(proc.stdout).not.toContain("configure");
20
+ expect(proc.stdout).not.toContain("completion");
21
+ expect(proc.stdout).not.toContain("mcp");
22
+ });
23
+
24
+ test("completion subcommand is disabled", () => {
25
+ const proc = spawnSync("bun", [main, "completion", "bash"], { encoding: "utf8" });
26
+ expect(proc.status).toBe(1);
27
+ expect(proc.stderr).toContain("Shell completion is not available");
18
28
  });
19
29
  });
@@ -8,6 +8,7 @@ import {
8
8
  diffCreate,
9
9
  diffCreateDetails,
10
10
  renderCreateTree,
11
+ resolveCreateOptions,
11
12
  substituteTemplateContent,
12
13
  } from "./create.ts";
13
14
 
@@ -39,6 +40,27 @@ describe("argsbarg create", () => {
39
40
 
40
41
  test("classNameFromKey", () => {
41
42
  expect(classNameFromKey("sqsp-i18n")).toBe("SqspI18n");
43
+ expect(classNameFromKey("at1")).toBe("At1");
44
+ expect(classNameFromKey("1password")).toBe("App1password");
45
+ });
46
+
47
+ test("resolveCreateOptions derives identity defaults from key", () => {
48
+ expect(resolveCreateOptions({ key: "1password", releaseRepo: "org/1password" }).className).toBe(
49
+ "App1password",
50
+ );
51
+ expect(
52
+ resolveCreateOptions({ key: "my-cli", className: "Custom", releaseRepo: "org/my-cli" })
53
+ .className,
54
+ ).toBe("Custom");
55
+ const opts = resolveCreateOptions({ key: "at1", releaseRepo: "bdombro/at1" });
56
+ expect(opts.tap).toBe("bdombro/at1");
57
+ expect(opts.releaseRepo).toBe("bdombro/at1");
58
+ expect(opts.homepage).toBe("https://github.com/bdombro/at1");
59
+ expect(opts.desc).toBe("At1 CLI");
60
+ });
61
+
62
+ test("resolveCreateOptions requires release repo", () => {
63
+ expect(() => resolveCreateOptions({ key: "at1" })).toThrow(/release repo/i);
42
64
  });
43
65
 
44
66
  test("renderCreateTree includes justfile and create-identity", () => {
@@ -113,7 +135,7 @@ describe("argsbarg create", () => {
113
135
  });
114
136
 
115
137
  test("--diff captures drift details", () => {
116
- const drifts = diffCreateDetails("/nonexistent", { key: "x" });
138
+ const drifts = diffCreateDetails("/nonexistent", { key: "x", releaseRepo: "org/x" });
117
139
  expect(drifts.length).toBeGreaterThan(0);
118
140
  });
119
141
  });
@@ -44,11 +44,20 @@ export function keyToEnvPrefix(key: string): string {
44
44
  .toUpperCase();
45
45
  }
46
46
 
47
+ /** PascalCase Homebrew formula class from CLI key; prefix `App` when Ruby constant rules require it. */
47
48
  export function classNameFromKey(key: string): string {
48
- return key
49
+ const name = key
49
50
  .split(/[-_]/)
51
+ .filter((s) => s.length > 0)
50
52
  .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
51
53
  .join("");
54
+ if (name.length === 0) {
55
+ throw new Error(`Invalid CLI key: ${key}`);
56
+ }
57
+ if (!/^[A-Z]/.test(name)) {
58
+ return `App${name}`;
59
+ }
60
+ return name;
52
61
  }
53
62
 
54
63
  function tapLibraryParts(tap: string): { org: string; repo: string } {
@@ -131,6 +140,12 @@ export function inferCreateOptions(
131
140
  };
132
141
  }
133
142
 
143
+ function assertReleaseRepoFormat(releaseRepo: string): void {
144
+ if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(releaseRepo)) {
145
+ throw new Error(`Invalid release repo (expected org/repo): ${releaseRepo}`);
146
+ }
147
+ }
148
+
134
149
  export function resolveCreateOptions(
135
150
  partial: Partial<CreateOptions>,
136
151
  baseDir?: string,
@@ -139,13 +154,20 @@ export function resolveCreateOptions(
139
154
  const tmpl = templateIdentity();
140
155
  const key = merged.key ?? tmpl.key ?? "full-example";
141
156
  const className = merged.className ?? classNameFromKey(key);
157
+ const releaseRepo = merged.releaseRepo;
158
+ if (!releaseRepo) {
159
+ throw new Error(
160
+ "GitHub release repo (org/repo) is required. Pass --release-repo or use the interactive wizard.",
161
+ );
162
+ }
163
+ assertReleaseRepoFormat(releaseRepo);
142
164
  const devTemplate = merged.devTemplate ?? (baseDir ? isDevTemplateDir(baseDir) : false);
143
165
  return {
144
166
  key,
145
167
  className,
146
- tap: merged.tap ?? `local/${key}`,
147
- homepage: merged.homepage ?? `https://github.com/${merged.releaseRepo ?? `example/${key}`}`,
148
- releaseRepo: merged.releaseRepo ?? `example/${key}`,
168
+ tap: merged.tap ?? releaseRepo,
169
+ homepage: merged.homepage ?? `https://github.com/${releaseRepo}`,
170
+ releaseRepo,
149
171
  desc: merged.desc ?? `${className} CLI`,
150
172
  force: merged.force ?? false,
151
173
  dryRun: merged.dryRun ?? false,
@@ -22,7 +22,7 @@ const sinkProgram = {
22
22
  topics: { readme: { text: "# readme\n" } },
23
23
  },
24
24
  mcpServer: { enabled: true },
25
- install: {},
25
+ configure: {},
26
26
  commands: [
27
27
  {
28
28
  key: "status",
@@ -62,7 +62,7 @@ describe("full-example template", () => {
62
62
  expect(resolveCapabilities(sinkProgram)).toEqual({
63
63
  completion: true,
64
64
  mcp: true,
65
- install: true,
65
+ configure: true,
66
66
  docs: true,
67
67
  configCommands: true,
68
68
  });
@@ -10,6 +10,8 @@ export const program = {
10
10
  key: "argsbarg",
11
11
  version: pkg.version,
12
12
  description: "Argsbarg developer tools — bootstrap CLIs from the full-example template.",
13
+ completion: { enabled: false },
14
+ configure: { enabled: false },
13
15
  commands: [
14
16
  {
15
17
  key: "create",
@@ -18,17 +20,30 @@ export const program = {
18
20
  { name: "key", description: "CLI binary name.", kind: CliOptionKind.String },
19
21
  {
20
22
  name: "class-name",
21
- description: "Homebrew formula class name.",
23
+ description: "Homebrew formula Ruby class (default: derived from --key).",
24
+ kind: CliOptionKind.String,
25
+ },
26
+ {
27
+ name: "tap",
28
+ description: "Homebrew tap (default: same as --release-repo).",
29
+ kind: CliOptionKind.String,
30
+ },
31
+ {
32
+ name: "homepage",
33
+ description: "Formula homepage (default: https://github.com/<release-repo>).",
22
34
  kind: CliOptionKind.String,
23
35
  },
24
- { name: "tap", description: "Homebrew tap (org/repo).", kind: CliOptionKind.String },
25
- { name: "homepage", description: "Formula homepage URL.", kind: CliOptionKind.String },
26
36
  {
27
37
  name: "release-repo",
28
- description: "GitHub org/repo for release binary.",
38
+ description: "GitHub org/repo for releases (required in non-interactive mode).",
39
+ kind: CliOptionKind.String,
40
+ },
41
+ {
42
+ name: "desc",
43
+ description:
44
+ "App + formula description (default: <ClassName> CLI; stored in create-identity.ts).",
29
45
  kind: CliOptionKind.String,
30
46
  },
31
- { name: "desc", description: "Formula description.", kind: CliOptionKind.String },
32
47
  { name: "force", description: "Overwrite existing files.", kind: CliOptionKind.Presence },
33
48
  {
34
49
  name: "dry-run",
@@ -5,7 +5,6 @@ import { relative, resolve } from "node:path";
5
5
  import {
6
6
  applyCreate,
7
7
  type CreateOptions,
8
- classNameFromKey,
9
8
  diffCreateDetails,
10
9
  parseCreateArgv,
11
10
  printCreateDiffs,
@@ -27,36 +26,23 @@ function collectInteractiveOptions(
27
26
  const targetDir = promptOptional("Target directory", dir) ?? dir;
28
27
  const baseDir = resolve(process.cwd(), targetDir);
29
28
  const key = promptRequired("CLI key (binary name)", partial.key);
30
- const className =
31
- promptOptional("Formula class name", partial.className ?? classNameFromKey(key)) ??
32
- classNameFromKey(key);
33
- const releaseRepo =
34
- promptOptional("GitHub release repo (org/repo)", partial.releaseRepo ?? `example/${key}`) ??
35
- `example/${key}`;
36
- const homepage =
37
- promptOptional("Homepage URL", partial.homepage ?? `https://github.com/${releaseRepo}`) ??
38
- `https://github.com/${releaseRepo}`;
39
- const tap =
40
- promptOptional("Homebrew tap (org/repo)", partial.tap ?? `local/${key}`) ?? `local/${key}`;
41
- const desc =
42
- promptOptional("Formula description", partial.desc ?? `${className} CLI`) ?? `${className} CLI`;
29
+ const releaseRepo = promptRequired("GitHub release repo (org/repo)", partial.releaseRepo);
43
30
 
44
31
  const opts = resolveCreateOptions(
45
32
  {
46
33
  ...partial,
47
34
  key,
48
- className,
49
- tap,
50
- homepage,
51
35
  releaseRepo,
52
- desc,
53
36
  force: partial.force ?? false,
54
37
  },
55
38
  baseDir,
56
39
  );
57
40
 
58
41
  process.stderr.write(`\nTarget: ${baseDir}\n`);
59
- process.stderr.write(`Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap}\n\n`);
42
+ process.stderr.write(
43
+ `Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap} Release: ${opts.releaseRepo}\n`,
44
+ );
45
+ process.stderr.write(`Homepage: ${opts.homepage}\n\n`);
60
46
  const tree = renderCreateTree(opts);
61
47
  process.stderr.write(`Files (${tree.size}):\n`);
62
48
  for (const rel of [...tree.keys()].sort()) {
@@ -104,6 +90,9 @@ export async function runCreate(input: Partial<CreateOptions> & { dir?: string }
104
90
  if (!partial.key) {
105
91
  throw new Error("--key is required in non-interactive mode.");
106
92
  }
93
+ if (!partial.releaseRepo) {
94
+ throw new Error("--release-repo is required in non-interactive mode.");
95
+ }
107
96
  opts = resolveCreateOptions(partial, baseDir);
108
97
  return runCreateApply(baseDir, opts, partial.dryRun ?? false);
109
98
  } catch (err) {
@@ -176,10 +176,12 @@ function promptConfigKey(
176
176
  return { value: undefined, userTyped: false };
177
177
  }
178
178
 
179
- /** Options for the interactive `install --configure` wizard. */
179
+ /** Options for the interactive configure wizard (app config target). */
180
180
  export interface RunInstallConfigureOpts {
181
181
  /** Where the wizard was started from (standalone command vs right after install). */
182
182
  context?: "standalone" | "after-install";
183
+ /** When false, omit the "Configuration Setup" banner (e.g. inside interactive `configure`). */
184
+ showHeading?: boolean;
183
185
  }
184
186
 
185
187
  /** Print the "Configuration Setup" heading before the configure prompts. */
@@ -225,13 +227,13 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
225
227
  return updates;
226
228
  }
227
229
 
228
- /** Run the full interactive configure wizard (`install --configure`). */
229
- export function runInstallConfigure(
230
+ /** Run the full interactive app config wizard (`configure`). */
231
+ export function runConfigure(
230
232
  program: CliProgram,
231
- _opts: RunInstallConfigureOpts = {},
233
+ opts: RunInstallConfigureOpts = {},
232
234
  ): { path: string; changed: boolean } {
233
235
  if (!program.appConfig) {
234
- throw new Error("install --configure requires program.appConfig on the program root.");
236
+ throw new Error("configure requires program.appConfig on the program root.");
235
237
  }
236
238
  if (!process.stdin.isTTY) {
237
239
  const { resolved } = bootstrapAppConfig(program, { validateFile: false });
@@ -239,7 +241,7 @@ export function runInstallConfigure(
239
241
  if (missing.length > 0) {
240
242
  process.stderr.write(`${formatMissingConfigMessage(program, missing)}\n`);
241
243
  } else {
242
- process.stderr.write("install --configure requires an interactive terminal.\n");
244
+ process.stderr.write("configure requires an interactive terminal.\n");
243
245
  }
244
246
  process.exit(1);
245
247
  }
@@ -253,7 +255,7 @@ export function runInstallConfigure(
253
255
  const next: Record<string, unknown> = { ...existing };
254
256
  let changed = false;
255
257
 
256
- if (shouldShowConfigureSetupHeading(program)) {
258
+ if (opts.showHeading !== false && shouldShowConfigureSetupHeading(program)) {
257
259
  writeConfigureSetupHeading();
258
260
  }
259
261
 
@@ -286,7 +288,7 @@ export function runInstallConfigure(
286
288
  return { path, changed: false };
287
289
  }
288
290
 
289
- /** Summary for `install --status`: config path, whether the file exists, and which required settings are set (never their values). */
291
+ /** Summary for `configure --status`: config path, whether the file exists, and which required settings are set (never their values). */
290
292
  export function appConfigStatus(program: CliProgram):
291
293
  | {
292
294
  path: string;
@@ -343,7 +345,7 @@ export function ensureAppConfig(
343
345
 
344
346
  if (opts.interactive && process.stdin.isTTY) {
345
347
  if (opts.configure) {
346
- runInstallConfigure(program, { context: "standalone" });
348
+ runConfigure(program, { context: "standalone" });
347
349
  fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
348
350
  resolved = resolveAppConfig(program, fileData, hostEnv);
349
351
  exportConfigToEnv(program, resolved, hostEnv);
@@ -85,7 +85,7 @@ describe("config/file", () => {
85
85
  expect(missing).toContain("apiToken");
86
86
  expect(missing).not.toContain("port");
87
87
  const msg = formatMissingConfigMessage(program, missing);
88
- expect(msg).toContain("install --configure");
88
+ expect(msg).toContain("configure");
89
89
  } finally {
90
90
  if (prev !== undefined) process.env.API_TOKEN = prev;
91
91
  }
@@ -226,7 +226,7 @@ export function formatMissingConfigMessage(program: CliProgram, keys: string[]):
226
226
  const path = displayAppConfigPath(program);
227
227
  return [
228
228
  `Missing required configuration: ${list}`,
229
- `Configure interactively: ${program.key} install --configure`,
229
+ `Configure interactively: ${program.key} configure`,
230
230
  `Or set via: ${program.key} config set <key> <value>`,
231
231
  `Config file: ${path}`,
232
232
  `See: ${program.key} docs mcp`,
@@ -239,7 +239,7 @@ export function formatMcpMissingConfigMessage(program: CliProgram, keys: string[
239
239
  const path = displayAppConfigPath(program);
240
240
  return [
241
241
  `Missing required configuration: ${list}`,
242
- `Configure: ${program.key} install --configure`,
242
+ `Configure: ${program.key} configure`,
243
243
  `Or set via: ${program.key} config set`,
244
244
  `Config file: ${path}`,
245
245
  ].join("\n");
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ appConfigHasEntries,
7
+ parseConfigureOpts,
8
+ validateConfigureOpts,
9
+ } from "../configure/index.ts";
10
+ import { detectInstalledArtifacts } from "../install/detect-installed.ts";
11
+ import { parseInstallOpts } from "../install/opts.ts";
12
+ import { resolveClaudeDesktopMcpPath, resolveInstallPaths } from "../install/paths.ts";
13
+ import { buildInstallPlan, buildUpdatePlan } from "../install/plan.ts";
14
+ import { buildUninstallPlan } from "../install/uninstall.ts";
15
+ import type { CliProgram } from "../types.ts";
16
+
17
+ const fixture: CliProgram = {
18
+ key: "testapp",
19
+ version: "0.0.0",
20
+ description: "Test",
21
+ mcpServer: { enabled: true },
22
+ handler: () => {},
23
+ };
24
+
25
+ let home: string;
26
+ let prevHome: string | undefined;
27
+ let prevXdg: string | undefined;
28
+
29
+ beforeEach(() => {
30
+ home = mkdtempSync(join(tmpdir(), "argsbarg-configure-"));
31
+ prevHome = process.env.HOME;
32
+ prevXdg = process.env.XDG_CONFIG_HOME;
33
+ process.env.HOME = home;
34
+ delete process.env.XDG_CONFIG_HOME;
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (prevHome === undefined) delete process.env.HOME;
39
+ else process.env.HOME = prevHome;
40
+ if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
41
+ else process.env.XDG_CONFIG_HOME = prevXdg;
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+
45
+ describe("configure opts", () => {
46
+ test("validate rejects multiple modes", () => {
47
+ const opts = parseConfigureOpts({ sync: "1", status: "1" });
48
+ expect(validateConfigureOpts(opts)).toContain("only one");
49
+ });
50
+
51
+ test("sync requires --yes", () => {
52
+ const opts = parseConfigureOpts({ sync: "1" });
53
+ expect(validateConfigureOpts(opts)).toContain("--yes");
54
+ });
55
+
56
+ test("remove-all requires --yes", () => {
57
+ const opts = parseConfigureOpts({ "remove-all": "1" });
58
+ expect(validateConfigureOpts(opts)).toContain("--yes");
59
+ });
60
+ });
61
+
62
+ describe("install paths", () => {
63
+ test("resolveInstallPaths includes skill and mcp paths", () => {
64
+ const paths = resolveInstallPaths(fixture);
65
+ expect(paths.cursorSkillDir).toContain(".cursor/skills");
66
+ expect(paths.cursorMcpPath).toContain("mcp.json");
67
+ });
68
+
69
+ test("claude desktop mcp path on darwin", () => {
70
+ const prev = process.platform;
71
+ Object.defineProperty(process, "platform", { value: "darwin" });
72
+ try {
73
+ expect(resolveClaudeDesktopMcpPath(home)).toBe(
74
+ join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
75
+ );
76
+ } finally {
77
+ Object.defineProperty(process, "platform", { value: prev });
78
+ }
79
+ });
80
+ });
81
+
82
+ describe("detect installed", () => {
83
+ test("detects cursor mcp when configured", () => {
84
+ const paths = resolveInstallPaths(fixture);
85
+ mkdirSync(join(home, ".cursor"), { recursive: true });
86
+ writeFileSync(
87
+ paths.cursorMcpPath,
88
+ JSON.stringify({ mcpServers: { testapp: { command: "testapp", args: ["mcp"] } } }),
89
+ "utf8",
90
+ );
91
+
92
+ const detected = detectInstalledArtifacts(paths, fixture);
93
+ expect(detected.cursorMcp).toBe(true);
94
+ expect(detected.app).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("sync plan", () => {
99
+ test("buildUpdatePlan greenfield includes agent targets", () => {
100
+ const paths = resolveInstallPaths(fixture);
101
+ const plan = buildUpdatePlan(fixture, paths, parseInstallOpts({ reinstall: "1", yes: "1" }));
102
+ expect(plan.length).toBeGreaterThan(0);
103
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
104
+ });
105
+
106
+ test("buildInstallPlan --all omits app self-install", () => {
107
+ const paths = resolveInstallPaths(fixture);
108
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
109
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
110
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(true);
111
+ });
112
+
113
+ test("buildInstallPlan respects configure.agentIntegration skill", () => {
114
+ const skillApp: CliProgram = {
115
+ ...fixture,
116
+ configure: { agentIntegration: "skill" },
117
+ };
118
+ const paths = resolveInstallPaths(skillApp);
119
+ mkdirSync(join(home, ".claude"), { recursive: true });
120
+ mkdirSync(join(home, ".cursor"), { recursive: true });
121
+ const plan = buildInstallPlan(skillApp, paths, parseInstallOpts({ mcp: "1" }));
122
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe("remove plan", () => {
127
+ test("buildUninstallPlan --all with nothing installed is empty", () => {
128
+ const paths = resolveInstallPaths(fixture);
129
+ const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
130
+ expect(plan.length).toBe(0);
131
+ });
132
+ });
133
+
134
+ describe("app config wizard", () => {
135
+ test("appConfigHasEntries when entries exist", () => {
136
+ expect(
137
+ appConfigHasEntries({
138
+ ...fixture,
139
+ appConfig: { entries: { token: { description: "API token." } } },
140
+ }),
141
+ ).toBe(true);
142
+ });
143
+
144
+ test("appConfigHasEntries false for empty entries or missing appConfig", () => {
145
+ expect(appConfigHasEntries({ ...fixture, appConfig: { entries: {} } })).toBe(false);
146
+ expect(appConfigHasEntries(fixture)).toBe(false);
147
+ });
148
+ });