agents-gitflow-guard 0.0.19 → 0.0.21

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.
package/README.md CHANGED
@@ -40,48 +40,58 @@ You define your own branches —
40
40
 
41
41
  ```bash
42
42
  # DSH — in-process plugin; restart DSH afterwards (plugins load at startup)
43
- dsh plugin --profile web add agents-gitflow-guard@0.0.19
43
+ dsh plugin --profile web add agents-gitflow-guard@0.0.21
44
44
  ```
45
45
 
46
46
  ```bash
47
47
  # Claude Code · Codex · OpenCode · Antigravity — standalone hooks, no DSH needed
48
- npm i -g agents-gitflow-guard@0.0.19
48
+ npm i -g agents-gitflow-guard@0.0.21
49
49
  ```
50
50
 
51
51
  ```bash
52
52
  # Pi — in-process extension
53
- npm i -D agents-gitflow-guard@0.0.19
53
+ npm i -D agents-gitflow-guard@0.0.21
54
54
  ```
55
55
 
56
56
  > **Version gotcha**: a bare `add` or unpinned `npm i` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. (DSH users: the pnpm peer-dependency *warning* is expected — DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup; the plugin works normally.)
57
57
  >
58
- > The hook clients (Claude Code · Codex · OpenCode · Antigravity) and Pi need one wiring step after install — walk the per-agent table in [Installation in detail](#installation-in-detail).
58
+ > The hook clients (Claude Code · Codex · OpenCode · Antigravity) need one wiring step after install — **one command per client** (below). Pi needs a copy step; DSH is already wired by install.
59
59
 
60
- **Step 2 — configure**, create `gitflow-guard.config.json` in your **project root**:
60
+ **Step 2 — wire your client (no config file needed).** The guard ships with **built-in defaults that protect `develop` (integration) + `main` (archive)** — zero configuration, on by default. The only thing you need is to tell your AI client to invoke the guard, with one command per stdin-hook client (DSH is wired automatically; Pi just copies a file, see below):
61
61
 
62
- ```jsonc
63
- {
64
- "enabled": true,
65
- "featurePattern": "feature/[\\w-]+",
66
- "branches": {
67
- "integration": ["develop"], // integration: features merge in via PR, protected
68
- "archive": ["main"] // archive: archived by you after release
69
- }
70
- }
62
+ ```bash
63
+ # Claude Code → this repo's .claude/settings.json
64
+ gitflow-guard wire --client claude --project --yes
65
+ ```
66
+
67
+ ```bash
68
+ # Codex / OpenCode / Antigravity (each its own config file; --yes skips the y/N prompt)
69
+ gitflow-guard wire --client codex --project --yes
70
+ gitflow-guard wire --client opencode --project --yes
71
+ gitflow-guard wire --client antigravity --project --yes # experimental
72
+ ```
73
+
74
+ ```bash
75
+ # Preview (no writes) / remove / interactive guide:
76
+ gitflow-guard wire --client claude --dry-run
77
+ gitflow-guard wire --client claude --unwire
78
+ gitflow-guard setup
71
79
  ```
72
80
 
73
- This one file is the entire setup: `integration` is the **only required** role; `preview` / `production` / `archive` are optionaladd them only if your flow needs them. The plugin is opt-in per project absent or `enabled: false`, it does nothing.
81
+ `wire` merges into your existing config **non-destructively** (already-present hooks are left untouched), and writes to your **project dir by default**`--global` (all repos on this machine) always asks first or needs `--yes`. Per-client files and formats are mirrored in [Installation in detail](#installation-in-detail).
82
+
83
+ > ⚠️ **main is protected by default.** Trunk / single-branch users (everyone pushes straight to one branch) will get blocked on direct `main` pushes until they opt out — create `gitflow-guard.config.json` with `{ "enabled": false }`, or map your own branches (see [Configuration Reference](#configuration-reference)). `gitflow-guard status` repeats this notice whenever the built-in defaults are in effect.
74
84
 
75
- **Step 3 — verify.** Ask the agent (or run in a DSH session) to `git push origin develop`. Expect the tool call to be denied:
85
+ **Step 3 — verify.** Ask the agent to `git push origin develop`. Expect the tool call to be denied:
76
86
 
77
87
  ```text
78
88
  Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
79
89
  Next: Integration branch (develop) is updated via PR/MR from a feature branch: push the feature first, then `gh pr create --base develop` / `glab mr create --target-branch develop`.
80
90
  ```
81
91
 
82
- Messages are English by default; add `"locale": "zh"` to the config to switch to Chinese (see [Configuration Reference](#configuration-reference)).
92
+ Messages are English by default; create a config with `"locale": "zh"` to switch to Chinese — messages then read like: *已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……* (see [Configuration Reference](#configuration-reference)).
83
93
 
84
- **Done.** The guard is live for this repo. Keep reading for the [Configuration](#configuration-reference) to map your own branches, or the [Gate Matrix](#gate-matrix--what-gets-blocked-what-passes) for the full decision table.
94
+ **Done.** The guard is live for this repo with the built-in defaults. Want more stages (`preview` / `production`) or different branch names? Write a `gitflow-guard.config.json` and only the fields you care about — everything else keeps the built-in defaults. For the full decision table, see the [Gate Matrix](#gate-matrix--what-gets-blocked-what-passes).
85
95
 
86
96
  ### Full walkthrough — one feature, end to end
87
97
 
@@ -139,7 +149,7 @@ Nobody has to remember the rules — the rules are enforced.
139
149
  ## What it does — capabilities
140
150
 
141
151
  - **Blocks, before execution**: direct push / force-push / delete of protected role branches (integration / preview / production / archive); agent merging into production or archive.
142
- - **Role-driven, fully configurable**: `integration` is the only required role; `preview` / `production` / `archive` are optional arrays of branch names or regexes, each with its own update rules (`pr` / `flexible`, `mergeBy`).
152
+ - **Role-driven, fully configurable**: `integration` (built-in default: `develop`) is the core role; `preview` / `production` / `archive` are optional arrays of branch names or regexes, each with its own update rules (`pr` / `flexible`, `mergeBy`).
143
153
  - **Merge-by-user where it matters**: production & archive merges stay in your hands — the plugin blocks the agent from clicking merge, so your action *is* the confirmation.
144
154
  - **Works with any naming**: branch names are mapped by your config, never hard-coded (see [Configuration](#configuration-reference)).
145
155
  - **Fully audited**: every deny is appended to an audit log under your user state directory (`~/.local/state/gitflow-guard/`, `%LOCALAPPDATA%\gitflow-guard` on Windows) — outside the repository, never committed, outside the agent's writable sandbox, and shared across all linked worktrees of one repository.
@@ -186,7 +196,7 @@ No chat-confirmation or permit store: sensitive merges (production / archive) ar
186
196
 
187
197
  #### 1. Config is the single source of truth
188
198
 
189
- Nothing about branch names or rules is hard-coded. `integration` is the only required role; `preview` / `production` / `archive` are optional arrays of exact names or regexes, each with its own `update` and `mergeBy`. The same binary scales from a solo `develop` to an enterprise multi-env pipeline.
199
+ Nothing about branch names or rules is hard-coded. `integration` ships as a built-in default (`develop`); `preview` / `production` / `archive` are optional arrays of exact names or regexes, each with its own `update` and `mergeBy` — deep-merged over the defaults. The same binary scales from a solo `develop` to an enterprise multi-env pipeline.
190
200
 
191
201
  #### 2. Blocking happens before execution, not after
192
202
 
@@ -200,9 +210,28 @@ No plugin code decides "is this merge OK?" for production or archive. The gate s
200
210
 
201
211
  ## Configuration Reference
202
212
 
213
+ ### Built-in defaults & deep-merge override
214
+
215
+ The guard is **on by default** — no `gitflow-guard.config.json` needed. It protects:
216
+
217
+ | default | role | rule |
218
+ |---|---|---|
219
+ | `develop` | **integration** | no direct push; updates via PR/MR (`update: "pr"`) |
220
+ | `main` | **archive** | no direct push / no agent merge; the archive merge is yours (`mergeBy: "user"`) |
221
+
222
+ When you do create `gitflow-guard.config.json`, its fields are **deep-merged over the defaults**: each field/role you write replaces the default for that field/role, everything you don't write keeps the default. Write only what you want to change:
223
+
224
+ ```jsonc
225
+ {
226
+ "branches": { "production": ["release-[\\w-]+"] } // defaults keep develop+main; production is added
227
+ }
228
+ ```
229
+
230
+ **Disable entirely** (trunk / single-branch flows): `{ "enabled": false }`. Fixing an accidental block is a one-file change, and `gitflow-guard status` always explains what is in effect (including when it is the built-in defaults).
231
+
203
232
  ### Branch roles — the model behind the checks
204
233
 
205
- Only **`integration`** is required. Every other role is optional configure what your flow actually uses, and each entry is an exact branch name **or** a regex pattern.
234
+ A **role** maps branch names (or regexes) to a rule set. `integration` is provided by the defaults; every other role is optional.
206
235
 
207
236
  ```text
208
237
  feature branches ──(free)──> integration (integration branch; updates via PR/MR)
@@ -216,10 +245,10 @@ archive (optional; you archive after release)
216
245
  | role | config key | required? | enforced behavior |
217
246
  |---|---|---|---|
218
247
  | **feature** | `featurePattern` | — | free: commit / push / sync / rebase |
219
- | **integration** | `branches.integration` | always | no direct push (default `pr`); features merge in via PR/MR |
248
+ | **integration** | `branches.integration` | default (`develop`) | no direct push (default `pr`); features merge in via PR/MR |
220
249
  | **preview** | `branches.preview` (array) | optional | no direct push; updates via PR/MR only (env endpoints) |
221
250
  | **production** | `branches.production` (array) | optional | PR/MR only; merge by user only (`mergeBy: "user"`) |
222
- | **archive** | `branches.archive` (array) | optional | archive PR/MR may be created by agents; the merge stays user-hand only |
251
+ | **archive** | `branches.archive` (array) | default (`main`) | archive PR/MR may be created by agents; the merge stays user-hand only |
223
252
 
224
253
  ### Customizing branch names & rules — any naming works
225
254
 
@@ -259,10 +288,10 @@ archive (optional; you archive after release)
259
288
 
260
289
  ```jsonc
261
290
  {
262
- "enabled": true, // opt-in: file exists AND enabled=true
291
+ "enabled": true, // default true set false to turn the guard off
263
292
  "featurePattern": "feature/[\\w-]+", // JS regex matching your working/feature branches
264
293
  "branches": {
265
- "integration": { "branches": ["develop"], "update": "pr" }, // REQUIRED
294
+ "integration": { "branches": ["develop"], "update": "pr" }, // default: ["develop"] — omit to keep
266
295
  "preview": { "branches": ["ita1"], "update": "pr" }, // optional
267
296
  "production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // optional
268
297
  "archive": ["main"] // optional
@@ -288,8 +317,8 @@ archive (optional; you archive after release)
288
317
  registerLocale('fr', fr)
289
318
  ```
290
319
  - **Unknown locales**: an unregistered `"locale"` value falls back to English during interception (by design — hooks never stall on wording), so a typo is easy to miss; the one-line warning shows up in `gitflow-guard status`.
291
- - **Validation**: `integration` is required; overlapping role entries are rejected; invalid regex is rejected. **Any error disables the plugin for that project** (reported) rather than applying a half-guessed setup.
292
- - **Strict mode**: by default a broken config warns on stderr once and lets the command pass (fail-open, so a typo can't wedge your tooling). `"strict": true` flips config errors and internal errors to **block** (fail-closed) — for high-risk repos. A missing file or explicit `enabled: false` stays silent either way.
320
+ - **Validation**: overlapping role entries are rejected; invalid regex is rejected. **Any config error reverses the project to "not enabled"** (reported) rather than applying a half-guessed setup; watch out that a role you override with the same branch name as a default role (e.g. mapping `main` to integration while the default archive is still `main`) is an overlap error — cover or drop the other role too.
321
+ - **Strict mode**: by default a broken config warns on stderr once and lets the command pass (fail-open, so a typo can't wedge your tooling). `"strict": true` flips config errors and internal errors to **block** (fail-closed) — for high-risk repos. An explicit `enabled: false` stays silent; a *missing* file is not an error anymore — the built-in defaults (develop+main) are in effect.
293
322
 
294
323
  ---
295
324
 
@@ -327,14 +356,14 @@ The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab
327
356
 
328
357
  | Agent | Install command | After that |
329
358
  |---|---|---|
330
- | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.19` | restart DSH — the plugin auto-mounts as a profile layer |
331
- | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.19` | wire a hook to the `gitflow-guard` binary (below) |
332
- | Pi | `npm i -D agents-gitflow-guard@0.0.19` | copy `pi/gitflow-guard.ts` into `.pi/extensions/` (below) |
359
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.21` | restart DSH — the plugin auto-mounts as a profile layer |
360
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.21` | `gitflow-guard wire --client <name>` one command per client (below) |
361
+ | Pi | `npm i -D agents-gitflow-guard@0.0.21` | copy `pi/gitflow-guard.ts` into `.pi/extensions/` (below) |
333
362
 
334
363
  **DSH — in-process plugin** (the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo)):
335
364
 
336
365
  ```bash
337
- dsh plugin --profile web add agents-gitflow-guard@0.0.19 # pin recommended, see note above
366
+ dsh plugin --profile web add agents-gitflow-guard@0.0.21 # pin recommended, see note above
338
367
  ```
339
368
 
340
369
  Then restart DSH. Upgrades are the same command, followed by another restart.
@@ -348,13 +377,17 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
348
377
 
349
378
  The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes it a profile layer — no manual profile editing.
350
379
 
351
- **Standalone agent hooks** — Claude Code / Codex / OpenCode / Antigravity, no DSH required. Install the CLI once, then reference the `gitflow-guard` binary in each hook config:
380
+ **Standalone agent hooks** — Claude Code / Codex / OpenCode / Antigravity, no DSH required. Install the CLI once, then wire one client per command (the guard is on by default via its built-in config, so wiring is all that remains):
352
381
 
353
382
  ```bash
354
- npm i -g agents-gitflow-guard@0.0.19 # provides the `gitflow-guard` binary
383
+ npm i -g agents-gitflow-guard@0.0.21 # provides the `gitflow-guard` binary
384
+ gitflow-guard wire --client claude --project --yes
385
+ gitflow-guard wire --client codex --project --yes
386
+ gitflow-guard wire --client opencode --project --yes
387
+ gitflow-guard wire --client antigravity --project --yes # experimental
355
388
  ```
356
389
 
357
- This repo ships project configs at `.claude/settings.json` (Claude Code), `.codex/hooks.json` (Codex), `.opencode/hook/hooks.yaml` (OpenCode) and `.agents/hooks.json` (Antigravity / Google); any other repo adds its own:
390
+ `wire` reads the existing config file (if any), merges the hook entry in without touching anything else, is idempotent (already wired → skipped), supports `--dry-run` to preview and `--unwire` to remove, and asks before touching `--global` files. The exact files it writes (for reference, and for hand-writing instead of `wire`) are:
358
391
 
359
392
  ```jsonc
360
393
  // Claude Code — .claude/settings.json
@@ -407,7 +440,7 @@ hooks:
407
440
  Pi loads extensions in-process (no stdin payload, no subprocess hook). Install the shipped entry point into the project and keep the package in devDependencies:
408
441
 
409
442
  ```bash
410
- npm i -D agents-gitflow-guard@0.0.19
443
+ npm i -D agents-gitflow-guard@0.0.21
411
444
  mkdir -p .pi/extensions
412
445
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
413
446
  ```
@@ -421,7 +454,8 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
421
454
  - The hook reads the payload on stdin and answers with that platform's protocol: Claude Code / OpenCode → `exit 2` (stderr is the reason + "next step" hint); Codex → JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}` on stdout; Antigravity → JSON `{"decision":"deny","reason":...}` on stdout with `exit 0` (Antigravity requires exit 0 and rejects `hookSpecificOutput` / non-allow values). Pi has no stdin protocol: the in-process extension listens to the official `tool_call` event and denies via its return value `{ block: true, reason }` (the CLI subprocess only speaks the internal exit-2 contract).
422
455
  - Only the pre-tool event is needed: the guard blocks *before* the command runs. There is no permit to consume afterwards, so no post-tool hooks are required.
423
456
  - The examples above call the globally-installed `gitflow-guard` (`npm i -g`). If a hook subprocess can't see it on its `PATH`, point at the full binary path from `npm bin -g` — hook subprocesses may not inherit your interactive shell `PATH`. The `bin/gitflow-guard.mjs` paths in this repo's shipped configs only work from a checkout.
424
- - Fully opt-in: the hook does nothing unless the repo has `gitflow-guard.config.json` with `enabled: true`.
457
+ - **On by default**: the built-in config (integration=`develop`, archive=`main`) is active without any file. Trunk / single-branch repos: create `gitflow-guard.config.json` with `{ "enabled": false }`, or map your own branches. A custom config **deep-merges** over the defaults — write only the fields you want to change.
458
+ - `wire` never removes or rewrites your existing hook entries — it only adds its own command (deduped) and, with `--unwire`, removes exactly that command again.
425
459
 
426
460
  ---
427
461
 
@@ -429,7 +463,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
429
463
 
430
464
  ### My branches don't follow the default names — can I use it?
431
465
 
432
- Yes — nothing about the branch names is fixed. `integration` is the only required role; its entries (and those of `preview` / `production` / `archive`) are any exact branch names or regex patterns you like. `featurePattern` tells the plugin how to recognize your working branches.
466
+ Yes — nothing about the branch names is fixed. `integration` ships as a built-in default (`develop`) and any custom config deep-merges over it; its entries (and those of `preview` / `production` / `archive`) are any exact branch names or regex patterns you like. `featurePattern` tells the plugin how to recognize your working branches.
433
467
 
434
468
  A team that calls its integration branch `master`, adds a `beta` preview, and prefixes feature branches with `fix/` writes exactly that into the config; every block, report, and audit then speaks those names. There is no convention you must adopt — only a mapping you declare. See [Customizing branch names & rules](#customizing-branch-names--rules--any-naming-works).
435
469
 
@@ -475,7 +509,7 @@ The blocks are reserved for: (1) direct writes to protected role branches, and (
475
509
 
476
510
  A half-guessed setup is never applied by accident: any validation error disables the guard for that project and reports the errors.
477
511
 
478
- Common mistakes: missing `integration` (required), overlapping a branch across two roles (rejected explicitly), and a `featurePattern` that doesn't compile (rejected as invalid regex). The failure is loud and the file is one JSON object, so the fix is usually a thirty-second correction.
512
+ Common mistakes: overriding a role with the same branch name as a default role (e.g. `main` as integration while the default archive is still `main` — an explicit overlap error; cover or drop the other role too), overlapping a branch across two roles (rejected), and a `featurePattern` that doesn't compile (rejected as invalid regex). The failure is loud and the file is one JSON object, so the fix is usually a thirty-second correction.
479
513
 
480
514
  ---
481
515
 
@@ -498,10 +532,10 @@ If it saves your team from a shortcut gone wrong, the coffee button at the top o
498
532
 
499
533
  | term | meaning |
500
534
  |---|---|
501
- | **integration** | the branch and only required role (`branches.integration`); features merge in via PR/MR; protected |
535
+ | **integration** | the core role (built-in default: `develop`); features merge in via PR/MR; protected |
502
536
  | **preview** | optional env-endpoint branches (`branches.preview`, array); updates via PR/MR only |
503
537
  | **production** | optional production branches (`branches.production`, array); PR/MR + merge by user only |
504
- | **archive** | optional post-release archive branch (`branches.archive`); user-hand only |
538
+ | **archive** | optional post-release archive branch (`branches.archive`, array); agents may create PR/MRs into it, but the merge stays user-hand only |
505
539
  | **feature branch** | your working branch, matched by `featurePattern`; free zone |
506
540
  | **gate matrix** | the decision table mapping each classified command to allow/deny |
507
541
  | **pre-execute** | the tool-pipeline hook where denial happens — before the command runs |
package/README.zh.md CHANGED
@@ -39,37 +39,47 @@
39
39
 
40
40
  ```bash
41
41
  # DSH —— 进程内插件(装完重启 DSH; 插件在进程启动时加载)
42
- dsh plugin --profile web add agents-gitflow-guard@0.0.19
42
+ dsh plugin --profile web add agents-gitflow-guard@0.0.21
43
43
  ```
44
44
 
45
45
  ```bash
46
46
  # Claude Code · Codex · OpenCode · Antigravity —— 独立 hook,不需要 DSH
47
- npm i -g agents-gitflow-guard@0.0.19
47
+ npm i -g agents-gitflow-guard@0.0.21
48
48
  ```
49
49
 
50
50
  ```bash
51
51
  # Pi —— 进程内扩展
52
- npm i -D agents-gitflow-guard@0.0.19
52
+ npm i -D agents-gitflow-guard@0.0.21
53
53
  ```
54
54
 
55
55
  > **版本坑**: 裸 `add` 或不带版本的 `npm i` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。(DSH 用户: pnpm 打印的 peer 依赖 *警告* 属预期——DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`,插件正常工作。)
56
56
  >
57
- > hook 客户端(Claude Code · Codex · OpenCode · Antigravity)Pi 装完还要各做一步接线——见[安装详解](#安装详解)的逐客户端表。
57
+ > hook 客户端(Claude Code · Codex · OpenCode · Antigravity)装完还要各做一步接线——**每个客户端一条命令**(见下)。Pi 拷贝一个文件;DSH 装完即已接线。
58
58
 
59
- **第 2 步——配置**,在**项目根目录**创建 `gitflow-guard.config.json`:
59
+ **第 2 步——接线(无需配置文件)。** 守卫内置**默认配置,开箱即用:默认保护 `develop`(integration)+ `main`(archive)**,零配置。你要做的只是让 AI 客户端去调用守卫——每个 stdin-hook 客户端一条命令(DSH 自动接线;Pi 拷文件,见下):
60
60
 
61
- ```jsonc
62
- {
63
- "enabled": true,
64
- "featurePattern": "feature/[\\w-]+",
65
- "branches": {
66
- "integration": ["develop"], // 集成分支: feature 经 PR 合入, 受保护
67
- "archive": ["main"] // 归档分支: 发布后由你亲手合入
68
- }
69
- }
61
+ ```bash
62
+ # Claude Code → 本仓库 .claude/settings.json
63
+ gitflow-guard wire --client claude --project --yes
64
+ ```
65
+
66
+ ```bash
67
+ # Codex / OpenCode / Antigravity(各写各的配置文件; --yes 跳过 y/N 确认)
68
+ gitflow-guard wire --client codex --project --yes
69
+ gitflow-guard wire --client opencode --project --yes
70
+ gitflow-guard wire --client antigravity --project --yes # 实验支持
71
+ ```
72
+
73
+ ```bash
74
+ # 只预览不写入 / 移除 / 交互向导:
75
+ gitflow-guard wire --client claude --dry-run
76
+ gitflow-guard wire --client claude --unwire
77
+ gitflow-guard setup
70
78
  ```
71
79
 
72
- 这一个文件就是全部配置:其中的 **`integration` 是唯一必填**角色;`preview` / `production` / `archive` 都是可选,只有你配了才启用对应关卡。插件按项目 opt-in——文件不存在或 `enabled: false` 时什么都不做。
80
+ `wire` 对已有配置**非破坏性合并**(已存在的 hook 不动),默认只写**当前工程目录**;`--global`(本机所有仓库)写入前必先确认或需 `--yes`。各客户端的文件与格式见[安装详解](#安装详解)。
81
+
82
+ > ⚠️ **main 默认受保护。** trunk / 单分支工作流(所有人直推同一条分支)的用户,装完第一次直推 `main` 就会被拦——创建 `gitflow-guard.config.json` 写 `{ "enabled": false }`,或自行映射分支(见[配置参考](#配置参考))。`gitflow-guard status` 在默认配置生效时也会反复提示这一点。
73
83
 
74
84
  **第 3 步——验证**。让 agent 执行 `git push origin develop`,预期工具调用被拒绝:
75
85
 
@@ -78,9 +88,9 @@ Error: [gitflow-guard] blocked: Protected branch "develop" forbids direct push
78
88
  Next: Integration branch (develop) is updated via PR/MR from a feature branch: push the feature first, then `gh pr create --base develop` / `glab mr create --target-branch develop`.
79
89
  ```
80
90
 
81
- **文案默认是英文**(面向国际化)。要在你的项目里看中文,在 `gitflow-guard.config.json` 里加 `"locale": "zh"`;中文效果是:*已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……*
91
+ **文案默认是英文**(面向国际化)。要在你的项目里看中文,创建配置并加 `"locale": "zh"`;中文效果是:*已拦截:受保护分支「develop」禁止直推 / 下一步:集成分支(develop)由 PR/MR 合入 feature……*
82
92
 
83
- **完成。** 守卫对该仓库生效。继续往下看[配置参考](#配置参考)映射自己的分支,或看[门禁矩阵](#门禁矩阵拦什么放什么)的完整判定表。
93
+ **完成。** 守卫已用内置默认配置生效。想要更多关卡(`preview` / `production`)或改分支名?写一个 `gitflow-guard.config.json`,只写你在意的字段,其余保持内置默认。完整判定表见[门禁矩阵](#门禁矩阵拦什么放什么)
84
94
 
85
95
  ### 完整实战示例——一个 feature 的端到端旅程
86
96
 
@@ -105,7 +115,9 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
105
115
 
106
116
  **这是软规则。** Agent 会跳过、重排、干脆"忘记"它——不是因为恶意,而是因为软指令对模型来说本来就是可选的。
107
117
 
108
- 这个插件把软规则变成**硬机制**。agent 每次尝试的 git 操作都会对照*本地仓库的真实状态*检查;违规在命令执行前就被拦截,并给出原因和下一步。没人需要记得规则——规则被强制执行。
118
+ 这个插件把软规则变成**硬机制**。agent 每次尝试的 git 操作都会对照*本地仓库的真实状态*检查;违规在命令执行前就被拦截,并给出原因和下一步。
119
+
120
+ 没人需要记得规则——规则被强制执行。
109
121
 
110
122
  ---
111
123
 
@@ -136,7 +148,7 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
136
148
  ## 它能做什么
137
149
 
138
150
  - **执行前拦截**:直推 / 强推 / 删除受保护角色分支(integration / preview / production / archive);agent 试图合入生产或归档。
139
- - **角色驱动、完全可配**:`integration` 是唯一必填;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个角色独立 `update`(`pr` / `flexible`)与 `mergeBy`。
151
+ - **角色驱动、完全可配**:`integration`(内置默认 `develop`)是核心角色;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个角色独立 `update`(`pr` / `flexible`)与 `mergeBy`,自定义配置深度合并于默认之上。
140
152
  - **在关键处保留人的操作权**:生产与归档合并始终在你手上——插件阻止 agent 点击合并,于是你的动作*就是*确认。
141
153
  - **任何命名都行**:分支名全由配置映射,绝无硬编码(见[配置参考](#配置参考))。
142
154
  - **全程审计**:每次拦截都追加到用户级状态目录(macOS/Linux `~/.local/state/gitflow-guard/`,Windows `%LOCALAPPDATA%\gitflow-guard`)下的审计日志——在仓库外、绝不进版本库、位于 agent 可写沙箱之外,且同一仓库的所有 worktree 共享一份日志。
@@ -183,7 +195,7 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
183
195
 
184
196
  #### 1. 配置是唯一事实来源
185
197
 
186
- 分支名和规则没有任何硬编码。`integration` 是唯一必填角色;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个都有自己的 `update` 与 `mergeBy`。同一个二进制从单条 `develop` 一直可扩到企业多环境流水线。
198
+ 分支名和规则没有任何硬编码。`integration` 以内置默认(`develop`)提供;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个都有自己的 `update` 与 `mergeBy` — 在默认之上深度合并。同一个二进制从单条 `develop` 一直可扩到企业多环境流水线。
187
199
 
188
200
  #### 2. 拦截发生在执行前,不是执行后
189
201
 
@@ -197,9 +209,28 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
197
209
 
198
210
  ## 配置参考
199
211
 
212
+ ### 内置默认配置 + 深度合并覆盖
213
+
214
+ 守卫**默认开启**——不需要 `gitflow-guard.config.json`。默认保护:
215
+
216
+ | 默认值 | 角色 | 规则 |
217
+ |---|---|---|
218
+ | `develop` | **integration** | 禁直推;只经 PR/MR 合入(`update: "pr"`) |
219
+ | `main` | **archive** | 禁直推 / 禁 agent 合并;归档合并留给你(`mergeBy: "user"`) |
220
+
221
+ 当你创建 `gitflow-guard.config.json` 时,它的字段会**深度合并覆盖默认**:写到的字段/角色替换默认,没写的保持默认。只写你想改的:
222
+
223
+ ```jsonc
224
+ {
225
+ "branches": { "production": ["release-[\\w-]+"] } // 默认的 develop+main 不变;新增 production
226
+ }
227
+ ```
228
+
229
+ **完全关闭**(trunk / 单分支流程):`{ "enabled": false }`。误拦时改一个文件即可恢复;`gitflow-guard status` 始终说明当前生效的是内置默认还是自定义配置。
230
+
200
231
  ### 分支角色——插件校验的模型
201
232
 
202
- 只有 **`integration`** 是必填。其余全部可选——按你的流程配就好,每条目可以是精确分支名**或**正则。
233
+ **角色**把分支名(或正则)映射到规则集。`integration` 由内置默认提供;其余角色全部可选。
203
234
 
204
235
  ```text
205
236
  feature 分支 ──(自由)──> integration(集成分支, PR/MR 合入)
@@ -213,10 +244,10 @@ archive(可选, 发布后你亲手归档)
213
244
  | 角色 | 配置键 | 必填? | 强制行为 |
214
245
  |---|---|---|---|
215
246
  | **feature** | `featurePattern` | — | 自由: commit / push / 同步 / rebase |
216
- | **integration** | `branches.integration` | 必填 | 禁直推(默认 `pr`);feature 只经 PR/MR 合入 |
247
+ | **integration** | `branches.integration` | 默认(`develop`) | 禁直推(默认 `pr`);feature 只经 PR/MR 合入 |
217
248
  | **preview** | `branches.preview`(数组) | 可选 | 禁直推;只走 PR/MR(环境终点) |
218
249
  | **production** | `branches.production`(数组) | 可选 | 只走 PR/MR;合并仅限你(`mergeBy: "user"`) |
219
- | **archive** | `branches.archive`(数组) | 可选 | 允许 agent 创建指向它的 PR/MR; 合并仍限用户亲手 |
250
+ | **archive** | `branches.archive`(数组) | 默认(`main`) | 允许 agent 创建指向它的 PR/MR; 合并仍限用户亲手 |
220
251
 
221
252
  ### 自定义分支名与规则——任何命名都可以
222
253
 
@@ -256,10 +287,10 @@ archive(可选, 发布后你亲手归档)
256
287
 
257
288
  ```jsonc
258
289
  {
259
- "enabled": true, // opt-in: 文件存在且 enabled=true
290
+ "enabled": true, // 默认 true — 写 false 即关闭守卫
260
291
  "featurePattern": "feature/[\\w-]+", // 识别工作/feature 分支的 JS 正则
261
292
  "branches": {
262
- "integration": { "branches": ["develop"], "update": "pr" }, // 必填
293
+ "integration": { "branches": ["develop"], "update": "pr" }, // 默认 ["develop"] — 省略即保持默认
263
294
  "preview": { "branches": ["ita1"], "update": "pr" }, // 可选
264
295
  "production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // 可选
265
296
  "archive": ["main"] // 可选
@@ -284,8 +315,8 @@ archive(可选, 发布后你亲手归档)
284
315
  registerLocale('fr', fr)
285
316
  ```
286
317
  - **未注册语言**:拦截路径对未注册的 `"locale"` 静默回退英文(设计如此——hook 不因文案缺失卡死),笔误因此容易被忽略;一行告警在 `gitflow-guard status` 中可见。
287
- - **校验**:`integration` 必填;角色条目重叠会被拒;非法正则会报错。**任何错误都会让该项目的插件禁用并上报**(而不是用半吊子配置)
288
- - **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)——供高风险仓库选用。文件不存在或显式 `enabled: false` 两种模式下都保持静默。
318
+ - **校验**:角色条目重叠会被拒;非法正则会报错。**任何配置错误都会让该项目的守卫回退为"未启用"并上报**(而不是用半吊子配置)。注意:你覆盖的角色若与默认角色同名(如把 `main` 映射为 integration 而默认 archive 仍是 `main`)会触发重叠报错——需一并覆盖或去掉另一角色。
319
+ - **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)——供高风险仓库选用。显式 `enabled: false` 保持静默;而*文件不存在*不再是"未启用"——内置默认(develop+main)直接生效。
289
320
 
290
321
  ---
291
322
 
@@ -323,14 +354,14 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
323
354
 
324
355
  | 客户端 | 安装命令 | 装完再做什么 |
325
356
  |---|---|---|
326
- | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.19` | 重启 DSH——插件自动挂为 profile 层 |
327
- | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.19` | 在各自 hook 配置里指向 `gitflow-guard` 二进制(见下) |
328
- | Pi | `npm i -D agents-gitflow-guard@0.0.19` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
357
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.21` | 重启 DSH——插件自动挂为 profile 层 |
358
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.21` | `gitflow-guard wire --client <名>`——每个客户端一条命令(见下) |
359
+ | Pi | `npm i -D agents-gitflow-guard@0.0.21` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
329
360
 
330
361
  **DSH —— 进程内插件**(标准路径,已在[快速开始](#快速开始30-秒用上)覆盖):
331
362
 
332
363
  ```bash
333
- dsh plugin --profile web add agents-gitflow-guard@0.0.19 # 建议锁版本, 见上文提示
364
+ dsh plugin --profile web add agents-gitflow-guard@0.0.21 # 建议锁版本, 见上文提示
334
365
  ```
335
366
 
336
367
  然后重启 DSH。升级用同一命令,再重启一次。
@@ -344,13 +375,17 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
344
375
 
345
376
  包自带 `dsh.bundle.patch` 声明,`dsh plugin add` 自动把它挂为 profile 层,无需手工编辑 profile。
346
377
 
347
- **各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI,然后引用 `gitflow-guard` 二进制:
378
+ **各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI,然后**每客户端一条命令接线**(守卫凭内置默认配置已默认开启,接线是唯一剩下的事):
348
379
 
349
380
  ```bash
350
- npm i -g agents-gitflow-guard@0.0.19 # 提供 `gitflow-guard` 二进制
381
+ npm i -g agents-gitflow-guard@0.0.21 # 提供 `gitflow-guard` 二进制
382
+ gitflow-guard wire --client claude --project --yes
383
+ gitflow-guard wire --client codex --project --yes
384
+ gitflow-guard wire --client opencode --project --yes
385
+ gitflow-guard wire --client antigravity --project --yes # 实验支持
351
386
  ```
352
387
 
353
- 本仓库已自带 `.claude/settings.json`(Claude Code)、`.codex/hooks.json`(Codex)、`.opencode/hook/hooks.yaml`(OpenCode) `.agents/hooks.json`(Antigravity / Google);其他仓库加自己的 hooks:
388
+ `wire` 读取已有配置文件(如有)并把 hook 条目合入——不碰其他内容、幂等(已接则跳过)、支持 `--dry-run` 预览与 `--unwire` 移除、写 `--global` 前必先询问。它写入的准确文件(供参考,也可代替 `wire` 手写)是:
354
389
 
355
390
  ```jsonc
356
391
  // Claude Code — .claude/settings.json
@@ -403,7 +438,7 @@ hooks:
403
438
  Pi 以进程内扩展装载(没有 stdin payload,也没有子进程 hook)。把随包发布的入口装进项目、包留在 devDependencies:
404
439
 
405
440
  ```bash
406
- npm i -D agents-gitflow-guard@0.0.19
441
+ npm i -D agents-gitflow-guard@0.0.21
407
442
  mkdir -p .pi/extensions
408
443
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
409
444
  ```
@@ -417,7 +452,8 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
417
452
  - hook 读 stdin payload,按**各平台协议**作答:Claude Code / OpenCode → `exit 2`(stderr 展示原因 + "下一步"提示);Codex → stdout 输出 JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}`;Antigravity → stdout 输出 `{"decision":"deny","reason":...}` 且 **exit 0**(Antigravity 要求 exit 0,拒绝 hookSpecificOutput/非 allow 值)。Pi 没有 stdin 协议:进程内扩展监听官方 `tool_call` 事件,经返回值 `{ block: true, reason }` 拒绝(守卫 CLI 子进程只承载内部 exit-2 契约)。
418
453
  - 只需要**执行前事件**:守卫在命令执行*之前*拦截;没有特许可事后消费,因此无需执行后钩子。
419
454
  - 上面示例调的是全局安装的 `gitflow-guard`(`npm i -g`)。若 hook 子进程在它的 `PATH` 里找不到,就指向 `npm bin -g` 给出的完整二进制路径——hook 子进程不一定继承你交互 shell 的 PATH。本仓库自带配置里的 `bin/gitflow-guard.mjs` 路径只对 checkout 贡献者有效。
420
- - 完全 opt-in:仓库没有 `gitflow-guard.config.json`(或 `enabled` true)时 hook 什么都不做。
455
+ - **默认开启**:内置默认配置(integration=`develop`, archive=`main`)无需任何文件即生效。trunk / 单分支仓库:建 `gitflow-guard.config.json` `{ "enabled": false }`,或自行映射分支。自定义配置在默认之上**深度合并**——只写你想改的字段。
456
+ - `wire` 从不删除或重写你已有的 hook 条目——只添加自己的命令(去重);`--unwire` 精确移除同一条命令。
421
457
 
422
458
  ---
423
459
 
@@ -425,7 +461,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
425
461
 
426
462
  ### 我的分支不叫默认名字,能用吗?
427
463
 
428
- 能用——分支名没有任何写死。`integration` 是唯一必填;它的条目(以及 `preview`/`production`/`archive` 的)可以是任意精确分支名或正则。`featurePattern` 告诉插件怎么认你的工作分支。
464
+ 能用——分支名没有任何写死。`integration` 由内置默认提供(`develop`),自定义配置在默认之上深度合并;它的条目(以及 `preview`/`production`/`archive` 的)可以是任意精确分支名或正则。`featurePattern` 告诉插件怎么认你的工作分支。
429
465
 
430
466
  把集成分支叫 `master`、加一个 `beta` 预览、feature 前缀用 `fix/`——写进配置即可;拦截、报告、审计都跟着你的命名走。没有任何你必须遵守的约定,只有你声明的映射。见[自定义分支名与规则](#自定义分支名与规则任何命名都可以)。
431
467
 
@@ -471,7 +507,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
471
507
 
472
508
  半吊子配置绝不会意外生效:任何校验错误都会让该项目的守卫禁用并上报错误。
473
509
 
474
- 常见错误:`integration` 缺失(必填)、同一个分支被配到两个角色里(显式拒绝)、`featurePattern` 写不成合法正则(报错)。失败提示很明确,文件又是一个 JSON 对象,通常三十秒改好。
510
+ 常见错误:覆盖的角色与默认角色同名(如把 `main` 设为 integration 而默认 archive 仍是 `main`——显式重叠报错,需一并覆盖或去掉另一角色)、同一个分支被配到两个角色里(显式拒绝)、`featurePattern` 写不成合法正则(报错)。失败提示很明确,文件又是一个 JSON 对象,通常三十秒改好。
475
511
 
476
512
  ---
477
513
 
@@ -494,7 +530,7 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
494
530
 
495
531
  | 术语 | 含义 |
496
532
  |---|---|
497
- | **integration** | 集成分支,唯一必填角色(`branches.integration`);feature 经 PR/MR 合入;受保护 |
533
+ | **integration** | 集成分支,核心角色(内置默认 `develop`);feature 经 PR/MR 合入;受保护 |
498
534
  | **preview** | 可选环境终点分支(`branches.preview`,数组);只走 PR/MR 更新 |
499
535
  | **production** | 可选生产分支(`branches.production`,数组);PR/MR + 合并仅限用户 |
500
536
  | **archive** | 可选的发布后归档分支(`branches.archive`,数组);允许 agent 创建指向它的 PR/MR,合并仍限用户亲手 |
package/lib/cli.mjs CHANGED
@@ -1,6 +1,8 @@
1
- import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-CL6uB3oo.mjs";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
1
+ import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-DPJRoEJq.mjs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline";
4
6
  //#region src/platform.ts
5
7
  function str(v) {
6
8
  return typeof v === "string" ? v : "";
@@ -84,6 +86,213 @@ function encodeDeny(platform, reason) {
84
86
  }
85
87
  }
86
88
  //#endregion
89
+ //#region src/wire.ts
90
+ const CLIENTS = [
91
+ "dsh",
92
+ "claude",
93
+ "codex",
94
+ "opencode",
95
+ "antigravity",
96
+ "pi"
97
+ ];
98
+ function isWireClient(v) {
99
+ return CLIENTS.includes(v);
100
+ }
101
+ /** 各客户端的 hook 落位规格(dsh/pi 无 hook 文件, 仅输出接入引导) */
102
+ const WIRE_CLIENTS = [
103
+ {
104
+ client: "claude",
105
+ projectPath: ".claude/settings.json",
106
+ globalPath: () => join(homedir(), ".claude", "settings.json")
107
+ },
108
+ {
109
+ client: "codex",
110
+ projectPath: ".codex/hooks.json",
111
+ globalPath: () => join(homedir(), ".codex", "hooks.json")
112
+ },
113
+ {
114
+ client: "opencode",
115
+ projectPath: ".opencode/hook/hooks.yaml",
116
+ globalPath: () => join(homedir(), ".config", "opencode", "hook", "hooks.yaml")
117
+ },
118
+ {
119
+ client: "antigravity",
120
+ projectPath: ".agents/hooks.json",
121
+ globalPath: () => join(homedir(), ".gemini", "config", "hooks.json"),
122
+ experimental: true
123
+ },
124
+ {
125
+ client: "dsh",
126
+ projectPath: "",
127
+ globalPath: () => ""
128
+ },
129
+ {
130
+ client: "pi",
131
+ projectPath: "",
132
+ globalPath: () => ""
133
+ }
134
+ ];
135
+ /** 各 stdin-hook 客户端的 hook 命令(与 references/*.md 逐一对应; codex/antigravity 用相对 bin/...) */
136
+ const COMMANDS = {
137
+ claude: "node ${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs check --platform claude",
138
+ codex: "node bin/gitflow-guard.mjs check --platform codex",
139
+ opencode: "node \"$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs\" check --platform opencode",
140
+ antigravity: "node bin/gitflow-guard.mjs check --platform antigravity"
141
+ };
142
+ /** OpenCode YAML 模板(顶层 hooks: + 语义 id gitflow-guard) */
143
+ const OPENCODE_TEMPLATE = [
144
+ "hooks:",
145
+ " - id: gitflow-guard",
146
+ " event: tool.before.bash",
147
+ " actions:",
148
+ " - bash: |",
149
+ ` ${COMMANDS.opencode}`
150
+ ].join("\n");
151
+ const YAML_ID_GUARD = /^\s*- id: gitflow-guard\s*$/m;
152
+ const YAML_ID_ANY = /^\s*- id:/m;
153
+ /** 读取文本文件; 缺失返回 null(其余异常也视为缺失, 决策保守) */
154
+ async function readText(path) {
155
+ try {
156
+ return await readFile(path, "utf8");
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+ async function writeText(path, content) {
162
+ await mkdir(dirname(path), { recursive: true });
163
+ await writeFile(path, content, "utf8");
164
+ }
165
+ /** JSON 递归搜索: 是否已含该命令(任意形状, 幂等判重) */
166
+ function jsonContains(obj, needle) {
167
+ if (typeof obj === "string") return obj === needle;
168
+ if (Array.isArray(obj)) return obj.some((x) => jsonContains(x, needle));
169
+ if (obj !== null && typeof obj === "object") return Object.values(obj).some((x) => jsonContains(x, needle));
170
+ return false;
171
+ }
172
+ function parseJsonOrThrow(path, raw) {
173
+ try {
174
+ const parsed = JSON.parse(raw);
175
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
176
+ return parsed;
177
+ } catch {
178
+ throw new Error(`invalid JSON in ${path} — refusing to modify it`);
179
+ }
180
+ }
181
+ async function writeJson(path, obj) {
182
+ await writeText(path, `${JSON.stringify(obj, null, 2)}\n`);
183
+ }
184
+ /** JSON 客户端(claude/codex/antigravity)新增 hook 条目; 非破坏性合并, 同命令已存在则跳过 */
185
+ async function addJsonEntry(path, client, dryRun) {
186
+ const cmd = COMMANDS[client];
187
+ const raw = await readText(path);
188
+ const obj = raw === null ? {} : parseJsonOrThrow(path, raw);
189
+ if (jsonContains(obj, cmd)) return "exists";
190
+ const entry = client === "antigravity" ? {
191
+ matcher: "run_command",
192
+ hooks: [{
193
+ type: "command",
194
+ command: cmd
195
+ }]
196
+ } : {
197
+ matcher: client === "codex" ? "^Bash$" : "Bash",
198
+ hooks: [{
199
+ type: "command",
200
+ command: cmd
201
+ }]
202
+ };
203
+ if (client === "antigravity") {
204
+ const block = obj["gitflow-guard"] ??= { PreToolUse: [] };
205
+ if (!Array.isArray(block.PreToolUse)) throw new Error(`invalid ${path}: gitflow-guard.PreToolUse must be an array`);
206
+ block.PreToolUse.push(entry);
207
+ } else {
208
+ const hooksObj = obj["hooks"] ??= {};
209
+ const arr = hooksObj["PreToolUse"] ??= [];
210
+ if (!Array.isArray(arr)) throw new Error(`invalid ${path}: hooks.PreToolUse must be an array`);
211
+ arr.push(entry);
212
+ }
213
+ if (!dryRun) await writeJson(path, obj);
214
+ return "added";
215
+ }
216
+ /** JSON 客户端移除本插件条目; 不动其他内容 */
217
+ async function removeJsonEntry(path, client, dryRun) {
218
+ const cmd = COMMANDS[client];
219
+ const raw = await readText(path);
220
+ if (raw === null) return "absent";
221
+ const obj = parseJsonOrThrow(path, raw);
222
+ if (!jsonContains(obj, cmd)) return "absent";
223
+ if (client === "antigravity") delete obj["gitflow-guard"];
224
+ else {
225
+ const hooksObj = obj["hooks"];
226
+ const arr = hooksObj?.["PreToolUse"];
227
+ if (Array.isArray(arr)) {
228
+ const rest = arr.filter((e) => !(e?.hooks ?? []).some((h) => h?.command === cmd));
229
+ if (rest.length === 0) delete hooksObj["PreToolUse"];
230
+ else hooksObj["PreToolUse"] = rest;
231
+ if (hooksObj && Object.keys(hooksObj).length === 0) delete obj["hooks"];
232
+ }
233
+ }
234
+ if (!dryRun) await writeJson(path, obj);
235
+ return "removed";
236
+ }
237
+ /** OpenCode YAML: hooks: 列表按语义 id gitflow-guard 判重/落位 */
238
+ async function addYamlEntry(path, dryRun) {
239
+ const raw = await readText(path);
240
+ if (raw !== null) {
241
+ if (YAML_ID_GUARD.test(raw)) return "exists";
242
+ const lines = raw.split("\n");
243
+ const hooksIdx = lines.findIndex((l) => /^hooks:\s*$/.test(l));
244
+ const block = OPENCODE_TEMPLATE.split("\n").slice(1);
245
+ if (hooksIdx === -1) {
246
+ const joined = [
247
+ ...lines,
248
+ "",
249
+ ...block
250
+ ].join("\n");
251
+ if (!dryRun) await writeText(path, joined);
252
+ return "added";
253
+ }
254
+ lines.splice(hooksIdx + 1, 0, ...block);
255
+ if (!dryRun) await writeText(path, lines.join("\n"));
256
+ return "added";
257
+ }
258
+ if (!dryRun) await writeText(path, OPENCODE_TEMPLATE);
259
+ return "added";
260
+ }
261
+ /** OpenCode YAML: 移除 gitflow-guard 块; 若列表清空则连顶层 hooks: 一并清理 */
262
+ async function removeYamlEntry(path, dryRun) {
263
+ const raw = await readText(path);
264
+ if (raw === null) return "absent";
265
+ if (!YAML_ID_GUARD.test(raw)) return "absent";
266
+ const lines = raw.split("\n");
267
+ const start = lines.findIndex((l) => YAML_ID_GUARD.test(l));
268
+ let end = lines.length;
269
+ for (let i = start + 1; i < lines.length; i++) if (YAML_ID_ANY.test(lines[i])) {
270
+ end = i;
271
+ break;
272
+ }
273
+ let rest = [...lines.slice(0, start), ...lines.slice(end)];
274
+ if (!rest.some((l) => YAML_ID_ANY.test(l))) rest = rest.filter((l) => !/^hooks:\s*$/.test(l));
275
+ const text = rest.join("\n");
276
+ if (!dryRun) await writeText(path, text);
277
+ return "removed";
278
+ }
279
+ /** 执行一次 wire 落位/移除/预览; dsh/pi 由上层直接短路, 不进这里 */
280
+ async function applyWire(client, path, unwire, dryRun) {
281
+ if (client === "opencode") return unwire ? removeYamlEntry(path, dryRun) : addYamlEntry(path, dryRun);
282
+ return unwire ? removeJsonEntry(path, client, dryRun) : addJsonEntry(path, client, dryRun);
283
+ }
284
+ /** 只读探测: 该配置文件是否已含本插件 hook(status 的接线提示用) */
285
+ async function isWired(client, path) {
286
+ const raw = await readText(path);
287
+ if (raw === null) return false;
288
+ if (client === "opencode") return YAML_ID_GUARD.test(raw);
289
+ try {
290
+ return jsonContains(JSON.parse(raw), COMMANDS[client]);
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+ //#endregion
87
296
  //#region src/cli.ts
88
297
  function parseFlags(argv) {
89
298
  const flags = {};
@@ -95,11 +304,18 @@ function parseFlags(argv) {
95
304
  else if (a === "--platform") flags.platform = next();
96
305
  else if (a === "--command") flags.command = next();
97
306
  else if (a === "--locale") flags.locale = next();
307
+ else if (a === "--client") flags.client = next();
308
+ else if (a === "--global") flags.global = true;
309
+ else if (a === "--project") flags.project = true;
310
+ else if (a === "--unwire") flags.unwire = true;
311
+ else if (a === "--dry-run") flags.dryRun = true;
312
+ else if (a === "--yes") flags.yes = true;
98
313
  else if (a.startsWith("--repo=")) flags.repo = a.slice(7);
99
314
  else if (a.startsWith("--lines=")) flags.lines = Number(a.slice(8));
100
315
  else if (a.startsWith("--platform=")) flags.platform = a.slice(11);
101
316
  else if (a.startsWith("--command=")) flags.command = a.slice(10);
102
317
  else if (a.startsWith("--locale=")) flags.locale = a.slice(9);
318
+ else if (a.startsWith("--client=")) flags.client = a.slice(9);
103
319
  }
104
320
  return flags;
105
321
  }
@@ -131,6 +347,8 @@ async function main(argv, opts = {}) {
131
347
  if (cmd === "status") return await status(flags, runner);
132
348
  if (cmd === "audit") return await audit(flags, runner);
133
349
  if (cmd === "check") return await check(flags);
350
+ if (cmd === "wire") return await wire(flags, runner);
351
+ if (cmd === "setup") return await setup(flags, runner);
134
352
  const t = makeT(await resolveFrameworkLocale(flags, runner));
135
353
  console.error(`${t("cli.unknownCommand", { cmd: cmd ?? "" })}\n\n${t("usage.text")}`);
136
354
  return 1;
@@ -145,7 +363,8 @@ async function status(flags, runner) {
145
363
  console.error(makeT(resolveLocale(flags.locale))("cli.cannotLocate"));
146
364
  return 1;
147
365
  }
148
- const { config, errors, warnings } = await loadConfig(repoRoot);
366
+ const loaded = await loadConfig(repoRoot);
367
+ const { config, errors, warnings } = loaded;
149
368
  const enabled = config?.enabled === true;
150
369
  const t = makeT(cliLocale(flags, config?.locale));
151
370
  console.log(t("cli.statusTitle", { repo: repoRoot }));
@@ -173,6 +392,10 @@ async function status(flags, runner) {
173
392
  merge: c.branches.production.mergeBy || "user"
174
393
  }));
175
394
  if (c.branches.archive) console.log(t("cli.statusArchive", { list: c.branches.archive.branches.join(", ") }));
395
+ if (loaded.usingDefaults) {
396
+ console.log(t("cli.statusUsingDefaults"));
397
+ console.log(t("cli.statusMainProtected"));
398
+ }
176
399
  console.log(t("cli.statusCurrentBranch", { branch: branch ?? t("cli.statusUnknownBranch") }));
177
400
  const r = await runner.run([
178
401
  "for-each-ref",
@@ -190,6 +413,15 @@ async function status(flags, runner) {
190
413
  };
191
414
  console.log(t("cli.statusLocalBranches"));
192
415
  for (const b of localBranches) console.log(` ${b} → ${classifyBranch(b)}`);
416
+ const hints = [];
417
+ for (const spec of WIRE_CLIENTS) {
418
+ if (spec.client === "dsh" || spec.client === "pi") continue;
419
+ if (!await isWired(spec.client, join(repoRoot, spec.projectPath))) hints.push(spec.client);
420
+ }
421
+ if (hints.length > 0) {
422
+ console.log(t("cli.statusWireHints"));
423
+ for (const c of hints) console.log(t("cli.statusWireHint", { client: c }));
424
+ }
193
425
  return 0;
194
426
  }
195
427
  async function audit(flags, runner) {
@@ -214,6 +446,147 @@ async function audit(flags, runner) {
214
446
  }
215
447
  return 0;
216
448
  }
449
+ /** 交互提问(仅 TTY 可用); 返回小写化、去空格的答案 */
450
+ function askLine(question) {
451
+ return new Promise((resolve) => {
452
+ const rl = createInterface({
453
+ input: process.stdin,
454
+ output: process.stdout
455
+ });
456
+ rl.question(question, (ans) => {
457
+ rl.close();
458
+ resolve(ans.trim().toLowerCase());
459
+ });
460
+ });
461
+ }
462
+ /** 作用域解析: 显式旗标 > 交互询问(仅 TTY) > 非交互默认 project(安全) */
463
+ async function resolveScope(flags, t) {
464
+ if (flags.global) return "global";
465
+ if (flags.project) return "project";
466
+ if (process.stdin.isTTY) {
467
+ const ans = await askLine(t("cli.wireScopeAsk"));
468
+ if (ans === "project" || ans === "p") return "project";
469
+ if (ans === "global" || ans === "g") return "global";
470
+ console.log(t("cli.wireScopeInvalid"));
471
+ return null;
472
+ }
473
+ return "project";
474
+ }
475
+ /** wire/setup 共用落位核心: dsh/pi 只打印引导; 其余客户端读取/合并/写入对应配置文件(非破坏性) */
476
+ async function wireCore(client, scope, opts, t) {
477
+ const spec = WIRE_CLIENTS.find((s) => s.client === client);
478
+ if (client === "dsh") {
479
+ console.log(t("cli.wireDshGuide"));
480
+ return 0;
481
+ }
482
+ if (client === "pi") {
483
+ console.log(t("cli.wirePiGuide"));
484
+ return 0;
485
+ }
486
+ if (spec.experimental) console.log(t("cli.wireExperimental", { client }));
487
+ const path = scope === "project" ? join(opts.repoRoot, spec.projectPath) : spec.globalPath();
488
+ console.log(t("cli.wireTarget", {
489
+ client,
490
+ path
491
+ }));
492
+ if (opts.dryRun) {
493
+ const res = await applyWire(client, path, !!opts.unwire, true);
494
+ if (res === "added") console.log(t("cli.wireDryRunAdd", {
495
+ client,
496
+ path
497
+ }));
498
+ else if (res === "removed") console.log(t("cli.wireDryRunRemove", {
499
+ client,
500
+ path
501
+ }));
502
+ else console.log(t("cli.wireDryRunNoOp", {
503
+ client,
504
+ path
505
+ }));
506
+ return 0;
507
+ }
508
+ if (!opts.yes) {
509
+ if (scope === "global" && !process.stdin.isTTY) {
510
+ console.error(t("cli.wireRefuseGlobal"));
511
+ return 1;
512
+ }
513
+ if (process.stdin.isTTY) {
514
+ const ans = await askLine(t("cli.wireConfirmWrite", { path }));
515
+ if (ans !== "y" && ans !== "yes") return 1;
516
+ }
517
+ }
518
+ const res = await applyWire(client, path, !!opts.unwire, false);
519
+ if (res === "added") console.log(t("cli.wireCreated", {
520
+ client,
521
+ path
522
+ }));
523
+ else if (res === "exists") console.log(t("cli.wireAlready", {
524
+ client,
525
+ path
526
+ }));
527
+ else if (res === "removed") console.log(t("cli.wireRemoved", {
528
+ client,
529
+ path
530
+ }));
531
+ else console.log(t("cli.wireNotWired", {
532
+ client,
533
+ path
534
+ }));
535
+ return 0;
536
+ }
537
+ /** wire: 单客户端非交互/半交互落位(--client 必填; 作用域默认交互询问) */
538
+ async function wire(flags, runner) {
539
+ const t = makeT(await resolveFrameworkLocale(flags, runner));
540
+ const client = (flags.client ?? "").toLowerCase();
541
+ if (!isWireClient(client)) {
542
+ console.error(t("cli.wireUnknownClient", { client }));
543
+ return 1;
544
+ }
545
+ const scope = await resolveScope(flags, t);
546
+ if (!scope) return 1;
547
+ let repoRoot = null;
548
+ if (scope === "project") {
549
+ repoRoot = await resolveRepo(flags, runner);
550
+ if (!repoRoot) {
551
+ console.error(t("cli.wireNeedRepo"));
552
+ return 1;
553
+ }
554
+ }
555
+ return wireCore(client, scope, {
556
+ unwire: flags.unwire,
557
+ dryRun: flags.dryRun,
558
+ yes: flags.yes,
559
+ repoRoot
560
+ }, t);
561
+ }
562
+ /** setup: 交互向导(客户端 → 作用域 → 确认), 安装后一步式接线; 非交互终端拒绝并指路 wire */
563
+ async function setup(flags, runner) {
564
+ const t = makeT(await resolveFrameworkLocale(flags, runner));
565
+ if (!process.stdin.isTTY) {
566
+ console.error(t("cli.setupNoTty"));
567
+ return 1;
568
+ }
569
+ console.log(t("cli.setupIntro"));
570
+ const client = (await askLine(t("cli.setupClientAsk"))).trim().toLowerCase();
571
+ if (!isWireClient(client)) {
572
+ console.error(t("cli.setupClientInvalid"));
573
+ return 1;
574
+ }
575
+ const scope = await resolveScope(flags, t);
576
+ if (!scope) return 1;
577
+ let repoRoot = null;
578
+ if (scope === "project") {
579
+ repoRoot = await resolveRepo(flags, runner);
580
+ if (!repoRoot) {
581
+ console.error(t("cli.wireNeedRepo"));
582
+ return 1;
583
+ }
584
+ }
585
+ return wireCore(client, scope, {
586
+ yes: flags.yes,
587
+ repoRoot
588
+ }, t);
589
+ }
217
590
  function readStdin() {
218
591
  return new Promise((resolve) => {
219
592
  let data = "";
package/lib/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-CL6uB3oo.mjs";
1
+ import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-DPJRoEJq.mjs";
2
2
  export { MESSAGE_KEYS, appendAudit, apply, createPiExtension, evaluateCommand, formatDeny, name, registerLocale, stateDir, userStateRoot };
@@ -688,6 +688,31 @@ const en = {
688
688
  "cli.auditEmpty": () => " No audit entries yet",
689
689
  "cli.checkInternalError": (v) => `[gitflow-guard] check internal error, allowed through: ${v.msg}`,
690
690
  "cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] guard disabled: invalid config: ${v.err}`,
691
+ "cli.wireUnknownClient": (v) => `unknown client: ${v.client} (expected dsh|claude|codex|opencode|antigravity|pi)`,
692
+ "cli.wireNeedRepo": () => "project scope needs a git repository — run inside a repo or pass --repo <path>",
693
+ "cli.wireScopeAsk": () => "Scope — project (this repo only) or global (all repos on this machine)? [project/global] ",
694
+ "cli.wireScopeInvalid": () => "invalid scope (expected project or global)",
695
+ "cli.wireTarget": (v) => `${v.client}: wiring → ${v.path}`,
696
+ "cli.wireAlready": (v) => `${v.client}: hook already wired (${v.path})`,
697
+ "cli.wireCreated": (v) => `${v.client}: hook written → ${v.path}`,
698
+ "cli.wireRemoved": (v) => `${v.client}: hook removed → ${v.path}`,
699
+ "cli.wireNotWired": (v) => `${v.client}: no hook entry found (${v.path})`,
700
+ "cli.wireConfirmWrite": (v) => `Write ${v.path}? [y/N] `,
701
+ "cli.wireRefuseGlobal": () => "Refusing to modify a global config without confirmation — pass --yes to allow",
702
+ "cli.wireDshGuide": () => "DSH is an in-process plugin — no hook file to write. Mount it with: dsh plugin --profile web add agents-gitflow-guard",
703
+ "cli.wirePiGuide": () => "Pi is an in-process extension — no hook file to write. Copy pi/gitflow-guard.ts into .pi/extensions/ and list it in .pi/settings.json (see README).",
704
+ "cli.wireExperimental": (v) => `${v.client}: experimental support — verify the hook on a real device before relying on it`,
705
+ "cli.wireDryRunAdd": (v) => `${v.client}: [dry-run] would add hook → ${v.path}`,
706
+ "cli.wireDryRunRemove": (v) => `${v.client}: [dry-run] would remove hook → ${v.path}`,
707
+ "cli.wireDryRunNoOp": (v) => `${v.client}: [dry-run] nothing to do (${v.path})`,
708
+ "cli.statusUsingDefaults": () => "Config: built-in defaults (no gitflow-guard.config.json) — integration=develop, archive=main",
709
+ "cli.statusMainProtected": () => " main is protected by default. Trunk / single-branch users: create gitflow-guard.config.json with \"enabled\": false, or map your own branches.",
710
+ "cli.statusWireHints": () => "Wiring:",
711
+ "cli.statusWireHint": (v) => ` ${v.client}: not wired — run: gitflow-guard wire --client ${v.client}`,
712
+ "cli.setupIntro": () => "gitflow-guard setup — wire one client for this project. (Ctrl+C to cancel)",
713
+ "cli.setupClientAsk": () => "Which client? [dsh|claude|codex|opencode|antigravity|pi] ",
714
+ "cli.setupClientInvalid": () => "invalid client (expected dsh|claude|codex|opencode|antigravity|pi)",
715
+ "cli.setupNoTty": () => "setup needs an interactive terminal — use: gitflow-guard wire --client <name> --yes",
691
716
  "guardStrictConfigBroken.why": () => "Guard config is invalid while strict mode is enabled",
692
717
  "guardStrictConfigBroken.next": () => "Fix gitflow-guard.config.json (or remove \"strict\": true) before retrying.",
693
718
  "guardStrictInternalError.why": (v) => `Guard internal error while strict mode is enabled: ${v.msg}`,
@@ -698,13 +723,18 @@ Usage:
698
723
  gitflow-guard status [--repo <path>] [--locale <en|zh>]
699
724
  gitflow-guard audit [--lines <count>] [--repo <path>] [--locale <en|zh>]
700
725
  gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <path>] [--locale <en|zh>]
726
+ gitflow-guard wire --client <dsh|claude|codex|opencode|antigravity|pi> [--project|--global] [--unwire] [--dry-run] [--yes] [--repo <path>] [--locale <en|zh>]
727
+ gitflow-guard setup [--repo <path>] [--locale <en|zh>]
701
728
  gitflow-guard --help
702
729
 
703
730
  Notes:
704
731
  status/audit are read-only; the agent can self-inspect.
705
732
  --locale overrides the message language for this invocation (flag > project config > English).
706
733
  check reads the hook payload on stdin (platform-specific protocol: claude/opencode exit 2,
707
- codex/antigravity JSON on stdout) and is meant for pre/post hooks of AI agents.`
734
+ codex/antigravity JSON on stdout) and is meant for pre/post hooks of AI agents.
735
+ wire writes each client's hook config into the project (default) or global scope; dsh/pi are
736
+ in-process and only print guidance. No config file needed — built-in defaults (develop+main)
737
+ apply out of the box; create gitflow-guard.config.json to override, or set "enabled": false to turn off.`
708
738
  };
709
739
  const zh = {
710
740
  "role.integration": () => "集成分支",
@@ -765,6 +795,31 @@ const zh = {
765
795
  "cli.auditEmpty": () => " 暂无审计记录",
766
796
  "cli.checkInternalError": (v) => `[gitflow-guard] check 内部错误, 已放行: ${v.msg}`,
767
797
  "cli.guardDisabledInvalidConfig": (v) => `[gitflow-guard] 守卫未启用: 配置无效: ${v.err}`,
798
+ "cli.wireUnknownClient": (v) => `未知客户端: ${v.client}(应为 dsh|claude|codex|opencode|antigravity|pi)`,
799
+ "cli.wireNeedRepo": () => "项目级作用域需要一个 git 仓库 — 请在仓库内运行, 或传 --repo <路径>",
800
+ "cli.wireScopeAsk": () => "作用域 — project(仅当前仓库) 还是 global(本机所有仓库)? [project/global] ",
801
+ "cli.wireScopeInvalid": () => "无效作用域(应为 project 或 global)",
802
+ "cli.wireTarget": (v) => `${v.client}: 接线 → ${v.path}`,
803
+ "cli.wireAlready": (v) => `${v.client}: hook 已接线(${v.path})`,
804
+ "cli.wireCreated": (v) => `${v.client}: hook 已写入 → ${v.path}`,
805
+ "cli.wireRemoved": (v) => `${v.client}: hook 已移除 → ${v.path}`,
806
+ "cli.wireNotWired": (v) => `${v.client}: 未找到 hook 条目(${v.path})`,
807
+ "cli.wireConfirmWrite": (v) => `写入 ${v.path}? [y/N] `,
808
+ "cli.wireRefuseGlobal": () => "拒绝在未确认时改动全局配置 — 传 --yes 允许",
809
+ "cli.wireDshGuide": () => "DSH 是进程内插件, 无需写入 hook 文件。挂载: dsh plugin --profile web add agents-gitflow-guard",
810
+ "cli.wirePiGuide": () => "Pi 是进程内扩展, 无需写入 hook 文件。把 pi/gitflow-guard.ts 拷到 .pi/extensions/ 并在 .pi/settings.json 登记(见 README)",
811
+ "cli.wireExperimental": (v) => `${v.client}: 实验支持 — 请在真机核验后再依赖它`,
812
+ "cli.wireDryRunAdd": (v) => `${v.client}: [dry-run] 将添加 hook → ${v.path}`,
813
+ "cli.wireDryRunRemove": (v) => `${v.client}: [dry-run] 将移除 hook → ${v.path}`,
814
+ "cli.wireDryRunNoOp": (v) => `${v.client}: [dry-run] 无需改动(${v.path})`,
815
+ "cli.statusUsingDefaults": () => "配置: 内置默认(无 gitflow-guard.config.json)— integration=develop, archive=main",
816
+ "cli.statusMainProtected": () => " main 默认受保护。Trunk/单分支用户: 创建 gitflow-guard.config.json 写 \"enabled\": false, 或自行映射分支",
817
+ "cli.statusWireHints": () => "接线:",
818
+ "cli.statusWireHint": (v) => ` ${v.client}: 未接线 — 运行: gitflow-guard wire --client ${v.client}`,
819
+ "cli.setupIntro": () => "gitflow-guard setup — 为本项目接线一个客户端。(Ctrl+C 取消)",
820
+ "cli.setupClientAsk": () => "选哪个客户端? [dsh|claude|codex|opencode|antigravity|pi] ",
821
+ "cli.setupClientInvalid": () => "无效客户端(应为 dsh|claude|codex|opencode|antigravity|pi)",
822
+ "cli.setupNoTty": () => "setup 需要交互终端 — 请用: gitflow-guard wire --client <名字> --yes",
768
823
  "guardStrictConfigBroken.why": () => "守卫配置无效, 且已启用 strict 模式",
769
824
  "guardStrictConfigBroken.next": () => "请先修复 gitflow-guard.config.json(或移除 \"strict\": true)后重试",
770
825
  "guardStrictInternalError.why": (v) => `守卫内部错误, 且已启用 strict 模式: ${v.msg}`,
@@ -775,13 +830,17 @@ const zh = {
775
830
  gitflow-guard status [--repo <路径>] [--locale <en|zh>]
776
831
  gitflow-guard audit [--lines <数量>] [--repo <路径>] [--locale <en|zh>]
777
832
  gitflow-guard check [--platform <auto|claude|codex|opencode|antigravity>] [--command "<cmd>"] [--repo <路径>] [--locale <en|zh>]
833
+ gitflow-guard wire --client <dsh|claude|codex|opencode|antigravity|pi> [--project|--global] [--unwire] [--dry-run] [--yes] [--repo <路径>] [--locale <en|zh>]
834
+ gitflow-guard setup [--repo <路径>] [--locale <en|zh>]
778
835
  gitflow-guard --help
779
836
 
780
837
  说明:
781
838
  status/audit 只读, agent 可自查。
782
839
  --locale 可临时覆盖本次调用的文案语言(旗标 > 项目配置 > 英文)。
783
840
  check 读 stdin hook payload 做门禁(平台协议: claude/opencode exit 2, codex/antigravity stdout JSON),
784
- 供 Claude Code / Codex / OpenCode 等 agent 的 pre/post hook 调用。`
841
+ 供 Claude Code / Codex / OpenCode 等 agent 的 pre/post hook 调用。
842
+ wire 把各客户端默认 hook 写入工程(默认)或全局作用域; dsh/pi 为进程内接入, 仅打印引导。
843
+ 无需配置文件 — 内置默认(develop+main)开箱即用; 建 gitflow-guard.config.json 可覆盖, 或写 "enabled": false 关闭。`
785
844
  };
786
845
  /** 内置文案注册表: en 为兜底语言; 下游可经 registerLocale 追加 */
787
846
  const dicts = /* @__PURE__ */ new Map([["en", en], ["zh", zh]]);
@@ -821,10 +880,26 @@ function resolveLocale(v) {
821
880
  //#endregion
822
881
  //#region src/config.ts
823
882
  const CONFIG_FILE = "gitflow-guard.config.json";
824
- /** 默认配置(分支角色必须由项目显式配置, 无默认) */
883
+ /**
884
+ * 内置默认配置(零门槛开箱即用): 没有 gitflow-guard.config.json 也生效。
885
+ * 默认保护 develop(integration, 只走 PR/MR) + main(archive, 归档合并在人)。
886
+ * 用户 config 存在时按字段深度合并覆盖——只写想改的字段, 其余沿用默认。
887
+ */
825
888
  const DEFAULT_CONFIG = {
826
- enabled: false,
889
+ enabled: true,
827
890
  featurePattern: "feature/[\\w-]+",
891
+ branches: {
892
+ integration: {
893
+ branches: ["develop"],
894
+ update: "pr",
895
+ mergeBy: "anyone"
896
+ },
897
+ archive: {
898
+ branches: ["main"],
899
+ update: "pr",
900
+ mergeBy: "user"
901
+ }
902
+ },
828
903
  ci: { enabled: true },
829
904
  locale: "en"
830
905
  };
@@ -882,32 +957,30 @@ function mergeConfig(raw) {
882
957
  if (typeof raw !== "object" || raw === null) return {
883
958
  config: null,
884
959
  errors: ["Config file must be a JSON object"],
885
- warnings
960
+ warnings,
961
+ usingDefaults: false
886
962
  };
887
963
  const r = raw;
888
964
  const strict = r.strict === true ? true : r.strict === false ? false : void 0;
889
965
  if (r.strict !== void 0 && typeof r.strict !== "boolean") errors.push("strict must be a boolean");
890
- const config = {
891
- ...DEFAULT_CONFIG,
892
- ci: { ...DEFAULT_CONFIG.ci },
893
- branches: { integration: {
894
- branches: [],
895
- update: "pr",
896
- mergeBy: "anyone"
897
- } }
898
- };
966
+ const config = structuredClone(DEFAULT_CONFIG);
899
967
  if (typeof r.enabled === "boolean") config.enabled = r.enabled;
900
968
  if (typeof r.featurePattern === "string" && r.featurePattern !== "") config.featurePattern = r.featurePattern;
901
969
  if (typeof r.locale === "string" && r.locale !== "") {
902
970
  config.locale = r.locale;
903
971
  if (resolveLocale(r.locale) !== r.locale) warnings.push(`unknown locale "${r.locale}"; falling back to en`);
904
972
  } else if (r.locale !== void 0) errors.push("locale must be a string");
973
+ if (typeof r.ci === "object" && r.ci !== null) {
974
+ const ci = r.ci;
975
+ if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
976
+ }
977
+ if (r.branches !== void 0 && (typeof r.branches !== "object" || r.branches === null)) errors.push("branches must be an object");
905
978
  const b = r.branches ?? {};
906
979
  if ("integration" in b) {
907
980
  const { role, errors: e } = normalizeRole(b.integration, "integration", "pr", "anyone");
908
981
  config.branches.integration = role;
909
982
  errors.push(...e);
910
- } else errors.push("branches.integration is required");
983
+ }
911
984
  if (b.preview !== void 0) {
912
985
  const { role, errors: e } = normalizeRole(b.preview, "preview", "pr", "anyone");
913
986
  config.branches.preview = role;
@@ -923,14 +996,13 @@ function mergeConfig(raw) {
923
996
  config.branches.archive = role;
924
997
  errors.push(...e);
925
998
  }
926
- const ci = r.ci ?? {};
927
- if (typeof ci.enabled === "boolean") config.ci.enabled = ci.enabled;
928
999
  if (strict !== void 0) config.strict = strict;
929
1000
  errors.push(...validateConfig(config));
930
1001
  return {
931
1002
  config: errors.length > 0 ? null : config,
932
1003
  errors,
933
1004
  warnings,
1005
+ usingDefaults: false,
934
1006
  ...strict !== void 0 ? { strict } : {}
935
1007
  };
936
1008
  }
@@ -960,21 +1032,23 @@ function validateConfig(config) {
960
1032
  }
961
1033
  return errors;
962
1034
  }
963
- /** 从项目根加载配置; 无文件 = 未启用(opt-in) */
1035
+ /** 从项目根加载配置; 无文件 = 使用内置默认配置(开箱即用, develop/main 已受保护) */
964
1036
  async function loadConfig(repoRoot) {
965
1037
  let text;
966
1038
  try {
967
1039
  text = await readFile(join(repoRoot, CONFIG_FILE), "utf8");
968
1040
  } catch (e) {
969
1041
  if (e.code === "ENOENT") return {
970
- config: null,
1042
+ config: structuredClone(DEFAULT_CONFIG),
971
1043
  errors: [],
972
- warnings: []
1044
+ warnings: [],
1045
+ usingDefaults: true
973
1046
  };
974
1047
  return {
975
1048
  config: null,
976
1049
  errors: [`Failed to read config file: ${e.message}`],
977
- warnings: []
1050
+ warnings: [],
1051
+ usingDefaults: false
978
1052
  };
979
1053
  }
980
1054
  let raw;
@@ -986,6 +1060,7 @@ async function loadConfig(repoRoot) {
986
1060
  config: null,
987
1061
  errors: [`Failed to read config file: ${e.message}`],
988
1062
  warnings: [],
1063
+ usingDefaults: false,
989
1064
  ...strict ? { strict } : {}
990
1065
  };
991
1066
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agents-gitflow-guard",
3
- "version": "0.0.19",
4
- "description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity / Pi) — integration/preview/production/archive, each with its own update rules",
3
+ "version": "0.0.21",
4
+ "description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity / Pi) — zero-config defaults (develop+main) out of the box, deep-merge override, and per-client native hooks via gitflow-guard wire/setup; integration/preview/production/archive, each with its own update rules",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -42,7 +42,11 @@
42
42
  "git",
43
43
  "workflow",
44
44
  "guard",
45
- "agent"
45
+ "agent",
46
+ "zero-config",
47
+ "onboarding",
48
+ "wire",
49
+ "setup"
46
50
  ],
47
51
  "repository": {
48
52
  "type": "git",