agentquad 0.3.2 → 0.4.1

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.
@@ -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
+ }