agentquad 0.3.2 → 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.
- package/package.json +1 -1
- package/src/cli.js +186 -37
- package/src/codex-hook-installer.js +361 -0
- package/src/cursor-hook-installer.js +296 -0
- package/src/templates/codex-hooks/notify.js +103 -0
- package/src/templates/cursor-hooks/notify.js +103 -0
package/package.json
CHANGED
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.
|
|
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
|
|
296
|
-
const hk = inspectHooks()
|
|
305
|
+
const m = await import(mod)
|
|
306
|
+
const hk = m.inspectHooks()
|
|
297
307
|
checks.push({
|
|
298
|
-
name:
|
|
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:
|
|
313
|
+
name: `${name} hooks installed`,
|
|
304
314
|
ok: hk.installed,
|
|
305
315
|
detail: hk.installed
|
|
306
316
|
? `events: ${hk.eventsInstalled.join(', ')}`
|
|
307
|
-
:
|
|
317
|
+
: `缺失:跑 \`agentquad hook bootstrap --${flag}\` 一次`,
|
|
308
318
|
})
|
|
309
319
|
} catch (e) {
|
|
310
320
|
checks.push({
|
|
311
|
-
name:
|
|
321
|
+
name: `${name} hooks`,
|
|
312
322
|
ok: false,
|
|
313
323
|
detail: `inspect failed: ${e.message}`,
|
|
314
324
|
})
|
|
@@ -455,34 +465,44 @@ export async function runStart(cmdOpts = {}) {
|
|
|
455
465
|
console.log(buildStartupBanner({ port: actualPort, host }))
|
|
456
466
|
console.log(`AI terminal default cwd: ${defaultCwd}`)
|
|
457
467
|
|
|
458
|
-
// ─── 自动 bootstrap
|
|
459
|
-
//
|
|
468
|
+
// ─── 自动 bootstrap 3 个 AI CLI 的 hook(claude / codex / cursor)───
|
|
469
|
+
// 设计:每个工具独立 try/catch;缺啥补啥 / 已装则 noop / uninstall marker 被尊重
|
|
460
470
|
// 任何错误一律 warn-skip,绝不让 hook bootstrap 把 agentquad start 挂掉
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
471
489
|
}
|
|
472
|
-
} else {
|
|
473
490
|
if (r.scriptResult.action === 'installed') {
|
|
474
|
-
console.log(`✓
|
|
491
|
+
console.log(`✓ ${tool} hook script installed (v${r.scriptResult.version}) → ${r.scriptResult.scriptPath}`)
|
|
475
492
|
} else if (r.scriptResult.action === 'upgraded') {
|
|
476
|
-
console.log(`✓
|
|
493
|
+
console.log(`✓ ${tool} hook script upgraded v${r.scriptResult.previousVersion ?? 0} → v${r.scriptResult.version} (backup: ${r.scriptResult.backup})`)
|
|
477
494
|
}
|
|
478
495
|
if (r.alreadyInstalled) {
|
|
479
496
|
// 静默:避免每次 start 都刷屏。doctor 会显示状态
|
|
480
497
|
} else if (r.hookResult) {
|
|
481
|
-
console.log(`✓
|
|
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
|
+
}
|
|
482
502
|
}
|
|
503
|
+
} catch (e) {
|
|
504
|
+
console.warn(`⚠ ${tool} hook bootstrap failed: ${e?.message || e}`)
|
|
483
505
|
}
|
|
484
|
-
} catch (e) {
|
|
485
|
-
console.warn(`⚠ claude-code hook bootstrap failed: ${e?.message || e}`)
|
|
486
506
|
}
|
|
487
507
|
|
|
488
508
|
// listen 完成后异步发"重启完成 + Resume N 个会话"通知到 telegram。
|
|
@@ -892,25 +912,154 @@ async function actHookStatus() {
|
|
|
892
912
|
if (r.error) console.log(` ⚠️ ${r.error}`)
|
|
893
913
|
}
|
|
894
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
|
+
|
|
895
1044
|
// ─── 顶层 hook 子命令组(首选入口;发现性比埋在 openclaw 下好)──────
|
|
896
|
-
const hookCmd = program.command('hook').description('管理 AgentQuad 在
|
|
1045
|
+
const hookCmd = program.command('hook').description('管理 AgentQuad 在 Claude Code / Codex / Cursor 里安装的 hook(装/删/查/恢复)')
|
|
897
1046
|
|
|
898
|
-
hookCmd.command('install')
|
|
899
|
-
.description('
|
|
900
|
-
.action(
|
|
1047
|
+
addToolFlags(hookCmd.command('install'))
|
|
1048
|
+
.description('合并写入 hook 配置;默认 --all(claude + codex + cursor)')
|
|
1049
|
+
.action(actInstallHookMulti)
|
|
901
1050
|
|
|
902
|
-
hookCmd.command('uninstall')
|
|
903
|
-
.description('
|
|
1051
|
+
addToolFlags(hookCmd.command('uninstall'))
|
|
1052
|
+
.description('移除 AgentQuad 加的 hook entry,保留你其他 hook;默认写 .uninstalled marker,下次 start 不再装回')
|
|
904
1053
|
.option('--no-marker', '不写 .uninstalled marker(下次 agentquad start 会自动装回)')
|
|
905
|
-
.action(
|
|
1054
|
+
.action(actUninstallHookMulti)
|
|
906
1055
|
|
|
907
|
-
hookCmd.command('status')
|
|
908
|
-
.description('
|
|
909
|
-
.action(
|
|
1056
|
+
addToolFlags(hookCmd.command('status'))
|
|
1057
|
+
.description('查看每个工具的 hook 安装状态')
|
|
1058
|
+
.action(actHookStatusMulti)
|
|
910
1059
|
|
|
911
|
-
hookCmd.command('bootstrap')
|
|
1060
|
+
addToolFlags(hookCmd.command('bootstrap'))
|
|
912
1061
|
.description('一键部署 hook script + 安装 hooks(强制忽略 .uninstalled marker,用于"删过又想恢复"场景)')
|
|
913
|
-
.action(
|
|
1062
|
+
.action(actBootstrapHookMulti)
|
|
914
1063
|
|
|
915
1064
|
// ─── openclaw 子命令组:保留旧路径以向后兼容;hook 操作建议改用 `agentquad hook *` ───
|
|
916
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
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Agent CLI hooks 安装器:
|
|
3
|
+
* - 把 hook entry 合并写入 `~/.cursor/hooks.json`,不破坏用户现有 hook
|
|
4
|
+
* - 写 `"version": 1` 协议头(Cursor 1.7+ 要求)
|
|
5
|
+
*
|
|
6
|
+
* Cursor 事件:stop(turn end)/ beforeSubmitPrompt(等用户)/ sessionEnd
|
|
7
|
+
*
|
|
8
|
+
* 合并策略:
|
|
9
|
+
* - 已有 hooks.<event> 数组 → append;不删除已有 entry
|
|
10
|
+
* - AgentQuad 加的 entry 用 `_agentquadManaged: true` 标记
|
|
11
|
+
* - hooks.json 不存在 → 创建(带 version:1)
|
|
12
|
+
* - hooks.json 损坏 → warn-skip
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { DEFAULT_ROOT_DIR } from './config.js'
|
|
19
|
+
|
|
20
|
+
const MANAGED_KEY = '_agentquadManaged'
|
|
21
|
+
const HOOK_EVENTS = ['stop', 'beforeSubmitPrompt', 'sessionEnd']
|
|
22
|
+
const HOOK_VERSION_RE = /quadtodo-hook-version:\s*(\d+)/
|
|
23
|
+
const SCHEMA_VERSION = 1
|
|
24
|
+
|
|
25
|
+
function defaultHookScriptPath() {
|
|
26
|
+
return join(DEFAULT_ROOT_DIR, 'cursor-hooks', 'notify.js')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultHooksJsonPath() {
|
|
30
|
+
return join(homedir(), '.cursor', 'hooks.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defaultTemplatePath() {
|
|
34
|
+
return fileURLToPath(new URL('./templates/cursor-hooks/notify.js', import.meta.url))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function defaultUninstallMarkerPath() {
|
|
38
|
+
return join(DEFAULT_ROOT_DIR, 'cursor-hooks', '.uninstalled')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseHookVersion(content) {
|
|
42
|
+
if (!content) return null
|
|
43
|
+
const m = content.match(HOOK_VERSION_RE)
|
|
44
|
+
return m ? Number(m[1]) : 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function eventToArg(event) {
|
|
48
|
+
if (event === 'beforeSubmitPrompt') return 'notification'
|
|
49
|
+
if (event === 'sessionEnd') return 'session-end'
|
|
50
|
+
return 'stop'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildHookEntry(event, hookScriptPath) {
|
|
54
|
+
// Cursor 的 hook entry 是扁平的 object(不像 Claude 的 matcher+hooks 嵌套)
|
|
55
|
+
return {
|
|
56
|
+
type: 'command',
|
|
57
|
+
command: `node ${hookScriptPath} ${eventToArg(event)}`,
|
|
58
|
+
timeout: 30,
|
|
59
|
+
[MANAGED_KEY]: true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadHooksJson(path) {
|
|
64
|
+
if (!existsSync(path)) return { version: SCHEMA_VERSION }
|
|
65
|
+
const raw = readFileSync(path, 'utf8')
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const err = new Error(`cursor hooks.json malformed: ${e.message}`)
|
|
70
|
+
err.code = 'malformed_hooks_json'
|
|
71
|
+
err.path = path
|
|
72
|
+
throw err
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function saveHooksJson(path, data) {
|
|
77
|
+
const dir = dirname(path)
|
|
78
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
79
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + '\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function backupFile(path) {
|
|
83
|
+
if (!existsSync(path)) return null
|
|
84
|
+
const bak = `${path}.bak.${Date.now()}`
|
|
85
|
+
copyFileSync(path, bak)
|
|
86
|
+
return bak
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function installHooks({
|
|
90
|
+
hooksPath = defaultHooksJsonPath(),
|
|
91
|
+
hookScriptPath = defaultHookScriptPath(),
|
|
92
|
+
events = HOOK_EVENTS,
|
|
93
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
94
|
+
clearUninstallMarker = true,
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!existsSync(hookScriptPath)) {
|
|
97
|
+
const err = new Error(`hook script not found: ${hookScriptPath}`)
|
|
98
|
+
err.code = 'hook_script_missing'
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const data = loadHooksJson(hooksPath)
|
|
103
|
+
const backup = backupFile(hooksPath)
|
|
104
|
+
if (!data.version) data.version = SCHEMA_VERSION
|
|
105
|
+
if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {}
|
|
106
|
+
|
|
107
|
+
const added = []
|
|
108
|
+
for (const event of events) {
|
|
109
|
+
if (!Array.isArray(data.hooks[event])) data.hooks[event] = []
|
|
110
|
+
data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
|
|
111
|
+
data.hooks[event].push(buildHookEntry(event, hookScriptPath))
|
|
112
|
+
added.push(event)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
saveHooksJson(hooksPath, data)
|
|
116
|
+
let markerCleared = false
|
|
117
|
+
if (clearUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
118
|
+
try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
|
|
119
|
+
}
|
|
120
|
+
return { hooksPath, backup, added, skipped: [], markerCleared }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function uninstallHooks({
|
|
124
|
+
hooksPath = defaultHooksJsonPath(),
|
|
125
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
126
|
+
writeUninstallMarker = true,
|
|
127
|
+
} = {}) {
|
|
128
|
+
let markerWritten = false
|
|
129
|
+
const writeMarker = () => {
|
|
130
|
+
if (!writeUninstallMarker) return
|
|
131
|
+
try {
|
|
132
|
+
const dir = dirname(uninstallMarkerPath)
|
|
133
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
134
|
+
writeFileSync(uninstallMarkerPath, `${new Date().toISOString()}\n`)
|
|
135
|
+
markerWritten = true
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!existsSync(hooksPath)) {
|
|
140
|
+
writeMarker()
|
|
141
|
+
return { hooksPath, removed: [], backup: null, markerWritten }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const data = loadHooksJson(hooksPath)
|
|
145
|
+
const backup = backupFile(hooksPath)
|
|
146
|
+
const removed = []
|
|
147
|
+
|
|
148
|
+
if (data.hooks && typeof data.hooks === 'object') {
|
|
149
|
+
for (const event of Object.keys(data.hooks)) {
|
|
150
|
+
if (!Array.isArray(data.hooks[event])) continue
|
|
151
|
+
const before = data.hooks[event].length
|
|
152
|
+
data.hooks[event] = data.hooks[event].filter((entry) => !entry?.[MANAGED_KEY])
|
|
153
|
+
if (data.hooks[event].length !== before) {
|
|
154
|
+
removed.push({ event, removedCount: before - data.hooks[event].length })
|
|
155
|
+
}
|
|
156
|
+
if (data.hooks[event].length === 0) delete data.hooks[event]
|
|
157
|
+
}
|
|
158
|
+
if (Object.keys(data.hooks).length === 0) delete data.hooks
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
saveHooksJson(hooksPath, data)
|
|
162
|
+
writeMarker()
|
|
163
|
+
return { hooksPath, removed, backup, markerWritten }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function inspectHooks({
|
|
167
|
+
hooksPath = defaultHooksJsonPath(),
|
|
168
|
+
hookScriptPath = defaultHookScriptPath(),
|
|
169
|
+
} = {}) {
|
|
170
|
+
const scriptExists = existsSync(hookScriptPath)
|
|
171
|
+
if (!existsSync(hooksPath)) {
|
|
172
|
+
return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists }
|
|
173
|
+
}
|
|
174
|
+
let data
|
|
175
|
+
try {
|
|
176
|
+
data = loadHooksJson(hooksPath)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
return { installed: false, eventsInstalled: [], hooksPath, hookScriptPath, scriptExists, error: e.code }
|
|
179
|
+
}
|
|
180
|
+
const eventsInstalled = []
|
|
181
|
+
for (const event of HOOK_EVENTS) {
|
|
182
|
+
const arr = data?.hooks?.[event]
|
|
183
|
+
if (!Array.isArray(arr)) continue
|
|
184
|
+
if (arr.some((entry) => entry?.[MANAGED_KEY])) eventsInstalled.push(event)
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
installed: eventsInstalled.length === HOOK_EVENTS.length,
|
|
188
|
+
eventsInstalled,
|
|
189
|
+
hooksPath,
|
|
190
|
+
hookScriptPath,
|
|
191
|
+
scriptExists,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function deployHookScript({
|
|
196
|
+
scriptPath = defaultHookScriptPath(),
|
|
197
|
+
templatePath = defaultTemplatePath(),
|
|
198
|
+
} = {}) {
|
|
199
|
+
if (!existsSync(templatePath)) {
|
|
200
|
+
const err = new Error(`hook template not found: ${templatePath}`)
|
|
201
|
+
err.code = 'hook_template_missing'
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
204
|
+
const templateContent = readFileSync(templatePath, 'utf8')
|
|
205
|
+
const templateVersion = parseHookVersion(templateContent)
|
|
206
|
+
|
|
207
|
+
const dir = dirname(scriptPath)
|
|
208
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
209
|
+
|
|
210
|
+
const previousVersion = existsSync(scriptPath)
|
|
211
|
+
? parseHookVersion(readFileSync(scriptPath, 'utf8'))
|
|
212
|
+
: null
|
|
213
|
+
|
|
214
|
+
if (previousVersion !== null && previousVersion === templateVersion) {
|
|
215
|
+
return { action: 'unchanged', version: templateVersion, previousVersion, scriptPath, backup: null }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let backup = null
|
|
219
|
+
if (previousVersion !== null) {
|
|
220
|
+
backup = `${scriptPath}.bak.${Date.now()}`
|
|
221
|
+
copyFileSync(scriptPath, backup)
|
|
222
|
+
}
|
|
223
|
+
writeFileSync(scriptPath, templateContent)
|
|
224
|
+
return {
|
|
225
|
+
action: previousVersion === null ? 'installed' : 'upgraded',
|
|
226
|
+
version: templateVersion,
|
|
227
|
+
previousVersion,
|
|
228
|
+
scriptPath,
|
|
229
|
+
backup,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function bootstrapCursorHooks({
|
|
234
|
+
hooksPath = defaultHooksJsonPath(),
|
|
235
|
+
scriptPath = defaultHookScriptPath(),
|
|
236
|
+
templatePath = defaultTemplatePath(),
|
|
237
|
+
uninstallMarkerPath = defaultUninstallMarkerPath(),
|
|
238
|
+
respectUninstallMarker = true,
|
|
239
|
+
} = {}) {
|
|
240
|
+
if (respectUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
241
|
+
return { skipped: true, reason: 'uninstall_marker', uninstallMarkerPath }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let markerCleared = false
|
|
245
|
+
if (!respectUninstallMarker && existsSync(uninstallMarkerPath)) {
|
|
246
|
+
try { unlinkSync(uninstallMarkerPath); markerCleared = true } catch { /* ignore */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const scriptResult = deployHookScript({ scriptPath, templatePath })
|
|
250
|
+
|
|
251
|
+
const inspect = inspectHooks({ hooksPath, hookScriptPath: scriptPath })
|
|
252
|
+
if (inspect.error === 'malformed_hooks_json') {
|
|
253
|
+
return {
|
|
254
|
+
skipped: true,
|
|
255
|
+
reason: 'malformed_hooks_json',
|
|
256
|
+
hooksPath,
|
|
257
|
+
scriptResult,
|
|
258
|
+
markerCleared,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (inspect.installed) {
|
|
263
|
+
return {
|
|
264
|
+
skipped: false,
|
|
265
|
+
alreadyInstalled: true,
|
|
266
|
+
scriptResult,
|
|
267
|
+
hookResult: null,
|
|
268
|
+
markerCleared,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const hookResult = installHooks({
|
|
273
|
+
hooksPath,
|
|
274
|
+
hookScriptPath: scriptPath,
|
|
275
|
+
uninstallMarkerPath,
|
|
276
|
+
clearUninstallMarker: false,
|
|
277
|
+
})
|
|
278
|
+
return {
|
|
279
|
+
skipped: false,
|
|
280
|
+
alreadyInstalled: false,
|
|
281
|
+
scriptResult,
|
|
282
|
+
hookResult,
|
|
283
|
+
markerCleared,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const __test__ = {
|
|
288
|
+
buildHookEntry,
|
|
289
|
+
MANAGED_KEY,
|
|
290
|
+
HOOK_EVENTS,
|
|
291
|
+
SCHEMA_VERSION,
|
|
292
|
+
parseHookVersion,
|
|
293
|
+
eventToArg,
|
|
294
|
+
defaultTemplatePath,
|
|
295
|
+
defaultUninstallMarkerPath,
|
|
296
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quadtodo-hook-version: 2
|
|
3
|
+
/**
|
|
4
|
+
* AgentQuad Claude Code hook —— 把 PTY 内 Claude Code 的状态事件转推到微信。
|
|
5
|
+
*
|
|
6
|
+
* 调用约定:
|
|
7
|
+
* - argv[2] = 事件名: stop | notification | session-end
|
|
8
|
+
* - stdin = Claude Code 注入的 hook payload(JSON 文本,可空)
|
|
9
|
+
* - env: QUADTODO_SESSION_ID (空 = 非 AgentQuad 启动的 Claude Code,立刻 exit 0)
|
|
10
|
+
* QUADTODO_TARGET_USER (微信 peer id)
|
|
11
|
+
* QUADTODO_TODO_ID
|
|
12
|
+
* QUADTODO_TODO_TITLE
|
|
13
|
+
*
|
|
14
|
+
* 故障策略:失败一律静默。这个脚本绝不能阻塞 Claude Code。
|
|
15
|
+
* - 没注 env → 仍然记日志("no env"),exit 0
|
|
16
|
+
* - AgentQuad 没起 / 网络失败 → catch 后记日志,exit 0
|
|
17
|
+
* - JSON 解析失败 → 当作空 payload 继续
|
|
18
|
+
*
|
|
19
|
+
* Debug log: 写到 ~/.agentquad/claude-hooks/hook.log,记每次 fire。
|
|
20
|
+
* 这样能 100% 区分"hook 没 fire" vs "fire 了但 AgentQuad 没收到"。
|
|
21
|
+
*
|
|
22
|
+
* 这个文件是模板源;安装器会拷贝到 ~/.agentquad/claude-hooks/notify.js。
|
|
23
|
+
* 顶部 `quadtodo-hook-version` 行用于版本比对,升级 AgentQuad 时会自动覆盖旧脚本(带备份)。
|
|
24
|
+
* 注意:脚本独立运行(不能 import config.js)。LOG_PATH 用 import.meta.url 派生,
|
|
25
|
+
* 跟随脚本所在目录,自动适配 ~/.agentquad / ~/.quadtodo(legacy)。
|
|
26
|
+
*/
|
|
27
|
+
import { appendFileSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hook.log')
|
|
32
|
+
|
|
33
|
+
function logLine(obj) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n', 'utf8')
|
|
36
|
+
} catch { /* ignore — log 失败也不能阻塞 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const event = (process.argv[2] || 'unknown').toLowerCase()
|
|
40
|
+
const SESSION_ID = process.env.QUADTODO_SESSION_ID
|
|
41
|
+
if (!SESSION_ID) {
|
|
42
|
+
logLine({ event, status: 'skipped_no_env', argv: process.argv.slice(2) })
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUADTODO_URL = process.env.QUADTODO_URL || 'http://127.0.0.1:5677'
|
|
47
|
+
const ENDPOINT = `${QUADTODO_URL}/api/openclaw/hook`
|
|
48
|
+
logLine({ event, status: 'fired', sessionId: SESSION_ID, todoTitle: process.env.QUADTODO_TODO_TITLE })
|
|
49
|
+
|
|
50
|
+
let raw = ''
|
|
51
|
+
process.stdin.setEncoding('utf8')
|
|
52
|
+
process.stdin.on('data', (chunk) => {
|
|
53
|
+
raw += chunk
|
|
54
|
+
// 防止超大 payload 把这个进程占内存
|
|
55
|
+
if (raw.length > 64 * 1024) raw = raw.slice(0, 64 * 1024)
|
|
56
|
+
})
|
|
57
|
+
process.stdin.on('end', send)
|
|
58
|
+
// 没有 stdin 也要发(例如 SessionEnd 可能不带 payload)
|
|
59
|
+
setTimeout(() => { if (!sent) send() }, 1500).unref?.()
|
|
60
|
+
|
|
61
|
+
let sent = false
|
|
62
|
+
async function send() {
|
|
63
|
+
if (sent) return
|
|
64
|
+
sent = true
|
|
65
|
+
|
|
66
|
+
let hookPayload = null
|
|
67
|
+
if (raw.trim()) {
|
|
68
|
+
try { hookPayload = JSON.parse(raw) } catch { hookPayload = { _raw: raw.slice(0, 240) } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
event,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
targetUserId: process.env.QUADTODO_TARGET_USER || null,
|
|
75
|
+
todoId: process.env.QUADTODO_TODO_ID || null,
|
|
76
|
+
todoTitle: process.env.QUADTODO_TODO_TITLE || null,
|
|
77
|
+
hookPayload,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// 30s timeout:openclaw CLI shell-out 实测 4-6s,留足余量;
|
|
82
|
+
// Claude Code 默认等 hook 60s,所以 30s 安全。
|
|
83
|
+
const ctrl = new AbortController()
|
|
84
|
+
const timer = setTimeout(() => ctrl.abort(), 30_000)
|
|
85
|
+
timer.unref?.()
|
|
86
|
+
const res = await fetch(ENDPOINT, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body,
|
|
90
|
+
signal: ctrl.signal,
|
|
91
|
+
})
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const data = await res.json().catch(() => null)
|
|
95
|
+
logLine({ event, status: 'sent', sessionId: SESSION_ID, action: data?.action, reason: data?.reason })
|
|
96
|
+
} else {
|
|
97
|
+
const text = await res.text().catch(() => '')
|
|
98
|
+
logLine({ event, status: 'http_error', code: res.status, body: text.slice(0, 200) })
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logLine({ event, status: 'fetch_error', error: e?.message || String(e) })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quadtodo-hook-version: 2
|
|
3
|
+
/**
|
|
4
|
+
* AgentQuad Claude Code hook —— 把 PTY 内 Claude Code 的状态事件转推到微信。
|
|
5
|
+
*
|
|
6
|
+
* 调用约定:
|
|
7
|
+
* - argv[2] = 事件名: stop | notification | session-end
|
|
8
|
+
* - stdin = Claude Code 注入的 hook payload(JSON 文本,可空)
|
|
9
|
+
* - env: QUADTODO_SESSION_ID (空 = 非 AgentQuad 启动的 Claude Code,立刻 exit 0)
|
|
10
|
+
* QUADTODO_TARGET_USER (微信 peer id)
|
|
11
|
+
* QUADTODO_TODO_ID
|
|
12
|
+
* QUADTODO_TODO_TITLE
|
|
13
|
+
*
|
|
14
|
+
* 故障策略:失败一律静默。这个脚本绝不能阻塞 Claude Code。
|
|
15
|
+
* - 没注 env → 仍然记日志("no env"),exit 0
|
|
16
|
+
* - AgentQuad 没起 / 网络失败 → catch 后记日志,exit 0
|
|
17
|
+
* - JSON 解析失败 → 当作空 payload 继续
|
|
18
|
+
*
|
|
19
|
+
* Debug log: 写到 ~/.agentquad/claude-hooks/hook.log,记每次 fire。
|
|
20
|
+
* 这样能 100% 区分"hook 没 fire" vs "fire 了但 AgentQuad 没收到"。
|
|
21
|
+
*
|
|
22
|
+
* 这个文件是模板源;安装器会拷贝到 ~/.agentquad/claude-hooks/notify.js。
|
|
23
|
+
* 顶部 `quadtodo-hook-version` 行用于版本比对,升级 AgentQuad 时会自动覆盖旧脚本(带备份)。
|
|
24
|
+
* 注意:脚本独立运行(不能 import config.js)。LOG_PATH 用 import.meta.url 派生,
|
|
25
|
+
* 跟随脚本所在目录,自动适配 ~/.agentquad / ~/.quadtodo(legacy)。
|
|
26
|
+
*/
|
|
27
|
+
import { appendFileSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const LOG_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hook.log')
|
|
32
|
+
|
|
33
|
+
function logLine(obj) {
|
|
34
|
+
try {
|
|
35
|
+
appendFileSync(LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n', 'utf8')
|
|
36
|
+
} catch { /* ignore — log 失败也不能阻塞 */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const event = (process.argv[2] || 'unknown').toLowerCase()
|
|
40
|
+
const SESSION_ID = process.env.QUADTODO_SESSION_ID
|
|
41
|
+
if (!SESSION_ID) {
|
|
42
|
+
logLine({ event, status: 'skipped_no_env', argv: process.argv.slice(2) })
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const QUADTODO_URL = process.env.QUADTODO_URL || 'http://127.0.0.1:5677'
|
|
47
|
+
const ENDPOINT = `${QUADTODO_URL}/api/openclaw/hook`
|
|
48
|
+
logLine({ event, status: 'fired', sessionId: SESSION_ID, todoTitle: process.env.QUADTODO_TODO_TITLE })
|
|
49
|
+
|
|
50
|
+
let raw = ''
|
|
51
|
+
process.stdin.setEncoding('utf8')
|
|
52
|
+
process.stdin.on('data', (chunk) => {
|
|
53
|
+
raw += chunk
|
|
54
|
+
// 防止超大 payload 把这个进程占内存
|
|
55
|
+
if (raw.length > 64 * 1024) raw = raw.slice(0, 64 * 1024)
|
|
56
|
+
})
|
|
57
|
+
process.stdin.on('end', send)
|
|
58
|
+
// 没有 stdin 也要发(例如 SessionEnd 可能不带 payload)
|
|
59
|
+
setTimeout(() => { if (!sent) send() }, 1500).unref?.()
|
|
60
|
+
|
|
61
|
+
let sent = false
|
|
62
|
+
async function send() {
|
|
63
|
+
if (sent) return
|
|
64
|
+
sent = true
|
|
65
|
+
|
|
66
|
+
let hookPayload = null
|
|
67
|
+
if (raw.trim()) {
|
|
68
|
+
try { hookPayload = JSON.parse(raw) } catch { hookPayload = { _raw: raw.slice(0, 240) } }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
event,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
targetUserId: process.env.QUADTODO_TARGET_USER || null,
|
|
75
|
+
todoId: process.env.QUADTODO_TODO_ID || null,
|
|
76
|
+
todoTitle: process.env.QUADTODO_TODO_TITLE || null,
|
|
77
|
+
hookPayload,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
// 30s timeout:openclaw CLI shell-out 实测 4-6s,留足余量;
|
|
82
|
+
// Claude Code 默认等 hook 60s,所以 30s 安全。
|
|
83
|
+
const ctrl = new AbortController()
|
|
84
|
+
const timer = setTimeout(() => ctrl.abort(), 30_000)
|
|
85
|
+
timer.unref?.()
|
|
86
|
+
const res = await fetch(ENDPOINT, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body,
|
|
90
|
+
signal: ctrl.signal,
|
|
91
|
+
})
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const data = await res.json().catch(() => null)
|
|
95
|
+
logLine({ event, status: 'sent', sessionId: SESSION_ID, action: data?.action, reason: data?.reason })
|
|
96
|
+
} else {
|
|
97
|
+
const text = await res.text().catch(() => '')
|
|
98
|
+
logLine({ event, status: 'http_error', code: res.status, body: text.slice(0, 200) })
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
logLine({ event, status: 'fetch_error', error: e?.message || String(e) })
|
|
102
|
+
}
|
|
103
|
+
}
|