agentquad 0.3.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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/dist-web/assets/index-CMaXwixo.js +1234 -0
  4. package/dist-web/assets/index-DBHApzV1.css +32 -0
  5. package/dist-web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  6. package/dist-web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  7. package/dist-web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  8. package/dist-web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  9. package/dist-web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  10. package/dist-web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  11. package/dist-web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  12. package/dist-web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  13. package/dist-web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/dist-web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/dist-web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/dist-web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/dist-web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/dist-web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/dist-web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  20. package/dist-web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  21. package/dist-web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  22. package/dist-web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  23. package/dist-web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  24. package/dist-web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  25. package/dist-web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  26. package/dist-web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  27. package/dist-web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  28. package/dist-web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  29. package/dist-web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  30. package/dist-web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  31. package/dist-web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  32. package/dist-web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  33. package/dist-web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  34. package/dist-web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  35. package/dist-web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  36. package/dist-web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  37. package/dist-web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  38. package/dist-web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  39. package/dist-web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  40. package/dist-web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  41. package/dist-web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  42. package/dist-web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  43. package/dist-web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  44. package/dist-web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  45. package/dist-web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  46. package/dist-web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  47. package/dist-web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  48. package/dist-web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  49. package/dist-web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  50. package/dist-web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  51. package/dist-web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  52. package/dist-web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  53. package/dist-web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  54. package/dist-web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  55. package/dist-web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  56. package/dist-web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  57. package/dist-web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  58. package/dist-web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  59. package/dist-web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  60. package/dist-web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  61. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  62. package/dist-web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  63. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-BWTpRfYl.woff2 +0 -0
  64. package/dist-web/assets/jetbrains-mono-cyrillic-700-normal-CEoEElIJ.woff +0 -0
  65. package/dist-web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  66. package/dist-web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  67. package/dist-web/assets/jetbrains-mono-greek-700-normal-C6CZE3T8.woff2 +0 -0
  68. package/dist-web/assets/jetbrains-mono-greek-700-normal-DEigVDxa.woff +0 -0
  69. package/dist-web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  70. package/dist-web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  71. package/dist-web/assets/jetbrains-mono-latin-700-normal-BYuf6tUa.woff2 +0 -0
  72. package/dist-web/assets/jetbrains-mono-latin-700-normal-D3wTyLJW.woff +0 -0
  73. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  74. package/dist-web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  75. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CZipNAKV.woff2 +0 -0
  76. package/dist-web/assets/jetbrains-mono-latin-ext-700-normal-CxPITLHs.woff +0 -0
  77. package/dist-web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  78. package/dist-web/assets/jetbrains-mono-vietnamese-700-normal-BDLVIk2r.woff +0 -0
  79. package/dist-web/assets/logo-D4DDtU-r.png +0 -0
  80. package/dist-web/favicon.png +0 -0
  81. package/dist-web/index.html +14 -0
  82. package/package.json +88 -0
  83. package/src/ask-user-buttons.js +142 -0
  84. package/src/claude-transcript.js +203 -0
  85. package/src/cli.js +1040 -0
  86. package/src/codex-event-emitter.js +111 -0
  87. package/src/codex-prompt-detector.js +53 -0
  88. package/src/codex-sidecar.js +52 -0
  89. package/src/codex-transcript.js +74 -0
  90. package/src/config.js +692 -0
  91. package/src/data/claude-code-commands.json +52 -0
  92. package/src/db.js +1503 -0
  93. package/src/dispatch.js +13 -0
  94. package/src/export/todoMarkdown.js +246 -0
  95. package/src/first-run-wizard.js +82 -0
  96. package/src/git/gitStatus.js +139 -0
  97. package/src/lark-api-client.js +205 -0
  98. package/src/lark-bot.js +510 -0
  99. package/src/lark-card.js +88 -0
  100. package/src/lark-config-service.js +16 -0
  101. package/src/lark-event-client.js +107 -0
  102. package/src/lark-image.js +99 -0
  103. package/src/lark-markdown.js +51 -0
  104. package/src/lark-video.js +163 -0
  105. package/src/mcp/audit.js +34 -0
  106. package/src/mcp/server.js +83 -0
  107. package/src/mcp/tools/destructive/index.js +252 -0
  108. package/src/mcp/tools/openclaw/index.js +405 -0
  109. package/src/mcp/tools/read/index.js +269 -0
  110. package/src/mcp/tools/write/index.js +157 -0
  111. package/src/openclaw-bridge.js +566 -0
  112. package/src/openclaw-hook-installer.js +338 -0
  113. package/src/openclaw-hook.js +908 -0
  114. package/src/openclaw-wizard.js +2442 -0
  115. package/src/pending-questions.js +297 -0
  116. package/src/pricing.js +45 -0
  117. package/src/prompt-render.js +36 -0
  118. package/src/pty.js +992 -0
  119. package/src/routes/ai-terminal.js +1228 -0
  120. package/src/routes/git.js +89 -0
  121. package/src/routes/openclaw-hook.js +67 -0
  122. package/src/routes/openclaw-inbound.js +36 -0
  123. package/src/routes/recurringRules.js +80 -0
  124. package/src/routes/reports.js +50 -0
  125. package/src/routes/search.js +46 -0
  126. package/src/routes/stats.js +31 -0
  127. package/src/routes/telegram-config.js +152 -0
  128. package/src/routes/telegram-sync.js +221 -0
  129. package/src/routes/templates.js +63 -0
  130. package/src/routes/todos.js +649 -0
  131. package/src/routes/transcripts.js +75 -0
  132. package/src/routes/uploads.js +107 -0
  133. package/src/routes/wiki.js +142 -0
  134. package/src/search/fts.js +209 -0
  135. package/src/search/index.js +199 -0
  136. package/src/search/transcripts.js +148 -0
  137. package/src/server.js +1791 -0
  138. package/src/session-input-dispatcher.js +256 -0
  139. package/src/stats/markdown.js +42 -0
  140. package/src/stats/report.js +207 -0
  141. package/src/summarize.js +84 -0
  142. package/src/system-rules.js +52 -0
  143. package/src/telegram-bot.js +875 -0
  144. package/src/telegram-commands.js +149 -0
  145. package/src/telegram-config-service.js +84 -0
  146. package/src/telegram-image.js +95 -0
  147. package/src/telegram-loading-status.js +112 -0
  148. package/src/telegram-markdown.js +82 -0
  149. package/src/telegram-reaction-tracker.js +69 -0
  150. package/src/telegram-video.js +75 -0
  151. package/src/templates/claude-hooks/notify.js +103 -0
  152. package/src/transcript.js +305 -0
  153. package/src/transcripts/blocks.js +56 -0
  154. package/src/transcripts/index.js +222 -0
  155. package/src/transcripts/indexer.js +34 -0
  156. package/src/transcripts/matcher.js +70 -0
  157. package/src/transcripts/scanner.js +259 -0
  158. package/src/usage-footer.js +170 -0
  159. package/src/usage-parser.js +132 -0
  160. package/src/wiki/guide.js +44 -0
  161. package/src/wiki/index.js +232 -0
  162. package/src/wiki/redact.js +34 -0
  163. package/src/wiki/sources.js +122 -0
package/src/cli.js ADDED
@@ -0,0 +1,1040 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, realpathSync } from 'node:fs'
4
+ import { homedir, networkInterfaces } from 'node:os'
5
+ import { join, dirname, resolve as resolvePath } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { spawnSync } from 'node:child_process'
8
+ import {
9
+ DEFAULT_ROOT_DIR,
10
+ loadConfig,
11
+ saveConfig,
12
+ getConfigValue,
13
+ setConfigValue,
14
+ resolveToolsConfig,
15
+ } from './config.js'
16
+ import { shouldRunWizard, runFirstRunWizard } from './first-run-wizard.js'
17
+
18
+ const __filename = fileURLToPath(import.meta.url)
19
+ const __dirname = dirname(__filename)
20
+
21
+ // Bin names verified via `npm view <pkg> bin`.
22
+ // kind:
23
+ // - 'npm' → installed via `npm install -g <pkg>` (claude / codex)
24
+ // - 'shell' → installed via piping `<script>` to a shell (cursor; upstream installer)
25
+ export const TOOL_PACKAGES = {
26
+ claude: { kind: 'npm', pkg: '@anthropic-ai/claude-code', bin: 'claude' },
27
+ codex: { kind: 'npm', pkg: '@openai/codex', bin: 'codex' },
28
+ cursor: { kind: 'shell', script: 'curl https://cursor.com/install -fsSL | bash', bin: 'cursor-agent' },
29
+ }
30
+
31
+ export function planInstallTools(opts) {
32
+ const flags = opts || {}
33
+ const explicit = []
34
+ if (flags.claude) explicit.push('claude')
35
+ if (flags.codex) explicit.push('codex')
36
+ if (flags.cursor) explicit.push('cursor')
37
+ if (flags.all || explicit.length === 0) return ['claude', 'codex', 'cursor']
38
+ return explicit
39
+ }
40
+
41
+ function loadPkgVersion() {
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(resolvePath(__dirname, '../package.json'), 'utf8'))
44
+ return pkg.version || '0.0.0'
45
+ } catch { return '0.0.0' }
46
+ }
47
+
48
+ function pidFile(rootDir = DEFAULT_ROOT_DIR) {
49
+ return join(rootDir, 'agentquad.pid')
50
+ }
51
+
52
+ export function writePidFile(rootDir, { pid, port, host }) {
53
+ const payload = { pid, port, host, startedAt: new Date().toISOString() }
54
+ writeFileSync(pidFile(rootDir), JSON.stringify(payload))
55
+ }
56
+
57
+ export function readPidFile(rootDir) {
58
+ const pf = pidFile(rootDir)
59
+ if (!existsSync(pf)) return null
60
+ let raw
61
+ try {
62
+ raw = readFileSync(pf, 'utf8').trim()
63
+ } catch {
64
+ return null
65
+ }
66
+ try {
67
+ const obj = JSON.parse(raw)
68
+ if (obj && typeof obj.pid === 'number' && obj.pid > 0) return obj
69
+ } catch { /* legacy plain-number */ }
70
+ const n = Number(raw)
71
+ if (Number.isFinite(n) && n > 0) return { pid: n }
72
+ return null
73
+ }
74
+
75
+ function isAlive(pid) {
76
+ try { process.kill(pid, 0); return true } catch { return false }
77
+ }
78
+
79
+ // Tailscale 私网段:100.64.0.0 / 10 (RFC 6598 CGNAT)
80
+ function isTailscaleIPv4(addr) {
81
+ if (!addr || typeof addr !== 'string') return false
82
+ const parts = addr.split('.').map(Number)
83
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false
84
+ return parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127
85
+ }
86
+
87
+ // 枚举本机可用于访问的地址:区分 Tailscale / LAN / loopback。
88
+ // 返回 { tailscale: [...], lan: [...], loopback: [...] },每项带 name + address。
89
+ export function collectReachableAddresses() {
90
+ const out = { tailscale: [], lan: [], loopback: [] }
91
+ const ifs = networkInterfaces()
92
+ for (const [name, entries] of Object.entries(ifs)) {
93
+ for (const entry of entries || []) {
94
+ if (entry.family !== 'IPv4') continue
95
+ if (entry.internal) {
96
+ out.loopback.push({ name, address: entry.address })
97
+ } else if (isTailscaleIPv4(entry.address) || /tailscale|utun/i.test(name)) {
98
+ // 兜底:macOS 下 Tailscale 通常是 utunN 接口,配合 100.x 判定更稳
99
+ if (isTailscaleIPv4(entry.address)) {
100
+ out.tailscale.push({ name, address: entry.address })
101
+ } else {
102
+ out.lan.push({ name, address: entry.address })
103
+ }
104
+ } else {
105
+ out.lan.push({ name, address: entry.address })
106
+ }
107
+ }
108
+ }
109
+ return out
110
+ }
111
+
112
+ export function buildStartupBanner({ port, host, addresses = collectReachableAddresses() }) {
113
+ const lines = []
114
+ const url = (addr) => `http://${addr}:${port}`
115
+ const isLoopbackOnly = host === '127.0.0.1' || host === 'localhost'
116
+
117
+ if (isLoopbackOnly) {
118
+ lines.push(`AgentQuad listening on ${url('127.0.0.1')} (loopback only)`)
119
+ lines.push('')
120
+ lines.push('⚠️ To access from phone via Tailscale, run:')
121
+ lines.push(' agentquad config set host 0.0.0.0')
122
+ lines.push(' or start with:')
123
+ lines.push(' agentquad start --expose')
124
+ } else {
125
+ lines.push(`AgentQuad listening on ${url(host === '0.0.0.0' || host === '::' ? 'all-interfaces' : host)} (port ${port})`)
126
+ lines.push('')
127
+ lines.push('⚠️ SECURITY: AgentQuad exposes a shell + AI terminal. Reachable URLs:')
128
+ if (addresses.tailscale.length) {
129
+ lines.push(' Tailscale (recommended — private mesh VPN):')
130
+ for (const item of addresses.tailscale) {
131
+ lines.push(` ${url(item.address)} [${item.name}]`)
132
+ }
133
+ lines.push(' Tip: with MagicDNS you can also use http://<your-mac-name>:' + port)
134
+ } else {
135
+ lines.push(' ❌ No Tailscale interface detected.')
136
+ lines.push(' Install Tailscale on this Mac + your phone, sign into the same account.')
137
+ lines.push(' Guide: docs/MOBILE.md')
138
+ }
139
+ if (addresses.lan.length) {
140
+ lines.push(' LAN (same-WiFi only — anyone on the same network can reach these):')
141
+ for (const item of addresses.lan) {
142
+ lines.push(` ${url(item.address)} [${item.name}]`)
143
+ }
144
+ }
145
+ lines.push('')
146
+ lines.push(' Do NOT put this URL on the public internet without an auth layer.')
147
+ }
148
+
149
+ return lines.join('\n')
150
+ }
151
+
152
+ // ─── exported helpers (for tests) ───
153
+
154
+ /** Fixed list of check names — lets tests assert the structure is complete. */
155
+ export function buildDoctorChecks() {
156
+ return [
157
+ 'rootDir exists',
158
+ 'config.json parseable',
159
+ 'better-sqlite3 loadable',
160
+ 'node-pty loadable',
161
+ 'claude binary',
162
+ 'codex binary',
163
+ 'cursor binary',
164
+ ]
165
+ }
166
+
167
+ /**
168
+ * Runs every check and returns a structured report.
169
+ * @param {object} opts
170
+ * @param {string} [opts.rootDir] override root dir (tests use tmpdir)
171
+ */
172
+ export async function doctorReport({ rootDir = DEFAULT_ROOT_DIR } = {}) {
173
+ const checks = []
174
+
175
+ checks.push({
176
+ name: 'rootDir exists',
177
+ ok: existsSync(rootDir) || (loadConfig({ rootDir }), true),
178
+ })
179
+
180
+ {
181
+ const major = Number(process.version.slice(1).split('.')[0])
182
+ checks.push({
183
+ name: 'Node version',
184
+ ok: major >= 20,
185
+ detail: process.version + (major >= 20 ? '' : ' (please upgrade to Node 20+; e.g. `nvm install 20`)'),
186
+ })
187
+ }
188
+
189
+ {
190
+ const distIndex = resolvePath(__dirname, '../dist-web/index.html')
191
+ const ok = existsSync(distIndex)
192
+ checks.push({
193
+ name: 'frontend assets',
194
+ ok,
195
+ detail: ok
196
+ ? distIndex
197
+ : `missing ${distIndex} — run \`npm run build\` (from source) or \`npm i -g agentquad\` (reinstall)`,
198
+ })
199
+ }
200
+
201
+ let cfg = null
202
+ try {
203
+ cfg = loadConfig({ rootDir })
204
+ checks.push({ name: 'config.json parseable', ok: true })
205
+ } catch (e) {
206
+ checks.push({ name: 'config.json parseable', ok: false, detail: e.message })
207
+ }
208
+
209
+ try {
210
+ const { default: Database } = await import('better-sqlite3')
211
+ const test = new Database(':memory:')
212
+ test.prepare('SELECT 1').get()
213
+ test.close()
214
+ checks.push({ name: 'better-sqlite3 loadable', ok: true })
215
+ } catch (e) {
216
+ checks.push({ name: 'better-sqlite3 loadable', ok: false, detail: e.message })
217
+ }
218
+
219
+ try {
220
+ await import('node-pty')
221
+ checks.push({ name: 'node-pty loadable', ok: true })
222
+ } catch (e) {
223
+ checks.push({ name: 'node-pty loadable', ok: false, detail: e.message })
224
+ }
225
+
226
+ for (const tool of ['claude', 'codex', 'cursor']) {
227
+ const bin = cfg?.tools?.[tool]?.bin || cfg?.tools?.[tool]?.command || tool
228
+ const which = spawnSync('command', ['-v', bin], {
229
+ encoding: 'utf8',
230
+ shell: '/bin/sh',
231
+ })
232
+ const ok = which.status === 0 && which.stdout.trim().length > 0
233
+ checks.push({
234
+ name: `${tool} binary`,
235
+ ok,
236
+ detail: ok ? which.stdout.trim() : `${bin} not found in PATH`,
237
+ })
238
+ }
239
+
240
+ // ─── OpenClaw 桥接(仅当启用时检查)─────────────────────
241
+ const oc = cfg?.openclaw || {}
242
+ if (oc.enabled) {
243
+ // 1. openclaw CLI 可用?
244
+ const ocCli = spawnSync('command', ['-v', 'openclaw'], {
245
+ encoding: 'utf8',
246
+ shell: '/bin/sh',
247
+ })
248
+ const ocCliOk = ocCli.status === 0 && ocCli.stdout.trim().length > 0
249
+ checks.push({
250
+ name: 'openclaw CLI',
251
+ ok: ocCliOk,
252
+ detail: ocCliOk ? ocCli.stdout.trim() : 'openclaw not in PATH (install via `npm i -g openclaw`)',
253
+ })
254
+
255
+ // 2. targetUserId 配置(fallback)
256
+ // 主路径下,每个 ai-session 启动时由 OpenClaw skill 显式传 routeUserId(per-session)。
257
+ // 这里的 targetUserId 只是 ad-hoc ask_user / 没绑 session 时的兜底。
258
+ // 因此空值仅警告,不算 fail。
259
+ if (oc.targetUserId) {
260
+ checks.push({
261
+ name: 'openclaw.targetUserId (fallback)',
262
+ ok: true,
263
+ detail: oc.targetUserId,
264
+ })
265
+ } else {
266
+ checks.push({
267
+ name: 'openclaw.targetUserId (fallback)',
268
+ ok: true,
269
+ detail: '空(per-session 路由仍可工作;如要 ad-hoc 推送,set via `agentquad config set openclaw.targetUserId <peer-id>`)',
270
+ })
271
+ }
272
+
273
+ // 3. AgentQuad skill 装好了吗(OpenClaw 端配置)
274
+ const skillFile = join(homedir(), '.openclaw', 'skills', 'agentquad-claw', 'SKILL.md')
275
+ checks.push({
276
+ name: 'agentquad-claw skill installed',
277
+ ok: existsSync(skillFile),
278
+ detail: existsSync(skillFile)
279
+ ? skillFile
280
+ : '缺失:参考 docs/OPENCLAW.md',
281
+ })
282
+ // 3b. legacy skill 目录还在?软警告(非 failing)
283
+ const legacySkillDir = join(homedir(), '.openclaw', 'skills', 'quadtodo-claw')
284
+ if (existsSync(legacySkillDir)) {
285
+ checks.push({
286
+ name: 'legacy openclaw skill folder',
287
+ ok: true,
288
+ detail: 'legacy ~/.openclaw/skills/quadtodo-claw/ still exists — safe to delete',
289
+ })
290
+ }
291
+
292
+ // 4. Claude Code hook 安装状态(主动推送)
293
+ try {
294
+ const { inspectHooks } = await import('./openclaw-hook-installer.js')
295
+ const hk = inspectHooks()
296
+ checks.push({
297
+ name: 'claude-code hook script',
298
+ ok: hk.scriptExists,
299
+ detail: hk.hookScriptPath + (hk.scriptExists ? '' : ' (missing — should be auto-installed)'),
300
+ })
301
+ checks.push({
302
+ name: 'claude-code hooks installed',
303
+ ok: hk.installed,
304
+ detail: hk.installed
305
+ ? `events: ${hk.eventsInstalled.join(', ')}`
306
+ : '缺失:跑 `agentquad openclaw install-hook` 一次',
307
+ })
308
+ } catch (e) {
309
+ checks.push({
310
+ name: 'claude-code hooks',
311
+ ok: false,
312
+ detail: `inspect failed: ${e.message}`,
313
+ })
314
+ }
315
+ }
316
+
317
+ // ─── Telegram 直连(仅当启用时检查)────────────────────────
318
+ const tg = cfg?.telegram || {}
319
+ if (tg.enabled) {
320
+ // 5. supergroupId
321
+ checks.push({
322
+ name: 'telegram.supergroupId',
323
+ ok: Boolean(tg.supergroupId),
324
+ detail: tg.supergroupId || '未配置:第一次跑 AgentQuad 时让 bot 拿 chat.id(log 里),再 `agentquad config set telegram.supergroupId <id>`',
325
+ })
326
+
327
+ // 6. allowedChatIds(白名单)
328
+ const allowList = Array.isArray(tg.allowedChatIds) ? tg.allowedChatIds : []
329
+ checks.push({
330
+ name: 'telegram.allowedChatIds',
331
+ ok: allowList.length > 0,
332
+ detail: allowList.length > 0
333
+ ? allowList.join(', ')
334
+ : '空 = 拒所有:跑 `agentquad config set telegram.allowedChatIds.0 <supergroup-id>`',
335
+ })
336
+
337
+ // 7. token(从 ~/.agentquad/config.json 读)
338
+ try {
339
+ const { readBotToken } = await import('./telegram-bot.js')
340
+ const tok = readBotToken(() => cfg)
341
+ checks.push({
342
+ name: 'telegram bot token',
343
+ ok: Boolean(tok),
344
+ detail: tok ? '✓ token in ~/.agentquad/config.json' : '缺失:在 Web Settings → Telegram 里填 Bot Token,或编辑 ~/.agentquad/config.json 的 telegram.botToken',
345
+ })
346
+ } catch (e) {
347
+ checks.push({ name: 'telegram bot token', ok: false, detail: e.message })
348
+ }
349
+
350
+ // 注:hook check 已经在 openclaw 段做过;不重复
351
+ }
352
+
353
+ return { ok: checks.every(c => c.ok), checks }
354
+ }
355
+
356
+ // runStart:start 子命令的核心实现,导出给默认 action / 首跑向导复用
357
+ export async function runStart(cmdOpts = {}) {
358
+ // dry-run 短路(仅用于测试,让默认 action 测试不真起服务 / 不跑向导)
359
+ if (process.env.AGENTQUAD_DRY_RUN === '1') return
360
+
361
+ const rootDir = DEFAULT_ROOT_DIR
362
+ const cfg = loadConfig({ rootDir })
363
+ const defaultCwd = cmdOpts.cwd || cfg.defaultCwd || process.env.HOME || process.cwd()
364
+ const host = cmdOpts.expose
365
+ ? '0.0.0.0'
366
+ : (cmdOpts.host || cfg.host || '127.0.0.1')
367
+
368
+ // 首跑向导(命中条件才进;任何异常都不阻塞后续 start)
369
+ try {
370
+ const need = shouldRunWizard({
371
+ rootDir,
372
+ isTTY: !!process.stdin.isTTY && !!process.stdout.isTTY,
373
+ env: process.env,
374
+ flags: { wizard: cmdOpts.wizard !== false },
375
+ })
376
+ if (need) {
377
+ const r = await runFirstRunWizard()
378
+ if (r.defaultTool) {
379
+ setConfigValue('defaultTool', r.defaultTool, { rootDir })
380
+ }
381
+ }
382
+ } catch (e) {
383
+ console.warn(`⚠ first-run wizard skipped: ${e?.message || e}`)
384
+ }
385
+
386
+ // ─── stdout/stderr 复制到 ~/.agentquad/logs/agentquad.log ───
387
+ // 保留正常 console 输出 + 同步追加到日志文件,方便诊断
388
+ try {
389
+ const logsDir = join(rootDir, 'logs')
390
+ if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true })
391
+ const logFile = join(logsDir, 'agentquad.log')
392
+ // 启动时如果 log > 5MB 就截断到尾部 1MB
393
+ try {
394
+ const { statSync } = await import('node:fs')
395
+ const st = statSync(logFile)
396
+ if (st.size > 5 * 1024 * 1024) {
397
+ const buf = readFileSync(logFile)
398
+ const tail = buf.subarray(buf.length - 1024 * 1024)
399
+ writeFileSync(logFile, tail)
400
+ }
401
+ } catch { /* file 不存在或读不了,忽略 */ }
402
+ const { createWriteStream } = await import('node:fs')
403
+ const logStream = createWriteStream(logFile, { flags: 'a' })
404
+ logStream.write(`\n=== agentquad start ${new Date().toISOString()} pid=${process.pid} ===\n`)
405
+ const wrap = (orig) => (...args) => {
406
+ try {
407
+ const line = args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')
408
+ logStream.write(`${new Date().toISOString()} ${line}\n`)
409
+ } catch { /* 写 log 失败不阻塞 */ }
410
+ orig.apply(console, args)
411
+ }
412
+ console.log = wrap(console.log)
413
+ console.info = wrap(console.info)
414
+ console.warn = wrap(console.warn)
415
+ console.error = wrap(console.error)
416
+ console.debug = wrap(console.debug)
417
+ } catch (e) {
418
+ console.warn(`[startup] log file setup failed: ${e.message}; continuing without file log`)
419
+ }
420
+
421
+ const pf = pidFile(rootDir)
422
+ const existing = readPidFile(rootDir)
423
+ if (existing && isAlive(existing.pid)) {
424
+ const where = existing.port ? `http://${existing.host || '127.0.0.1'}:${existing.port}` : '(unknown port)'
425
+ console.error(`AgentQuad already running (pid ${existing.pid}) at ${where}. Run 'agentquad stop' first.`)
426
+ process.exit(1)
427
+ }
428
+ if (existing) { try { unlinkSync(pf) } catch { /* ignore */ } }
429
+
430
+ const port = cmdOpts.port || cfg.port
431
+ const { createServer } = await import('./server.js')
432
+ const srv = createServer({
433
+ dbFile: join(rootDir, 'data.db'),
434
+ logDir: join(rootDir, 'logs'),
435
+ tools: resolveToolsConfig(cfg.tools),
436
+ defaultCwd,
437
+ configRootDir: rootDir,
438
+ webDist: resolvePath(__dirname, '../dist-web'),
439
+ strictWebDist: true,
440
+ })
441
+
442
+ let actualPort
443
+ try {
444
+ actualPort = await srv.listen(port, host)
445
+ } catch (e) {
446
+ if (e.code === 'EADDRINUSE') {
447
+ console.error(`ports ${port} and ${port + 1} both in use — run 'agentquad config set port <newPort>' or stop whoever holds them`)
448
+ } else if (e.code === 'EADDRNOTAVAIL') {
449
+ console.error(`host ${host} not available on this machine — try --host 0.0.0.0`)
450
+ } else {
451
+ console.error(`listen failed: ${e.message}`)
452
+ }
453
+ process.exit(1)
454
+ }
455
+
456
+ writePidFile(rootDir, { pid: process.pid, port: actualPort, host })
457
+ console.log(buildStartupBanner({ port: actualPort, host }))
458
+ console.log(`AI terminal default cwd: ${defaultCwd}`)
459
+
460
+ // ─── 自动 bootstrap Claude Code hook(部署 notify.js + 合入 settings.json)───
461
+ // 设计:缺啥补啥 / 已装则 noop / 用户跑过 uninstall-hook 留下的 marker 会被尊重
462
+ // 任何错误一律 warn-skip,绝不让 hook bootstrap 把 agentquad start 挂掉
463
+ try {
464
+ const { bootstrapHooks } = await import('./openclaw-hook-installer.js')
465
+ const r = bootstrapHooks()
466
+ if (r.skipped) {
467
+ if (r.reason === 'uninstall_marker') {
468
+ console.log(`ℹ claude-code hook: 已被你 uninstall-hook 拒绝;想恢复跑 'agentquad openclaw bootstrap'`)
469
+ } else if (r.reason === 'malformed_settings') {
470
+ console.warn(`⚠ claude-code hook: ~/.claude/settings.json JSON 损坏,跳过自动安装;修好后跑 'agentquad openclaw bootstrap'`)
471
+ } else {
472
+ console.log(`ℹ claude-code hook bootstrap skipped: ${r.reason}`)
473
+ }
474
+ } else {
475
+ if (r.scriptResult.action === 'installed') {
476
+ console.log(`✓ claude-code hook script installed (v${r.scriptResult.version}) → ${r.scriptResult.scriptPath}`)
477
+ } else if (r.scriptResult.action === 'upgraded') {
478
+ console.log(`✓ claude-code hook script upgraded v${r.scriptResult.previousVersion ?? 0} → v${r.scriptResult.version} (backup: ${r.scriptResult.backup})`)
479
+ }
480
+ if (r.alreadyInstalled) {
481
+ // 静默:避免每次 start 都刷屏。doctor 会显示状态
482
+ } else if (r.hookResult) {
483
+ console.log(`✓ claude-code hooks installed: ${r.hookResult.added.join(', ')}`)
484
+ }
485
+ }
486
+ } catch (e) {
487
+ console.warn(`⚠ claude-code hook bootstrap failed: ${e?.message || e}`)
488
+ }
489
+
490
+ // listen 完成后异步发"重启完成 + Resume N 个会话"通知到 telegram。
491
+ // 不 await,发不发都不阻塞 boot;postText 走 telegram HTTPS 直发,不依赖 long-poll
492
+ if (typeof srv.notifyStartupRecovery === 'function') {
493
+ Promise.resolve().then(() => srv.notifyStartupRecovery()).catch(() => {})
494
+ }
495
+
496
+ if (cmdOpts.open !== false) {
497
+ try {
498
+ const { default: open } = await import('open')
499
+ // 浏览器自动打开仍走 127.0.0.1(避免 0.0.0.0 在浏览器里非法)
500
+ open(`http://127.0.0.1:${actualPort}`)
501
+ } catch (e) {
502
+ console.warn(`could not auto-open browser: ${e.message}`)
503
+ }
504
+ }
505
+
506
+ const shutdown = async (signal) => {
507
+ console.log(`\nreceived ${signal}, shutting down...`)
508
+ try { unlinkSync(pf) } catch { /* ignore */ }
509
+ await srv.close()
510
+ process.exit(0)
511
+ }
512
+ process.on('SIGINT', () => shutdown('SIGINT'))
513
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
514
+ }
515
+
516
+ // ─── commander ───
517
+
518
+ const program = new Command()
519
+ program
520
+ .name('agentquad')
521
+ .description('Local four-quadrant todo CLI with embedded Claude Code / Codex terminal')
522
+ .version(loadPkgVersion())
523
+
524
+ program.command('start')
525
+ .option('-p, --port <port>', 'override port', (v) => Number(v))
526
+ .option('--no-open', 'do not auto-open browser')
527
+ .option('--cwd <path>', 'default cwd for AI terminal sessions')
528
+ .option('--host <host>', 'bind address (e.g. 0.0.0.0 to allow Tailscale/LAN access)')
529
+ .option('--expose', 'shorthand for --host 0.0.0.0 (bind all interfaces)')
530
+ .option('--no-wizard', 'skip first-run wizard even if config.json is absent')
531
+ .action(async (cmdOpts) => { await runStart(cmdOpts) })
532
+
533
+ // 裸跑 `agentquad`(无子命令)→ 复用 start 逻辑,默认开向导
534
+ // 注意:--no-wizard 同时挂在 start 子命令上,两边需保持一致
535
+ program
536
+ .option('--no-wizard', 'skip first-run wizard')
537
+ .action(async function (cmdOpts) {
538
+ if (this.args.length) {
539
+ console.error(`Unknown command: ${this.args[0]}`)
540
+ console.error(`Run 'agentquad --help' for available commands.`)
541
+ process.exit(1)
542
+ }
543
+ await runStart(cmdOpts)
544
+ })
545
+
546
+ program.command('stop')
547
+ .action(async () => {
548
+ const pf = pidFile(DEFAULT_ROOT_DIR)
549
+ const info = readPidFile(DEFAULT_ROOT_DIR)
550
+ if (!info) { console.log('AgentQuad is not running (no pid file)'); return }
551
+ const pid = info.pid
552
+ if (!pid || !isAlive(pid)) {
553
+ console.log('stale pid file, removing')
554
+ try { unlinkSync(pf) } catch { /* ignore */ }
555
+ return
556
+ }
557
+ process.kill(pid, 'SIGTERM')
558
+ const deadline = Date.now() + 3000
559
+ while (Date.now() < deadline) {
560
+ if (!isAlive(pid)) {
561
+ try { unlinkSync(pf) } catch { /* ignore */ }
562
+ console.log('AgentQuad stopped')
563
+ return
564
+ }
565
+ await new Promise(r => setTimeout(r, 100))
566
+ }
567
+ console.log('AgentQuad did not exit in 3s, sending SIGKILL')
568
+ try { process.kill(pid, 'SIGKILL') } catch { /* ignore */ }
569
+ try { unlinkSync(pf) } catch { /* ignore */ }
570
+ })
571
+
572
+ program.command('status')
573
+ .action(async () => {
574
+ const info = readPidFile(DEFAULT_ROOT_DIR)
575
+ if (!info) { console.log('not running'); return }
576
+ const pid = info.pid
577
+ if (!isAlive(pid)) {
578
+ console.log(`stale pid file (${pid}), not running`)
579
+ return
580
+ }
581
+ const port = info.port ?? loadConfig().port
582
+ try {
583
+ const r = await fetch(`http://127.0.0.1:${port}/api/status`)
584
+ const body = await r.json()
585
+ console.log(`running pid=${pid} port=${port} version=${body.version} activeSessions=${body.activeSessions}`)
586
+ } catch {
587
+ console.log(`running pid=${pid} port=${port} (could not reach /api/status)`)
588
+ }
589
+ })
590
+
591
+ program.command('doctor')
592
+ .action(async () => {
593
+ const report = await doctorReport()
594
+ for (const c of report.checks) {
595
+ const icon = c.ok ? '✓' : '✗'
596
+ const tail = c.detail ? ` — ${c.detail}` : ''
597
+ console.log(`${icon} ${c.name}${tail}`)
598
+ }
599
+
600
+ const missing = report.checks
601
+ .filter(c => !c.ok && /^(claude|codex|cursor) binary$/.test(c.name))
602
+ .map(c => c.name.split(' ')[0])
603
+
604
+ if (missing.length > 0) {
605
+ const flags = missing.map(t => `--${t}`).join(' ')
606
+ console.log(`\nMissing AI CLI(s): ${missing.join(', ')}`)
607
+ console.log(`Suggested fix: agentquad install-tools ${flags}`)
608
+ if (process.stdin.isTTY) {
609
+ const ans = await prompt(`Run it now? [Enter = yes / q = skip] `)
610
+ if (ans.trim().toLowerCase() !== 'q') {
611
+ const r = spawnSync(process.execPath, [
612
+ fileURLToPath(import.meta.url),
613
+ 'install-tools',
614
+ ...missing.map(t => `--${t}`),
615
+ '-y',
616
+ ], { stdio: 'inherit' })
617
+ process.exit(r.status ?? 1)
618
+ }
619
+ }
620
+ }
621
+
622
+ process.exit(report.ok ? 0 : 1)
623
+ })
624
+
625
+ program.command('install-tools')
626
+ .description('Install missing AI CLIs (claude / codex / cursor)')
627
+ .option('--claude', 'install only @anthropic-ai/claude-code (npm)')
628
+ .option('--codex', 'install only @openai/codex (npm)')
629
+ .option('--cursor', 'install only cursor-agent (upstream shell installer)')
630
+ .option('--all', 'install all (default if no flag given)')
631
+ .option('-y, --yes', 'skip the y/N confirmation')
632
+ .action(async (opts) => {
633
+ const tools = planInstallTools(opts)
634
+ const items = tools.map((t) => ({ tool: t, ...TOOL_PACKAGES[t] }))
635
+
636
+ console.log('About to install:')
637
+ for (const it of items) {
638
+ if (it.kind === 'shell') console.log(` - ${it.bin} via: ${it.script}`)
639
+ else console.log(` - ${it.pkg} (binary: ${it.bin}) via npm install -g`)
640
+ }
641
+
642
+ if (!opts.yes && process.stdin.isTTY) {
643
+ const ok = await prompt('Continue? [y/N] ')
644
+ if (!/^y(es)?$/i.test(ok.trim())) {
645
+ console.log('Aborted.')
646
+ process.exit(0)
647
+ }
648
+ }
649
+
650
+ let allOk = true
651
+ for (const it of items) {
652
+ if (it.kind === 'shell') {
653
+ console.log(`\n>> ${it.script}`)
654
+ const r = spawnSync('/bin/sh', ['-lc', it.script], { stdio: 'inherit' })
655
+ if (r.status !== 0) {
656
+ console.error(`\n✗ shell installer for ${it.bin} exited ${r.status}`)
657
+ console.error(` Manual fix: re-run "${it.script}" in your shell, then check PATH.`)
658
+ allOk = false
659
+ break
660
+ }
661
+ } else {
662
+ console.log(`\n>> npm install -g ${it.pkg}`)
663
+ const r = spawnSync('npm', ['install', '-g', it.pkg], { stdio: 'inherit' })
664
+ if (r.status !== 0) {
665
+ console.error(`\n✗ npm install -g ${it.pkg} exited ${r.status}`)
666
+ printInstallFailureFix(it)
667
+ allOk = false
668
+ break
669
+ }
670
+ }
671
+ const w = spawnSync('command', ['-v', it.bin], { encoding: 'utf8', shell: '/bin/sh' })
672
+ if (w.status !== 0 || !w.stdout.trim()) {
673
+ console.error(`\n✗ installer reported success but \`${it.bin}\` is not in PATH.`)
674
+ if (it.kind === 'shell') {
675
+ console.error(` You may need to restart your shell, or run the installer manually: ${it.script}`)
676
+ } else {
677
+ printInstallFailureFix(it)
678
+ }
679
+ allOk = false
680
+ break
681
+ }
682
+ console.log(`✓ ${it.bin} → ${w.stdout.trim()}`)
683
+ }
684
+
685
+ process.exit(allOk ? 0 : 1)
686
+ })
687
+
688
+ // ─── agentquad mcp install / status ─────────────────────────────
689
+
690
+ export function defaultClaudeSettingsPath() {
691
+ return join(homedir(), '.claude', 'settings.json')
692
+ }
693
+
694
+ export function buildMcpServerEntry({ host, port } = {}) {
695
+ const h = host && host !== '0.0.0.0' ? host : '127.0.0.1'
696
+ return {
697
+ type: 'http',
698
+ url: `http://${h}:${port}/mcp`,
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Merge `agentquad` 进 settings.json 的 mcpServers 段,不破坏现有条目。
704
+ * - 如果 settings.json 不存在:创建一个只含 mcpServers 的新文件
705
+ * - 如果存在且有效 JSON:merge
706
+ * - 如果存在但不是 JSON:报错(让用户自己先修好)
707
+ * - 如果存在 legacy `quadtodo` entry 且其 url/command 指向 OUR 包 bin → 删除
708
+ *
709
+ * 返回 { path, action: 'created'|'updated'|'unchanged', entry, legacyRemoved }
710
+ */
711
+ export function installMcpIntoClaudeSettings({
712
+ settingsPath = defaultClaudeSettingsPath(),
713
+ host,
714
+ port,
715
+ name = 'agentquad',
716
+ } = {}) {
717
+ const entry = buildMcpServerEntry({ host, port })
718
+ let settings = {}
719
+ let existed = false
720
+ if (existsSync(settingsPath)) {
721
+ existed = true
722
+ const raw = readFileSync(settingsPath, 'utf8')
723
+ try {
724
+ settings = JSON.parse(raw)
725
+ } catch (e) {
726
+ const err = new Error(`settings.json exists but is not valid JSON: ${e.message}`)
727
+ err.code = 'invalid_settings'
728
+ throw err
729
+ }
730
+ } else {
731
+ const dir = dirname(settingsPath)
732
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
733
+ }
734
+ if (!settings.mcpServers || typeof settings.mcpServers !== 'object') {
735
+ settings.mcpServers = {}
736
+ }
737
+ // legacy 清理:如果有 quadtodo entry 且看起来是 OUR 包(http URL 指向 /mcp,或
738
+ // command 字段含 /quadtodo/ | /agentquad/),删掉它。
739
+ let legacyRemoved = false
740
+ const legacy = settings.mcpServers.quadtodo
741
+ if (legacy && name !== 'quadtodo') {
742
+ const isOurs =
743
+ (typeof legacy.url === 'string' && /\/mcp\/?$/.test(legacy.url)) ||
744
+ (typeof legacy.command === 'string' && /\/agentquad\/|\/quadtodo\//.test(legacy.command))
745
+ if (isOurs) {
746
+ delete settings.mcpServers.quadtodo
747
+ legacyRemoved = true
748
+ }
749
+ }
750
+ const existing = settings.mcpServers[name]
751
+ const same = existing && existing.type === entry.type && existing.url === entry.url
752
+ if (same && !legacyRemoved) {
753
+ return { path: settingsPath, action: 'unchanged', entry, legacyRemoved }
754
+ }
755
+ settings.mcpServers[name] = entry
756
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
757
+ return { path: settingsPath, action: existed ? 'updated' : 'created', entry, legacyRemoved }
758
+ }
759
+
760
+ const mcpCmd = program.command('mcp').description('Claude Code MCP: install / status')
761
+
762
+ mcpCmd.command('install')
763
+ .option('--settings <path>', 'path to claude settings.json', defaultClaudeSettingsPath())
764
+ .option('--host <host>', 'override host in the URL (useful when this Mac is accessed remotely)')
765
+ .action((opts) => {
766
+ const cfg = loadConfig()
767
+ try {
768
+ const out = installMcpIntoClaudeSettings({
769
+ settingsPath: opts.settings,
770
+ host: opts.host || cfg.host,
771
+ port: cfg.port,
772
+ })
773
+ const icon = out.action === 'unchanged' ? '=' : out.action === 'created' ? '+' : '~'
774
+ console.log(`${icon} ${out.action} ${out.path}`)
775
+ if (out.legacyRemoved) {
776
+ console.log(' removed legacy mcpServers["quadtodo"] entry')
777
+ }
778
+ console.log(` mcpServers.agentquad.url = ${out.entry.url}`)
779
+ if (out.action === 'unchanged') {
780
+ console.log(' (already configured)')
781
+ } else {
782
+ console.log(' Claude Code 里输入 /mcp 可验证连接。')
783
+ }
784
+ } catch (e) {
785
+ console.error(`install failed: ${e.message}`)
786
+ process.exit(1)
787
+ }
788
+ })
789
+
790
+ mcpCmd.command('status')
791
+ .action(async () => {
792
+ const cfg = loadConfig()
793
+ const port = cfg.port
794
+ const url = `http://127.0.0.1:${port}/mcp/health`
795
+ try {
796
+ const r = await fetch(url)
797
+ const body = await r.json()
798
+ console.log(`✓ ${url}`)
799
+ console.log(` ${JSON.stringify(body)}`)
800
+ } catch (e) {
801
+ console.error(`✗ ${url} unreachable: ${e.message}`)
802
+ console.error(` AgentQuad 是不是没跑?试 'agentquad start' 或 'npm start'`)
803
+ process.exit(1)
804
+ }
805
+ })
806
+
807
+ // ─── hook 操作共享 action(被顶层 `hook` 命令组和老的 `openclaw` 子命令复用)─────
808
+ async function actInstallHook() {
809
+ const { installHooks } = await import('./openclaw-hook-installer.js')
810
+ try {
811
+ const out = installHooks()
812
+ console.log(`✓ installed ${out.added.join(', ')} hooks`)
813
+ console.log(` settings: ${out.settingsPath}`)
814
+ if (out.backup) console.log(` backup: ${out.backup}`)
815
+ if (out.markerCleared) console.log(` uninstall marker cleared`)
816
+ console.log('')
817
+ console.log('完成。新的 PTY 会话启动后会自动通过 hook 推送状态到微信。')
818
+ console.log('注意:现存的 PTY 会话(重启前已经在跑的)env 已固定,不受影响;')
819
+ console.log(' 新 agentquad.start_ai_session 启动的 PTY 才会带 hook env。')
820
+ } catch (e) {
821
+ console.error(`install-hook failed: ${e.message}`)
822
+ if (e.code === 'malformed_settings') {
823
+ console.error(` 你的 ~/.claude/settings.json JSON 不合法,先修复再试。`)
824
+ }
825
+ if (e.code === 'hook_script_missing') {
826
+ console.error(` hook 脚本缺失。跑 'agentquad hook bootstrap' 一键部署 + 安装。`)
827
+ }
828
+ process.exit(1)
829
+ }
830
+ }
831
+
832
+ async function actBootstrapHook() {
833
+ const { bootstrapHooks } = await import('./openclaw-hook-installer.js')
834
+ try {
835
+ const r = bootstrapHooks({ respectUninstallMarker: false })
836
+ if (r.skipped) {
837
+ if (r.reason === 'malformed_settings') {
838
+ console.error(`✗ bootstrap skipped: ${r.settingsPath} JSON 不合法,请先修复`)
839
+ process.exit(1)
840
+ }
841
+ console.log(`= bootstrap skipped: ${r.reason}`)
842
+ return
843
+ }
844
+ const sr = r.scriptResult
845
+ if (sr.action === 'installed') {
846
+ console.log(`✓ hook script installed (v${sr.version}) → ${sr.scriptPath}`)
847
+ } else if (sr.action === 'upgraded') {
848
+ console.log(`✓ hook script upgraded v${sr.previousVersion ?? 0} → v${sr.version}`)
849
+ if (sr.backup) console.log(` backup: ${sr.backup}`)
850
+ } else {
851
+ console.log(`= hook script up-to-date (v${sr.version}) → ${sr.scriptPath}`)
852
+ }
853
+ if (r.alreadyInstalled) {
854
+ console.log(`= hooks already installed in ~/.claude/settings.json`)
855
+ } else if (r.hookResult) {
856
+ console.log(`✓ hooks installed: ${r.hookResult.added.join(', ')}`)
857
+ if (r.hookResult.backup) console.log(` settings backup: ${r.hookResult.backup}`)
858
+ }
859
+ if (r.markerCleared) console.log(` uninstall marker cleared`)
860
+ } catch (e) {
861
+ console.error(`bootstrap failed: ${e.message}`)
862
+ process.exit(1)
863
+ }
864
+ }
865
+
866
+ async function actUninstallHook(opts) {
867
+ const { uninstallHooks } = await import('./openclaw-hook-installer.js')
868
+ try {
869
+ const out = uninstallHooks({ writeUninstallMarker: opts.marker !== false })
870
+ if (out.removed.length === 0) {
871
+ console.log('= no AgentQuad hooks installed; nothing to remove')
872
+ } else {
873
+ console.log(`✓ removed AgentQuad hooks from ${out.settingsPath}`)
874
+ for (const r of out.removed) console.log(` ${r.event}: -${r.removedCount}`)
875
+ if (out.backup) console.log(` backup: ${out.backup}`)
876
+ }
877
+ if (out.markerWritten) {
878
+ console.log(` marker written → 下次 'agentquad start' 不会自动装回;想恢复跑 'agentquad hook bootstrap'`)
879
+ }
880
+ } catch (e) {
881
+ console.error(`uninstall-hook failed: ${e.message}`)
882
+ process.exit(1)
883
+ }
884
+ }
885
+
886
+ async function actHookStatus() {
887
+ const { inspectHooks } = await import('./openclaw-hook-installer.js')
888
+ const r = inspectHooks()
889
+ const icon = r.installed ? '✓' : '✗'
890
+ console.log(`${icon} hooks installed: ${r.installed}`)
891
+ console.log(` events: ${r.eventsInstalled.length ? r.eventsInstalled.join(', ') : '(none)'}`)
892
+ console.log(` settings: ${r.settingsPath}`)
893
+ console.log(` hook script: ${r.hookScriptPath} (${r.scriptExists ? 'exists' : 'MISSING'})`)
894
+ if (r.error) console.log(` ⚠️ ${r.error}`)
895
+ }
896
+
897
+ // ─── 顶层 hook 子命令组(首选入口;发现性比埋在 openclaw 下好)──────
898
+ const hookCmd = program.command('hook').description('管理 AgentQuad 在 ~/.claude/settings.json 里安装的 hook(装/删/查/恢复)')
899
+
900
+ hookCmd.command('install')
901
+ .description('把 AgentQuad 的 3 个 hook(Stop/Notification/SessionEnd)合并写入 ~/.claude/settings.json')
902
+ .action(actInstallHook)
903
+
904
+ hookCmd.command('uninstall')
905
+ .description('从 ~/.claude/settings.json 移除 AgentQuad 加的 hook entry,保留你其他 hook(默认写 .uninstalled marker,下次 start 不再自动装回)')
906
+ .option('--no-marker', '不写 .uninstalled marker(下次 agentquad start 会自动装回)')
907
+ .action(actUninstallHook)
908
+
909
+ hookCmd.command('status')
910
+ .description('查看 AgentQuad hook 是否安装到 ~/.claude/settings.json')
911
+ .action(actHookStatus)
912
+
913
+ hookCmd.command('bootstrap')
914
+ .description('一键部署 hook script + 安装 hooks(强制忽略 .uninstalled marker,用于"删过又想恢复"场景)')
915
+ .action(actBootstrapHook)
916
+
917
+ // ─── openclaw 子命令组:保留旧路径以向后兼容;hook 操作建议改用 `agentquad hook *` ───
918
+ const openclawCmd = program.command('openclaw').description('OpenClaw bridge: install/uninstall Claude Code hooks for proactive WeChat push')
919
+
920
+ openclawCmd.command('install-hook')
921
+ .description('alias of `agentquad hook install`')
922
+ .action(actInstallHook)
923
+
924
+ openclawCmd.command('bootstrap')
925
+ .description('alias of `agentquad hook bootstrap`')
926
+ .action(actBootstrapHook)
927
+
928
+ openclawCmd.command('uninstall-hook')
929
+ .description('alias of `agentquad hook uninstall`')
930
+ .option('--no-marker', '不写 .uninstalled marker(下次 agentquad start 会自动装回)')
931
+ .action(actUninstallHook)
932
+
933
+ openclawCmd.command('hook-status')
934
+ .description('alias of `agentquad hook status`')
935
+ .action(actHookStatus)
936
+
937
+ openclawCmd.command('inbound')
938
+ .description('OpenClaw skill 单入口:转发一条用户消息到 AgentQuad wizard,stdout 是给用户的回复')
939
+ .requiredOption('--from <peer>', '微信对端 user_id(OpenClaw 给的 from_user_id)')
940
+ .requiredOption('--text <text>', '用户原文')
941
+ .option('--port <port>', 'AgentQuad 端口', (v) => Number(v))
942
+ .action(async (opts) => {
943
+ const cfg = loadConfig()
944
+ const port = opts.port || cfg.port || 5677
945
+ const url = `http://127.0.0.1:${port}/api/openclaw/inbound`
946
+ try {
947
+ const res = await fetch(url, {
948
+ method: 'POST',
949
+ headers: { 'Content-Type': 'application/json' },
950
+ body: JSON.stringify({ from: opts.from, text: opts.text }),
951
+ })
952
+ const data = await res.json().catch(() => ({}))
953
+ if (!res.ok || !data.ok) {
954
+ console.error(`✗ ${res.status} ${data.error || 'unknown'}`)
955
+ process.exit(1)
956
+ }
957
+ // 把 reply 直接打到 stdout — OpenClaw skill 会把它转发回微信用户
958
+ process.stdout.write(String(data.reply || ''))
959
+ // exit 0
960
+ } catch (e) {
961
+ console.error(`✗ inbound failed: ${e.message}`)
962
+ console.error(` AgentQuad 是不是没跑?试 'agentquad status'`)
963
+ process.exit(1)
964
+ }
965
+ })
966
+
967
+ openclawCmd.command('inbound-state')
968
+ .description('查看 wizard 当前进行中的 peer 列表(调试用)')
969
+ .action(async () => {
970
+ const cfg = loadConfig()
971
+ const port = cfg.port || 5677
972
+ try {
973
+ const res = await fetch(`http://127.0.0.1:${port}/api/openclaw/inbound/state`)
974
+ const data = await res.json()
975
+ console.log(JSON.stringify(data, null, 2))
976
+ } catch (e) {
977
+ console.error(`✗ ${e.message}`)
978
+ process.exit(1)
979
+ }
980
+ })
981
+
982
+ const cfgCmd = program.command('config').description('read/write ~/.agentquad/config.json')
983
+ cfgCmd.command('get <key>').action((key) => {
984
+ const v = getConfigValue(key)
985
+ if (v === undefined) process.exit(1)
986
+ console.log(typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v))
987
+ })
988
+ cfgCmd.command('set <key> <value>').action((key, value) => {
989
+ const coerced = setConfigValue(key, value)
990
+ console.log(`set ${key} = ${coerced}`)
991
+ })
992
+ cfgCmd.command('list').action(() => {
993
+ console.log(JSON.stringify(loadConfig(), null, 2))
994
+ })
995
+
996
+ // 仅当被作为可执行脚本运行时才 parse(import 进来做测试时跳过)。
997
+ // 用 realpath 比对,避免 npm link symlink 下 process.argv[1] !== import.meta.url 把判断打飞。
998
+ const isMain = (() => {
999
+ if (!process.argv[1]) return false
1000
+ try {
1001
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
1002
+ } catch {
1003
+ // fallback:argv[1] 是 cli.js 或 bin 名 'agentquad' / 'quadtodo'(legacy alias)
1004
+ if (process.argv[1].endsWith('cli.js')) return true
1005
+ if (/\/(agentquad|quadtodo)$/.test(process.argv[1])) return true
1006
+ return false
1007
+ }
1008
+ })()
1009
+ if (isMain) {
1010
+ program.parseAsync(process.argv)
1011
+ }
1012
+
1013
+ function printInstallFailureFix(it) {
1014
+ console.error(`
1015
+ Common fixes:
1016
+ - Permissions: try \`sudo npm install -g ${it.pkg}\`,
1017
+ or move npm prefix into your home dir:
1018
+ \`npm config set prefix ~/.npm-global\`
1019
+ and add \`~/.npm-global/bin\` to your PATH.
1020
+ - If you use nvm: \`nvm use 20\` first, then retry.
1021
+ - Network/registry: check \`npm config get registry\`.
1022
+ `)
1023
+ }
1024
+
1025
+ function prompt(question) {
1026
+ return new Promise((resolve) => {
1027
+ process.stdout.write(question)
1028
+ let buf = ''
1029
+ process.stdin.setEncoding('utf8')
1030
+ const onData = (chunk) => {
1031
+ buf += chunk
1032
+ const nl = buf.indexOf('\n')
1033
+ if (nl >= 0) {
1034
+ process.stdin.removeListener('data', onData)
1035
+ resolve(buf.slice(0, nl))
1036
+ }
1037
+ }
1038
+ process.stdin.on('data', onData)
1039
+ })
1040
+ }