argsbarg 5.1.7 → 5.1.9

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 (92) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/docs/cli-program.md +3 -1
  3. package/docs/config-schema.md +5 -3
  4. package/docs/configure.md +13 -7
  5. package/docs/distribution-homebrew.md +3 -2
  6. package/examples/full-example/justfile +2 -5
  7. package/examples/full-example/scripts/with-dev-formula.ts +6 -1
  8. package/index.d.ts +10 -1
  9. package/package.json +1 -1
  10. package/src/builtins/builtins.test.ts +1 -3
  11. package/src/builtins/completion-bash.ts +6 -27
  12. package/src/builtins/completion-group.ts +1 -2
  13. package/src/builtins/completion-simulate-shared.ts +2 -10
  14. package/src/builtins/completion-zsh.ts +7 -33
  15. package/src/builtins/config.test.ts +28 -0
  16. package/src/builtins/config.ts +40 -10
  17. package/src/builtins/configure-copy.ts +3 -6
  18. package/src/builtins/dispatch.ts +2 -10
  19. package/src/builtins/export.ts +1 -3
  20. package/src/builtins/presentation.ts +2 -7
  21. package/src/builtins/registry.ts +1 -5
  22. package/src/cli-tool/create.test.ts +3 -6
  23. package/src/cli-tool/create.ts +5 -22
  24. package/src/cli-tool/post-create.ts +1 -3
  25. package/src/cli-tool/program.ts +1 -2
  26. package/src/cli-tool/run-create.ts +2 -8
  27. package/src/cli.ts +6 -27
  28. package/src/config/bindings.test.ts +72 -0
  29. package/src/config/bindings.ts +115 -0
  30. package/src/config/bootstrap.ts +80 -65
  31. package/src/config/context.test.ts +45 -0
  32. package/src/config/context.ts +74 -14
  33. package/src/config/entry.ts +1 -3
  34. package/src/config/file.test.ts +46 -3
  35. package/src/config/file.ts +59 -19
  36. package/src/config/resolve.ts +1 -4
  37. package/src/config/schema.ts +1 -3
  38. package/src/config/validate.test.ts +3 -11
  39. package/src/config/validate.ts +30 -30
  40. package/src/config.integration.test.ts +1 -5
  41. package/src/configure/configure.test.ts +19 -5
  42. package/src/configure/index.ts +17 -15
  43. package/src/docs/api-guide.ts +1 -6
  44. package/src/docs/builtin.ts +1 -5
  45. package/src/docs/docs.test.ts +2 -6
  46. package/src/docs/mcp-guide.ts +3 -13
  47. package/src/docs/mcp-resources.test.ts +1 -3
  48. package/src/docs/mcp-resources.ts +1 -6
  49. package/src/docs/save.ts +2 -10
  50. package/src/formats.test.ts +1 -7
  51. package/src/formats.ts +1 -5
  52. package/src/headless.test.ts +5 -17
  53. package/src/help.ts +9 -51
  54. package/src/hidden-mcpb.test.ts +3 -12
  55. package/src/hidden.ts +1 -3
  56. package/src/install/binary-placement.test.ts +2 -6
  57. package/src/install/binary-placement.ts +1 -4
  58. package/src/install/gh-release-update.ts +5 -22
  59. package/src/install/mcp-codex.ts +1 -2
  60. package/src/install/mcp-config.ts +2 -12
  61. package/src/install/mcp-opencode.test.ts +1 -3
  62. package/src/install/mcp-opencode.ts +3 -15
  63. package/src/install/plan.ts +2 -10
  64. package/src/install/status.ts +3 -5
  65. package/src/install/target-base.ts +2 -11
  66. package/src/install/target-detect.ts +1 -5
  67. package/src/install/target-effective.ts +3 -13
  68. package/src/install/target-mcp-cli.ts +4 -26
  69. package/src/install/target-mcp-json.ts +3 -14
  70. package/src/install/target-plan-build.ts +1 -5
  71. package/src/install/target-registry.ts +13 -21
  72. package/src/install/target-scope.ts +8 -25
  73. package/src/install/target-types.ts +1 -6
  74. package/src/install/targets/app.ts +2 -2
  75. package/src/install/targets/opencode-mcp.ts +1 -6
  76. package/src/install/targets.test.ts +3 -10
  77. package/src/install/uninstall.ts +2 -9
  78. package/src/invoke.test.ts +1 -3
  79. package/src/mcp/bundle.ts +4 -19
  80. package/src/mcp/claude.test.ts +3 -3
  81. package/src/mcp/claude.ts +4 -17
  82. package/src/mcp/env.ts +1 -3
  83. package/src/mcp/server.ts +3 -12
  84. package/src/mcp/tools.ts +2 -11
  85. package/src/mcp.integration.test.ts +4 -10
  86. package/src/parse.test.ts +9 -34
  87. package/src/parse.ts +10 -44
  88. package/src/schema.ts +2 -10
  89. package/src/skill/hint.ts +1 -5
  90. package/src/skill/install.ts +1 -5
  91. package/src/test-fixtures.ts +1 -3
  92. package/src/validate.ts +29 -90
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.9] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **`configure --sync` config bootstrap** — creates `~/.local/lib/<key>/config.json` as `{}` when missing (all apps; Homebrew `post_install`).
15
+ - **`_bindings` metadata** — per-key intent (`env` | `file` | `skip`) in config file; wizard persists env/skip choices; `configure set --from-env`.
16
+ - **`ctx.appConfig` unsafe I/O** — `getUnsafe`, `setUnsafe`, `readUnsafe` for raw file access (works without `program.appConfig`).
17
+
18
+ ### Changed
19
+
20
+ - **App config detection** — `appConfigInstalled` / `--status` use file existence only (not directory-only).
21
+ - **Configure wizard** — skips addressed keys; Enter at env prompt persists `_bindings`; accurate write messaging.
22
+ - **`configure --status`** — binding hints on required keys (`set (env)`, etc.).
23
+ - **Partial config validation** — single-key / bindings-only writes skip required-property checks.
24
+ - biome lineLength=120
25
+
26
+ ## [5.1.8] - 2026-07-05
27
+
28
+
10
29
  ## [5.1.7] - 2026-07-05
11
30
 
12
31
  ### Changed
@@ -627,7 +646,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
627
646
  - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
628
647
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
629
648
 
630
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.7...HEAD
649
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.9...HEAD
650
+ [5.1.9]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.9
651
+ [5.1.8]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.8
631
652
  [5.1.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.7
632
653
  [5.1.6]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.6
633
654
  [5.1.5]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.5
@@ -432,7 +432,9 @@ await cli.run();
432
432
 
433
433
  See [config-schema.md](config-schema.md) for codegen, [configure.md](configure.md) (`configure.targets`), and [mcp.md](mcp.md).
434
434
 
435
- **Handler access (`ctx.appConfig`):** `get`, `require`, `set`, `read`, `path`, `dir` — prefer over `process.env` in handlers; env export remains for subprocess inheritance. `path` is the resolved absolute config file path (`~/.local/lib/<key>/config`); `dir` is its parent directory.
435
+ **Handler access (`ctx.appConfig`):** `get`, `require`, `set`, `read` (schema-aware, resolved values); `getUnsafe`, `setUnsafe`, `readUnsafe` (raw file, works without `program.appConfig`); `path`, `dir`. Prefer `get`/`set` when `appConfig` is declared. Env export remains for subprocess inheritance. `path` is `~/.local/lib/<key>/config.json`; `dir` is its parent.
436
+
437
+ **`_bindings`:** reserved metadata for per-key intent (`env`, `file`, `skip`). Set by the wizard, `configure set --from-env`, or `ctx.appConfig.set`. Does not change resolve order (env still wins when set).
436
438
 
437
439
  ## Reserved names
438
440
 
@@ -42,11 +42,13 @@ await cli.run();
42
42
  | Interactive `configure` / `--status` | Auto-runs config wizard when `entries` is non-empty; `--status` for read-only inventory |
43
43
  | Built-in `configure get` / `configure set` | Read/write resolved values (opt-out via `commands: false`) |
44
44
  | MCP bundle / Claude plugin | `userConfig` for entries with `env` set |
45
- | `ctx.appConfig` in handlers | `get`, `require`, `set`, `read`, `path`, `dir` — prefer over `process.env` |
45
+ | `ctx.appConfig` in handlers | `get`, `require`, `set`, `read`, `getUnsafe`, `setUnsafe`, `readUnsafe`, `path`, `dir` — prefer `get`/`set` when `appConfig` is set |
46
46
 
47
- **Validation at runtime** — argsbarg validates the config file and `config set` / `ctx.appConfig.set` against the effective JSON Schema (block `jsonSchema` or synthesized all-string schema).
47
+ **Handler access** — with `program.appConfig`: `get` / `set` / `require` use schema validation and resolved values. `getUnsafe` / `setUnsafe` / `readUnsafe` read and write the raw file (for `_bindings` and ad-hoc keys). Without `program.appConfig`, only `path`, `dir`, and the `*Unsafe` methods work.
48
48
 
49
- **No public config I/O exports** — consumers use `program.appConfig` for authoring and `ctx.appConfig` in handlers.
49
+ **`_bindings`** — reserved top-level metadata: `{ "_bindings": { "apiToken": "env" } }`. Set via wizard (Enter to use env), `configure set --from-env`, or `ctx.appConfig.set` (marks `file`). Optional keys can be bound to `skip`.
50
+
51
+ **Validation at runtime** — argsbarg validates the config file and `configure set` / `ctx.appConfig.set` against the effective JSON Schema. Partial writes (bindings only, single-key updates) skip required-property checks.
50
52
 
51
53
  See [cli-program.md — Configuration](cli-program.md#configuration-programappconfig) for resolution order, bootstrap timing, and `configure get`/`set`.
52
54
 
package/docs/configure.md CHANGED
@@ -54,7 +54,7 @@ Dev flow matches release: formula `install` copies the binary and generates comp
54
54
 
55
55
  # Read or write app config (non-interactive; when program.appConfig is set)
56
56
  <key> configure get [key] [--json] [--pretty]
57
- <key> configure set <key> <value> [--json]
57
+ <key> configure set <key> <value> [--json] [--from-env]
58
58
  ```
59
59
 
60
60
  Non-interactive / CI: pass **`--yes`** (or **`--json`**, **`--sync`**, **`--remove-all`**, **`--remove-config`**) — see [Confirmation](#confirmation).
@@ -69,14 +69,14 @@ Non-interactive / CI: pass **`--yes`** (or **`--json`**, **`--sync`**, **`--remo
69
69
  | Claude skill | Y/n prompt | `~/.claude/skills/<dir>/` when `~/.claude` exists |
70
70
  | Codex / OpenCode / OpenClaw skills | Y/n prompt | Agent-specific dirs when available |
71
71
  | MCP config | Y/n prompt when `mcpServer.enabled` | Cursor, Claude Code/Desktop, OpenCode, Codex, OpenClaw, ChatGPT desktop |
72
- | App config | auto-runs wizard | Interactive wizard writes `~/.local/lib/<key>/config.json` (schema-aware: comma-separated or JSON for primitive arrays) |
72
+ | App config | auto-runs wizard | Interactive wizard may update `~/.local/lib/<key>/config.json` when values change; `--sync` bootstraps an empty file on install |
73
73
 
74
74
  ### Externally managed binary (Homebrew)
75
75
 
76
76
  When **`PATH`** resolves the program key to the **running executable** (e.g. after `brew install`):
77
77
 
78
78
  - **`configure --status`** shows `app: system (PATH)`
79
- - **`--sync`** refreshes skills and MCP only — not the binary or completions
79
+ - **`--sync`** refreshes skills and MCP; also creates `~/.local/lib/<key>/config.json` as `{}` when missing (all apps)
80
80
 
81
81
  MCP config uses the command name on **`PATH`**, not a Cellar path.
82
82
 
@@ -110,13 +110,19 @@ Artifact keys: `chatgptMcp`, `claudeCodeMcp`, `claudeDesktopMcp`, `claudeSkill`,
110
110
 
111
111
  ## App config (`program.appConfig`)
112
112
 
113
- When `program.appConfig` is set, ArgsBarg manages a flat JSON config file at `~/.local/lib/<sanitized-key>/config.json`.
113
+ Every app gets `~/.local/lib/<sanitized-key>/config.json` on first **`configure --sync`** (Homebrew `post_install`), even without `program.appConfig`.
114
+
115
+ When `program.appConfig` is set, ArgsBarg manages schema-driven values in that file.
114
116
 
115
117
  | Mode | Description |
116
118
  | --- | --- |
117
- | Interactive `configure` | Config wizard when you accept the configure target |
118
- | `--status` | Shows config path and which required keys are set or missing |
119
+ | `configure --sync` | Bootstraps `config.json` as `{}` when missing |
120
+ | Interactive `configure` | Config wizard when `entries` is non-empty; writes only when values or `_bindings` change |
121
+ | `--status` | Shows config path, required keys (`set` / `missing`), and binding hints (`env`, `file`, `skip`) |
119
122
  | `--remove-config --yes` | Removes the config directory |
123
+ | `configure set --from-env` | Bind a key to its mapped env var (stores `_bindings`, no literal secret) |
124
+
125
+ Per-key intent is stored under the reserved `_bindings` object (e.g. `"apiToken": "env"`). Env still wins at resolve time when set; bindings record user choice and suppress re-prompts.
120
126
 
121
127
  Export helpers from `argsbarg`: `resolveAppConfigPath`, `displayAppConfigPath`.
122
128
 
@@ -127,7 +133,7 @@ Export helpers from `argsbarg`: `resolveAppConfigPath`, `displayAppConfigPath`.
127
133
  | Flag | Description |
128
134
  | --- | --- |
129
135
  | `--status` | Read-only inventory |
130
- | `--sync` | Refresh installed agent artifacts (Homebrew `post_install`; greenfield → full sync plan) |
136
+ | `--sync` | Refresh installed agent artifacts; bootstrap `config.json` when missing (Homebrew `post_install`; greenfield → full sync plan) |
131
137
  | `--remove-all` | Remove all detected agent artifacts |
132
138
  | `--remove-config` | Remove app config directory only |
133
139
 
@@ -8,7 +8,8 @@ Argsbarg apps distribute the **binary and shell completions** through Homebrew,
8
8
  | --- | --- |
9
9
  | Binary + completions | Formula `install` block |
10
10
  | Skills + MCP | Formula `post_install` → `{key} configure --sync --yes` |
11
- | App config | User opt-in: `{key} configure` (interactive; not run from formula `post_install`) |
11
+ | App config file | Bootstrapped as `{}` on `post_install` via `--sync` (`~/.local/lib/<key>/config.json`) |
12
+ | App config values | User opt-in: `{key} configure` (interactive wizard when `program.appConfig` has entries) |
12
13
  | App config cleanup | Formula `uninstall` → `{key} configure --remove-config --yes` |
13
14
 
14
15
  **Only tap-from-repo** — in-repo `Formula/` or GitHub tap. Not Homebrew core.
@@ -89,7 +90,7 @@ Local dev formulae (`just install-local`) use a plain `file://` URL and do not n
89
90
 
90
91
  Completions require users to configure their shell per [Homebrew Shell Completion](https://docs.brew.sh/Shell-Completion).
91
92
 
92
- **Why configure is separate from `post_install`:** the wizard is interactive (TTY + prompts for secrets). Formula `post_install` runs non-interactively during `brew install` and in CI (`brew test`). Apps with `appConfig` print a one-line configure hint in formula `caveats` instead.
93
+ **Why the wizard is separate from `post_install`:** prompting for secrets requires a TTY. `post_install` only bootstraps an empty `config.json` and refreshes skills/MCP. Apps with `appConfig` print a one-line configure hint in formula `caveats` for the interactive wizard.
93
94
 
94
95
  **MCP hosts:** when `mcpServer.enabled` is true, add a caveats line that chat apps (Cursor, Claude Desktop, etc.) must be **restarted** after `brew install` / `brew upgrade` — `post_install` updates MCP config on disk, but hosts typically load it only at startup.
95
96
 
@@ -20,10 +20,8 @@ build:
20
20
  @rm -f .*.bun-build
21
21
 
22
22
  # Run schemagen, git diff check, typecheck, and format
23
- check: schemagen
23
+ check: schemagen format typecheck
24
24
  git diff --exit-code schemas/
25
- just typecheck
26
- just format
27
25
 
28
26
  # Run the CLI from source with optional args; restarts on file changes
29
27
  dev *ARGS:
@@ -38,7 +36,7 @@ alias fmt := format
38
36
 
39
37
  # Format and lint sources (auto-fix)
40
38
  format:
41
- bun run biome check ./src ./scripts --write
39
+ bun run biome check ./src ./scripts --write --unsafe
42
40
 
43
41
  # Alias for backward compatibility
44
42
  install: install-local
@@ -81,7 +79,6 @@ run *ARGS:
81
79
  # Generate JSON Schema artifacts from TypeScript types
82
80
  schemagen:
83
81
  bun run schemagen
84
- just format
85
82
 
86
83
  # Install bun/npm dependencies
87
84
  setup:
@@ -50,6 +50,11 @@ if (sep === -1 || sep === process.argv.length - 1) {
50
50
  }
51
51
 
52
52
  const cmd = process.argv.slice(sep + 1);
53
+ const executable = cmd[0];
54
+ if (!executable) {
55
+ process.stderr.write("Usage: bun scripts/with-dev-formula.ts -- <command...>\n");
56
+ process.exit(1);
57
+ }
53
58
  let restored = false;
54
59
 
55
60
  function restore(): void {
@@ -63,7 +68,7 @@ function restore(): void {
63
68
  try {
64
69
  backupReleaseFormula();
65
70
  writeDevFormula();
66
- const result = spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit" });
71
+ const result = spawnSync(executable, cmd.slice(1), { stdio: "inherit" });
67
72
  process.exitCode = result.status === null ? 1 : result.status;
68
73
  } finally {
69
74
  restore();
package/index.d.ts CHANGED
@@ -7,11 +7,15 @@ export declare function displayAppConfigPath(program: CliProgram): string;
7
7
  export type ResolvedConfig = Record<string, unknown>;
8
8
  declare class EmptyAppConfigSnapshot {
9
9
  private readonly program;
10
- constructor(program: CliProgram);
10
+ private fileData;
11
+ constructor(program: CliProgram, fileData?: Record<string, unknown>);
11
12
  get(_key: string): undefined;
12
13
  require(key: string): never;
13
14
  set(_key: string, _value: unknown): void;
14
15
  read(): ResolvedConfig;
16
+ readUnsafe(): Record<string, unknown>;
17
+ getUnsafe(key: string): unknown;
18
+ setUnsafe(key: string, value: unknown): void;
15
19
  /** Resolved absolute path to the app JSON config file (OS default from `program.key`). */
16
20
  get path(): string;
17
21
  /** Resolved absolute directory containing the config file. */
@@ -26,13 +30,18 @@ declare class AppConfigSnapshot {
26
30
  require(key: string): unknown;
27
31
  set(key: string, value: unknown): void;
28
32
  read(): ResolvedConfig;
33
+ readUnsafe(): Record<string, unknown>;
34
+ getUnsafe(key: string): unknown;
35
+ setUnsafe(key: string, value: unknown): void;
29
36
  /** Resolved absolute path to the app JSON config file (`~/.local/lib/<key>/config.json`). */
30
37
  get path(): string;
31
38
  /** Resolved absolute directory containing the config file. */
32
39
  get dir(): string;
33
40
  /** Replace snapshot after external bootstrap (internal). */
34
41
  refresh(fileData: Record<string, unknown>, resolved: ResolvedConfig): void;
42
+ private persistFileData;
35
43
  private assertEntryKey;
44
+ private assertUnsafeKey;
36
45
  }
37
46
  export type AnyAppConfigSnapshot = AppConfigSnapshot | EmptyAppConfigSnapshot;
38
47
  /** Coerced leaf inputs keyed by option and positional names. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "5.1.7",
3
+ "version": "5.1.9",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3"
@@ -52,9 +52,7 @@ describe("builtins help copy", () => {
52
52
 
53
53
  test("configure copy omits MCP when mcpServer unset", () => {
54
54
  const caps = resolveCapabilities(noMcp);
55
- expect(configureCommandDescription(noMcp, caps)).toBe(
56
- "Set up agent skills for this app (binary via Homebrew).",
57
- );
55
+ expect(configureCommandDescription(noMcp, caps)).toBe("Set up agent skills for this app (binary via Homebrew).");
58
56
  expect(configureCommandDescription(noMcp, caps)).not.toContain("MCP");
59
57
  expect(configureSyncOptionDescription(noMcp, caps)).not.toContain("MCP");
60
58
  const configure = cliBuiltinConfigureCommand(noMcp);
@@ -1,13 +1,7 @@
1
1
  import { CliOptionKind, type CliRouter } from "../types.ts";
2
2
  import { emitConsumeLong, emitConsumeShort, emitMatchChild } from "./completion-simulate-shared.ts";
3
3
  import { collectScopes, type ScopeRec } from "./scopes.ts";
4
- import {
5
- escShellSingleQuoted,
6
- identToken,
7
- kHelpLong,
8
- kHelpShort,
9
- mainName,
10
- } from "./shell-helpers.ts";
4
+ import { escShellSingleQuoted, identToken, kHelpLong, kHelpShort, mainName } from "./shell-helpers.ts";
11
5
 
12
6
  function emitSimulate(ident: string): string {
13
7
  let o = "_${ident}_nac_simulate() {\n".replace("${ident}", ident);
@@ -18,10 +12,7 @@ function emitSimulate(ident: string): string {
18
12
  o += " ((i++)); continue\n";
19
13
  o += " fi\n";
20
14
  o += " if [[ $w == --* ]]; then\n";
21
- o += ' steps=$(_${ident}_nac_consume_long "$sid" "$w" "${COMP_WORDS[i+1]}")\n'.replace(
22
- "${ident}",
23
- ident,
24
- );
15
+ o += ' steps=$(_${ident}_nac_consume_long "$sid" "$w" "${COMP_WORDS[i+1]}")\n'.replace("${ident}", ident);
25
16
  o += " case $steps in\n";
26
17
  o += " 0) break ;;\n";
27
18
  o += " 1) ((i++)) ;;\n";
@@ -54,20 +45,13 @@ function emitEnumReplyBash(ident: string, scopes: ScopeRec[]): string {
54
45
  o += ' local sid="$1" prev="$2" cur="$3"\n';
55
46
  o += " case $sid in\n";
56
47
  for (const [i, sc] of scopes.entries()) {
57
- const enumOpts = sc.opts.filter(
58
- (op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0,
59
- );
48
+ const enumOpts = sc.opts.filter((op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0);
60
49
  if (enumOpts.length === 0) continue;
61
50
  o += ` ${i})\n`;
62
51
  o += " case $prev in\n";
63
52
  for (const op of enumOpts) {
64
53
  const words = (op.choices ?? []).map((c) => escShellSingleQuoted(c)).join(" ");
65
- o +=
66
- " --" +
67
- op.name +
68
- ") COMPREPLY=( $(compgen -W '" +
69
- words +
70
- '\' -- "$cur") ); return 0 ;;\n';
54
+ o += ` --${op.name}) COMPREPLY=( $(compgen -W '${words}' -- "$cur") ); return 0 ;;\n`;
71
55
  }
72
56
  o += " esac\n";
73
57
  o += " ;;\n";
@@ -85,10 +69,7 @@ function emitMainBodyBash(schema: CliRouter, ident: string): string {
85
69
  o += ' local prev="${COMP_WORDS[COMP_CWORD-1]:-}"\n';
86
70
  o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
87
71
  o += " local sid=$REPLY_SID\n";
88
- o += ' if _${ident}_nac_enum_reply "$sid" "$prev" "$cur"; then return; fi\n'.replace(
89
- "${ident}",
90
- ident,
91
- );
72
+ o += ' if _${ident}_nac_enum_reply "$sid" "$prev" "$cur"; then return; fi\n'.replace("${ident}", ident);
92
73
  o += " if [[ $cur == -* ]]; then\n";
93
74
  o += ' local oname="A_${ident}_${sid}_opts"\n'.replace("${ident}", ident);
94
75
  o += " local -a optsarr\n";
@@ -111,9 +92,7 @@ function emitMainBodyBash(schema: CliRouter, ident: string): string {
111
92
  o += " fi\n";
112
93
  o += " fi\n";
113
94
  o += "}\n\n";
114
- o += "complete -F _${main} ${schema.key}\n"
115
- .replace("${main}", main)
116
- .replace("${schema.key}", schema.key);
95
+ o += "complete -F _${main} ${schema.key}\n".replace("${main}", main).replace("${schema.key}", schema.key);
117
96
  return o;
118
97
  }
119
98
 
@@ -45,7 +45,6 @@ export function cliBuiltinCompletionGroup(program: CliProgram): import("../types
45
45
  ],
46
46
  };
47
47
  router.notes =
48
- "Completions are installed by Homebrew during formula install.\n\n" +
49
- "See: https://docs.brew.sh/Shell-Completion";
48
+ "Completions are installed by Homebrew during formula install.\n\n" + "See: https://docs.brew.sh/Shell-Completion";
50
49
  return router;
51
50
  }
@@ -44,11 +44,7 @@ export function emitConsumeLong(ident: string, scopes: ScopeRec[]): string {
44
44
  }
45
45
 
46
46
  /** Emits `_<ident>_nac_consume_short`; dialect selects substring syntax. */
47
- export function emitConsumeShort(
48
- ident: string,
49
- scopes: ScopeRec[],
50
- dialect: CompletionShellDialect,
51
- ): string {
47
+ export function emitConsumeShort(ident: string, scopes: ScopeRec[], dialect: CompletionShellDialect): string {
52
48
  const firstChar = dialect === "bash" ? "ch=${rest:0:1}" : "ch=${rest[1,1]}";
53
49
  const restAdvance = dialect === "bash" ? "rest=${rest:1}" : "rest=${rest[2,-1]}";
54
50
 
@@ -93,11 +89,7 @@ export function emitConsumeShort(
93
89
  }
94
90
 
95
91
  /** Emits `_<ident>_nac_match_child` — identical for bash and zsh. */
96
- export function emitMatchChild(
97
- ident: string,
98
- scopes: ScopeRec[],
99
- pathIndex: Record<string, number>,
100
- ): string {
92
+ export function emitMatchChild(ident: string, scopes: ScopeRec[], pathIndex: Record<string, number>): string {
101
93
  let o = "_${ident}_nac_match_child() {\n".replace("${ident}", ident);
102
94
  o += ' local sid="$1" w="$2"\n';
103
95
  o += " case $sid in\n";
@@ -1,13 +1,7 @@
1
1
  import { CliOptionKind, type CliRouter } from "../types.ts";
2
2
  import { emitConsumeLong, emitConsumeShort, emitMatchChild } from "./completion-simulate-shared.ts";
3
3
  import { collectScopes, type ScopeRec } from "./scopes.ts";
4
- import {
5
- escShellSingleQuoted,
6
- identToken,
7
- kHelpLong,
8
- kHelpShort,
9
- mainName,
10
- } from "./shell-helpers.ts";
4
+ import { escShellSingleQuoted, identToken, kHelpLong, kHelpShort, mainName } from "./shell-helpers.ts";
11
5
 
12
6
  function emitScopeArraysZsh(ident: string, scopes: ScopeRec[]): string {
13
7
  let out = "";
@@ -25,19 +19,9 @@ function emitScopeArraysZsh(ident: string, scopes: ScopeRec[]): string {
25
19
  escShellSingleQuoted("Show help for this command.") +
26
20
  "'";
27
21
  for (const o of sc.opts) {
28
- out +=
29
- " '" +
30
- escShellSingleQuoted(`--${o.name}`) +
31
- ":" +
32
- escShellSingleQuoted(o.description) +
33
- "'";
22
+ out += ` '${escShellSingleQuoted(`--${o.name}`)}:${escShellSingleQuoted(o.description)}'`;
34
23
  if (o.shortName) {
35
- out +=
36
- " '" +
37
- escShellSingleQuoted(`-${o.shortName}`) +
38
- ":" +
39
- escShellSingleQuoted(o.description) +
40
- "'";
24
+ out += ` '${escShellSingleQuoted(`-${o.shortName}`)}:${escShellSingleQuoted(o.description)}'`;
41
25
  }
42
26
  }
43
27
  out += ")\n";
@@ -63,10 +47,7 @@ function emitSimulateZsh(ident: string): string {
63
47
  o += " ((i++)); continue\n";
64
48
  o += " fi\n";
65
49
  o += " if [[ $w == --* ]]; then\n";
66
- o += ' steps=$(_${ident}_nac_consume_long "$sid" "$w" "${words[i+1]}")\n'.replace(
67
- "${ident}",
68
- ident,
69
- );
50
+ o += ' steps=$(_${ident}_nac_consume_long "$sid" "$w" "${words[i+1]}")\n'.replace("${ident}", ident);
70
51
  o += " case $steps in\n";
71
52
  o += " 0) break ;;\n";
72
53
  o += " 1) ((i++)) ;;\n";
@@ -99,9 +80,7 @@ function emitEnumReplyZsh(ident: string, scopes: ScopeRec[]): string {
99
80
  o += " local sid=$1 prev=$2\n";
100
81
  o += " case $sid in\n";
101
82
  for (const [i, sc] of scopes.entries()) {
102
- const enumOpts = sc.opts.filter(
103
- (op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0,
104
- );
83
+ const enumOpts = sc.opts.filter((op) => op.kind === CliOptionKind.Enum && (op.choices?.length ?? 0) > 0);
105
84
  if (enumOpts.length === 0) continue;
106
85
  o += ` ${i})\n`;
107
86
  o += " case $prev in\n";
@@ -124,10 +103,7 @@ function emitMainBodyZsh(schema: CliRouter, ident: string): string {
124
103
  o += ' local curcontext="$curcontext" ret=1\n';
125
104
  o += " _${ident}_nac_simulate\n".replace("${ident}", ident);
126
105
  o += " local sid=$REPLY_SID\n";
127
- o += ' if _${ident}_nac_enum_reply "$sid" "$words[CURRENT-1]"; then return 0; fi\n'.replace(
128
- "${ident}",
129
- ident,
130
- );
106
+ o += ' if _${ident}_nac_enum_reply "$sid" "$words[CURRENT-1]"; then return 0; fi\n'.replace("${ident}", ident);
131
107
  o += " if [[ $PREFIX == -* ]]; then\n";
132
108
  o += " local -a optsarr\n";
133
109
  o += ' local oname="A_${ident}_${sid}_opts"\n'.replace("${ident}", ident);
@@ -149,9 +125,7 @@ function emitMainBodyZsh(schema: CliRouter, ident: string): string {
149
125
  o += " fi\n";
150
126
  o += " return ret\n";
151
127
  o += "}\n\n";
152
- o += "compdef _${main} ${schema.key}\n"
153
- .replace("${main}", main)
154
- .replace("${schema.key}", schema.key);
128
+ o += "compdef _${main} ${schema.key}\n".replace("${main}", main).replace("${schema.key}", schema.key);
155
129
  return o;
156
130
  }
157
131
 
@@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test";
6
6
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
+ import { readBindings } from "../config/bindings.ts";
9
10
  import { resolveAppConfigPath, writeAppConfigFile } from "../config/file.ts";
10
11
  import { Cli, type CliProgram } from "../index.ts";
11
12
 
@@ -93,4 +94,31 @@ describe("builtins/config", () => {
93
94
  rmSync(dir, { recursive: true, force: true });
94
95
  }
95
96
  });
97
+
98
+ test("config set --from-env stores binding without literal", async () => {
99
+ const dir = mkdtempSync(join(tmpdir(), "cfg-from-env-"));
100
+ const prevHome = process.env.HOME;
101
+ process.env.HOME = dir;
102
+ const prev = process.env.API_TOKEN;
103
+ process.env.API_TOKEN = "from-env";
104
+ try {
105
+ const program = configFixture();
106
+ writeAppConfigFile(program, { apiToken: "seed" }, { partial: true });
107
+ const result = await new Cli(program).invoke(["configure", "set", "apiToken", "--from-env"]);
108
+ expect(result.exitCode).toBe(0);
109
+ const raw = await new Cli(program).invoke(["configure", "get", "apiToken"]);
110
+ expect(raw.stdout.trim()).toBe("REDACTED");
111
+ const configPath = resolveAppConfigPath(program);
112
+ const { readFileSync } = await import("node:fs");
113
+ const onDisk = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
114
+ expect(onDisk.apiToken).toBeUndefined();
115
+ expect(readBindings(onDisk).apiToken).toBe("env");
116
+ } finally {
117
+ if (prevHome === undefined) delete process.env.HOME;
118
+ else process.env.HOME = prevHome;
119
+ if (prev === undefined) delete process.env.API_TOKEN;
120
+ else process.env.API_TOKEN = prev;
121
+ rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ });
96
124
  });
@@ -2,6 +2,7 @@
2
2
  Built-in `configure get` / `configure set` subcommands.
3
3
  */
4
4
 
5
+ import { clearFileValue, setBinding } from "../config/bindings.ts";
5
6
  import { bootstrapAppConfig } from "../config/bootstrap.ts";
6
7
  import {
7
8
  configCommandsEnabled,
@@ -28,12 +29,7 @@ const PRETTY_OPTION: CliOption = {
28
29
  kind: CliOptionKind.Presence,
29
30
  };
30
31
 
31
- function configGetOutput(
32
- program: CliProgram,
33
- key: string | undefined,
34
- json: boolean,
35
- pretty: boolean,
36
- ): void {
32
+ function configGetOutput(program: CliProgram, key: string | undefined, json: boolean, pretty: boolean): void {
37
33
  const appConfig = program.appConfig;
38
34
  if (!appConfig) {
39
35
  return;
@@ -106,6 +102,31 @@ function configGetOutput(
106
102
  }
107
103
  }
108
104
 
105
+ const FROM_ENV_OPTION: CliOption = {
106
+ name: "from-env",
107
+ description: "Bind this key to its mapped environment variable (no literal value stored).",
108
+ kind: CliOptionKind.Presence,
109
+ };
110
+
111
+ function configSetFromEnv(program: CliProgram, key: string): void {
112
+ const appConfig = program.appConfig;
113
+ if (!appConfig) {
114
+ return;
115
+ }
116
+ const entry = appConfig.entries[key];
117
+ if (!entry?.env) {
118
+ process.stderr.write(`Configuration key '${key}' has no env mapping for --from-env.\n`);
119
+ process.exit(1);
120
+ }
121
+ const hostEnv = captureMappedHostEnv(program);
122
+ const { fileData } = bootstrapAppConfig(program, { validateFile: true });
123
+ let next = clearFileValue(fileData, key);
124
+ next = setBinding(next, key, "env");
125
+ writeAppConfigFile(program, next, { partial: true });
126
+ const resolved = resolveAppConfig(program, next, hostEnv);
127
+ exportConfigToEnv(program, resolved, hostEnv);
128
+ }
129
+
109
130
  function configSetRun(program: CliProgram, key: string, rawValue: string, useJson: boolean): void {
110
131
  const appConfig = program.appConfig;
111
132
  if (!appConfig) {
@@ -135,8 +156,8 @@ function configSetRun(program: CliProgram, key: string, rawValue: string, useJso
135
156
 
136
157
  const hostEnv = captureMappedHostEnv(program);
137
158
  const { fileData } = bootstrapAppConfig(program, { validateFile: true });
138
- const next = { ...fileData, [key]: parsed };
139
- writeAppConfigFile(program, next);
159
+ const next = setBinding({ ...fileData, [key]: parsed }, key, "file");
160
+ writeAppConfigFile(program, next, { partial: true });
140
161
  const resolved = resolveAppConfig(program, next, hostEnv);
141
162
  exportConfigToEnv(program, resolved, hostEnv);
142
163
  }
@@ -166,7 +187,7 @@ function configSetLeaf(program: CliProgram, mcpSetEnabled: boolean): CliLeaf {
166
187
  return {
167
188
  key: "set",
168
189
  description: "Write one configuration key to the config file.",
169
- options: [JSON_OPTION],
190
+ options: [JSON_OPTION, FROM_ENV_OPTION],
170
191
  mcpTool: mcpSetEnabled ? undefined : { enabled: false },
171
192
  positionals: [
172
193
  {
@@ -191,10 +212,19 @@ function configSetLeaf(program: CliProgram, mcpSetEnabled: boolean): CliLeaf {
191
212
  process.stderr.write("configure set requires a key.\n");
192
213
  process.exit(1);
193
214
  }
215
+ if (ctx.hasFlag("from-env")) {
216
+ const raw = ctx.args[1];
217
+ if (raw !== undefined && raw.length > 0) {
218
+ process.stderr.write("configure set --from-env does not accept a value.\n");
219
+ process.exit(1);
220
+ }
221
+ configSetFromEnv(program, key);
222
+ return;
223
+ }
194
224
  const raw = ctx.args[1];
195
225
  if (raw === undefined || raw.length === 0) {
196
226
  if (!ctx.hasFlag("json")) {
197
- process.stderr.write("configure set requires a value (or --json).\n");
227
+ process.stderr.write("configure set requires a value (or --from-env).\n");
198
228
  process.exit(1);
199
229
  }
200
230
  process.stderr.write("configure set requires a value.\n");
@@ -20,7 +20,7 @@ function enabledKinds(program: CliProgram, caps: CliCapabilities): Kind[] {
20
20
 
21
21
  function joinEnglish(items: string[]): string {
22
22
  if (items.length === 0) return "agent artifacts";
23
- if (items.length === 1) return items[0]!;
23
+ if (items.length === 1) return items[0] ?? "agent artifacts";
24
24
  if (items.length === 2) return `${items[0]} and ${items[1]}`;
25
25
  return `${items.slice(0, -1).join(", ")}, and ${items[items.length - 1]}`;
26
26
  }
@@ -41,7 +41,7 @@ export function configureSyncOptionDescription(program: CliProgram, caps: CliCap
41
41
  return `Refresh installed ${short(program, caps)}. Used by Homebrew post_install.`;
42
42
  }
43
43
 
44
- export function docsSkillTopicDescription(program: CliProgram, caps: CliCapabilities): string {
44
+ export function docsSkillTopicDescription(_program: CliProgram, caps: CliCapabilities): string {
45
45
  if (caps.configure) {
46
46
  return "Print a reference agent SKILL; run `configure` to install an optimized copy.";
47
47
  }
@@ -78,9 +78,6 @@ export function configureCommandNotes(program: CliProgram, _caps: CliCapabilitie
78
78
  if (program.appConfig) {
79
79
  lines.push("Remove app config only:", ` ${app} configure --remove-config --yes`, "");
80
80
  }
81
- lines.push(
82
- "Use --dry to preview changes without writing files.",
83
- "Use --json for machine-readable output.",
84
- );
81
+ lines.push("Use --dry to preview changes without writing files.", "Use --json for machine-readable output.");
85
82
  return lines.join("\n");
86
83
  }
@@ -11,11 +11,7 @@ import { completionBashScript } from "./completion-bash.ts";
11
11
  import { completionFishScript } from "./completion-fish.ts";
12
12
  import { cliBuiltinCompletionGroup as completionGroup } from "./completion-group.ts";
13
13
  import { completionZshScript } from "./completion-zsh.ts";
14
- import {
15
- CONFIGURE_RUN_KEY,
16
- cliBuiltinConfigureCommand,
17
- isConfigureConfigPath,
18
- } from "./configure.ts";
14
+ import { CONFIGURE_RUN_KEY, cliBuiltinConfigureCommand, isConfigureConfigPath } from "./configure.ts";
19
15
  import { cliBuiltinMcpCommand } from "./mcp.ts";
20
16
  import { cliPresentationRoot } from "./presentation.ts";
21
17
  import { cliBuiltinVersionCommand } from "./version.ts";
@@ -35,11 +31,7 @@ function completionSchema(program: CliProgram, opts: DispatchBuiltinOpts): CliRo
35
31
  /**
36
32
  * Handles built-in commands after parse.
37
33
  */
38
- export async function dispatchBuiltin(
39
- program: CliProgram,
40
- pr: ParseResult,
41
- opts: DispatchBuiltinOpts,
42
- ): Promise<void> {
34
+ export async function dispatchBuiltin(program: CliProgram, pr: ParseResult, opts: DispatchBuiltinOpts): Promise<void> {
43
35
  if (pr.kind !== ParseKind.Ok) {
44
36
  return;
45
37
  }
@@ -41,9 +41,7 @@ function exportBuiltinNode(cmd: CliNode): CliSchemaExport | null {
41
41
  if (cmd.fallbackMode !== undefined) {
42
42
  out.fallbackMode = cmd.fallbackMode;
43
43
  }
44
- const children = cmd.commands
45
- .map((ch) => exportBuiltinNode(ch))
46
- .filter((ch): ch is CliSchemaExport => ch !== null);
44
+ const children = cmd.commands.map((ch) => exportBuiltinNode(ch)).filter((ch): ch is CliSchemaExport => ch !== null);
47
45
  if (children.length > 0) {
48
46
  out.commands = children;
49
47
  }