argsbarg 5.1.8 → 5.1.10

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 (94) hide show
  1. package/CHANGELOG.md +29 -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 +17 -3
  6. package/examples/full-example/justfile +6 -5
  7. package/examples/full-example/scripts/formula-shared.test.ts +68 -0
  8. package/examples/full-example/scripts/formula-shared.ts +39 -1
  9. package/examples/full-example/scripts/release.ts +212 -0
  10. package/index.d.ts +10 -1
  11. package/package.json +2 -1
  12. package/src/builtins/builtins.test.ts +1 -3
  13. package/src/builtins/completion-bash.ts +6 -27
  14. package/src/builtins/completion-group.ts +1 -2
  15. package/src/builtins/completion-simulate-shared.ts +2 -10
  16. package/src/builtins/completion-zsh.ts +7 -33
  17. package/src/builtins/config.test.ts +28 -0
  18. package/src/builtins/config.ts +40 -10
  19. package/src/builtins/configure-copy.ts +3 -6
  20. package/src/builtins/dispatch.ts +2 -10
  21. package/src/builtins/export.ts +1 -3
  22. package/src/builtins/presentation.ts +2 -7
  23. package/src/builtins/registry.ts +1 -5
  24. package/src/cli-tool/create.test.ts +3 -6
  25. package/src/cli-tool/create.ts +5 -22
  26. package/src/cli-tool/post-create.ts +1 -3
  27. package/src/cli-tool/program.ts +1 -2
  28. package/src/cli-tool/run-create.ts +2 -8
  29. package/src/cli.ts +6 -27
  30. package/src/config/bindings.test.ts +72 -0
  31. package/src/config/bindings.ts +115 -0
  32. package/src/config/bootstrap.ts +80 -65
  33. package/src/config/context.test.ts +45 -0
  34. package/src/config/context.ts +74 -14
  35. package/src/config/entry.ts +1 -3
  36. package/src/config/file.test.ts +46 -3
  37. package/src/config/file.ts +59 -19
  38. package/src/config/resolve.ts +1 -4
  39. package/src/config/schema.ts +1 -3
  40. package/src/config/validate.test.ts +3 -11
  41. package/src/config/validate.ts +30 -30
  42. package/src/config.integration.test.ts +1 -5
  43. package/src/configure/configure.test.ts +19 -5
  44. package/src/configure/index.ts +17 -15
  45. package/src/docs/api-guide.ts +1 -6
  46. package/src/docs/builtin.ts +1 -5
  47. package/src/docs/docs.test.ts +2 -6
  48. package/src/docs/mcp-guide.ts +3 -13
  49. package/src/docs/mcp-resources.test.ts +1 -3
  50. package/src/docs/mcp-resources.ts +1 -6
  51. package/src/docs/save.ts +2 -10
  52. package/src/formats.test.ts +1 -7
  53. package/src/formats.ts +1 -5
  54. package/src/headless.test.ts +5 -17
  55. package/src/help.ts +9 -51
  56. package/src/hidden-mcpb.test.ts +3 -12
  57. package/src/hidden.ts +1 -3
  58. package/src/install/binary-placement.test.ts +2 -6
  59. package/src/install/binary-placement.ts +1 -4
  60. package/src/install/gh-release-update.ts +5 -22
  61. package/src/install/mcp-codex.ts +1 -2
  62. package/src/install/mcp-config.ts +2 -12
  63. package/src/install/mcp-opencode.test.ts +1 -3
  64. package/src/install/mcp-opencode.ts +3 -15
  65. package/src/install/plan.ts +2 -10
  66. package/src/install/status.ts +3 -5
  67. package/src/install/target-base.ts +2 -11
  68. package/src/install/target-detect.ts +1 -5
  69. package/src/install/target-effective.ts +3 -13
  70. package/src/install/target-mcp-cli.ts +4 -26
  71. package/src/install/target-mcp-json.ts +3 -14
  72. package/src/install/target-plan-build.ts +1 -5
  73. package/src/install/target-registry.ts +13 -21
  74. package/src/install/target-scope.ts +8 -25
  75. package/src/install/target-types.ts +1 -6
  76. package/src/install/targets/app.ts +2 -2
  77. package/src/install/targets/opencode-mcp.ts +1 -6
  78. package/src/install/targets.test.ts +3 -10
  79. package/src/install/uninstall.ts +2 -9
  80. package/src/invoke.test.ts +1 -3
  81. package/src/mcp/bundle.ts +4 -19
  82. package/src/mcp/claude.test.ts +3 -3
  83. package/src/mcp/claude.ts +4 -17
  84. package/src/mcp/env.ts +1 -3
  85. package/src/mcp/server.ts +3 -12
  86. package/src/mcp/tools.ts +2 -11
  87. package/src/mcp.integration.test.ts +4 -10
  88. package/src/parse.test.ts +9 -34
  89. package/src/parse.ts +10 -44
  90. package/src/schema.ts +2 -10
  91. package/src/skill/hint.ts +1 -5
  92. package/src/skill/install.ts +1 -5
  93. package/src/test-fixtures.ts +1 -3
  94. package/src/validate.ts +29 -90
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.10] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **Zip release assets** — Homebrew formulae download `{key}.zip` from GitHub Releases; `buildReleaseArchive` in `formula-shared.ts`; `just release --purge` to delete stale releases.
15
+
16
+ ### Changed
17
+
18
+ - **Release workflow** — `scripts/release.ts` uploads a zip archive (smaller download) instead of a bare Mach-O binary; formula `sha256` pins the archive.
19
+
20
+ ## [5.1.9] - 2026-07-05
21
+
22
+ ### Added
23
+
24
+ - **`configure --sync` config bootstrap** — creates `~/.local/lib/<key>/config.json` as `{}` when missing (all apps; Homebrew `post_install`).
25
+ - **`_bindings` metadata** — per-key intent (`env` | `file` | `skip`) in config file; wizard persists env/skip choices; `configure set --from-env`.
26
+ - **`ctx.appConfig` unsafe I/O** — `getUnsafe`, `setUnsafe`, `readUnsafe` for raw file access (works without `program.appConfig`).
27
+
28
+ ### Changed
29
+
30
+ - **App config detection** — `appConfigInstalled` / `--status` use file existence only (not directory-only).
31
+ - **Configure wizard** — skips addressed keys; Enter at env prompt persists `_bindings`; accurate write messaging.
32
+ - **`configure --status`** — binding hints on required keys (`set (env)`, etc.).
33
+ - **Partial config validation** — single-key / bindings-only writes skip required-property checks.
34
+ - biome lineLength=120
35
+
10
36
  ## [5.1.8] - 2026-07-05
11
37
 
12
38
 
@@ -630,7 +656,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
630
656
  - 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`).
631
657
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
632
658
 
633
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.8...HEAD
659
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.10...HEAD
660
+ [5.1.10]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.10
661
+ [5.1.9]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.9
634
662
  [5.1.8]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.8
635
663
  [5.1.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.7
636
664
  [5.1.6]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.6
@@ -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
 
@@ -117,9 +118,22 @@ Template source: [`examples/full-example/`](../examples/full-example/) in the ar
117
118
  ## Release workflow
118
119
 
119
120
  1. `just build` → `dist/{key}`
120
- 2. `scripts/release.ts` → writes `Formula/{key}.rb` (GitHub URL + sha256), commits, tags, uploads `dist/{key}` to GitHub Releases
121
+ 2. `scripts/release.ts` → zips `dist/{key}` to `dist/{key}.zip`, writes `Formula/{key}.rb` (GitHub zip URL + archive sha256), commits, tags, uploads `dist/{key}.zip` to GitHub Releases
121
122
  3. Users `brew upgrade {key}` from the tap
122
123
 
124
+ Requires the `zip` CLI on the release machine. `just install-local` still stages the bare binary via `file://` (no zip).
125
+
126
+ ### Stale release cleanup
127
+
128
+ ```bash
129
+ just release --purge # delete all GitHub releases except the newest (confirm on TTY)
130
+ just release --purge --yes # skip confirmation
131
+ just release --purge --dry-run # list tags that would be deleted
132
+ just release patch --purge # release, then purge older releases
133
+ ```
134
+
135
+ `--purge` removes GitHub Release records and attached assets only — git tags remain on the remote. Old formula pins that reference deleted release URLs will fail until users upgrade.
136
+
123
137
  ## Removed (breaking)
124
138
 
125
139
  - Self-install to `~/.local/bin`
@@ -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:
@@ -91,6 +88,10 @@ setup:
91
88
  test: check
92
89
  bun test .
93
90
 
91
+ # Bump version, build, publish; or pass --purge to delete stale GitHub releases
92
+ release *ARGS:
93
+ bun scripts/release.ts {{ARGS}}
94
+
94
95
  # Install release formula from tap and run formula test
95
96
  test-release:
96
97
  brew untap {{tap}} 2>/dev/null || true
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import {
8
+ buildReleaseArchive,
9
+ releaseArchiveName,
10
+ releaseFormulaUrl,
11
+ selectStaleReleaseTags,
12
+ } from "./formula-shared.ts";
13
+
14
+ describe("releaseArchiveName", () => {
15
+ test("returns key.zip", () => {
16
+ expect(releaseArchiveName()).toBe("full-example.zip");
17
+ });
18
+ });
19
+
20
+ describe("releaseFormulaUrl", () => {
21
+ test("points at zip asset on GitHub releases", () => {
22
+ expect(releaseFormulaUrl("1.2.3")).toBe(
23
+ "https://github.com/bdombro/bun-argsbarg/releases/download/v1.2.3/full-example.zip",
24
+ );
25
+ });
26
+ });
27
+
28
+ describe("selectStaleReleaseTags", () => {
29
+ test("returns empty for zero or one release", () => {
30
+ expect(selectStaleReleaseTags([])).toEqual([]);
31
+ expect(selectStaleReleaseTags([{ tagName: "v1.0.0", publishedAt: "2026-01-01T00:00:00Z" }])).toEqual([]);
32
+ });
33
+
34
+ test("keeps newest by publishedAt and returns the rest", () => {
35
+ const releases = [
36
+ { tagName: "v1.0.0", publishedAt: "2026-01-01T00:00:00Z" },
37
+ { tagName: "v1.1.0", publishedAt: "2026-02-01T00:00:00Z" },
38
+ { tagName: "v1.0.1", publishedAt: "2026-01-15T00:00:00Z" },
39
+ ];
40
+ expect(selectStaleReleaseTags(releases)).toEqual(["v1.0.1", "v1.0.0"]);
41
+ });
42
+ });
43
+
44
+ describe("buildReleaseArchive", () => {
45
+ const workDirs: string[] = [];
46
+
47
+ afterEach(() => {
48
+ for (const dir of workDirs.splice(0)) {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ test("creates zip with binary at archive root and matching sha256", async () => {
54
+ const work = mkdtempSync(join(tmpdir(), "argsbarg-release-zip-"));
55
+ workDirs.push(work);
56
+ const binaryPath = join(work, "full-example");
57
+ writeFileSync(binaryPath, "#!/bin/sh\necho hi\n", { mode: 0o755 });
58
+
59
+ const { archivePath, sha256 } = await buildReleaseArchive(binaryPath);
60
+
61
+ expect(archivePath).toBe(`${binaryPath}.zip`);
62
+ const onDisk = createHash("sha256").update(readFileSync(archivePath)).digest("hex");
63
+ expect(sha256).toBe(onDisk);
64
+
65
+ const listing = execFileSync("unzip", ["-l", archivePath], { encoding: "utf8" });
66
+ expect(listing).toContain("full-example");
67
+ });
68
+ });
@@ -1,9 +1,18 @@
1
1
  /** Shared Ruby fragments embedded in Homebrew formulae. */
2
2
 
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { $ } from "bun";
3
6
  import { createIdentity } from "./create-identity.ts";
4
7
 
5
8
  const { key, className, desc, homepage, releaseRepo } = createIdentity;
6
9
 
10
+ /** GitHub release row used by {@link selectStaleReleaseTags}. */
11
+ export interface ReleaseTag {
12
+ tagName: string;
13
+ publishedAt: string;
14
+ }
15
+
7
16
  export const formulaInstallRuby = `def install
8
17
  bin.install "${key}"
9
18
  chmod 0755, bin/"${key}"
@@ -36,11 +45,40 @@ export interface FormulaCoords {
36
45
  url: string;
37
46
  urlStanza: string;
38
47
  version: string;
48
+ /** SHA-256 hex digest of the release archive at `url` (zip for GitHub releases). */
39
49
  sha256: string;
40
50
  /** When true, embed {@link githubPrivateReleaseDownloadStrategyRuby} in the formula class. */
41
51
  privateRelease?: boolean;
42
52
  }
43
53
 
54
+ /** Release asset filename on GitHub (zip containing the bare binary at archive root). */
55
+ export function releaseArchiveName(): string {
56
+ return `${key}.zip`;
57
+ }
58
+
59
+ /** Build `{binaryPath}.zip` from a compiled binary; return archive path and sha256 of the zip. */
60
+ export async function buildReleaseArchive(binaryPath: string): Promise<{ archivePath: string; sha256: string }> {
61
+ const archivePath = `${binaryPath}.zip`;
62
+ const result = await $`zip -j -9 ${archivePath} ${binaryPath}`.nothrow();
63
+ if (result.exitCode !== 0) {
64
+ throw new Error(`zip failed: ${result.stderr}`);
65
+ }
66
+ const sha256 = createHash("sha256").update(readFileSync(archivePath)).digest("hex");
67
+ return { archivePath, sha256 };
68
+ }
69
+
70
+ /** Tags to delete when keeping only the newest release (sorted by `publishedAt` desc). */
71
+ export function selectStaleReleaseTags(releases: ReleaseTag[]): string[] {
72
+ if (releases.length <= 1) {
73
+ return [];
74
+ }
75
+ const sorted = [...releases].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
76
+ return sorted.slice(1).map((r) => r.tagName);
77
+ }
78
+
79
+ /** GitHub `org/repo` slug for release and purge commands. */
80
+ export const releaseRepoSlug = releaseRepo;
81
+
44
82
  /** Resolves private GitHub release assets via the API at download time. */
45
83
  export const githubPrivateReleaseDownloadStrategyRuby = ` class GitHubPrivateReleaseDownloadStrategy < CurlDownloadStrategy
46
84
  def initialize(url, name, version, **meta)
@@ -82,7 +120,7 @@ export function releaseUrlStanza(url: string): string {
82
120
  }
83
121
 
84
122
  export function releaseFormulaUrl(version: string): string {
85
- return `https://github.com/${releaseRepo}/releases/download/v${version}/${key}`;
123
+ return `https://github.com/${releaseRepo}/releases/download/v${version}/${releaseArchiveName()}`;
86
124
  }
87
125
 
88
126
  export function renderFormula(coords: FormulaCoords): string {
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bun
2
+ /** Bump version, build, publish zip release; optional `--purge` to drop stale GitHub releases. */
3
+
4
+ import * as fs from "node:fs";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import * as readline from "node:readline/promises";
7
+ import { $ } from "bun";
8
+ import { createIdentity } from "./create-identity.ts";
9
+ import {
10
+ buildReleaseArchive,
11
+ type ReleaseTag,
12
+ releaseRepoSlug,
13
+ renderReleaseFormula,
14
+ selectStaleReleaseTags,
15
+ } from "./formula-shared.ts";
16
+
17
+ const { key } = createIdentity;
18
+ const formulaPath = `Formula/${key}.rb`;
19
+ const binaryPath = `dist/${key}`;
20
+ const programPath = "src/program.ts";
21
+
22
+ /** Allowed semver bump kinds for `scripts/release.ts`. */
23
+ type Bump = "major" | "minor" | "patch";
24
+
25
+ interface ReleaseOptions {
26
+ bump?: Bump;
27
+ purge: boolean;
28
+ yes: boolean;
29
+ dryRun: boolean;
30
+ }
31
+
32
+ /** Entry point: release bump, purge-only, or release then purge. */
33
+ async function main(): Promise<void> {
34
+ const options = parseOptions(process.argv.slice(2));
35
+ if (options.purge && !options.bump) {
36
+ await purgeStaleReleases(options);
37
+ return;
38
+ }
39
+ if (!options.bump) {
40
+ usage();
41
+ }
42
+ await runRelease(options.bump, options);
43
+ }
44
+
45
+ /** Prints usage and exits. */
46
+ function usage(): never {
47
+ process.stderr.write(
48
+ "Usage:\n" +
49
+ " bun scripts/release.ts <major|minor|patch> [--purge]\n" +
50
+ " bun scripts/release.ts --purge [--yes] [--dry-run]\n",
51
+ );
52
+ process.exit(1);
53
+ }
54
+
55
+ /** Parses argv into release options. */
56
+ function parseOptions(argv: string[]): ReleaseOptions {
57
+ const yes = argv.includes("--yes");
58
+ const dryRun = argv.includes("--dry-run");
59
+ const purge = argv.includes("--purge");
60
+ const bump = argv.find((a): a is Bump => a === "major" || a === "minor" || a === "patch");
61
+ for (const arg of argv) {
62
+ if (arg.startsWith("--") && arg !== "--purge" && arg !== "--yes" && arg !== "--dry-run") {
63
+ usage();
64
+ }
65
+ }
66
+ if (!purge && !bump) {
67
+ usage();
68
+ }
69
+ return { bump, purge, yes, dryRun };
70
+ }
71
+
72
+ /** Full release pipeline for a semver bump. */
73
+ async function runRelease(bump: Bump, options: ReleaseOptions): Promise<void> {
74
+ const testResult = await $`just test`.nothrow();
75
+ if (testResult.exitCode !== 0) process.exit(testResult.exitCode);
76
+
77
+ const currentVersion = readCurrentVersion();
78
+ const newVersion = applyBump(currentVersion, bump);
79
+ console.log(`Releasing ${currentVersion} → ${newVersion}`);
80
+
81
+ updateVersion(newVersion);
82
+ updateChangelog(newVersion);
83
+
84
+ const buildResult = await $`just build`.nothrow();
85
+ if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
86
+
87
+ const archivePath = await updateReleaseFormula(newVersion);
88
+
89
+ const docgenResult = await $`just docgen`.nothrow();
90
+ if (docgenResult.exitCode !== 0) process.exit(docgenResult.exitCode);
91
+
92
+ await commitAndTag(newVersion);
93
+ await createGithubRelease(`v${newVersion}`, archivePath);
94
+
95
+ console.log(`Released v${newVersion}`);
96
+
97
+ if (options.purge) {
98
+ await purgeStaleReleases(options);
99
+ }
100
+ }
101
+
102
+ /** Applies a semver bump to `current` and returns the new version string. */
103
+ function applyBump(current: string, bump: Bump): string {
104
+ const [major, minor, patch] = current.split(".").map(Number) as [number, number, number];
105
+ if (bump === "major") return `${major + 1}.0.0`;
106
+ if (bump === "minor") return `${major}.${minor + 1}.0`;
107
+ return `${major}.${minor}.${patch + 1}`;
108
+ }
109
+
110
+ /** Commits all staged changes, creates an annotated tag, and pushes both to origin. */
111
+ async function commitAndTag(newVersion: string): Promise<void> {
112
+ await $`git add -A`;
113
+ await $`git commit -m ${`chore: release v${newVersion}`}`;
114
+ await $`git tag v${newVersion}`;
115
+ await $`git push`;
116
+ await $`git push origin v${newVersion}`;
117
+ }
118
+
119
+ /** Creates a GitHub release for `tag` with the zip archive attached. */
120
+ async function createGithubRelease(tag: string, archivePath: string): Promise<void> {
121
+ await $`gh release create ${tag} ${archivePath} --title ${tag} --generate-notes`;
122
+ }
123
+
124
+ /** Reads the CLI version from `src/program.ts`. */
125
+ function readCurrentVersion(): string {
126
+ const content = fs.readFileSync(programPath, "utf-8");
127
+ const match = /version:\s*"([^"]+)"/.exec(content);
128
+ if (!match) {
129
+ process.stderr.write(`Could not read version from ${programPath}\n`);
130
+ process.exit(1);
131
+ }
132
+ const version = match[1];
133
+ if (!version) {
134
+ process.stderr.write(`Could not read version from ${programPath}\n`);
135
+ process.exit(1);
136
+ }
137
+ const parts = version.split(".").map(Number);
138
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
139
+ process.stderr.write(`Invalid semver in ${programPath}: ${version}\n`);
140
+ process.exit(1);
141
+ }
142
+ return version;
143
+ }
144
+
145
+ /** Promotes `[Unreleased]` to a dated version section in `CHANGELOG.md`. */
146
+ function updateChangelog(newVersion: string): void {
147
+ const changelogPath = "CHANGELOG.md";
148
+ const content = fs.readFileSync(changelogPath, "utf-8");
149
+ const date = new Date().toISOString().slice(0, 10);
150
+ fs.writeFileSync(
151
+ changelogPath,
152
+ content.replace(/^## \[Unreleased\]/m, `## [Unreleased]\n\n## [${newVersion}] - ${date}`),
153
+ );
154
+ }
155
+
156
+ /** Overwrites the version literal in `src/program.ts`. */
157
+ function updateVersion(newVersion: string): void {
158
+ const content = fs.readFileSync(programPath, "utf-8");
159
+ fs.writeFileSync(programPath, content.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`));
160
+ }
161
+
162
+ /** Writes the release formula with zip URL and archive sha256; returns the archive path. */
163
+ async function updateReleaseFormula(version: string): Promise<string> {
164
+ const { archivePath, sha256 } = await buildReleaseArchive(binaryPath);
165
+ fs.writeFileSync(formulaPath, renderReleaseFormula(version, sha256));
166
+ return archivePath;
167
+ }
168
+
169
+ /** Deletes all GitHub releases except the most recent. */
170
+ async function purgeStaleReleases(options: ReleaseOptions): Promise<void> {
171
+ const list = await $`gh release list -R ${releaseRepoSlug} --json tagName,publishedAt`.nothrow();
172
+ if (list.exitCode !== 0) process.exit(list.exitCode);
173
+
174
+ const releases = JSON.parse(list.stdout.toString()) as ReleaseTag[];
175
+ const toDelete = selectStaleReleaseTags(releases);
176
+
177
+ if (toDelete.length === 0) {
178
+ console.log("No stale releases to delete.");
179
+ return;
180
+ }
181
+
182
+ console.log(`Will delete ${toDelete.length} release(s):`);
183
+ for (const tag of toDelete) {
184
+ console.log(` ${tag}`);
185
+ }
186
+
187
+ if (options.dryRun) {
188
+ return;
189
+ }
190
+
191
+ if (!options.yes) {
192
+ if (!input.isTTY) {
193
+ process.stderr.write("Not a TTY; pass --yes to confirm purge.\n");
194
+ process.exit(1);
195
+ }
196
+ const rl = readline.createInterface({ input, output });
197
+ const answer = await rl.question("Delete these releases? [y/N] ");
198
+ rl.close();
199
+ if (answer.trim().toLowerCase() !== "y") {
200
+ console.log("Aborted.");
201
+ return;
202
+ }
203
+ }
204
+
205
+ for (const tag of toDelete) {
206
+ const del = await $`gh release delete ${tag} -R ${releaseRepoSlug} --yes`.nothrow();
207
+ if (del.exitCode !== 0) process.exit(del.exitCode);
208
+ console.log(`Deleted ${tag}`);
209
+ }
210
+ }
211
+
212
+ await main();
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.8",
3
+ "version": "5.1.10",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3"
@@ -32,6 +32,7 @@
32
32
  "devDependencies": {
33
33
  "@biomejs/biome": "^2.5.0",
34
34
  "@types/bun": "^1.3.12",
35
+ "dts-bundle-generator": "^9.5.1",
35
36
  "typescript": "^5.9.3"
36
37
  }
37
38
  }
@@ -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);