agents-gitflow-guard 0.0.19 → 0.0.20
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 +72 -38
- package/README.zh.md +72 -38
- package/lib/cli.mjs +377 -4
- package/lib/index.mjs +1 -1
- package/lib/{src-CL6uB3oo.mjs → src-DPJRoEJq.mjs} +96 -21
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -40,37 +40,47 @@ 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.
|
|
43
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.20
|
|
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.
|
|
48
|
+
npm i -g agents-gitflow-guard@0.0.20
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
# Pi — in-process extension
|
|
53
|
-
npm i -D agents-gitflow-guard@0.0.
|
|
53
|
+
npm i -D agents-gitflow-guard@0.0.20
|
|
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)
|
|
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 —
|
|
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
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
85
|
**Step 3 — verify.** Ask the agent (or run in a DSH session) to `git push origin develop`. Expect the tool call to be denied:
|
|
76
86
|
|
|
@@ -79,9 +89,9 @@ 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;
|
|
92
|
+
Messages are English by default; create a config with `"locale": "zh"` to switch to Chinese (see [Configuration Reference](#configuration-reference)).
|
|
83
93
|
|
|
84
|
-
**Done.** The guard is live for this repo.
|
|
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
|
|
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`
|
|
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
|
-
|
|
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` |
|
|
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) |
|
|
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, //
|
|
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" }, //
|
|
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**:
|
|
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.
|
|
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.
|
|
331
|
-
| Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.
|
|
332
|
-
| Pi | `npm i -D agents-gitflow-guard@0.0.
|
|
359
|
+
| DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.20` | restart DSH — the plugin auto-mounts as a profile layer |
|
|
360
|
+
| Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.20` | `gitflow-guard wire --client <name>` — one command per client (below) |
|
|
361
|
+
| Pi | `npm i -D agents-gitflow-guard@0.0.20` | 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.
|
|
366
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.20 # 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
|
|
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.
|
|
383
|
+
npm i -g agents-gitflow-guard@0.0.20 # 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
|
-
|
|
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.
|
|
443
|
+
npm i -D agents-gitflow-guard@0.0.20
|
|
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
|
-
-
|
|
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`
|
|
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:
|
|
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,7 +532,7 @@ 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
|
|
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
538
|
| **archive** | optional post-release archive branch (`branches.archive`); user-hand only |
|
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.
|
|
42
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.20
|
|
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.
|
|
47
|
+
npm i -g agents-gitflow-guard@0.0.20
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
# Pi —— 进程内扩展
|
|
52
|
-
npm i -D agents-gitflow-guard@0.0.
|
|
52
|
+
npm i -D agents-gitflow-guard@0.0.20
|
|
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)
|
|
57
|
+
> hook 客户端(Claude Code · Codex · OpenCode · Antigravity)装完还要各做一步接线——**每个客户端一条命令**(见下)。Pi 拷贝一个文件;DSH 装完即已接线。
|
|
58
58
|
|
|
59
|
-
**第 2
|
|
59
|
+
**第 2 步——接线(无需配置文件)。** 守卫内置**默认配置,开箱即用:默认保护 `develop`(integration)+ `main`(archive)**,零配置。你要做的只是让 AI 客户端去调用守卫——每个 stdin-hook 客户端一条命令(DSH 自动接线;Pi 拷文件,见下):
|
|
60
60
|
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
**文案默认是英文**(面向国际化)
|
|
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
|
|
|
@@ -136,7 +146,7 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
|
|
|
136
146
|
## 它能做什么
|
|
137
147
|
|
|
138
148
|
- **执行前拦截**:直推 / 强推 / 删除受保护角色分支(integration / preview / production / archive);agent 试图合入生产或归档。
|
|
139
|
-
- **角色驱动、完全可配**:`integration`
|
|
149
|
+
- **角色驱动、完全可配**:`integration`(内置默认 `develop`)是核心角色;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个角色独立 `update`(`pr` / `flexible`)与 `mergeBy`,自定义配置深度合并于默认之上。
|
|
140
150
|
- **在关键处保留人的操作权**:生产与归档合并始终在你手上——插件阻止 agent 点击合并,于是你的动作*就是*确认。
|
|
141
151
|
- **任何命名都行**:分支名全由配置映射,绝无硬编码(见[配置参考](#配置参考))。
|
|
142
152
|
- **全程审计**:每次拦截都追加到用户级状态目录(macOS/Linux `~/.local/state/gitflow-guard/`,Windows `%LOCALAPPDATA%\gitflow-guard`)下的审计日志——在仓库外、绝不进版本库、位于 agent 可写沙箱之外,且同一仓库的所有 worktree 共享一份日志。
|
|
@@ -183,7 +193,7 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
|
|
|
183
193
|
|
|
184
194
|
#### 1. 配置是唯一事实来源
|
|
185
195
|
|
|
186
|
-
分支名和规则没有任何硬编码。`integration`
|
|
196
|
+
分支名和规则没有任何硬编码。`integration` 以内置默认(`develop`)提供;`preview` / `production` / `archive` 是可选数组(精确名或正则),每个都有自己的 `update` 与 `mergeBy` — 在默认之上深度合并。同一个二进制从单条 `develop` 一直可扩到企业多环境流水线。
|
|
187
197
|
|
|
188
198
|
#### 2. 拦截发生在执行前,不是执行后
|
|
189
199
|
|
|
@@ -197,9 +207,28 @@ AI 编码 agent 在你的仓库里工作。它通过系统提示词、项目智
|
|
|
197
207
|
|
|
198
208
|
## 配置参考
|
|
199
209
|
|
|
210
|
+
### 内置默认配置 + 深度合并覆盖
|
|
211
|
+
|
|
212
|
+
守卫**默认开启**——不需要 `gitflow-guard.config.json`。默认保护:
|
|
213
|
+
|
|
214
|
+
| 默认值 | 角色 | 规则 |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `develop` | **integration** | 禁直推;只经 PR/MR 合入(`update: "pr"`) |
|
|
217
|
+
| `main` | **archive** | 禁直推 / 禁 agent 合并;归档合并留给你(`mergeBy: "user"`) |
|
|
218
|
+
|
|
219
|
+
当你创建 `gitflow-guard.config.json` 时,它的字段会**深度合并覆盖默认**:写到的字段/角色替换默认,没写的保持默认。只写你想改的:
|
|
220
|
+
|
|
221
|
+
```jsonc
|
|
222
|
+
{
|
|
223
|
+
"branches": { "production": ["release-[\\w-]+"] } // 默认的 develop+main 不变;新增 production
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**完全关闭**(trunk / 单分支流程):`{ "enabled": false }`。误拦时改一个文件即可恢复;`gitflow-guard status` 始终说明当前生效的是内置默认还是自定义配置。
|
|
228
|
+
|
|
200
229
|
### 分支角色——插件校验的模型
|
|
201
230
|
|
|
202
|
-
|
|
231
|
+
**角色**把分支名(或正则)映射到规则集。`integration` 由内置默认提供;其余角色全部可选。
|
|
203
232
|
|
|
204
233
|
```text
|
|
205
234
|
feature 分支 ──(自由)──> integration(集成分支, PR/MR 合入)
|
|
@@ -213,10 +242,10 @@ archive(可选, 发布后你亲手归档)
|
|
|
213
242
|
| 角色 | 配置键 | 必填? | 强制行为 |
|
|
214
243
|
|---|---|---|---|
|
|
215
244
|
| **feature** | `featurePattern` | — | 自由: commit / push / 同步 / rebase |
|
|
216
|
-
| **integration** | `branches.integration` |
|
|
245
|
+
| **integration** | `branches.integration` | 默认(`develop`) | 禁直推(默认 `pr`);feature 只经 PR/MR 合入 |
|
|
217
246
|
| **preview** | `branches.preview`(数组) | 可选 | 禁直推;只走 PR/MR(环境终点) |
|
|
218
247
|
| **production** | `branches.production`(数组) | 可选 | 只走 PR/MR;合并仅限你(`mergeBy: "user"`) |
|
|
219
|
-
| **archive** | `branches.archive`(数组) |
|
|
248
|
+
| **archive** | `branches.archive`(数组) | 默认(`main`) | 允许 agent 创建指向它的 PR/MR; 合并仍限用户亲手 |
|
|
220
249
|
|
|
221
250
|
### 自定义分支名与规则——任何命名都可以
|
|
222
251
|
|
|
@@ -256,10 +285,10 @@ archive(可选, 发布后你亲手归档)
|
|
|
256
285
|
|
|
257
286
|
```jsonc
|
|
258
287
|
{
|
|
259
|
-
"enabled": true, //
|
|
288
|
+
"enabled": true, // 默认 true — 写 false 即关闭守卫
|
|
260
289
|
"featurePattern": "feature/[\\w-]+", // 识别工作/feature 分支的 JS 正则
|
|
261
290
|
"branches": {
|
|
262
|
-
"integration": { "branches": ["develop"], "update": "pr" }, //
|
|
291
|
+
"integration": { "branches": ["develop"], "update": "pr" }, // 默认 ["develop"] — 省略即保持默认
|
|
263
292
|
"preview": { "branches": ["ita1"], "update": "pr" }, // 可选
|
|
264
293
|
"production": { "branches": ["prd"], "update": "pr", "mergeBy": "user" }, // 可选
|
|
265
294
|
"archive": ["main"] // 可选
|
|
@@ -284,8 +313,8 @@ archive(可选, 发布后你亲手归档)
|
|
|
284
313
|
registerLocale('fr', fr)
|
|
285
314
|
```
|
|
286
315
|
- **未注册语言**:拦截路径对未注册的 `"locale"` 静默回退英文(设计如此——hook 不因文案缺失卡死),笔误因此容易被忽略;一行告警在 `gitflow-guard status` 中可见。
|
|
287
|
-
-
|
|
288
|
-
- **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)
|
|
316
|
+
- **校验**:角色条目重叠会被拒;非法正则会报错。**任何配置错误都会让该项目的守卫回退为"未启用"并上报**(而不是用半吊子配置)。注意:你覆盖的角色若与默认角色同名(如把 `main` 映射为 integration 而默认 archive 仍是 `main`)会触发重叠报错——需一并覆盖或去掉另一角色。
|
|
317
|
+
- **strict 模式**:默认配置损坏时 stderr 告警一次后放行(fail-open,避免一个笔误卡死工具管道);`"strict": true` 把配置异常与内部错误翻转为**拦截**(fail-closed)——供高风险仓库选用。显式 `enabled: false` 保持静默;而*文件不存在*不再是"未启用"——内置默认(develop+main)直接生效。
|
|
289
318
|
|
|
290
319
|
---
|
|
291
320
|
|
|
@@ -323,14 +352,14 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
|
|
|
323
352
|
|
|
324
353
|
| 客户端 | 安装命令 | 装完再做什么 |
|
|
325
354
|
|---|---|---|
|
|
326
|
-
| DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
327
|
-
| Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.
|
|
328
|
-
| Pi | `npm i -D agents-gitflow-guard@0.0.
|
|
355
|
+
| DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.20` | 重启 DSH——插件自动挂为 profile 层 |
|
|
356
|
+
| Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.20` | `gitflow-guard wire --client <名>`——每个客户端一条命令(见下) |
|
|
357
|
+
| Pi | `npm i -D agents-gitflow-guard@0.0.20` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
|
|
329
358
|
|
|
330
359
|
**DSH —— 进程内插件**(标准路径,已在[快速开始](#快速开始30-秒用上)覆盖):
|
|
331
360
|
|
|
332
361
|
```bash
|
|
333
|
-
dsh plugin --profile web add agents-gitflow-guard@0.0.
|
|
362
|
+
dsh plugin --profile web add agents-gitflow-guard@0.0.20 # 建议锁版本, 见上文提示
|
|
334
363
|
```
|
|
335
364
|
|
|
336
365
|
然后重启 DSH。升级用同一命令,再重启一次。
|
|
@@ -344,13 +373,17 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
|
|
|
344
373
|
|
|
345
374
|
包自带 `dsh.bundle.patch` 声明,`dsh plugin add` 自动把它挂为 profile 层,无需手工编辑 profile。
|
|
346
375
|
|
|
347
|
-
**各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI
|
|
376
|
+
**各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI,然后**每客户端一条命令接线**(守卫凭内置默认配置已默认开启,接线是唯一剩下的事):
|
|
348
377
|
|
|
349
378
|
```bash
|
|
350
|
-
npm i -g agents-gitflow-guard@0.0.
|
|
379
|
+
npm i -g agents-gitflow-guard@0.0.20 # 提供 `gitflow-guard` 二进制
|
|
380
|
+
gitflow-guard wire --client claude --project --yes
|
|
381
|
+
gitflow-guard wire --client codex --project --yes
|
|
382
|
+
gitflow-guard wire --client opencode --project --yes
|
|
383
|
+
gitflow-guard wire --client antigravity --project --yes # 实验支持
|
|
351
384
|
```
|
|
352
385
|
|
|
353
|
-
|
|
386
|
+
`wire` 读取已有配置文件(如有)并把 hook 条目合入——不碰其他内容、幂等(已接则跳过)、支持 `--dry-run` 预览与 `--unwire` 移除、写 `--global` 前必先询问。它写入的准确文件(供参考,也可代替 `wire` 手写)是:
|
|
354
387
|
|
|
355
388
|
```jsonc
|
|
356
389
|
// Claude Code — .claude/settings.json
|
|
@@ -403,7 +436,7 @@ hooks:
|
|
|
403
436
|
Pi 以进程内扩展装载(没有 stdin payload,也没有子进程 hook)。把随包发布的入口装进项目、包留在 devDependencies:
|
|
404
437
|
|
|
405
438
|
```bash
|
|
406
|
-
npm i -D agents-gitflow-guard@0.0.
|
|
439
|
+
npm i -D agents-gitflow-guard@0.0.20
|
|
407
440
|
mkdir -p .pi/extensions
|
|
408
441
|
cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
|
|
409
442
|
```
|
|
@@ -417,7 +450,8 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
|
|
|
417
450
|
- 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
451
|
- 只需要**执行前事件**:守卫在命令执行*之前*拦截;没有特许可事后消费,因此无需执行后钩子。
|
|
419
452
|
- 上面示例调的是全局安装的 `gitflow-guard`(`npm i -g`)。若 hook 子进程在它的 `PATH` 里找不到,就指向 `npm bin -g` 给出的完整二进制路径——hook 子进程不一定继承你交互 shell 的 PATH。本仓库自带配置里的 `bin/gitflow-guard.mjs` 路径只对 checkout 贡献者有效。
|
|
420
|
-
-
|
|
453
|
+
- **默认开启**:内置默认配置(integration=`develop`, archive=`main`)无需任何文件即生效。trunk / 单分支仓库:建 `gitflow-guard.config.json` 写 `{ "enabled": false }`,或自行映射分支。自定义配置在默认之上**深度合并**——只写你想改的字段。
|
|
454
|
+
- `wire` 从不删除或重写你已有的 hook 条目——只添加自己的命令(去重);`--unwire` 精确移除同一条命令。
|
|
421
455
|
|
|
422
456
|
---
|
|
423
457
|
|
|
@@ -425,7 +459,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
|
|
|
425
459
|
|
|
426
460
|
### 我的分支不叫默认名字,能用吗?
|
|
427
461
|
|
|
428
|
-
能用——分支名没有任何写死。`integration`
|
|
462
|
+
能用——分支名没有任何写死。`integration` 由内置默认提供(`develop`),自定义配置在默认之上深度合并;它的条目(以及 `preview`/`production`/`archive` 的)可以是任意精确分支名或正则。`featurePattern` 告诉插件怎么认你的工作分支。
|
|
429
463
|
|
|
430
464
|
把集成分支叫 `master`、加一个 `beta` 预览、feature 前缀用 `fix/`——写进配置即可;拦截、报告、审计都跟着你的命名走。没有任何你必须遵守的约定,只有你声明的映射。见[自定义分支名与规则](#自定义分支名与规则任何命名都可以)。
|
|
431
465
|
|
|
@@ -471,7 +505,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
|
|
|
471
505
|
|
|
472
506
|
半吊子配置绝不会意外生效:任何校验错误都会让该项目的守卫禁用并上报错误。
|
|
473
507
|
|
|
474
|
-
|
|
508
|
+
常见错误:覆盖的角色与默认角色同名(如把 `main` 设为 integration 而默认 archive 仍是 `main`——显式重叠报错,需一并覆盖或去掉另一角色)、同一个分支被配到两个角色里(显式拒绝)、`featurePattern` 写不成合法正则(报错)。失败提示很明确,文件又是一个 JSON 对象,通常三十秒改好。
|
|
475
509
|
|
|
476
510
|
---
|
|
477
511
|
|
|
@@ -494,7 +528,7 @@ MIT,免费,无条件。随便用、随便改、随便发,唯一义务是保留
|
|
|
494
528
|
|
|
495
529
|
| 术语 | 含义 |
|
|
496
530
|
|---|---|
|
|
497
|
-
| **integration** |
|
|
531
|
+
| **integration** | 集成分支,核心角色(内置默认 `develop`);feature 经 PR/MR 合入;受保护 |
|
|
498
532
|
| **preview** | 可选环境终点分支(`branches.preview`,数组);只走 PR/MR 更新 |
|
|
499
533
|
| **production** | 可选生产分支(`branches.production`,数组);PR/MR + 合并仅限用户 |
|
|
500
534
|
| **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-
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
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
|
|
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-
|
|
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:
|
|
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
|
-
}
|
|
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
|
-
/** 从项目根加载配置; 无文件 =
|
|
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:
|
|
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.
|
|
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.20",
|
|
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",
|