harnessbrew 0.5.2 → 0.6.0

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 (81) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +88 -9
  3. package/dist/cli.d.ts +17 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +184 -26
  6. package/dist/cli.js.map +1 -1
  7. package/dist/core/adapter-plugins.d.ts +12 -0
  8. package/dist/core/adapter-plugins.d.ts.map +1 -0
  9. package/dist/core/adapter-plugins.js +151 -0
  10. package/dist/core/adapter-plugins.js.map +1 -0
  11. package/dist/core/bundle.d.ts +33 -5
  12. package/dist/core/bundle.d.ts.map +1 -1
  13. package/dist/core/bundle.js +329 -60
  14. package/dist/core/bundle.js.map +1 -1
  15. package/dist/core/doctor.d.ts +2 -2
  16. package/dist/core/doctor.d.ts.map +1 -1
  17. package/dist/core/doctor.js +2 -0
  18. package/dist/core/doctor.js.map +1 -1
  19. package/dist/core/git.d.ts +1 -0
  20. package/dist/core/git.d.ts.map +1 -1
  21. package/dist/core/git.js +17 -0
  22. package/dist/core/git.js.map +1 -1
  23. package/dist/core/installations.d.ts +1 -0
  24. package/dist/core/installations.d.ts.map +1 -1
  25. package/dist/core/installations.js +135 -14
  26. package/dist/core/installations.js.map +1 -1
  27. package/dist/core/journal.d.ts +26 -0
  28. package/dist/core/journal.d.ts.map +1 -0
  29. package/dist/core/journal.js +363 -0
  30. package/dist/core/journal.js.map +1 -0
  31. package/dist/core/locks.d.ts +10 -0
  32. package/dist/core/locks.d.ts.map +1 -0
  33. package/dist/core/locks.js +90 -0
  34. package/dist/core/locks.js.map +1 -0
  35. package/dist/core/paths.d.ts +1 -0
  36. package/dist/core/paths.d.ts.map +1 -1
  37. package/dist/core/paths.js +3 -0
  38. package/dist/core/paths.js.map +1 -1
  39. package/dist/core/state.d.ts +2 -0
  40. package/dist/core/state.d.ts.map +1 -1
  41. package/dist/core/state.js +21 -2
  42. package/dist/core/state.js.map +1 -1
  43. package/dist/core/taps.d.ts +7 -1
  44. package/dist/core/taps.d.ts.map +1 -1
  45. package/dist/core/taps.js +74 -16
  46. package/dist/core/taps.js.map +1 -1
  47. package/dist/core/targets/claude-code.d.ts.map +1 -1
  48. package/dist/core/targets/claude-code.js +11 -1
  49. package/dist/core/targets/claude-code.js.map +1 -1
  50. package/dist/core/targets/codex.d.ts.map +1 -1
  51. package/dist/core/targets/codex.js +11 -1
  52. package/dist/core/targets/codex.js.map +1 -1
  53. package/dist/core/targets/planner.d.ts +2 -3
  54. package/dist/core/targets/planner.d.ts.map +1 -1
  55. package/dist/core/targets/planner.js +43 -3
  56. package/dist/core/targets/planner.js.map +1 -1
  57. package/dist/core/targets/registry.d.ts +7 -3
  58. package/dist/core/targets/registry.d.ts.map +1 -1
  59. package/dist/core/targets/registry.js +55 -0
  60. package/dist/core/targets/registry.js.map +1 -1
  61. package/dist/core/targets/transaction.d.ts.map +1 -1
  62. package/dist/core/targets/transaction.js +46 -10
  63. package/dist/core/targets/transaction.js.map +1 -1
  64. package/dist/core/targets/types.d.ts +8 -4
  65. package/dist/core/targets/types.d.ts.map +1 -1
  66. package/dist/core/targets/types.js +1 -1
  67. package/dist/core/targets/types.js.map +1 -1
  68. package/dist/core/targets.d.ts +6 -7
  69. package/dist/core/targets.d.ts.map +1 -1
  70. package/dist/core/targets.js +33 -22
  71. package/dist/core/targets.js.map +1 -1
  72. package/dist/core/upgrades.d.ts.map +1 -1
  73. package/dist/core/upgrades.js +10 -1
  74. package/dist/core/upgrades.js.map +1 -1
  75. package/dist/index.d.ts +13 -5
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +6 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/version.d.ts +1 -1
  80. package/dist/version.js +1 -1
  81. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.6.0 - 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - add a versioned JSON CLI envelope with command-level results and structured errors
10
+ - add transactional `--dry-run` previews that report path fingerprints and roll back all managed changes
11
+ - add Target Adapter API v1 with validated registration, Cellar-bounded symlink plans, transaction-managed execution, and lockfile version fingerprints
12
+ - add explicit CLI management for trusted Adapter modules with persisted identity verification and transactional previews
13
+
14
+ ### Security
15
+
16
+ - validate complete Receipt structures, canonical Cellar locations, operation metadata, owned keys, and all destructive paths before use
17
+ - detect added, removed, modified, unsupported, and permission-changed Cellar files through exact inventory comparison
18
+ - prevent forced cleanup from recursively deleting a real directory that replaced a managed directory symlink
19
+ - serialize CLI mutations per HarnessBrew home and protect shared Target configuration updates across processes
20
+ - recover interrupted mutations from durable write-ahead journals, including abandoned process locks and external Target files
21
+ - add Harnessfile and lockfile v2 with structured scopes, portable paths, content digests, explicit lock refresh, and Target convergence
22
+ - require explicit Tap trust before Target activation, enforce fast-forward updates by default, and roll back invalid Tap candidates
23
+
5
24
  ## 0.5.2 - 2026-08-13
6
25
 
7
26
  ### Fixed
package/README.md CHANGED
@@ -41,7 +41,7 @@ HarnessBrew 默认将受管状态保存到 `~/.harnessbrew`。测试或隔离环
41
41
  注册自己的资产 Tap:
42
42
 
43
43
  ```bash
44
- harnessbrew tap add xiejinheng/agents git@github.com:xiejinheng/agent-assets.git
44
+ harnessbrew tap add xiejinheng/agents git@github.com:xiejinheng/agent-assets.git --trust
45
45
  ```
46
46
 
47
47
  搜索并查看 Formula:
@@ -64,7 +64,7 @@ harnessbrew install xiejinheng/agents/code-review \
64
64
  更新和升级:
65
65
 
66
66
  ```bash
67
- harnessbrew update
67
+ harnessbrew update [--allow-rewind]
68
68
  harnessbrew outdated
69
69
  harnessbrew upgrade code-review
70
70
  ```
@@ -140,7 +140,8 @@ Git commit 是安装版本的唯一事实来源;Formula 不维护 `.snapshots`
140
140
 
141
141
  MCP Formula 的入口是统一 JSON。stdio 配置使用 `command`、可选 `args` 和只包含环境变量名称的 `envVars`;HTTP 配置使用 `transport: "http"`、`url`、可选 `bearerTokenEnvVar` 与 `headersFromEnv`。HarnessBrew 不接受入口中的明文 `env` 密钥值。
142
142
 
143
- `adapter` Formula 当前是可由 Git/Cellar 管理的扩展包,但内置 Target 不执行它。请使用不带 `--target` `install` 保存;`link` 或 `install --target` 会明确拒绝,直到插件运行时接口开放。
143
+ `adapter` Formula 是可由 Git/Cellar 管理的扩展资产,但不会作为代码自动执行。Target Adapter 运行时使用单独的可信模块机制:先审查并安装 npm 包,
144
+ 再通过 `harnessbrew adapter add <module>` 显式授权。这样 Tap 仍保持声明式、不执行任意代码,而第三方 Target 可以接入版本化 Adapter SDK。
144
145
 
145
146
  ## Target
146
147
 
@@ -187,26 +188,41 @@ harnessbrew unlink code-review --target openai-codex
187
188
  `Harnessfile` 适合提交到个人 dotfiles 或项目仓库:
188
189
 
189
190
  ```yaml
190
- schemaVersion: 1
191
+ schemaVersion: 2
191
192
  taps:
192
193
  - name: xiejinheng/agents
193
194
  git: git@github.com:xiejinheng/agent-assets.git
194
195
  ref: main
196
+ trust: true
195
197
 
196
198
  assets:
197
199
  - formula: xiejinheng/agents/code-review
198
- targets: [openai-codex]
200
+ targets:
201
+ - target: openai-codex
202
+ scope: user
203
+ - target: claude-code
204
+ scope: project
205
+ project: .
199
206
  ```
200
207
 
208
+ v2 的 Target placement 可以声明 `user` 或 `project` scope,并可使用相对于 `Harnessfile` 的 `project` 和 `root` 路径。
209
+ schema v1 的 `targets: [openai-codex]` 仍然兼容,并按 user scope 解释。
210
+
201
211
  安装并生成 `Harnessfile.lock`:
202
212
 
203
213
  ```bash
204
214
  harnessbrew bundle install
205
215
  ```
206
216
 
207
- lockfile 会记录每个 Tap 的准确 commit、依赖闭包和 target,应与 `Harnessfile` 一起提交到 Git。
217
+ v2 lockfile 会记录 Manifest 摘要、HarnessBrew Adapter 版本、每个 Tap 的准确 commit、Formula 内容摘要、依赖闭包和完整 Target placement,
218
+ 应与 `Harnessfile` 一起提交到 Git。
208
219
 
209
220
  在其他设备运行同一命令时,HarnessBrew 会检出 lockfile 固定的 commit,而不是未经确认地使用 Tap 最新版本。
221
+ 修改 v2 Harnessfile 后需要显式更新 lockfile:
222
+
223
+ ```bash
224
+ harnessbrew bundle install --update-lock
225
+ ```
210
226
 
211
227
  清理清单之外的受管资产:
212
228
 
@@ -223,9 +239,11 @@ harnessbrew bundle install --file ./config/Harnessfile
223
239
  ## CLI
224
240
 
225
241
  ```text
226
- harnessbrew tap add <owner/name> <git-url> [--ref <ref>]
242
+ harnessbrew tap add <owner/name> <git-url> [--ref <ref>] [--trust]
227
243
  harnessbrew tap list
228
- harnessbrew tap update [owner/name]
244
+ harnessbrew tap update [owner/name] [--allow-rewind]
245
+ harnessbrew tap trust <owner/name>
246
+ harnessbrew tap untrust <owner/name>
229
247
  harnessbrew tap remove <owner/name>
230
248
  harnessbrew untap <owner/name>
231
249
  harnessbrew search [query] [--kind <kind>] [--target <target>]
@@ -240,10 +258,20 @@ harnessbrew update
240
258
  harnessbrew outdated
241
259
  harnessbrew upgrade [formula]
242
260
  harnessbrew uninstall <formula> [--force]
243
- harnessbrew bundle install [--file <path>]
261
+ harnessbrew bundle install [--file <path>] [--update-lock]
244
262
  harnessbrew bundle cleanup [--file <path>]
245
263
  ```
246
264
 
265
+ 所有命令都可追加 `--json`,stdout 将只包含一个 schema v1 JSON envelope:`result` 是命令级结构化结果,`output` 保留人类文本,
266
+ 失败时包含 `error.code`、`error.message`、`diagnostics` 和非零 `exitCode`。
267
+
268
+ 变更命令可追加 `--dry-run`。HarnessBrew 会在同一 Home 写锁下完整执行校验和安装事务,收集每个路径的 before/after 类型与摘要,
269
+ 随后回滚 Cellar、Receipt、Tap checkout 和 Agent Target;与 `--json` 组合时,预览位于 `changes` 数组。dry-run 可能执行 Git fetch/clone 等只读网络操作。
270
+
271
+ ```bash
272
+ harnessbrew install code-review --target openai-codex --dry-run --json
273
+ ```
274
+
247
275
  ## 本地目录
248
276
 
249
277
  ```text
@@ -259,6 +287,8 @@ Tap 工作树和 Cellar 内容都由 HarnessBrew 管理,不应直接编辑。
259
287
  ## 安全边界
260
288
 
261
289
  - Formula 是声明式 JSON;HarnessBrew 不执行 Tap 中的任意脚本。
290
+ - 新注册的 Tap 默认不受信任:可以搜索和安装到 Cellar,但必须通过 `tap trust`、`tap add --trust` 或 Harnessfile v2 的 `trust: true` 才能链接或渲染到 Agent Target。旧状态记录按兼容策略视为已信任。
291
+ - Tap 更新默认只接受 Git fast-forward;仓库历史被重写时必须人工检查后使用 `--allow-rewind`。候选 commit 校验失败会恢复原 checkout 和状态。
262
292
  - Formula 入口不能逃逸所属目录。
263
293
  - 安装前会检查依赖、冲突和 target 兼容性。
264
294
  - HarnessBrew 不覆盖未由 Receipt 管理的目标文件。
@@ -278,6 +308,55 @@ npm run check
278
308
 
279
309
  `npm run check` 会执行 TypeScript 编译、全部 Node.js 测试、安装包冒烟测试和 `npm pack --dry-run`。
280
310
 
311
+ ## Target Adapter SDK
312
+
313
+ Node.js/TypeScript 宿主可以通过公开 API 注册第三方 Agent Target。Adapter API v1 只接收 Receipt 与 Target Context,并返回一条声明式安装计划;
314
+ 实际写入、冲突检测、Receipt、doctor、relink、upgrade、dry-run 和回滚仍由 HarnessBrew transaction layer 负责。
315
+
316
+ ```ts
317
+ import { registerTargetAdapter, type TargetAdapter } from "harnessbrew";
318
+
319
+ const adapter: TargetAdapter = {
320
+ apiVersion: 1,
321
+ name: "cursor",
322
+ version: "1.0.0",
323
+ capabilities: {
324
+ skill: "symlink-directory",
325
+ agent: "symlink-file",
326
+ workflow: "symlink-file",
327
+ instruction: "symlink-file",
328
+ prompt: "symlink-file",
329
+ mcp: "unsupported",
330
+ adapter: "unsupported"
331
+ },
332
+ plan(receipt, context = {}) {
333
+ // Return one absolute destination and a source inside receipt.cellarPath.
334
+ return { target: "cursor", coordinate: receipt.coordinate, operations: [/* ... */] };
335
+ }
336
+ };
337
+
338
+ const unregister = registerTargetAdapter(adapter);
339
+ ```
340
+
341
+ SDK 会校验 API 版本、名称、版本、完整能力矩阵、计划身份、绝对目标路径和 Cellar source 边界。v1 的第三方 Adapter 计划只允许
342
+ `symlink-file`、`symlink-directory` 和 `unsupported`,不提供直接写文件或生成共享配置的操作。注册是显式、进程内操作;HarnessBrew 不会从 Tap
343
+ 自动执行 Adapter Formula。第三方 Adapter 本身是具有宿主进程权限的可信代码,只应加载经过审查的 npm 包。
344
+
345
+ 独立 CLI 可以持久化管理可信 Adapter 模块。模块需已通过 npm 安装并可被 `harnessbrew` 解析,也可以使用绝对路径或 `file://` URL;它必须默认导出
346
+ 一个 Adapter,或提供名为 `adapter` 的导出:
347
+
348
+ ```bash
349
+ harnessbrew adapter add @harnessbrew/adapter-cursor
350
+ harnessbrew adapter list
351
+ harnessbrew install review --target cursor
352
+ harnessbrew adapter remove cursor
353
+ ```
354
+
355
+ `adapter add` 是一次显式的代码执行授权。HarnessBrew 将模块标识及审核时的 name、version、API version 写入
356
+ `~/.harnessbrew/adapters.json`;后续只在 install/link/unlink/relink/upgrade/bundle 需要 Target 时加载,并在每次加载时核对身份。
357
+ 如果包升级改变身份,命令会关闭失败,要求先 remove、审查后再 add。`adapter list/remove` 本身不执行插件,HarnessBrew 也不会自动运行 `npm install`。
358
+ CLI 加载的 Target 同样进入 Harnessfile v2 lock 的 Adapter 签名。
359
+
281
360
  ## 架构
282
361
 
283
362
  完整设计见 [docs/architecture.md](docs/architecture.md)。
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type TransactionChange } from "./core/journal.js";
1
2
  export interface CliIO {
2
3
  stdout: (message: string) => void;
3
4
  stderr: (message: string) => void;
@@ -5,5 +6,21 @@ export interface CliIO {
5
6
  export interface CliOptions {
6
7
  home?: string;
7
8
  }
9
+ export interface CliJsonError {
10
+ code: "COMMAND_FAILED" | "HARNESSBREW_ERROR";
11
+ message: string;
12
+ }
13
+ export interface CliJsonEnvelope {
14
+ schemaVersion: 1;
15
+ ok: boolean;
16
+ command: string;
17
+ exitCode: number;
18
+ dryRun: boolean;
19
+ result: unknown;
20
+ output: string[];
21
+ diagnostics: string[];
22
+ error?: CliJsonError;
23
+ changes?: TransactionChange[];
24
+ }
8
25
  export declare function runCli(args: readonly string[], io?: CliIO, options?: CliOptions): Promise<number>;
9
26
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA4SD,wBAAsB,MAAM,CAC1B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,GAAE,KAAiB,EACrB,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,MAAM,CAAC,CAUjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAyBA,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,GAAG,mBAAmB,CAAC;IAC7C,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,CAAC,CAAC;IACjB,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,iBAAiB,EAAE,CAAC;CAC/B;AA4XD,wBAAsB,MAAM,CAC1B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,GAAE,KAAiB,EACrB,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,MAAM,CAAC,CA6FjB"}
package/dist/cli.js CHANGED
@@ -2,13 +2,20 @@ import path from "node:path";
2
2
  import { HarnessBrewError } from "./core/errors.js";
3
3
  import { formulaKinds, getFormula, searchFormulas } from "./core/formulas.js";
4
4
  import { installFormula, listInstalled, uninstallFormula } from "./core/installations.js";
5
- import { builtinTargets, installForTarget, linkFormula, unlinkFormula } from "./core/targets.js";
5
+ import { installForTarget, linkFormula, unlinkFormula } from "./core/targets.js";
6
+ import { hasTargetAdapter } from "./core/targets/registry.js";
6
7
  import { findOutdated, upgradeFormulas } from "./core/upgrades.js";
7
8
  import { bundleCleanup, bundleInstall } from "./core/bundle.js";
8
9
  import { resolveHarnessHome } from "./core/paths.js";
9
- import { addTap, listTaps, removeTap, updateTaps } from "./core/taps.js";
10
+ import { addTap, listTaps, removeTap, setTapTrust, updateTaps } from "./core/taps.js";
10
11
  import { VERSION } from "./version.js";
11
12
  import { doctor, relinkFormula } from "./core/doctor.js";
13
+ import { addAdapterPlugin, listAdapterPlugins, loadAdapterPlugins, removeAdapterPlugin } from "./core/adapter-plugins.js";
14
+ import { withHomeLock } from "./core/locks.js";
15
+ import { withJournalPreview, withJournalTransaction } from "./core/journal.js";
16
+ function setResult(io, result) {
17
+ io.setResult?.(result);
18
+ }
12
19
  const defaultIO = {
13
20
  stdout: (message) => console.log(message),
14
21
  stderr: (message) => console.error(message)
@@ -18,12 +25,12 @@ const helpText = `HarnessBrew ${VERSION}
18
25
  A Git-native package manager for AI Agent assets.
19
26
 
20
27
  Usage:
21
- harnessbrew <command> [options]
28
+ harnessbrew <command> [options] [--json] [--dry-run]
22
29
 
23
30
  Commands:
24
31
  help Show this help
25
32
  version Show the installed version
26
- tap Add, list, update, or remove Git taps
33
+ tap Add, trust, list, update, or remove Git taps
27
34
  untap Remove a Git tap
28
35
  search Search formulas across registered taps
29
36
  info Show formula metadata
@@ -38,11 +45,16 @@ Commands:
38
45
  outdated List installed formulas with available changes
39
46
  upgrade Upgrade formulas while preserving target links
40
47
  bundle Rebuild or clean an environment from Harnessfile
48
+ adapter Add, list, or remove trusted Target Adapter modules
41
49
 
42
50
  Target placement options:
43
51
  --scope <user|project> Select user or project installation scope
44
52
  --project <path> Project root (implies --scope project)
45
53
  --target-root <path> Override the target's native root
54
+
55
+ Automation options:
56
+ --json Emit one versioned JSON result envelope
57
+ --dry-run Execute a mutation transaction, report changes, then roll it back
46
58
  `;
47
59
  function optionValue(args, name) {
48
60
  const index = args.indexOf(name);
@@ -77,65 +89,118 @@ async function runTapCommand(args, home, io) {
77
89
  const [action = "list", ...rest] = args;
78
90
  if (action === "list") {
79
91
  const taps = await listTaps(home);
80
- taps.forEach((tap) => io.stdout(`${tap.name}\t${tap.commit.slice(0, 12)}\t${tap.url}`));
92
+ taps.forEach((tap) => io.stdout(`${tap.name}\t${tap.trusted ? "trusted" : "untrusted"}\t${tap.commit.slice(0, 12)}\t${tap.url}`));
93
+ setResult(io, taps);
81
94
  return 0;
82
95
  }
83
96
  if (action === "add") {
84
97
  const [name, url] = rest;
85
98
  if (name === undefined || url === undefined) {
86
- throw new HarnessBrewError("Usage: harnessbrew tap add <owner/name> <git-url> [--ref <ref>]");
99
+ throw new HarnessBrewError("Usage: harnessbrew tap add <owner/name> <git-url> [--ref <ref>] [--trust]");
87
100
  }
88
101
  const ref = optionValue(rest, "--ref");
89
- const record = await addTap(home, name, url, ref === undefined ? {} : { ref });
102
+ const record = await addTap(home, name, url, {
103
+ ...(ref === undefined ? {} : { ref }),
104
+ trust: rest.includes("--trust")
105
+ });
90
106
  io.stdout(`Tapped ${record.name} at ${record.commit.slice(0, 12)}.`);
107
+ setResult(io, record);
91
108
  return 0;
92
109
  }
93
110
  if (action === "update") {
94
- const updates = await updateTaps(home, rest[0]);
111
+ const requested = rest.find((value) => !value.startsWith("--"));
112
+ const updates = await updateTaps(home, requested, { allowRewind: rest.includes("--allow-rewind") });
95
113
  updates.forEach((update) => {
96
114
  io.stdout(update.changed
97
115
  ? `Updated ${update.name}: ${update.before.slice(0, 12)} -> ${update.after.slice(0, 12)}.`
98
116
  : `${update.name} is already up-to-date.`);
99
117
  });
118
+ setResult(io, updates);
100
119
  return 0;
101
120
  }
102
121
  if (action === "remove") {
103
122
  const [name] = rest;
104
123
  if (name === undefined)
105
124
  throw new HarnessBrewError("Usage: harnessbrew tap remove <owner/name>");
106
- await removeTap(home, name);
125
+ const record = await removeTap(home, name);
107
126
  io.stdout(`Untapped ${name}.`);
127
+ setResult(io, record);
128
+ return 0;
129
+ }
130
+ if (action === "trust" || action === "untrust") {
131
+ const [name] = rest;
132
+ if (name === undefined)
133
+ throw new HarnessBrewError(`Usage: harnessbrew tap ${action} <owner/name>`);
134
+ const record = await setTapTrust(home, name, action === "trust");
135
+ io.stdout(`${record.name} is now ${record.trusted ? "trusted" : "untrusted"}.`);
136
+ setResult(io, record);
108
137
  return 0;
109
138
  }
110
139
  throw new HarnessBrewError(`Unknown tap action: ${action}`);
111
140
  }
141
+ async function runAdapterCommand(args, home, io) {
142
+ const [action = "list", ...rest] = args;
143
+ if (action === "list") {
144
+ const records = await listAdapterPlugins(home);
145
+ records.forEach((record) => io.stdout(`${record.name}\t${record.version}\t${record.module}`));
146
+ setResult(io, records);
147
+ return 0;
148
+ }
149
+ if (action === "add") {
150
+ const [moduleSpecifier] = rest;
151
+ if (moduleSpecifier === undefined) {
152
+ throw new HarnessBrewError("Usage: harnessbrew adapter add <npm-package|absolute-path|file-url>");
153
+ }
154
+ const record = await addAdapterPlugin(home, moduleSpecifier);
155
+ io.stdout(`Added trusted Adapter ${record.name}@${record.version} from ${record.module}.`);
156
+ setResult(io, record);
157
+ return 0;
158
+ }
159
+ if (action === "remove") {
160
+ const [name] = rest;
161
+ if (name === undefined)
162
+ throw new HarnessBrewError("Usage: harnessbrew adapter remove <target>");
163
+ const record = await removeAdapterPlugin(home, name);
164
+ io.stdout(`Removed Adapter ${record.name}@${record.version}.`);
165
+ setResult(io, record);
166
+ return 0;
167
+ }
168
+ throw new HarnessBrewError(`Unknown adapter action: ${action}`);
169
+ }
112
170
  async function execute(args, io, options) {
113
171
  const [command] = args;
114
172
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
115
173
  io.stdout(helpText.trimEnd());
174
+ setResult(io, { help: helpText.trimEnd() });
116
175
  return 0;
117
176
  }
118
177
  if (command === "version" || command === "--version" || command === "-v") {
119
178
  io.stdout(VERSION);
179
+ setResult(io, { version: VERSION });
120
180
  return 0;
121
181
  }
122
182
  const home = resolveHarnessHome(options.home);
183
+ if (command === "adapter") {
184
+ return runAdapterCommand(args.slice(1), home, io);
185
+ }
123
186
  if (command === "tap") {
124
187
  return runTapCommand(args.slice(1), home, io);
125
188
  }
126
189
  if (command === "update") {
127
- const updates = await updateTaps(home);
190
+ const updates = await updateTaps(home, undefined, { allowRewind: args.includes("--allow-rewind") });
128
191
  updates.forEach((update) => io.stdout(update.changed
129
192
  ? `Updated ${update.name}: ${update.before.slice(0, 12)} -> ${update.after.slice(0, 12)}.`
130
193
  : `${update.name} is already up-to-date.`));
194
+ setResult(io, updates);
131
195
  return 0;
132
196
  }
133
197
  if (command === "untap") {
134
198
  const name = args[1];
135
199
  if (name === undefined)
136
200
  throw new HarnessBrewError("Usage: harnessbrew untap <owner/name>");
137
- await removeTap(home, name);
201
+ const record = await removeTap(home, name);
138
202
  io.stdout(`Untapped ${name}.`);
203
+ setResult(io, record);
139
204
  return 0;
140
205
  }
141
206
  if (command === "search") {
@@ -154,6 +219,7 @@ async function execute(args, io, options) {
154
219
  ...(target === undefined ? {} : { target })
155
220
  });
156
221
  formulas.forEach((formula) => io.stdout(`${formula.coordinate}\t${formula.kind}\t${formula.description}`));
222
+ setResult(io, formulas);
157
223
  return 0;
158
224
  }
159
225
  if (command === "info") {
@@ -161,7 +227,7 @@ async function execute(args, io, options) {
161
227
  if (name === undefined)
162
228
  throw new HarnessBrewError("Usage: harnessbrew info <formula>");
163
229
  const formula = await getFormula(home, name);
164
- io.stdout(JSON.stringify({
230
+ const result = {
165
231
  coordinate: formula.coordinate,
166
232
  kind: formula.kind,
167
233
  description: formula.description,
@@ -169,7 +235,9 @@ async function execute(args, io, options) {
169
235
  dependencies: formula.dependencies,
170
236
  commit: formula.commit,
171
237
  ...(formula.deprecated === undefined ? {} : { deprecated: formula.deprecated })
172
- }, null, 2));
238
+ };
239
+ io.stdout(JSON.stringify(result, null, 2));
240
+ setResult(io, result);
173
241
  return 0;
174
242
  }
175
243
  if (command === "install") {
@@ -177,18 +245,20 @@ async function execute(args, io, options) {
177
245
  if (name === undefined)
178
246
  throw new HarnessBrewError("Usage: harnessbrew install <formula>");
179
247
  const targetValue = optionValue(args, "--target");
180
- if (targetValue !== undefined && !builtinTargets.includes(targetValue)) {
181
- throw new HarnessBrewError(`Unsupported built-in target: ${targetValue}`);
248
+ if (targetValue !== undefined && !hasTargetAdapter(targetValue)) {
249
+ throw new HarnessBrewError(`Target adapter is not registered: ${targetValue}`);
182
250
  }
183
251
  const receipts = targetValue === undefined
184
252
  ? await installFormula(home, name)
185
253
  : await installForTarget(home, name, targetValue, targetOptions(args));
186
254
  receipts.forEach((receipt) => io.stdout(`Installed ${receipt.coordinate} at ${receipt.commit.slice(0, 12)}.`));
255
+ setResult(io, receipts);
187
256
  return 0;
188
257
  }
189
258
  if (command === "list") {
190
259
  const receipts = await listInstalled(home);
191
260
  receipts.forEach((receipt) => io.stdout(`${receipt.coordinate}\t${receipt.commit.slice(0, 12)}`));
261
+ setResult(io, receipts);
192
262
  return 0;
193
263
  }
194
264
  if (command === "uninstall") {
@@ -197,6 +267,7 @@ async function execute(args, io, options) {
197
267
  throw new HarnessBrewError("Usage: harnessbrew uninstall <formula> [--force]");
198
268
  const receipt = await uninstallFormula(home, name, { force: args.includes("--force") });
199
269
  io.stdout(`Uninstalled ${receipt.coordinate}.`);
270
+ setResult(io, receipt);
200
271
  return 0;
201
272
  }
202
273
  if (command === "link" || command === "unlink") {
@@ -205,25 +276,28 @@ async function execute(args, io, options) {
205
276
  if (name === undefined || targetValue === undefined) {
206
277
  throw new HarnessBrewError(`Usage: harnessbrew ${command} <formula> --target <target> [--scope <user|project>] [--project <path>]`);
207
278
  }
208
- if (!builtinTargets.includes(targetValue)) {
209
- throw new HarnessBrewError(`Unsupported built-in target: ${targetValue}`);
279
+ if (!hasTargetAdapter(targetValue)) {
280
+ throw new HarnessBrewError(`Target adapter is not registered: ${targetValue}`);
210
281
  }
211
282
  if (command === "link") {
212
- await linkFormula(home, name, targetValue, targetOptions(args));
283
+ const receipt = await linkFormula(home, name, targetValue, targetOptions(args));
213
284
  io.stdout(`Linked ${name} to ${targetValue}.`);
285
+ setResult(io, receipt);
214
286
  }
215
287
  else {
216
- await unlinkFormula(home, name, targetValue, {
288
+ const receipt = await unlinkFormula(home, name, targetValue, {
217
289
  ...(hasTargetPlacementOptions(args) ? targetOptions(args) : {}),
218
290
  force: args.includes("--force")
219
291
  });
220
292
  io.stdout(`Unlinked ${name} from ${targetValue}.`);
293
+ setResult(io, receipt);
221
294
  }
222
295
  return 0;
223
296
  }
224
297
  if (command === "doctor") {
225
298
  const name = args[1]?.startsWith("--") === true ? undefined : args[1];
226
299
  const report = await doctor(home, name);
300
+ setResult(io, report);
227
301
  if (report.healthy) {
228
302
  io.stdout(`Doctor found no issues across ${report.checked} formula(s).`);
229
303
  return 0;
@@ -241,14 +315,15 @@ async function execute(args, io, options) {
241
315
  if (name === undefined)
242
316
  throw new HarnessBrewError("Usage: harnessbrew relink <formula> [--target <target>]");
243
317
  const targetValue = optionValue(args, "--target");
244
- if (targetValue !== undefined && !builtinTargets.includes(targetValue)) {
245
- throw new HarnessBrewError(`Unsupported built-in target: ${targetValue}`);
318
+ if (targetValue !== undefined && !hasTargetAdapter(targetValue)) {
319
+ throw new HarnessBrewError(`Target adapter is not registered: ${targetValue}`);
246
320
  }
247
- await relinkFormula(home, name, {
321
+ const receipt = await relinkFormula(home, name, {
248
322
  ...(hasTargetPlacementOptions(args) ? targetOptions(args) : {}),
249
323
  ...(targetValue === undefined ? {} : { target: targetValue })
250
324
  });
251
325
  io.stdout(`Relinked ${name}${targetValue === undefined ? "" : ` to ${targetValue}`}.`);
326
+ setResult(io, receipt);
252
327
  return 0;
253
328
  }
254
329
  if (command === "outdated") {
@@ -256,6 +331,7 @@ async function execute(args, io, options) {
256
331
  outdated.forEach((item) => io.stdout(item.available
257
332
  ? `${item.coordinate}\t${item.installedCommit.slice(0, 12)} -> ${item.availableCommit?.slice(0, 12)}`
258
333
  : `${item.coordinate}\t${item.installedCommit.slice(0, 12)} -> unavailable`));
334
+ setResult(io, outdated);
259
335
  return 0;
260
336
  }
261
337
  if (command === "upgrade") {
@@ -264,34 +340,116 @@ async function execute(args, io, options) {
264
340
  if (results.length === 0)
265
341
  io.stdout("All installed formulas are up-to-date.");
266
342
  results.forEach((result) => io.stdout(`Upgraded ${result.coordinate}: ${result.before.slice(0, 12)} -> ${result.after.slice(0, 12)}.`));
343
+ setResult(io, results);
267
344
  return 0;
268
345
  }
269
346
  if (command === "bundle") {
270
347
  const action = args[1];
271
348
  const filePath = optionValue(args, "--file") ?? path.resolve("Harnessfile");
272
349
  if (action === "install") {
273
- const lock = await bundleInstall(home, filePath);
350
+ const lock = await bundleInstall(home, filePath, { updateLock: args.includes("--update-lock") });
274
351
  io.stdout(`Bundle installed ${lock.assets.length} formulas from ${lock.taps.length} taps.`);
352
+ setResult(io, lock);
275
353
  return 0;
276
354
  }
277
355
  if (action === "cleanup") {
278
356
  const result = await bundleCleanup(home, filePath);
279
357
  result.removed.forEach((coordinate) => io.stdout(`Removed ${coordinate}.`));
358
+ setResult(io, result);
280
359
  return 0;
281
360
  }
282
- throw new HarnessBrewError("Usage: harnessbrew bundle <install|cleanup> [--file <Harnessfile>]");
361
+ throw new HarnessBrewError("Usage: harnessbrew bundle <install|cleanup> [--file <Harnessfile>] [--update-lock]");
283
362
  }
284
363
  io.stderr(`Unknown command: ${command}`);
285
364
  io.stderr("Run 'harnessbrew help' for usage.");
286
365
  return 1;
287
366
  }
288
367
  export async function runCli(args, io = defaultIO, options = {}) {
368
+ const json = args.includes("--json");
369
+ const dryRun = args.includes("--dry-run");
370
+ const commandArgs = args.filter((argument) => argument !== "--json" && argument !== "--dry-run");
371
+ const output = [];
372
+ const diagnostics = [];
373
+ let result = null;
374
+ const commandIO = json || dryRun
375
+ ? {
376
+ stdout: (message) => output.push(message),
377
+ stderr: (message) => diagnostics.push(message),
378
+ setResult: (value) => { result = value; }
379
+ }
380
+ : io;
381
+ const command = commandArgs[0];
382
+ const tapAction = command === "tap" ? (commandArgs[1] ?? "list") : undefined;
383
+ const adapterAction = command === "adapter" ? (commandArgs[1] ?? "list") : undefined;
384
+ const mutates = command === "update" || command === "untap" || command === "install"
385
+ || command === "uninstall" || command === "link" || command === "unlink" || command === "relink"
386
+ || command === "upgrade" || command === "bundle"
387
+ || (command === "tap" && tapAction !== "list")
388
+ || (command === "adapter" && adapterAction !== "list");
389
+ const emitJson = (exitCode, changes = [], errorCode) => {
390
+ const envelope = {
391
+ schemaVersion: 1,
392
+ ok: exitCode === 0,
393
+ command: command ?? "help",
394
+ exitCode,
395
+ dryRun,
396
+ result,
397
+ output,
398
+ diagnostics,
399
+ ...(exitCode === 0 ? {} : {
400
+ error: {
401
+ code: errorCode ?? "COMMAND_FAILED",
402
+ message: diagnostics[0] ?? "Command failed."
403
+ }
404
+ }),
405
+ ...(dryRun ? { changes } : {})
406
+ };
407
+ io.stdout(JSON.stringify(envelope));
408
+ };
289
409
  try {
290
- return await execute(args, io, options);
410
+ const home = resolveHarnessHome(options.home);
411
+ if (dryRun && !mutates)
412
+ throw new HarnessBrewError("--dry-run requires a mutating command.");
413
+ const invoke = async () => {
414
+ const unload = command !== undefined
415
+ && ["install", "link", "unlink", "relink", "upgrade", "bundle"].includes(command)
416
+ ? await loadAdapterPlugins(home)
417
+ : () => { };
418
+ try {
419
+ return await execute(commandArgs, commandIO, options);
420
+ }
421
+ finally {
422
+ unload();
423
+ }
424
+ };
425
+ let exitCode;
426
+ let changes = [];
427
+ if (dryRun) {
428
+ const preview = await withHomeLock(home, () => withJournalPreview(home, `preview:${command ?? "unknown"}`, invoke));
429
+ exitCode = preview.result;
430
+ changes = preview.changes;
431
+ if (!json) {
432
+ io.stdout(`Dry run completed: ${changes.length} path change(s) would be made.`);
433
+ changes.forEach((change) => io.stdout(`${change.before.kind === "missing" ? "create" : change.after.kind === "missing" ? "remove" : "modify"}\t${change.path}`));
434
+ }
435
+ }
436
+ else {
437
+ exitCode = mutates
438
+ ? await withHomeLock(home, () => withJournalTransaction(home, `cli:${command ?? "unknown"}`, invoke))
439
+ : await invoke();
440
+ }
441
+ if (json)
442
+ emitJson(exitCode, changes);
443
+ return exitCode;
291
444
  }
292
445
  catch (error) {
293
446
  if (error instanceof HarnessBrewError) {
294
- io.stderr(error.message);
447
+ if (json) {
448
+ diagnostics.push(error.message);
449
+ emitJson(1, [], "HARNESSBREW_ERROR");
450
+ }
451
+ else
452
+ io.stderr(error.message);
295
453
  return 1;
296
454
  }
297
455
  throw error;