
Claude To Im
- 1 installs
- Updated March 7, 2026
- deusyu/claude-to-im-skill
Bridges Claude Code to IM platforms (Telegram, Discord, Feishu/Lark) via a background daemon that forwards messages to sessions.
About
Runs and manages a daemon that bridges IM platforms to Claude Code sessions. A developer uses it to forward Telegram, Discord, or Feishu messages into Claude Code and manage the bridge lifecycle.
- Bridges Telegram, Discord, and Feishu/Lark to Claude Code
- Subcommands: setup, start, stop, status, logs, reconfigure, doctor
Claude To Im by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/deusyu/claude-to-im-skill --skill claude-to-imAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | March 7, 2026 |
| Repository | deusyu/claude-to-im-skill ↗ |
What it does
Bridges Claude Code to IM platforms (Telegram, Discord, Feishu/Lark) via a background daemon that forwards messages to sessions.
Files
Claude-to-IM Bridge Skill
You are managing the Claude-to-IM bridge. User data is stored at ~/.claude-to-im/.
First, locate the skill directory by finding this SKILL.md file:
- Use Glob with pattern
**/skills/**/claude-to-im/SKILL.mdto find its path, then derive the skill root directory from it. - Store that path mentally as SKILL_DIR for all subsequent file references.
Command parsing
Parse the user's intent from $ARGUMENTS into one of these subcommands:
| User says (examples) | Subcommand |
|---|---|
setup, configure, 配置 | setup |
start, start bridge, 启动, 启动桥接 | start |
stop, stop bridge, 停止, 停止桥接 | stop |
status, bridge status, 状态 | status |
logs, logs 200, 查看日志, 查看日志 200 | logs |
reconfigure, 修改配置 | reconfigure |
doctor, diagnose, 诊断 | doctor |
Extract optional numeric argument for logs (default 50).
IMPORTANT: Before asking users for any platform credentials, first read SKILL_DIR/references/setup-guides.md to get the detailed step-by-step guidance for that platform. Present the relevant guide text to the user via AskUserQuestion so they know exactly what to do.
Runtime detection
Before executing any subcommand, detect which environment you are running in:
1. Claude Code — AskUserQuestion tool is available. Use it for interactive setup wizards. 2. Codex / other — AskUserQuestion is NOT available. Fall back to non-interactive guidance: explain the steps, show SKILL_DIR/config.env.example, and ask the user to create ~/.claude-to-im/config.env manually.
You can test this by checking if AskUserQuestion is in your available tools list.
Config check (applies to start, stop, status, logs, reconfigure, doctor)
Before running any subcommand other than setup, check if ~/.claude-to-im/config.env exists:
- If it does NOT exist:
- In Claude Code: tell the user "No configuration found" and automatically start the
setupwizard using AskUserQuestion. - In Codex: tell the user "No configuration found. Please create
~/.claude-to-im/config.envbased on the example:" then show the contents ofSKILL_DIR/config.env.exampleand stop. Do NOT attempt to start the daemon. - If it exists: proceed with the requested subcommand.
Subcommands
setup
Run an interactive setup wizard. This subcommand requires AskUserQuestion. If it is not available (Codex environment), instead show the contents of SKILL_DIR/config.env.example with field-by-field explanations and instruct the user to create the config file manually.
When AskUserQuestion IS available, collect input one field at a time. After each answer, confirm the value back to the user (masking secrets to last 4 chars only) before moving to the next question.
Step 1 — Choose channels
Ask which channels to enable (telegram, discord, feishu). Accept comma-separated input. Briefly describe each:
- telegram — Best for personal use. Streaming preview, inline permission buttons.
- discord — Good for team use. Server/channel/user-level access control.
- feishu (Lark) — For Feishu/Lark teams. Event-based messaging.
Step 2 — Collect tokens per channel
For each enabled channel, read SKILL_DIR/references/setup-guides.md and present the relevant platform guide to the user. Collect one credential at a time:
- Telegram: Bot Token → confirm (masked) → Chat ID (see guide for how to get it) → confirm → Allowed User IDs (optional). Important: At least one of Chat ID or Allowed User IDs must be set, otherwise the bot will reject all messages.
- Discord: Bot Token → confirm (masked) → Allowed User IDs → Allowed Channel IDs (optional) → Allowed Guild IDs (optional). Important: At least one of Allowed User IDs or Allowed Channel IDs must be set, otherwise the bot will reject all messages (default-deny).
- Feishu: App ID → confirm → App Secret → confirm (masked) → Domain (optional) → Allowed User IDs (optional). Guide through all 4 steps (A: batch permissions, B: enable bot, C: events & callbacks with long connection, D: publish version).
Step 3 — General settings
Ask for runtime, default working directory, model, and mode:
- Runtime:
claude(default),codex,auto claude— uses Claude Code CLI + Claude Agent SDK (requiresclaudeCLI installed)codex— uses OpenAI Codex SDK (requirescodexCLI; auth viacodex auth loginorOPENAI_API_KEY)auto— tries Claude first, falls back to Codex if Claude CLI not found- Working Directory: default
$CWD - Model (optional): Leave blank to inherit the runtime's own default model. If the user wants to override, ask them to enter a model name. Do NOT hardcode or suggest specific model names — the available models change over time.
- Mode:
code(default),plan,ask
Step 4 — Write config and validate
1. Show a final summary table with all settings (secrets masked to last 4 chars) 2. Ask user to confirm before writing 3. Use Bash to create directory structure: mkdir -p ~/.claude-to-im/{data,logs,runtime,data/messages} 4. Use Write to create ~/.claude-to-im/config.env with all settings in KEY=VALUE format 5. Use Bash to set permissions: chmod 600 ~/.claude-to-im/config.env 6. Validate tokens:
- Telegram:
curl -s "https://api.telegram.org/bot${TOKEN}/getMe"— check for"ok":true - Feishu:
curl -s -X POST "${DOMAIN}/open-apis/auth/v3/tenant_access_token/internal" -H "Content-Type: application/json" -d '{"app_id":"...","app_secret":"..."}'— check for"code":0 - Discord: verify token matches format
[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+
7. Report results with a summary table. If any validation fails, explain what might be wrong and how to fix it. 8. On success, tell the user: "Setup complete! Run /claude-to-im start to start the bridge."
start
Pre-check: Verify ~/.claude-to-im/config.env exists (see "Config check" above). Do NOT proceed without it.
Run: bash "SKILL_DIR/scripts/daemon.sh" start
Show the output to the user. If it fails, tell the user:
- Run
doctorto diagnose:/claude-to-im doctor - Check recent logs:
/claude-to-im logs
stop
Run: bash "SKILL_DIR/scripts/daemon.sh" stop
status
Run: bash "SKILL_DIR/scripts/daemon.sh" status
logs
Extract optional line count N from arguments (default 50). Run: bash "SKILL_DIR/scripts/daemon.sh" logs N
reconfigure
1. Read current config from ~/.claude-to-im/config.env 2. Show current settings in a clear table format, with all secrets masked (only last 4 chars visible) 3. Use AskUserQuestion to ask what the user wants to change 4. When collecting new values, read SKILL_DIR/references/setup-guides.md and present the relevant guide for that field 5. Update the config file atomically (write to tmp, rename) 6. Re-validate any changed tokens 7. Remind user: "Run /claude-to-im stop then /claude-to-im start to apply the changes."
doctor
Run: bash "SKILL_DIR/scripts/doctor.sh"
Show results and suggest fixes for any failures. Common fixes:
- SDK cli.js missing →
cd SKILL_DIR && npm install - dist/daemon.mjs stale →
cd SKILL_DIR && npm run build - Config missing → run
setup
Notes
- Always mask secrets in output (show only last 4 characters)
- Never start the daemon without a valid config.env — always check first, redirect to setup or show config example
- The daemon runs as a background Node.js process managed by platform supervisor (launchd on macOS, setsid on Linux, WinSW/NSSM on Windows)
- Config persists at
~/.claude-to-im/config.env— survives across sessions
node_modules/
dist/
*.tgz
.env
config.env
# Claude-to-IM Bridge Configuration
# Copy to ~/.claude-to-im/config.env and edit
# Runtime backend: claude | codex | auto
# claude (default) — uses Claude Code CLI + @anthropic-ai/claude-agent-sdk
# codex — uses @openai/codex-sdk (auth: codex auth login, or OPENAI_API_KEY)
# auto — tries Claude first, falls back to Codex if CLI not found
CTI_RUNTIME=claude
# Enabled channels (comma-separated: telegram,discord,feishu)
CTI_ENABLED_CHANNELS=telegram
# Default working directory for Claude Code sessions
CTI_DEFAULT_WORKDIR=/path/to/your/project
# Default model (optional — inherits from runtime's own default if not set)
# CTI_DEFAULT_MODEL=
# Default mode (code, plan, ask)
CTI_DEFAULT_MODE=code
# ── Codex auth (optional — only if not using `codex auth login`) ──
# Priority: CTI_CODEX_API_KEY > CODEX_API_KEY > OPENAI_API_KEY
# CTI_CODEX_API_KEY=
# CTI_CODEX_BASE_URL=
# ── Telegram ──
CTI_TG_BOT_TOKEN=your-telegram-bot-token
# Chat ID for authorization (at least one of CHAT_ID or ALLOWED_USERS is required)
# Get it: send a message to the bot, then visit https://api.telegram.org/botYOUR_TOKEN/getUpdates
CTI_TG_CHAT_ID=your-chat-id
# CTI_TG_ALLOWED_USERS=user_id_1,user_id_2
# ── Discord ──
# CTI_DISCORD_BOT_TOKEN=your-discord-bot-token
# CTI_DISCORD_ALLOWED_USERS=user_id_1,user_id_2
# CTI_DISCORD_ALLOWED_CHANNELS=channel_id_1
# CTI_DISCORD_ALLOWED_GUILDS=guild_id_1
# ── Feishu / Lark ──
# CTI_FEISHU_APP_ID=your-app-id
# CTI_FEISHU_APP_SECRET=your-app-secret
# CTI_FEISHU_DOMAIN=https://open.feishu.cn
# CTI_FEISHU_ALLOWED_USERS=user_id_1,user_id_2
# ── Permission ──
# Auto-approve all tool permission requests without user confirmation.
# Useful for channels that lack interactive permission UI (e.g. Feishu
# WebSocket long-connection mode, where there is no HTTP webhook to
# render clickable approve/deny buttons).
# ⚠️ Only enable this in trusted, access-controlled environments.
# CTI_AUTO_APPROVE=true
MIT License
Copyright (c) 2024-2025 op7418
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "claude-to-im-skill",
"version": "0.1.0",
"description": "Claude Code Skill: Bridge IM platforms (Telegram, Discord, Feishu) to Claude Code sessions",
"type": "module",
"scripts": {
"build": "node scripts/build.js",
"typecheck": "tsc --noEmit",
"dev": "tsx src/main.ts",
"test": "CTI_HOME=$(mktemp -d) node --test --import tsx --test-timeout=15000 src/__tests__/*.test.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.62",
"claude-to-im": "github:op7418/claude-to-im"
},
"optionalDependencies": {
"@openai/codex-sdk": "^0.110.0"
},
"devDependencies": {
"@types/node": "^22",
"esbuild": "^0.25.0",
"tsx": "^4.21.0",
"typescript": "^5"
},
"engines": {
"node": ">=20"
}
}
Claude-to-IM Skill
将 Claude Code / Codex 桥接到 IM 平台 —— 在 Telegram、Discord 或飞书中与 AI 编程代理对话。
English
想要桌面图形界面? 试试 CodePilot —— 一个功能完整的桌面应用,提供可视化聊天界面、会话管理、文件树预览、权限控制等。本 Skill 从 CodePilot 的 IM 桥接模块中提取而来,适合偏好轻量级纯 CLI 方案的用户。
---
工作原理
本 Skill 运行一个后台守护进程,将你的 IM 机器人连接到 Claude Code 或 Codex 会话。来自 IM 的消息被转发给 AI 编程代理,响应(包括工具调用、权限请求、流式预览)会发回到聊天中。
你 (Telegram/Discord/飞书)
↕ Bot API
后台守护进程 (Node.js)
↕ Claude Agent SDK 或 Codex SDK(通过 CTI_RUNTIME 配置)
Claude Code / Codex → 读写你的代码库功能特点
- 三大 IM 平台 — Telegram、Discord、飞书,可任意组合启用
- 交互式配置 — 引导式向导逐步收集 token,附带详细获取说明
- 权限控制 — 工具调用需要在聊天中通过内联按钮明确批准
- 流式预览 — 实时查看 Claude 的输出(Telegram 和 Discord 支持)
- 会话持久化 — 对话在守护进程重启后保留
- 密钥保护 — token 以
chmod 600存储,日志中自动脱敏 - 无需编写代码 — 安装 Skill 后运行
/claude-to-im setup即可
前置要求
- Node.js >= 20
- Claude Code CLI(
CTI_RUNTIME=claude或auto时需要)— 已安装并完成认证(claude命令可用) - Codex CLI(
CTI_RUNTIME=codex或auto时需要)—npm install -g @openai/codex。鉴权:运行codex auth login,或设置OPENAI_API_KEY(可选,API 模式)
安装
npx skills(推荐)
npx skills add op7418/Claude-to-IM-skillGit 克隆
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.claude/skills/claude-to-im将仓库直接克隆到个人 Skills 目录,Claude Code 会自动发现。
符号链接方式
如果你想把仓库放在其他位置(比如方便开发):
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/code/Claude-to-IM-skill
mkdir -p ~/.claude/skills
ln -s ~/code/Claude-to-IM-skill ~/.claude/skills/claude-to-imCodex
如果你使用 Codex,直接克隆到 Codex skills 目录:
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.codex/skills/claude-to-im或使用提供的安装脚本,自动安装依赖并构建:
# 克隆并安装(复制模式)
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/code/Claude-to-IM-skill
bash ~/code/Claude-to-IM-skill/scripts/install-codex.sh
# 或使用符号链接模式(方便开发)
bash ~/code/Claude-to-IM-skill/scripts/install-codex.sh --link验证安装
Claude Code: 启动新会话,输入 / 应能看到 claude-to-im。也可以问 Claude:"What skills are available?"
Codex: 启动新会话,说 "claude-to-im setup" 或 "启动桥接",Codex 会识别 Skill 并运行配置向导。
快速开始
1. 配置
/claude-to-im setup向导会引导你完成以下步骤:
1. 选择渠道 — 选择 Telegram、Discord、飞书,或任意组合 2. 输入凭据 — 向导会详细说明如何获取每个 token、需要开启哪些设置、授予哪些权限 3. 设置默认值 — 工作目录、模型、模式 4. 验证 — 立即通过平台 API 验证 token 有效性
2. 启动
/claude-to-im start守护进程在后台启动。关闭终端后仍会继续运行。
3. 开始聊天
打开 IM 应用,给你的机器人发消息,Claude Code 会回复。
当 Claude 需要使用工具(编辑文件、运行命令)时,聊天中会弹出带有 允许 / 拒绝 按钮的权限请求。
命令列表
所有命令在 Claude Code 或 Codex 中执行:
| Claude Code | Codex(自然语言) | 说明 |
|---|---|---|
/claude-to-im setup | "claude-to-im setup" / "配置" | 交互式配置向导 |
/claude-to-im start | "start bridge" / "启动桥接" | 启动桥接守护进程 |
/claude-to-im stop | "stop bridge" / "停止桥接" | 停止守护进程 |
/claude-to-im status | "bridge status" / "状态" | 查看运行状态 |
/claude-to-im logs | "查看日志" | 查看最近 50 行日志 |
/claude-to-im logs 200 | "logs 200" | 查看最近 200 行日志 |
/claude-to-im reconfigure | "reconfigure" / "修改配置" | 交互式修改配置 |
/claude-to-im doctor | "doctor" / "诊断" | 诊断问题 |
平台配置指南
setup 向导会在每一步提供内联指引,以下是概要:
Telegram
1. 在 Telegram 中搜索 @BotFather → 发送 /newbot → 按提示操作 2. 复制 bot token(格式:123456789:AABbCc...) 3. 建议:/setprivacy → Disable(用于群组) 4. 获取 User ID:给 @userinfobot 发消息
Discord
1. 前往 Discord 开发者门户 → 新建应用 2. Bot 标签页 → Reset Token → 复制 token 3. 在 Privileged Gateway Intents 下开启 Message Content Intent 4. OAuth2 → URL Generator → scope 选 bot → 权限选 Send Messages、Read Message History、View Channels → 复制邀请链接
飞书 / Lark
1. 前往飞书开放平台(或 Lark) 2. 创建自建应用 → 获取 App ID 和 App Secret 3. 批量添加权限:进入"权限管理" → 使用批量配置添加所有必需权限(setup 向导提供完整 JSON) 4. 在"添加应用能力"中启用机器人 5. 事件与回调:选择长连接作为事件订阅方式 → 添加 im.message.receive_v1 事件 6. 发布:进入"版本管理与发布" → 创建版本 → 提交审核 → 在管理后台审核通过 7. 注意:版本审核通过并发布后机器人才能使用
架构
~/.claude-to-im/
├── config.env ← 凭据与配置 (chmod 600)
├── data/ ← 持久化 JSON 存储
│ ├── sessions.json
│ ├── bindings.json
│ ├── permissions.json
│ └── messages/ ← 按会话分文件的消息历史
├── logs/
│ └── bridge.log ← 自动轮转,密钥脱敏
└── runtime/
├── bridge.pid ← 守护进程 PID 文件
└── status.json ← 当前状态核心组件
| 组件 | 职责 |
|---|---|
src/main.ts | 守护进程入口,组装依赖注入,启动 bridge |
src/config.ts | 加载/保存 config.env,映射为 bridge 设置 |
src/store.ts | JSON 文件 BridgeStore(30 个方法,写穿缓存) |
src/llm-provider.ts | Claude Agent SDK query() → SSE 流 |
src/codex-provider.ts | Codex SDK runStreamed() → SSE 流 |
src/sse-utils.ts | 共享的 SSE 格式化辅助函数 |
src/permission-gateway.ts | 异步桥接:SDK canUseTool ↔ IM 按钮 |
src/logger.ts | 密钥脱敏的文件日志,支持轮转 |
scripts/daemon.sh | 进程管理(start/stop/status/logs) |
scripts/doctor.sh | 诊断检查 |
SKILL.md | Claude Code Skill 定义文件 |
权限流程
1. Claude 想使用工具(如编辑文件)
2. SDK 调用 canUseTool() → LLMProvider 发射 permission_request SSE 事件
3. Bridge 在 IM 聊天中发送内联按钮:[允许] [拒绝]
4. canUseTool() 阻塞等待用户响应(5 分钟超时)
5. 用户点击允许 → Bridge 解除权限等待
6. SDK 继续执行工具 → 结果流式发回 IM故障排查
运行诊断:
/claude-to-im doctor检查项目:Node.js 版本、配置文件是否存在及权限、token 有效性(实时 API 调用)、日志目录、PID 文件一致性、最近的错误。
| 问题 | 解决方案 |
|---|---|
Bridge 无法启动 | 运行 doctor,检查 Node 版本和日志 |
收不到消息 | 用 doctor 验证 token,检查允许用户配置 |
权限超时 | 用户 5 分钟内未响应,工具调用自动拒绝 |
PID 文件残留 | 运行 stop 再 start,脚本会自动清理 |
详见 references/troubleshooting.md。
安全
- 所有凭据存储在
~/.claude-to-im/config.env,权限chmod 600 - 日志输出中 token 自动脱敏(基于正则匹配)
- 允许用户/频道/服务器列表限制谁可以与机器人交互
- 守护进程是本地进程,没有入站网络监听
- 详见 SECURITY.md 了解威胁模型和应急响应
开发
npm install # 安装依赖
npm run dev # 开发模式运行
npm run typecheck # 类型检查
npm test # 运行测试
npm run build # 构建打包许可
MIT
Claude-to-IM Skill
Bridge Claude Code / Codex to IM platforms — chat with AI coding agents from Telegram, Discord, or Feishu/Lark.
中文文档
Want a desktop GUI instead? Check out CodePilot — a full-featured desktop app with visual chat interface, session management, file tree preview, permission controls, and more. This skill was extracted from CodePilot's IM bridge module for users who prefer a lightweight, CLI-only setup.
---
How It Works
This skill runs a background daemon that connects your IM bots to Claude Code or Codex sessions. Messages from IM are forwarded to the AI coding agent, and responses (including tool use, permission requests, streaming previews) are sent back to your chat.
You (Telegram/Discord/Feishu)
↕ Bot API
Background Daemon (Node.js)
↕ Claude Agent SDK or Codex SDK (configurable via CTI_RUNTIME)
Claude Code / Codex → reads/writes your codebaseFeatures
- Three IM platforms — Telegram, Discord, Feishu/Lark, enable any combination
- Interactive setup — guided wizard collects tokens with step-by-step instructions
- Permission control — tool calls require explicit approval via inline buttons in chat
- Streaming preview — see Claude's response as it types (Telegram & Discord)
- Session persistence — conversations survive daemon restarts
- Secret protection — tokens stored with
chmod 600, auto-redacted in all logs - Zero code required — install the skill and run
/claude-to-im setup, that's it
Prerequisites
- Node.js >= 20
- Claude Code CLI (for
CTI_RUNTIME=claudeorauto) — installed and authenticated (claudecommand available) - Codex CLI (for
CTI_RUNTIME=codexorauto) —npm install -g @openai/codex. Auth: runcodex auth login, or setOPENAI_API_KEY(optional, for API mode)
Installation
npx skills (recommended)
npx skills add op7418/Claude-to-IM-skillGit clone
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.claude/skills/claude-to-imClones the repo directly into your personal skills directory. Claude Code discovers it automatically.
Symlink
If you prefer to keep the repo elsewhere (e.g., for development):
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/code/Claude-to-IM-skill
mkdir -p ~/.claude/skills
ln -s ~/code/Claude-to-IM-skill ~/.claude/skills/claude-to-imCodex
If you use Codex, clone directly into the Codex skills directory:
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.codex/skills/claude-to-imOr use the provided install script for automatic dependency installation and build:
# Clone and install (copy mode)
git clone https://github.com/op7418/Claude-to-IM-skill.git ~/code/Claude-to-IM-skill
bash ~/code/Claude-to-IM-skill/scripts/install-codex.sh
# Or use symlink mode for development
bash ~/code/Claude-to-IM-skill/scripts/install-codex.sh --linkVerify installation
Claude Code: Start a new session and type / — you should see claude-to-im in the skill list. Or ask Claude: "What skills are available?"
Codex: Start a new session and say "claude-to-im setup" or "start bridge" — Codex will recognize the skill and run the setup wizard.
Quick Start
1. Setup
/claude-to-im setupThe wizard will guide you through:
1. Choose channels — pick Telegram, Discord, Feishu, or any combination 2. Enter credentials — the wizard explains exactly where to get each token, which settings to enable, and what permissions to grant 3. Set defaults — working directory, model, and mode 4. Validate — tokens are verified against platform APIs immediately
2. Start
/claude-to-im startThe daemon starts in the background. You can close the terminal — it keeps running.
3. Chat
Open your IM app and send a message to your bot. Claude Code will respond.
When Claude needs to use a tool (edit a file, run a command), you'll see a permission prompt with Allow / Deny buttons right in the chat.
Commands
All commands are run inside Claude Code or Codex:
| Claude Code | Codex (natural language) | Description |
|---|---|---|
/claude-to-im setup | "claude-to-im setup" / "配置" | Interactive setup wizard |
/claude-to-im start | "start bridge" / "启动桥接" | Start the bridge daemon |
/claude-to-im stop | "stop bridge" / "停止桥接" | Stop the bridge daemon |
/claude-to-im status | "bridge status" / "状态" | Show daemon status |
/claude-to-im logs | "查看日志" | Show last 50 log lines |
/claude-to-im logs 200 | "logs 200" | Show last 200 log lines |
/claude-to-im reconfigure | "reconfigure" / "修改配置" | Update config interactively |
/claude-to-im doctor | "doctor" / "诊断" | Diagnose issues |
Platform Setup Guides
The setup wizard provides inline guidance for every step. Here's a summary:
Telegram
1. Message @BotFather on Telegram → /newbot → follow prompts 2. Copy the bot token (format: 123456789:AABbCc...) 3. Recommended: /setprivacy → Disable (for group use) 4. Find your User ID: message @userinfobot
Discord
1. Go to Discord Developer Portal → New Application 2. Bot tab → Reset Token → copy it 3. Enable Message Content Intent under Privileged Gateway Intents 4. OAuth2 → URL Generator → scope bot → permissions: Send Messages, Read Message History, View Channels → copy invite URL
Feishu / Lark
1. Go to Feishu Open Platform (or Lark) 2. Create Custom App → get App ID and App Secret 3. Batch-add permissions: go to "Permissions & Scopes" → use batch configuration to add all required scopes (the setup wizard provides the exact JSON) 4. Enable Bot feature under "Add Features" 5. Events & Callbacks: select "Long Connection" as event dispatch method → add im.message.receive_v1 event 6. Publish: go to "Version Management & Release" → create version → submit for review → approve in Admin Console 7. Important: The bot will NOT work until the version is approved and published
Architecture
~/.claude-to-im/
├── config.env ← Credentials & settings (chmod 600)
├── data/ ← Persistent JSON storage
│ ├── sessions.json
│ ├── bindings.json
│ ├── permissions.json
│ └── messages/ ← Per-session message history
├── logs/
│ └── bridge.log ← Auto-rotated, secrets redacted
└── runtime/
├── bridge.pid ← Daemon PID file
└── status.json ← Current statusKey components
| Component | Role |
|---|---|
src/main.ts | Daemon entry — assembles DI, starts bridge |
src/config.ts | Load/save config.env, map to bridge settings |
src/store.ts | JSON file BridgeStore (30 methods, write-through cache) |
src/llm-provider.ts | Claude Agent SDK query() → SSE stream |
src/codex-provider.ts | Codex SDK runStreamed() → SSE stream |
src/sse-utils.ts | Shared SSE formatting helper |
src/permission-gateway.ts | Async bridge: SDK canUseTool ↔ IM buttons |
src/logger.ts | Secret-redacted file logging with rotation |
scripts/daemon.sh | Process management (start/stop/status/logs) |
scripts/doctor.sh | Health checks |
SKILL.md | Claude Code skill definition |
Permission flow
1. Claude wants to use a tool (e.g., Edit file)
2. SDK calls canUseTool() → LLMProvider emits permission_request SSE
3. Bridge sends inline buttons to IM chat: [Allow] [Deny]
4. canUseTool() blocks, waiting for user response (5 min timeout)
5. User taps Allow → bridge resolves the pending permission
6. SDK continues tool execution → result streamed back to IMTroubleshooting
Run diagnostics:
/claude-to-im doctorThis checks: Node.js version, config file existence and permissions, token validity (live API calls), log directory, PID file consistency, and recent errors.
| Issue | Solution |
|---|---|
Bridge won't start | Run doctor. Check if Node >= 20. Check logs. |
Messages not received | Verify token with doctor. Check allowed users config. |
Permission timeout | User didn't respond within 5 min. Tool call auto-denied. |
Stale PID file | Run stop then start. daemon.sh auto-cleans stale PIDs. |
See references/troubleshooting.md for more details.
Security
- All credentials stored in
~/.claude-to-im/config.envwithchmod 600 - Tokens are automatically redacted in all log output (pattern-based masking)
- Allowed user/channel/guild lists restrict who can interact with the bot
- The daemon is a local process with no inbound network listeners
- See SECURITY.md for threat model and incident response
Development
npm install # Install dependencies
npm run dev # Run in dev mode
npm run typecheck # Type check
npm test # Run tests
npm run build # Build bundleLicense
MIT
Platform Setup Guides
Detailed step-by-step guides for each IM platform. Referenced by the setup and reconfigure subcommands.
---
Telegram
Bot Token
How to get a Telegram Bot Token: 1. Open Telegram and search for @BotFather 2. Send /newbot to create a new bot 3. Follow the prompts: choose a display name and a username (must end in bot) 4. BotFather will reply with a token like 7823456789:AAF-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 5. Copy the full token and paste it here
Recommended bot settings (send these commands to @BotFather):
/setprivacy→ choose your bot →Disable(so the bot can read group messages, only needed for group use)/setcommands→ set commands likenew - Start new session,mode - Switch mode
Token format: 数字:字母数字字符串 (e.g. 7823456789:AAF-xxx...xxx)
Chat ID
How to get your Telegram Chat ID: 1. Start a chat with your bot (search for the bot's username and click Start) 2. Send any message to the bot (e.g. "hello") 3. Open this URL in your browser (replace YOUR_BOT_TOKEN with your actual bot token): https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates 4. In the JSON response, find "chat":{"id":123456789,...} — that number is your Chat ID 5. For group chats, the Chat ID is a negative number (e.g. -1001234567890)
Why this matters: The bot uses Chat ID for authorization. If neither Chat ID nor Allowed User IDs are configured, the bot will reject all incoming messages.
Allowed User IDs (optional)
How to find your Telegram User ID: 1. Search for @userinfobot on Telegram and start a chat 2. It will reply with your User ID (a number like 123456789) 3. Alternatively, forward a message from yourself to @userinfobot
Enter comma-separated IDs to restrict access (recommended for security). Leave empty to allow anyone who can message the bot.
---
Discord
Bot Token
How to create a Discord Bot and get the token: 1. Go to https://discord.com/developers/applications 2. Click "New Application" → give it a name → click "Create" 3. Go to the "Bot" tab on the left sidebar 4. Click "Reset Token" → copy the token (you can only see it once!)
Required bot settings (on the Bot tab):
- Under Privileged Gateway Intents, enable:
- ✅ Message Content Intent (required to read message text)
Invite the bot to your server: 1. Go to the "OAuth2" tab → "URL Generator" 2. Under Scopes, check: bot 3. Under Bot Permissions, check: Send Messages, Read Message History, View Channels 4. Copy the generated URL at the bottom and open it in your browser 5. Select the server and click "Authorize"
Token format: a long base64-like string (e.g. MTIzNDU2Nzg5.Gxxxxx.xxxxxxxxxxxxxxxxxxxxxxxx)
Allowed User IDs
How to find Discord User IDs: 1. In Discord, go to Settings → Advanced → enable Developer Mode 2. Right-click on any user → "Copy User ID"
Enter comma-separated IDs.
Why this matters: The bot uses a default-deny policy. If neither Allowed User IDs nor Allowed Channel IDs are configured, the bot will silently reject all incoming messages. You must set at least one.
Allowed Channel IDs (optional)
How to find Discord Channel IDs: 1. With Developer Mode enabled, right-click on any channel → "Copy Channel ID"
Enter comma-separated IDs to restrict the bot to specific channels. Leave empty to allow all channels the bot can see.
Allowed Guild (Server) IDs (optional)
How to find Discord Server IDs: 1. With Developer Mode enabled, right-click on the server icon → "Copy Server ID"
Enter comma-separated IDs. Leave empty to allow all servers the bot is in.
---
Feishu / Lark
App ID and App Secret
How to create a Feishu/Lark app and get credentials: 1. Go to Feishu: https://open.feishu.cn/app or Lark: https://open.larksuite.com/app 2. Click "Create Custom App" 3. Fill in the app name and description → click "Create" 4. On the app's "Credentials & Basic Info" page, find:
- App ID (like
cli_xxxxxxxxxx) - App Secret (click to reveal, like
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx)
Step A — Batch-add required permissions
1. On the app page, go to "Permissions & Scopes" 2. Instead of adding permissions one by one, use batch configuration: click the "Batch switch to configure by dependency" link (or find the JSON editor) 3. Paste the following JSON to add all required permissions at once:
{
"scopes": {
"tenant": [
"aily:file:read",
"aily:file:write",
"application:application.app_message_stats.overview:readonly",
"application:application:self_manage",
"application:bot.menu:write",
"contact:user.employee_id:readonly",
"corehr:file:download",
"event:ip_list",
"im:chat.access_event.bot_p2p_chat:read",
"im:chat.members:bot_access",
"im:message",
"im:message.group_at_msg:readonly",
"im:message.p2p_msg:readonly",
"im:message:readonly",
"im:message:send_as_bot",
"im:resource"
],
"user": [
"aily:file:read",
"aily:file:write",
"im:chat.access_event.bot_p2p_chat:read"
]
}
}4. Click "Save" to apply all permissions
Step B — Enable the bot
1. Go to "Add Features" → enable "Bot" 2. Set the bot name and description
Step C — Configure Events & Callbacks (long connection)
1. Go to "Events & Callbacks" in the left sidebar 2. Under "Event Dispatch Method", select "Long Connection" (长连接 / WebSocket mode) 3. Click "Add Event" and add these events:
im.message.receive_v1— Receive messagesp2p_chat_create— Bot added to chat (optional but recommended)
4. Click "Save"
Step D — Publish the app
1. Go to "Version Management & Release" → click "Create Version" 2. Fill in version number and update description → click "Save" 3. Click "Submit for Review" 4. For personal/test use, the admin can approve it directly in the Feishu Admin Console → App Review 5. Important: The bot will NOT respond to messages until the version is approved and published
Domain (optional)
Default: https://open.feishu.cn Use https://open.larksuite.com for Lark (international version). Leave empty to use the default Feishu domain.
Allowed User IDs (optional)
Feishu user IDs (open_id format like ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx). You can find them in the Feishu Admin Console under user profiles. Leave empty to allow all users who can message the bot.
Troubleshooting
Bridge won't start
Symptoms: /claude-to-im start fails or daemon exits immediately.
Steps:
1. Run /claude-to-im doctor to identify the issue 2. Check that Node.js >= 20 is installed: node --version 3. Check that Claude Code CLI is available: claude --version 4. Verify config exists: ls -la ~/.claude-to-im/config.env 5. Check logs for startup errors: /claude-to-im logs
Common causes:
- Missing or invalid config.env -- run
/claude-to-im setup - Node.js not found or wrong version -- install Node.js >= 20
- Port or resource conflict -- check if another instance is running with
/claude-to-im status
Messages not received
Symptoms: Bot is online but doesn't respond to messages.
Steps:
1. Verify the bot token is valid: /claude-to-im doctor 2. Check allowed user IDs in config -- if set, only listed users can interact 3. For Telegram: ensure you've sent /start to the bot first 4. For Discord: verify the bot has been invited to the server with message read permissions 5. For Feishu: confirm the app has been approved and event subscriptions are configured 6. Check logs for incoming message events: /claude-to-im logs 200
Permission timeout
Symptoms: Claude Code session starts but times out waiting for tool approval.
Steps:
1. The bridge runs Claude Code in non-interactive mode; ensure your Claude Code configuration allows the necessary tools 2. Consider using --allowedTools in your configuration to pre-approve common tools 3. Check network connectivity if the timeout occurs during API calls
High memory usage
Symptoms: The daemon process consumes increasing memory over time.
Steps:
1. Check current memory usage: /claude-to-im status 2. Restart the daemon to reset memory:
/claude-to-im stop
/claude-to-im start3. If the issue persists, check how many concurrent sessions are active -- each Claude Code session consumes memory 4. Review logs for error loops that may cause memory leaks
Stale PID file
Symptoms: Status shows "running" but the process doesn't exist, or start refuses because it thinks a daemon is already running.
The daemon management script (daemon.sh) handles stale PID files automatically. If you still encounter issues:
1. Run /claude-to-im stop -- it will clean up the stale PID file 2. If stop also fails, manually remove the PID file:
rm ~/.claude-to-im/runtime/bridge.pid3. Run /claude-to-im start to launch a fresh instance
Usage Guide
This skill works with both Claude Code (via /claude-to-im slash commands) and Codex (via natural language like "start bridge", "配置", "诊断"). All commands below use Claude Code syntax; Codex users can use equivalent natural language.
setup
Interactive wizard that configures the bridge.
/claude-to-im setupThe wizard will prompt you for:
1. Channels to enable -- Enter comma-separated values: telegram, discord, feishu 2. Platform credentials -- Bot tokens, app IDs, and secrets for each enabled channel 3. Allowed users (optional) -- Restrict which users can interact with the bot 4. Working directory -- Default project directory for Claude Code sessions 5. Model and mode -- Claude model and interaction mode (code/plan/ask)
After collecting input, the wizard validates tokens by calling each platform's API and reports results.
Example interaction:
> /claude-to-im setup
Which channels to enable? telegram,discord
Enter Telegram bot token: <your-token>
Enter Discord bot token: <your-token>
Default working directory [/current/dir]: /Users/me/projects
Model [claude-sonnet-4-20250514]:
Mode [code]:
Validating tokens...
Telegram: OK (bot @MyBotName)
Discord: OK (format valid)
Config written to ~/.claude-to-im/config.envstart
Starts the bridge daemon in the background.
/claude-to-im startThe daemon process ID is stored in ~/.claude-to-im/runtime/bridge.pid. If the daemon is already running, the command reports the existing process.
If startup fails, run /claude-to-im doctor to diagnose issues.
stop
Stops the running bridge daemon.
/claude-to-im stopSends SIGTERM to the daemon process and cleans up the PID file.
status
Shows whether the daemon is running and basic health information.
/claude-to-im statusOutput includes:
- Running/stopped state
- PID (if running)
- Uptime
- Connected channels
logs
Shows recent log output from the daemon.
/claude-to-im logs # Last 50 lines (default)
/claude-to-im logs 200 # Last 200 linesLogs are stored in ~/.claude-to-im/logs/ and are automatically redacted to mask secrets.
reconfigure
Interactively update the current configuration.
/claude-to-im reconfigureDisplays current settings with secrets masked, then prompts for changes. After updating, you must restart the daemon for changes to take effect:
/claude-to-im stop
/claude-to-im startdoctor
Runs diagnostic checks and reports issues.
/claude-to-im doctorChecks performed:
- Node.js version (>= 20 required)
- Claude Code CLI availability
- Config file exists and has correct permissions
- Required tokens are set for enabled channels
- Token validity (API calls)
- Daemon process health
- Log directory writability
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/main.ts'],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: 'dist/daemon.mjs',
external: [
// SDK must stay external — it spawns a CLI subprocess and resolves
// dist/cli.js relative to its own package location. Bundling it
// breaks that path resolution.
'@anthropic-ai/claude-agent-sdk',
'@openai/codex-sdk',
// discord.js optional native deps
'bufferutil', 'utf-8-validate', 'zlib-sync', 'erlpack',
// Node.js built-ins
'fs', 'path', 'os', 'crypto', 'http', 'https', 'net', 'tls',
'stream', 'events', 'url', 'util', 'child_process', 'worker_threads',
'node:*',
],
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" },
});
console.log('Built dist/daemon.mjs');
<#
.SYNOPSIS
Windows entry point — delegates to supervisor-windows.ps1.
.DESCRIPTION
Usage: powershell -File scripts\daemon.ps1 start|stop|status|logs|install-service|uninstall-service
#>
param(
[Parameter(Position=0)]
[string]$Command = 'help',
[Parameter(Position=1)]
[int]$LogLines = 50
)
$supervisorScript = Join-Path (Split-Path -Parent $PSCommandPath) 'supervisor-windows.ps1'
& $supervisorScript $Command $LogLines
#!/usr/bin/env bash
set -euo pipefail
CTI_HOME="${CTI_HOME:-$HOME/.claude-to-im}"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PID_FILE="$CTI_HOME/runtime/bridge.pid"
STATUS_FILE="$CTI_HOME/runtime/status.json"
LOG_FILE="$CTI_HOME/logs/bridge.log"
# ── Common helpers ──
ensure_dirs() { mkdir -p "$CTI_HOME"/{data,logs,runtime,data/messages}; }
ensure_built() {
local need_build=0
if [ ! -f "$SKILL_DIR/dist/daemon.mjs" ]; then
need_build=1
else
local newest_src
newest_src=$(find "$SKILL_DIR/src" -name '*.ts' -newer "$SKILL_DIR/dist/daemon.mjs" 2>/dev/null | head -1)
if [ -n "$newest_src" ]; then
need_build=1
fi
fi
if [ "$need_build" = "1" ]; then
echo "Building daemon bundle..."
(cd "$SKILL_DIR" && npm run build)
fi
}
# Clean environment for subprocess isolation.
clean_env() {
unset CLAUDECODE 2>/dev/null || true
local runtime
runtime=$(grep "^CTI_RUNTIME=" "$CTI_HOME/config.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'" | tr -d '"' || true)
runtime="${runtime:-claude}"
local mode="${CTI_ENV_ISOLATION:-strict}"
if [ "$mode" = "strict" ]; then
case "$runtime" in
codex)
while IFS='=' read -r name _; do
case "$name" in ANTHROPIC_*) unset "$name" 2>/dev/null || true ;; esac
done < <(env)
;;
claude)
if [ "${CTI_ANTHROPIC_PASSTHROUGH:-}" != "true" ]; then
while IFS='=' read -r name _; do
case "$name" in ANTHROPIC_*) unset "$name" 2>/dev/null || true ;; esac
done < <(env)
fi
while IFS='=' read -r name _; do
case "$name" in OPENAI_*) unset "$name" 2>/dev/null || true ;; esac
done < <(env)
;;
auto)
if [ "${CTI_ANTHROPIC_PASSTHROUGH:-}" != "true" ]; then
while IFS='=' read -r name _; do
case "$name" in ANTHROPIC_*) unset "$name" 2>/dev/null || true ;; esac
done < <(env)
fi
;;
esac
fi
}
read_pid() {
[ -f "$PID_FILE" ] && cat "$PID_FILE" 2>/dev/null || echo ""
}
pid_alive() {
local pid="$1"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}
status_running() {
[ -f "$STATUS_FILE" ] && grep -q '"running"[[:space:]]*:[[:space:]]*true' "$STATUS_FILE" 2>/dev/null
}
show_last_exit_reason() {
if [ -f "$STATUS_FILE" ]; then
local reason
reason=$(grep -o '"lastExitReason"[[:space:]]*:[[:space:]]*"[^"]*"' "$STATUS_FILE" 2>/dev/null | head -1 | sed 's/.*: *"//;s/"$//')
[ -n "$reason" ] && echo "Last exit reason: $reason"
fi
}
show_failure_help() {
echo ""
echo "Recent logs:"
tail -20 "$LOG_FILE" 2>/dev/null || echo " (no log file)"
echo ""
echo "Next steps:"
echo " 1. Run diagnostics: bash \"$SKILL_DIR/scripts/doctor.sh\""
echo " 2. Check full logs: bash \"$SKILL_DIR/scripts/daemon.sh\" logs 100"
echo " 3. Rebuild bundle: cd \"$SKILL_DIR\" && npm run build"
}
# ── Load platform-specific supervisor ──
case "$(uname -s)" in
Darwin)
# shellcheck source=supervisor-macos.sh
source "$SKILL_DIR/scripts/supervisor-macos.sh"
;;
MINGW*|MSYS*|CYGWIN*)
# Windows detected via Git Bash / MSYS2 / Cygwin — delegate to PowerShell
echo "Windows detected. Delegating to supervisor-windows.ps1..."
powershell.exe -ExecutionPolicy Bypass -File "$SKILL_DIR/scripts/supervisor-windows.ps1" "$@"
exit $?
;;
*)
# shellcheck source=supervisor-linux.sh
source "$SKILL_DIR/scripts/supervisor-linux.sh"
;;
esac
# ── Commands ──
case "${1:-help}" in
start)
ensure_dirs
ensure_built
# Check if already running (supervisor-aware: launchctl on macOS, PID on Linux)
if supervisor_is_running; then
EXISTING_PID=$(read_pid)
echo "Bridge already running${EXISTING_PID:+ (PID: $EXISTING_PID)}"
cat "$STATUS_FILE" 2>/dev/null
exit 1
fi
clean_env
echo "Starting bridge..."
supervisor_start
# Poll for up to 10 seconds waiting for status.json to report running
STARTED=false
for _ in $(seq 1 10); do
sleep 1
if status_running; then
STARTED=true
break
fi
# If supervisor process already died, stop waiting
if ! supervisor_is_running; then
break
fi
done
if [ "$STARTED" = "true" ]; then
NEW_PID=$(read_pid)
echo "Bridge started${NEW_PID:+ (PID: $NEW_PID)}"
cat "$STATUS_FILE" 2>/dev/null
else
echo "Failed to start bridge."
supervisor_is_running || echo " Process not running."
status_running || echo " status.json not reporting running=true."
show_last_exit_reason
show_failure_help
exit 1
fi
;;
stop)
if supervisor_is_managed; then
echo "Stopping bridge..."
supervisor_stop
echo "Bridge stopped"
else
PID=$(read_pid)
if [ -z "$PID" ]; then echo "No bridge running"; exit 0; fi
if pid_alive "$PID"; then
kill "$PID"
for _ in $(seq 1 10); do
pid_alive "$PID" || break
sleep 1
done
pid_alive "$PID" && kill -9 "$PID"
echo "Bridge stopped"
else
echo "Bridge was not running (stale PID file)"
fi
rm -f "$PID_FILE"
fi
;;
status)
# Platform-specific status info (prints launchd/service state)
supervisor_status_extra
# Process status: supervisor-aware (launchctl on macOS, PID on Linux)
if supervisor_is_running; then
PID=$(read_pid)
echo "Bridge process is running${PID:+ (PID: $PID)}"
# Business status from status.json
if status_running; then
echo "Bridge status: running"
else
echo "Bridge status: process alive but status.json not reporting running"
fi
cat "$STATUS_FILE" 2>/dev/null
else
echo "Bridge is not running"
[ -f "$PID_FILE" ] && rm -f "$PID_FILE"
show_last_exit_reason
fi
;;
logs)
N="${2:-50}"
tail -n "$N" "$LOG_FILE" 2>/dev/null | sed -E 's/(token|secret|password)(["\\x27]?\s*[:=]\s*["\\x27]?)[^ "]+/\1\2*****/gi'
;;
*)
echo "Usage: daemon.sh {start|stop|status|logs [N]}"
;;
esac
#!/usr/bin/env bash
set -euo pipefail
CTI_HOME="$HOME/.claude-to-im"
CONFIG_FILE="$CTI_HOME/config.env"
PID_FILE="$CTI_HOME/runtime/bridge.pid"
LOG_FILE="$CTI_HOME/logs/bridge.log"
PASS=0
FAIL=0
check() {
local label="$1"
local result="$2"
if [ "$result" = "0" ]; then
echo "[OK] $label"
PASS=$((PASS + 1))
else
echo "[FAIL] $label"
FAIL=$((FAIL + 1))
fi
}
# --- Node.js version ---
if command -v node &>/dev/null; then
NODE_VER=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VER" -ge 20 ] 2>/dev/null; then
check "Node.js >= 20 (found v$(node -v | sed 's/v//'))" 0
else
check "Node.js >= 20 (found v$(node -v | sed 's/v//'), need >= 20)" 1
fi
else
check "Node.js installed" 1
fi
# --- Read runtime setting ---
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CTI_RUNTIME=$(grep "^CTI_RUNTIME=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'" | tr -d '"')
CTI_RUNTIME="${CTI_RUNTIME:-claude}"
echo "Runtime: $CTI_RUNTIME"
echo ""
# --- Claude CLI available (claude/auto modes) ---
if [ "$CTI_RUNTIME" = "claude" ] || [ "$CTI_RUNTIME" = "auto" ]; then
if command -v claude &>/dev/null; then
CLAUDE_VER=$(claude --version 2>/dev/null || echo "unknown")
check "Claude CLI available (${CLAUDE_VER})" 0
else
if [ "$CTI_RUNTIME" = "claude" ]; then
check "Claude CLI available (not found in PATH)" 1
else
check "Claude CLI available (not found — will use Codex fallback)" 0
fi
fi
# --- SDK cli.js resolvable ---
SDK_CLI="$SKILL_DIR/node_modules/@anthropic-ai/claude-agent-sdk/dist/cli.js"
if [ -f "$SDK_CLI" ]; then
check "Claude SDK cli.js exists ($SDK_CLI)" 0
else
if [ "$CTI_RUNTIME" = "claude" ]; then
check "Claude SDK cli.js exists (not found — run 'npm install' in $SKILL_DIR)" 1
else
check "Claude SDK cli.js exists (not found — OK for auto/codex mode)" 0
fi
fi
fi
# --- Codex checks (codex/auto modes) ---
if [ "$CTI_RUNTIME" = "codex" ] || [ "$CTI_RUNTIME" = "auto" ]; then
if command -v codex &>/dev/null; then
CODEX_VER=$(codex --version 2>/dev/null || echo "unknown")
check "Codex CLI available (${CODEX_VER})" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "Codex CLI available (not found in PATH)" 1
else
check "Codex CLI available (not found — will use Claude)" 0
fi
fi
# Check @openai/codex-sdk
CODEX_SDK="$SKILL_DIR/node_modules/@openai/codex-sdk"
if [ -d "$CODEX_SDK" ]; then
check "@openai/codex-sdk installed" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "@openai/codex-sdk installed (not found — run 'npm install' in $SKILL_DIR)" 1
else
check "@openai/codex-sdk installed (not found — OK for auto/claude mode)" 0
fi
fi
# Check Codex auth: any of CTI_CODEX_API_KEY / CODEX_API_KEY / OPENAI_API_KEY,
# or `codex auth status` showing logged-in (interactive login).
CODEX_AUTH=1
if [ -n "${CTI_CODEX_API_KEY:-}" ] || [ -n "${CODEX_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ]; then
CODEX_AUTH=0
elif command -v codex &>/dev/null; then
CODEX_AUTH_OUT=$(codex auth status 2>&1 || true)
if echo "$CODEX_AUTH_OUT" | grep -qiE 'logged.in|authenticated'; then
CODEX_AUTH=0
fi
fi
if [ "$CODEX_AUTH" = "0" ]; then
check "Codex auth available (API key or login)" 0
else
if [ "$CTI_RUNTIME" = "codex" ]; then
check "Codex auth available (set OPENAI_API_KEY or run 'codex auth login')" 1
else
check "Codex auth available (not found — needed only for Codex fallback)" 0
fi
fi
fi
# --- dist/daemon.mjs freshness ---
DAEMON_MJS="$SKILL_DIR/dist/daemon.mjs"
if [ -f "$DAEMON_MJS" ]; then
STALE_SRC=$(find "$SKILL_DIR/src" -name '*.ts' -newer "$DAEMON_MJS" 2>/dev/null | head -1)
if [ -z "$STALE_SRC" ]; then
check "dist/daemon.mjs is up to date" 0
else
check "dist/daemon.mjs is stale (src changed, run 'npm run build')" 1
fi
else
check "dist/daemon.mjs exists (not built — run 'npm run build')" 1
fi
# --- config.env exists ---
if [ -f "$CONFIG_FILE" ]; then
check "config.env exists" 0
else
check "config.env exists ($CONFIG_FILE not found)" 1
fi
# --- config.env permissions ---
if [ -f "$CONFIG_FILE" ]; then
PERMS=$(stat -f "%Lp" "$CONFIG_FILE" 2>/dev/null || stat -c "%a" "$CONFIG_FILE" 2>/dev/null || echo "unknown")
if [ "$PERMS" = "600" ]; then
check "config.env permissions are 600" 0
else
check "config.env permissions are 600 (currently $PERMS)" 1
fi
fi
# --- Load config for channel checks ---
get_config() { grep "^$1=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^["'"'"']//;s/["'"'"']$//'; }
if [ -f "$CONFIG_FILE" ]; then
CTI_CHANNELS=$(get_config CTI_ENABLED_CHANNELS)
# --- Telegram ---
if echo "$CTI_CHANNELS" | grep -q telegram; then
TG_TOKEN=$(get_config CTI_TG_BOT_TOKEN)
if [ -n "$TG_TOKEN" ]; then
TG_RESULT=$(curl -s --max-time 5 "https://api.telegram.org/bot${TG_TOKEN}/getMe" 2>/dev/null || echo '{"ok":false}')
if echo "$TG_RESULT" | grep -q '"ok":true'; then
check "Telegram bot token is valid" 0
else
check "Telegram bot token is valid (getMe failed)" 1
fi
else
check "Telegram bot token configured" 1
fi
fi
# --- Feishu ---
if echo "$CTI_CHANNELS" | grep -q feishu; then
FS_APP_ID=$(get_config CTI_FEISHU_APP_ID)
FS_SECRET=$(get_config CTI_FEISHU_APP_SECRET)
FS_DOMAIN=$(get_config CTI_FEISHU_DOMAIN)
FS_DOMAIN="${FS_DOMAIN:-https://open.feishu.cn}"
if [ -n "$FS_APP_ID" ] && [ -n "$FS_SECRET" ]; then
FEISHU_RESULT=$(curl -s --max-time 5 -X POST "${FS_DOMAIN}/open-apis/auth/v3/tenant_access_token/internal" \
-H "Content-Type: application/json" \
-d "{\"app_id\":\"${FS_APP_ID}\",\"app_secret\":\"${FS_SECRET}\"}" 2>/dev/null || echo '{"code":1}')
if echo "$FEISHU_RESULT" | grep -q '"code"[[:space:]]*:[[:space:]]*0'; then
check "Feishu app credentials are valid" 0
else
check "Feishu app credentials are valid (token request failed)" 1
fi
else
check "Feishu app credentials configured" 1
fi
fi
# --- Discord ---
if echo "$CTI_CHANNELS" | grep -q discord; then
DC_TOKEN=$(get_config CTI_DISCORD_BOT_TOKEN)
if [ -n "$DC_TOKEN" ]; then
if echo "${DC_TOKEN}" | grep -qE '^[A-Za-z0-9_-]{20,}\.'; then
check "Discord bot token format" 0
else
check "Discord bot token format (does not match expected pattern)" 1
fi
else
check "Discord bot token configured" 1
fi
fi
fi
# --- Log directory writable ---
LOG_DIR="$CTI_HOME/logs"
if [ -d "$LOG_DIR" ] && [ -w "$LOG_DIR" ]; then
check "Log directory is writable" 0
else
check "Log directory is writable ($LOG_DIR)" 1
fi
# --- PID file consistency ---
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
check "PID file consistent (process $PID is running)" 0
else
check "PID file consistent (stale PID $PID, process not running)" 1
fi
else
check "PID file consistency (no PID file, OK)" 0
fi
# --- Recent errors in log ---
if [ -f "$LOG_FILE" ]; then
ERROR_COUNT=$(tail -50 "$LOG_FILE" | grep -ciE 'ERROR|Fatal' || true)
if [ "$ERROR_COUNT" -eq 0 ]; then
check "No recent errors in log (last 50 lines)" 0
else
check "No recent errors in log (found $ERROR_COUNT ERROR/Fatal lines)" 1
fi
else
check "Log file exists (not yet created)" 0
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo ""
echo "Common fixes:"
echo " SDK cli.js missing → cd $SKILL_DIR && npm install"
echo " dist/daemon.mjs stale → cd $SKILL_DIR && npm run build"
echo " config.env missing → run setup wizard"
echo " Stale PID file → run stop, then start"
fi
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
#!/usr/bin/env bash
set -euo pipefail
# Install claude-to-im skill for Codex.
# Usage: bash scripts/install-codex.sh [--link]
# --link Create a symlink instead of copying (for development)
SKILL_NAME="claude-to-im"
CODEX_SKILLS_DIR="$HOME/.codex/skills"
TARGET_DIR="$CODEX_SKILLS_DIR/$SKILL_NAME"
SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "Installing $SKILL_NAME skill for Codex..."
# Check source
if [ ! -f "$SOURCE_DIR/SKILL.md" ]; then
echo "Error: SKILL.md not found in $SOURCE_DIR"
exit 1
fi
# Create skills directory
mkdir -p "$CODEX_SKILLS_DIR"
# Check if already installed
if [ -e "$TARGET_DIR" ]; then
if [ -L "$TARGET_DIR" ]; then
EXISTING=$(readlink "$TARGET_DIR")
echo "Already installed as symlink → $EXISTING"
echo "To reinstall, remove it first: rm $TARGET_DIR"
exit 0
else
echo "Already installed at $TARGET_DIR"
echo "To reinstall, remove it first: rm -rf $TARGET_DIR"
exit 0
fi
fi
if [ "${1:-}" = "--link" ]; then
ln -s "$SOURCE_DIR" "$TARGET_DIR"
echo "Symlinked: $TARGET_DIR → $SOURCE_DIR"
else
cp -R "$SOURCE_DIR" "$TARGET_DIR"
echo "Copied to: $TARGET_DIR"
fi
# Ensure dependencies (need devDependencies for build step)
if [ ! -d "$TARGET_DIR/node_modules" ] || [ ! -d "$TARGET_DIR/node_modules/@openai/codex-sdk" ]; then
echo "Installing dependencies..."
(cd "$TARGET_DIR" && npm install)
fi
# Ensure build
if [ ! -f "$TARGET_DIR/dist/daemon.mjs" ]; then
echo "Building daemon bundle..."
(cd "$TARGET_DIR" && npm run build)
fi
# Prune devDependencies after build
echo "Pruning dev dependencies..."
(cd "$TARGET_DIR" && npm prune --production)
echo ""
echo "Done! Start a new Codex session and use:"
echo " claude-to-im setup — configure IM platform credentials"
echo " claude-to-im start — start the bridge daemon"
echo " claude-to-im doctor — diagnose issues"
#!/usr/bin/env bash
# Linux supervisor — setsid/nohup fallback process management.
# Sourced by daemon.sh; expects CTI_HOME, SKILL_DIR, PID_FILE, STATUS_FILE, LOG_FILE.
# ── Public interface (called by daemon.sh) ──
supervisor_start() {
if command -v setsid >/dev/null 2>&1; then
setsid node "$SKILL_DIR/dist/daemon.mjs" >> "$LOG_FILE" 2>&1 < /dev/null &
else
nohup node "$SKILL_DIR/dist/daemon.mjs" >> "$LOG_FILE" 2>&1 < /dev/null &
fi
# Fallback: write shell $! as PID; main.ts will overwrite with real PID
echo $! > "$PID_FILE"
}
supervisor_stop() {
local pid
pid=$(read_pid)
if [ -z "$pid" ]; then echo "No bridge running"; return 0; fi
if pid_alive "$pid"; then
kill "$pid"
for _ in $(seq 1 10); do
pid_alive "$pid" || break
sleep 1
done
pid_alive "$pid" && kill -9 "$pid"
echo "Bridge stopped"
else
echo "Bridge was not running (stale PID file)"
fi
rm -f "$PID_FILE"
}
supervisor_is_managed() {
# Linux fallback has no service manager; always false
return 1
}
supervisor_status_extra() {
# No extra status for Linux fallback
:
}
supervisor_is_running() {
local pid
pid=$(read_pid)
pid_alive "$pid"
}
#!/usr/bin/env bash
# macOS supervisor — launchd-based process management.
# Sourced by daemon.sh; expects CTI_HOME, SKILL_DIR, PID_FILE, STATUS_FILE, LOG_FILE.
LAUNCHD_LABEL="com.claude-to-im.bridge"
PLIST_DIR="$HOME/Library/LaunchAgents"
PLIST_FILE="$PLIST_DIR/$LAUNCHD_LABEL.plist"
# ── launchd helpers ──
# Collect env vars that should be forwarded into the plist.
# We honour clean_env() logic by reading *after* clean_env runs.
build_env_dict() {
local indent=" "
local dict=""
# Always forward basics
for var in HOME PATH USER SHELL LANG TMPDIR; do
local val="${!var:-}"
[ -z "$val" ] && continue
dict+="${indent}<key>${var}</key>\n${indent}<string>${val}</string>\n"
done
# Forward CTI_* vars
while IFS='=' read -r name val; do
case "$name" in CTI_*)
dict+="${indent}<key>${name}</key>\n${indent}<string>${val}</string>\n"
;; esac
done < <(env)
# Forward runtime-specific API keys
local runtime
runtime=$(grep "^CTI_RUNTIME=" "$CTI_HOME/config.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d "'" | tr -d '"' || true)
runtime="${runtime:-claude}"
case "$runtime" in
codex|auto)
for var in OPENAI_API_KEY CODEX_API_KEY CTI_CODEX_API_KEY CTI_CODEX_BASE_URL; do
local val="${!var:-}"
[ -z "$val" ] && continue
dict+="${indent}<key>${var}</key>\n${indent}<string>${val}</string>\n"
done
;;
esac
case "$runtime" in
claude|auto)
if [ "${CTI_ANTHROPIC_PASSTHROUGH:-}" = "true" ]; then
for var in ANTHROPIC_API_KEY ANTHROPIC_BASE_URL; do
local val="${!var:-}"
[ -z "$val" ] && continue
dict+="${indent}<key>${var}</key>\n${indent}<string>${val}</string>\n"
done
fi
;;
esac
echo -e "$dict"
}
generate_plist() {
local node_path
node_path=$(command -v node)
mkdir -p "$PLIST_DIR"
local env_dict
env_dict=$(build_env_dict)
cat > "$PLIST_FILE" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${node_path}</string>
<string>${SKILL_DIR}/dist/daemon.mjs</string>
</array>
<key>WorkingDirectory</key>
<string>${SKILL_DIR}</string>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>EnvironmentVariables</key>
<dict>
${env_dict} </dict>
</dict>
</plist>
PLIST
}
# ── Public interface (called by daemon.sh) ──
supervisor_start() {
launchctl bootout "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || true
generate_plist
launchctl bootstrap "gui/$(id -u)" "$PLIST_FILE"
launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_LABEL"
}
supervisor_stop() {
launchctl bootout "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || true
rm -f "$PID_FILE"
}
supervisor_is_managed() {
launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" &>/dev/null
}
supervisor_status_extra() {
if supervisor_is_managed; then
echo "Bridge is registered with launchd ($LAUNCHD_LABEL)"
# Extract PID from launchctl as the authoritative source
local lc_pid
lc_pid=$(launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null | grep -m1 'pid = ' | sed 's/.*pid = //' | tr -d ' ')
if [ -n "$lc_pid" ] && [ "$lc_pid" != "0" ] && [ "$lc_pid" != "-" ]; then
echo "launchd reports PID: $lc_pid"
fi
fi
}
# Override: on macOS, check launchctl first, then fall back to PID file
supervisor_is_running() {
# Primary: launchctl knows the process
if supervisor_is_managed; then
local lc_pid
lc_pid=$(launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null | grep -m1 'pid = ' | sed 's/.*pid = //' | tr -d ' ')
if [ -n "$lc_pid" ] && [ "$lc_pid" != "0" ] && [ "$lc_pid" != "-" ]; then
return 0
fi
fi
# Fallback: PID file
local pid
pid=$(read_pid)
pid_alive "$pid"
}
<#
.SYNOPSIS
Windows daemon manager for claude-to-im bridge.
.DESCRIPTION
Manages the bridge process on Windows.
Preferred: WinSW or NSSM wrapping as a Windows Service.
Fallback: Start-Process with hidden window + PID tracking.
Usage:
powershell -File scripts\daemon.ps1 start
powershell -File scripts\daemon.ps1 stop
powershell -File scripts\daemon.ps1 status
powershell -File scripts\daemon.ps1 logs [N]
powershell -File scripts\daemon.ps1 install-service # WinSW/NSSM setup
powershell -File scripts\daemon.ps1 uninstall-service
#>
param(
[Parameter(Position=0)]
[ValidateSet('start','stop','status','logs','install-service','uninstall-service','help')]
[string]$Command = 'help',
[Parameter(Position=1)]
[int]$LogLines = 50
)
$ErrorActionPreference = 'Stop'
# ── Paths ──
$CtiHome = if ($env:CTI_HOME) { $env:CTI_HOME } else { Join-Path $env:USERPROFILE '.claude-to-im' }
$SkillDir = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
$RuntimeDir = Join-Path $CtiHome 'runtime'
$PidFile = Join-Path $RuntimeDir 'bridge.pid'
$StatusFile = Join-Path $RuntimeDir 'status.json'
$LogFile = Join-Path $CtiHome 'logs' 'bridge.log'
$DaemonMjs = Join-Path $SkillDir 'dist' 'daemon.mjs'
$ServiceName = 'ClaudeToIMBridge'
# ── Helpers ──
function Ensure-Dirs {
@('data','logs','runtime','data/messages') | ForEach-Object {
$dir = Join-Path $CtiHome $_
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
}
}
function Ensure-Built {
if (-not (Test-Path $DaemonMjs)) {
Write-Host "Building daemon bundle..."
Push-Location $SkillDir
npm run build
Pop-Location
} else {
$srcFiles = Get-ChildItem -Path (Join-Path $SkillDir 'src') -Filter '*.ts' -Recurse
$bundleTime = (Get-Item $DaemonMjs).LastWriteTime
$stale = $srcFiles | Where-Object { $_.LastWriteTime -gt $bundleTime } | Select-Object -First 1
if ($stale) {
Write-Host "Rebuilding daemon bundle (source changed)..."
Push-Location $SkillDir
npm run build
Pop-Location
}
}
}
function Read-Pid {
if (Test-Path $PidFile) { return (Get-Content $PidFile -Raw).Trim() }
return $null
}
function Test-PidAlive {
param([string]$Pid)
if (-not $Pid) { return $false }
try { $null = Get-Process -Id ([int]$Pid) -ErrorAction Stop; return $true }
catch { return $false }
}
function Test-StatusRunning {
if (-not (Test-Path $StatusFile)) { return $false }
$json = Get-Content $StatusFile -Raw | ConvertFrom-Json
return $json.running -eq $true
}
function Show-LastExitReason {
if (Test-Path $StatusFile) {
$json = Get-Content $StatusFile -Raw | ConvertFrom-Json
if ($json.lastExitReason) {
Write-Host "Last exit reason: $($json.lastExitReason)"
}
}
}
function Show-FailureHelp {
Write-Host ""
Write-Host "Recent logs:"
if (Test-Path $LogFile) {
Get-Content $LogFile -Tail 20
} else {
Write-Host " (no log file)"
}
Write-Host ""
Write-Host "Next steps:"
Write-Host " 1. Run diagnostics: powershell -File `"$SkillDir\scripts\doctor.ps1`""
Write-Host " 2. Check full logs: powershell -File `"$SkillDir\scripts\daemon.ps1`" logs 100"
Write-Host " 3. Rebuild bundle: cd `"$SkillDir`"; npm run build"
}
function Get-NodePath {
$nodePath = (Get-Command node -ErrorAction SilentlyContinue).Source
if (-not $nodePath) {
Write-Error "Node.js not found in PATH. Install Node.js >= 20."
exit 1
}
return $nodePath
}
# ── WinSW / NSSM detection ──
function Find-ServiceManager {
# Prefer WinSW, then NSSM
$winsw = Get-Command 'WinSW.exe' -ErrorAction SilentlyContinue
if ($winsw) { return @{ type = 'winsw'; path = $winsw.Source } }
$nssm = Get-Command 'nssm.exe' -ErrorAction SilentlyContinue
if ($nssm) { return @{ type = 'nssm'; path = $nssm.Source } }
return $null
}
function Install-WinSWService {
param([string]$WinSWPath)
$nodePath = Get-NodePath
$xmlPath = Join-Path $SkillDir "$ServiceName.xml"
# Run as current user so the service can access ~/.claude-to-im and Codex login state
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
Write-Host "Service will run as: $currentUser"
$cred = Get-Credential -UserName $currentUser -Message "Enter password for '$currentUser' (required for Windows Service logon)"
$plainPwd = $cred.GetNetworkCredential().Password
# Generate WinSW config XML
@"
<service>
<id>$ServiceName</id>
<name>Claude-to-IM Bridge</name>
<description>Claude-to-IM bridge daemon</description>
<executable>$nodePath</executable>
<arguments>$DaemonMjs</arguments>
<workingdirectory>$SkillDir</workingdirectory>
<serviceaccount>
<username>$currentUser</username>
<password>$([System.Security.SecurityElement]::Escape($plainPwd))</password>
<allowservicelogon>true</allowservicelogon>
</serviceaccount>
<env name="USERPROFILE" value="$env:USERPROFILE"/>
<env name="APPDATA" value="$env:APPDATA"/>
<env name="LOCALAPPDATA" value="$env:LOCALAPPDATA"/>
<env name="PATH" value="$env:PATH"/>
<env name="CTI_HOME" value="$CtiHome"/>
<logpath>$(Join-Path $CtiHome 'logs')</logpath>
<log mode="append">
<logfile>bridge-service.log</logfile>
</log>
<onfailure action="restart" delay="10 sec"/>
<onfailure action="restart" delay="30 sec"/>
<onfailure action="none"/>
</service>
"@ | Set-Content -Path $xmlPath -Encoding UTF8
# Copy WinSW next to the XML with matching name
$winswCopy = Join-Path $SkillDir "$ServiceName.exe"
Copy-Item $WinSWPath $winswCopy -Force
& $winswCopy install
Write-Host "Service '$ServiceName' installed via WinSW."
Write-Host " Service account: $currentUser"
Write-Host "Start with: & `"$winswCopy`" start"
Write-Host "Or: sc.exe start $ServiceName"
}
function Install-NSSMService {
param([string]$NSSMPath)
$nodePath = Get-NodePath
# Run as current user so the service can access ~/.claude-to-im and Codex login state
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
Write-Host "Service will run as: $currentUser"
$cred = Get-Credential -UserName $currentUser -Message "Enter password for '$currentUser' (required for Windows Service logon)"
$plainPwd = $cred.GetNetworkCredential().Password
& $NSSMPath install $ServiceName $nodePath $DaemonMjs
& $NSSMPath set $ServiceName AppDirectory $SkillDir
& $NSSMPath set $ServiceName ObjectName $currentUser $plainPwd
& $NSSMPath set $ServiceName AppStdout $LogFile
& $NSSMPath set $ServiceName AppStderr $LogFile
& $NSSMPath set $ServiceName AppStdoutCreationDisposition 4
& $NSSMPath set $ServiceName AppStderrCreationDisposition 4
& $NSSMPath set $ServiceName Description "Claude-to-IM bridge daemon"
& $NSSMPath set $ServiceName AppRestartDelay 10000
& $NSSMPath set $ServiceName AppEnvironmentExtra "USERPROFILE=$env:USERPROFILE" "APPDATA=$env:APPDATA" "LOCALAPPDATA=$env:LOCALAPPDATA" "CTI_HOME=$CtiHome"
Write-Host "Service '$ServiceName' installed via NSSM."
Write-Host " Service account: $currentUser"
Write-Host "Start with: nssm start $ServiceName"
Write-Host "Or: sc.exe start $ServiceName"
}
# ── Fallback: Start-Process (no service manager) ──
function Start-Fallback {
$nodePath = Get-NodePath
# Clean env
$envClone = [System.Collections.Hashtable]::new()
foreach ($key in [System.Environment]::GetEnvironmentVariables().Keys) {
$envClone[$key] = [System.Environment]::GetEnvironmentVariable($key)
}
# Remove CLAUDECODE
[System.Environment]::SetEnvironmentVariable('CLAUDECODE', $null)
$proc = Start-Process -FilePath $nodePath `
-ArgumentList $DaemonMjs `
-WorkingDirectory $SkillDir `
-WindowStyle Hidden `
-RedirectStandardOutput $LogFile `
-RedirectStandardError $LogFile `
-PassThru
# Write initial PID (main.ts will overwrite with real PID)
Set-Content -Path $PidFile -Value $proc.Id
return $proc.Id
}
# ── Commands ──
switch ($Command) {
'start' {
Ensure-Dirs
Ensure-Built
$existingPid = Read-Pid
if ($existingPid -and (Test-PidAlive $existingPid)) {
Write-Host "Bridge already running (PID: $existingPid)"
if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
exit 1
}
# Check if registered as Windows Service
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
Write-Host "Starting bridge via Windows Service..."
Start-Service -Name $ServiceName
Start-Sleep -Seconds 3
$newPid = Read-Pid
if ($newPid -and (Test-PidAlive $newPid) -and (Test-StatusRunning)) {
Write-Host "Bridge started (PID: $newPid, managed by Windows Service)"
if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
} else {
Write-Host "Failed to start bridge via service."
Show-LastExitReason
Show-FailureHelp
exit 1
}
} else {
Write-Host "Starting bridge (background process)..."
$pid = Start-Fallback
Start-Sleep -Seconds 3
$newPid = Read-Pid
if ($newPid -and (Test-PidAlive $newPid) -and (Test-StatusRunning)) {
Write-Host "Bridge started (PID: $newPid)"
if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
} else {
Write-Host "Failed to start bridge."
if (-not $newPid -or -not (Test-PidAlive $newPid)) {
Write-Host " Process exited immediately."
}
Show-LastExitReason
Show-FailureHelp
exit 1
}
}
}
'stop' {
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') {
Write-Host "Stopping bridge via Windows Service..."
Stop-Service -Name $ServiceName -Force
Write-Host "Bridge stopped"
if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
} else {
$pid = Read-Pid
if (-not $pid) { Write-Host "No bridge running"; exit 0 }
if (Test-PidAlive $pid) {
Stop-Process -Id ([int]$pid) -Force
Write-Host "Bridge stopped"
} else {
Write-Host "Bridge was not running (stale PID file)"
}
if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
}
}
'status' {
$pid = Read-Pid
# Check Windows Service
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($svc) {
Write-Host "Windows Service '$ServiceName': $($svc.Status)"
}
if ($pid -and (Test-PidAlive $pid)) {
Write-Host "Bridge process is running (PID: $pid)"
if (Test-StatusRunning) {
Write-Host "Bridge status: running"
} else {
Write-Host "Bridge status: process alive but status.json not reporting running"
}
if (Test-Path $StatusFile) { Get-Content $StatusFile -Raw }
} else {
Write-Host "Bridge is not running"
if (Test-Path $PidFile) { Remove-Item $PidFile -Force }
Show-LastExitReason
}
}
'logs' {
if (Test-Path $LogFile) {
Get-Content $LogFile -Tail $LogLines | ForEach-Object {
$_ -replace '(token|secret|password)(["'']?\s*[:=]\s*["'']?)[^\s"]+', '$1$2*****'
}
} else {
Write-Host "No log file found at $LogFile"
}
}
'install-service' {
Ensure-Dirs
Ensure-Built
$mgr = Find-ServiceManager
if (-not $mgr) {
Write-Host "No service manager found. Install one of:"
Write-Host " WinSW: https://github.com/winsw/winsw/releases"
Write-Host " NSSM: https://nssm.cc/download"
Write-Host ""
Write-Host "After installing, add it to PATH and re-run:"
Write-Host " powershell -File `"$PSCommandPath`" install-service"
exit 1
}
switch ($mgr.type) {
'winsw' { Install-WinSWService -WinSWPath $mgr.path }
'nssm' { Install-NSSMService -NSSMPath $mgr.path }
}
}
'uninstall-service' {
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc) {
Write-Host "Service '$ServiceName' is not installed."
exit 0
}
if ($svc.Status -eq 'Running') {
Stop-Service -Name $ServiceName -Force
}
$mgr = Find-ServiceManager
if ($mgr -and $mgr.type -eq 'nssm') {
& $mgr.path remove $ServiceName confirm
} else {
# WinSW or generic
$winswExe = Join-Path $SkillDir "$ServiceName.exe"
if (Test-Path $winswExe) {
& $winswExe uninstall
Remove-Item $winswExe -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $SkillDir "$ServiceName.xml") -Force -ErrorAction SilentlyContinue
} else {
sc.exe delete $ServiceName
}
}
Write-Host "Service '$ServiceName' uninstalled."
}
'help' {
Write-Host "Usage: daemon.ps1 {start|stop|status|logs [N]|install-service|uninstall-service}"
Write-Host ""
Write-Host "Commands:"
Write-Host " start Start the bridge daemon"
Write-Host " stop Stop the bridge daemon"
Write-Host " status Show bridge status"
Write-Host " logs [N] Show last N log lines (default 50)"
Write-Host " install-service Install as Windows Service (requires WinSW or NSSM)"
Write-Host " uninstall-service Remove the Windows Service"
}
}
Security
Credential Storage
All credentials are stored in ~/.claude-to-im/config.env with file permissions set to 600 (owner read/write only). This file is created during setup and never committed to version control.
The .gitignore excludes config.env to prevent accidental commits.
Log Redaction
All tokens and secrets are masked in log output and terminal display. Only the last 4 characters of any secret are shown (e.g., ****abcd). This applies to:
- Setup wizard confirmation output
reconfigurecommand displaylogscommand output- Error messages
Threat Model
This project operates as a single-user local daemon:
- The daemon runs on the user's local machine under their user account
- No network listeners are opened; the daemon connects outbound to IM platform APIs only
- Authentication is handled by the IM platform's bot token mechanism
- Access control is enforced via allowed user/channel ID lists configured per platform
The primary threats are:
- Token leakage: Mitigated by file permissions, log redaction, and
.gitignore - Unauthorized message senders: Mitigated by allowed user ID filtering per platform
- Local privilege escalation: Mitigated by running as unprivileged user process
Token Rotation
To rotate compromised or expired tokens:
1. Revoke the old token on the IM platform 2. Generate a new token 3. Run /claude-to-im reconfigure to update the stored credentials 4. Run /claude-to-im stop then /claude-to-im start to apply changes
Leak Response
If you suspect a token has been leaked:
1. Immediately revoke the token on the respective IM platform 2. Run /claude-to-im stop to halt the daemon 3. Run /claude-to-im reconfigure with a new token 4. Review ~/.claude-to-im/logs/ for unauthorized activity 5. Run /claude-to-im start with the new credentials
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// ── SSE utils tests ─────────────────────────────────────────
import { sseEvent } from '../sse-utils.js';
describe('sseEvent', () => {
it('formats a string data payload', () => {
const result = sseEvent('text', 'hello');
assert.equal(result, 'data: {"type":"text","data":"hello"}\n');
});
it('stringifies object data payload', () => {
const result = sseEvent('result', { usage: { input_tokens: 10 } });
const parsed = JSON.parse(result.slice(6));
assert.equal(parsed.type, 'result');
const inner = JSON.parse(parsed.data);
assert.equal(inner.usage.input_tokens, 10);
});
it('handles newlines in data', () => {
const result = sseEvent('text', 'line1\nline2');
const parsed = JSON.parse(result.slice(6));
assert.equal(parsed.data, 'line1\nline2');
});
});
// ── CodexProvider tests ─────────────────────────────────────
async function collectStream(stream: ReadableStream<string>): Promise<string[]> {
const reader = stream.getReader();
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return chunks;
}
function parseSSEChunks(chunks: string[]): Array<{ type: string; data: string }> {
return chunks
.flatMap(chunk => chunk.split('\n'))
.filter(line => line.startsWith('data: '))
.map(line => JSON.parse(line.slice(6)));
}
describe('CodexProvider', () => {
it('emits error when SDK init fails', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
// Force ensureSDK to fail by setting sdk to a broken module
(provider as any).sdk = { Codex: class { constructor() { throw new Error('Missing API key'); } } };
(provider as any).codex = null;
// Reset so ensureSDK re-runs the constructor
(provider as any).sdk = null;
// Override ensureSDK directly
(provider as any).ensureSDK = async () => { throw new Error('SDK init failed: Missing API key'); };
const stream = provider.streamChat({
prompt: 'test',
sessionId: 'test-session',
});
const chunks = await collectStream(stream);
const events = parseSSEChunks(chunks);
const errorEvent = events.find(e => e.type === 'error');
assert.ok(errorEvent, 'Should emit an error event');
assert.ok(errorEvent!.data.includes('Missing API key'), 'Error should contain the cause');
});
it('maps agent_message item to text SSE event', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'agent_message',
id: 'msg-1',
text: 'Hello from Codex!',
});
const events = parseSSEChunks(chunks);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'text');
assert.equal(events[0].data, 'Hello from Codex!');
});
it('maps command_execution item to tool_use + tool_result', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'command_execution',
id: 'cmd-1',
command: 'ls -la',
aggregated_output: 'file1.txt\nfile2.txt',
exit_code: 0,
status: 'completed',
});
const events = parseSSEChunks(chunks);
assert.equal(events.length, 2);
const toolUse = JSON.parse(events[0].data);
assert.equal(toolUse.name, 'Bash');
assert.equal(toolUse.input.command, 'ls -la');
const toolResult = JSON.parse(events[1].data);
assert.equal(toolResult.tool_use_id, 'cmd-1');
assert.equal(toolResult.is_error, false);
});
it('marks non-zero exit code as error', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'command_execution',
id: 'cmd-2',
command: 'false',
aggregated_output: '',
exit_code: 1,
});
const events = parseSSEChunks(chunks);
const toolResult = JSON.parse(events[1].data);
assert.equal(toolResult.is_error, true);
});
it('maps file_change item correctly', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'file_change',
id: 'fc-1',
changes: [
{ path: 'src/main.ts', kind: 'update' },
{ path: 'src/new.ts', kind: 'add' },
],
});
const events = parseSSEChunks(chunks);
assert.equal(events.length, 2);
const toolUse = JSON.parse(events[0].data);
assert.equal(toolUse.name, 'Edit');
const toolResult = JSON.parse(events[1].data);
assert.ok(toolResult.content.includes('update: src/main.ts'));
});
it('maps mcp_tool_call item correctly', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'mcp_tool_call',
id: 'mcp-1',
server: 'myserver',
tool: 'search',
arguments: { query: 'test' },
result: { content: 'found 3 results' },
});
const events = parseSSEChunks(chunks);
const toolUse = JSON.parse(events[0].data);
assert.equal(toolUse.name, 'mcp__myserver__search');
const toolResult = JSON.parse(events[1].data);
assert.equal(toolResult.content, 'found 3 results');
});
it('maps mcp_tool_call with structured_content', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'mcp_tool_call',
id: 'mcp-2',
server: 'myserver',
tool: 'getData',
arguments: {},
result: { structured_content: { items: [1, 2, 3] } },
});
const events = parseSSEChunks(chunks);
const toolResult = JSON.parse(events[1].data);
assert.equal(toolResult.content, JSON.stringify({ items: [1, 2, 3] }));
});
it('skips empty agent_message', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const chunks: string[] = [];
const mockController = {
enqueue: (chunk: string) => chunks.push(chunk),
} as unknown as ReadableStreamDefaultController<string>;
(provider as any).handleCompletedItem(mockController, {
type: 'agent_message',
id: 'msg-2',
text: '',
});
assert.equal(chunks.length, 0);
});
it('does not pass model by default and skips stale Claude resume id', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
let resumeCalls = 0;
let startCalls = 0;
let capturedStartOptions: Record<string, unknown> | undefined;
const mockThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0 } };
})(),
}),
};
(provider as any).sdk = { Codex: class { constructor() {} } };
(provider as any).codex = {
resumeThread: () => {
resumeCalls += 1;
return mockThread;
},
startThread: (opts: Record<string, unknown>) => {
startCalls += 1;
capturedStartOptions = opts;
return mockThread;
},
};
const stream = provider.streamChat({
prompt: 'hello',
sessionId: 'model-default-session',
sdkSessionId: 'old-claude-session-id',
model: 'claude-sonnet-4-20250514',
});
await collectStream(stream);
assert.equal(resumeCalls, 0, 'Should skip resume for stale Claude-model session in Codex runtime');
assert.equal(startCalls, 1, 'Should start a fresh Codex thread');
assert.ok(capturedStartOptions, 'startThread options should be captured');
assert.ok(!Object.prototype.hasOwnProperty.call(capturedStartOptions!, 'model'), 'Model should not be forwarded by default');
});
it('passes model only when CTI_CODEX_PASS_MODEL=true', async () => {
const old = process.env.CTI_CODEX_PASS_MODEL;
process.env.CTI_CODEX_PASS_MODEL = 'true';
try {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
let capturedStartOptions: Record<string, unknown> | undefined;
const mockThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1, cached_input_tokens: 0 } };
})(),
}),
};
(provider as any).sdk = { Codex: class { constructor() {} } };
(provider as any).codex = {
startThread: (opts: Record<string, unknown>) => {
capturedStartOptions = opts;
return mockThread;
},
};
const stream = provider.streamChat({
prompt: 'hello',
sessionId: 'model-forward-session',
model: 'gpt-5-codex',
});
await collectStream(stream);
assert.equal(capturedStartOptions?.model, 'gpt-5-codex');
} finally {
if (old === undefined) {
delete process.env.CTI_CODEX_PASS_MODEL;
} else {
process.env.CTI_CODEX_PASS_MODEL = old;
}
}
});
it('retries with fresh thread when resume fails before any events', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
let resumeCalls = 0;
let startCalls = 0;
const resumeThread = {
runStreamed: async () => {
throw new Error('resuming session with different model');
},
};
const freshThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 2, output_tokens: 3, cached_input_tokens: 0 } };
})(),
}),
};
(provider as any).sdk = { Codex: class { constructor() {} } };
(provider as any).codex = {
resumeThread: () => {
resumeCalls += 1;
return resumeThread;
},
startThread: () => {
startCalls += 1;
return freshThread;
},
};
const stream = provider.streamChat({
prompt: 'retry test',
sessionId: 'resume-retry-session',
sdkSessionId: 'codex-old-thread-id',
model: 'gpt-5-codex',
});
const chunks = await collectStream(stream);
const events = parseSSEChunks(chunks);
const errorEvent = events.find(e => e.type === 'error');
const resultEvent = events.find(e => e.type === 'result');
assert.equal(resumeCalls, 1, 'Should attempt resume once');
assert.equal(startCalls, 1, 'Should fall back to a fresh thread');
assert.ok(!errorEvent, 'Retry success should not emit error');
assert.ok(resultEvent, 'Retry success should emit result');
});
});
// ── Image input building tests ──────────────────────────────
import fs from 'node:fs';
/** Helper: build a full FileAttachment object for tests. */
function makeFile(type: string, data: string, name = 'test-file') {
return { id: `file-${Date.now()}`, name, type, size: data.length, data };
}
describe('CodexProvider image input', () => {
it('builds local_image input array for text+image', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
// Mock the SDK so we can capture the input passed to runStreamed
let capturedInput: unknown;
const mockThread = {
runStreamed: (input: unknown) => {
capturedInput = input;
return {
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 0, output_tokens: 0 } };
})(),
};
},
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
// Use valid base64 (1x1 red PNG pixel)
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
const stream = provider.streamChat({
prompt: 'Describe this image',
sessionId: 'img-session',
files: [makeFile('image/png', pngBase64, 'test.png')],
});
await collectStream(stream);
assert.ok(Array.isArray(capturedInput), 'Input should be an array for image input');
const parts = capturedInput as Array<Record<string, string>>;
assert.equal(parts.length, 2);
assert.equal(parts[0].type, 'text');
assert.equal(parts[0].text, 'Describe this image');
assert.equal(parts[1].type, 'local_image');
assert.ok(parts[1].path.endsWith('.png'), 'Temp file should have .png extension');
});
it('passes plain string when no images attached', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
let capturedInput: unknown;
const mockThread = {
runStreamed: (input: unknown) => {
capturedInput = input;
return {
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 0, output_tokens: 0 } };
})(),
};
},
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
const stream = provider.streamChat({
prompt: 'Hello',
sessionId: 'no-img-session',
});
await collectStream(stream);
assert.equal(typeof capturedInput, 'string', 'Input should be a plain string without images');
assert.equal(capturedInput, 'Hello');
});
it('builds local_image input with multiple images, ignoring non-image files', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
let capturedInput: unknown;
const mockThread = {
runStreamed: (input: unknown) => {
capturedInput = input;
return {
events: (async function* () {
yield { type: 'turn.completed', usage: { input_tokens: 0, output_tokens: 0 } };
})(),
};
},
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
const stream = provider.streamChat({
prompt: 'Compare these',
sessionId: 'multi-img-session',
files: [
makeFile('image/png', 'cG5n', 'a.png'),
makeFile('image/jpeg', 'anBn', 'b.jpg'),
makeFile('text/plain', 'dGV4dA==', 'c.txt'),
],
});
await collectStream(stream);
const parts = capturedInput as Array<Record<string, string>>;
assert.equal(parts.length, 3, 'Should have 1 text + 2 local_image parts (non-image file excluded)');
assert.equal(parts[0].type, 'text');
assert.equal(parts[1].type, 'local_image');
assert.ok(parts[1].path.endsWith('.png'));
assert.equal(parts[2].type, 'local_image');
assert.ok(parts[2].path.endsWith('.jpg'));
});
});
// ── Error event tests ───────────────────────────────────────
describe('CodexProvider error events', () => {
it('reads message field from turn.failed event', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const mockThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'turn.failed', message: 'Rate limit exceeded' };
})(),
}),
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
const stream = provider.streamChat({
prompt: 'test',
sessionId: 'err-session-1',
});
const chunks = await collectStream(stream);
const events = parseSSEChunks(chunks);
const errorEvent = events.find(e => e.type === 'error');
assert.ok(errorEvent, 'Should emit an error event');
assert.equal(errorEvent!.data, 'Rate limit exceeded');
});
it('reads message field from error event', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const mockThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'error', message: 'Connection lost' };
})(),
}),
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
const stream = provider.streamChat({
prompt: 'test',
sessionId: 'err-session-2',
});
const chunks = await collectStream(stream);
const events = parseSSEChunks(chunks);
const errorEvent = events.find(e => e.type === 'error');
assert.ok(errorEvent, 'Should emit an error event');
assert.equal(errorEvent!.data, 'Connection lost');
});
it('falls back to default message when message field is absent', async () => {
const { CodexProvider } = await import('../codex-provider.js');
const { PendingPermissions } = await import('../permission-gateway.js');
const provider = new CodexProvider(new PendingPermissions());
const mockThread = {
runStreamed: () => ({
events: (async function* () {
yield { type: 'turn.failed' };
})(),
}),
};
(provider as any).sdk = {
Codex: class { constructor() {} },
};
(provider as any).codex = {
startThread: () => mockThread,
};
const stream = provider.streamChat({
prompt: 'test',
sessionId: 'err-session-3',
});
const chunks = await collectStream(stream);
const events = parseSSEChunks(chunks);
const errorEvent = events.find(e => e.type === 'error');
assert.ok(errorEvent);
assert.equal(errorEvent!.data, 'Turn failed');
});
});
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { maskSecret, configToSettings, type Config } from '../config.js';
// ── maskSecret ──
describe('maskSecret', () => {
it('masks short values entirely', () => {
assert.equal(maskSecret('abc'), '****');
assert.equal(maskSecret('abcd'), '****');
assert.equal(maskSecret(''), '****');
});
it('preserves last 4 chars for longer values', () => {
assert.equal(maskSecret('12345678'), '****5678');
assert.equal(maskSecret('secret-token-abcd'), '*************abcd');
});
it('handles exactly 5 chars', () => {
assert.equal(maskSecret('12345'), '*2345');
});
});
// ── configToSettings ──
describe('configToSettings', () => {
const base: Config = {
runtime: 'claude',
enabledChannels: [],
defaultWorkDir: '/tmp/test',
defaultMode: 'code',
};
it('always sets remote_bridge_enabled to true', () => {
const m = configToSettings(base);
assert.equal(m.get('remote_bridge_enabled'), 'true');
});
it('sets channel enabled flags based on enabledChannels', () => {
const m = configToSettings({ ...base, enabledChannels: ['telegram', 'discord'] });
assert.equal(m.get('bridge_telegram_enabled'), 'true');
assert.equal(m.get('bridge_discord_enabled'), 'true');
assert.equal(m.get('bridge_feishu_enabled'), 'false');
});
it('maps telegram config', () => {
const m = configToSettings({
...base,
enabledChannels: ['telegram'],
tgBotToken: 'bot123:abc',
tgAllowedUsers: ['user1', 'user2'],
tgChatId: '99999',
});
assert.equal(m.get('telegram_bot_token'), 'bot123:abc');
assert.equal(m.get('telegram_bridge_allowed_users'), 'user1,user2');
assert.equal(m.get('telegram_chat_id'), '99999');
});
it('maps discord config', () => {
const m = configToSettings({
...base,
enabledChannels: ['discord'],
discordBotToken: 'discord-token',
discordAllowedUsers: ['u1'],
discordAllowedChannels: ['c1', 'c2'],
discordAllowedGuilds: ['g1'],
});
assert.equal(m.get('bridge_discord_bot_token'), 'discord-token');
assert.equal(m.get('bridge_discord_allowed_users'), 'u1');
assert.equal(m.get('bridge_discord_allowed_channels'), 'c1,c2');
assert.equal(m.get('bridge_discord_allowed_guilds'), 'g1');
});
it('maps feishu config', () => {
const m = configToSettings({
...base,
enabledChannels: ['feishu'],
feishuAppId: 'app-id',
feishuAppSecret: 'app-secret',
feishuDomain: 'example.com',
feishuAllowedUsers: ['fu1'],
});
assert.equal(m.get('bridge_feishu_app_id'), 'app-id');
assert.equal(m.get('bridge_feishu_app_secret'), 'app-secret');
assert.equal(m.get('bridge_feishu_domain'), 'example.com');
assert.equal(m.get('bridge_feishu_allowed_users'), 'fu1');
});
it('maps workdir and mode, omits model when not set', () => {
const m = configToSettings(base);
assert.equal(m.get('bridge_default_work_dir'), '/tmp/test');
assert.equal(m.has('bridge_default_model'), false);
assert.equal(m.has('default_model'), false);
assert.equal(m.get('bridge_default_mode'), 'code');
});
it('maps model when explicitly set', () => {
const m = configToSettings({ ...base, defaultModel: 'gpt-4o' });
assert.equal(m.get('bridge_default_model'), 'gpt-4o');
assert.equal(m.get('default_model'), 'gpt-4o');
});
it('maps non-default mode', () => {
const m = configToSettings({ ...base, defaultMode: 'plan' });
assert.equal(m.get('bridge_default_mode'), 'plan');
});
it('omits optional fields when not set', () => {
const m = configToSettings(base);
assert.equal(m.has('telegram_bot_token'), false);
assert.equal(m.has('bridge_discord_bot_token'), false);
assert.equal(m.has('bridge_feishu_app_id'), false);
});
});
// ── Config file parsing (loadConfig/saveConfig round-trip) ──
describe('loadConfig/saveConfig round-trip', () => {
let tmpDir: string;
let origHome: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cti-config-test-'));
origHome = process.env.HOME || '';
// We can't easily override CTI_HOME since it's a const,
// so we test the parsing logic indirectly through configToSettings
});
afterEach(() => {
process.env.HOME = origHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('configToSettings returns correct defaults', () => {
const m = configToSettings({
runtime: 'claude',
enabledChannels: [],
defaultWorkDir: process.cwd(),
defaultMode: 'code',
});
assert.equal(m.get('bridge_telegram_enabled'), 'false');
assert.equal(m.get('bridge_discord_enabled'), 'false');
assert.equal(m.get('bridge_feishu_enabled'), 'false');
});
});
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { maskSecrets } from '../logger.js';
describe('maskSecrets', () => {
it('masks token=value patterns', () => {
const input = 'token=secret123456789';
const result = maskSecrets(input);
assert.notEqual(result, input);
// Should not contain the full token
assert.ok(!result.includes('secret123456789'));
});
it('masks secret=value patterns', () => {
const input = 'secret=my-secret-value';
const result = maskSecrets(input);
assert.ok(!result.includes('my-secret-value'));
});
it('masks password=value patterns', () => {
const input = 'password=hunter2abc';
const result = maskSecrets(input);
assert.ok(!result.includes('hunter2abc'));
});
it('masks api_key=value patterns', () => {
const input = 'api_key=sk-abcdef123456';
const result = maskSecrets(input);
assert.ok(!result.includes('sk-abcdef123456'));
});
it('masks Telegram bot token format', () => {
const input = 'Using bot token bot1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ12345678a';
const result = maskSecrets(input);
assert.ok(!result.includes('bot1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ12345678a'));
});
it('masks Bearer tokens', () => {
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test.signature';
const result = maskSecrets(input);
assert.ok(!result.includes('Bearer eyJhbGciOiJIUzI1NiJ9.test.signature'));
});
it('leaves normal text unchanged', () => {
const input = 'Starting bridge on port 8080';
assert.equal(maskSecrets(input), input);
});
it('preserves last 4 chars of masked values', () => {
const input = 'token=abcdefghijklmnop';
const result = maskSecrets(input);
// The last 4 chars of the matched portion should be visible
assert.ok(result.includes('mnop'));
});
it('handles quoted values', () => {
const input = 'token="my-secret-token"';
const result = maskSecrets(input);
assert.ok(!result.includes('my-secret-token'));
});
});
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PendingPermissions } from '../permission-gateway.js';
describe('PendingPermissions', () => {
it('waitFor resolves on allow', async () => {
const pp = new PendingPermissions();
const promise = pp.waitFor('req-1');
assert.equal(pp.size, 1);
pp.resolve('req-1', { behavior: 'allow' });
const result = await promise;
assert.equal(result.behavior, 'allow');
assert.equal(pp.size, 0);
});
it('waitFor resolves on deny', async () => {
const pp = new PendingPermissions();
const promise = pp.waitFor('req-2');
pp.resolve('req-2', { behavior: 'deny', message: 'Not allowed' });
const result = await promise;
assert.equal(result.behavior, 'deny');
assert.equal(result.message, 'Not allowed');
});
it('resolve returns false for unknown id', () => {
const pp = new PendingPermissions();
assert.equal(pp.resolve('unknown', { behavior: 'allow' }), false);
});
it('resolve returns true for known id', async () => {
const pp = new PendingPermissions();
pp.waitFor('req-3');
assert.equal(pp.resolve('req-3', { behavior: 'allow' }), true);
});
it('denyAll resolves all pending', async () => {
const pp = new PendingPermissions();
const p1 = pp.waitFor('req-a');
const p2 = pp.waitFor('req-b');
assert.equal(pp.size, 2);
pp.denyAll();
const [r1, r2] = await Promise.all([p1, p2]);
assert.equal(r1.behavior, 'deny');
assert.equal(r2.behavior, 'deny');
assert.equal(pp.size, 0);
});
it('denyAll message says bridge shutting down', async () => {
const pp = new PendingPermissions();
const p = pp.waitFor('req-c');
pp.denyAll();
const result = await p;
assert.equal(result.message, 'Bridge shutting down');
});
it('timeout auto-denies after expiry', async () => {
// Create with short timeout for testing
const pp = new PendingPermissions();
// Access private field to set short timeout
(pp as any).timeoutMs = 50;
const result = await pp.waitFor('req-timeout');
assert.equal(result.behavior, 'deny');
assert.match(result.message!, /timed out/i);
assert.equal(pp.size, 0);
});
});
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { JsonFileStore } from '../store.js';
import { CTI_HOME } from '../config.js';
const DATA_DIR = path.join(CTI_HOME, 'data');
// We construct the store with a settings map directly
function makeSettings(): Map<string, string> {
return new Map([
['remote_bridge_enabled', 'true'],
['bridge_default_work_dir', '/tmp/test-cwd'],
['bridge_default_model', 'test-model'],
['bridge_default_mode', 'code'],
]);
}
describe('JsonFileStore', () => {
beforeEach(() => {
// Clean data dir before each test for isolation
fs.rmSync(DATA_DIR, { recursive: true, force: true });
});
it('getSetting returns values from settings map', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getSetting('remote_bridge_enabled'), 'true');
assert.equal(store.getSetting('bridge_default_model'), 'test-model');
assert.equal(store.getSetting('nonexistent'), null);
});
it('createSession and getSession', () => {
const store = new JsonFileStore(makeSettings());
const session = store.createSession('test', 'model-1', 'system prompt', '/tmp');
assert.ok(session.id);
assert.equal(session.model, 'model-1');
assert.equal(session.working_directory, '/tmp');
assert.equal(session.system_prompt, 'system prompt');
const fetched = store.getSession(session.id);
assert.deepEqual(fetched, session);
});
it('getSession returns null for unknown id', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getSession('nonexistent'), null);
});
it('upsertChannelBinding creates and updates', () => {
const store = new JsonFileStore(makeSettings());
const b1 = store.upsertChannelBinding({
channelType: 'telegram',
chatId: '123',
codepilotSessionId: 'sess-1',
workingDirectory: '/tmp',
model: 'model-1',
});
assert.ok(b1.id);
assert.equal(b1.channelType, 'telegram');
assert.equal(b1.chatId, '123');
// Upsert same channel+chat should update
const b2 = store.upsertChannelBinding({
channelType: 'telegram',
chatId: '123',
codepilotSessionId: 'sess-2',
workingDirectory: '/tmp/new',
model: 'model-2',
});
assert.equal(b2.id, b1.id);
assert.equal(b2.codepilotSessionId, 'sess-2');
});
it('upsertChannelBinding uses default mode from settings', () => {
const settings = makeSettings();
settings.set('bridge_default_mode', 'plan');
const store = new JsonFileStore(settings);
const b = store.upsertChannelBinding({
channelType: 'telegram',
chatId: '456',
codepilotSessionId: 'sess-1',
workingDirectory: '/tmp',
model: 'model-1',
});
assert.equal(b.mode, 'plan');
});
it('getChannelBinding returns null for missing', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getChannelBinding('telegram', 'missing'), null);
});
it('listChannelBindings filters by type', () => {
const store = new JsonFileStore(makeSettings());
store.upsertChannelBinding({
channelType: 'telegram',
chatId: '1',
codepilotSessionId: 's1',
workingDirectory: '/tmp',
model: 'm',
});
store.upsertChannelBinding({
channelType: 'discord',
chatId: '2',
codepilotSessionId: 's2',
workingDirectory: '/tmp',
model: 'm',
});
assert.equal(store.listChannelBindings('telegram').length, 1);
assert.equal(store.listChannelBindings('discord').length, 1);
assert.equal(store.listChannelBindings().length, 2);
});
it('addMessage and getMessages', () => {
const store = new JsonFileStore(makeSettings());
const session = store.createSession('test', 'model', undefined, '/tmp');
store.addMessage(session.id, 'user', 'hello');
store.addMessage(session.id, 'assistant', 'hi');
const { messages } = store.getMessages(session.id);
assert.equal(messages.length, 2);
assert.equal(messages[0].role, 'user');
assert.equal(messages[1].content, 'hi');
});
it('getMessages with limit returns last N', () => {
const store = new JsonFileStore(makeSettings());
const session = store.createSession('test', 'model', undefined, '/tmp');
store.addMessage(session.id, 'user', 'msg1');
store.addMessage(session.id, 'user', 'msg2');
store.addMessage(session.id, 'user', 'msg3');
const { messages } = store.getMessages(session.id, { limit: 2 });
assert.equal(messages.length, 2);
assert.equal(messages[0].content, 'msg2');
assert.equal(messages[1].content, 'msg3');
});
// ── Session Locking ──
it('acquireSessionLock succeeds on first call', () => {
const store = new JsonFileStore(makeSettings());
assert.ok(store.acquireSessionLock('sess', 'lock1', 'owner1', 60));
});
it('acquireSessionLock fails when held by another', () => {
const store = new JsonFileStore(makeSettings());
assert.ok(store.acquireSessionLock('sess', 'lock1', 'owner1', 60));
assert.equal(store.acquireSessionLock('sess', 'lock2', 'owner2', 60), false);
});
it('acquireSessionLock succeeds with same lockId', () => {
const store = new JsonFileStore(makeSettings());
assert.ok(store.acquireSessionLock('sess', 'lock1', 'owner1', 60));
assert.ok(store.acquireSessionLock('sess', 'lock1', 'owner1', 60));
});
it('releaseSessionLock allows re-acquire', () => {
const store = new JsonFileStore(makeSettings());
store.acquireSessionLock('sess', 'lock1', 'owner1', 60);
store.releaseSessionLock('sess', 'lock1');
assert.ok(store.acquireSessionLock('sess', 'lock2', 'owner2', 60));
});
it('expired lock can be re-acquired', async () => {
const store = new JsonFileStore(makeSettings());
// Acquire with very short TTL
store.acquireSessionLock('sess', 'lock1', 'owner1', 0);
// Should be expired immediately
await new Promise((r) => setTimeout(r, 10));
assert.ok(store.acquireSessionLock('sess', 'lock2', 'owner2', 60));
});
// ── Permission Links ──
it('insertPermissionLink and getPermissionLink', () => {
const store = new JsonFileStore(makeSettings());
store.insertPermissionLink({
permissionRequestId: 'pr-1',
channelType: 'telegram',
chatId: '123',
messageId: 'msg-1',
toolName: 'bash',
suggestions: 'allow,deny',
});
const link = store.getPermissionLink('pr-1');
assert.ok(link);
assert.equal(link.permissionRequestId, 'pr-1');
assert.equal(link.resolved, false);
});
it('markPermissionLinkResolved is atomic', () => {
const store = new JsonFileStore(makeSettings());
store.insertPermissionLink({
permissionRequestId: 'pr-2',
channelType: 'telegram',
chatId: '123',
messageId: 'msg-2',
toolName: 'bash',
suggestions: '',
});
assert.ok(store.markPermissionLinkResolved('pr-2'));
// Second call returns false (already resolved)
assert.equal(store.markPermissionLinkResolved('pr-2'), false);
// Unknown id returns false
assert.equal(store.markPermissionLinkResolved('unknown'), false);
});
// ── Dedup ──
it('dedup insert and check within window', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.checkDedup('key1'), false);
store.insertDedup('key1');
assert.equal(store.checkDedup('key1'), true);
});
it('cleanupExpiredDedup removes old entries', () => {
const store = new JsonFileStore(makeSettings());
store.insertDedup('key1');
// The entry was just inserted so it shouldn't be expired
store.cleanupExpiredDedup();
assert.equal(store.checkDedup('key1'), true);
});
// ── Audit Log ──
it('insertAuditLog keeps max 1000', () => {
const store = new JsonFileStore(makeSettings());
for (let i = 0; i < 1010; i++) {
store.insertAuditLog({
channelType: 'telegram',
chatId: '123',
direction: 'inbound',
messageId: `msg-${i}`,
summary: `msg ${i}`,
});
}
// We can't directly inspect length, but it shouldn't crash
});
// ── Channel Offsets ──
it('getChannelOffset returns default for unknown key', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getChannelOffset('unknown'), '0');
});
it('setChannelOffset and getChannelOffset round-trip', () => {
const store = new JsonFileStore(makeSettings());
store.setChannelOffset('tg:offset', '12345');
assert.equal(store.getChannelOffset('tg:offset'), '12345');
});
// ── SDK Session ──
it('updateSdkSessionId updates session and bindings', () => {
const store = new JsonFileStore(makeSettings());
const session = store.createSession('test', 'model', undefined, '/tmp');
store.upsertChannelBinding({
channelType: 'telegram',
chatId: '1',
codepilotSessionId: session.id,
workingDirectory: '/tmp',
model: 'model',
});
store.updateSdkSessionId(session.id, 'sdk-123');
const binding = store.getChannelBinding('telegram', '1');
assert.equal(binding?.sdkSessionId, 'sdk-123');
});
it('updateSessionModel updates model', () => {
const store = new JsonFileStore(makeSettings());
const session = store.createSession('test', 'model-old', undefined, '/tmp');
store.updateSessionModel(session.id, 'model-new');
const updated = store.getSession(session.id);
assert.equal(updated?.model, 'model-new');
});
// ── Provider (no-op) ──
it('getProvider returns undefined', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getProvider('any'), undefined);
});
it('getDefaultProviderId returns null', () => {
const store = new JsonFileStore(makeSettings());
assert.equal(store.getDefaultProviderId(), null);
});
});
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export interface Config {
runtime: 'claude' | 'codex' | 'auto';
enabledChannels: string[];
defaultWorkDir: string;
defaultModel?: string;
defaultMode: string;
// Telegram
tgBotToken?: string;
tgChatId?: string;
tgAllowedUsers?: string[];
// Feishu
feishuAppId?: string;
feishuAppSecret?: string;
feishuDomain?: string;
feishuAllowedUsers?: string[];
// Discord
discordBotToken?: string;
discordAllowedUsers?: string[];
discordAllowedChannels?: string[];
discordAllowedGuilds?: string[];
// Auto-approve all tool permission requests without user confirmation
autoApprove?: boolean;
}
export const CTI_HOME = process.env.CTI_HOME || path.join(os.homedir(), ".claude-to-im");
export const CONFIG_PATH = path.join(CTI_HOME, "config.env");
function parseEnvFile(content: string): Map<string, string> {
const entries = new Map<string, string>();
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// Strip surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
entries.set(key, value);
}
return entries;
}
function splitCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export function loadConfig(): Config {
let env = new Map<string, string>();
try {
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
env = parseEnvFile(content);
} catch {
// Config file doesn't exist yet — use defaults
}
const rawRuntime = env.get("CTI_RUNTIME") || "claude";
const runtime = (["claude", "codex", "auto"].includes(rawRuntime) ? rawRuntime : "claude") as Config["runtime"];
return {
runtime,
enabledChannels: splitCsv(env.get("CTI_ENABLED_CHANNELS")) ?? [],
defaultWorkDir: env.get("CTI_DEFAULT_WORKDIR") || process.cwd(),
defaultModel: env.get("CTI_DEFAULT_MODEL") || undefined,
defaultMode: env.get("CTI_DEFAULT_MODE") || "code",
tgBotToken: env.get("CTI_TG_BOT_TOKEN") || undefined,
tgChatId: env.get("CTI_TG_CHAT_ID") || undefined,
tgAllowedUsers: splitCsv(env.get("CTI_TG_ALLOWED_USERS")),
feishuAppId: env.get("CTI_FEISHU_APP_ID") || undefined,
feishuAppSecret: env.get("CTI_FEISHU_APP_SECRET") || undefined,
feishuDomain: env.get("CTI_FEISHU_DOMAIN") || undefined,
feishuAllowedUsers: splitCsv(env.get("CTI_FEISHU_ALLOWED_USERS")),
discordBotToken: env.get("CTI_DISCORD_BOT_TOKEN") || undefined,
discordAllowedUsers: splitCsv(env.get("CTI_DISCORD_ALLOWED_USERS")),
discordAllowedChannels: splitCsv(
env.get("CTI_DISCORD_ALLOWED_CHANNELS")
),
discordAllowedGuilds: splitCsv(env.get("CTI_DISCORD_ALLOWED_GUILDS")),
autoApprove: env.get("CTI_AUTO_APPROVE") === "true",
};
}
function formatEnvLine(key: string, value: string | undefined): string {
if (value === undefined || value === "") return "";
return `${key}=${value}\n`;
}
export function saveConfig(config: Config): void {
let out = "";
out += formatEnvLine("CTI_RUNTIME", config.runtime);
out += formatEnvLine(
"CTI_ENABLED_CHANNELS",
config.enabledChannels.join(",")
);
out += formatEnvLine("CTI_DEFAULT_WORKDIR", config.defaultWorkDir);
if (config.defaultModel) out += formatEnvLine("CTI_DEFAULT_MODEL", config.defaultModel);
out += formatEnvLine("CTI_DEFAULT_MODE", config.defaultMode);
out += formatEnvLine("CTI_TG_BOT_TOKEN", config.tgBotToken);
out += formatEnvLine("CTI_TG_CHAT_ID", config.tgChatId);
out += formatEnvLine(
"CTI_TG_ALLOWED_USERS",
config.tgAllowedUsers?.join(",")
);
out += formatEnvLine("CTI_FEISHU_APP_ID", config.feishuAppId);
out += formatEnvLine("CTI_FEISHU_APP_SECRET", config.feishuAppSecret);
out += formatEnvLine("CTI_FEISHU_DOMAIN", config.feishuDomain);
out += formatEnvLine(
"CTI_FEISHU_ALLOWED_USERS",
config.feishuAllowedUsers?.join(",")
);
out += formatEnvLine("CTI_DISCORD_BOT_TOKEN", config.discordBotToken);
out += formatEnvLine(
"CTI_DISCORD_ALLOWED_USERS",
config.discordAllowedUsers?.join(",")
);
out += formatEnvLine(
"CTI_DISCORD_ALLOWED_CHANNELS",
config.discordAllowedChannels?.join(",")
);
out += formatEnvLine(
"CTI_DISCORD_ALLOWED_GUILDS",
config.discordAllowedGuilds?.join(",")
);
fs.mkdirSync(CTI_HOME, { recursive: true });
const tmpPath = CONFIG_PATH + ".tmp";
fs.writeFileSync(tmpPath, out, { mode: 0o600 });
fs.renameSync(tmpPath, CONFIG_PATH);
}
export function maskSecret(value: string): string {
if (value.length <= 4) return "****";
return "*".repeat(value.length - 4) + value.slice(-4);
}
export function configToSettings(config: Config): Map<string, string> {
const m = new Map<string, string>();
m.set("remote_bridge_enabled", "true");
// ── Telegram ──
// Upstream keys: telegram_bot_token, bridge_telegram_enabled,
// telegram_bridge_allowed_users, telegram_chat_id
m.set(
"bridge_telegram_enabled",
config.enabledChannels.includes("telegram") ? "true" : "false"
);
if (config.tgBotToken) m.set("telegram_bot_token", config.tgBotToken);
if (config.tgAllowedUsers)
m.set("telegram_bridge_allowed_users", config.tgAllowedUsers.join(","));
if (config.tgChatId) m.set("telegram_chat_id", config.tgChatId);
// ── Discord ──
// Upstream keys: bridge_discord_bot_token, bridge_discord_enabled,
// bridge_discord_allowed_users, bridge_discord_allowed_channels,
// bridge_discord_allowed_guilds
m.set(
"bridge_discord_enabled",
config.enabledChannels.includes("discord") ? "true" : "false"
);
if (config.discordBotToken)
m.set("bridge_discord_bot_token", config.discordBotToken);
if (config.discordAllowedUsers)
m.set("bridge_discord_allowed_users", config.discordAllowedUsers.join(","));
if (config.discordAllowedChannels)
m.set(
"bridge_discord_allowed_channels",
config.discordAllowedChannels.join(",")
);
if (config.discordAllowedGuilds)
m.set(
"bridge_discord_allowed_guilds",
config.discordAllowedGuilds.join(",")
);
// ── Feishu ──
// Upstream keys: bridge_feishu_app_id, bridge_feishu_app_secret,
// bridge_feishu_domain, bridge_feishu_enabled, bridge_feishu_allowed_users
m.set(
"bridge_feishu_enabled",
config.enabledChannels.includes("feishu") ? "true" : "false"
);
if (config.feishuAppId) m.set("bridge_feishu_app_id", config.feishuAppId);
if (config.feishuAppSecret)
m.set("bridge_feishu_app_secret", config.feishuAppSecret);
if (config.feishuDomain) m.set("bridge_feishu_domain", config.feishuDomain);
if (config.feishuAllowedUsers)
m.set("bridge_feishu_allowed_users", config.feishuAllowedUsers.join(","));
// ── Defaults ──
// Upstream keys: bridge_default_work_dir, bridge_default_model, default_model
m.set("bridge_default_work_dir", config.defaultWorkDir);
if (config.defaultModel) {
m.set("bridge_default_model", config.defaultModel);
m.set("default_model", config.defaultModel);
}
m.set("bridge_default_mode", config.defaultMode);
return m;
}
/**
* SSE Utilities — helpers for formatting Server-Sent Event strings.
*
* Used by LLMProvider implementations to produce the SSE stream format
* consumed by the bridge conversation engine.
*/
export function sseEvent(type: string, data: unknown): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
return `data: ${JSON.stringify({ type, data: payload })}\n`;
}