mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
3c5549e221
commit
e58ec4f649
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/
|
||||
|
||||
|
||||
@ -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_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)
|
||||
|
||||
@ -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.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<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')
|
||||
// 副作用 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[] } {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user