@quecue/dev-notify 0.0.0-stage → 0.1.0-alpha.3

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,236 @@
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('临时 Claude Code 设置清理失败,请确认安装结束后清理 .dev-notify-*.tmp');
181
+ }
182
+ }
183
+ try {
184
+ await remove(lockFile);
185
+ }
186
+ catch {
187
+ prompts.out('Claude Code 设置锁清理失败,请确认安装结束后处理 .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
+ prompts.out(`Claude Code 用户级设置:${file}`);
202
+ if (JSON.stringify(data) === JSON.stringify(target)) {
203
+ prompts.out(action === 'install' ? '本工具 hooks 已安装且配置一致,未重复添加。' : '没有本工具标记的 hooks,未修改。');
204
+ return true;
205
+ }
206
+ if (action === 'install') {
207
+ prompts.out(`通知配置:${path.resolve(config)}`);
208
+ prompts.out('将为该 Claude Code 配置目录下的所有项目启用:最终回复通知,以及权限等待等需要输入的通知。');
209
+ prompts.out('最终回复和通知正文可能包含敏感内容,将发送到上述配置实际生效的 Telegram / 飞书目标;环境变量仍可覆盖。');
210
+ prompts.out('同时安装会话生命周期和结构化问题 hooks;未独立运行 reply setup 授权时不接收远程输入、不启动桥接。');
211
+ prompts.out('不读取整份会话记录;启用回复后读取会话进程元数据,结构化问题/权限请求会发送相应问题或工具输入。');
212
+ prompts.out('PreToolUse 只登记身份;PermissionRequest 与普通交互终端原生窗口并行等待,远程 5 分钟有效。安装完成事件用于结果反馈,不默认作答。当前已核对 Claude Code 2.1.316 实现;旧版和特殊宿主需另行验收。');
213
+ if (status.others)
214
+ prompts.out(`保留当前文件中另外 ${status.others} 个相关处理器;可能出现重复通知。`);
215
+ prompts.out('项目级或插件 hooks 也可能同时生效;不会自动迁移或删除原 Python hooks。');
216
+ if (status.disabled)
217
+ prompts.out('注意:该文件设置了 disableAllHooks,安装后仍不会生效;本工具不擅自解除禁用。');
218
+ }
219
+ else
220
+ prompts.out('只移除本工具标记的通知、生命周期和交互处理器;保留其他设置、hooks、通知配置和系统凭据。如需停止回复桥接,请运行 reply disable / stop。');
221
+ if (!yes(await prompts.text(action === 'install' ? '确认合并安装 Claude Code 通知 hooks?[y/N] ' : '确认移除本工具的 Claude Code hooks?[y/N] '))) {
222
+ prompts.out('已取消,Claude Code 设置未修改。');
223
+ return false;
224
+ }
225
+ await commit(file, before, target, prompts, options.replace);
226
+ prompts.out(action === 'install' ? 'Claude Code hooks 已写入。请在 /hooks 中检查并按界面确认;未生效时重启会话。'
227
+ : '本工具的 Claude Code hooks 已移除;请在 /hooks 中检查或重启会话。');
228
+ return true;
229
+ }
230
+ export async function offerClaudeCode(config, prompts, options = {}) {
231
+ if (!yes(await prompts.text('是否配置 Claude Code 自动通知?将另行展示用户级设置路径及发送范围。[y/N] '))) {
232
+ prompts.out('未更改 Claude Code hooks;已有集成保持原样。');
233
+ return;
234
+ }
235
+ await configureClaudeCode(config, 'install', prompts, options);
236
+ }
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+ import { ConfigError, decodeUtf8, loadConfig, ready } from './config.js';
3
+ import { makePlan } from './channels.js';
4
+ import { NOTIFICATION_MATCHER } from './claude-code.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
+ export function claudeHookMessage(bytes) {
34
+ if (bytes.byteLength > MAX_HOOK_BYTES)
35
+ throw new ConfigError('Claude Code hook 输入超过大小限制,未截断发送');
36
+ let data;
37
+ try {
38
+ data = JSON.parse(decodeUtf8(bytes));
39
+ }
40
+ catch {
41
+ throw new ConfigError('Claude Code hook 输入不是合法 UTF-8 JSON,未发送通知');
42
+ }
43
+ if (!data || typeof data !== 'object' || Array.isArray(data))
44
+ throw new ConfigError('Claude Code hook 输入必须是 JSON 对象');
45
+ if (data.hook_event_name !== 'Stop' && data.hook_event_name !== 'Notification')
46
+ return null;
47
+ const footer = [typeof data.cwd === 'string' ? `项目:${metadata(path.win32.basename(data.cwd), 120)}` : '',
48
+ typeof data.session_id === 'string' ? `会话:${metadata(data.session_id, 120)}` : ''].filter(Boolean).join('\n');
49
+ if (data.hook_event_name === 'Stop') {
50
+ if (data.stop_hook_active !== undefined && typeof data.stop_hook_active !== 'boolean')
51
+ throw new ConfigError('Claude Code Stop 状态字段无效');
52
+ if (data.stop_hook_active === true)
53
+ return null;
54
+ if (data.last_assistant_message !== undefined && typeof data.last_assistant_message !== 'string')
55
+ throw new ConfigError('Claude Code 最终回复字段无效');
56
+ return { title: 'Claude Code 回复完成', level: 'success', footer,
57
+ content: data.last_assistant_message?.trim() ? data.last_assistant_message
58
+ : '本轮回复已结束,但 Claude Code 未提供最终正文。请在终端查看回复;不会读取可能滞后的会话文件来代替正文。' };
59
+ }
60
+ // 空闲提醒在 Stop 之后重复触发;只转发明确需要用户处理的通知。
61
+ if (!NOTIFICATION_MATCHER.split('|').includes(data.notification_type))
62
+ return null;
63
+ if (data.message !== undefined && typeof data.message !== 'string')
64
+ throw new ConfigError('Claude Code 通知正文字段无效');
65
+ return { title: 'Claude Code 需要你处理', level: 'warn', footer,
66
+ content: data.message?.trim() ? data.message : 'Claude Code 正在等待你的确认或输入,请回到终端处理。' };
67
+ }
68
+ export async function dispatchClaudeHook(bytes, configFile, context) {
69
+ const message = claudeHookMessage(bytes);
70
+ const data = JSON.parse(decodeUtf8(bytes));
71
+ const options = { send: context.send, ...context.reply, env: context.env, err: context.err, response: context.response };
72
+ try {
73
+ await lifecycleHook(data, configFile, options);
74
+ await interactionHook(data, configFile, options);
75
+ }
76
+ catch {
77
+ context.err('Claude Code 回复状态或交互处理失败;未自动选择答案/批准权限,请在本机处理。');
78
+ }
79
+ if (!message)
80
+ return;
81
+ let binding = null;
82
+ try {
83
+ binding = await attachReply(message, data, configFile, options);
84
+ }
85
+ catch {
86
+ context.err('远程回复未就绪;仍尝试发送普通通知。');
87
+ }
88
+ const config = await loadConfig(configFile, true, context.env);
89
+ let covered = { telegram: [], feishu: false };
90
+ try {
91
+ covered = await notificationCoverage(data, configFile, config, options);
92
+ }
93
+ catch {
94
+ context.err('无法核验选择题通知覆盖情况;保留普通权限提醒。');
95
+ }
96
+ let channels = 0;
97
+ for (const channel of ['telegram', 'feishu']) {
98
+ if (!ready(config, channel))
99
+ continue;
100
+ channels++;
101
+ try {
102
+ if (channel === 'feishu' && covered.feishu)
103
+ continue;
104
+ const plan = makePlan(channel, message, config);
105
+ if (channel === 'telegram' && covered.telegram.length) {
106
+ plan.items = plan.items.filter(item => !covered.telegram.includes(item.recipient));
107
+ plan.recipients = new Set(plan.items.map(item => item.recipient)).size;
108
+ if (!plan.items.length)
109
+ continue;
110
+ }
111
+ if (binding)
112
+ mapReplies(plan, binding);
113
+ const result = await context.send(plan, config);
114
+ if (!result.ok)
115
+ context.err(`${channel}: Claude Code 通知未完整发送;不自动重发整条消息。`);
116
+ }
117
+ catch {
118
+ context.err(`${channel}: Claude Code 通知发送失败;请检查配置、系统凭据及网络,不自动重发整条消息。`);
119
+ }
120
+ }
121
+ if (!channels)
122
+ throw new ConfigError('Claude Code 通知没有可用渠道,请运行 dev-notify doctor 检查');
123
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { realpathSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { appPaths, ConfigError, createConfig, decodeUtf8, loadConfig, ready } from './config.js';
8
+ import { deliver, makePlan } from './channels.js';
9
+ import { wizard, clearCredentials } from './setup.js';
10
+ import { terminalPrompts, readText, Cancelled } from './prompts.js';
11
+ import { CredentialError } from './credentials.js';
12
+ import { claudeCodeStatus, configureClaudeCode, offerClaudeCode } from './claude-code.js';
13
+ import { dispatchClaudeHook, readHookInput } from './claude-hook.js';
14
+ import { accessReplies, configureReplies, disableReplies } from './reply-state.js';
15
+ import { startBridge, stopBridge, bridgeStatus, runBridge } from './reply-bridge.js';
16
+ const HELP = `dev-notify 0.1.0-alpha.3 — 跨平台开发任务通知
17
+
18
+ 命令:
19
+ send --title 标题 [--content 正文 | --content-file 文件 | --stdin]
20
+ [--channel all|telegram|feishu] [--level info|success|warn|error]
21
+ [--footer 辅助信息] [--dry-run]
22
+ setup 配置渠道及系统凭据,可选测试与 Claude Code 通知
23
+ setup --template [--yes] 仅创建无密钥模板,不覆盖已有配置、不安装 hooks
24
+ credentials clear 确认后移除当前 Node 凭据引用和条目
25
+ paths 显示配置路径,不写文件
26
+ doctor / status 离线检查,不发消息
27
+ integration list 列出可用集成
28
+ integration claude-code install / status / remove
29
+ 单独安装、查询或移除用户级 hooks
30
+ reply setup 另行授权可信 Telegram 私聊用户
31
+ reply start / status / stop
32
+ 启动、查询或停止回复桥接
33
+ reply disable 确认后撤销远程回复授权并请求停止桥接
34
+ reply run 前台运行桥接(排错用,不启用系统自启动)
35
+
36
+ 全局选项:--config 路径、--help、--version
37
+ 系统凭据使用 Windows Credential Manager / macOS Keychain;不调用 Python,不写入明文密钥。`;
38
+ const defaults = {
39
+ env: process.env,
40
+ out: text => console.log(text),
41
+ err: text => console.error(text),
42
+ stdin: async () => { const chunks = []; for await (const chunk of process.stdin)
43
+ chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); },
44
+ hookStdin: () => readHookInput(),
45
+ confirm: async () => {
46
+ if (!process.stdin.isTTY)
47
+ throw new ConfigError('非交互环境请使用 setup --template --yes');
48
+ return /^(y|yes)$/i.test(await readText('确认创建模板?[y/N] '));
49
+ },
50
+ send: deliver,
51
+ };
52
+ export async function main(argv = process.argv.slice(2), overrides = {}) {
53
+ const context = { ...defaults, ...overrides };
54
+ const isHook = argv[0] === 'integration' && argv[1] === 'claude-code' && argv[2] === 'hook';
55
+ try {
56
+ let parsed;
57
+ try {
58
+ parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: {
59
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, config: { type: 'string' },
60
+ title: { type: 'string' }, content: { type: 'string' }, 'content-file': { type: 'string' }, stdin: { type: 'boolean' },
61
+ channel: { type: 'string' }, level: { type: 'string' }, footer: { type: 'string' }, 'dry-run': { type: 'boolean' },
62
+ template: { type: 'boolean' }, yes: { type: 'boolean' },
63
+ } });
64
+ }
65
+ catch {
66
+ throw new ConfigError('参数无效,请使用 --help;不回显可能含秘密的参数');
67
+ }
68
+ const { values, positionals } = parsed;
69
+ if (values.help) {
70
+ context.out(HELP);
71
+ return 0;
72
+ }
73
+ if (values.version) {
74
+ context.out('dev-notify 0.1.0-alpha.3');
75
+ return 0;
76
+ }
77
+ const [command, action, integrationAction] = positionals;
78
+ const allowed = {
79
+ send: ['title', 'content', 'content-file', 'stdin', 'channel', 'level', 'footer', 'dry-run'],
80
+ setup: ['template', 'yes'], paths: [], doctor: [], status: [], integration: [], credentials: [], reply: [],
81
+ };
82
+ if (!command || !Object.hasOwn(allowed, command) || positionals.length > (command === 'integration' ? 3 : command === 'credentials' || command === 'reply' ? 2 : 1)
83
+ || Object.keys(values).some(key => key !== 'config' && !allowed[command].includes(key)))
84
+ throw new ConfigError('命令或选项组合无效,请使用 --help');
85
+ const paths = appPaths(process.platform, context.env);
86
+ const file = values.config ? path.resolve(values.config) : paths.config;
87
+ if (command === 'paths') {
88
+ context.out(JSON.stringify({ config: file, data: paths.data }, null, 2));
89
+ return 0;
90
+ }
91
+ if (command === 'integration') {
92
+ if (action === 'list' && !integrationAction) {
93
+ context.out('claude-code: 可选通知集成、Telegram 回复与结构化问题交互;通知 hooks 与 reply setup 输入授权分开,默认不开启远程输入。');
94
+ return 0;
95
+ }
96
+ if (action !== 'claude-code' || !['install', 'status', 'remove', 'hook'].includes(integrationAction))
97
+ throw new ConfigError('请使用 integration list 或 integration claude-code install / status / remove');
98
+ if (integrationAction === 'hook') {
99
+ await dispatchClaudeHook(await context.hookStdin(), file, { ...context, response: value => context.out(JSON.stringify(value)) });
100
+ return 0;
101
+ }
102
+ if (integrationAction === 'status') {
103
+ const status = await claudeCodeStatus({ env: context.env });
104
+ context.out(`Claude Code 用户级设置:${status.file}`);
105
+ context.out(`本工具 hooks:Stop ${status.stop} 个,Notification ${status.notification} 个,会话生命周期 ${status.lifecycle}/3 个,交互及结果确认 ${status.interaction}/5 个;其他处理器 ${status.others} 个。`);
106
+ context.out(status.disabled ? '当前文件设置了 disableAllHooks;本工具不会解除禁用。'
107
+ : '仅检查用户级文件;实际是否加载或受项目、托管策略限制,请在 Claude Code /hooks 中确认。');
108
+ return status.stop === 1 && status.notification === 1 && status.lifecycle === 3 && status.interaction === 5 && !status.disabled ? 0 : 1;
109
+ }
110
+ const prompts = context.prompts ?? terminalPrompts();
111
+ if (integrationAction === 'install') {
112
+ const config = await loadConfig(file, true, context.env);
113
+ if (!ready(config, 'telegram') && !ready(config, 'feishu'))
114
+ throw new ConfigError('请先运行 setup 配置可用通知渠道,再安装 Claude Code hooks');
115
+ }
116
+ return await configureClaudeCode(file, integrationAction, prompts, { env: context.env }) ? 0 : 130;
117
+ }
118
+ if (command === 'reply') {
119
+ if (!['setup', 'start', 'status', 'stop', 'disable', 'run'].includes(action))
120
+ throw new ConfigError('请使用 reply setup / start / status / stop / disable / run');
121
+ if (action === 'setup') {
122
+ const saved = await configureReplies(file, context.prompts ?? terminalPrompts(), context.env);
123
+ if (saved)
124
+ context.out('授权已保存;请确认已安装本版 hooks,再运行 reply start。新授权不会恢复旧消息映射。');
125
+ return saved ? 0 : 130;
126
+ }
127
+ if (action === 'disable') {
128
+ if (!await disableReplies(file, context.prompts ?? terminalPrompts()))
129
+ return 130;
130
+ const stopped = await stopBridge(file);
131
+ context.out(stopped ? '桥接已停止或未运行。' : '已撤销授权并请求停止;正在进行中的调用不能撤回。');
132
+ return 0;
133
+ }
134
+ if (action === 'stop') {
135
+ const stopped = await stopBridge(file);
136
+ context.out(stopped ? '桥接已停止或未运行;授权仍保留,下一次事件可再次启动。彻底关闭请用 reply disable。'
137
+ : '停止请求已登记;桥接尚未退出,未强杀进程,请用 reply status 检查。');
138
+ return stopped ? 0 : 1;
139
+ }
140
+ if (action === 'status') {
141
+ const access = await accessReplies(file, context.env), status = await bridgeStatus(file);
142
+ context.out(access ? `远程回复:已另行授权 ${access.policy.users.length} 个私聊用户;逐次工具权限${access.policy.permissions ? '已启用' : '未启用'}。` : '远程回复:未启用。');
143
+ context.out(status.ready ? '桥接:已就绪。' : status.running ? '桥接:进程存在但未就绪。' : '桥接:未运行。');
144
+ if (status.error)
145
+ context.err(status.error);
146
+ return access && status.ready ? 0 : 1;
147
+ }
148
+ if (action === 'run') {
149
+ await runBridge(file, { env: context.env, log: context.err });
150
+ return 0;
151
+ }
152
+ const ready = await startBridge(file, context.env);
153
+ context.out(ready ? '回复桥接已就绪;请从新任务通知进入。' : '桥接尚未就绪;请用 reply status / reply run 查看安全错误,不要同时运行旧 Python 桥接。');
154
+ return ready ? 0 : 1;
155
+ }
156
+ if (command === 'credentials') {
157
+ if (action !== 'clear')
158
+ throw new ConfigError('当前只支持 credentials clear,且必须交互确认');
159
+ return await clearCredentials(file, context.prompts ?? terminalPrompts()) ? 0 : 130;
160
+ }
161
+ if (command === 'setup') {
162
+ if (!values.template) {
163
+ if (values.yes)
164
+ throw new ConfigError('--yes 只允许与 --template 同时使用,不能跳过密钥保存确认');
165
+ const prompts = context.prompts ?? terminalPrompts();
166
+ const saved = await wizard(file, {
167
+ prompts, store: context.store, env: context.env,
168
+ testSend: async (config) => {
169
+ let count = 0, ok = true;
170
+ for (const channel of ['telegram', 'feishu']) {
171
+ if (!ready(config, channel))
172
+ continue;
173
+ count++;
174
+ const plan = makePlan(channel, { title: 'dev-notify 配置测试', content: '这是一条由你确认发送的测试通知。', footer: '', level: 'info' }, config);
175
+ const result = await context.send(plan, config);
176
+ context.out(`${channel}: 测试已发送 ${result.sent}/${result.planned}`);
177
+ ok = ok && result.ok;
178
+ }
179
+ return count > 0 && ok;
180
+ },
181
+ });
182
+ if (!saved)
183
+ return 130;
184
+ try {
185
+ await offerClaudeCode(file, prompts, { env: context.env });
186
+ const config = await loadConfig(file, true, context.env);
187
+ if (ready(config, 'telegram') && /^(y|yes)$/i.test(await prompts.text('是否另行配置可信私聊用户回复 Claude?默认关闭。[y/N] '))) {
188
+ await configureReplies(file, prompts, context.env);
189
+ context.out('回复授权流程结束;确认 hooks 已安装后可运行 reply start,或由下一次支持的会话事件启动。');
190
+ }
191
+ }
192
+ catch (error) {
193
+ if (error instanceof Cancelled) {
194
+ context.out('渠道配置已保存;已取消后续 Claude Code 集成/回复配置。');
195
+ return 0;
196
+ }
197
+ context.err('渠道配置已保存,但 Claude Code hook 或回复配置未完成;请运行 integration claude-code status / reply status 检查后重试。');
198
+ return 1;
199
+ }
200
+ return 0;
201
+ }
202
+ context.out(`将创建无密钥模板: ${file};不会联网或修改 hooks`);
203
+ if (!values.yes && !await context.confirm()) {
204
+ context.out('已取消,未写入');
205
+ return 130;
206
+ }
207
+ await createConfig(file);
208
+ context.out('已创建模板;请配置环境凭据和接收人');
209
+ return 0;
210
+ }
211
+ const config = await loadConfig(file, values.config !== undefined, context.env);
212
+ if (command === 'doctor' || command === 'status') {
213
+ context.out(`配置路径: ${file}`);
214
+ for (const channel of ['telegram', 'feishu'])
215
+ context.out(`${channel}: ${ready(config, channel) ? '配置齐全,网络未验证' : '未启用或配置不完整'}`);
216
+ context.out('凭据: 环境变量优先,其次 Windows Credential Manager / macOS Keychain;检查不联网,系统可能要求授权');
217
+ return ready(config, 'telegram') || ready(config, 'feishu') ? 0 : 1;
218
+ }
219
+ if (!values.title?.trim())
220
+ throw new ConfigError('send 必须指定非空 --title');
221
+ if ([values.content !== undefined, values['content-file'] !== undefined, !!values.stdin].filter(Boolean).length > 1)
222
+ throw new ConfigError('正文来源只能指定一个');
223
+ const level = values.level ?? 'info';
224
+ const channel = values.channel ?? 'all';
225
+ if (!['info', 'success', 'warn', 'error'].includes(level) || !['all', 'telegram', 'feishu'].includes(channel))
226
+ throw new ConfigError('渠道或通知级别无效');
227
+ let content = values.content ?? values.title;
228
+ if (values['content-file'] !== undefined)
229
+ content = decodeUtf8(await readFile(values['content-file']));
230
+ if (values.stdin)
231
+ content = decodeUtf8(await context.stdin());
232
+ const message = { title: values.title, content, footer: values.footer ?? '', level: level };
233
+ const selected = channel === 'all' ? ['telegram', 'feishu'] : [channel];
234
+ let failed = false, planned = 0;
235
+ for (const name of selected) {
236
+ if (!ready(config, name)) {
237
+ if (channel !== 'all')
238
+ throw new ConfigError('指定渠道未启用或配置不完整');
239
+ continue;
240
+ }
241
+ planned++;
242
+ let plan;
243
+ try {
244
+ plan = makePlan(name, message, config);
245
+ }
246
+ catch {
247
+ context.err(`${name}: 无法生成安全分段,请缩短标题/辅助信息或检查正文格式`);
248
+ failed = true;
249
+ continue;
250
+ }
251
+ if (values['dry-run']) {
252
+ context.out(`${name}: 接收目标 ${plan.recipients},预计请求 ${plan.items.length};未发送`);
253
+ continue;
254
+ }
255
+ const result = await context.send(plan, config);
256
+ context.out(`${name}: 已发送 ${result.sent}/${result.planned}`);
257
+ if (!result.ok) {
258
+ failed = true;
259
+ context.err(result.error ?? '投递失败');
260
+ }
261
+ }
262
+ if (!planned)
263
+ throw new ConfigError('没有可发送的渠道,请配置环境凭据和接收人');
264
+ return failed ? 1 : 0;
265
+ }
266
+ catch (error) {
267
+ if (error instanceof Cancelled) {
268
+ context.out('已取消,未继续执行。');
269
+ return isHook ? 0 : 130;
270
+ }
271
+ context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '操作失败,请检查文件、权限或输入;不输出可能含秘密的底层异常');
272
+ // Stop hook 的退出码 2 会要求 Claude 继续;通知失败只能报告,不能阻塞或触发重试循环。
273
+ return isHook ? 0 : 2;
274
+ }
275
+ }
276
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
277
+ process.exitCode = await main();
278
+ }