argsbarg 4.0.4 → 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 (147) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +6 -6
  4. package/docs/ai-skills.md +8 -5
  5. package/docs/bundled-docs.md +1 -1
  6. package/docs/cli-program.md +9 -7
  7. package/docs/config-schema.md +37 -13
  8. package/docs/developing.md +8 -8
  9. package/docs/distribution-homebrew.md +103 -0
  10. package/docs/install.md +143 -106
  11. package/docs/mcp.md +23 -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/mcp-test.ts +8 -24
  33. package/examples/nested.ts +1 -3
  34. package/index.d.ts +81 -65
  35. package/package.json +2 -2
  36. package/src/builtins/builtins.test.ts +37 -22
  37. package/src/builtins/completion-group.ts +17 -15
  38. package/src/builtins/config.test.ts +31 -25
  39. package/src/builtins/config.ts +4 -3
  40. package/src/builtins/dispatch.ts +25 -1
  41. package/src/builtins/install.ts +45 -82
  42. package/src/builtins/mcp.ts +1 -1
  43. package/src/builtins/registry.ts +2 -0
  44. package/src/builtins/uninstall.ts +80 -0
  45. package/src/capabilities.ts +5 -7
  46. package/src/cli-tool/cli-smoke.test.ts +19 -0
  47. package/src/cli-tool/create.test.ts +119 -0
  48. package/src/cli-tool/create.ts +380 -0
  49. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
  50. package/src/cli-tool/main.ts +8 -0
  51. package/src/cli-tool/post-create.ts +111 -0
  52. package/src/cli-tool/program.ts +82 -0
  53. package/src/cli-tool/prompt.ts +28 -0
  54. package/src/cli-tool/run-create.ts +149 -0
  55. package/src/config/bootstrap.ts +174 -66
  56. package/src/config/context.test.ts +22 -36
  57. package/src/config/context.ts +5 -4
  58. package/src/config/file.test.ts +66 -56
  59. package/src/config/file.ts +33 -25
  60. package/src/config/resolve.test.ts +192 -1
  61. package/src/config/resolve.ts +92 -13
  62. package/src/config.integration.test.ts +17 -10
  63. package/src/docs/api-guide.test.ts +4 -5
  64. package/src/docs/docs.test.ts +2 -1
  65. package/src/docs/mcp-guide.ts +7 -8
  66. package/src/hidden-mcpb.test.ts +41 -1
  67. package/src/index.ts +7 -10
  68. package/src/install/binary-placement.test.ts +101 -0
  69. package/src/install/binary-placement.ts +47 -0
  70. package/src/install/detect-installed.ts +2 -97
  71. package/src/install/index.ts +239 -168
  72. package/src/install/install-validate.test.ts +61 -0
  73. package/src/install/install.test.ts +170 -90
  74. package/src/install/mcp-openclaw.test.ts +40 -0
  75. package/src/install/mcp-openclaw.ts +106 -0
  76. package/src/install/normalize-uninstall.ts +11 -0
  77. package/src/install/normalize.ts +20 -0
  78. package/src/install/paths.ts +18 -26
  79. package/src/install/plan.ts +40 -261
  80. package/src/install/shell.ts +0 -14
  81. package/src/install/status.test.ts +85 -0
  82. package/src/install/status.ts +22 -15
  83. package/src/install/target-base.ts +93 -0
  84. package/src/install/target-detect.ts +20 -0
  85. package/src/install/target-effective.ts +129 -0
  86. package/src/install/target-mcp-cli.ts +149 -0
  87. package/src/install/target-mcp-json.ts +130 -0
  88. package/src/install/target-plan-build.ts +67 -0
  89. package/src/install/target-registry.ts +57 -0
  90. package/src/install/target-scope.ts +253 -0
  91. package/src/install/target-skill.ts +104 -0
  92. package/src/install/target-types.ts +129 -0
  93. package/src/install/targets/app.ts +60 -0
  94. package/src/install/targets/chatgpt-mcp.ts +12 -0
  95. package/src/install/targets/claude-code-mcp.ts +15 -0
  96. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  97. package/src/install/targets/claude-skill.ts +16 -0
  98. package/src/install/targets/codex-mcp.ts +25 -0
  99. package/src/install/targets/codex-skill.ts +14 -0
  100. package/src/install/targets/configure.ts +63 -0
  101. package/src/install/targets/cursor-mcp.ts +15 -0
  102. package/src/install/targets/cursor-skill.ts +16 -0
  103. package/src/install/targets/index.ts +50 -0
  104. package/src/install/targets/openclaw-mcp.ts +25 -0
  105. package/src/install/targets/openclaw-skill.ts +17 -0
  106. package/src/install/targets/opencode-mcp.ts +101 -0
  107. package/src/install/targets/opencode-skill.ts +15 -0
  108. package/src/install/targets.test.ts +118 -0
  109. package/src/install/uninstall.ts +16 -152
  110. package/src/invoke.test.ts +7 -1
  111. package/src/mcp/bundle.ts +16 -4
  112. package/src/mcp/claude.test.ts +14 -1
  113. package/src/mcp/claude.ts +11 -4
  114. package/src/mcp/env.test.ts +92 -0
  115. package/src/mcp/env.ts +15 -14
  116. package/src/mcp/zip.test.ts +17 -0
  117. package/src/mcp/zip.ts +62 -9
  118. package/src/mcp.integration.test.ts +1 -1
  119. package/src/parse.test.ts +14 -2
  120. package/src/paths/host.ts +11 -11
  121. package/src/paths/remove-empty-dir.ts +13 -0
  122. package/src/prompt.ts +10 -0
  123. package/src/schema.ts +9 -1
  124. package/src/skill/generate.ts +18 -4
  125. package/src/skill/install.ts +33 -6
  126. package/src/skill/naming.ts +28 -0
  127. package/src/types.ts +86 -7
  128. package/src/validate.ts +73 -9
  129. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  130. package/examples/config-app/main.ts +0 -20
  131. package/examples/config-app/program.ts +0 -81
  132. package/examples/config-app/schema.ts +0 -37
  133. package/examples/config-app/types.ts +0 -19
  134. package/examples/consumer-app/README.md +0 -57
  135. package/examples/consumer-app/src/main.ts +0 -15
  136. package/examples/consumer-app/src/program.ts +0 -108
  137. package/src/install/binary.ts +0 -94
  138. package/src/install/completions.ts +0 -56
  139. package/src/install/update.test.ts +0 -108
  140. package/src/install/update.ts +0 -57
  141. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  142. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  143. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  144. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  145. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  146. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  147. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -1,10 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import type { CliProgram } from "../types.ts";
6
6
  import { bootstrapAppConfig } from "./bootstrap.ts";
7
- import { readAppConfigFile, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
7
+ import {
8
+ readAppConfigFile,
9
+ resolveAppConfigDir,
10
+ resolveAppConfigPath,
11
+ uninstallAppConfig,
12
+ writeAppConfigFile,
13
+ } from "./file.ts";
8
14
  import { buildProgramUserConfig } from "./manifest.ts";
9
15
  import { formatMissingConfigMessage, missingRequiredConfig, resolveAppConfig } from "./resolve.ts";
10
16
 
@@ -13,7 +19,6 @@ const program: CliProgram = {
13
19
  version: "1.0.0",
14
20
  description: "Demo.",
15
21
  appConfig: {
16
- path: "~/.config/myapp-test/config",
17
22
  entries: {
18
23
  apiToken: { description: "Token.", env: "API_TOKEN" },
19
24
  port: { description: "HTTP listen port (default 8080).", required: false },
@@ -22,6 +27,19 @@ const program: CliProgram = {
22
27
  handler: () => {},
23
28
  };
24
29
 
30
+ function withHome<T>(fn: (home: string) => T): T {
31
+ const home = mkdtempSync(join(tmpdir(), "cfg-test-"));
32
+ const prevHome = process.env.HOME;
33
+ process.env.HOME = home;
34
+ try {
35
+ return fn(home);
36
+ } finally {
37
+ if (prevHome === undefined) delete process.env.HOME;
38
+ else process.env.HOME = prevHome;
39
+ rmSync(home, { recursive: true, force: true });
40
+ }
41
+ }
42
+
25
43
  describe("config/file", () => {
26
44
  test("buildProgramUserConfig from program.appConfig env entries", () => {
27
45
  const cfg = buildProgramUserConfig(program);
@@ -33,31 +51,29 @@ describe("config/file", () => {
33
51
  expect(cfg?.port).toBeUndefined();
34
52
  });
35
53
 
54
+ test("resolveAppConfigPath uses config.json", () => {
55
+ withHome((home) => {
56
+ expect(resolveAppConfigPath(program)).toBe(
57
+ join(home, ".local", "lib", "myapp", "config.json"),
58
+ );
59
+ });
60
+ });
61
+
36
62
  test("resolveAppConfig prefers host env over file", () => {
37
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
38
- const prevHome = process.env.HOME;
39
- process.env.HOME = dir;
40
- const prevToken = process.env.API_TOKEN;
41
- process.env.API_TOKEN = "from-host";
42
- try {
43
- const p: CliProgram = {
44
- ...program,
45
- appConfig: {
46
- ...program.appConfig!,
47
- path: join(dir, ".config", "myapp-test", "config"),
48
- },
49
- };
50
- mkdirSync(join(dir, ".config", "myapp-test"), { recursive: true });
51
- writeFileSync(resolveAppConfigPath(p), `${JSON.stringify({ apiToken: "from-file" })}\n`);
52
- const resolved = resolveAppConfig(p, { apiToken: "from-file" });
53
- expect(resolved.apiToken).toBe("from-host");
54
- } finally {
55
- if (prevHome === undefined) delete process.env.HOME;
56
- else process.env.HOME = prevHome;
57
- if (prevToken === undefined) delete process.env.API_TOKEN;
58
- else process.env.API_TOKEN = prevToken;
59
- rmSync(dir, { recursive: true, force: true });
60
- }
63
+ withHome((_home) => {
64
+ const prevToken = process.env.API_TOKEN;
65
+ process.env.API_TOKEN = "from-host";
66
+ try {
67
+ const configPath = resolveAppConfigPath(program);
68
+ mkdirSync(dirname(configPath), { recursive: true });
69
+ writeFileSync(configPath, `${JSON.stringify({ apiToken: "from-file" })}\n`);
70
+ const resolved = resolveAppConfig(program, { apiToken: "from-file" });
71
+ expect(resolved.apiToken).toBe("from-host");
72
+ } finally {
73
+ if (prevToken === undefined) delete process.env.API_TOKEN;
74
+ else process.env.API_TOKEN = prevToken;
75
+ }
76
+ });
61
77
  });
62
78
 
63
79
  test("missingRequiredConfig and formatMissingConfigMessage", () => {
@@ -76,37 +92,31 @@ describe("config/file", () => {
76
92
  });
77
93
 
78
94
  test("rejects unknown keys on read", () => {
79
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
80
- try {
81
- const p: CliProgram = {
82
- ...program,
83
- appConfig: {
84
- ...program.appConfig!,
85
- path: join(dir, "config"),
86
- },
87
- };
88
- writeFileSync(join(dir, "config"), `${JSON.stringify({ extra: true })}\n`);
89
- expect(() => readAppConfigFile(p)).toThrow(/Unknown config key/);
90
- } finally {
91
- rmSync(dir, { recursive: true, force: true });
92
- }
95
+ withHome(() => {
96
+ const configPath = resolveAppConfigPath(program);
97
+ mkdirSync(dirname(configPath), { recursive: true });
98
+ writeFileSync(configPath, `${JSON.stringify({ extra: true })}\n`);
99
+ expect(() => readAppConfigFile(program)).toThrow(/Unknown config key/);
100
+ });
93
101
  });
94
102
 
95
103
  test("writeAppConfigFile round-trip", () => {
96
- const dir = mkdtempSync(join(tmpdir(), "cfg-test-"));
97
- try {
98
- const p: CliProgram = {
99
- ...program,
100
- appConfig: {
101
- ...program.appConfig!,
102
- path: join(dir, "config"),
103
- },
104
- };
105
- writeAppConfigFile(p, { apiToken: "saved" });
106
- const { resolved } = bootstrapAppConfig(p, { validateFile: true });
104
+ withHome(() => {
105
+ writeAppConfigFile(program, { apiToken: "saved" });
106
+ const { resolved } = bootstrapAppConfig(program, { validateFile: true });
107
107
  expect(resolved.apiToken).toBe("saved");
108
- } finally {
109
- rmSync(dir, { recursive: true, force: true });
110
- }
108
+ });
109
+ });
110
+
111
+ test("uninstallAppConfig removes config directory recursively", () => {
112
+ withHome(() => {
113
+ writeAppConfigFile(program, { apiToken: "saved" });
114
+ const configPath = resolveAppConfigPath(program);
115
+ const configDir = resolveAppConfigDir(program);
116
+ writeFileSync(join(configDir, "extra.txt"), "leftover", "utf8");
117
+ expect(uninstallAppConfig(program, false)).toEqual([configPath, `${configDir}/`]);
118
+ expect(existsSync(configPath)).toBe(false);
119
+ expect(existsSync(configDir)).toBe(false);
120
+ });
111
121
  });
112
122
  });
@@ -2,24 +2,20 @@
2
2
  JSON app config file path helpers and strict read/write.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { sanitizeToolSegment } from "../mcp/tools.ts";
8
- import { appConfigHome, expandTilde } from "../paths/host.ts";
8
+ import { appConfigLibHome, displayHomePath } from "../paths/host.ts";
9
9
  import type { CliProgram } from "../types.ts";
10
10
  import { effectiveJsonSchema } from "./schema.ts";
11
11
  import { validateConfigDocument } from "./validate.ts";
12
12
 
13
13
  export type AppConfigFileData = Record<string, unknown>;
14
14
 
15
- /** Resolved absolute path to the app JSON config file. */
15
+ /** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
16
16
  export function resolveAppConfigPath(program: CliProgram): string {
17
- const custom = program.appConfig?.path;
18
- if (custom) {
19
- return expandTilde(custom);
20
- }
21
17
  const dirName = sanitizeToolSegment(program.key);
22
- return join(appConfigHome(), dirName, "config");
18
+ return join(appConfigLibHome(), dirName, "config.json");
23
19
  }
24
20
 
25
21
  /** Resolved absolute directory containing the app JSON config file. */
@@ -27,14 +23,9 @@ export function resolveAppConfigDir(program: CliProgram): string {
27
23
  return dirname(resolveAppConfigPath(program));
28
24
  }
29
25
 
30
- /** Human-readable config path for error messages (`~` when under home). */
26
+ /** Human-readable config path for error messages (`~/…` when under home). */
31
27
  export function displayAppConfigPath(program: CliProgram): string {
32
- const resolved = resolveAppConfigPath(program);
33
- const home = process.env.HOME ?? "";
34
- if (home.length > 0 && resolved.startsWith(home)) {
35
- return `~${resolved.slice(home.length)}`;
36
- }
37
- return resolved;
28
+ return displayHomePath(resolveAppConfigPath(program));
38
29
  }
39
30
 
40
31
  function parseConfigJson(text: string, path: string): AppConfigFileData {
@@ -46,7 +37,7 @@ function parseConfigJson(text: string, path: string): AppConfigFileData {
46
37
  return parsed as AppConfigFileData;
47
38
  } catch (err) {
48
39
  const msg = err instanceof Error ? err.message : String(err);
49
- throw new Error(`Invalid JSON in config file ${path}: ${msg}`);
40
+ throw new Error(`Invalid JSON in config file ${displayHomePath(path)}: ${msg}`);
50
41
  }
51
42
  }
52
43
 
@@ -60,7 +51,7 @@ export function readAppConfigFileRaw(path: string): AppConfigFileData {
60
51
  text = readFileSync(path, "utf8");
61
52
  } catch (err) {
62
53
  const msg = err instanceof Error ? err.message : String(err);
63
- throw new Error(`Could not read config file ${path}: ${msg}`);
54
+ throw new Error(`Could not read config file ${displayHomePath(path)}: ${msg}`);
64
55
  }
65
56
  return parseConfigJson(text, path);
66
57
  }
@@ -89,7 +80,7 @@ export function validateAppConfigData(
89
80
  const allowed = new Set(Object.keys(appConfig.entries));
90
81
  for (const key of Object.keys(data)) {
91
82
  if (!allowed.has(key)) {
92
- const where = pathLabel ?? "config";
83
+ const where = pathLabel ? displayHomePath(pathLabel) : "config";
93
84
  throw new Error(`Unknown config key '${key}' in ${where}`);
94
85
  }
95
86
  }
@@ -99,7 +90,7 @@ export function validateAppConfigData(
99
90
  }
100
91
  const result = validateConfigDocument(data, jsonSchema);
101
92
  if (!result.valid) {
102
- const where = pathLabel ?? "config";
93
+ const where = pathLabel ? displayHomePath(pathLabel) : "config";
103
94
  throw new Error(`Invalid config in ${where}: ${result.errors.join("; ")}`);
104
95
  }
105
96
  }
@@ -112,14 +103,31 @@ export function writeAppConfigFile(program: CliProgram, data: AppConfigFileData)
112
103
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
113
104
  }
114
105
 
115
- /** Removes the app config file when present. Returns true if removed. */
116
- export function uninstallAppConfig(program: CliProgram, dry: boolean): boolean {
106
+ /** True when the app config file or config directory is present. */
107
+ export function appConfigInstalled(program: CliProgram): boolean {
117
108
  const path = resolveAppConfigPath(program);
118
- if (!existsSync(path)) {
119
- return false;
109
+ if (existsSync(path)) return true;
110
+ return existsSync(resolveAppConfigDir(program));
111
+ }
112
+
113
+ /** Removes the app config file and config directory when present. */
114
+ export function uninstallAppConfig(program: CliProgram, dry: boolean): string[] {
115
+ const path = resolveAppConfigPath(program);
116
+ const dir = resolveAppConfigDir(program);
117
+ const hasFile = existsSync(path);
118
+ const hasDir = existsSync(dir);
119
+
120
+ if (!hasFile && !hasDir) {
121
+ return [];
120
122
  }
123
+
124
+ const changed: string[] = [];
125
+ if (hasFile) changed.push(path);
126
+ if (hasDir) changed.push(`${dir}/`);
127
+
121
128
  if (!dry) {
122
- unlinkSync(path);
129
+ if (hasFile) unlinkSync(path);
130
+ if (hasDir) rmSync(dir, { recursive: true, force: true });
123
131
  }
124
- return true;
132
+ return changed;
125
133
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { CliProgram } from "../types.ts";
3
- import { resolveAppConfig } from "./resolve.ts";
3
+ import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
4
4
 
5
5
  const program: CliProgram = {
6
6
  key: "app",
@@ -85,4 +85,195 @@ describe("config/resolve", () => {
85
85
  const resolved = resolveAppConfig(stringProgram, {});
86
86
  expect(resolved.greeting).toBe("world");
87
87
  });
88
+
89
+ test("prefers captured host env over file when process.env was exported from file", () => {
90
+ const hostEnv = { API_TOKEN: "from-host" };
91
+ process.env.API_TOKEN = "from-file-export";
92
+ try {
93
+ const resolved = resolveAppConfig(program, { apiToken: "from-file" }, hostEnv);
94
+ expect(resolved.apiToken).toBe("from-host");
95
+ } finally {
96
+ delete process.env.API_TOKEN;
97
+ }
98
+ });
99
+
100
+ test("exportConfigToEnv does not overwrite host env", () => {
101
+ const prev = process.env.API_TOKEN;
102
+ process.env.API_TOKEN = "from-host";
103
+ const hostEnv = captureMappedHostEnv(program);
104
+ try {
105
+ exportConfigToEnv(program, { apiToken: "from-file" }, hostEnv);
106
+ expect(process.env.API_TOKEN).toBe("from-host");
107
+ } finally {
108
+ if (prev === undefined) delete process.env.API_TOKEN;
109
+ else process.env.API_TOKEN = prev;
110
+ }
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
+ });
88
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";
@@ -20,8 +20,26 @@ function isPresent(value: unknown): boolean {
20
20
  return true;
21
21
  }
22
22
 
23
- function envOverrideValue(envName: string): string | undefined {
24
- const val = process.env[envName];
23
+ /** Snapshot mapped host env at bootstrap entry (before file exports mutate process.env). */
24
+ export function captureMappedHostEnv(program: CliProgram): Record<string, string | undefined> {
25
+ const out: Record<string, string | undefined> = {};
26
+ const entries = program.appConfig?.entries;
27
+ if (!entries) {
28
+ return out;
29
+ }
30
+ for (const entry of Object.values(entries)) {
31
+ if (entry.env) {
32
+ out[entry.env] = process.env[entry.env];
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function envOverrideValue(
39
+ envName: string,
40
+ hostEnv?: Record<string, string | undefined>,
41
+ ): string | undefined {
42
+ const val = hostEnv && envName in hostEnv ? hostEnv[envName] : process.env[envName];
25
43
  if (val === undefined || val.length === 0) {
26
44
  return undefined;
27
45
  }
@@ -55,15 +73,19 @@ function coerceEnvValue(program: CliProgram, key: string, raw: string): unknown
55
73
  return raw;
56
74
  }
57
75
 
58
- /** Resolve all schema keys from file data and process.env. */
59
- export function resolveAppConfig(program: CliProgram, fileData: AppConfigFileData): ResolvedConfig {
76
+ /** Resolve all schema keys from file data and mapped host env (env wins over file). */
77
+ export function resolveAppConfig(
78
+ program: CliProgram,
79
+ fileData: AppConfigFileData,
80
+ hostEnv?: Record<string, string | undefined>,
81
+ ): ResolvedConfig {
60
82
  const appConfig = program.appConfig;
61
83
  if (!appConfig) {
62
84
  return {};
63
85
  }
64
86
  const out: ResolvedConfig = {};
65
87
  for (const [key, entry] of Object.entries(appConfig.entries)) {
66
- const value = resolveConfigKey(program, key, entry, fileData);
88
+ const value = resolveConfigKey(program, key, entry, fileData, hostEnv);
67
89
  if (value !== undefined) {
68
90
  out[key] = value;
69
91
  }
@@ -71,21 +93,66 @@ export function resolveAppConfig(program: CliProgram, fileData: AppConfigFileDat
71
93
  return out;
72
94
  }
73
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
+
74
128
  function resolveConfigKey(
75
129
  program: CliProgram,
76
130
  key: string,
77
131
  entry: CliAppConfigEntry,
78
132
  fileData: AppConfigFileData,
133
+ hostEnv?: Record<string, string | undefined>,
79
134
  ): unknown {
80
- if (entry.env) {
81
- const fromEnv = envOverrideValue(entry.env);
82
- if (fromEnv !== undefined) {
83
- return coerceEnvValue(program, key, fromEnv);
84
- }
135
+ const fromEnv = tryEnvOverride(program, key, entry, hostEnv);
136
+ if (fromEnv !== undefined) {
137
+ return fromEnv;
85
138
  }
86
139
  if (key in fileData && isPresent(fileData[key])) {
87
140
  return fileData[key];
88
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
+ }
89
156
  const def = schemaDefaultForKey(program, key);
90
157
  if (def !== undefined) {
91
158
  return def;
@@ -93,8 +160,12 @@ function resolveConfigKey(
93
160
  return undefined;
94
161
  }
95
162
 
96
- /** Write mapped config values to process.env for subprocess inheritance. */
97
- export function exportConfigToEnv(program: CliProgram, resolved: ResolvedConfig): void {
163
+ /** Write mapped config values to process.env for subprocess inheritance (never overwrites host env). */
164
+ export function exportConfigToEnv(
165
+ program: CliProgram,
166
+ resolved: ResolvedConfig,
167
+ hostEnv?: Record<string, string | undefined>,
168
+ ): void {
98
169
  const appConfig = program.appConfig;
99
170
  if (!appConfig) {
100
171
  return;
@@ -103,6 +174,14 @@ export function exportConfigToEnv(program: CliProgram, resolved: ResolvedConfig)
103
174
  if (!entry.env) {
104
175
  continue;
105
176
  }
177
+ const captured = hostEnv?.[entry.env];
178
+ if (captured !== undefined && captured.length > 0) {
179
+ continue;
180
+ }
181
+ const existing = process.env[entry.env];
182
+ if (existing !== undefined && existing.length > 0) {
183
+ continue;
184
+ }
106
185
  const value = resolved[key];
107
186
  if (!isPresent(value)) {
108
187
  continue;