From e58ec4f64925f243f0d75ccc3e76e8bb83c66e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 12 Apr 2026 00:44:35 +0800 Subject: [PATCH] experiment(desktop): add adapters mode to claude-sidecar (Feishu + Telegram) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Feishu and Telegram IM adapters used to be standalone Bun processes that the user had to launch manually with bun + .ts source on disk — which meant they were effectively unreachable from the bundled desktop app, since the user wouldn't have bun or the source tree. This adds them as a third mode of the merged claude-sidecar binary: claude-sidecar adapters --app-root [--feishu] [--telegram] The launcher pre-checks credentials via the same `loadConfig()` the adapters use internally, then conditionally `await import()`s each enabled adapter whose creds are present. Adapters with missing creds are warned and skipped, so a partially-configured user (e.g. only Feishu set up, no Telegram bot token) still gets the working adapter started cleanly instead of having Telegram's top-level `process.exit(1)` kill the whole process. Adapter source code is unchanged — the adapters still self-start at top-level via Lark.WSClient.start() / grammy bot.start(). Their SIGINT handlers also still register independently. The only thing gating runtime is whether bun's static-import follows the dynamic specifier into adapters/feishu/index.ts and adapters/telegram/index.ts, which it does. Bundle impact ============= metric P0+P2 only +adapters mode delta claude-sidecar 66 MB 68 MB +2 MB .app total 87 MB 88 MB +1 MB .dmg 37 MB 37 MB 0 MB Both adapter SDKs (@larksuiteoapi/node-sdk and grammy) statically inline into the binary at a +2 MB cost, fully absorbed by DMG compression. Compared to the alternative (a separate ~60 MB sidecar binary per adapter, or even one combined ~60 MB adapter binary) this is essentially free. Verification ============ * `claude-sidecar server` regression test still passes (boots, /api/sessions → 200, CronScheduler runs) * `claude-sidecar cli --version` returns 999.0.0-local * `claude-sidecar adapters` (no flags) → exit 2 with usage error * `claude-sidecar adapters --feishu --telegram` (no creds) → both warned and skipped, exit 1 * `claude-sidecar adapters --feishu` (FEISHU_APP_ID=cli_fake creds) → Feishu adapter boots, Lark client `client ready`, attempts API connect, fails with 400 from feishu API and gracefully retries (correct behavior — fake creds) * `claude-sidecar adapters --telegram` (TELEGRAM_BOT_TOKEN=fake:token) → grammy bot.start() called, getMe API hits with 404, throws GrammyError (correct — fake creds) * `bun test adapters/` → 299 pass / 0 fail * `bun test src/` → 358 pass / 45 fail / 2 errors, identical to baseline Scanner change ============== desktop/scripts/scan-missing-imports.ts now also walks adapters/ in addition to src/, so any future feature() gated stubs in the adapter tree get auto-stubbed. As of this commit, adapters/ has 0 missing imports — all clean. Next step (UI integration, not done here) ========================================== To actually wire this into the desktop UX, the Tauri main process needs: - A "Configure IM adapters" settings page (App ID/Secret, bot token, allowed users) that writes ~/.claude/adapters.json - A "Start/stop Feishu" / "Start/stop Telegram" toggle that spawns `claude-sidecar adapters --feishu` (or both) as a managed sidecar, monitors lifecycle, restarts on crash The runtime infrastructure is now in place — that work is purely UI + Rust spawn glue and can be done independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 + desktop/scripts/scan-missing-imports.ts | 24 +++- desktop/sidecars/claude-sidecar.ts | 144 +++++++++++++++++++----- 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index cd1e779b..4713b5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ node_modules .runtime/ extracted-natives/ +# Adapter attachment cache (created at runtime by AttachmentStore when +# running adapter tests or the feishu/telegram adapters locally) +.lark-attachments/ +.tg-attachments/ + # Playwright MCP snapshots & logs .playwright-mcp/ diff --git a/desktop/scripts/scan-missing-imports.ts b/desktop/scripts/scan-missing-imports.ts index 3f4db9b5..9cb4fc58 100644 --- a/desktop/scripts/scan-missing-imports.ts +++ b/desktop/scripts/scan-missing-imports.ts @@ -23,6 +23,11 @@ import path from 'node:path' const repoRoot = path.resolve(import.meta.dir, '../..') const srcRoot = path.join(repoRoot, 'src') +const adaptersRoot = path.join(repoRoot, 'adapters') + +// 扫描 + 创建 stub 时允许的根目录。stub 写到这些目录之外会被拒绝, +// 防止意外往 node_modules / 系统路径写文件。 +const ALLOWED_STUB_ROOTS = [srcRoot, adaptersRoot] const STUB_MARKER_TS = '// @generated stub from scan-missing-imports' const STUB_MARKER_TEXT = '' @@ -143,11 +148,18 @@ function pickStubContent(stubPath: string): { content: string; marker: string } return { content: TS_STUB_CONTENT, marker: STUB_MARKER_TS } } +async function* walkRoots(roots: string[]): AsyncGenerator { + for (const root of roots) { + if (!existsSync(root)) continue + yield* walk(root) + } +} + async function main() { const missing = new Map>() // stubPath → set of importers let scannedFiles = 0 - for await (const file of walk(srcRoot)) { + for await (const file of walkRoots([srcRoot, adaptersRoot])) { scannedFiles++ let contents: string try { @@ -184,9 +196,13 @@ async function main() { let createdCount = 0 let skippedCount = 0 for (const [stubPath, importers] of missing) { - // 安全检查:只在 src/ 下创建,且如果文件已存在但不是 stub 就跳过 - if (!stubPath.startsWith(srcRoot + path.sep)) { - console.warn(`[scan] skip out-of-src stub target: ${stubPath}`) + // 安全检查:只在 ALLOWED_STUB_ROOTS(src/、adapters/)下创建, + // 且如果文件已存在但不是 stub 就跳过 + const isAllowed = ALLOWED_STUB_ROOTS.some( + (root) => stubPath.startsWith(root + path.sep), + ) + if (!isAllowed) { + console.warn(`[scan] skip out-of-tree stub target: ${stubPath}`) continue } const { content, marker } = pickStubContent(stubPath) diff --git a/desktop/sidecars/claude-sidecar.ts b/desktop/sidecars/claude-sidecar.ts index 363abc67..db070432 100644 --- a/desktop/sidecars/claude-sidecar.ts +++ b/desktop/sidecars/claude-sidecar.ts @@ -1,45 +1,137 @@ /** * Claude Code 桌面端合并 sidecar 入口。 * - * 历史上 server 和 cli 是两个独立的 bun-compile 二进制,各自携带一份 - * ~55MB 的 bun runtime。把两份合并成一个二进制只保留一份 runtime, - * 是 P0 之后剩余空间的最大优化点。 + * 历史上 server / cli / IM adapters 是各自独立的进程。每个 bun-compile + * 二进制都要带一份 ~55MB 的 bun runtime,光这一项就重复占了 100MB+。 + * 把所有运行模式合并到同一个二进制里,runtime 只保留一份;调用方通过 + * 第一个 positional 参数选择模式: * - * 调用约定(必须由调用方传第一个 positional 参数选择模式): - * - * claude-sidecar server --app-root --host 127.0.0.1 --port 12345 - * claude-sidecar cli --app-root [其它 CLI 参数...] + * claude-sidecar server --app-root --host 127.0.0.1 --port 12345 + * claude-sidecar cli --app-root [其它 CLI 参数...] + * claude-sidecar adapters --app-root [--feishu] [--telegram] * * 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的 - * 子模块树,原因和 server-launcher.ts / cli-launcher.ts 注释一致 —— - * src/server/index.ts 顶层立刻读 process.argv,必须在它求值前 splice 掉 - * --app-root 和 mode 这些 launcher-only 参数。 + * 子模块树。原因:src/server/index.ts、src/entrypoints/cli.tsx、以及 + * adapters/feishu/index.ts 等顶层都会立即读 process.argv / process.env, + * 必须在它们求值前 splice 掉 --app-root、mode、--feishu/--telegram 这些 + * launcher-only 参数。 */ const rawArgs = process.argv.slice(2) if (rawArgs.length === 0) { - console.error('claude-sidecar: missing mode argument (expected "server" or "cli")') + console.error('claude-sidecar: missing mode argument (expected "server", "cli" or "adapters")') process.exit(2) } const mode = rawArgs[0]! const restArgs = rawArgs.slice(1) -const { appRoot, args } = parseLauncherArgs(restArgs) - -process.env.CLAUDE_APP_ROOT = appRoot -process.env.CALLER_DIR ||= process.cwd() -process.argv = [process.argv[0]!, process.argv[1]!, ...args] - -await import('../../preload.ts') - -if (mode === 'server') { - const { startServer } = await import('../../src/server/index.ts') - startServer() -} else if (mode === 'cli') { - await import('../../src/entrypoints/cli.tsx') +if (mode === 'adapters') { + await runAdapters(restArgs) } else { - console.error(`claude-sidecar: unknown mode "${mode}" (expected "server" or "cli")`) - process.exit(2) + const { appRoot, args } = parseLauncherArgs(restArgs) + + process.env.CLAUDE_APP_ROOT = appRoot + process.env.CALLER_DIR ||= process.cwd() + process.argv = [process.argv[0]!, process.argv[1]!, ...args] + + await import('../../preload.ts') + + if (mode === 'server') { + const { startServer } = await import('../../src/server/index.ts') + startServer() + } else if (mode === 'cli') { + await import('../../src/entrypoints/cli.tsx') + } else { + console.error(`claude-sidecar: unknown mode "${mode}" (expected "server", "cli" or "adapters")`) + process.exit(2) + } +} + +async function runAdapters(rawArgs: string[]): Promise { + // adapters 模式的参数解析独立于 server/cli —— 这里只接受 --feishu / + // --telegram 选择启用哪个适配器,再加可选的 --app-root(透传给 + // adapters/common/config.ts 内的 process.env 读取)。 + let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null + let enableFeishu = false + let enableTelegram = false + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i] + if (arg === '--app-root') { + appRoot = rawArgs[i + 1] ?? null + i += 1 + continue + } + if (arg === '--feishu') { + enableFeishu = true + continue + } + if (arg === '--telegram') { + enableTelegram = true + continue + } + console.warn(`claude-sidecar adapters: ignoring unknown arg "${arg}"`) + } + + if (!enableFeishu && !enableTelegram) { + console.error( + 'claude-sidecar adapters: must enable at least one of --feishu / --telegram', + ) + process.exit(2) + } + + if (appRoot) { + process.env.CLAUDE_APP_ROOT = appRoot + } + process.env.CALLER_DIR ||= process.cwd() + + await import('../../preload.ts') + + // 在 import adapter 之前先用同一份 loadConfig() 检查凭据。adapter 的 + // top-level 代码里已经有 if (!cred) process.exit(1),但那会把整个 + // 进程拖死 —— 包括另一个本来正常的 adapter。这里提前 gate 一下, + // 缺凭据的 adapter 直接跳过、不 import。 + const { loadConfig } = await import('../../adapters/common/config.ts') + const config = loadConfig() + + let started = 0 + + if (enableFeishu) { + if (!config.feishu.appId || !config.feishu.appSecret) { + console.warn( + '[claude-sidecar] --feishu requested but FEISHU_APP_ID / FEISHU_APP_SECRET missing in env or ~/.claude/adapters.json — skipping', + ) + } else { + console.log('[claude-sidecar] starting Feishu adapter') + // 副作用 import:feishu/index.ts 顶层会自动 new WSClient + start() + await import('../../adapters/feishu/index.ts') + started += 1 + } + } + + if (enableTelegram) { + if (!config.telegram.botToken) { + console.warn( + '[claude-sidecar] --telegram requested but TELEGRAM_BOT_TOKEN missing in env or ~/.claude/adapters.json — skipping', + ) + } else { + console.log('[claude-sidecar] starting Telegram adapter') + // 副作用 import:telegram/index.ts 顶层会自动 bot.start() + await import('../../adapters/telegram/index.ts') + started += 1 + } + } + + if (started === 0) { + console.error( + '[claude-sidecar] no adapter could be started — check credentials in env or ~/.claude/adapters.json', + ) + process.exit(1) + } + + // 让进程保持存活:两个 adapter 都通过 long-lived WebSocket(Lark WSClient + // / grammY long-polling)持有 event loop,自然不会退出。这里不需要额外 + // setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。 } function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {