@tunnelbox/claude-code 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/README.md +120 -0
- package/dist/index.mjs +1351 -0
- package/official-plugin/.claude-plugin/plugin.json +10 -0
- package/official-plugin/bin/tunnelbox +4 -0
- package/official-plugin/bin/tunnelbox.cmd +3 -0
- package/official-plugin/bin/tunnelbox.mjs +274 -0
- package/official-plugin/commands/pair.md +10 -0
- package/official-plugin/commands/status.md +9 -0
- package/official-plugin/hooks/hooks.json +16 -0
- package/official-plugin/skills/pair/SKILL.md +12 -0
- package/package.json +32 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tunnelbox 官方 Claude Code 插件 的 bin 工具(自包含 Node,无第三方依赖)。
|
|
4
|
+
*
|
|
5
|
+
* 子命令:
|
|
6
|
+
* pair —— 向中继申请配对码并打印(二维码可选由 qrcode 提供,缺省降级为链接)
|
|
7
|
+
* status —— 打印中继/agent/绑定状态
|
|
8
|
+
* ask —— 供 hooks/PermissionRequest 调用:把当回合工具审批转发到手机
|
|
9
|
+
*
|
|
10
|
+
* 状态:优先存 ${CLAUDE_PLUGIN_DATA}(官方注入的插件持久目录),否则 ~/.config/opencode/official-plugin.json。
|
|
11
|
+
* 环境:TUNNELBOX_RELAY_URL 可覆盖中继地址。
|
|
12
|
+
* 通道约定:SDK 桥在 options.env 注入 TUNNELBOX_SDK_SESSION=1 → 本脚本“不拦截直接退出”,
|
|
13
|
+
* 审批交由 SDK 的 canUseTool(避免 PermissionRequest hook 与 canUseTool 双弹)。
|
|
14
|
+
* 独立 `claude --plugin-dir` 会话无该标记时才做手机转发;未配对/中继不可达时不输出(交本地询问)。
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir, hostname } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_RELAY = process.env.TUNNELBOX_RELAY_URL || "ws://127.0.0.1:8080";
|
|
22
|
+
const AGENT_TYPE = "claude-code";
|
|
23
|
+
const TIMEOUT_MS = 120000;
|
|
24
|
+
|
|
25
|
+
function stateDir() {
|
|
26
|
+
if (process.env.CLAUDE_PLUGIN_DATA) return process.env.CLAUDE_PLUGIN_DATA;
|
|
27
|
+
return join(homedir(), ".config", "opencode");
|
|
28
|
+
}
|
|
29
|
+
const STATE_PATH = () => join(stateDir(), "official-plugin.json");
|
|
30
|
+
const PAIRING_PATH = () => join(stateDir(), "official-plugin-pairing.txt");
|
|
31
|
+
|
|
32
|
+
function loadState() {
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(STATE_PATH())) {
|
|
35
|
+
const o = JSON.parse(readFileSync(STATE_PATH(), "utf8"));
|
|
36
|
+
if (o && o.agentId) return { agentId: o.agentId, relayUrl: o.relayUrl || DEFAULT_RELAY, claimed: !!o.claimed };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
const s = { agentId: randomBytes(16).toString("hex"), relayUrl: DEFAULT_RELAY, claimed: false };
|
|
42
|
+
saveState(s);
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
function saveState(s) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirname(STATE_PATH()), { recursive: true });
|
|
48
|
+
writeFileSync(STATE_PATH(), JSON.stringify(s, null, 2), "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function envelope(type, payload) {
|
|
55
|
+
return { type, payload, ts: Date.now() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 建立到中继的出站 WS,返回 { ready, send, close, wait }。 */
|
|
59
|
+
function connect(url, agentId) {
|
|
60
|
+
const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentId)}`;
|
|
61
|
+
const ws = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${agentId}` } });
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const listeners = new Map();
|
|
64
|
+
let opened = false;
|
|
65
|
+
ws.onopen = () => {
|
|
66
|
+
opened = true;
|
|
67
|
+
resolve({
|
|
68
|
+
isOpen: () => opened && ws.readyState === 1,
|
|
69
|
+
send: (env) => {
|
|
70
|
+
if (opened && ws.readyState === 1) {
|
|
71
|
+
ws.send(JSON.stringify(env));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
},
|
|
76
|
+
on: (type, fn) => {
|
|
77
|
+
const arr = listeners.get(type) || [];
|
|
78
|
+
arr.push(fn);
|
|
79
|
+
listeners.set(type, arr);
|
|
80
|
+
},
|
|
81
|
+
close: () => {
|
|
82
|
+
try {
|
|
83
|
+
ws.close();
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
ws.onerror = () => {
|
|
91
|
+
if (!opened) {
|
|
92
|
+
resolve({
|
|
93
|
+
isOpen: () => false,
|
|
94
|
+
send: () => false,
|
|
95
|
+
on: () => {},
|
|
96
|
+
close: () => {},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
ws.onclose = () => {};
|
|
101
|
+
ws.onmessage = (ev) => {
|
|
102
|
+
let msg;
|
|
103
|
+
try {
|
|
104
|
+
msg = JSON.parse(String(ev.data));
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const fns = listeners.get(msg.type) || [];
|
|
109
|
+
for (const fn of fns) fn(msg);
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runPair() {
|
|
115
|
+
const state = loadState();
|
|
116
|
+
const relay = state.relayUrl.replace(/\/+$/, "");
|
|
117
|
+
const httpBase = relay.replace(/^ws/, "http");
|
|
118
|
+
const link = (code) => {
|
|
119
|
+
const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
|
|
120
|
+
return `${httpBase}/app/#/pages/index/index?${p.toString()}`;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (state.claimed) {
|
|
124
|
+
console.log("本机已绑定手机账号,可直接在手机「我的电脑」里连接;如需重新配对,请先在手机端解绑。");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const c = await connect(`${relay}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
|
|
129
|
+
if (!c.isOpen()) {
|
|
130
|
+
console.error("无法连接中继:" + relay + "\n请设置 TUNNELBOX_RELAY_URL 或在官方-plugin 状态文件中配置。");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
c.send(envelope("agent.info", {
|
|
134
|
+
name: hostname(),
|
|
135
|
+
version: "plugin",
|
|
136
|
+
type: AGENT_TYPE,
|
|
137
|
+
capabilities: { streaming: false, thinking: false, permission: true, commands: false, abort: false },
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
const t = setTimeout(() => resolve(), 10000);
|
|
142
|
+
c.on("pair.created", (m) => {
|
|
143
|
+
const code = m.payload?.code;
|
|
144
|
+
if (!code) return;
|
|
145
|
+
clearTimeout(t);
|
|
146
|
+
const l = link(code);
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log("tunnelbox 手机配对");
|
|
149
|
+
console.log(" 配对码: " + code);
|
|
150
|
+
console.log(" 链接 : " + l);
|
|
151
|
+
console.log("手机浏览器打开链接,或打开 tunnelbox 应用输入配对码(一次性)。");
|
|
152
|
+
try {
|
|
153
|
+
mkdirSync(dirname(PAIRING_PATH()), { recursive: true });
|
|
154
|
+
writeFileSync(PAIRING_PATH(), `配对码: ${code}\n配对链接: ${l}\n`, "utf8");
|
|
155
|
+
} catch {
|
|
156
|
+
/* ignore */
|
|
157
|
+
}
|
|
158
|
+
console.log(` 配对信息已写入: ${PAIRING_PATH()}`);
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
c.on("agent.claimed", () => {
|
|
162
|
+
state.claimed = true;
|
|
163
|
+
saveState(state);
|
|
164
|
+
});
|
|
165
|
+
if (!c.send(envelope("pair.create", {}))) {
|
|
166
|
+
clearTimeout(t);
|
|
167
|
+
console.error("向中继申请配对码失败。");
|
|
168
|
+
resolve();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
c.close();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function runStatus() {
|
|
175
|
+
const state = loadState();
|
|
176
|
+
console.log(`中继: ${state.relayUrl}`);
|
|
177
|
+
console.log(`agent 标识: ${state.agentId}`);
|
|
178
|
+
console.log(`已绑定手机: ${state.claimed ? "是" : "否"}`);
|
|
179
|
+
console.log(`状态文件: ${STATE_PATH()}`);
|
|
180
|
+
if (existsSync(PAIRING_PATH())) {
|
|
181
|
+
console.log("最新配对信息:");
|
|
182
|
+
console.log(readFileSync(PAIRING_PATH(), "utf8").trim());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runAsk() {
|
|
187
|
+
// SDK 桥已接管审批 → 不拦截,让 canUseTool 处理
|
|
188
|
+
if (process.env.TUNNELBOX_SDK_SESSION === "1") return;
|
|
189
|
+
|
|
190
|
+
let input = "";
|
|
191
|
+
try {
|
|
192
|
+
const chunks = [];
|
|
193
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
194
|
+
input = Buffer.concat(chunks).toString("utf8");
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
let req;
|
|
199
|
+
try {
|
|
200
|
+
req = JSON.parse(input);
|
|
201
|
+
} catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!req || typeof req !== "object") return;
|
|
205
|
+
|
|
206
|
+
const state = loadState();
|
|
207
|
+
// 未绑定 / 中继不可达 → 不输出,交本地询问
|
|
208
|
+
if (!state.claimed) return;
|
|
209
|
+
const tool = String(req.tool_name || req.toolName || "tool");
|
|
210
|
+
const toolInput = req.tool_input || req.toolInput || req.tool_use_input || {};
|
|
211
|
+
const id = `p-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
212
|
+
|
|
213
|
+
const c = await connect(`${state.relayUrl.replace(/\/+$/, "")}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
|
|
214
|
+
if (!c.isOpen()) return;
|
|
215
|
+
|
|
216
|
+
const sessionID = String(req.session_id || req.sessionId || req.project_dir || "local-session");
|
|
217
|
+
const prompt =
|
|
218
|
+
(typeof req.permission_decision_reason === "string" && req.permission_decision_reason) ||
|
|
219
|
+
`Claude Code 请求执行 ${tool}` +
|
|
220
|
+
(Object.keys(toolInput).length ? `\n${JSON.stringify(toolInput, null, 2)}` : "");
|
|
221
|
+
|
|
222
|
+
const decision = await new Promise((resolve) => {
|
|
223
|
+
let settled = false;
|
|
224
|
+
const done = (v) => {
|
|
225
|
+
if (settled) return;
|
|
226
|
+
settled = true;
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
resolve(v);
|
|
229
|
+
};
|
|
230
|
+
const timer = setTimeout(() => done({ permissionDecision: "deny", permissionDecisionReason: "审批超时(fail-closed)" }), TIMEOUT_MS);
|
|
231
|
+
c.on("permission.reply", (m) => {
|
|
232
|
+
const p = m.payload || {};
|
|
233
|
+
if (String(p.permissionID) !== id) return;
|
|
234
|
+
if (p.status === "allow") done({ permissionDecision: "allow", permissionDecisionReason: "手机已批准" });
|
|
235
|
+
else done({ permissionDecision: "deny", permissionDecisionReason: p.reason || "手机已拒绝" });
|
|
236
|
+
});
|
|
237
|
+
c.on("permission.replied", (m) => {
|
|
238
|
+
const p = m.payload || {};
|
|
239
|
+
if (String(p.permissionID) !== id) return;
|
|
240
|
+
if (p.status === "allow") done({ permissionDecision: "allow", permissionDecisionReason: "手机已批准" });
|
|
241
|
+
else done({ permissionDecision: "deny", permissionDecisionReason: "手机已拒绝" });
|
|
242
|
+
});
|
|
243
|
+
c.send(
|
|
244
|
+
envelope("permission.request", {
|
|
245
|
+
id,
|
|
246
|
+
sessionID,
|
|
247
|
+
tool,
|
|
248
|
+
args: toolInput,
|
|
249
|
+
prompt,
|
|
250
|
+
createdAt: Date.now(),
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
c.close();
|
|
255
|
+
process.stdout.write(JSON.stringify(decision) + "\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function main() {
|
|
259
|
+
const cmd = process.argv[2] || "";
|
|
260
|
+
if (cmd === "pair") await runPair();
|
|
261
|
+
else if (cmd === "status") runStatus();
|
|
262
|
+
else if (cmd === "ask") await runAsk();
|
|
263
|
+
else {
|
|
264
|
+
console.log("用法: tunnelbox <pair|status|ask>");
|
|
265
|
+
console.log(" pair 取手机配对码并打印");
|
|
266
|
+
console.log(" status 显示中继/绑定状态");
|
|
267
|
+
console.log(" ask 供 PermissionRequest hook 调用(把审批转发到手机)");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
main().catch((e) => {
|
|
272
|
+
console.error(e && e.message ? e.message : String(e));
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PermissionRequest": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash|Edit|Write|WebFetch|WebSearch|NotebookEdit|Task|MCP.*",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node",
|
|
10
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/bin/tunnelbox.mjs", "ask"]
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: tunnelbox 手机配对。当用户询问手机远程连接/配对/扫码/如何用手机控制时自动调用。
|
|
3
|
+
disable-model-invocation: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# tunnelbox 手机配对
|
|
7
|
+
|
|
8
|
+
本技能用于把本机 Claude Code 绑定到手机上的 tunnelbox。
|
|
9
|
+
|
|
10
|
+
1. 运行命令:`tunnelbox pair`
|
|
11
|
+
2. 把命令输出的**配对码**与**配对链接**完整、原样展示给用户(保留代码格式)。
|
|
12
|
+
3. 向用户说明:手机浏览器打开链接,或打开 tunnelbox 应用输入配对码;配对码为一次性,使用后即失效。
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tunnelbox/claude-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "tunnelbox 的 Claude Code 集成(官方 Agent SDK 桥 + 官方插件 official-plugin):手机远程驱动 Claude Code(会话/流式/审批/中止)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"official-plugin",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"type-check": "tsc --noEmit",
|
|
14
|
+
"build": "node scripts/build.mjs",
|
|
15
|
+
"start": "node dist/index.mjs",
|
|
16
|
+
"smoke": "node scripts/smoke.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.258",
|
|
23
|
+
"qrcode": "^1.5.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@tunnelbox/core": "0.1.0",
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"@types/qrcode": "^1.5.5",
|
|
29
|
+
"esbuild": "^0.24.0",
|
|
30
|
+
"typescript": "^5.4.5"
|
|
31
|
+
}
|
|
32
|
+
}
|