@quecue/dev-notify 0.1.0-alpha.1 → 0.1.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.
@@ -0,0 +1,235 @@
1
+ import { readFile, mkdir, open, rename, unlink } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { ConfigError, decodeUtf8 } from './config.js';
7
+ export const HOOK_MARKER = 'dev-notify:claude-code:v1';
8
+ export const NOTIFICATION_MATCHER = 'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input';
9
+ export const EVENTS = ['Stop', 'Notification', 'SessionStart', 'UserPromptSubmit', 'SessionEnd', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'];
10
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const yes = (value) => /^(y|yes)$/i.test(value.trim());
12
+ export function claudeSettingsPath(env = process.env, home = homedir(), platform = process.platform) {
13
+ const join = platform === 'win32' ? path.win32 : path.posix;
14
+ const root = env.CLAUDE_CONFIG_DIR || join.join(home, '.claude');
15
+ if (!join.isAbsolute(root))
16
+ throw new ConfigError('CLAUDE_CONFIG_DIR 需要是绝对路径,Claude Code 设置没有改动');
17
+ return join.join(root, 'settings.json');
18
+ }
19
+ function settingsFile(options) {
20
+ return claudeSettingsPath(options.env, options.home, options.platform);
21
+ }
22
+ async function snapshot(file) {
23
+ try {
24
+ const bytes = await readFile(file);
25
+ if (bytes.length > 1024 * 1024)
26
+ throw new ConfigError('Claude Code 设置超过 1 MiB,操作已停止,文件没有改动。');
27
+ return bytes;
28
+ }
29
+ catch (error) {
30
+ if (error.code === 'ENOENT')
31
+ return null;
32
+ if (error instanceof ConfigError)
33
+ throw error;
34
+ throw new ConfigError('无法读取 Claude Code 设置,操作已停止。');
35
+ }
36
+ }
37
+ function parseSettings(before) {
38
+ let data;
39
+ try {
40
+ data = before === null ? {} : JSON.parse(decodeUtf8(before));
41
+ }
42
+ catch {
43
+ throw new ConfigError('Claude Code 设置不是合法的 UTF-8 JSON,没有覆盖。');
44
+ }
45
+ if (!record(data) || (data.hooks !== undefined && !record(data.hooks)))
46
+ throw new ConfigError('Claude Code 设置或 hooks 结构无效,没有覆盖。');
47
+ for (const event of EVENTS) {
48
+ const groups = data.hooks?.[event];
49
+ if (groups === undefined)
50
+ continue;
51
+ if (!Array.isArray(groups) || groups.some(group => !record(group) || !Array.isArray(group.hooks)
52
+ || group.hooks.some((hook) => !record(hook))))
53
+ throw new ConfigError('Claude Code hook 数组结构无效,没有覆盖。');
54
+ }
55
+ return data;
56
+ }
57
+ function owned(hook) {
58
+ return hook.type === 'command' && hook.statusMessage === HOOK_MARKER && Array.isArray(hook.args)
59
+ && hook.args.length === 6 && typeof hook.args[0] === 'string'
60
+ && hook.args[1] === 'integration' && hook.args[2] === 'claude-code' && hook.args[3] === 'hook'
61
+ && hook.args[4] === '--config' && typeof hook.args[5] === 'string';
62
+ }
63
+ function describe(file, data) {
64
+ const result = { file, stop: 0, notification: 0, lifecycle: 0, interaction: 0, others: 0, disabled: data.disableAllHooks === true };
65
+ for (const event of EVENTS)
66
+ for (const group of data.hooks?.[event] ?? [])
67
+ for (const hook of group.hooks) {
68
+ if (owned(hook)) {
69
+ if (event === 'Stop')
70
+ result.stop++;
71
+ else if (event === 'Notification')
72
+ result.notification++;
73
+ else if (['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure', 'PermissionDenied'].includes(event))
74
+ result.interaction++;
75
+ else
76
+ result.lifecycle++;
77
+ }
78
+ else
79
+ result.others++;
80
+ }
81
+ return result;
82
+ }
83
+ export async function claudeCodeStatus(options = {}) {
84
+ const file = settingsFile(options);
85
+ return describe(file, parseSettings(await snapshot(file)));
86
+ }
87
+ export async function replyHooksReady(config, options = {}) {
88
+ const data = parseSettings(await snapshot(settingsFile(options)));
89
+ if (data.disableAllHooks === true)
90
+ return false;
91
+ const normalize = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
92
+ for (const event of EVENTS) {
93
+ const groups = data.hooks?.[event] ?? [];
94
+ const matches = groups.flatMap((group) => group.hooks)
95
+ .filter((hook) => owned(hook) && normalize(hook.args[5]) === normalize(config));
96
+ if (matches.length !== 1 || (event !== 'Notification' && matches[0].async === true))
97
+ return false;
98
+ if (event !== 'Notification' && groups.some((group) => group.matcher && group.matcher !== '*'
99
+ && group.hooks.includes(matches[0])))
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ function merged(data, config, runtime) {
105
+ const target = structuredClone(data);
106
+ for (const event of EVENTS) {
107
+ const groups = [];
108
+ for (const group of target.hooks?.[event] ?? []) {
109
+ const remaining = group.hooks.filter((hook) => !owned(hook));
110
+ if (remaining.length === group.hooks.length)
111
+ groups.push(group);
112
+ else if (remaining.length)
113
+ groups.push({ ...group, hooks: remaining });
114
+ }
115
+ if (config !== null) {
116
+ const handler = { type: 'command', command: runtime.node,
117
+ args: [runtime.cli, 'integration', 'claude-code', 'hook', '--config', config],
118
+ ...(event === 'Notification' ? { async: true } : { timeout: event === 'PermissionRequest' ? 360 : event === 'PreToolUse' ? 10 : 120 }), statusMessage: HOOK_MARKER };
119
+ const matcher = event === 'Notification' ? NOTIFICATION_MATCHER : null;
120
+ groups.push(matcher ? { matcher, hooks: [handler] } : { hooks: [handler] });
121
+ }
122
+ if (groups.length) {
123
+ target.hooks ??= {};
124
+ target.hooks[event] = groups;
125
+ }
126
+ else if (target.hooks)
127
+ delete target.hooks[event];
128
+ }
129
+ if (target.hooks && !Object.keys(target.hooks).length)
130
+ delete target.hooks;
131
+ return target;
132
+ }
133
+ function same(left, right) {
134
+ return left === null ? right === null : right !== null && left.equals(right);
135
+ }
136
+ async function remove(file) {
137
+ try {
138
+ await unlink(file);
139
+ }
140
+ catch (error) {
141
+ if (error.code !== 'ENOENT')
142
+ throw error;
143
+ }
144
+ }
145
+ async function commit(file, before, target, prompts, replace = rename) {
146
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
147
+ const lockFile = file + '.dev-notify.lock';
148
+ let lock;
149
+ try {
150
+ lock = await open(lockFile, 'wx', 0o600);
151
+ }
152
+ catch {
153
+ throw new ConfigError('暂时无法获取 Claude Code 设置写入锁。请确认没有安装进程在运行,再处理残留的 .dev-notify.lock。');
154
+ }
155
+ const temporary = path.join(path.dirname(file), `.dev-notify-${randomBytes(16).toString('hex')}.tmp`);
156
+ let created = false;
157
+ try {
158
+ await lock.close();
159
+ if (!same(before, await snapshot(file)))
160
+ throw new ConfigError('Claude Code 设置在确认期间发生了变化,没有覆盖,请重试。');
161
+ const stream = await open(temporary, 'wx', 0o600);
162
+ created = true;
163
+ try {
164
+ await stream.writeFile(JSON.stringify(target, null, 2) + '\n', 'utf8');
165
+ await stream.sync();
166
+ }
167
+ finally {
168
+ await stream.close();
169
+ }
170
+ if (!same(before, await snapshot(file)))
171
+ throw new ConfigError('Claude Code 设置在保存期间发生了变化,没有覆盖,请重试。');
172
+ await replace(temporary, file);
173
+ }
174
+ finally {
175
+ if (created) {
176
+ try {
177
+ await remove(temporary);
178
+ }
179
+ catch {
180
+ prompts.out('临时文件清理失败。安装结束后请删除 .dev-notify-*.tmp。');
181
+ }
182
+ }
183
+ try {
184
+ await remove(lockFile);
185
+ }
186
+ catch {
187
+ prompts.out('锁文件清理失败。安装结束后请删除 .dev-notify.lock。');
188
+ }
189
+ }
190
+ }
191
+ export async function configureClaudeCode(config, action, prompts, options = {}) {
192
+ const file = settingsFile(options), before = await snapshot(file), data = parseSettings(before);
193
+ const status = describe(file, data);
194
+ if (action === 'remove' && status.stop + status.notification + status.lifecycle + status.interaction === 0) {
195
+ prompts.out(`Claude Code 用户级设置:${file}`);
196
+ prompts.out('没有找到本工具添加的 hooks,设置没有改动。');
197
+ return true;
198
+ }
199
+ const runtime = options.runtime ?? { node: process.execPath, cli: fileURLToPath(new URL('./cli.js', import.meta.url)) };
200
+ const target = merged(data, action === 'install' ? path.resolve(config) : null, runtime);
201
+ // setup 快速路径已在汇总确认前展示路径和发送范围,避免保存后重复整段说明。
202
+ const quiet = options.confirm === false;
203
+ if (!quiet)
204
+ prompts.out(`Claude Code 用户级设置:${file}`);
205
+ if (JSON.stringify(data) === JSON.stringify(target)) {
206
+ prompts.out(action === 'install' ? '本工具 hooks 已经安装,配置一致,无需重复添加。' : '没有找到本工具添加的 hooks,设置没有改动。');
207
+ return true;
208
+ }
209
+ if (action === 'install') {
210
+ if (!quiet) {
211
+ prompts.out(`通知配置:${path.resolve(config)}`);
212
+ prompts.out('会为这个 Claude Code 配置目录下的所有项目启用通知:任务结束时发送最终回复,需要你确认或输入时也会提醒。');
213
+ prompts.out('最终回复和提醒正文可能包含敏感内容,会发送到配置实际生效的 Telegram / 飞书。环境变量仍然优先。');
214
+ prompts.out('同时安装会话生命周期和结构化问题 hooks。远程输入需要另行运行 reply setup 授权。');
215
+ prompts.out('不会读取整份会话记录。启用回复后,会读取会话的进程元数据;结构化问题或权限请求会发送对应的问题和工具信息。');
216
+ prompts.out('PreToolUse 只登记调用身份;PermissionRequest 会和本机终端窗口同时等待,远程回答的有效期是 5 分钟。安装完成事件只用于反馈结果,不会代替你作答。已核对 Claude Code 2.1.316;其他版本或宿主环境需要另行验证。');
217
+ }
218
+ if (status.others)
219
+ prompts.out(`当前文件里还有 ${status.others} 个其他相关处理器,可能会重复通知。`);
220
+ prompts.out('项目级或插件的 hooks 也可能同时生效;本工具不会自动迁移或删除旧版 hooks。');
221
+ if (status.disabled)
222
+ prompts.out('注意:该文件启用了 disableAllHooks,安装后 hooks 也不会生效;请自行解除禁用。');
223
+ }
224
+ else if (!quiet)
225
+ prompts.out('只会移除本工具添加的通知、生命周期和交互处理器;其他设置、hooks、通知配置和系统凭据都会保留。如需停止回复桥接,请运行 reply disable 或 reply stop。');
226
+ if (options.confirm !== false
227
+ && !yes(await prompts.text(action === 'install' ? '安装这些 Claude Code hooks?[y/N] ' : '移除本工具的 Claude Code hooks?[y/N] '))) {
228
+ prompts.out('已取消,Claude Code 设置没有改动。');
229
+ return false;
230
+ }
231
+ await commit(file, before, target, prompts, options.replace);
232
+ prompts.out(action === 'install' ? 'Claude Code hooks 已安装。请在 /hooks 中检查并按界面确认;如果没有生效,请重启会话。'
233
+ : '本工具的 Claude Code hooks 已移除。请在 /hooks 中检查,必要时重启会话。');
234
+ return true;
235
+ }
@@ -0,0 +1,136 @@
1
+ import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
2
+ import { makePlan } from './channels.js';
3
+ import { NOTIFICATION_MATCHER } from './claude-code.js';
4
+ import { findSessionName } from './reply-platform.js';
5
+ import { lifecycleHook, interactionHook, attachReply, mapReplies, notificationCoverage } from './reply-hooks.js';
6
+ export const MAX_HOOK_BYTES = 8 * 1024 * 1024;
7
+ export async function readHookInput(input = process.stdin, limit = MAX_HOOK_BYTES) {
8
+ const chunks = [];
9
+ let size = 0;
10
+ const timer = setTimeout(() => input.destroy(new ConfigError('Claude Code hook 输入超时,通知没有发送。')), 15000);
11
+ timer.unref();
12
+ try {
13
+ for await (const chunk of input) {
14
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
15
+ size += bytes.length;
16
+ if (size > limit)
17
+ throw new ConfigError('Claude Code hook 输入超过大小限制,通知没有发送;内容不会被截断。');
18
+ chunks.push(bytes);
19
+ }
20
+ return Buffer.concat(chunks);
21
+ }
22
+ finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+ function metadata(value, limit) {
27
+ if (typeof value !== 'string')
28
+ return '';
29
+ const clean = value.replace(/[\x00-\x1f\x7f]/g, ' ');
30
+ const chars = Array.from(clean);
31
+ return chars.length > limit ? chars.slice(0, limit).join('') + '…' : clean;
32
+ }
33
+ function footer(data, sessionName) {
34
+ const project = typeof data.cwd === 'string' && data.cwd.trim() ? `项目:${metadata(data.cwd, 300)}` : '';
35
+ const topic = typeof sessionName === 'string' && sessionName.trim() ? `主题:${metadata(sessionName.trim(), 120)}` : '';
36
+ const session = typeof data.session_id === 'string' && data.session_id.trim() ? `会话:${metadata(data.session_id, 120)}` : '';
37
+ return [project, topic, session].filter(Boolean).join('\n');
38
+ }
39
+ export function claudeHookMessage(bytes, sessionName = '') {
40
+ if (bytes.byteLength > MAX_HOOK_BYTES)
41
+ throw new ConfigError('Claude Code hook 输入超过大小限制,通知没有发送;内容不会被截断。');
42
+ let data;
43
+ try {
44
+ data = JSON.parse(decodeUtf8(bytes));
45
+ }
46
+ catch {
47
+ throw new ConfigError('Claude Code hook 输入不是合法的 UTF-8 JSON,通知没有发送。');
48
+ }
49
+ if (!data || typeof data !== 'object' || Array.isArray(data))
50
+ throw new ConfigError('Claude Code hook 输入需要是 JSON 对象。');
51
+ if (data.hook_event_name !== 'Stop' && data.hook_event_name !== 'Notification')
52
+ return null;
53
+ const details = footer(data, sessionName);
54
+ if (data.hook_event_name === 'Stop') {
55
+ if (data.stop_hook_active !== undefined && typeof data.stop_hook_active !== 'boolean')
56
+ throw new ConfigError('Claude Code Stop 状态字段无效。');
57
+ if (data.stop_hook_active === true)
58
+ return null;
59
+ if (data.last_assistant_message !== undefined && typeof data.last_assistant_message !== 'string')
60
+ throw new ConfigError('Claude Code 最终回复字段无效。');
61
+ return { title: 'Claude Code 回复完成', level: 'success', footer: details,
62
+ content: data.last_assistant_message?.trim() ? data.last_assistant_message
63
+ : '本轮回复已结束,Claude Code 没有提供最终正文。请回到终端查看;这里不会读取会话文件来补正文,以免显示过期内容。' };
64
+ }
65
+ // 空闲提醒在 Stop 之后重复触发;只转发明确需要用户处理的通知。
66
+ if (!NOTIFICATION_MATCHER.split('|').includes(data.notification_type))
67
+ return null;
68
+ if (data.message !== undefined && typeof data.message !== 'string')
69
+ throw new ConfigError('Claude Code 通知正文字段无效。');
70
+ return { title: 'Claude Code 需要你处理', level: 'warn', footer: details,
71
+ content: data.message?.trim() ? data.message : 'Claude Code 正在等待你的确认或输入,请回到终端处理。' };
72
+ }
73
+ export async function dispatchClaudeHook(bytes, configFile, context) {
74
+ const message = claudeHookMessage(bytes);
75
+ const data = JSON.parse(decodeUtf8(bytes));
76
+ const options = { send: context.send, ...context.reply, env: context.env, err: context.err, response: context.response };
77
+ try {
78
+ await lifecycleHook(data, configFile, options);
79
+ await interactionHook(data, configFile, options);
80
+ }
81
+ catch {
82
+ context.err('Claude Code 的回复状态或交互处理失败。没有自动选择答案或批准权限,请在本机处理。');
83
+ }
84
+ if (!message)
85
+ return;
86
+ let sessionName = '';
87
+ try {
88
+ sessionName = await findSessionName(data.session_id, { env: context.env });
89
+ }
90
+ catch {
91
+ context.err('读取不到 Claude Code 会话名称,通知底部将省略主题。');
92
+ }
93
+ const notification = sessionName ? claudeHookMessage(bytes, sessionName) ?? message : message;
94
+ let binding = null;
95
+ try {
96
+ binding = await attachReply(notification, data, configFile, options);
97
+ }
98
+ catch {
99
+ context.err('远程回复还没有就绪,仍会尝试发送普通通知。');
100
+ }
101
+ const config = await loadConfig(configFile, true, context.env);
102
+ let covered = { telegram: [], feishu: false };
103
+ try {
104
+ covered = await notificationCoverage(data, configFile, config, options);
105
+ }
106
+ catch {
107
+ context.err('没有核验到选择题通知的覆盖情况,将保留普通权限提醒。');
108
+ }
109
+ let channels = 0;
110
+ for (const channel of ['telegram', 'feishu']) {
111
+ if (!ready(config, channel))
112
+ continue;
113
+ channels++;
114
+ try {
115
+ if (channel === 'feishu' && covered.feishu)
116
+ continue;
117
+ const plan = makePlan(channel, notification, config);
118
+ if (channel === 'telegram' && covered.telegram.length) {
119
+ plan.items = plan.items.filter(item => !covered.telegram.includes(item.recipient));
120
+ plan.recipients = new Set(plan.items.map(item => item.recipient)).size;
121
+ if (!plan.items.length)
122
+ continue;
123
+ }
124
+ if (binding)
125
+ mapReplies(plan, binding);
126
+ const result = await context.send(plan, config);
127
+ if (!result.ok)
128
+ context.err(`${channel}:Claude Code 通知没有完整发送。整条消息不会自动重发。`);
129
+ }
130
+ catch {
131
+ context.err(`${channel}:Claude Code 通知发送失败。请检查配置、系统凭据和网络;整条消息不会自动重发。`);
132
+ }
133
+ }
134
+ if (!channels)
135
+ throw new ConfigError('Claude Code 通知没有可用渠道,请运行 dev-notify doctor 检查。');
136
+ }