dingtalk-ask-mcp-server 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.
package/dist/index.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dingtalk-ask-mcp-server:基于 stdio transport 的 human-in-the-loop MCP server(瘦客户端)。
4
+ *
5
+ * 暴露 ask_human / set_away_mode 两个工具。多 Agent 架构下,本 server 不再自持钉钉连接,
6
+ * 而是把决策经本地 HTTP 转发给【唯一】的 dingtalk-daemon(共享持连守护进程),由它推钉钉、
7
+ * 收回复、按 Agent 标签与编号路由——规避同应用多开 Stream 连接被负载均衡的问题。
8
+ *
9
+ * 启动时 ensureDaemon 确保守护进程在跑(不在则 detached 拉起)。stdio 是 MCP 协议信道,
10
+ * 严禁向 stdout 打印日志,一律走 stderr。
11
+ */
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import { resultText } from "./decision/resultText.js";
16
+ import { resolveDingtalkConfig } from "./dingtalk/config.js";
17
+ import { isAway, setAway } from "./awayState.js";
18
+ import { resolveAgentLabel } from "./agentLabel.js";
19
+ import { mergeArgsIntoEnv } from "./cliArgs.js";
20
+ import { ensureDaemon, postAskToDaemon } from "./daemonClient.js";
21
+ import { daemonLockPath } from "./daemon/lock.js";
22
+ /** 合并 CLI 参数与环境变量(参数优先):允许用 --client-id 等命令行参数传钉钉配置。 */
23
+ const env = mergeArgsIntoEnv(process.env, process.argv.slice(2));
24
+ /**
25
+ * 解析钉钉配置;缺凭证时打印缺失项到 stderr 并 exit(1)(AC5:不产生半可用状态)。
26
+ *
27
+ * @returns 已校验的钉钉配置。
28
+ */
29
+ function loadConfigOrExit() {
30
+ try {
31
+ return resolveDingtalkConfig(env);
32
+ }
33
+ catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ console.error(`dingtalk-ask-mcp-server 配置错误:${message}`);
36
+ process.exit(1);
37
+ }
38
+ }
39
+ const config = loadConfigOrExit();
40
+ /** 本 Agent 标签:优先 --agent-label / DINGTALK_AGENT_LABEL,其次工作目录名(推钉钉时标注来源)。 */
41
+ const agentLabel = resolveAgentLabel(env, process.cwd());
42
+ /** 缓存的守护进程锁;POST 失败时清空重新 ensure。 */
43
+ let cachedLock;
44
+ /**
45
+ * 取一个就绪守护进程的锁(缓存优先,缺省则 ensure 拉起/等待)。
46
+ *
47
+ * @returns 守护进程锁。
48
+ */
49
+ async function getLock() {
50
+ if (cachedLock !== undefined) {
51
+ return cachedLock;
52
+ }
53
+ cachedLock = await ensureDaemon(config, daemonLockPath());
54
+ return cachedLock;
55
+ }
56
+ /**
57
+ * 共享决策入口:把一次决策转发给守护进程,等待结果。POST 失败时重新 ensure 一次再试
58
+ * (守护进程可能刚重启换了端口)。
59
+ *
60
+ * @param input 决策入参(question、可选 options、可选 timeoutMs)。
61
+ * @returns 决策结果(answered/timeout)。
62
+ */
63
+ async function askDecision(input) {
64
+ const body = {
65
+ question: input.question,
66
+ options: input.options,
67
+ label: agentLabel,
68
+ timeoutMs: input.timeoutMs ?? config.timeoutMs,
69
+ };
70
+ try {
71
+ return await postAskToDaemon(await getLock(), body);
72
+ }
73
+ catch (error) {
74
+ console.error(`转发守护进程失败,重新 ensure 后重试:${error instanceof Error ? error.message : String(error)}`);
75
+ cachedLock = undefined;
76
+ return postAskToDaemon(await getLock(), body);
77
+ }
78
+ }
79
+ const server = new McpServer({
80
+ name: "dingtalk-ask-mcp-server",
81
+ version: "0.1.0",
82
+ }, {
83
+ // server 级引导:随 server 分发、无需用户写任何配置文件。
84
+ // 客户端(如 Claude Code)会把它注入模型上下文,作为 ask_human 何时该调的总纲。
85
+ instructions: "本 server 提供 ask_human 工具,用于把「需要用户拍板的决策」或「执行有副作用操作前的确认」" +
86
+ "推送到用户钉钉并阻塞等待回复。遇到这两类情况时优先调用 ask_human,而不是把问题写在回复里停下干等" +
87
+ "(用户可能已离开电脑)。克制使用:只在重大、有副作用、或你无法替用户判断时调用," +
88
+ "日常只读小步骤不要打扰。",
89
+ });
90
+ server.registerTool("ask_human", {
91
+ description: "把需要人来拍板的问题推送到用户钉钉,阻塞等待用户在手机/钉钉上的回复," +
92
+ "并把用户的选择或文字作为结果返回。用户可能已离开电脑,这是你在无人值守时获取人工决策的通道。\n" +
93
+ "在以下两类情况【主动调用本工具】,而不是把问题写在回复里停下干等:\n" +
94
+ "1) 决策类——存在多个可行方案且取舍会明显影响结果(架构/技术选型/实现路径/命名与模块边界/数据结构)," +
95
+ "或需求有歧义、按不同理解会做出不同东西:用 options 给出 2~4 个候选方向。\n" +
96
+ "2) 确认类——在执行【有副作用或难以回滚】的操作之前先征得同意(运行进程或改动系统状态的命令、" +
97
+ "删除或覆盖文件、git push、发布/部署、改数据库 schema、数据迁移、安装或升级依赖、跨多文件批量改动):" +
98
+ "用 options 传 [\"是,执行\",\"否,先别\"] 之类的 yes/no。\n" +
99
+ "克制使用:只在重大、有副作用、或你无法替用户判断时调用;日常小步骤、只读操作、无争议的事不要调用,避免频繁打扰。",
100
+ inputSchema: {
101
+ question: z.string().describe("要请用户拍板的问题正文;决策类说清各方向影响,确认类说清「要做什么+风险」。"),
102
+ options: z
103
+ .array(z.string())
104
+ .optional()
105
+ .describe("可选的候选方向:决策类给 2~4 个方向;确认类给 [\"是,执行\",\"否,先别\"];无则纯文本问答。"),
106
+ },
107
+ }, async ({ question, options }) => {
108
+ // 离开模式是统一总闸:关(用户在电脑前)时不推钉钉,直接让模型在终端向用户提问。
109
+ // 开(用户已离开)时才把问题推钉钉、阻塞等回复。
110
+ if (!isAway()) {
111
+ return {
112
+ content: [
113
+ {
114
+ type: "text",
115
+ text: "【离开模式关闭·用户在电脑前】无需推钉钉。请直接在当前终端对话里向用户提出这个问题并等待其回复," +
116
+ "不要依赖本工具的返回值作为答复。(用户离开电脑时会发 /away 开启离开模式,届时再调用 ask_human 才会推送到钉钉。)",
117
+ },
118
+ ],
119
+ };
120
+ }
121
+ // 转发守护进程;失败收敛成可读文本让模型自决,不向宿主抛异常。
122
+ try {
123
+ const outcome = await askDecision({ question, options });
124
+ return {
125
+ content: [{ type: "text", text: resultText(outcome, config.timeoutMs) }],
126
+ };
127
+ }
128
+ catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ console.error(`ask_human 处理失败:${message}`);
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: `无法把决策推送到钉钉:${message}。请你自行决定:稍后重试(再次调用 ask_human)、选择一个安全默认、或停止并说明原因。`,
136
+ },
137
+ ],
138
+ };
139
+ }
140
+ });
141
+ server.registerTool("set_away_mode", {
142
+ description: "开启或关闭「离开模式」。当用户在对话中表示要离开电脑(如「我出去一下」「去开会」「下班了」「先走了」)时," +
143
+ "调用 set_away_mode(away=true);表示回到电脑前(如「我回来了」「继续吧」)时调用 set_away_mode(away=false)。\n" +
144
+ "离开模式开启后,需要用户拍板的决策(ask_human)与命令审批会推送到用户钉钉、由用户在手机上处理;" +
145
+ "关闭后(默认)这些只在终端进行、不打扰手机。用户也可自行在钉钉发 /away(离开)、/back(回来)或用本地命令切换。",
146
+ inputSchema: {
147
+ away: z
148
+ .boolean()
149
+ .describe("true=进入离开模式(决策/命令审批推钉钉);false=退出(只在终端)。"),
150
+ },
151
+ }, async ({ away }) => {
152
+ setAway(away);
153
+ return {
154
+ content: [
155
+ {
156
+ type: "text",
157
+ text: away
158
+ ? "已进入离开模式:后续需要你拍板的决策与命令审批会推送到你的钉钉。"
159
+ : "已退出离开模式:决策与命令审批只在终端进行,不推钉钉。",
160
+ },
161
+ ],
162
+ };
163
+ });
164
+ /**
165
+ * 应用主入口:先确保共享守护进程在跑(不在则拉起),再通过 stdio 连接 MCP server。
166
+ *
167
+ * 取舍:ensureDaemon 失败时不 exit——保留 server 存活可让 ask_human 仍返回可读错误文本让模型自决
168
+ * (复刻旧版"钉钉连接失败不退出"的容错)。缺凭证是启动即 exit(1),与此区分。
169
+ *
170
+ * @returns 服务启动完成后返回的 Promise。
171
+ * @throws 当 stdio 传输连接失败时抛出错误(由 main().catch 兜底 exit 1)。
172
+ */
173
+ async function main() {
174
+ try {
175
+ cachedLock = await ensureDaemon(config, daemonLockPath());
176
+ console.error(`钉钉守护进程就绪(port=${cachedLock.port})。`);
177
+ }
178
+ catch (error) {
179
+ console.error(`确保钉钉守护进程失败:${error instanceof Error ? error.message : String(error)}。server 仍启动,ask_human 将在调用时重试。`);
180
+ }
181
+ const transport = new StdioServerTransport();
182
+ await server.connect(transport);
183
+ console.error(`dingtalk-ask-mcp-server 已启动(stdio,Agent 标签=${agentLabel})。`);
184
+ console.error(`当前离开模式:${isAway() ? '开(命令审批/决策推钉钉)' : '关(只在终端)'}。可在钉钉发 /away、/back 遥控。`);
185
+ }
186
+ main().catch((error) => {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ console.error(`dingtalk-ask-mcp-server 启动失败:${message}`);
189
+ process.exit(1);
190
+ });
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 统一安装命令 `dingtalk-ask install`(bin 入口):一次装 MCP + 命令审批钩子,支持 CC 与 Codex。
4
+ *
5
+ * 用法:
6
+ * dingtalk-ask install --client-id … --client-secret … --robot-code … --user-id … \
7
+ * [--timeout <ms>] [--host cc|codex|both]
8
+ *
9
+ * 默认宿主:CC 必装;Codex 仅当检测到 ~/.codex 时自动一并装(或 --host 显式指定)。
10
+ * 写入均为宿主本地配置(.mcp.json / .claude/settings.json / ~/.codex/config.toml,皆不入库),
11
+ * 合并保留既有内容、幂等。纯逻辑在 installConfig.ts / installHookCore.ts。
12
+ */
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join, dirname } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { resolveDingtalkConfig } from './dingtalk/config.js';
18
+ import { mergeArgsIntoEnv } from './cliArgs.js';
19
+ import { mergeHook, permissionHookPath, resolveSettingsPath } from './installHookCore.js';
20
+ import { daemonLockPath, readDaemonLock, removeDaemonLock } from './daemon/lock.js';
21
+ import { probeHealth } from './daemonClient.js';
22
+ import { isAway, setAway } from './awayState.js';
23
+ import { buildMcpEntry, mergeMcpServers, ensureMcpToolTimeout, codexHasDingtalk, buildCodexBlock, MCP_SERVER_NAME, } from './installConfig.js';
24
+ /** 解析目标宿主。默认 CC;Codex 见 resolveCodexTarget。 */
25
+ function parseHost(argv) {
26
+ const i = argv.indexOf('--host');
27
+ const v = i >= 0 ? argv[i + 1] : undefined;
28
+ if (v === 'cc' || v === 'codex' || v === 'both') {
29
+ return v;
30
+ }
31
+ return undefined;
32
+ }
33
+ /** 读 JSON 文件;不存在/空 → {};损坏 → 抛错(不覆盖)。 */
34
+ function readJson(path) {
35
+ let raw;
36
+ try {
37
+ raw = readFileSync(path, 'utf8');
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ if (raw.trim() === '') {
43
+ return {};
44
+ }
45
+ try {
46
+ return JSON.parse(raw);
47
+ }
48
+ catch (error) {
49
+ throw new Error(`${path} 不是合法 JSON(${error instanceof Error ? error.message : String(error)}),已中止,未改动。`);
50
+ }
51
+ }
52
+ function writeJson(path, obj) {
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
55
+ }
56
+ /** 装 Claude Code:.mcp.json(MCP server)+ .claude/settings.json(钩子 + MCP_TOOL_TIMEOUT)。 */
57
+ function installClaudeCode(config, argv, nodePath, indexPath) {
58
+ const cwd = process.cwd();
59
+ // 1. .mcp.json
60
+ const mcpPath = join(cwd, '.mcp.json');
61
+ const entry = buildMcpEntry(config, nodePath, indexPath);
62
+ const { settings: mcpMerged, existed } = mergeMcpServers(readJson(mcpPath), MCP_SERVER_NAME, entry);
63
+ writeJson(mcpPath, mcpMerged);
64
+ console.log(`✅ [CC] ${existed ? '更新' : '写入'} MCP server 到 ${mcpPath}`);
65
+ // 2. .claude/settings.json:钩子 + MCP_TOOL_TIMEOUT(略大于 ask 超时)
66
+ const settingsPath = resolveSettingsPath(argv.includes('--global') ? ['--global'] : [], cwd, homedir());
67
+ const command = `node "${permissionHookPath()}"`;
68
+ const hookMerged = mergeHook(readJson(settingsPath), command);
69
+ const tmMerged = ensureMcpToolTimeout(hookMerged.settings, config.timeoutMs + 60_000);
70
+ writeJson(settingsPath, tmMerged.settings);
71
+ console.log(`✅ [CC] ${hookMerged.changed ? '安装' : '已存在'}命令审批钩子;` +
72
+ `${tmMerged.changed ? '设置' : '保留'} MCP_TOOL_TIMEOUT → ${settingsPath}`);
73
+ }
74
+ /** 装 Codex:追加 ~/.codex/config.toml 的 [mcp_servers.dingtalk-ask](幂等,先备份)。 */
75
+ function installCodex(config, nodePath, indexPath) {
76
+ const cfgPath = join(homedir(), '.codex', 'config.toml');
77
+ let content = '';
78
+ try {
79
+ content = readFileSync(cfgPath, 'utf8');
80
+ }
81
+ catch {
82
+ content = '';
83
+ }
84
+ if (codexHasDingtalk(content)) {
85
+ console.log(`✅ [Codex] ${cfgPath} 已含 dingtalk-ask,跳过。`);
86
+ return;
87
+ }
88
+ mkdirSync(dirname(cfgPath), { recursive: true });
89
+ if (content !== '') {
90
+ copyFileSync(cfgPath, `${cfgPath}.bak`);
91
+ }
92
+ writeFileSync(cfgPath, content + buildCodexBlock(config, nodePath, indexPath), 'utf8');
93
+ console.log(`✅ [Codex] 追加 [mcp_servers.dingtalk-ask] 到 ${cfgPath}${content !== '' ? '(已备份 .bak)' : ''}`);
94
+ }
95
+ function usage() {
96
+ console.log('dingtalk-ask —— 钉钉 human-in-the-loop MCP 工具');
97
+ console.log('');
98
+ console.log('用法:');
99
+ console.log(' dingtalk-ask install --client-id … --client-secret … --robot-code … --user-id … \\');
100
+ console.log(' [--timeout <ms>] [--host cc|codex|both] # 一键装 MCP+钩子');
101
+ console.log(' dingtalk-ask daemon <status|stop> # 查看/停止共享守护进程');
102
+ console.log(' dingtalk-ask away <on|off|status> # 本地切换离开模式');
103
+ process.exit(1);
104
+ }
105
+ /** install 子命令:装 MCP + 钩子。 */
106
+ function runInstall(rest) {
107
+ let config;
108
+ try {
109
+ config = resolveDingtalkConfig(mergeArgsIntoEnv(process.env, rest));
110
+ }
111
+ catch (error) {
112
+ console.error(`配置错误:${error instanceof Error ? error.message : String(error)}`);
113
+ console.error('请用 --client-id / --client-secret / --robot-code / --user-id 提供凭证。');
114
+ process.exit(1);
115
+ }
116
+ const nodePath = process.execPath;
117
+ const indexPath = fileURLToPath(new URL('./index.js', import.meta.url));
118
+ // 目标宿主:显式 --host 优先;否则 CC 必装,Codex 存在则一并装。
119
+ const host = parseHost(rest);
120
+ const doCc = host === undefined || host === 'cc' || host === 'both';
121
+ const doCodex = host === 'codex' || host === 'both' || (host === undefined && existsSync(join(homedir(), '.codex')));
122
+ if (doCc) {
123
+ installClaudeCode(config, rest, nodePath, indexPath);
124
+ }
125
+ if (doCodex) {
126
+ installCodex(config, nodePath, indexPath);
127
+ }
128
+ console.log('\n完成。后续:');
129
+ if (doCc) {
130
+ console.log(' · 重启 Claude Code 让新 MCP server 与 MCP_TOOL_TIMEOUT 生效。');
131
+ }
132
+ if (doCodex) {
133
+ console.log(' · 重开 Codex 会话让 config.toml 生效(tool_timeout_sec 已设 1500)。');
134
+ }
135
+ console.log(' · 前提:钉钉企业内部应用(H5)+机器人+Stream 已配好。');
136
+ console.log(' · 离开电脑时在钉钉发 /away 开启推送,/back 关闭。');
137
+ }
138
+ /** daemon 子命令:查看/停止共享守护进程。 */
139
+ async function runDaemon(rest) {
140
+ const sub = rest[0];
141
+ const lock = readDaemonLock(daemonLockPath());
142
+ if (sub === 'status') {
143
+ if (lock === undefined) {
144
+ console.log('钉钉守护进程:未运行(无锁文件)。');
145
+ return;
146
+ }
147
+ const probe = await probeHealth(lock);
148
+ console.log(`钉钉守护进程:${probe.ok ? '在跑' : '锁存在但无应答(可能已死)'},port=${lock.port},pid=${lock.pid},就绪=${probe.ready}。`);
149
+ return;
150
+ }
151
+ if (sub === 'stop') {
152
+ if (lock === undefined) {
153
+ console.log('钉钉守护进程:无锁文件,无需停止。');
154
+ return;
155
+ }
156
+ if (lock.pid > 0) {
157
+ try {
158
+ process.kill(lock.pid, 'SIGTERM');
159
+ console.log(`已向守护进程 pid=${lock.pid} 发送 SIGTERM。`);
160
+ }
161
+ catch (error) {
162
+ console.log(`发送 SIGTERM 失败(进程可能已退出):${error instanceof Error ? error.message : String(error)}`);
163
+ }
164
+ }
165
+ removeDaemonLock(daemonLockPath());
166
+ console.log('已清理锁文件。');
167
+ return;
168
+ }
169
+ usage();
170
+ }
171
+ /** away 子命令:本地切换离开模式(与钉钉 /away、set_away_mode 工具同一开关)。 */
172
+ function runAway(rest) {
173
+ const sub = rest[0];
174
+ if (sub === 'on') {
175
+ setAway(true);
176
+ console.log('已开启离开模式:决策与命令审批推钉钉。');
177
+ return;
178
+ }
179
+ if (sub === 'off') {
180
+ setAway(false);
181
+ console.log('已关闭离开模式:只在终端。');
182
+ return;
183
+ }
184
+ if (sub === 'status') {
185
+ console.log(`当前离开模式:${isAway() ? '开' : '关'}。`);
186
+ return;
187
+ }
188
+ usage();
189
+ }
190
+ function main() {
191
+ const args = process.argv.slice(2);
192
+ const cmd = args[0];
193
+ const rest = args.slice(1);
194
+ if (cmd === 'install') {
195
+ runInstall(rest);
196
+ }
197
+ else if (cmd === 'daemon') {
198
+ void runDaemon(rest);
199
+ }
200
+ else if (cmd === 'away') {
201
+ runAway(rest);
202
+ }
203
+ else {
204
+ usage();
205
+ }
206
+ }
207
+ main();
@@ -0,0 +1,80 @@
1
+ /** MCP server 名(各宿主统一)。 */
2
+ export const MCP_SERVER_NAME = 'dingtalk-ask';
3
+ /** 由配置构造凭证 env(写入宿主配置的 env 段)。 */
4
+ function credEnv(config) {
5
+ return {
6
+ DINGTALK_CLIENT_ID: config.clientId,
7
+ DINGTALK_CLIENT_SECRET: config.clientSecret,
8
+ DINGTALK_ROBOT_CODE: config.robotCode,
9
+ DINGTALK_USER_ID: config.userId,
10
+ DINGTALK_ASK_TIMEOUT_MS: String(config.timeoutMs),
11
+ };
12
+ }
13
+ /**
14
+ * 构造 .mcp.json 的 dingtalk-ask 条目:用绝对 node 路径 + 绝对入口,免依赖宿主 PATH。
15
+ *
16
+ * @param config 已解析钉钉配置。
17
+ * @param nodePath 绝对 node 可执行路径。
18
+ * @param indexPath 绝对 dist/index.js 路径。
19
+ * @returns MCP server 条目。
20
+ */
21
+ export function buildMcpEntry(config, nodePath, indexPath) {
22
+ return { command: nodePath, args: [indexPath], env: credEnv(config) };
23
+ }
24
+ /**
25
+ * 把 MCP server 条目合并进现有配置对象(保留其它 server 与顶层字段)。
26
+ *
27
+ * @param existing 现有 .mcp.json 对象(可空)。
28
+ * @param name server 名。
29
+ * @param entry 要写入的条目。
30
+ * @returns { settings: 合并后对象, existed: 该名是否已存在(被覆盖更新) }。
31
+ */
32
+ export function mergeMcpServers(existing, name, entry) {
33
+ const servers = { ...(existing.mcpServers ?? {}) };
34
+ const existed = name in servers;
35
+ servers[name] = entry;
36
+ return { settings: { ...existing, mcpServers: servers }, existed };
37
+ }
38
+ /**
39
+ * 在 settings 对象上确保 env.MCP_TOOL_TIMEOUT(不覆盖已有值)。
40
+ *
41
+ * @param settings 现有 settings 对象。
42
+ * @param ms 期望的超时毫秒(通常略大于 ask 超时)。
43
+ * @returns { settings, changed }。
44
+ */
45
+ export function ensureMcpToolTimeout(settings, ms) {
46
+ const env = { ...(settings.env ?? {}) };
47
+ if (typeof env.MCP_TOOL_TIMEOUT === 'string' && env.MCP_TOOL_TIMEOUT !== '') {
48
+ return { settings, changed: false };
49
+ }
50
+ env.MCP_TOOL_TIMEOUT = String(ms);
51
+ return { settings: { ...settings, env }, changed: true };
52
+ }
53
+ /** 判断 Codex config.toml 是否已含 dingtalk-ask(幂等用)。 */
54
+ export function codexHasDingtalk(content) {
55
+ return content.includes(`[mcp_servers.${MCP_SERVER_NAME}]`);
56
+ }
57
+ /**
58
+ * 构造 Codex config.toml 追加块(TOML 基本字符串转义,无需 TOML 库)。
59
+ *
60
+ * @param config 已解析钉钉配置。
61
+ * @param nodePath 绝对 node 路径。
62
+ * @param indexPath 绝对 dist/index.js 路径。
63
+ * @returns 可直接追加到 config.toml 末尾的文本块。
64
+ */
65
+ export function buildCodexBlock(config, nodePath, indexPath) {
66
+ const q = (v) => JSON.stringify(v);
67
+ const env = credEnv(config);
68
+ return [
69
+ '',
70
+ `[mcp_servers.${MCP_SERVER_NAME}]`,
71
+ `command = ${q(nodePath)}`,
72
+ `args = [${q(indexPath)}]`,
73
+ 'startup_timeout_sec = 30',
74
+ 'tool_timeout_sec = 1500',
75
+ '',
76
+ `[mcp_servers.${MCP_SERVER_NAME}.env]`,
77
+ ...Object.entries(env).map(([k, v]) => `${k} = ${q(v)}`),
78
+ '',
79
+ ].join('\n');
80
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 一键安装 Claude Code 的 PermissionRequest 钩子到 settings.json(bin 入口)。
4
+ *
5
+ * 用法:
6
+ * dingtalk-ask-install-hook # 装到 <cwd>/.claude/settings.json(项目级)
7
+ * dingtalk-ask-install-hook --global # 装到 ~/.claude/settings.json(用户级)
8
+ * dingtalk-ask-install-hook <path> # 装到指定 settings.json
9
+ *
10
+ * 纯逻辑在 installHookCore.ts;本文件仅无条件调用主流程(全局命令经 shim 调用时也能跑)。
11
+ */
12
+ import { homedir } from 'node:os';
13
+ import { runInstallHook } from './installHookCore.js';
14
+ runInstallHook(process.argv.slice(2), process.cwd(), homedir());
@@ -0,0 +1,97 @@
1
+ /**
2
+ * install-hook 的纯逻辑与主流程(无 shebang、可被测试安全 import,不含"直接运行"副作用)。
3
+ *
4
+ * bin 入口 installHook.ts 只 import runInstallHook 并无条件调用——避免全局命令经 npm shim
5
+ * 调用时 process.argv[1] 与 import.meta.url 对不上导致 main 不执行。
6
+ */
7
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { join, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ /** 钩子内部超时(毫秒),与 settings 钩子 timeout 对齐。 */
11
+ const HOOK_TIMEOUT_MS = 60_000;
12
+ /** 解析目标 settings.json 路径(纯函数,便于单测)。 */
13
+ export function resolveSettingsPath(argv, cwd, home) {
14
+ const arg = argv[0];
15
+ if (arg === '--global') {
16
+ return join(home, '.claude', 'settings.json');
17
+ }
18
+ if (arg !== undefined && arg !== '') {
19
+ return arg;
20
+ }
21
+ return join(cwd, '.claude', 'settings.json');
22
+ }
23
+ /** dist/permissionHook.js 的绝对路径(与本文件同级)。 */
24
+ export function permissionHookPath() {
25
+ return fileURLToPath(new URL('./permissionHook.js', import.meta.url));
26
+ }
27
+ /**
28
+ * 把 PermissionRequest 钩子合并进 settings 对象(纯函数,便于单测)。
29
+ *
30
+ * @param settings 现有 settings 对象(可为空对象)。
31
+ * @param command 钩子命令串。
32
+ * @returns { settings: 合并后对象, changed: 是否有改动 }。
33
+ */
34
+ export function mergeHook(settings, command) {
35
+ const hooks = (settings.hooks ?? {});
36
+ const list = Array.isArray(hooks.PermissionRequest) ? [...hooks.PermissionRequest] : [];
37
+ // 幂等:任一条目的命令已含 permissionHook.js 则视为已安装。
38
+ const already = list.some((entry) => Array.isArray(entry.hooks) && entry.hooks.some((h) => typeof h.command === 'string' && h.command.includes('permissionHook.js')));
39
+ if (already) {
40
+ return { settings, changed: false };
41
+ }
42
+ list.push({ matcher: 'Bash', hooks: [{ type: 'command', command, timeout: HOOK_TIMEOUT_MS }] });
43
+ return {
44
+ settings: { ...settings, hooks: { ...hooks, PermissionRequest: list } },
45
+ changed: true,
46
+ };
47
+ }
48
+ /** 读取 settings.json;不存在返回空对象;解析失败抛错(不覆盖用户文件)。 */
49
+ function readSettings(path) {
50
+ let raw;
51
+ try {
52
+ raw = readFileSync(path, 'utf8');
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ if (raw.trim() === '') {
58
+ return {};
59
+ }
60
+ try {
61
+ return JSON.parse(raw);
62
+ }
63
+ catch (error) {
64
+ throw new Error(`${path} 不是合法 JSON(${error instanceof Error ? error.message : String(error)}),已中止,未改动。`);
65
+ }
66
+ }
67
+ /**
68
+ * install-hook 主流程:解析路径 → 合并钩子 → 写回 → 打印后续提示。
69
+ *
70
+ * @param argv CLI 参数(process.argv.slice(2))。
71
+ * @param cwd 当前工作目录。
72
+ * @param home 用户主目录。
73
+ */
74
+ export function runInstallHook(argv, cwd, home) {
75
+ const settingsPath = resolveSettingsPath(argv, cwd, home);
76
+ const command = `node "${permissionHookPath()}"`;
77
+ let settings;
78
+ try {
79
+ settings = readSettings(settingsPath);
80
+ }
81
+ catch (error) {
82
+ console.error(error instanceof Error ? error.message : String(error));
83
+ process.exit(1);
84
+ }
85
+ const { settings: merged, changed } = mergeHook(settings, command);
86
+ if (!changed) {
87
+ console.log(`PermissionRequest 钩子已存在于 ${settingsPath},无需重复安装。`);
88
+ return;
89
+ }
90
+ mkdirSync(dirname(settingsPath), { recursive: true });
91
+ writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
92
+ console.log(`✅ 已安装 PermissionRequest 钩子到 ${settingsPath}`);
93
+ console.log(` command: ${command}`);
94
+ console.log('\n还需两步(若未做):');
95
+ console.log(' 1. 配 .mcp.json 的 dingtalk-ask server(含 DINGTALK_* env,或用 CLI 参数传配置)。');
96
+ console.log(' 2. Claude Code 设 MCP_TOOL_TIMEOUT(如 settings.local.json 的 env)给阻塞式工具留时间。');
97
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PermissionRequest 钩子的纯判定逻辑(无 I/O,便于单测)。
3
+ *
4
+ * 拆出两件事:
5
+ * 1. buildPermissionQuestion:把 命令 + 目录 组装成推给用户的问题正文。
6
+ * 2. decideFromReply:把 /ask 端点返回的 { kind, value } 映射到钩子该做的三态决定。
7
+ *
8
+ * 安全铁律(design-cc-permission-hook.md):只有明确的"允许"才 allow;
9
+ * 其余一切(拒绝、超时、歧义、空、错误)都不能误放行——要么 deny、要么回落,绝不 allow。
10
+ */
11
+ /** 明确表示"允许"的回复词(trim + 小写后精确匹配)。 */
12
+ const ALLOW_WORDS = new Set(['允许', '同意', '是', '可以', 'y', 'yes', '1', 'allow', 'ok']);
13
+ /** 明确表示"拒绝"的回复词(trim + 小写后精确匹配)。 */
14
+ const DENY_WORDS = new Set(['拒绝', '否', '不', '不行', 'n', 'no', '2', 'deny']);
15
+ /**
16
+ * 组装推送给用户的审批问题正文。
17
+ *
18
+ * @param command 待批准执行的命令原文。
19
+ * @param cwd 命令的工作目录(可能缺省)。
20
+ * @returns 形如 "是否允许执行命令?\n$ <cmd>\n(目录: <cwd>)" 的问题串。
21
+ */
22
+ export function buildPermissionQuestion(command, cwd) {
23
+ const dirLine = cwd !== undefined && cwd !== '' ? `\n(目录: ${cwd})` : '';
24
+ return `是否允许执行命令?\n$ ${command}${dirLine}`;
25
+ }
26
+ /**
27
+ * 把 /ask 响应映射为钩子决定。
28
+ *
29
+ * 判定规则:
30
+ * - kind==="timeout" → fallthrough(超时绝不放行,回落让用户在终端处理)。
31
+ * - kind==="answered":
32
+ * · value 命中允许词 → allow。
33
+ * · value 命中拒绝词 → deny(message 用回复原文,空则用默认理由)。
34
+ * · 其它(自由文本/歧义/空)→ fallthrough(安全兜底,绝不误放行)。
35
+ *
36
+ * @param res /ask 端点返回的结果。
37
+ * @returns 钩子应执行的三态决定。
38
+ */
39
+ export function decideFromReply(res) {
40
+ if (res.kind !== 'answered') {
41
+ return { behavior: 'fallthrough' };
42
+ }
43
+ const raw = (res.value ?? '').trim();
44
+ if (raw === '') {
45
+ return { behavior: 'fallthrough' };
46
+ }
47
+ const normalized = raw.toLowerCase();
48
+ if (ALLOW_WORDS.has(normalized)) {
49
+ return { behavior: 'allow' };
50
+ }
51
+ if (DENY_WORDS.has(normalized)) {
52
+ // 拒绝理由优先用用户回复原文(可能含解释),否则给默认。
53
+ return { behavior: 'deny', message: raw === '' ? '用户在钉钉拒绝执行该命令。' : `用户在钉钉拒绝:${raw}` };
54
+ }
55
+ // 歧义/自由文本:不敢自作主张放行,也不硬拦,回落终端由用户定夺。
56
+ return { behavior: 'fallthrough' };
57
+ }
58
+ /**
59
+ * 把三态决定序列化为 Claude Code PermissionRequest 钩子的 stdout 输出。
60
+ *
61
+ * @param decision 三态决定。
62
+ * @returns 应写入 stdout 的字符串;fallthrough 返回空串(→ 回落)。
63
+ */
64
+ export function serializeDecision(decision) {
65
+ if (decision.behavior === 'fallthrough') {
66
+ return '';
67
+ }
68
+ if (decision.behavior === 'allow') {
69
+ return JSON.stringify({
70
+ hookSpecificOutput: {
71
+ hookEventName: 'PermissionRequest',
72
+ decision: { behavior: 'allow' },
73
+ },
74
+ });
75
+ }
76
+ return JSON.stringify({
77
+ hookSpecificOutput: {
78
+ hookEventName: 'PermissionRequest',
79
+ decision: { behavior: 'deny', message: decision.message },
80
+ },
81
+ });
82
+ }