argsbarg 4.1.0 → 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 (119) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +8 -8
  4. package/docs/ai-skills.md +9 -9
  5. package/docs/bundled-docs.md +5 -5
  6. package/docs/cli-program.md +13 -11
  7. package/docs/config-schema.md +36 -9
  8. package/docs/configure.md +177 -0
  9. package/docs/developing.md +7 -7
  10. package/docs/distribution-homebrew.md +104 -0
  11. package/docs/mcp.md +11 -12
  12. package/docs/output-schema.md +1 -1
  13. package/examples/full-example/Formula/.gitkeep +0 -0
  14. package/examples/full-example/README.md +98 -0
  15. package/examples/full-example/biome.json +22 -0
  16. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  17. package/examples/full-example/justfile +134 -0
  18. package/examples/{consumer-app → full-example}/package.json +10 -3
  19. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  20. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  21. package/examples/full-example/scripts/create-identity.ts +11 -0
  22. package/examples/full-example/scripts/formula-shared.ts +73 -0
  23. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  24. package/examples/full-example/scripts/print-identity.ts +27 -0
  25. package/examples/full-example/src/commands/echo/command.ts +21 -0
  26. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  27. package/examples/full-example/src/commands/status/command.ts +36 -0
  28. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  29. package/examples/full-example/src/index.ts +10 -0
  30. package/examples/full-example/src/program.ts +57 -0
  31. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  32. package/examples/nested.ts +1 -3
  33. package/index.d.ts +49 -81
  34. package/package.json +2 -2
  35. package/src/builtins/builtins.test.ts +84 -64
  36. package/src/builtins/completion-group.ts +17 -17
  37. package/src/builtins/configure-copy.ts +86 -0
  38. package/src/builtins/configure.ts +70 -0
  39. package/src/builtins/dispatch.ts +13 -9
  40. package/src/builtins/index.ts +1 -1
  41. package/src/builtins/mcp.ts +2 -2
  42. package/src/builtins/registry.ts +6 -4
  43. package/src/capabilities.ts +22 -15
  44. package/src/cli-tool/cli-smoke.test.ts +29 -0
  45. package/src/cli-tool/create.test.ts +141 -0
  46. package/src/cli-tool/create.ts +402 -0
  47. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +16 -15
  48. package/src/cli-tool/main.ts +8 -0
  49. package/src/cli-tool/post-create.ts +111 -0
  50. package/src/cli-tool/program.ts +97 -0
  51. package/src/cli-tool/prompt.ts +28 -0
  52. package/src/cli-tool/run-create.ts +138 -0
  53. package/src/cli.ts +0 -2
  54. package/src/config/bootstrap.ts +27 -18
  55. package/src/config/file.test.ts +1 -1
  56. package/src/config/resolve.test.ts +167 -0
  57. package/src/config/resolve.ts +52 -8
  58. package/src/configure/configure.test.ts +148 -0
  59. package/src/configure/index.ts +284 -0
  60. package/src/configure/prompt.ts +40 -0
  61. package/src/docs/api-guide.test.ts +4 -5
  62. package/src/docs/builtin.ts +3 -5
  63. package/src/docs/docs.test.ts +6 -5
  64. package/src/docs/mcp-guide.ts +11 -12
  65. package/src/index.ts +5 -12
  66. package/src/install/binary-placement.test.ts +101 -0
  67. package/src/install/binary-placement.ts +47 -0
  68. package/src/install/install-validate.test.ts +5 -5
  69. package/src/install/normalize-uninstall.ts +11 -0
  70. package/src/install/normalize.ts +4 -19
  71. package/src/install/opts.ts +17 -0
  72. package/src/install/paths.ts +0 -22
  73. package/src/install/plan.ts +14 -6
  74. package/src/install/shell.ts +0 -14
  75. package/src/install/status.test.ts +6 -6
  76. package/src/install/status.ts +0 -6
  77. package/src/install/target-effective.ts +8 -10
  78. package/src/install/target-scope.ts +26 -36
  79. package/src/install/target-types.ts +0 -16
  80. package/src/install/targets/app.ts +19 -28
  81. package/src/install/targets/configure.ts +6 -2
  82. package/src/install/targets/index.ts +0 -3
  83. package/src/install/targets.test.ts +26 -44
  84. package/src/invoke.test.ts +1 -1
  85. package/src/mcp/env.test.ts +92 -0
  86. package/src/mcp/env.ts +15 -14
  87. package/src/mcp/tools.ts +1 -1
  88. package/src/mcp.integration.test.ts +4 -4
  89. package/src/parse.test.ts +13 -14
  90. package/src/prompt.ts +10 -0
  91. package/src/schema.ts +1 -1
  92. package/src/skill/hint.ts +2 -2
  93. package/src/types.ts +48 -22
  94. package/src/validate.ts +22 -28
  95. package/docs/install.md +0 -290
  96. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  97. package/examples/config-app/main.ts +0 -20
  98. package/examples/config-app/program.ts +0 -78
  99. package/examples/config-app/schema.ts +0 -37
  100. package/examples/config-app/types.ts +0 -19
  101. package/examples/consumer-app/README.md +0 -56
  102. package/examples/consumer-app/src/main.ts +0 -15
  103. package/examples/consumer-app/src/program.ts +0 -108
  104. package/src/builtins/install.ts +0 -136
  105. package/src/install/app.ts +0 -94
  106. package/src/install/bootstrap.ts +0 -22
  107. package/src/install/completions.ts +0 -56
  108. package/src/install/index.ts +0 -415
  109. package/src/install/install.test.ts +0 -333
  110. package/src/install/targets/completions.ts +0 -133
  111. package/src/install/update.test.ts +0 -123
  112. package/src/install/update.ts +0 -54
  113. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  114. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  115. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  116. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  117. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  118. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  119. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -0,0 +1,28 @@
1
+ /** Interactive prompts for `argsbarg create`. */
2
+
3
+ import { readPromptLine as readStdinLine } from "../prompt.ts";
4
+
5
+ export function readPromptLine(prompt: string): string {
6
+ process.stderr.write(prompt);
7
+ return readStdinLine().trim();
8
+ }
9
+
10
+ export function promptConfirm(message: string): boolean {
11
+ const ans = readPromptLine(`${message} [y/N]: `);
12
+ return ans === "y" || ans === "Y";
13
+ }
14
+
15
+ export function promptOptional(label: string, current?: string): string | undefined {
16
+ const suffix = current ? ` [${current}]` : "";
17
+ const ans = readPromptLine(`${label}${suffix}: `);
18
+ if (ans.length === 0) return current;
19
+ return ans;
20
+ }
21
+
22
+ export function promptRequired(label: string, current?: string): string {
23
+ while (true) {
24
+ const value = promptOptional(label, current);
25
+ if (value && value.length > 0) return value;
26
+ process.stderr.write(" (required)\n");
27
+ }
28
+ }
@@ -0,0 +1,138 @@
1
+ /** `argsbarg create` command orchestration. */
2
+
3
+ import { mkdirSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
+ import {
6
+ applyCreate,
7
+ type CreateOptions,
8
+ diffCreateDetails,
9
+ parseCreateArgv,
10
+ printCreateDiffs,
11
+ renderCreateTree,
12
+ resolveCreateOptions,
13
+ } from "./create.ts";
14
+ import { printPostCreatePlan, runPostCreate } from "./post-create.ts";
15
+ import { promptConfirm, promptOptional, promptRequired } from "./prompt.ts";
16
+
17
+ function isInteractiveTty(): boolean {
18
+ return Boolean(process.stdin.isTTY);
19
+ }
20
+
21
+ function collectInteractiveOptions(
22
+ partial: Partial<CreateOptions>,
23
+ dir: string,
24
+ ): { opts: CreateOptions; baseDir: string } {
25
+ process.stderr.write("Argsbarg create — bootstrap a new CLI from full-example\n\n");
26
+ const targetDir = promptOptional("Target directory", dir) ?? dir;
27
+ const baseDir = resolve(process.cwd(), targetDir);
28
+ const key = promptRequired("CLI key (binary name)", partial.key);
29
+ const releaseRepo = promptRequired("GitHub release repo (org/repo)", partial.releaseRepo);
30
+
31
+ const opts = resolveCreateOptions(
32
+ {
33
+ ...partial,
34
+ key,
35
+ releaseRepo,
36
+ force: partial.force ?? false,
37
+ },
38
+ baseDir,
39
+ );
40
+
41
+ process.stderr.write(`\nTarget: ${baseDir}\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`);
46
+ const tree = renderCreateTree(opts);
47
+ process.stderr.write(`Files (${tree.size}):\n`);
48
+ for (const rel of [...tree.keys()].sort()) {
49
+ process.stderr.write(` ${rel}\n`);
50
+ }
51
+ process.stderr.write("\n");
52
+ printPostCreatePlan();
53
+ process.stderr.write("\n");
54
+
55
+ if (!promptConfirm("Proceed")) {
56
+ throw new Error("Aborted.");
57
+ }
58
+
59
+ return { opts, baseDir };
60
+ }
61
+
62
+ export async function runCreate(input: Partial<CreateOptions> & { dir?: string }): Promise<number> {
63
+ try {
64
+ const baseDir = resolve(process.cwd(), input.dir ?? ".");
65
+ const partial = { ...input };
66
+ delete (partial as { dir?: string }).dir;
67
+
68
+ if (partial.check || partial.diff) {
69
+ const drifts = diffCreateDetails(baseDir, partial);
70
+ if (drifts.length > 0) {
71
+ process.stderr.write(`Create drift in ${baseDir}:\n`);
72
+ for (const d of drifts) process.stderr.write(` ${d.rel}\n`);
73
+ if (partial.diff) printCreateDiffs(drifts, baseDir);
74
+ return 1;
75
+ }
76
+ process.stdout.write(`Create OK: ${baseDir}\n`);
77
+ return 0;
78
+ }
79
+
80
+ let opts: CreateOptions;
81
+ if (!partial.yes && isInteractiveTty()) {
82
+ const collected = collectInteractiveOptions(partial, input.dir ?? ".");
83
+ opts = collected.opts;
84
+ return runCreateApply(collected.baseDir, opts, partial.dryRun ?? false);
85
+ }
86
+
87
+ if (!partial.yes && !isInteractiveTty()) {
88
+ throw new Error("Refusing to proceed without --yes (stdin is not a TTY).");
89
+ }
90
+ if (!partial.key) {
91
+ throw new Error("--key is required in non-interactive mode.");
92
+ }
93
+ if (!partial.releaseRepo) {
94
+ throw new Error("--release-repo is required in non-interactive mode.");
95
+ }
96
+ opts = resolveCreateOptions(partial, baseDir);
97
+ return runCreateApply(baseDir, opts, partial.dryRun ?? false);
98
+ } catch (err) {
99
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
100
+ return 1;
101
+ }
102
+ }
103
+
104
+ async function runCreateApply(
105
+ baseDir: string,
106
+ opts: CreateOptions,
107
+ dryRun: boolean,
108
+ ): Promise<number> {
109
+ if (dryRun) {
110
+ const written = applyCreate(baseDir, { ...opts, dryRun: true, check: false });
111
+ process.stdout.write(`Would write ${written.length} file(s) under ${baseDir}\n`);
112
+ for (const w of written) process.stdout.write(` ${relative(baseDir, w) || w}\n`);
113
+ printPostCreatePlan();
114
+ return 0;
115
+ }
116
+
117
+ mkdirSync(baseDir, { recursive: true });
118
+ const written = applyCreate(baseDir, {
119
+ ...opts,
120
+ dryRun: false,
121
+ check: false,
122
+ force: opts.force,
123
+ });
124
+ process.stdout.write(`Created ${written.length} file(s) under ${baseDir}\n`);
125
+ for (const w of written) {
126
+ process.stdout.write(` ${relative(baseDir, w) || w}\n`);
127
+ }
128
+
129
+ await runPostCreate(baseDir, false);
130
+ process.stdout.write("Done.\n");
131
+ return 0;
132
+ }
133
+
134
+ /** Parse argv and run create (for tests and direct script invocation). */
135
+ export async function runCreateCommand(rest: string[]): Promise<number> {
136
+ const { dir, opts } = parseCreateArgv(rest);
137
+ return runCreate({ ...opts, dir });
138
+ }
package/src/cli.ts CHANGED
@@ -20,7 +20,6 @@ import { type AnyAppConfigSnapshot, createAppConfigSnapshot } from "./config/con
20
20
  import { effectiveJsonSchema } from "./config/schema.ts";
21
21
  import { CliContext } from "./context.ts";
22
22
  import { cliHelpRender } from "./help.ts";
23
- import { maybeBootstrapInstallArgv } from "./install/bootstrap.ts";
24
23
  import { bootstrapMcpEnv } from "./mcp/env.ts";
25
24
  import { mcpServeStdioLoop } from "./mcp/server.ts";
26
25
  import { ParseKind, type ParseResult, parse, postParseValidate } from "./parse.ts";
@@ -95,7 +94,6 @@ export class Cli {
95
94
  }
96
95
 
97
96
  async run(argv: string[] = process.argv.slice(2)): Promise<never> {
98
- argv = maybeBootstrapInstallArgv(argv, this.program);
99
97
  assertBuiltinAllowed(argv, this.caps);
100
98
 
101
99
  const prep = this.prepareDispatch(argv);
@@ -4,6 +4,7 @@ settings are present before the CLI or MCP server handles a request.
4
4
  */
5
5
 
6
6
  import { readSync } from "node:fs";
7
+ import { readPromptLine as readStdinLine } from "../prompt.ts";
7
8
  import type { CliAppConfigEntry, CliProgram } from "../types.ts";
8
9
  import {
9
10
  configEntryRequired,
@@ -124,9 +125,7 @@ function readPromptLine(mask: boolean): string {
124
125
  if (mask) {
125
126
  return readSensitiveLine();
126
127
  }
127
- const buf = Buffer.alloc(4096);
128
- const n = readSync(0, buf, { length: 4096 });
129
- return buf.toString("utf8", 0, n).replace(/\r?\n$/, "");
128
+ return readStdinLine();
130
129
  }
131
130
 
132
131
  /** Whether this setting already has a non-empty value in the user's shell environment. */
@@ -149,7 +148,7 @@ function promptConfigKey(
149
148
  configure: boolean,
150
149
  jsonSchemaRequired: Set<string> | undefined,
151
150
  hostEnv: Record<string, string | undefined>,
152
- ): { value: unknown } {
151
+ ): { value: unknown; userTyped: boolean } {
153
152
  const baseTitle = entry.title ?? defaultConfigEntryTitle(key);
154
153
  const titleWithEnv = entry.env ? `${baseTitle} (${entry.env})` : baseTitle;
155
154
  const required = configEntryRequired(key, entry, jsonSchemaRequired);
@@ -169,18 +168,20 @@ function promptConfigKey(
169
168
  }
170
169
  const input = readPromptLine(sensitive);
171
170
  if (input.length === 0 && hasCurrent) {
172
- return { value: current };
171
+ return { value: current, userTyped: false };
173
172
  }
174
173
  if (input.length > 0) {
175
- return { value: input };
174
+ return { value: input, userTyped: true };
176
175
  }
177
- return { value: undefined };
176
+ return { value: undefined, userTyped: false };
178
177
  }
179
178
 
180
- /** Options for the interactive `install --configure` wizard. */
179
+ /** Options for the interactive configure wizard (app config target). */
181
180
  export interface RunInstallConfigureOpts {
182
181
  /** Where the wizard was started from (standalone command vs right after install). */
183
182
  context?: "standalone" | "after-install";
183
+ /** When false, omit the "Configuration Setup" banner (e.g. inside interactive `configure`). */
184
+ showHeading?: boolean;
184
185
  }
185
186
 
186
187
  /** Print the "Configuration Setup" heading before the configure prompts. */
@@ -218,7 +219,7 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
218
219
  writeConfigureSetupHeading();
219
220
  headingWritten = true;
220
221
  }
221
- const { value } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
222
+ const { value, userTyped } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
222
223
  if (value !== undefined && String(value).length > 0) {
223
224
  updates[key] = value;
224
225
  }
@@ -226,13 +227,13 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
226
227
  return updates;
227
228
  }
228
229
 
229
- /** Run the full interactive configure wizard (`install --configure`). */
230
- export function runInstallConfigure(
230
+ /** Run the full interactive app config wizard (`configure`). */
231
+ export function runConfigure(
231
232
  program: CliProgram,
232
- _opts: RunInstallConfigureOpts = {},
233
+ opts: RunInstallConfigureOpts = {},
233
234
  ): { path: string; changed: boolean } {
234
235
  if (!program.appConfig) {
235
- throw new Error("install --configure requires program.appConfig on the program root.");
236
+ throw new Error("configure requires program.appConfig on the program root.");
236
237
  }
237
238
  if (!process.stdin.isTTY) {
238
239
  const { resolved } = bootstrapAppConfig(program, { validateFile: false });
@@ -240,7 +241,7 @@ export function runInstallConfigure(
240
241
  if (missing.length > 0) {
241
242
  process.stderr.write(`${formatMissingConfigMessage(program, missing)}\n`);
242
243
  } else {
243
- process.stderr.write("install --configure requires an interactive terminal.\n");
244
+ process.stderr.write("configure requires an interactive terminal.\n");
244
245
  }
245
246
  process.exit(1);
246
247
  }
@@ -254,15 +255,23 @@ export function runInstallConfigure(
254
255
  const next: Record<string, unknown> = { ...existing };
255
256
  let changed = false;
256
257
 
257
- if (shouldShowConfigureSetupHeading(program)) {
258
+ if (opts.showHeading !== false && shouldShowConfigureSetupHeading(program)) {
258
259
  writeConfigureSetupHeading();
259
260
  }
260
261
 
261
262
  for (const [key, entry] of Object.entries(program.appConfig.entries)) {
262
263
  const before = next[key];
263
264
  const current = resolved[key];
264
- const { value } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
265
+ const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
265
266
  if (value !== undefined && String(value).length > 0) {
267
+ const storedInFile =
268
+ key in existing &&
269
+ existing[key] !== undefined &&
270
+ existing[key] !== null &&
271
+ String(existing[key]).length > 0;
272
+ if (!userTyped && !storedInFile) {
273
+ continue;
274
+ }
266
275
  if (JSON.stringify(value) !== JSON.stringify(before)) {
267
276
  changed = true;
268
277
  }
@@ -279,7 +288,7 @@ export function runInstallConfigure(
279
288
  return { path, changed: false };
280
289
  }
281
290
 
282
- /** 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). */
283
292
  export function appConfigStatus(program: CliProgram):
284
293
  | {
285
294
  path: string;
@@ -336,7 +345,7 @@ export function ensureAppConfig(
336
345
 
337
346
  if (opts.interactive && process.stdin.isTTY) {
338
347
  if (opts.configure) {
339
- runInstallConfigure(program, { context: "standalone" });
348
+ runConfigure(program, { context: "standalone" });
340
349
  fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
341
350
  resolved = resolveAppConfig(program, fileData, hostEnv);
342
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
  }
@@ -109,4 +109,171 @@ describe("config/resolve", () => {
109
109
  else process.env.API_TOKEN = prev;
110
110
  }
111
111
  });
112
+
113
+ test("resolve callback supplies value when env and file are absent", () => {
114
+ const resolveProgram: CliProgram = {
115
+ ...program,
116
+ appConfig: {
117
+ ...program.appConfig!,
118
+ entries: {
119
+ ...program.appConfig!.entries,
120
+ apiToken: {
121
+ description: "Token.",
122
+ env: "API_TOKEN",
123
+ resolve: () => "from-resolve",
124
+ },
125
+ },
126
+ },
127
+ };
128
+ const prev = process.env.API_TOKEN;
129
+ delete process.env.API_TOKEN;
130
+ try {
131
+ const resolved = resolveAppConfig(resolveProgram, {});
132
+ expect(resolved.apiToken).toBe("from-resolve");
133
+ } finally {
134
+ if (prev !== undefined) process.env.API_TOKEN = prev;
135
+ }
136
+ });
137
+
138
+ test("env overrides resolve callback", () => {
139
+ const resolveProgram: CliProgram = {
140
+ ...program,
141
+ appConfig: {
142
+ ...program.appConfig!,
143
+ entries: {
144
+ ...program.appConfig!.entries,
145
+ apiToken: {
146
+ description: "Token.",
147
+ env: "API_TOKEN",
148
+ resolve: () => "from-resolve",
149
+ },
150
+ },
151
+ },
152
+ };
153
+ const prev = process.env.API_TOKEN;
154
+ process.env.API_TOKEN = "from-env";
155
+ try {
156
+ const resolved = resolveAppConfig(resolveProgram, {});
157
+ expect(resolved.apiToken).toBe("from-env");
158
+ } finally {
159
+ if (prev === undefined) delete process.env.API_TOKEN;
160
+ else process.env.API_TOKEN = prev;
161
+ }
162
+ });
163
+
164
+ test("file overrides resolve callback", () => {
165
+ const resolveProgram: CliProgram = {
166
+ ...program,
167
+ appConfig: {
168
+ ...program.appConfig!,
169
+ entries: {
170
+ ...program.appConfig!.entries,
171
+ apiToken: {
172
+ description: "Token.",
173
+ env: "API_TOKEN",
174
+ resolve: () => "from-resolve",
175
+ },
176
+ },
177
+ },
178
+ };
179
+ const prev = process.env.API_TOKEN;
180
+ delete process.env.API_TOKEN;
181
+ try {
182
+ const resolved = resolveAppConfig(resolveProgram, { apiToken: "from-file" });
183
+ expect(resolved.apiToken).toBe("from-file");
184
+ } finally {
185
+ if (prev !== undefined) process.env.API_TOKEN = prev;
186
+ }
187
+ });
188
+
189
+ test("falls back to env when resolve returns undefined", () => {
190
+ const resolveProgram: CliProgram = {
191
+ ...program,
192
+ appConfig: {
193
+ ...program.appConfig!,
194
+ entries: {
195
+ ...program.appConfig!.entries,
196
+ apiToken: {
197
+ description: "Token.",
198
+ env: "API_TOKEN",
199
+ resolve: () => undefined,
200
+ },
201
+ },
202
+ },
203
+ };
204
+ const hostEnv = { API_TOKEN: "from-env-fallback" };
205
+ const prev = process.env.API_TOKEN;
206
+ delete process.env.API_TOKEN;
207
+ try {
208
+ const resolved = resolveAppConfig(resolveProgram, {}, hostEnv);
209
+ expect(resolved.apiToken).toBe("from-env-fallback");
210
+ } finally {
211
+ if (prev !== undefined) process.env.API_TOKEN = prev;
212
+ }
213
+ });
214
+
215
+ test("resolve callback is skipped when env is set", () => {
216
+ let resolveCalled = false;
217
+ const resolveProgram: CliProgram = {
218
+ ...program,
219
+ appConfig: {
220
+ ...program.appConfig!,
221
+ entries: {
222
+ ...program.appConfig!.entries,
223
+ apiToken: {
224
+ description: "Token.",
225
+ env: "API_TOKEN",
226
+ resolve: () => {
227
+ resolveCalled = true;
228
+ return "from-resolve";
229
+ },
230
+ },
231
+ },
232
+ },
233
+ };
234
+ const prev = process.env.API_TOKEN;
235
+ process.env.API_TOKEN = "from-env";
236
+ try {
237
+ const resolved = resolveAppConfig(resolveProgram, {});
238
+ expect(resolved.apiToken).toBe("from-env");
239
+ expect(resolveCalled).toBe(false);
240
+ } finally {
241
+ if (prev === undefined) delete process.env.API_TOKEN;
242
+ else process.env.API_TOKEN = prev;
243
+ }
244
+ });
245
+
246
+ test("async resolve is ignored with stderr warning", () => {
247
+ const resolveProgram: CliProgram = {
248
+ ...program,
249
+ appConfig: {
250
+ ...program.appConfig!,
251
+ entries: {
252
+ ...program.appConfig!.entries,
253
+ apiToken: {
254
+ description: "Token.",
255
+ env: "API_TOKEN",
256
+ resolve: async () => "from-async-resolve",
257
+ },
258
+ },
259
+ },
260
+ };
261
+ const prev = process.env.API_TOKEN;
262
+ delete process.env.API_TOKEN;
263
+ const prevStderr = process.stderr.write;
264
+ const stderrLines: string[] = [];
265
+ process.stderr.write = ((chunk: string | Uint8Array) => {
266
+ stderrLines.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
267
+ return true;
268
+ }) as typeof process.stderr.write;
269
+ try {
270
+ const resolved = resolveAppConfig(resolveProgram, {}, { API_TOKEN: undefined });
271
+ expect(resolved.apiToken).toBeUndefined();
272
+ expect(stderrLines.join("")).toContain("returned a Promise");
273
+ } finally {
274
+ process.stderr.write = prevStderr;
275
+ if (prev === undefined) delete process.env.API_TOKEN;
276
+ else process.env.API_TOKEN = prev;
277
+ }
278
+ });
112
279
  });
@@ -2,7 +2,7 @@
2
2
  Resolve program.appConfig values: defaults, file, env override, export to process.env.
3
3
  */
4
4
 
5
- import type { CliAppConfigEntry, CliProgram } from "../types.ts";
5
+ import type { CliAppConfigEntry, CliAppConfigResolveContext, CliProgram } from "../types.ts";
6
6
  import { configEntryRequired, jsonSchemaRequiredKeys } from "./entry.ts";
7
7
  import type { AppConfigFileData } from "./file.ts";
8
8
  import { displayAppConfigPath } from "./file.ts";
@@ -93,6 +93,38 @@ export function resolveAppConfig(
93
93
  return out;
94
94
  }
95
95
 
96
+ function tryEnvOverride(
97
+ program: CliProgram,
98
+ key: string,
99
+ entry: CliAppConfigEntry,
100
+ hostEnv?: Record<string, string | undefined>,
101
+ ): unknown {
102
+ if (!entry.env) {
103
+ return undefined;
104
+ }
105
+ const fromEnv = envOverrideValue(entry.env, hostEnv);
106
+ if (fromEnv === undefined) {
107
+ return undefined;
108
+ }
109
+ return coerceEnvValue(program, key, fromEnv);
110
+ }
111
+
112
+ function buildResolveContext(
113
+ program: CliProgram,
114
+ key: string,
115
+ entry: CliAppConfigEntry,
116
+ fileData: AppConfigFileData,
117
+ hostEnv?: Record<string, string | undefined>,
118
+ ): CliAppConfigResolveContext {
119
+ return {
120
+ key,
121
+ entry,
122
+ program,
123
+ fileValue: fileData[key],
124
+ envValue: entry.env ? envOverrideValue(entry.env, hostEnv) : undefined,
125
+ };
126
+ }
127
+
96
128
  function resolveConfigKey(
97
129
  program: CliProgram,
98
130
  key: string,
@@ -100,15 +132,27 @@ function resolveConfigKey(
100
132
  fileData: AppConfigFileData,
101
133
  hostEnv?: Record<string, string | undefined>,
102
134
  ): unknown {
103
- if (entry.env) {
104
- const fromEnv = envOverrideValue(entry.env, hostEnv);
105
- if (fromEnv !== undefined) {
106
- return coerceEnvValue(program, key, fromEnv);
107
- }
135
+ const fromEnv = tryEnvOverride(program, key, entry, hostEnv);
136
+ if (fromEnv !== undefined) {
137
+ return fromEnv;
108
138
  }
109
139
  if (key in fileData && isPresent(fileData[key])) {
110
140
  return fileData[key];
111
141
  }
142
+ if (entry.resolve) {
143
+ const fromResolve = entry.resolve(buildResolveContext(program, key, entry, fileData, hostEnv));
144
+ if (fromResolve != null && typeof (fromResolve as Promise<unknown>).then === "function") {
145
+ process.stderr.write(
146
+ `[argsbarg] config "${key}": resolve() returned a Promise; use a synchronous resolver (e.g. Bun.spawnSync with piped stdout).\n`,
147
+ );
148
+ } else if (isPresent(fromResolve)) {
149
+ return fromResolve;
150
+ }
151
+ }
152
+ const fromEnvFallback = tryEnvOverride(program, key, entry, hostEnv);
153
+ if (fromEnvFallback !== undefined) {
154
+ return fromEnvFallback;
155
+ }
112
156
  const def = schemaDefaultForKey(program, key);
113
157
  if (def !== undefined) {
114
158
  return def;
@@ -182,7 +226,7 @@ export function formatMissingConfigMessage(program: CliProgram, keys: string[]):
182
226
  const path = displayAppConfigPath(program);
183
227
  return [
184
228
  `Missing required configuration: ${list}`,
185
- `Configure interactively: ${program.key} install --configure`,
229
+ `Configure interactively: ${program.key} configure`,
186
230
  `Or set via: ${program.key} config set <key> <value>`,
187
231
  `Config file: ${path}`,
188
232
  `See: ${program.key} docs mcp`,
@@ -195,7 +239,7 @@ export function formatMcpMissingConfigMessage(program: CliProgram, keys: string[
195
239
  const path = displayAppConfigPath(program);
196
240
  return [
197
241
  `Missing required configuration: ${list}`,
198
- `Configure: ${program.key} install --configure`,
242
+ `Configure: ${program.key} configure`,
199
243
  `Or set via: ${program.key} config set`,
200
244
  `Config file: ${path}`,
201
245
  ].join("\n");