argsbarg 4.1.0 → 4.1.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 (103) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +90 -84
  3. package/docs/README.md +6 -6
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +5 -3
  6. package/docs/config-schema.md +36 -9
  7. package/docs/developing.md +7 -7
  8. package/docs/distribution-homebrew.md +103 -0
  9. package/docs/install.md +105 -189
  10. package/docs/mcp.md +2 -3
  11. package/docs/output-schema.md +1 -1
  12. package/examples/full-example/Formula/.gitkeep +0 -0
  13. package/examples/full-example/README.md +98 -0
  14. package/examples/full-example/biome.json +22 -0
  15. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  16. package/examples/full-example/justfile +134 -0
  17. package/examples/{consumer-app → full-example}/package.json +10 -3
  18. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  19. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  20. package/examples/full-example/scripts/create-identity.ts +11 -0
  21. package/examples/full-example/scripts/formula-shared.ts +73 -0
  22. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  23. package/examples/full-example/scripts/print-identity.ts +27 -0
  24. package/examples/full-example/src/commands/echo/command.ts +21 -0
  25. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  26. package/examples/full-example/src/commands/status/command.ts +36 -0
  27. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  28. package/examples/full-example/src/index.ts +10 -0
  29. package/examples/full-example/src/program.ts +57 -0
  30. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  31. package/examples/nested.ts +1 -3
  32. package/index.d.ts +27 -66
  33. package/package.json +2 -2
  34. package/src/builtins/builtins.test.ts +22 -23
  35. package/src/builtins/completion-group.ts +17 -15
  36. package/src/builtins/dispatch.ts +25 -1
  37. package/src/builtins/install.ts +22 -52
  38. package/src/builtins/registry.ts +2 -0
  39. package/src/builtins/uninstall.ts +80 -0
  40. package/src/capabilities.ts +1 -3
  41. package/src/cli-tool/cli-smoke.test.ts +19 -0
  42. package/src/cli-tool/create.test.ts +119 -0
  43. package/src/cli-tool/create.ts +380 -0
  44. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
  45. package/src/cli-tool/main.ts +8 -0
  46. package/src/cli-tool/post-create.ts +111 -0
  47. package/src/cli-tool/program.ts +82 -0
  48. package/src/cli-tool/prompt.ts +28 -0
  49. package/src/cli-tool/run-create.ts +149 -0
  50. package/src/cli.ts +0 -2
  51. package/src/config/bootstrap.ts +16 -9
  52. package/src/config/resolve.test.ts +167 -0
  53. package/src/config/resolve.ts +50 -6
  54. package/src/docs/api-guide.test.ts +4 -5
  55. package/src/docs/docs.test.ts +2 -1
  56. package/src/docs/mcp-guide.ts +7 -8
  57. package/src/index.ts +3 -10
  58. package/src/install/binary-placement.test.ts +101 -0
  59. package/src/install/binary-placement.ts +47 -0
  60. package/src/install/index.ts +117 -123
  61. package/src/install/install.test.ts +89 -105
  62. package/src/install/normalize-uninstall.ts +11 -0
  63. package/src/install/normalize.ts +4 -19
  64. package/src/install/paths.ts +0 -22
  65. package/src/install/plan.ts +14 -6
  66. package/src/install/shell.ts +0 -14
  67. package/src/install/status.test.ts +6 -6
  68. package/src/install/status.ts +0 -6
  69. package/src/install/target-effective.ts +0 -2
  70. package/src/install/target-scope.ts +15 -28
  71. package/src/install/target-types.ts +0 -16
  72. package/src/install/targets/app.ts +19 -28
  73. package/src/install/targets/configure.ts +5 -1
  74. package/src/install/targets/index.ts +0 -3
  75. package/src/install/targets.test.ts +24 -42
  76. package/src/mcp/env.test.ts +92 -0
  77. package/src/mcp/env.ts +15 -14
  78. package/src/parse.test.ts +3 -2
  79. package/src/prompt.ts +10 -0
  80. package/src/schema.ts +9 -1
  81. package/src/types.ts +27 -9
  82. package/src/validate.ts +5 -11
  83. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  84. package/examples/config-app/main.ts +0 -20
  85. package/examples/config-app/program.ts +0 -78
  86. package/examples/config-app/schema.ts +0 -37
  87. package/examples/config-app/types.ts +0 -19
  88. package/examples/consumer-app/README.md +0 -56
  89. package/examples/consumer-app/src/main.ts +0 -15
  90. package/examples/consumer-app/src/program.ts +0 -108
  91. package/src/install/app.ts +0 -94
  92. package/src/install/bootstrap.ts +0 -22
  93. package/src/install/completions.ts +0 -56
  94. package/src/install/targets/completions.ts +0 -133
  95. package/src/install/update.test.ts +0 -123
  96. package/src/install/update.ts +0 -54
  97. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  98. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  99. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  100. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  101. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  102. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  103. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -0,0 +1,149 @@
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
+ classNameFromKey,
9
+ diffCreateDetails,
10
+ parseCreateArgv,
11
+ printCreateDiffs,
12
+ renderCreateTree,
13
+ resolveCreateOptions,
14
+ } from "./create.ts";
15
+ import { printPostCreatePlan, runPostCreate } from "./post-create.ts";
16
+ import { promptConfirm, promptOptional, promptRequired } from "./prompt.ts";
17
+
18
+ function isInteractiveTty(): boolean {
19
+ return Boolean(process.stdin.isTTY);
20
+ }
21
+
22
+ function collectInteractiveOptions(
23
+ partial: Partial<CreateOptions>,
24
+ dir: string,
25
+ ): { opts: CreateOptions; baseDir: string } {
26
+ process.stderr.write("Argsbarg create — bootstrap a new CLI from full-example\n\n");
27
+ const targetDir = promptOptional("Target directory", dir) ?? dir;
28
+ const baseDir = resolve(process.cwd(), targetDir);
29
+ 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`;
43
+
44
+ const opts = resolveCreateOptions(
45
+ {
46
+ ...partial,
47
+ key,
48
+ className,
49
+ tap,
50
+ homepage,
51
+ releaseRepo,
52
+ desc,
53
+ force: partial.force ?? false,
54
+ },
55
+ baseDir,
56
+ );
57
+
58
+ process.stderr.write(`\nTarget: ${baseDir}\n`);
59
+ process.stderr.write(`Key: ${opts.key} Class: ${opts.className} Tap: ${opts.tap}\n\n`);
60
+ const tree = renderCreateTree(opts);
61
+ process.stderr.write(`Files (${tree.size}):\n`);
62
+ for (const rel of [...tree.keys()].sort()) {
63
+ process.stderr.write(` ${rel}\n`);
64
+ }
65
+ process.stderr.write("\n");
66
+ printPostCreatePlan();
67
+ process.stderr.write("\n");
68
+
69
+ if (!promptConfirm("Proceed")) {
70
+ throw new Error("Aborted.");
71
+ }
72
+
73
+ return { opts, baseDir };
74
+ }
75
+
76
+ export async function runCreate(input: Partial<CreateOptions> & { dir?: string }): Promise<number> {
77
+ try {
78
+ const baseDir = resolve(process.cwd(), input.dir ?? ".");
79
+ const partial = { ...input };
80
+ delete (partial as { dir?: string }).dir;
81
+
82
+ if (partial.check || partial.diff) {
83
+ const drifts = diffCreateDetails(baseDir, partial);
84
+ if (drifts.length > 0) {
85
+ process.stderr.write(`Create drift in ${baseDir}:\n`);
86
+ for (const d of drifts) process.stderr.write(` ${d.rel}\n`);
87
+ if (partial.diff) printCreateDiffs(drifts, baseDir);
88
+ return 1;
89
+ }
90
+ process.stdout.write(`Create OK: ${baseDir}\n`);
91
+ return 0;
92
+ }
93
+
94
+ let opts: CreateOptions;
95
+ if (!partial.yes && isInteractiveTty()) {
96
+ const collected = collectInteractiveOptions(partial, input.dir ?? ".");
97
+ opts = collected.opts;
98
+ return runCreateApply(collected.baseDir, opts, partial.dryRun ?? false);
99
+ }
100
+
101
+ if (!partial.yes && !isInteractiveTty()) {
102
+ throw new Error("Refusing to proceed without --yes (stdin is not a TTY).");
103
+ }
104
+ if (!partial.key) {
105
+ throw new Error("--key is required in non-interactive mode.");
106
+ }
107
+ opts = resolveCreateOptions(partial, baseDir);
108
+ return runCreateApply(baseDir, opts, partial.dryRun ?? false);
109
+ } catch (err) {
110
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
111
+ return 1;
112
+ }
113
+ }
114
+
115
+ async function runCreateApply(
116
+ baseDir: string,
117
+ opts: CreateOptions,
118
+ dryRun: boolean,
119
+ ): Promise<number> {
120
+ if (dryRun) {
121
+ const written = applyCreate(baseDir, { ...opts, dryRun: true, check: false });
122
+ process.stdout.write(`Would write ${written.length} file(s) under ${baseDir}\n`);
123
+ for (const w of written) process.stdout.write(` ${relative(baseDir, w) || w}\n`);
124
+ printPostCreatePlan();
125
+ return 0;
126
+ }
127
+
128
+ mkdirSync(baseDir, { recursive: true });
129
+ const written = applyCreate(baseDir, {
130
+ ...opts,
131
+ dryRun: false,
132
+ check: false,
133
+ force: opts.force,
134
+ });
135
+ process.stdout.write(`Created ${written.length} file(s) under ${baseDir}\n`);
136
+ for (const w of written) {
137
+ process.stdout.write(` ${relative(baseDir, w) || w}\n`);
138
+ }
139
+
140
+ await runPostCreate(baseDir, false);
141
+ process.stdout.write("Done.\n");
142
+ return 0;
143
+ }
144
+
145
+ /** Parse argv and run create (for tests and direct script invocation). */
146
+ export async function runCreateCommand(rest: string[]): Promise<number> {
147
+ const { dir, opts } = parseCreateArgv(rest);
148
+ return runCreate({ ...opts, dir });
149
+ }
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,12 +168,12 @@ 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
179
  /** Options for the interactive `install --configure` wizard. */
@@ -218,7 +217,7 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
218
217
  writeConfigureSetupHeading();
219
218
  headingWritten = true;
220
219
  }
221
- const { value } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
220
+ const { value, userTyped } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
222
221
  if (value !== undefined && String(value).length > 0) {
223
222
  updates[key] = value;
224
223
  }
@@ -261,8 +260,16 @@ export function runInstallConfigure(
261
260
  for (const [key, entry] of Object.entries(program.appConfig.entries)) {
262
261
  const before = next[key];
263
262
  const current = resolved[key];
264
- const { value } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
263
+ const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
265
264
  if (value !== undefined && String(value).length > 0) {
265
+ const storedInFile =
266
+ key in existing &&
267
+ existing[key] !== undefined &&
268
+ existing[key] !== null &&
269
+ String(existing[key]).length > 0;
270
+ if (!userTyped && !storedInFile) {
271
+ continue;
272
+ }
266
273
  if (JSON.stringify(value) !== JSON.stringify(before)) {
267
274
  changed = true;
268
275
  }
@@ -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;
@@ -71,21 +71,20 @@ test("generateApiGuide resolves program key in install notes", () => {
71
71
  };
72
72
  const md = generateApiGuide(fixture);
73
73
  expect(md).not.toContain("{argsbarg:program}");
74
- expect(md).toContain("myapp install --yes");
74
+ expect(md).toContain("brew install");
75
75
  expect(md).not.toContain("Upgrade to latest release");
76
76
  });
77
77
 
78
- test("generateApiGuide includes upgrade section when updateGetLatest is set", () => {
78
+ test("generateApiGuide mentions Homebrew upgrade", () => {
79
79
  const fixture: CliProgram = {
80
80
  key: "myapp",
81
81
  version: "1.0.0",
82
82
  description: "Demo app.",
83
- install: { updateGetLatest: async () => ({ path: process.execPath }) },
84
83
  commands: [{ key: "run", description: "Run.", handler: () => {} }],
85
84
  };
86
85
  const md = generateApiGuide(fixture);
87
- expect(md).toContain("Upgrade to latest release");
88
- expect(md).toContain("myapp install --update");
86
+ expect(md).toContain("brew upgrade");
87
+ expect(md).not.toContain("install --update");
89
88
  });
90
89
 
91
90
  test("generateApiGuide resolves {argsbarg:program} in consumer notes", () => {
@@ -217,7 +217,8 @@ test("generateMcpGuide includes schema URI and install targets", () => {
217
217
  expect(guide).toContain("claude_desktop_config.json");
218
218
  expect(guide).toContain("## Installation");
219
219
  expect(guide).toContain("## Running directly");
220
- expect(guide).toContain("install --app");
220
+ expect(guide).toContain("install --mcp");
221
+ expect(guide).toContain("brew install myapp");
221
222
  expect(guide).toContain("OpenAI Codex");
222
223
  expect(guide).toContain("ChatGPT");
223
224
  });
@@ -111,7 +111,7 @@ export function generateMcpGuide(root: CliProgram): string {
111
111
 
112
112
  if (caps.install) {
113
113
  lines.push(
114
- `Install the CLI first so \`${root.key}\` is on your PATH (e.g. \`${root.key} install --app --yes\` or \`install --all --yes\`). Host configs reference the app by name.`,
114
+ `Install the CLI first so \`${root.key}\` is on your PATH (e.g. \`brew install ${root.key}\`). Host configs reference the app by name.`,
115
115
  "",
116
116
  );
117
117
  } else {
@@ -167,13 +167,12 @@ export function generateMcpGuide(root: CliProgram): string {
167
167
  "",
168
168
  );
169
169
 
170
- if (mcp.shellEnv) {
171
- lines.push("## Environment", "");
172
- lines.push(
173
- "- **`shellEnv`** — captures login-shell environment at MCP startup (PATH, toolchain shims, exports).",
174
- );
175
- lines.push("");
176
- }
170
+ lines.push(
171
+ "## Environment",
172
+ "",
173
+ "- **`shellEnv`** — on by default; captures login-shell environment at MCP startup (PATH, toolchain shims, exports). Opt out with `shellEnv: false`.",
174
+ "",
175
+ );
177
176
 
178
177
  if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
179
178
  lines.push("## Configuration", "");
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ module layout.
9
9
 
10
10
  export { Cli, type CliInvokeKind, type CliInvokeResult } from "./cli.ts";
11
11
  export { cliErrWithHelp } from "./cli-errors.ts";
12
+ export { displayAppConfigPath, resolveAppConfigPath } from "./config/file.ts";
12
13
  export type { CliLeafInputs } from "./context.ts";
13
14
  export { CliContext } from "./context.ts";
14
15
  export {
@@ -26,19 +27,13 @@ export {
26
27
  shouldRunHeadlessWithYes,
27
28
  wantsExplicitJson,
28
29
  } from "./headless.ts";
29
- export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
30
- export {
31
- createGhFetchLatest,
32
- createGhVersionCheck,
33
- ghReleaseUpdateGetLatest,
34
- isAlreadyCurrent,
35
- parseReleaseTag,
36
- } from "./install/gh-release-update.ts";
37
30
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
38
31
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
39
32
  export type {
40
33
  CliAppConfig,
41
34
  CliAppConfigEntry,
35
+ CliAppConfigResolveContext,
36
+ CliAppConfigResolveFn,
42
37
  CliDocsConfig,
43
38
  CliDocsTopic,
44
39
  CliHandler,
@@ -52,8 +47,6 @@ export type {
52
47
  CliOption,
53
48
  CliPositional,
54
49
  CliProgram,
55
- CliUpdateArtifact,
56
- CliUpdateGetLatest,
57
50
  InstallAgentIntegration,
58
51
  InstallTargetSpec,
59
52
  ResolvedInstallTarget,
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
+ import {
7
+ isAppInstalled,
8
+ isExternallyManagedBinary,
9
+ resolvePathCommand,
10
+ } from "./binary-placement.ts";
11
+
12
+ const program: CliProgram = {
13
+ key: "placementapp",
14
+ version: "1.0.0",
15
+ description: "x",
16
+ handler: () => {},
17
+ };
18
+
19
+ let tmp: string;
20
+ let prevPath: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ tmp = mkdtempSync(join(tmpdir(), "argsbarg-placement-"));
24
+ prevPath = process.env.PATH;
25
+ });
26
+
27
+ afterEach(() => {
28
+ if (prevPath === undefined) delete process.env.PATH;
29
+ else process.env.PATH = prevPath;
30
+ rmSync(tmp, { recursive: true, force: true });
31
+ });
32
+
33
+ describe("isExternallyManagedBinary", () => {
34
+ test("false when command is not on PATH", () => {
35
+ process.env.PATH = tmp;
36
+ expect(isExternallyManagedBinary("placementapp-not-on-path")).toBe(false);
37
+ });
38
+
39
+ test("true when PATH resolves to execPath", () => {
40
+ const bin = join(tmp, "placementapp");
41
+ writeFileSync(bin, "#!/bin/sh\n", "utf8");
42
+ chmodSync(bin, 0o755);
43
+ process.env.PATH = tmp;
44
+ expect(isExternallyManagedBinary("placementapp", bin)).toBe(true);
45
+ });
46
+
47
+ test("true when PATH entry is a symlink to execPath", () => {
48
+ const bin = join(tmp, "placementapp");
49
+ const target = join(tmp, "real-binary");
50
+ writeFileSync(target, "fake", "utf8");
51
+ symlinkSync(target, bin);
52
+ process.env.PATH = tmp;
53
+ expect(isExternallyManagedBinary("placementapp", target)).toBe(true);
54
+ });
55
+
56
+ test("false when PATH points at a different binary", () => {
57
+ const bin = join(tmp, "placementapp");
58
+ const other = join(tmp, "other");
59
+ writeFileSync(bin, "a", "utf8");
60
+ writeFileSync(other, "b", "utf8");
61
+ process.env.PATH = tmp;
62
+ expect(isExternallyManagedBinary("placementapp", other)).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("isAppInstalled", () => {
67
+ test("true when externally managed", () => {
68
+ const bin = join(tmp, program.key);
69
+ const prevExec = process.execPath;
70
+ writeFileSync(bin, "x", "utf8");
71
+ chmodSync(bin, 0o755);
72
+ process.env.PATH = tmp;
73
+ process.execPath = bin;
74
+ try {
75
+ expect(isAppInstalled(program)).toBe(true);
76
+ } finally {
77
+ process.execPath = prevExec;
78
+ }
79
+ });
80
+
81
+ test("false when not on PATH and no local copy", () => {
82
+ const home = mkdtempSync(join(tmpdir(), "argsbarg-placement-home-"));
83
+ const prevHome = process.env.HOME;
84
+ process.env.HOME = home;
85
+ process.env.PATH = tmp;
86
+ try {
87
+ expect(isAppInstalled(program)).toBe(false);
88
+ } finally {
89
+ if (prevHome === undefined) delete process.env.HOME;
90
+ else process.env.HOME = prevHome;
91
+ rmSync(home, { recursive: true, force: true });
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("resolvePathCommand", () => {
97
+ test("returns undefined when missing", () => {
98
+ process.env.PATH = tmp;
99
+ expect(resolvePathCommand("missing-cmd")).toBeUndefined();
100
+ });
101
+ });