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>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-12 00:44:35 +08:00
parent 3c5549e221
commit e58ec4f649
3 changed files with 143 additions and 30 deletions

5
.gitignore vendored
View File

@ -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/

View File

@ -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 = '<!-- @generated stub from scan-missing-imports -->'
@ -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<string> {
for (const root of roots) {
if (!existsSync(root)) continue
yield* walk(root)
}
}
async function main() {
const missing = new Map<string, Set<string>>() // 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_ROOTSsrc/、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)

View File

@ -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 <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [ CLI ...]
* 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
* server-launcher.ts / cli-launcher.ts
* src/server/index.ts process.argv splice
* --app-root mode launcher-only
* src/server/index.tssrc/entrypoints/cli.tsx
* adapters/feishu/index.ts process.argv / process.env
* splice --app-rootmode--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<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[] } {