agents-gitflow-guard 0.0.18 → 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/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-BQYC4N6b.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-BQYC4N6b.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 };