cc-haha/desktop/sidecars/claude-sidecar.ts
程序员阿江(Relakkes) e58ec4f649 experiment(desktop): add adapters mode to claude-sidecar (Feishu + Telegram)
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 <path> [--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) <noreply@anthropic.com>
2026-04-12 00:44:35 +08:00

157 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Claude Code 桌面端合并 sidecar 入口。
*
* 历史上 server / cli / IM adapters 是各自独立的进程。每个 bun-compile
* 二进制都要带一份 ~55MB 的 bun runtime光这一项就重复占了 100MB+。
* 把所有运行模式合并到同一个二进制里runtime 只保留一份;调用方通过
* 第一个 positional 参数选择模式:
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [其它 CLI 参数...]
* claude-sidecar adapters --app-root <path> [--feishu] [--telegram]
*
* 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的
* 子模块树。原因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", "cli" or "adapters")')
process.exit(2)
}
const mode = rawArgs[0]!
const restArgs = rawArgs.slice(1)
if (mode === 'adapters') {
await runAdapters(restArgs)
} else {
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<void> {
// 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')
// 副作用 importfeishu/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')
// 副作用 importtelegram/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 WebSocketLark WSClient
// / grammY long-polling持有 event loop自然不会退出。这里不需要额外
// setInterval 兜底。两个 adapter 自己注册的 SIGINT handler 都会触发。
}
function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg!)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-sidecar')
}
return { appRoot, args: nextArgs }
}