coding-tool-x 3.3.8 → 3.3.9

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 (75) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-DLpoDZ2M.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-D_hRb55W.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-JXsyym1s.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DZWSeb-q.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-Cs624DR3.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-bEliz7qz.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-J3RecFGn.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-Cuc23WS7.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +13 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/config/paths.js +638 -93
  25. package/src/server/api/agents.js +1 -1
  26. package/src/server/api/claude-hooks.js +13 -8
  27. package/src/server/api/codex-proxy.js +5 -4
  28. package/src/server/api/hooks.js +45 -0
  29. package/src/server/api/plugins.js +0 -1
  30. package/src/server/api/ui-config.js +5 -0
  31. package/src/server/codex-proxy-server.js +89 -59
  32. package/src/server/gemini-proxy-server.js +107 -88
  33. package/src/server/index.js +1 -0
  34. package/src/server/opencode-proxy-server.js +381 -225
  35. package/src/server/proxy-server.js +86 -60
  36. package/src/server/services/alias.js +3 -3
  37. package/src/server/services/channels.js +3 -2
  38. package/src/server/services/codex-channels.js +41 -87
  39. package/src/server/services/codex-env-manager.js +423 -0
  40. package/src/server/services/codex-settings-manager.js +15 -15
  41. package/src/server/services/codex-statistics-service.js +3 -27
  42. package/src/server/services/config-export-service.js +20 -7
  43. package/src/server/services/config-registry-service.js +3 -2
  44. package/src/server/services/config-sync-manager.js +1 -1
  45. package/src/server/services/favorites.js +4 -3
  46. package/src/server/services/gemini-channels.js +3 -3
  47. package/src/server/services/gemini-statistics-service.js +3 -25
  48. package/src/server/services/mcp-service.js +2 -3
  49. package/src/server/services/model-detector.js +4 -3
  50. package/src/server/services/native-oauth-adapters.js +2 -1
  51. package/src/server/services/network-access.js +39 -1
  52. package/src/server/services/notification-hooks.js +951 -0
  53. package/src/server/services/opencode-channels.js +6 -6
  54. package/src/server/services/opencode-sessions.js +2 -2
  55. package/src/server/services/opencode-statistics-service.js +3 -27
  56. package/src/server/services/plugins-service.js +110 -31
  57. package/src/server/services/prompts-service.js +2 -3
  58. package/src/server/services/proxy-log-helper.js +242 -0
  59. package/src/server/services/proxy-runtime.js +6 -4
  60. package/src/server/services/repo-scanner-base.js +12 -4
  61. package/src/server/services/request-logger.js +7 -7
  62. package/src/server/services/security-config.js +4 -4
  63. package/src/server/services/session-cache.js +2 -2
  64. package/src/server/services/sessions.js +2 -2
  65. package/src/server/services/skill-service.js +174 -55
  66. package/src/server/services/statistics-service.js +5 -5
  67. package/src/server/services/ui-config.js +4 -3
  68. package/src/server/services/workspace-service.js +1 -1
  69. package/src/server/websocket-server.js +5 -4
  70. package/dist/web/assets/Home-BMoFdAwy.css +0 -1
  71. package/dist/web/assets/Home-DNwp-0J-.js +0 -1
  72. package/dist/web/assets/index-BXeSvAwU.js +0 -2
  73. package/dist/web/assets/index-DWAC3Tdv.css +0 -1
  74. package/docs/bannel.png +0 -0
  75. package/docs/model-redirection.md +0 -251
@@ -0,0 +1,951 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const { execSync } = require('child_process');
7
+ const { PATHS, NATIVE_PATHS } = require('../../config/paths');
8
+ const { loadUIConfig, saveUIConfig } = require('./ui-config');
9
+ const codexSettingsManager = require('./codex-settings-manager');
10
+ const geminiSettingsManager = require('./gemini-settings-manager');
11
+
12
+ const MANAGED_HOOK_NAME = 'coding-tool-notify';
13
+ const MANAGED_OPENCODE_PLUGIN_FILE = 'coding-tool-notify.js';
14
+
15
+ function normalizeType(type) {
16
+ return type === 'dialog' ? 'dialog' : 'notification';
17
+ }
18
+
19
+ function ensureParentDir(filePath) {
20
+ const dir = path.dirname(filePath);
21
+ if (!fs.existsSync(dir)) {
22
+ fs.mkdirSync(dir, { recursive: true });
23
+ }
24
+ }
25
+
26
+ function readJsonFile(filePath) {
27
+ if (!fs.existsSync(filePath)) {
28
+ return {};
29
+ }
30
+
31
+ try {
32
+ const content = fs.readFileSync(filePath, 'utf8');
33
+ return content.trim() ? JSON.parse(content) : {};
34
+ } catch (error) {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function writeJsonFile(filePath, value) {
40
+ ensureParentDir(filePath);
41
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
42
+ }
43
+
44
+ function readClaudeSettings() {
45
+ return readJsonFile(NATIVE_PATHS.claude.settings);
46
+ }
47
+
48
+ function writeClaudeSettings(settings) {
49
+ writeJsonFile(NATIVE_PATHS.claude.settings, settings);
50
+ }
51
+
52
+ function getFeishuConfig() {
53
+ const uiConfig = loadUIConfig();
54
+ return {
55
+ enabled: uiConfig.feishuNotification?.enabled === true,
56
+ webhookUrl: uiConfig.feishuNotification?.webhookUrl || ''
57
+ };
58
+ }
59
+
60
+ function applyClaudeDisablePreference(uiConfig = {}, claudeEnabled) {
61
+ const nextConfig = (uiConfig && typeof uiConfig === 'object') ? { ...uiConfig } : {};
62
+ if (claudeEnabled) {
63
+ delete nextConfig.claudeNotificationDisabledByUser;
64
+ } else {
65
+ nextConfig.claudeNotificationDisabledByUser = true;
66
+ }
67
+ return nextConfig;
68
+ }
69
+
70
+ function saveNotificationUiConfig(feishu = {}, claudeEnabled) {
71
+ let uiConfig = loadUIConfig();
72
+ if (typeof claudeEnabled === 'boolean') {
73
+ uiConfig = applyClaudeDisablePreference(uiConfig, claudeEnabled);
74
+ }
75
+ uiConfig.feishuNotification = {
76
+ enabled: feishu.enabled === true,
77
+ webhookUrl: feishu.webhookUrl || ''
78
+ };
79
+ saveUIConfig(uiConfig);
80
+ }
81
+
82
+ function createValidationError(message) {
83
+ const error = new Error(message);
84
+ error.statusCode = 400;
85
+ return error;
86
+ }
87
+
88
+ function validateFeishuWebhookUrl(webhookUrl) {
89
+ const value = String(webhookUrl || '').trim();
90
+ if (!value) {
91
+ return null;
92
+ }
93
+
94
+ let urlObj;
95
+ try {
96
+ urlObj = new URL(value);
97
+ } catch (error) {
98
+ throw createValidationError('飞书 Webhook URL 格式不正确');
99
+ }
100
+
101
+ if (urlObj.protocol !== 'https:') {
102
+ throw createValidationError('飞书 Webhook 必须使用 HTTPS');
103
+ }
104
+
105
+ if (urlObj.hostname !== 'open.feishu.cn') {
106
+ throw createValidationError('仅支持 open.feishu.cn 的飞书 Webhook');
107
+ }
108
+
109
+ return urlObj;
110
+ }
111
+
112
+ function removeNotifyScript() {
113
+ if (fs.existsSync(PATHS.notifyHook)) {
114
+ fs.unlinkSync(PATHS.notifyHook);
115
+ }
116
+ }
117
+
118
+ function parseManagedType(input) {
119
+ const value = String(input || '');
120
+ const matches = [
121
+ value.match(/--cc-notify-type=(dialog|notification)/i),
122
+ value.match(/--mode=(dialog|notification)/i),
123
+ value.match(/MODE\s*=\s*["'](dialog|notification)["']/i)
124
+ ];
125
+
126
+ for (const match of matches) {
127
+ if (match?.[1]) {
128
+ return normalizeType(match[1].toLowerCase());
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ function getManagedCommandType(input) {
136
+ return parseManagedType(input);
137
+ }
138
+
139
+ function isManagedNotifyPath(input) {
140
+ const normalizedInput = String(input || '').replace(/\\/g, '/');
141
+ const normalizedPath = String(PATHS.notifyHook || '').replace(/\\/g, '/');
142
+ return normalizedInput.includes('notify-hook.js') || (normalizedPath && normalizedInput.includes(normalizedPath));
143
+ }
144
+
145
+ function buildManagedArgs(source, type, extra = []) {
146
+ const mode = normalizeType(type);
147
+ return [
148
+ `--source=${source}`,
149
+ `--mode=${mode}`,
150
+ `--cc-notify-type=${mode}`,
151
+ ...extra
152
+ ];
153
+ }
154
+
155
+ function quoteShellArg(value) {
156
+ const stringValue = String(value || '');
157
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(stringValue)) {
158
+ return stringValue;
159
+ }
160
+ return `"${stringValue.replace(/"/g, '\\"')}"`;
161
+ }
162
+
163
+ function buildClaudeCommand(type) {
164
+ const mode = normalizeType(type);
165
+ const args = ['node', PATHS.notifyHook, `--source=claude`, `--mode=${mode}`, `--cc-notify-type=${mode}`];
166
+ return `${quoteShellArg(args[0])} ${quoteShellArg(args[1])} ${args.slice(2).map(quoteShellArg).join(' ')}`;
167
+ }
168
+
169
+ function buildCodexNotifyCommand(type) {
170
+ const mode = normalizeType(type);
171
+ return ['node', PATHS.notifyHook, `--source=codex`, `--mode=${mode}`, `--cc-notify-type=${mode}`];
172
+ }
173
+
174
+ function buildGeminiCommand(type) {
175
+ const mode = normalizeType(type);
176
+ const args = ['node', PATHS.notifyHook, `--source=gemini`, `--mode=${mode}`, `--cc-notify-type=${mode}`];
177
+ return `${quoteShellArg(args[0])} ${quoteShellArg(args[1])} ${args.slice(2).map(quoteShellArg).join(' ')}`;
178
+ }
179
+
180
+ function getOpenCodeManagedPluginPath() {
181
+ return path.join(NATIVE_PATHS.opencode.config, 'plugins', MANAGED_OPENCODE_PLUGIN_FILE);
182
+ }
183
+
184
+ function buildOpenCodePluginContent(type) {
185
+ const mode = normalizeType(type);
186
+ return `// Managed by Coding Tool. Do not edit manually.
187
+ // mode:${mode}
188
+ import { spawn } from 'node:child_process'
189
+
190
+ const SCRIPT_PATH = ${JSON.stringify(PATHS.notifyHook)}
191
+ const MODE = ${JSON.stringify(mode)}
192
+
193
+ function fire(eventType) {
194
+ try {
195
+ const child = spawn('node', [
196
+ SCRIPT_PATH,
197
+ '--source=opencode',
198
+ \`--mode=\${MODE}\`,
199
+ \`--cc-notify-type=\${MODE}\`,
200
+ \`--event-type=\${eventType}\`
201
+ ], {
202
+ detached: true,
203
+ stdio: 'ignore'
204
+ })
205
+ child.unref()
206
+ } catch (error) {
207
+ // Ignore notification failures.
208
+ }
209
+ }
210
+
211
+ export const CodingToolNotifyPlugin = async () => ({
212
+ event: async ({ event }) => {
213
+ const eventType = event?.type
214
+ if (eventType === 'session.idle' || eventType === 'session.error') {
215
+ fire(eventType)
216
+ }
217
+ }
218
+ })
219
+ `;
220
+ }
221
+
222
+ function escapeForAppleScript(value) {
223
+ return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
224
+ }
225
+
226
+ function escapeForPowerShellSingleQuote(value) {
227
+ return String(value || '').replace(/'/g, "''");
228
+ }
229
+
230
+ function escapeForXml(value) {
231
+ return String(value || '')
232
+ .replace(/&/g, '&')
233
+ .replace(/</g, '&lt;')
234
+ .replace(/>/g, '&gt;')
235
+ .replace(/"/g, '&quot;')
236
+ .replace(/'/g, '&apos;');
237
+ }
238
+
239
+ function generateNotifyScript(feishu = {}) {
240
+ const feishuEnabled = feishu.enabled === true && !!feishu.webhookUrl;
241
+
242
+ return `#!/usr/bin/env node
243
+ // Coding Tool 通知脚本 - 自动生成,请勿手动修改
244
+ const fs = require('fs')
245
+ const os = require('os')
246
+ const http = require('http')
247
+ const https = require('https')
248
+ const { execSync } = require('child_process')
249
+
250
+ const FEISHU_ENABLED = ${feishuEnabled ? 'true' : 'false'}
251
+ const FEISHU_WEBHOOK_URL = ${JSON.stringify(feishuEnabled ? feishu.webhookUrl : '')}
252
+
253
+ function readArg(name) {
254
+ const prefix = \`\${name}=\`
255
+ const matched = process.argv.slice(2).find((arg) => String(arg || '').startsWith(prefix))
256
+ return matched ? matched.slice(prefix.length) : ''
257
+ }
258
+
259
+ function readOptionalPayload() {
260
+ const payloadCandidates = []
261
+
262
+ if (!process.stdin.isTTY) {
263
+ try {
264
+ const raw = fs.readFileSync(0, 'utf8').trim()
265
+ if (raw) payloadCandidates.push(raw)
266
+ } catch (error) {
267
+ // ignore stdin read errors
268
+ }
269
+ }
270
+
271
+ for (const arg of process.argv.slice(2)) {
272
+ const trimmed = String(arg || '').trim()
273
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
274
+ payloadCandidates.push(trimmed)
275
+ }
276
+ }
277
+
278
+ for (const raw of payloadCandidates) {
279
+ try {
280
+ return JSON.parse(raw)
281
+ } catch (error) {
282
+ // ignore malformed payloads
283
+ }
284
+ }
285
+
286
+ return null
287
+ }
288
+
289
+ function resolveMessage(source, eventType, payload) {
290
+ const effectiveEventType = eventType || payload?.type || payload?.hook_event?.event_type || ''
291
+
292
+ if (source === 'codex') {
293
+ if (effectiveEventType === 'agent-turn-complete') {
294
+ return 'Codex CLI 回合已完成 | 等待交互'
295
+ }
296
+ return 'Codex CLI 已返回结果 | 等待交互'
297
+ }
298
+
299
+ if (source === 'gemini') {
300
+ return 'Gemini CLI 回合已完成 | 等待交互'
301
+ }
302
+
303
+ if (source === 'opencode') {
304
+ if (effectiveEventType === 'session.error') {
305
+ return 'OpenCode 会话异常,请检查日志'
306
+ }
307
+ return 'OpenCode 响应已完成 | 等待交互'
308
+ }
309
+
310
+ return 'Claude Code 任务已完成 | 等待交互'
311
+ }
312
+
313
+ function notify(mode, message) {
314
+ const title = 'Coding Tool'
315
+ const platform = os.platform()
316
+
317
+ try {
318
+ if (platform === 'darwin') {
319
+ if (mode === 'dialog') {
320
+ const appleScript = 'display dialog "' + escapeForAppleScript(message) +
321
+ '" with title "' + escapeForAppleScript(title) +
322
+ '" buttons {"好的"} default button 1 with icon note'
323
+ execSync('osascript -e ' + JSON.stringify(appleScript), { stdio: 'ignore' })
324
+ } else {
325
+ const fallbackScript = 'display notification "' + escapeForAppleScript(message) +
326
+ '" with title "' + escapeForAppleScript(title) + '" sound name "Glass"'
327
+ const command = 'if command -v terminal-notifier >/dev/null 2>&1; then ' +
328
+ 'terminal-notifier -title ' + JSON.stringify(title) +
329
+ ' -message ' + JSON.stringify(message) +
330
+ ' -sound Glass -activate com.apple.Terminal; ' +
331
+ 'else osascript -e ' + JSON.stringify(fallbackScript) + '; fi'
332
+ execSync(command, { stdio: 'ignore' })
333
+ }
334
+ return
335
+ }
336
+
337
+ if (platform === 'win32') {
338
+ if (mode === 'dialog') {
339
+ const ps = "Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show('" +
340
+ escapeForPowerShellSingleQuote(message) + "', '" +
341
+ escapeForPowerShellSingleQuote(title) + "', 'OK', 'Information')"
342
+ execSync('powershell -NoProfile -Command ' + JSON.stringify(ps), { stdio: 'ignore' })
343
+ } else {
344
+ const toastXml = '<toast><visual><binding template="ToastGeneric"><text>' +
345
+ escapeForXml(title) + '</text><text>' + escapeForXml(message) +
346
+ '</text></binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>'
347
+ const ps = 'try { ' +
348
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; ' +
349
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null; ' +
350
+ '$xml = New-Object Windows.Data.Xml.Dom.XmlDocument; ' +
351
+ '$xml.LoadXml(\\'' + toastXml.replace(/'/g, "''") + '\\'); ' +
352
+ '$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); ' +
353
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Coding Tool').Show($toast) " +
354
+ "} catch { $wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('" +
355
+ escapeForPowerShellSingleQuote(message) + "', 5, '" + escapeForPowerShellSingleQuote(title) + "', 0x40) }"
356
+ execSync('powershell -NoProfile -Command ' + JSON.stringify(ps), { stdio: 'ignore' })
357
+ }
358
+ return
359
+ }
360
+
361
+ const escapedTitle = String(title || '').replace(/"/g, '\\"')
362
+ const escapedMessage = String(message || '').replace(/"/g, '\\"')
363
+ if (mode === 'dialog') {
364
+ execSync(
365
+ 'zenity --info --title="' + escapedTitle + '" --text="' + escapedMessage +
366
+ '" 2>/dev/null || notify-send "' + escapedTitle + '" "' + escapedMessage + '"',
367
+ { stdio: 'ignore' }
368
+ )
369
+ } else {
370
+ execSync('notify-send "' + escapedTitle + '" "' + escapedMessage + '"', { stdio: 'ignore' })
371
+ }
372
+ } catch (error) {
373
+ // ignore system notification failures
374
+ }
375
+ }
376
+
377
+ function sendFeishu(message, source) {
378
+ if (!FEISHU_ENABLED || !FEISHU_WEBHOOK_URL) {
379
+ return Promise.resolve()
380
+ }
381
+
382
+ return new Promise((resolve) => {
383
+ try {
384
+ const urlObj = new URL(FEISHU_WEBHOOK_URL)
385
+ const timestamp = new Date().toLocaleString('zh-CN')
386
+ const data = JSON.stringify({
387
+ msg_type: 'interactive',
388
+ card: {
389
+ header: {
390
+ title: { tag: 'plain_text', content: 'Coding Tool - 通知' },
391
+ template: 'green'
392
+ },
393
+ elements: [
394
+ {
395
+ tag: 'div',
396
+ text: { tag: 'lark_md', content: '**来源**: ' + source }
397
+ },
398
+ {
399
+ tag: 'div',
400
+ text: { tag: 'lark_md', content: '**状态**: ' + message }
401
+ },
402
+ {
403
+ tag: 'div',
404
+ text: { tag: 'lark_md', content: '**时间**: ' + timestamp }
405
+ },
406
+ {
407
+ tag: 'div',
408
+ text: { tag: 'lark_md', content: '**设备**: ' + os.hostname() }
409
+ }
410
+ ]
411
+ }
412
+ })
413
+
414
+ const options = {
415
+ hostname: urlObj.hostname,
416
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
417
+ path: urlObj.pathname + urlObj.search,
418
+ method: 'POST',
419
+ headers: {
420
+ 'Content-Type': 'application/json',
421
+ 'Content-Length': Buffer.byteLength(data)
422
+ },
423
+ timeout: 10000
424
+ }
425
+
426
+ const requestModule = urlObj.protocol === 'https:' ? https : http
427
+ const request = requestModule.request(options, () => resolve())
428
+ request.on('error', () => resolve())
429
+ request.on('timeout', () => {
430
+ request.destroy()
431
+ resolve()
432
+ })
433
+ request.write(data)
434
+ request.end()
435
+ } catch (error) {
436
+ resolve()
437
+ }
438
+ })
439
+ }
440
+
441
+ (async () => {
442
+ const source = readArg('--source') || 'claude'
443
+ const mode = readArg('--mode') || readArg('--cc-notify-type') || 'notification'
444
+ const eventType = readArg('--event-type') || ''
445
+ const payload = readOptionalPayload()
446
+ const message = resolveMessage(source, eventType, payload)
447
+
448
+ notify(mode === 'dialog' ? 'dialog' : 'notification', message)
449
+ await sendFeishu(message, source)
450
+ })().catch(() => {
451
+ process.exit(0)
452
+ })
453
+
454
+ function escapeForAppleScript(value) {
455
+ return String(value || '').replace(/\\\\/g, '\\\\\\\\').replace(/"/g, '\\\\"')
456
+ }
457
+
458
+ function escapeForPowerShellSingleQuote(value) {
459
+ return String(value || '').replace(/'/g, "''")
460
+ }
461
+
462
+ function escapeForXml(value) {
463
+ return String(value || '')
464
+ .replace(/&/g, '&amp;')
465
+ .replace(/</g, '&lt;')
466
+ .replace(/>/g, '&gt;')
467
+ .replace(/"/g, '&quot;')
468
+ .replace(/'/g, '&apos;')
469
+ }
470
+ `;
471
+ }
472
+
473
+ function writeNotifyScript(feishu = {}) {
474
+ ensureParentDir(PATHS.notifyHook);
475
+ fs.writeFileSync(PATHS.notifyHook, generateNotifyScript(feishu), { mode: 0o755 });
476
+ }
477
+
478
+ function getClaudeHookStatus() {
479
+ const settings = readClaudeSettings();
480
+ const stopHooks = Array.isArray(settings?.hooks?.Stop) ? settings.hooks.Stop : [];
481
+ let enabled = false;
482
+ let external = false;
483
+ let type = 'notification';
484
+
485
+ stopHooks.forEach((group) => {
486
+ const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
487
+ hooks.forEach((hook) => {
488
+ const command = String(hook?.command || '');
489
+ if (!command) return;
490
+
491
+ const isManaged = isManagedNotifyPath(command);
492
+ if (isManaged) {
493
+ enabled = true;
494
+ type = parseManagedType(command) || type;
495
+ } else {
496
+ external = true;
497
+ }
498
+ });
499
+ });
500
+
501
+ return {
502
+ enabled,
503
+ external,
504
+ type,
505
+ method: 'Stop Hook'
506
+ };
507
+ }
508
+
509
+ function saveClaudeHook(enabled, type) {
510
+ const settings = readClaudeSettings();
511
+ const hooks = settings.hooks && typeof settings.hooks === 'object' ? { ...settings.hooks } : {};
512
+ const currentGroups = Array.isArray(hooks.Stop) ? hooks.Stop : [];
513
+
514
+ const filteredGroups = currentGroups.map((group) => {
515
+ const groupHooks = Array.isArray(group?.hooks) ? group.hooks : [];
516
+ const nextHooks = groupHooks.filter((hook) => !isManagedNotifyPath(hook?.command));
517
+ if (nextHooks.length === 0) {
518
+ return null;
519
+ }
520
+ return { ...group, hooks: nextHooks };
521
+ }).filter(Boolean);
522
+
523
+ if (enabled) {
524
+ filteredGroups.push({
525
+ hooks: [
526
+ {
527
+ name: MANAGED_HOOK_NAME,
528
+ type: 'command',
529
+ command: buildClaudeCommand(type)
530
+ }
531
+ ]
532
+ });
533
+ }
534
+
535
+ if (filteredGroups.length > 0) {
536
+ hooks.Stop = filteredGroups;
537
+ } else {
538
+ delete hooks.Stop;
539
+ }
540
+
541
+ if (Object.keys(hooks).length > 0) {
542
+ settings.hooks = hooks;
543
+ } else {
544
+ delete settings.hooks;
545
+ }
546
+
547
+ writeClaudeSettings(settings);
548
+ }
549
+
550
+ function safeReadCodexConfig() {
551
+ try {
552
+ if (typeof codexSettingsManager.configExists === 'function' && !codexSettingsManager.configExists()) {
553
+ return {};
554
+ }
555
+ return codexSettingsManager.readConfig();
556
+ } catch (error) {
557
+ return {};
558
+ }
559
+ }
560
+
561
+ function isManagedCodexNotify(notify) {
562
+ return Array.isArray(notify) && notify.some((part) => isManagedNotifyPath(part));
563
+ }
564
+
565
+ function parseCodexNotificationStatus(config = {}) {
566
+ const notify = Array.isArray(config?.notify) ? config.notify : [];
567
+
568
+ if (notify.length === 0) {
569
+ return {
570
+ enabled: false,
571
+ external: false,
572
+ type: 'notification',
573
+ method: 'notify'
574
+ };
575
+ }
576
+
577
+ const joined = notify.join(' ');
578
+ const managed = isManagedCodexNotify(notify);
579
+
580
+ return {
581
+ enabled: managed,
582
+ external: !managed,
583
+ type: parseManagedType(joined) || 'notification',
584
+ method: 'notify'
585
+ };
586
+ }
587
+
588
+ function getCodexHookStatus() {
589
+ return parseCodexNotificationStatus(safeReadCodexConfig());
590
+ }
591
+
592
+ function saveCodexHook(enabled, type) {
593
+ const config = safeReadCodexConfig();
594
+ const nextConfig = (config && typeof config === 'object') ? { ...config } : {};
595
+ const managed = isManagedCodexNotify(nextConfig.notify);
596
+
597
+ if (enabled) {
598
+ nextConfig.notify = buildCodexNotifyCommand(type);
599
+ } else if (managed) {
600
+ delete nextConfig.notify;
601
+ }
602
+
603
+ ensureParentDir(NATIVE_PATHS.codex.config);
604
+ codexSettingsManager.writeConfig(nextConfig);
605
+ }
606
+
607
+ function safeReadGeminiSettings() {
608
+ try {
609
+ if (typeof geminiSettingsManager.settingsExists === 'function' && !geminiSettingsManager.settingsExists()) {
610
+ return {};
611
+ }
612
+ return geminiSettingsManager.readSettings();
613
+ } catch (error) {
614
+ return {};
615
+ }
616
+ }
617
+
618
+ function isManagedGeminiHook(hook) {
619
+ const command = String(hook?.command || '');
620
+ const name = String(hook?.name || '');
621
+ return name === MANAGED_HOOK_NAME || isManagedNotifyPath(command);
622
+ }
623
+
624
+ function parseGeminiNotificationStatus(settings = {}) {
625
+ const afterAgentGroups = Array.isArray(settings?.hooks?.AfterAgent) ? settings.hooks.AfterAgent : [];
626
+ let enabled = false;
627
+ let external = false;
628
+ let type = 'notification';
629
+
630
+ afterAgentGroups.forEach((group) => {
631
+ const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
632
+ hooks.forEach((hook) => {
633
+ if (isManagedGeminiHook(hook)) {
634
+ enabled = true;
635
+ type = parseManagedType(hook.command) || type;
636
+ } else {
637
+ external = true;
638
+ }
639
+ });
640
+ });
641
+
642
+ return {
643
+ enabled,
644
+ external,
645
+ type,
646
+ method: 'AfterAgent Hook'
647
+ };
648
+ }
649
+
650
+ function getGeminiHookStatus() {
651
+ return parseGeminiNotificationStatus(safeReadGeminiSettings());
652
+ }
653
+
654
+ function saveGeminiHook(enabled, type) {
655
+ const settings = safeReadGeminiSettings();
656
+ const nextSettings = (settings && typeof settings === 'object') ? { ...settings } : {};
657
+ const hooks = nextSettings.hooks && typeof nextSettings.hooks === 'object' ? { ...nextSettings.hooks } : {};
658
+ const currentGroups = Array.isArray(hooks.AfterAgent) ? hooks.AfterAgent : [];
659
+
660
+ const filteredGroups = currentGroups.map((group) => {
661
+ const groupHooks = Array.isArray(group?.hooks) ? group.hooks : [];
662
+ const nextHooks = groupHooks.filter((hook) => !isManagedGeminiHook(hook));
663
+ if (nextHooks.length === 0) {
664
+ return null;
665
+ }
666
+ return { ...group, hooks: nextHooks };
667
+ }).filter(Boolean);
668
+
669
+ if (enabled) {
670
+ filteredGroups.push({
671
+ matcher: '*',
672
+ hooks: [
673
+ {
674
+ name: MANAGED_HOOK_NAME,
675
+ type: 'command',
676
+ command: buildGeminiCommand(type)
677
+ }
678
+ ]
679
+ });
680
+ }
681
+
682
+ if (filteredGroups.length > 0) {
683
+ hooks.AfterAgent = filteredGroups;
684
+ } else {
685
+ delete hooks.AfterAgent;
686
+ }
687
+
688
+ if (Object.keys(hooks).length > 0) {
689
+ nextSettings.hooks = hooks;
690
+ } else {
691
+ delete nextSettings.hooks;
692
+ }
693
+
694
+ ensureParentDir(geminiSettingsManager.getSettingsPath());
695
+ geminiSettingsManager.writeSettings(nextSettings);
696
+ }
697
+
698
+ function parseOpenCodeNotificationStatus(content = '') {
699
+ if (!content) {
700
+ return {
701
+ enabled: false,
702
+ external: false,
703
+ type: 'notification',
704
+ method: 'Plugin Events'
705
+ };
706
+ }
707
+
708
+ return {
709
+ enabled: true,
710
+ external: false,
711
+ type: parseManagedType(content) || 'notification',
712
+ method: 'Plugin Events'
713
+ };
714
+ }
715
+
716
+ function getOpenCodeHookStatus() {
717
+ const pluginPath = getOpenCodeManagedPluginPath();
718
+ if (!fs.existsSync(pluginPath)) {
719
+ return parseOpenCodeNotificationStatus('');
720
+ }
721
+
722
+ return parseOpenCodeNotificationStatus(fs.readFileSync(pluginPath, 'utf8'));
723
+ }
724
+
725
+ function saveOpenCodeHook(enabled, type) {
726
+ const pluginPath = getOpenCodeManagedPluginPath();
727
+ const opencodeSettingsManager = require('./opencode-settings-manager');
728
+ const configPath = opencodeSettingsManager.selectConfigPath();
729
+
730
+ if (!enabled) {
731
+ // Remove plugin file
732
+ if (fs.existsSync(pluginPath)) {
733
+ fs.unlinkSync(pluginPath);
734
+ }
735
+
736
+ // Remove from opencode.json plugins array
737
+ if (fs.existsSync(configPath)) {
738
+ try {
739
+ const config = opencodeSettingsManager.readConfig(configPath);
740
+ if (Array.isArray(config.plugins)) {
741
+ config.plugins = config.plugins.filter(p => p !== './plugins/coding-tool-notify.js');
742
+ if (config.plugins.length === 0) {
743
+ delete config.plugins;
744
+ }
745
+ opencodeSettingsManager.writeConfig(configPath, config);
746
+ }
747
+ } catch (error) {
748
+ console.error('Failed to update opencode.json:', error);
749
+ }
750
+ }
751
+ return;
752
+ }
753
+
754
+ // Create plugin file
755
+ ensureParentDir(pluginPath);
756
+ fs.writeFileSync(pluginPath, buildOpenCodePluginContent(type), 'utf8');
757
+
758
+ // Add to opencode.json plugins array
759
+ try {
760
+ const config = fs.existsSync(configPath)
761
+ ? opencodeSettingsManager.readConfig(configPath)
762
+ : {};
763
+
764
+ if (!Array.isArray(config.plugins)) {
765
+ config.plugins = [];
766
+ }
767
+
768
+ const pluginRef = './plugins/coding-tool-notify.js';
769
+ if (!config.plugins.includes(pluginRef)) {
770
+ config.plugins.push(pluginRef);
771
+ }
772
+
773
+ opencodeSettingsManager.writeConfig(configPath, config);
774
+ } catch (error) {
775
+ console.error('Failed to update opencode.json:', error);
776
+ }
777
+ }
778
+
779
+ function getNotificationSettings() {
780
+ return {
781
+ success: true,
782
+ platform: os.platform(),
783
+ feishu: getFeishuConfig(),
784
+ platforms: {
785
+ claude: getClaudeHookStatus(),
786
+ codex: getCodexHookStatus(),
787
+ gemini: getGeminiHookStatus(),
788
+ opencode: getOpenCodeHookStatus()
789
+ }
790
+ };
791
+ }
792
+
793
+ function normalizePlatformInput(platform = {}) {
794
+ return {
795
+ enabled: platform.enabled === true,
796
+ type: normalizeType(platform.type)
797
+ };
798
+ }
799
+
800
+ function saveNotificationSettings(input = {}) {
801
+ const existingFeishu = getFeishuConfig();
802
+ const requestedWebhookUrl = String(input?.feishu?.webhookUrl || '').trim();
803
+ const feishu = {
804
+ enabled: input?.feishu?.enabled === true,
805
+ webhookUrl: requestedWebhookUrl || (input?.feishu?.enabled === true ? existingFeishu.webhookUrl || '' : '')
806
+ };
807
+ if (feishu.enabled && feishu.webhookUrl) {
808
+ validateFeishuWebhookUrl(feishu.webhookUrl);
809
+ }
810
+ const platforms = {
811
+ claude: normalizePlatformInput(input?.platforms?.claude),
812
+ codex: normalizePlatformInput(input?.platforms?.codex),
813
+ gemini: normalizePlatformInput(input?.platforms?.gemini),
814
+ opencode: normalizePlatformInput(input?.platforms?.opencode)
815
+ };
816
+
817
+ saveNotificationUiConfig(feishu, platforms.claude.enabled);
818
+
819
+ const hasManagedPlatform = Object.values(platforms).some((platform) => platform.enabled);
820
+ if (hasManagedPlatform) {
821
+ writeNotifyScript(feishu);
822
+ }
823
+
824
+ saveClaudeHook(platforms.claude.enabled, platforms.claude.type);
825
+ saveCodexHook(platforms.codex.enabled, platforms.codex.type);
826
+ saveGeminiHook(platforms.gemini.enabled, platforms.gemini.type);
827
+ saveOpenCodeHook(platforms.opencode.enabled, platforms.opencode.type);
828
+
829
+ if (!hasManagedPlatform) {
830
+ removeNotifyScript();
831
+ }
832
+
833
+ return getNotificationSettings();
834
+ }
835
+
836
+ function sendFeishuTest(webhookUrl) {
837
+ return new Promise((resolve, reject) => {
838
+ try {
839
+ const urlObj = validateFeishuWebhookUrl(webhookUrl);
840
+ const data = JSON.stringify({
841
+ msg_type: 'interactive',
842
+ card: {
843
+ header: {
844
+ title: { tag: 'plain_text', content: 'Coding Tool - 测试通知' },
845
+ template: 'blue'
846
+ },
847
+ elements: [
848
+ {
849
+ tag: 'div',
850
+ text: { tag: 'lark_md', content: '**状态**: 这是一条测试通知' }
851
+ },
852
+ {
853
+ tag: 'div',
854
+ text: { tag: 'lark_md', content: '**时间**: ' + new Date().toLocaleString('zh-CN') }
855
+ }
856
+ ]
857
+ }
858
+ });
859
+
860
+ const options = {
861
+ hostname: urlObj.hostname,
862
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
863
+ path: urlObj.pathname + urlObj.search,
864
+ method: 'POST',
865
+ headers: {
866
+ 'Content-Type': 'application/json',
867
+ 'Content-Length': Buffer.byteLength(data)
868
+ },
869
+ timeout: 10000
870
+ };
871
+
872
+ const requestModule = urlObj.protocol === 'https:' ? https : http;
873
+ const request = requestModule.request(options, () => resolve());
874
+ request.on('error', reject);
875
+ request.on('timeout', () => {
876
+ request.destroy(new Error('飞书测试通知超时'));
877
+ });
878
+ request.write(data);
879
+ request.end();
880
+ } catch (error) {
881
+ reject(error);
882
+ }
883
+ });
884
+ }
885
+
886
+ function generateSystemNotificationCommand(type, message) {
887
+ const normalizedType = normalizeType(type);
888
+ const title = 'Coding Tool';
889
+ const platform = os.platform();
890
+
891
+ if (platform === 'darwin') {
892
+ if (normalizedType === 'dialog') {
893
+ return `osascript -e 'display dialog "${escapeForAppleScript(message)}" with title "${escapeForAppleScript(title)}" buttons {"好的"} default button 1 with icon note'`;
894
+ }
895
+ return `if command -v terminal-notifier &>/dev/null; then terminal-notifier -title "${escapeForAppleScript(title)}" -message "${escapeForAppleScript(message)}" -sound Glass -activate com.apple.Terminal; else osascript -e 'display notification "${escapeForAppleScript(message)}" with title "${escapeForAppleScript(title)}" sound name "Glass"'; fi`;
896
+ }
897
+
898
+ if (platform === 'win32') {
899
+ if (normalizedType === 'dialog') {
900
+ return `powershell -NoProfile -Command "Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show('${escapeForPowerShellSingleQuote(message)}', '${escapeForPowerShellSingleQuote(title)}', 'OK', 'Information')"`;
901
+ }
902
+
903
+ const toastXml = `<toast><visual><binding template="ToastGeneric"><text>${escapeForXml(title)}</text><text>${escapeForXml(message)}</text></binding></visual><audio src="ms-winsoundevent:Notification.Default"/></toast>`;
904
+ return `powershell -NoProfile -Command "try { [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null; $xml = New-Object Windows.Data.Xml.Dom.XmlDocument; $xml.LoadXml('${toastXml.replace(/'/g, "''")}'); $toast = [Windows.UI.Notifications.ToastNotification]::new($xml); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Coding Tool').Show($toast) } catch { $wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('${escapeForPowerShellSingleQuote(message)}', 5, '${escapeForPowerShellSingleQuote(title)}', 0x40) }"`;
905
+ }
906
+
907
+ if (normalizedType === 'dialog') {
908
+ return `zenity --info --title="Coding Tool" --text="${String(message || '').replace(/"/g, '\\"')}" 2>/dev/null || notify-send "Coding Tool" "${String(message || '').replace(/"/g, '\\"')}"`;
909
+ }
910
+
911
+ return `notify-send "Coding Tool" "${String(message || '').replace(/"/g, '\\"')}"`;
912
+ }
913
+
914
+ function testNotification({ type, testFeishu, webhookUrl } = {}) {
915
+ if (testFeishu && webhookUrl) {
916
+ return sendFeishuTest(webhookUrl);
917
+ }
918
+
919
+ execSync(generateSystemNotificationCommand(type || 'notification', '这是一条测试通知'), { stdio: 'ignore' });
920
+ }
921
+
922
+ module.exports = {
923
+ MANAGED_HOOK_NAME,
924
+ getNotificationSettings,
925
+ saveNotificationSettings,
926
+ testNotification,
927
+ getOpenCodeManagedPluginPath,
928
+ buildOpenCodePluginContent,
929
+ buildCodexNotifyCommand,
930
+ writeNotifyScript,
931
+ generateNotifyScript,
932
+ _test: {
933
+ applyClaudeDisablePreference,
934
+ getManagedCommandType,
935
+ parseManagedType,
936
+ getClaudeHookStatus,
937
+ getCodexHookStatus,
938
+ getGeminiHookStatus,
939
+ getOpenCodeHookStatus,
940
+ parseCodexNotificationStatus,
941
+ parseGeminiNotificationStatus,
942
+ parseOpenCodeNotificationStatus,
943
+ validateFeishuWebhookUrl,
944
+ buildCodexNotifyCommand,
945
+ buildGeminiCommand,
946
+ buildClaudeCommand,
947
+ buildOpenCodePluginContent,
948
+ getOpenCodeManagedPluginPath,
949
+ generateNotifyScript
950
+ }
951
+ };