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
@@ -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
+ }
@@ -1,8 +1,10 @@
1
1
  /*
2
- Bootstrap: load config, validate, resolve, export, TTY prompts, install --configure.
2
+ Loads the app config file, runs setup prompts when needed, and checks that required
3
+ settings are present before the CLI or MCP server handles a request.
3
4
  */
4
5
 
5
- import { existsSync, readSync } from "node:fs";
6
+ import { readSync } from "node:fs";
7
+ import { readPromptLine as readStdinLine } from "../prompt.ts";
6
8
  import type { CliAppConfigEntry, CliProgram } from "../types.ts";
7
9
  import {
8
10
  configEntryRequired,
@@ -11,6 +13,7 @@ import {
11
13
  jsonSchemaRequiredKeys,
12
14
  } from "./entry.ts";
13
15
  import {
16
+ appConfigInstalled,
14
17
  displayAppConfigPath,
15
18
  readAppConfigFile,
16
19
  readAppConfigFileRaw,
@@ -19,6 +22,7 @@ import {
19
22
  } from "./file.ts";
20
23
  import type { ResolvedConfig } from "./resolve.ts";
21
24
  import {
25
+ captureMappedHostEnv,
22
26
  exportConfigToEnv,
23
27
  formatMissingConfigMessage,
24
28
  missingRequiredConfig,
@@ -27,87 +31,169 @@ import {
27
31
  } from "./resolve.ts";
28
32
  import { effectiveJsonSchema } from "./schema.ts";
29
33
 
34
+ export { displayAppConfigPath } from "./file.ts";
35
+
36
+ /** Tells ensureAppConfig whether to prompt the user and how strictly to require settings. */
30
37
  export interface EnsureAppConfigOpts {
38
+ /** In a terminal, ask for any required settings that are still empty (or run the full configure wizard). */
31
39
  interactive: boolean;
40
+ /** Stop the program with an error if required settings are still missing after loading. */
32
41
  exitOnMissing: boolean;
42
+ /** With `interactive`, walk through every setting instead of only the missing required ones. */
33
43
  configure?: boolean;
34
44
  }
35
45
 
46
+ /** Config as read from disk, plus the final values after env vars and defaults are applied. */
36
47
  export interface ConfigBootstrapResult {
48
+ /** Key/value pairs stored in the config file. */
37
49
  fileData: Record<string, unknown>;
50
+ /** Effective values the app will use (file, defaults, and shell env combined). */
38
51
  resolved: ResolvedConfig;
39
52
  }
40
53
 
41
- /** Load, validate, resolve, and export config. */
54
+ /** Read the config file, merge env overrides, and export mapped values into `process.env`. */
42
55
  export function bootstrapAppConfig(
43
56
  program: CliProgram,
44
57
  opts: { validateFile: boolean },
45
58
  ): ConfigBootstrapResult {
46
- const path = resolveAppConfigPath(program);
47
- const fileData = opts.validateFile ? readAppConfigFile(program) : readAppConfigFileRaw(path);
48
- const resolved = resolveAppConfig(program, fileData);
49
- exportConfigToEnv(program, resolved);
59
+ const fileData = opts.validateFile
60
+ ? readAppConfigFile(program)
61
+ : readAppConfigFileRaw(resolveAppConfigPath(program));
62
+ const hostEnv = captureMappedHostEnv(program);
63
+ const resolved = resolveAppConfig(program, fileData, hostEnv);
64
+ exportConfigToEnv(program, resolved, hostEnv);
50
65
  return { fileData, resolved };
51
66
  }
52
67
 
53
- function readPromptLine(mask: boolean): string {
54
- if (!mask) {
55
- const buf = Buffer.alloc(4096);
56
- const n = readSync(0, buf, { length: 4096 });
57
- return buf.toString("utf8", 0, n).replace(/\r?\n$/, "");
58
- }
59
- let result = "";
60
- const buf = Buffer.alloc(1);
61
- while (true) {
62
- const n = readSync(0, buf, { length: 1 });
63
- if (n <= 0) {
64
- break;
65
- }
66
- const byte = buf[0];
67
- if (byte === undefined) {
68
- continue;
68
+ /** Read a line from the terminal without showing what the user types (for tokens and passwords). */
69
+ function readSensitiveLine(): string {
70
+ const stdin = process.stdin;
71
+ const canRaw = stdin.isTTY && typeof stdin.setRawMode === "function";
72
+ const wasRaw = canRaw && stdin.isRaw;
73
+ if (canRaw) {
74
+ try {
75
+ stdin.setRawMode(true);
76
+ } catch {
77
+ // Best-effort: read still works if raw mode is unavailable.
69
78
  }
70
- if (byte === 10 || byte === 13) {
71
- break;
79
+ }
80
+ try {
81
+ let result = "";
82
+ const buf = Buffer.alloc(1);
83
+ while (true) {
84
+ const n = readSync(0, buf, { length: 1 });
85
+ if (n <= 0) {
86
+ break;
87
+ }
88
+ const byte = buf[0];
89
+ if (byte === 3) {
90
+ // Raw mode delivers Ctrl+C as ETX instead of SIGINT.
91
+ process.stderr.write("\n");
92
+ process.exit(130);
93
+ }
94
+ if (byte === 4) {
95
+ break;
96
+ }
97
+ if (byte === 10 || byte === 13) {
98
+ break;
99
+ }
100
+ if (byte === 127 || byte === 8) {
101
+ if (result.length > 0) {
102
+ result = result.slice(0, -1);
103
+ process.stderr.write("\b \b");
104
+ }
105
+ continue;
106
+ }
107
+ result += String.fromCharCode(byte);
108
+ process.stderr.write("*");
72
109
  }
73
- if (byte === 127 || byte === 8) {
74
- result = result.slice(0, -1);
75
- continue;
110
+ process.stderr.write("\n");
111
+ return result;
112
+ } finally {
113
+ if (canRaw) {
114
+ try {
115
+ stdin.setRawMode(!!wasRaw);
116
+ } catch {
117
+ // Ignore restore failures.
118
+ }
76
119
  }
77
- result += String.fromCharCode(byte);
78
- process.stderr.write("*");
79
120
  }
80
- process.stderr.write("\n");
81
- return result;
82
121
  }
83
122
 
123
+ /** Read one line of user input; sensitive settings use a hidden prompt. */
124
+ function readPromptLine(mask: boolean): string {
125
+ if (mask) {
126
+ return readSensitiveLine();
127
+ }
128
+ return readStdinLine();
129
+ }
130
+
131
+ /** Whether this setting already has a non-empty value in the user's shell environment. */
132
+ function resolvedFromEnv(
133
+ entry: CliAppConfigEntry,
134
+ hostEnv: Record<string, string | undefined>,
135
+ ): boolean {
136
+ if (!entry.env) {
137
+ return false;
138
+ }
139
+ const val = hostEnv[entry.env];
140
+ return val !== undefined && val.length > 0;
141
+ }
142
+
143
+ /** Ask the user for one setting and return what they chose (or nothing if they skipped it). */
84
144
  function promptConfigKey(
85
145
  key: string,
86
146
  entry: CliAppConfigEntry,
87
147
  current: unknown,
88
148
  configure: boolean,
89
149
  jsonSchemaRequired: Set<string> | undefined,
90
- ): unknown {
91
- const title = entry.title ?? defaultConfigEntryTitle(key);
150
+ hostEnv: Record<string, string | undefined>,
151
+ ): { value: unknown; userTyped: boolean } {
152
+ const baseTitle = entry.title ?? defaultConfigEntryTitle(key);
153
+ const titleWithEnv = entry.env ? `${baseTitle} (${entry.env})` : baseTitle;
92
154
  const required = configEntryRequired(key, entry, jsonSchemaRequired);
93
- const heading = required || !configure ? title : `${title} (optional)`;
155
+ const heading = required || !configure ? titleWithEnv : `${titleWithEnv} (optional)`;
94
156
  process.stderr.write(`${heading}\n`);
95
157
  process.stderr.write(` ${entry.description}\n`);
96
158
  const hasCurrent = current !== undefined && current !== null && String(current).length > 0;
159
+ const sensitive = configEntrySensitive(key, entry);
97
160
  if (hasCurrent) {
98
- const sensitive = configEntrySensitive(key, entry);
99
- process.stderr.write(`Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
100
- process.stderr.write("Value (Enter to keep): ");
161
+ process.stderr.write(` Current: ${sensitive ? "REDACTED" : stringifyConfigValue(current)}\n`);
162
+ const acceptPrompt = resolvedFromEnv(entry, hostEnv)
163
+ ? ` Value (Enter to copy from env): `
164
+ : ` Value (Enter to keep): `;
165
+ process.stderr.write(acceptPrompt);
101
166
  } else {
102
- process.stderr.write("Value: ");
167
+ process.stderr.write(` Value: `);
103
168
  }
104
- const input = readPromptLine(hasCurrent && configEntrySensitive(key, entry));
169
+ const input = readPromptLine(sensitive);
105
170
  if (input.length === 0 && hasCurrent) {
106
- return current;
171
+ return { value: current, userTyped: false };
107
172
  }
108
- return input.length > 0 ? input : undefined;
173
+ if (input.length > 0) {
174
+ return { value: input, userTyped: true };
175
+ }
176
+ return { value: undefined, userTyped: false };
177
+ }
178
+
179
+ /** Options for the interactive `install --configure` wizard. */
180
+ export interface RunInstallConfigureOpts {
181
+ /** Where the wizard was started from (standalone command vs right after install). */
182
+ context?: "standalone" | "after-install";
183
+ }
184
+
185
+ /** Print the "Configuration Setup" heading before the configure prompts. */
186
+ function writeConfigureSetupHeading(): void {
187
+ process.stderr.write("\nConfiguration Setup\n\n");
188
+ }
189
+
190
+ /** Whether this app has any settings worth prompting during configure. */
191
+ function shouldShowConfigureSetupHeading(program: CliProgram): boolean {
192
+ if (!program.appConfig) return false;
193
+ return Object.keys(program.appConfig.entries).length > 0;
109
194
  }
110
195
 
196
+ /** Ask the user for each required setting that is still empty; returns updates to save to the config file. */
111
197
  function promptMissingRequired(program: CliProgram): Record<string, unknown> {
112
198
  const appConfig = program.appConfig;
113
199
  const updates: Record<string, unknown> = {};
@@ -116,7 +202,9 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
116
202
  }
117
203
  const jsonSchema = effectiveJsonSchema(program);
118
204
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
205
+ const hostEnv = captureMappedHostEnv(program);
119
206
  const { resolved } = bootstrapAppConfig(program, { validateFile: false });
207
+ let headingWritten = false;
120
208
  for (const [key, entry] of Object.entries(appConfig.entries)) {
121
209
  if (!configEntryRequired(key, entry, fromSchema)) {
122
210
  continue;
@@ -125,7 +213,11 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
125
213
  if (current !== undefined && current !== null && String(current).length > 0) {
126
214
  continue;
127
215
  }
128
- const value = promptConfigKey(key, entry, undefined, false, fromSchema);
216
+ if (!headingWritten) {
217
+ writeConfigureSetupHeading();
218
+ headingWritten = true;
219
+ }
220
+ const { value, userTyped } = promptConfigKey(key, entry, undefined, false, fromSchema, hostEnv);
129
221
  if (value !== undefined && String(value).length > 0) {
130
222
  updates[key] = value;
131
223
  }
@@ -133,8 +225,11 @@ function promptMissingRequired(program: CliProgram): Record<string, unknown> {
133
225
  return updates;
134
226
  }
135
227
 
136
- /** Interactive `install --configure`. */
137
- export function runInstallConfigure(program: CliProgram): { path: string; changed: boolean } {
228
+ /** Run the full interactive configure wizard (`install --configure`). */
229
+ export function runInstallConfigure(
230
+ program: CliProgram,
231
+ _opts: RunInstallConfigureOpts = {},
232
+ ): { path: string; changed: boolean } {
138
233
  if (!program.appConfig) {
139
234
  throw new Error("install --configure requires program.appConfig on the program root.");
140
235
  }
@@ -151,17 +246,30 @@ export function runInstallConfigure(program: CliProgram): { path: string; change
151
246
 
152
247
  const path = resolveAppConfigPath(program);
153
248
  const existing = readAppConfigFileRaw(path);
249
+ const hostEnv = captureMappedHostEnv(program);
154
250
  const jsonSchema = effectiveJsonSchema(program);
155
251
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
156
- const { resolved } = bootstrapAppConfig(program, { validateFile: false });
252
+ const resolved = resolveAppConfig(program, existing, hostEnv);
157
253
  const next: Record<string, unknown> = { ...existing };
158
254
  let changed = false;
159
255
 
256
+ if (shouldShowConfigureSetupHeading(program)) {
257
+ writeConfigureSetupHeading();
258
+ }
259
+
160
260
  for (const [key, entry] of Object.entries(program.appConfig.entries)) {
161
- const current = resolved[key];
162
261
  const before = next[key];
163
- const value = promptConfigKey(key, entry, current, true, fromSchema);
262
+ const current = resolved[key];
263
+ const { value, userTyped } = promptConfigKey(key, entry, current, true, fromSchema, hostEnv);
164
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
+ }
165
273
  if (JSON.stringify(value) !== JSON.stringify(before)) {
166
274
  changed = true;
167
275
  }
@@ -171,14 +279,14 @@ export function runInstallConfigure(program: CliProgram): { path: string; change
171
279
 
172
280
  if (changed) {
173
281
  writeAppConfigFile(program, next);
174
- const updated = resolveAppConfig(program, next);
175
- exportConfigToEnv(program, updated);
282
+ const updated = resolveAppConfig(program, next, hostEnv);
283
+ exportConfigToEnv(program, updated, hostEnv);
176
284
  return { path, changed: true };
177
285
  }
178
286
  return { path, changed: false };
179
287
  }
180
288
 
181
- /** Config status for install --status (values never included). */
289
+ /** Summary for `install --status`: config path, whether the file exists, and which required settings are set (never their values). */
182
290
  export function appConfigStatus(program: CliProgram):
183
291
  | {
184
292
  path: string;
@@ -189,15 +297,16 @@ export function appConfigStatus(program: CliProgram):
189
297
  if (!program.appConfig) {
190
298
  return undefined;
191
299
  }
192
- const path = resolveAppConfigPath(program);
300
+ const path = displayAppConfigPath(program);
193
301
  let fileData: Record<string, unknown> = {};
194
302
  try {
195
303
  fileData = readAppConfigFile(program);
196
304
  } catch {
197
- fileData = readAppConfigFileRaw(path);
305
+ fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
198
306
  }
199
- const resolved = resolveAppConfig(program, fileData);
200
- exportConfigToEnv(program, resolved);
307
+ const hostEnv = captureMappedHostEnv(program);
308
+ const resolved = resolveAppConfig(program, fileData, hostEnv);
309
+ exportConfigToEnv(program, resolved, hostEnv);
201
310
  const jsonSchema = effectiveJsonSchema(program);
202
311
  const fromSchema = jsonSchema ? jsonSchemaRequiredKeys(jsonSchema) : undefined;
203
312
  const required = Object.entries(program.appConfig.entries)
@@ -207,10 +316,10 @@ export function appConfigStatus(program: CliProgram):
207
316
  set:
208
317
  resolved[key] !== undefined && resolved[key] !== null && String(resolved[key]).length > 0,
209
318
  }));
210
- return { path, exists: existsSync(path), required };
319
+ return { path, exists: appConfigInstalled(program), required };
211
320
  }
212
321
 
213
- /** Loads config, optionally prompts, and enforces required keys. */
322
+ /** Load config at startup, optionally prompt the user, and fail if required settings are still missing. */
214
323
  export function ensureAppConfig(
215
324
  program: CliProgram,
216
325
  opts: EnsureAppConfigOpts,
@@ -228,15 +337,16 @@ export function ensureAppConfig(
228
337
  process.exit(1);
229
338
  }
230
339
 
231
- let resolved = resolveAppConfig(program, fileData);
232
- exportConfigToEnv(program, resolved);
340
+ const hostEnv = captureMappedHostEnv(program);
341
+ let resolved = resolveAppConfig(program, fileData, hostEnv);
342
+ exportConfigToEnv(program, resolved, hostEnv);
233
343
 
234
344
  if (opts.interactive && process.stdin.isTTY) {
235
345
  if (opts.configure) {
236
- runInstallConfigure(program);
346
+ runInstallConfigure(program, { context: "standalone" });
237
347
  fileData = readAppConfigFileRaw(resolveAppConfigPath(program));
238
- resolved = resolveAppConfig(program, fileData);
239
- exportConfigToEnv(program, resolved);
348
+ resolved = resolveAppConfig(program, fileData, hostEnv);
349
+ exportConfigToEnv(program, resolved, hostEnv);
240
350
  return { fileData, resolved };
241
351
  }
242
352
  const updates = promptMissingRequired(program);
@@ -244,8 +354,8 @@ export function ensureAppConfig(
244
354
  const merged = { ...fileData, ...updates };
245
355
  writeAppConfigFile(program, merged);
246
356
  fileData = merged;
247
- resolved = resolveAppConfig(program, fileData);
248
- exportConfigToEnv(program, resolved);
357
+ resolved = resolveAppConfig(program, fileData, hostEnv);
358
+ exportConfigToEnv(program, resolved, hostEnv);
249
359
  }
250
360
  }
251
361
 
@@ -261,5 +371,3 @@ export function ensureAppConfig(
261
371
 
262
372
  return { fileData, resolved };
263
373
  }
264
-
265
- export { displayAppConfigPath };
@@ -4,33 +4,31 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import type { CliProgram } from "../types.ts";
6
6
  import { createAppConfigSnapshot } from "./context.ts";
7
- import { resolveAppConfigDir } from "./file.ts";
7
+ import { resolveAppConfigDir, resolveAppConfigPath } from "./file.ts";
8
8
  import { resolveAppConfig } from "./resolve.ts";
9
9
 
10
- function configProgram(configPath: string): CliProgram {
11
- return {
12
- key: "ctx-test",
13
- version: "1.0.0",
14
- description: "Context test.",
15
- appConfig: {
16
- path: configPath,
17
- entries: {
18
- apiToken: { description: "Token.", env: "API_TOKEN" },
19
- note: { description: "Note.", required: false },
20
- },
10
+ const program: CliProgram = {
11
+ key: "ctx-test",
12
+ version: "1.0.0",
13
+ description: "Context test.",
14
+ appConfig: {
15
+ entries: {
16
+ apiToken: { description: "Token.", env: "API_TOKEN" },
17
+ note: { description: "Note.", required: false },
21
18
  },
22
- handler: () => {},
23
- };
24
- }
19
+ },
20
+ handler: () => {},
21
+ };
25
22
 
26
23
  describe("config/context", () => {
27
24
  test("AppConfigSnapshot get, require, read, set", () => {
28
25
  const dir = mkdtempSync(join(tmpdir(), "ctx-test-"));
29
- const path = join(dir, "config");
30
- const program = configProgram(path);
26
+ const prevHome = process.env.HOME;
27
+ process.env.HOME = dir;
31
28
  const prevToken = process.env.API_TOKEN;
32
29
  delete process.env.API_TOKEN;
33
30
  try {
31
+ const path = resolveAppConfigPath(program);
34
32
  const fileData = { apiToken: "tok", note: "hello" };
35
33
  const resolved = resolveAppConfig(program, fileData);
36
34
  const ctx = createAppConfigSnapshot(program, fileData, resolved);
@@ -44,6 +42,8 @@ describe("config/context", () => {
44
42
  expect(ctx.get("note")).toBe("updated");
45
43
  expect(ctx.read().note).toBe("updated");
46
44
  } finally {
45
+ if (prevHome === undefined) delete process.env.HOME;
46
+ else process.env.HOME = prevHome;
47
47
  if (prevToken === undefined) delete process.env.API_TOKEN;
48
48
  else process.env.API_TOKEN = prevToken;
49
49
  rmSync(dir, { recursive: true, force: true });
@@ -51,39 +51,25 @@ describe("config/context", () => {
51
51
  });
52
52
 
53
53
  test("EmptyAppConfigSnapshot when program.appConfig unset", () => {
54
- const program: CliProgram = {
54
+ const programWithoutConfig: CliProgram = {
55
55
  key: "x",
56
56
  version: "1.0.0",
57
57
  description: "No config.",
58
58
  handler: () => {},
59
59
  };
60
- const empty = createAppConfigSnapshot(program, {}, {});
60
+ const empty = createAppConfigSnapshot(programWithoutConfig, {}, {});
61
61
  expect(empty.get("any")).toBeUndefined();
62
62
  expect(() => empty.set("any", "v")).toThrow(/program.appConfig is not set/);
63
63
  expect(empty.path).toContain("x");
64
- expect(empty.path.endsWith("/config") || empty.path.endsWith("\\config")).toBe(true);
64
+ expect(empty.path.endsWith("/config.json") || empty.path.endsWith("\\config.json")).toBe(true);
65
65
  expect(empty.dir).toBe(dirname(empty.path));
66
66
  });
67
67
 
68
- test("AppConfigSnapshot path uses OS default when program.appConfig.path omitted", () => {
69
- const program: CliProgram = {
70
- key: "ctx-test",
71
- version: "1.0.0",
72
- description: "Context test.",
73
- appConfig: {
74
- entries: { note: { description: "Note." } },
75
- },
76
- handler: () => {},
77
- };
68
+ test("AppConfigSnapshot path uses OS default from program key", () => {
78
69
  const ctx = createAppConfigSnapshot(program, {}, {});
79
70
  expect(ctx.path).toContain("ctx_test");
80
- expect(ctx.path.endsWith("/config") || ctx.path.endsWith("\\config")).toBe(true);
71
+ expect(ctx.path.endsWith("/config.json") || ctx.path.endsWith("\\config.json")).toBe(true);
81
72
  expect(ctx.dir).toBe(resolveAppConfigDir(program));
82
73
  expect(ctx.dir).toBe(dirname(ctx.path));
83
74
  });
84
-
85
- test("resolveAppConfigDir honors custom program.appConfig.path", () => {
86
- const program = configProgram("/tmp/custom/settings.json");
87
- expect(resolveAppConfigDir(program)).toBe("/tmp/custom");
88
- });
89
75
  });
@@ -5,7 +5,7 @@ Handler-facing resolved app config snapshot (ctx.appConfig).
5
5
  import type { CliProgram } from "../types.ts";
6
6
  import { resolveAppConfigDir, resolveAppConfigPath, writeAppConfigFile } from "./file.ts";
7
7
  import type { ResolvedConfig } from "./resolve.ts";
8
- import { exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
8
+ import { captureMappedHostEnv, exportConfigToEnv, resolveAppConfig } from "./resolve.ts";
9
9
 
10
10
  /** Empty snapshot when program.appConfig is not set. */
11
11
  export class EmptyAppConfigSnapshot {
@@ -71,18 +71,19 @@ export class AppConfigSnapshot {
71
71
 
72
72
  set(key: string, value: unknown): void {
73
73
  this.assertEntryKey(key);
74
+ const hostEnv = captureMappedHostEnv(this.program);
74
75
  const next = { ...this.fileData, [key]: value };
75
76
  writeAppConfigFile(this.program, next);
76
77
  this.fileData = next;
77
- this.snapshot = resolveAppConfig(this.program, next);
78
- exportConfigToEnv(this.program, this.snapshot);
78
+ this.snapshot = resolveAppConfig(this.program, next, hostEnv);
79
+ exportConfigToEnv(this.program, this.snapshot, hostEnv);
79
80
  }
80
81
 
81
82
  read(): ResolvedConfig {
82
83
  return { ...this.snapshot };
83
84
  }
84
85
 
85
- /** Resolved absolute path to the app JSON config file (honors `program.appConfig.path` or OS default). */
86
+ /** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
86
87
  get path(): string {
87
88
  return resolveAppConfigPath(this.program);
88
89
  }