agentquad 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>AgentQuad</title>
8
- <script type="module" crossorigin src="/assets/index-j5Dz0G5-.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-oovKASxm.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Wl5vjZ8T.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentquad",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "AgentQuad — local four-quadrant AI task scheduler with embedded Claude Code / Codex terminals",
5
5
  "license": "MIT",
6
6
  "author": "LIUZHENHUA521",
package/src/cli.js CHANGED
@@ -290,25 +290,35 @@ export async function doctorReport({ rootDir = DEFAULT_ROOT_DIR } = {}) {
290
290
  })
291
291
  }
292
292
 
293
- // 4. Claude Code hook 安装状态(主动推送)
293
+ // 4. (历史保留位)claude-code hook 现在挪到 oc.enabled 外面统一检查
294
+ }
295
+
296
+ // ─── AI CLI hooks 安装状态(claude / codex / cursor)——所有用户都看 ──
297
+ // 不再绑定 openclaw 启用与否,因为 hook 还服务 Telegram / Lark 推送
298
+ const HOOK_DOCTOR_TOOLS = [
299
+ { name: 'claude-code', mod: './openclaw-hook-installer.js', flag: 'claude' },
300
+ { name: 'codex', mod: './codex-hook-installer.js', flag: 'codex' },
301
+ { name: 'cursor', mod: './cursor-hook-installer.js', flag: 'cursor' },
302
+ ]
303
+ for (const { name, mod, flag } of HOOK_DOCTOR_TOOLS) {
294
304
  try {
295
- const { inspectHooks } = await import('./openclaw-hook-installer.js')
296
- const hk = inspectHooks()
305
+ const m = await import(mod)
306
+ const hk = m.inspectHooks()
297
307
  checks.push({
298
- name: 'claude-code hook script',
308
+ name: `${name} hook script`,
299
309
  ok: hk.scriptExists,
300
310
  detail: hk.hookScriptPath + (hk.scriptExists ? '' : ' (missing — should be auto-installed)'),
301
311
  })
302
312
  checks.push({
303
- name: 'claude-code hooks installed',
313
+ name: `${name} hooks installed`,
304
314
  ok: hk.installed,
305
315
  detail: hk.installed
306
316
  ? `events: ${hk.eventsInstalled.join(', ')}`
307
- : '缺失:跑 `agentquad openclaw install-hook` 一次',
317
+ : `缺失:跑 \`agentquad hook bootstrap --${flag}\` 一次`,
308
318
  })
309
319
  } catch (e) {
310
320
  checks.push({
311
- name: 'claude-code hooks',
321
+ name: `${name} hooks`,
312
322
  ok: false,
313
323
  detail: `inspect failed: ${e.message}`,
314
324
  })
@@ -375,10 +385,7 @@ export async function runStart(cmdOpts = {}) {
375
385
  flags: { wizard: cmdOpts.wizard !== false },
376
386
  })
377
387
  if (need) {
378
- const r = await runFirstRunWizard()
379
- if (r.defaultTool) {
380
- setConfigValue('defaultTool', r.defaultTool, { rootDir })
381
- }
388
+ await runFirstRunWizard()
382
389
  }
383
390
  } catch (e) {
384
391
  console.warn(`⚠ first-run wizard skipped: ${e?.message || e}`)
@@ -458,34 +465,44 @@ export async function runStart(cmdOpts = {}) {
458
465
  console.log(buildStartupBanner({ port: actualPort, host }))
459
466
  console.log(`AI terminal default cwd: ${defaultCwd}`)
460
467
 
461
- // ─── 自动 bootstrap Claude Code hook(部署 notify.js + 合入 settings.json)───
462
- // 设计:缺啥补啥 / 已装则 noop / 用户跑过 uninstall-hook 留下的 marker 会被尊重
468
+ // ─── 自动 bootstrap 3 AI CLI 的 hook(claude / codex / cursor)───
469
+ // 设计:每个工具独立 try/catch;缺啥补啥 / 已装则 noop / uninstall marker 被尊重
463
470
  // 任何错误一律 warn-skip,绝不让 hook bootstrap 把 agentquad start 挂掉
464
- try {
465
- const { bootstrapHooks } = await import('./openclaw-hook-installer.js')
466
- const r = bootstrapHooks()
467
- if (r.skipped) {
468
- if (r.reason === 'uninstall_marker') {
469
- console.log(`ℹ claude-code hook: 已被你 uninstall-hook 拒绝;想恢复跑 'agentquad openclaw bootstrap'`)
470
- } else if (r.reason === 'malformed_settings') {
471
- console.warn(`⚠ claude-code hook: ~/.claude/settings.json JSON 损坏,跳过自动安装;修好后跑 'agentquad openclaw bootstrap'`)
472
- } else {
473
- console.log(`ℹ claude-code hook bootstrap skipped: ${r.reason}`)
471
+ const BOOTSTRAP_TOOLS = [
472
+ { tool: 'claude-code', mod: './openclaw-hook-installer.js', fn: 'bootstrapHooks', malformedReason: 'malformed_settings' },
473
+ { tool: 'codex', mod: './codex-hook-installer.js', fn: 'bootstrapCodexHooks', malformedReason: 'malformed_hooks_json' },
474
+ { tool: 'cursor', mod: './cursor-hook-installer.js', fn: 'bootstrapCursorHooks', malformedReason: 'malformed_hooks_json' },
475
+ ]
476
+ for (const { tool, mod, fn, malformedReason } of BOOTSTRAP_TOOLS) {
477
+ try {
478
+ const m = await import(mod)
479
+ const r = m[fn]()
480
+ if (r.skipped) {
481
+ if (r.reason === 'uninstall_marker') {
482
+ console.log(`ℹ ${tool} hook: 已被你 hook uninstall 拒绝;想恢复跑 'agentquad hook bootstrap --${tool === 'claude-code' ? 'claude' : tool}'`)
483
+ } else if (r.reason === malformedReason) {
484
+ console.warn(`⚠ ${tool} hook: 配置 JSON 损坏,跳过自动安装;修好后跑 'agentquad hook bootstrap --${tool === 'claude-code' ? 'claude' : tool}'`)
485
+ } else {
486
+ console.log(`ℹ ${tool} hook bootstrap skipped: ${r.reason}`)
487
+ }
488
+ continue
474
489
  }
475
- } else {
476
490
  if (r.scriptResult.action === 'installed') {
477
- console.log(`✓ claude-code hook script installed (v${r.scriptResult.version}) → ${r.scriptResult.scriptPath}`)
491
+ console.log(`✓ ${tool} hook script installed (v${r.scriptResult.version}) → ${r.scriptResult.scriptPath}`)
478
492
  } else if (r.scriptResult.action === 'upgraded') {
479
- console.log(`✓ claude-code hook script upgraded v${r.scriptResult.previousVersion ?? 0} → v${r.scriptResult.version} (backup: ${r.scriptResult.backup})`)
493
+ console.log(`✓ ${tool} hook script upgraded v${r.scriptResult.previousVersion ?? 0} → v${r.scriptResult.version} (backup: ${r.scriptResult.backup})`)
480
494
  }
481
495
  if (r.alreadyInstalled) {
482
496
  // 静默:避免每次 start 都刷屏。doctor 会显示状态
483
497
  } else if (r.hookResult) {
484
- console.log(`✓ claude-code hooks installed: ${r.hookResult.added.join(', ')}`)
498
+ console.log(`✓ ${tool} hooks installed: ${r.hookResult.added.join(', ')}`)
499
+ if (r.hookResult.configResult?.action && r.hookResult.configResult.action !== 'already_present') {
500
+ console.log(` feature flag ${r.hookResult.configResult.action} → ${r.hookResult.configResult.configPath}`)
501
+ }
485
502
  }
503
+ } catch (e) {
504
+ console.warn(`⚠ ${tool} hook bootstrap failed: ${e?.message || e}`)
486
505
  }
487
- } catch (e) {
488
- console.warn(`⚠ claude-code hook bootstrap failed: ${e?.message || e}`)
489
506
  }
490
507
 
491
508
  // listen 完成后异步发"重启完成 + Resume N 个会话"通知到 telegram。
@@ -895,25 +912,154 @@ async function actHookStatus() {
895
912
  if (r.error) console.log(` ⚠️ ${r.error}`)
896
913
  }
897
914
 
915
+ // ─── 多工具 hook 操作(claude / codex / cursor)─────────────────────
916
+ export function planHookTools(opts) {
917
+ const flags = opts || {}
918
+ const explicit = []
919
+ if (flags.claude) explicit.push('claude')
920
+ if (flags.codex) explicit.push('codex')
921
+ if (flags.cursor) explicit.push('cursor')
922
+ if (flags.all || explicit.length === 0) return ['claude', 'codex', 'cursor']
923
+ return explicit
924
+ }
925
+
926
+ const HOOK_INSTALLERS = {
927
+ claude: { mod: () => import('./openclaw-hook-installer.js'), bootstrap: 'bootstrapHooks' },
928
+ codex: { mod: () => import('./codex-hook-installer.js'), bootstrap: 'bootstrapCodexHooks' },
929
+ cursor: { mod: () => import('./cursor-hook-installer.js'), bootstrap: 'bootstrapCursorHooks' },
930
+ }
931
+
932
+ async function actInstallHookMulti(opts) {
933
+ let allOk = true
934
+ for (const tool of planHookTools(opts)) {
935
+ try {
936
+ const m = await HOOK_INSTALLERS[tool].mod()
937
+ const out = m.installHooks()
938
+ const path = out.settingsPath || out.hooksPath
939
+ console.log(`✓ [${tool}] installed ${out.added.join(', ')} → ${path}`)
940
+ if (out.backup) console.log(` backup: ${out.backup}`)
941
+ if (out.configResult?.action && out.configResult.action !== 'already_present') {
942
+ console.log(` feature flag ${out.configResult.action} → ${out.configResult.configPath}`)
943
+ }
944
+ if (out.markerCleared) console.log(` uninstall marker cleared`)
945
+ } catch (e) {
946
+ console.error(`✗ [${tool}] ${e.message}`)
947
+ if (e.code === 'hook_script_missing') {
948
+ console.error(` 跑 'agentquad hook bootstrap --${tool}' 一键部署 + 安装`)
949
+ }
950
+ allOk = false
951
+ }
952
+ }
953
+ if (!allOk) process.exit(1)
954
+ }
955
+
956
+ async function actUninstallHookMulti(opts) {
957
+ let allOk = true
958
+ for (const tool of planHookTools(opts)) {
959
+ try {
960
+ const m = await HOOK_INSTALLERS[tool].mod()
961
+ const out = m.uninstallHooks({ writeUninstallMarker: opts.marker !== false })
962
+ const path = out.settingsPath || out.hooksPath
963
+ if (out.removed.length === 0) {
964
+ console.log(`= [${tool}] no AgentQuad hooks; nothing to remove`)
965
+ } else {
966
+ const total = out.removed.reduce((s, r) => s + r.removedCount, 0)
967
+ console.log(`✓ [${tool}] removed ${total} entries → ${path}`)
968
+ for (const r of out.removed) console.log(` ${r.event}: -${r.removedCount}`)
969
+ if (out.backup) console.log(` backup: ${out.backup}`)
970
+ }
971
+ if (out.markerWritten) console.log(` marker written`)
972
+ } catch (e) {
973
+ console.error(`✗ [${tool}] ${e.message}`)
974
+ allOk = false
975
+ }
976
+ }
977
+ if (!allOk) process.exit(1)
978
+ }
979
+
980
+ async function actHookStatusMulti(opts) {
981
+ for (const tool of planHookTools(opts)) {
982
+ try {
983
+ const m = await HOOK_INSTALLERS[tool].mod()
984
+ const r = m.inspectHooks()
985
+ const icon = r.installed ? '✓' : '✗'
986
+ const path = r.settingsPath || r.hooksPath
987
+ console.log(`${icon} [${tool}] installed: ${r.installed}`)
988
+ console.log(` events: ${r.eventsInstalled.length ? r.eventsInstalled.join(', ') : '(none)'}`)
989
+ console.log(` config: ${path}`)
990
+ console.log(` script: ${r.hookScriptPath} (${r.scriptExists ? 'exists' : 'MISSING'})`)
991
+ if (r.featureFlagOk === false) console.log(` ⚠️ codex_hooks feature flag not set in ~/.codex/config.toml`)
992
+ if (r.error) console.log(` ⚠️ ${r.error}`)
993
+ } catch (e) {
994
+ console.error(`✗ [${tool}] ${e.message}`)
995
+ }
996
+ }
997
+ }
998
+
999
+ async function actBootstrapHookMulti(opts) {
1000
+ let allOk = true
1001
+ for (const tool of planHookTools(opts)) {
1002
+ const cfg = HOOK_INSTALLERS[tool]
1003
+ try {
1004
+ const m = await cfg.mod()
1005
+ const r = m[cfg.bootstrap]({ respectUninstallMarker: false })
1006
+ if (r.skipped) {
1007
+ const reasonTxt = r.reason === 'malformed_settings' || r.reason === 'malformed_hooks_json'
1008
+ ? `${r.reason} — 请先修复 JSON 再试`
1009
+ : r.reason
1010
+ console.log(`= [${tool}] skipped: ${reasonTxt}`)
1011
+ if (r.reason === 'malformed_settings' || r.reason === 'malformed_hooks_json') allOk = false
1012
+ continue
1013
+ }
1014
+ const sr = r.scriptResult
1015
+ const verb = sr.action === 'unchanged' ? '=' : '✓'
1016
+ console.log(`${verb} [${tool}] script ${sr.action} (v${sr.version})`)
1017
+ if (sr.backup) console.log(` script backup: ${sr.backup}`)
1018
+ if (r.alreadyInstalled) {
1019
+ console.log(` hooks already installed`)
1020
+ } else if (r.hookResult) {
1021
+ console.log(` hooks installed: ${r.hookResult.added.join(', ')}`)
1022
+ if (r.hookResult.backup) console.log(` config backup: ${r.hookResult.backup}`)
1023
+ if (r.hookResult.configResult?.action && r.hookResult.configResult.action !== 'already_present') {
1024
+ console.log(` feature flag ${r.hookResult.configResult.action} → ${r.hookResult.configResult.configPath}`)
1025
+ }
1026
+ }
1027
+ if (r.markerCleared) console.log(` uninstall marker cleared`)
1028
+ } catch (e) {
1029
+ console.error(`✗ [${tool}] ${e.message}`)
1030
+ allOk = false
1031
+ }
1032
+ }
1033
+ if (!allOk) process.exit(1)
1034
+ }
1035
+
1036
+ function addToolFlags(cmd) {
1037
+ return cmd
1038
+ .option('--claude', 'apply to Claude Code hooks (~/.claude/settings.json)')
1039
+ .option('--codex', 'apply to OpenAI Codex hooks (~/.codex/hooks.json + config.toml)')
1040
+ .option('--cursor', 'apply to Cursor Agent hooks (~/.cursor/hooks.json)')
1041
+ .option('--all', 'apply to all tools (default if no flag given)')
1042
+ }
1043
+
898
1044
  // ─── 顶层 hook 子命令组(首选入口;发现性比埋在 openclaw 下好)──────
899
- const hookCmd = program.command('hook').description('管理 AgentQuad 在 ~/.claude/settings.json 里安装的 hook(装/删/查/恢复)')
1045
+ const hookCmd = program.command('hook').description('管理 AgentQuad 在 Claude Code / Codex / Cursor 里安装的 hook(装/删/查/恢复)')
900
1046
 
901
- hookCmd.command('install')
902
- .description(' AgentQuad 3 hook(Stop/Notification/SessionEnd)合并写入 ~/.claude/settings.json')
903
- .action(actInstallHook)
1047
+ addToolFlags(hookCmd.command('install'))
1048
+ .description('合并写入 hook 配置;默认 --all(claude + codex + cursor)')
1049
+ .action(actInstallHookMulti)
904
1050
 
905
- hookCmd.command('uninstall')
906
- .description('从 ~/.claude/settings.json 移除 AgentQuad 加的 hook entry,保留你其他 hook(默认写 .uninstalled marker,下次 start 不再自动装回)')
1051
+ addToolFlags(hookCmd.command('uninstall'))
1052
+ .description('移除 AgentQuad 加的 hook entry,保留你其他 hook;默认写 .uninstalled marker,下次 start 不再装回')
907
1053
  .option('--no-marker', '不写 .uninstalled marker(下次 agentquad start 会自动装回)')
908
- .action(actUninstallHook)
1054
+ .action(actUninstallHookMulti)
909
1055
 
910
- hookCmd.command('status')
911
- .description('查看 AgentQuad hook 是否安装到 ~/.claude/settings.json')
912
- .action(actHookStatus)
1056
+ addToolFlags(hookCmd.command('status'))
1057
+ .description('查看每个工具的 hook 安装状态')
1058
+ .action(actHookStatusMulti)
913
1059
 
914
- hookCmd.command('bootstrap')
1060
+ addToolFlags(hookCmd.command('bootstrap'))
915
1061
  .description('一键部署 hook script + 安装 hooks(强制忽略 .uninstalled marker,用于"删过又想恢复"场景)')
916
- .action(actBootstrapHook)
1062
+ .action(actBootstrapHookMulti)
917
1063
 
918
1064
  // ─── openclaw 子命令组:保留旧路径以向后兼容;hook 操作建议改用 `agentquad hook *` ───
919
1065
  const openclawCmd = program.command('openclaw').description('OpenClaw bridge: install/uninstall Claude Code hooks for proactive WeChat push')
@@ -0,0 +1,361 @@
1
+ /**
2
+ * OpenAI Codex CLI hooks 安装器:
3
+ * - 往 `~/.codex/config.toml` 末尾追加 `[features] codex_hooks = true`(若缺)
4
+ * - 把 hook entry 合并写入 `~/.codex/hooks.json`,不破坏用户现有 hook
5
+ *
6
+ * Codex 没有 SessionEnd 事件,仅装 Stop / UserPromptSubmit 两个。
7
+ *
8
+ * 合并策略:
9
+ * - 已有 hooks.<event> 数组 → append;不删除已有 entry
10
+ * - AgentQuad 加的 entry 用 `_agentquadManaged: true` 字段标记,方便 uninstall
11
+ * - 卸载时仅删带这个标记的 entry,其他保留不动
12
+ * - hooks.json 不存在 → 创建
13
+ * - hooks.json 损坏 → warn-skip 不擅自覆盖
14
+ *
15
+ * 启动期 bootstrap(bootstrapCodexHooks):
16
+ * - 部署/升级 ~/.agentquad/codex-hooks/notify.js(带版本号比对)
17
+ * - 合并 hook 到 hooks.json + 写 feature flag
18
+ * - 用户跑过 hook uninstall → 留 .uninstalled marker,bootstrap 默认尊重
19
+ */
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+ import { homedir } from 'node:os'
23
+ import { fileURLToPath } from 'node:url'
24
+ import { DEFAULT_ROOT_DIR } from './config.js'
25
+
26
+ const MANAGED_KEY = '_agentquadManaged'
27
+ const HOOK_EVENTS = ['Stop', 'UserPromptSubmit']
28
+ const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
29
+ const FEATURE_FLAG_MARKER = '# agentquad-managed codex_hooks flag'
30
+
31
+ function defaultHookScriptPath() {
32
+ return join(DEFAULT_ROOT_DIR, 'codex-hooks', 'notify.js')
33
+ }
34
+
35
+ function defaultConfigDir() {
36
+ return join(homedir(), '.codex')
37
+ }
38
+
39
+ function defaultConfigTomlPath() {
40
+ return join(defaultConfigDir(), 'config.toml')
41
+ }
42
+
43
+ function defaultHooksJsonPath() {
44
+ return join(defaultConfigDir(), 'hooks.json')
45
+ }
46
+
47
+ function defaultTemplatePath() {
48
+ return fileURLToPath(new URL('./templates/codex-hooks/notify.js', import.meta.url))
49
+ }
50
+
51
+ function defaultUninstallMarkerPath() {
52
+ return join(DEFAULT_ROOT_DIR, 'codex-hooks', '.uninstalled')
53
+ }
54
+
55
+ function parseHookVersion(content) {
56
+ if (!content) return null
57
+ const m = content.match(HOOK_VERSION_RE)
58
+ return m ? Number(m[1]) : 0
59
+ }
60
+
61
+ function buildHookEntry(event, hookScriptPath) {
62
+ const eventLower = event === 'UserPromptSubmit' ? 'notification' : 'stop'
63
+ return {
64
+ matcher: '',
65
+ hooks: [
66
+ {
67
+ type: 'command',
68
+ command: `node ${hookScriptPath} ${eventLower}`,
69
+ timeout: 30,
70
+ [MANAGED_KEY]: true,
71
+ },
72
+ ],
73
+ [MANAGED_KEY]: true,
74
+ }
75
+ }
76
+
77
+ function loadHooksJson(path) {
78
+ if (!existsSync(path)) return {}
79
+ const raw = readFileSync(path, 'utf8')
80
+ try {
81
+ return JSON.parse(raw)
82
+ } catch (e) {
83
+ const err = new Error(`codex hooks.json malformed: ${e.message}`)
84
+ err.code = 'malformed_hooks_json'
85
+ err.path = path
86
+ throw err
87
+ }
88
+ }
89
+
90
+ function saveHooksJson(path, data) {
91
+ const dir = dirname(path)
92
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
93
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
94
+ }
95
+
96
+ function backupFile(path) {
97
+ if (!existsSync(path)) return null
98
+ const bak = `${path}.bak.${Date.now()}`
99
+ copyFileSync(path, bak)
100
+ return bak
101
+ }
102
+
103
+ /**
104
+ * 在 config.toml 中确保 `[features] codex_hooks = true` 存在。
105
+ * 行级别幂等:如果 file 里已经出现 `codex_hooks` 这个 key(任何位置),就不动;
106
+ * 否则在末尾追加一个带标记注释的 [features] 段。
107
+ *
108
+ * 不解析 TOML、不动其它内容,避免破坏用户配置(如 [projects."..."] 列表)。
109
+ *
110
+ * 返回 { configPath, action: 'added' | 'already_present' | 'created', backup }
111
+ */
112
+ export function ensureFeatureFlag({
113
+ configPath = defaultConfigTomlPath(),
114
+ } = {}) {
115
+ const dir = dirname(configPath)
116
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
117
+
118
+ if (!existsSync(configPath)) {
119
+ writeFileSync(
120
+ configPath,
121
+ `${FEATURE_FLAG_MARKER}\n[features]\ncodex_hooks = true\n`,
122
+ )
123
+ return { configPath, action: 'created', backup: null }
124
+ }
125
+
126
+ const raw = readFileSync(configPath, 'utf8')
127
+ if (/^\s*codex_hooks\s*=\s*true/m.test(raw)) {
128
+ return { configPath, action: 'already_present', backup: null }
129
+ }
130
+
131
+ const backup = backupFile(configPath)
132
+ const sep = raw.endsWith('\n') ? '' : '\n'
133
+ const appended = `${raw}${sep}\n${FEATURE_FLAG_MARKER}\n[features]\ncodex_hooks = true\n`
134
+ writeFileSync(configPath, appended)
135
+ return { configPath, action: 'added', backup }
136
+ }
137
+
138
+ /**
139
+ * 把 AgentQuad 的 hook entry 合并到 hooks.json。幂等。
140
+ *
141
+ * 返回 { hooksPath, backup, added, configResult }
142
+ */
143
+ export function installHooks({
144
+ hooksPath = defaultHooksJsonPath(),
145
+ configPath = defaultConfigTomlPath(),
146
+ hookScriptPath = defaultHookScriptPath(),
147
+ events = HOOK_EVENTS,
148
+ uninstallMarkerPath = defaultUninstallMarkerPath(),
149
+ clearUninstallMarker = true,
150
+ } = {}) {
151
+ if (!existsSync(hookScriptPath)) {
152
+ const err = new Error(`hook script not found: ${hookScriptPath}`)
153
+ err.code = 'hook_script_missing'
154
+ throw err
155
+ }
156
+
157
+ const configResult = ensureFeatureFlag({ configPath })
158
+ const data = loadHooksJson(hooksPath)
159
+ const backup = backupFile(hooksPath)
160
+ if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {}
161
+
162
+ const added = []
163
+ for (const event of events) {
164
+ if (!Array.isArray(data.hooks[event])) data.hooks[event] = []
165
+ data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
166
+ data.hooks[event].push(buildHookEntry(event, hookScriptPath))
167
+ added.push(event)
168
+ }
169
+
170
+ saveHooksJson(hooksPath, data)
171
+ let markerCleared = false
172
+ if (clearUninstallMarker && existsSync(uninstallMarkerPath)) {
173
+ try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
174
+ }
175
+ return { hooksPath, backup, added, skipped: [], configResult, markerCleared }
176
+ }
177
+
178
+ /**
179
+ * 移除 AgentQuad 加的所有 hook entry(按 _agentquadManaged 标记)。
180
+ * 不动用户的 feature flag(即便是我们写的也保留 — codex_hooks=true 是无害默认)。
181
+ */
182
+ export function uninstallHooks({
183
+ hooksPath = defaultHooksJsonPath(),
184
+ uninstallMarkerPath = defaultUninstallMarkerPath(),
185
+ writeUninstallMarker = true,
186
+ } = {}) {
187
+ let markerWritten = false
188
+ const writeMarker = () => {
189
+ if (!writeUninstallMarker) return
190
+ try {
191
+ const dir = dirname(uninstallMarkerPath)
192
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
193
+ writeFileSync(uninstallMarkerPath, `${new Date().toISOString()}\n`)
194
+ markerWritten = true
195
+ } catch { /* ignore */ }
196
+ }
197
+
198
+ if (!existsSync(hooksPath)) {
199
+ writeMarker()
200
+ return { hooksPath, removed: [], backup: null, markerWritten }
201
+ }
202
+
203
+ const data = loadHooksJson(hooksPath)
204
+ const backup = backupFile(hooksPath)
205
+ const removed = []
206
+
207
+ if (data.hooks && typeof data.hooks === 'object') {
208
+ for (const event of Object.keys(data.hooks)) {
209
+ if (!Array.isArray(data.hooks[event])) continue
210
+ const before = data.hooks[event].length
211
+ data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
212
+ if (data.hooks[event].length !== before) {
213
+ removed.push({ event, removedCount: before - data.hooks[event].length })
214
+ }
215
+ if (data.hooks[event].length === 0) delete data.hooks[event]
216
+ }
217
+ if (Object.keys(data.hooks).length === 0) delete data.hooks
218
+ }
219
+
220
+ saveHooksJson(hooksPath, data)
221
+ writeMarker()
222
+ return { hooksPath, removed, backup, markerWritten }
223
+ }
224
+
225
+ export function inspectHooks({
226
+ hooksPath = defaultHooksJsonPath(),
227
+ configPath = defaultConfigTomlPath(),
228
+ hookScriptPath = defaultHookScriptPath(),
229
+ } = {}) {
230
+ const scriptExists = existsSync(hookScriptPath)
231
+ const featureFlagOk = existsSync(configPath)
232
+ && /^\s*codex_hooks\s*=\s*true/m.test(readFileSync(configPath, 'utf8'))
233
+
234
+ if (!existsSync(hooksPath)) {
235
+ return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists, featureFlagOk }
236
+ }
237
+ let data
238
+ try {
239
+ data = loadHooksJson(hooksPath)
240
+ } catch (e) {
241
+ return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists, featureFlagOk, error: e.code }
242
+ }
243
+ const eventsInstalled = []
244
+ for (const event of HOOK_EVENTS) {
245
+ const arr = data?.hooks?.[event]
246
+ if (!Array.isArray(arr)) continue
247
+ if (arr.some((entry) => entry?.[MANAGED_KEY])) eventsInstalled.push(event)
248
+ }
249
+ return {
250
+ installed: eventsInstalled.length === HOOK_EVENTS.length && featureFlagOk,
251
+ eventsInstalled,
252
+ hooksPath,
253
+ hookScriptPath,
254
+ scriptExists,
255
+ featureFlagOk,
256
+ }
257
+ }
258
+
259
+ export function deployHookScript({
260
+ scriptPath = defaultHookScriptPath(),
261
+ templatePath = defaultTemplatePath(),
262
+ } = {}) {
263
+ if (!existsSync(templatePath)) {
264
+ const err = new Error(`hook template not found: ${templatePath}`)
265
+ err.code = 'hook_template_missing'
266
+ throw err
267
+ }
268
+ const templateContent = readFileSync(templatePath, 'utf8')
269
+ const templateVersion = parseHookVersion(templateContent)
270
+
271
+ const dir = dirname(scriptPath)
272
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
273
+
274
+ const previousVersion = existsSync(scriptPath)
275
+ ? parseHookVersion(readFileSync(scriptPath, 'utf8'))
276
+ : null
277
+
278
+ if (previousVersion !== null && previousVersion === templateVersion) {
279
+ return { action: 'unchanged', version: templateVersion, previousVersion, scriptPath, backup: null }
280
+ }
281
+
282
+ let backup = null
283
+ if (previousVersion !== null) {
284
+ backup = `${scriptPath}.bak.${Date.now()}`
285
+ copyFileSync(scriptPath, backup)
286
+ }
287
+ writeFileSync(scriptPath, templateContent)
288
+ return {
289
+ action: previousVersion === null ? 'installed' : 'upgraded',
290
+ version: templateVersion,
291
+ previousVersion,
292
+ scriptPath,
293
+ backup,
294
+ }
295
+ }
296
+
297
+ export function bootstrapCodexHooks({
298
+ hooksPath = defaultHooksJsonPath(),
299
+ configPath = defaultConfigTomlPath(),
300
+ scriptPath = defaultHookScriptPath(),
301
+ templatePath = defaultTemplatePath(),
302
+ uninstallMarkerPath = defaultUninstallMarkerPath(),
303
+ respectUninstallMarker = true,
304
+ } = {}) {
305
+ if (respectUninstallMarker && existsSync(uninstallMarkerPath)) {
306
+ return { skipped: true, reason: 'uninstall_marker', uninstallMarkerPath }
307
+ }
308
+
309
+ let markerCleared = false
310
+ if (!respectUninstallMarker && existsSync(uninstallMarkerPath)) {
311
+ try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
312
+ }
313
+
314
+ const scriptResult = deployHookScript({ scriptPath, templatePath })
315
+
316
+ const inspect = inspectHooks({ hooksPath, configPath, hookScriptPath: scriptPath })
317
+ if (inspect.error === 'malformed_hooks_json') {
318
+ return {
319
+ skipped: true,
320
+ reason: 'malformed_hooks_json',
321
+ hooksPath,
322
+ scriptResult,
323
+ markerCleared,
324
+ }
325
+ }
326
+
327
+ if (inspect.installed) {
328
+ return {
329
+ skipped: false,
330
+ alreadyInstalled: true,
331
+ scriptResult,
332
+ hookResult: null,
333
+ markerCleared,
334
+ }
335
+ }
336
+
337
+ const hookResult = installHooks({
338
+ hooksPath,
339
+ configPath,
340
+ hookScriptPath: scriptPath,
341
+ uninstallMarkerPath,
342
+ clearUninstallMarker: false,
343
+ })
344
+ return {
345
+ skipped: false,
346
+ alreadyInstalled: false,
347
+ scriptResult,
348
+ hookResult,
349
+ markerCleared,
350
+ }
351
+ }
352
+
353
+ export const __test__ = {
354
+ buildHookEntry,
355
+ MANAGED_KEY,
356
+ HOOK_EVENTS,
357
+ parseHookVersion,
358
+ defaultTemplatePath,
359
+ defaultUninstallMarkerPath,
360
+ FEATURE_FLAG_MARKER,
361
+ }