diff --git a/.gitignore b/.gitignore index 157d056..b747105 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ .DS_Store npm-debug.log* .codex-telegram-claws-state.json +bridge/claude-to-im/dist/ +bridge/claude-to-im/config.env +bridge/claude-to-im/.env +bridge/claude-to-im/.env.* diff --git a/README.md b/README.md index 0bdde3d..d1e654b 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,21 @@ Development mode: npm run dev ``` +### Feishu Bridge (Card Permission Approval) + +This repo now vendors a full `claude-to-im` runtime under `bridge/claude-to-im`, +so you can run Feishu with card-based permission approval flow from this project. +Default bridge home is isolated at `~/.codexclaw-bridge` to avoid conflicts with existing standalone `claude-to-im` installs. + +```bash +npm run feishu:setup +npm run feishu:install +npm run feishu:start +npm run feishu:status +``` + +More details: `docs/feishu-bridge.md` + Validation: ```bash diff --git a/bridge/claude-to-im/.gitignore b/bridge/claude-to-im/.gitignore new file mode 100644 index 0000000..adab65e --- /dev/null +++ b/bridge/claude-to-im/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +.env +config.env diff --git a/bridge/claude-to-im/CLAUDE.md b/bridge/claude-to-im/CLAUDE.md new file mode 100644 index 0000000..0559986 --- /dev/null +++ b/bridge/claude-to-im/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md — Project Guidelines for claude-to-im-skill + +## Replying to GitHub Issues + +When replying to user-reported issues, always include a **self-help prompt** at the end of the reply. Guide users to use their AI coding assistant (Claude Code / Codex) to diagnose and fix the problem themselves. Example: + +> **自助排查提示:** 你可以直接在 Claude Code(或 Codex)中发送以下提示,让 AI 帮你诊断问题: +> +> ``` +> 请帮我排查 claude-to-im 桥接服务的问题。 +> 1. 读取 ~/.claude-to-im/logs/bridge.log 最近 50 行日志 +> 2. 读取 ~/.claude-to-im/config.env 检查配置是否正确 +> 3. 运行 bash ~/.claude/skills/claude-to-im/scripts/doctor.sh 并分析输出 +> 4. 根据日志和配置给出具体的修复建议 +> ``` + +This approach: +- Reduces maintainer burden by enabling users to self-diagnose +- Leverages the fact that users already have an AI coding assistant installed +- Provides actionable next steps rather than just error explanations diff --git a/bridge/claude-to-im/LICENSE b/bridge/claude-to-im/LICENSE new file mode 100644 index 0000000..eb629d5 --- /dev/null +++ b/bridge/claude-to-im/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/bridge/claude-to-im/README.md b/bridge/claude-to-im/README.md new file mode 100644 index 0000000..bb4d1ab --- /dev/null +++ b/bridge/claude-to-im/README.md @@ -0,0 +1,255 @@ +# Claude-to-IM Skill + +Bridge Claude Code / Codex to IM platforms — chat with AI coding agents from Telegram, Discord, Feishu/Lark, or QQ. + +[中文文档](README_CN.md) + +> **Want a desktop GUI instead?** Check out [CodePilot](https://github.com/op7418/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/QQ) + ↕ Bot API +Background Daemon (Node.js) + ↕ Claude Agent SDK or Codex SDK (configurable via CTI_RUNTIME) +Claude Code / Codex → reads/writes your codebase +``` + +## Features + +- **Four IM platforms** — Telegram, Discord, Feishu/Lark, QQ — enable any combination +- **Interactive setup** — guided wizard collects tokens with step-by-step instructions +- **Permission control** — tool calls require explicit approval via inline buttons (Telegram/Discord) or text `/perm` commands (Feishu/QQ) +- **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=claude` or `auto`) — installed and authenticated (`claude` command available) +- **Codex CLI** (for `CTI_RUNTIME=codex` or `auto`) — `npm install -g @openai/codex`. Auth: run `codex auth login`, or set `OPENAI_API_KEY` (optional, for API mode) + +## Installation + +### npx skills (recommended) + +```bash +npx skills add op7418/Claude-to-IM-skill +``` + +### Git clone + +```bash +git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.claude/skills/claude-to-im +``` + +Clones 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): + +```bash +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-im +``` + +### Codex + +If you use [Codex](https://github.com/openai/codex), clone directly into the Codex skills directory: + +```bash +git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.codex/skills/claude-to-im +``` + +Or use the provided install script for automatic dependency installation and build: + +```bash +# 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 --link +``` + +### Verify 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 setup +``` + +The wizard will guide you through: + +1. **Choose channels** — pick Telegram, Discord, Feishu, QQ, 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 start +``` + +The 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 (Telegram/Discord), or a text `/perm` command prompt (Feishu/QQ). + +## 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](https://discord.com/developers/applications) → 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](https://open.feishu.cn/app) (or [Lark](https://open.larksuite.com/app)) +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 + +### QQ + +> QQ currently supports **C2C private chat only**. No group/channel support, no inline permission buttons, no streaming preview. Permissions use text `/perm ...` commands. Image inbound only (no image replies). + +1. Go to [QQ Bot OpenClaw](https://q.qq.com/qqbot/openclaw) +2. Create a QQ Bot or select an existing one → get **App ID** and **App Secret** (only two required fields) +3. Configure sandbox access and scan QR code with QQ to add the bot +4. `CTI_QQ_ALLOWED_USERS` takes `user_openid` values (not QQ numbers) — can be left empty initially +5. Set `CTI_QQ_IMAGE_ENABLED=false` if the underlying provider doesn't support image input + +## 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 status +``` + +### Key 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 IM +``` + +## Troubleshooting + +Run diagnostics: + +``` +/claude-to-im doctor +``` + +This 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](references/troubleshooting.md) for more details. + +## Security + +- All credentials stored in `~/.claude-to-im/config.env` with `chmod 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](SECURITY.md) for threat model and incident response + +## Development + +```bash +npm install # Install dependencies +npm run dev # Run in dev mode +npm run typecheck # Type check +npm test # Run tests +npm run build # Build bundle +``` + +## License + +[MIT](LICENSE) diff --git a/bridge/claude-to-im/README_CN.md b/bridge/claude-to-im/README_CN.md new file mode 100644 index 0000000..35941bb --- /dev/null +++ b/bridge/claude-to-im/README_CN.md @@ -0,0 +1,255 @@ +# Claude-to-IM Skill + +将 Claude Code / Codex 桥接到 IM 平台 —— 在 Telegram、Discord、飞书或 QQ 中与 AI 编程代理对话。 + +[English](README.md) + +> **想要桌面图形界面?** 试试 [CodePilot](https://github.com/op7418/CodePilot) —— 一个功能完整的桌面应用,提供可视化聊天界面、会话管理、文件树预览、权限控制等。本 Skill 从 CodePilot 的 IM 桥接模块中提取而来,适合偏好轻量级纯 CLI 方案的用户。 + +--- + +## 工作原理 + +本 Skill 运行一个后台守护进程,将你的 IM 机器人连接到 Claude Code 或 Codex 会话。来自 IM 的消息被转发给 AI 编程代理,响应(包括工具调用、权限请求、流式预览)会发回到聊天中。 + +``` +你 (Telegram/Discord/飞书/QQ) + ↕ Bot API +后台守护进程 (Node.js) + ↕ Claude Agent SDK 或 Codex SDK(通过 CTI_RUNTIME 配置) +Claude Code / Codex → 读写你的代码库 +``` + +## 功能特点 + +- **四大 IM 平台** — Telegram、Discord、飞书、QQ,可任意组合启用 +- **交互式配置** — 引导式向导逐步收集 token,附带详细获取说明 +- **权限控制** — 工具调用需要在聊天中通过内联按钮(Telegram/Discord)或文本 `/perm` 命令(飞书/QQ)明确批准 +- **流式预览** — 实时查看 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(推荐) + +```bash +npx skills add op7418/Claude-to-IM-skill +``` + +### Git 克隆 + +```bash +git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.claude/skills/claude-to-im +``` + +将仓库直接克隆到个人 Skills 目录,Claude Code 会自动发现。 + +### 符号链接方式 + +如果你想把仓库放在其他位置(比如方便开发): + +```bash +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-im +``` + +### Codex + +如果你使用 [Codex](https://github.com/openai/codex),直接克隆到 Codex skills 目录: + +```bash +git clone https://github.com/op7418/Claude-to-IM-skill.git ~/.codex/skills/claude-to-im +``` + +或使用提供的安装脚本,自动安装依赖并构建: + +```bash +# 克隆并安装(复制模式) +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、飞书、QQ,或任意组合 +2. **输入凭据** — 向导会详细说明如何获取每个 token、需要开启哪些设置、授予哪些权限 +3. **设置默认值** — 工作目录、模型、模式 +4. **验证** — 立即通过平台 API 验证 token 有效性 + +### 2. 启动 + +``` +/claude-to-im start +``` + +守护进程在后台启动。关闭终端后仍会继续运行。 + +### 3. 开始聊天 + +打开 IM 应用,给你的机器人发消息,Claude Code 会回复。 + +当 Claude 需要使用工具(编辑文件、运行命令)时,聊天中会弹出带有 **允许** / **拒绝** 按钮的权限请求(Telegram/Discord),或文本 `/perm` 命令提示(飞书/QQ)。 + +## 命令列表 + +所有命令在 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 开发者门户](https://discord.com/developers/applications) → 新建应用 +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. 前往[飞书开放平台](https://open.feishu.cn/app)(或 [Lark](https://open.larksuite.com/app)) +2. 创建自建应用 → 获取 App ID 和 App Secret +3. **批量添加权限**:进入"权限管理" → 使用批量配置添加所有必需权限(`setup` 向导提供完整 JSON) +4. 在"添加应用能力"中启用机器人 +5. **事件与回调**:选择**长连接**作为事件订阅方式 → 添加 `im.message.receive_v1` 事件 +6. **发布**:进入"版本管理与发布" → 创建版本 → 提交审核 → 在管理后台审核通过 +7. **注意**:版本审核通过并发布后机器人才能使用 + +### QQ + +> QQ 目前仅支持 **C2C 私聊**(沙箱接入)。不支持群聊/频道、内联权限按钮、流式预览。权限确认使用文本 `/perm ...` 命令。仅支持图片入站(不支持图片回复)。 + +1. 前往 [QQ 机器人 OpenClaw](https://q.qq.com/qqbot/openclaw) +2. 创建或选择已有 QQ 机器人 → 获取 **App ID** 和 **App Secret**(仅需这两个必填项) +3. 配置沙箱接入,用 QQ 扫码添加机器人 +4. `CTI_QQ_ALLOWED_USERS` 填写 `user_openid`(不是 QQ 号)— 可先留空 +5. 如果底层 provider 不支持图片输入,设置 `CTI_QQ_IMAGE_ENABLED=false` + +## 架构 + +``` +~/.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](references/troubleshooting.md)。 + +## 安全 + +- 所有凭据存储在 `~/.claude-to-im/config.env`,权限 `chmod 600` +- 日志输出中 token 自动脱敏(基于正则匹配) +- 允许用户/频道/服务器列表限制谁可以与机器人交互 +- 守护进程是本地进程,没有入站网络监听 +- 详见 [SECURITY.md](SECURITY.md) 了解威胁模型和应急响应 + +## 开发 + +```bash +npm install # 安装依赖 +npm run dev # 开发模式运行 +npm run typecheck # 类型检查 +npm test # 运行测试 +npm run build # 构建打包 +``` + +## 许可 + +[MIT](LICENSE) diff --git a/bridge/claude-to-im/SECURITY.md b/bridge/claude-to-im/SECURITY.md new file mode 100644 index 0000000..0fae58d --- /dev/null +++ b/bridge/claude-to-im/SECURITY.md @@ -0,0 +1,50 @@ +# 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 +- `reconfigure` command display +- `logs` command 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 diff --git a/bridge/claude-to-im/SKILL.md b/bridge/claude-to-im/SKILL.md new file mode 100644 index 0000000..dadc856 --- /dev/null +++ b/bridge/claude-to-im/SKILL.md @@ -0,0 +1,178 @@ +--- +name: claude-to-im +description: | + Bridge THIS Claude Code session to Telegram, Discord, Feishu/Lark, or QQ so the + user can chat with Claude from their phone. Use for: setting up, starting, stopping, + or diagnosing the claude-to-im bridge daemon; forwarding Claude replies to a messaging + app; any phrase like "claude-to-im", "bridge", "消息推送", "消息转发", "桥接", + "连上飞书", "手机上看claude", "启动后台服务", "诊断", "查看日志", "配置". + Subcommands: setup, start, stop, status, logs, reconfigure, doctor. + Do NOT use for: building standalone bots, webhook integrations, or coding with IM + platform SDKs — those are regular programming tasks. +argument-hint: "setup | start | stop | status | logs [N] | reconfigure | doctor" +allowed-tools: + - Bash + - Read + - Write + - Edit + - AskUserQuestion + - Grep + - Glob +--- + +# Claude-to-IM Bridge Skill + +You are managing the Claude-to-IM bridge. +User data is stored at `~/.claude-to-im/`. + +The skill directory (SKILL_DIR) is at `~/.claude/skills/claude-to-im`. +If that path doesn't exist, fall back to Glob with pattern `**/skills/**/claude-to-im/SKILL.md` and derive the root from the result. + +## Command parsing + +Parse the user's intent from `$ARGUMENTS` into one of these subcommands: + +| User says (examples) | Subcommand | +|---|---| +| `setup`, `configure`, `配置`, `我想在飞书上用 Claude`, `帮我连接 Telegram` | setup | +| `start`, `start bridge`, `启动`, `启动桥接` | start | +| `stop`, `stop bridge`, `停止`, `停止桥接` | stop | +| `status`, `bridge status`, `状态`, `运行状态`, `怎么看桥接的运行状态` | status | +| `logs`, `logs 200`, `查看日志`, `查看日志 200` | logs | +| `reconfigure`, `修改配置`, `帮我改一下 token`, `换个 bot` | reconfigure | +| `doctor`, `diagnose`, `诊断`, `挂了`, `没反应了`, `bot 没反应`, `出问题了` | doctor | + +**Disambiguation: `status` vs `doctor`** — Use `status` when the user just wants to check if the bridge is running (informational). Use `doctor` when the user reports a problem or suspects something is broken (diagnostic). When in doubt and the user describes a symptom (e.g., "没反应了", "挂了"), prefer `doctor`. + +Extract optional numeric argument for `logs` (default 50). + +Before asking users for any platform credentials, read `SKILL_DIR/references/setup-guides.md` internally so you know where to find each credential. Do NOT dump the full guide to the user upfront — only mention the specific next step they need to do (e.g., "Go to https://open.feishu.cn → your app → Credentials to find the App ID"). If the user says they don't know how, then show the relevant section of the guide. + +## 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 `setup` wizard using AskUserQuestion. + - In Codex: tell the user "No configuration found. Please create `~/.claude-to-im/config.env` based on the example:" then show the contents of `SKILL_DIR/config.env.example` and stop. Don't attempt to start the daemon — without config.env the process will crash on startup and leave behind a stale PID file that blocks future starts. +- **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, qq). 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. Streaming cards, tool progress, inline permission buttons. +- **qq** — QQ C2C private chat only. No inline permission buttons, no streaming preview. Permissions use text `/perm ...` commands. + +**Step 2 — Collect tokens per channel** + +For each enabled channel, collect one credential at a time. Tell the user where to find each value in one sentence. Only show the full guide section (from `SKILL_DIR/references/setup-guides.md`) if the user asks for help or says they don't know how: + +- **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). After collecting credentials, explain the two-phase setup the user must complete: + - **Phase 1** (before starting bridge): (A) batch-add permissions, (B) enable bot capability, (C) publish first version + admin approve. This makes permissions and bot effective. + - **Phase 2** (requires running bridge): (D) run `/claude-to-im start`, (E) configure events (`im.message.receive_v1`) and callback (`card.action.trigger`) with long connection mode, (F) publish second version + admin approve. + - **Why two phases:** Feishu validates WebSocket connection when saving event subscription — if the bridge isn't running, saving will fail. The bridge needs published permissions to connect. + - Keep this to a short checklist — show the full guide only if asked. +- **QQ**: Collect two required fields, then optional ones: + 1. QQ App ID (required) → confirm + 2. QQ App Secret (required) → confirm (masked) + - Tell the user: these two values can be found at https://q.qq.com/qqbot/openclaw + 3. Allowed User OpenIDs (optional, press Enter to skip) — note: this is `user_openid`, NOT QQ number. If the user doesn't have openid yet, they can leave it empty. + 4. Image Enabled (optional, default true, press Enter to skip) — if the underlying provider doesn't support image input, set to false + 5. Max Image Size MB (optional, default 20, press Enter to skip) + - Remind user: QQ first version only supports C2C private chat sandbox access. No group/channel support, no inline buttons, no streaming preview. + +**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 (requires `claude` CLI installed) + - `codex` — uses OpenAI Codex SDK (requires `codex` CLI; auth via `codex auth login` or `OPENAI_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 — read `SKILL_DIR/references/token-validation.md` for the exact commands and expected responses for each platform. This catches typos and wrong credentials before the user tries to start the daemon. +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). Without it, the daemon will crash immediately and leave a stale PID file. + +Run: `bash "SKILL_DIR/scripts/daemon.sh" start` + +Show the output to the user. If it fails, tell the user: +- Run `doctor` to 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, tell the user where to find the value; only show the full guide from `SKILL_DIR/references/setup-guides.md` if they ask for help +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` + +For more complex issues (messages not received, permission timeouts, high memory, stale PID files), read `SKILL_DIR/references/troubleshooting.md` for detailed diagnosis steps. + +**Feishu upgrade note:** If the user upgraded from an older version of this skill and Feishu is returning permission errors (e.g. streaming cards not working, typing indicators failing, permission buttons unresponsive), the root cause is almost certainly missing permissions or callbacks in the Feishu backend. Refer the user to the "Upgrading from a previous version" section in `SKILL_DIR/references/setup-guides.md` — they need to add new scopes (`cardkit:card:write`, `cardkit:card:read`, `im:message:update`, `im:message.reactions:read`, `im:message.reactions:write_only`), add the `card.action.trigger` callback, and re-publish the app. The upgrade requires two publish cycles because adding the callback needs an active WebSocket connection (bridge must be running). + +## Notes + +- Always mask secrets in output (show only last 4 characters) — users often share terminal output in bug reports, so exposed tokens would be a security incident. +- Always check for config.env before starting the daemon — without it the process crashes on startup and leaves a stale PID file that blocks future starts (requiring manual cleanup). +- 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. diff --git a/bridge/claude-to-im/config.env.example b/bridge/claude-to-im/config.env.example new file mode 100644 index 0000000..7c30acc --- /dev/null +++ b/bridge/claude-to-im/config.env.example @@ -0,0 +1,77 @@ +# 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,qq) +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 + +# ── Claude CLI path (optional) ── +# Override if you have multiple `claude` versions in PATH, or the daemon +# picks the wrong one (e.g. an npm-installed 1.x instead of the native 2.x). +# CTI_CLAUDE_CODE_EXECUTABLE=/path/to/claude + +# ── Third-party API provider (optional) ── +# If you use Claude through a third-party API provider (not the default +# Anthropic API), set these so the daemon forwards them to the CLI subprocess. +# They are automatically passed through in both inherit and strict env modes. +# All ANTHROPIC_* vars in this file are forwarded to the launchd/setsid daemon. +# ANTHROPIC_API_KEY=your-third-party-api-key +# ANTHROPIC_BASE_URL=https://your-api-provider.com/v1 +# ANTHROPIC_AUTH_TOKEN=your-auth-token + +# ── 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 + +# ── QQ ── +# Required: obtain from https://q.qq.com/qqbot/openclaw +# CTI_QQ_APP_ID=your-qq-app-id +# CTI_QQ_APP_SECRET=your-qq-app-secret +# Allowed users — comma-separated user_openid values (NOT QQ numbers) +# CTI_QQ_ALLOWED_USERS=openid_1,openid_2 +# Image input — set to false if the underlying provider doesn't support image input +# CTI_QQ_IMAGE_ENABLED=true +# Max image size in MB (default 20) +# CTI_QQ_MAX_IMAGE_SIZE=20 + +# ── 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 diff --git a/bridge/claude-to-im/evals/evals.json b/bridge/claude-to-im/evals/evals.json new file mode 100644 index 0000000..0dd0b0d --- /dev/null +++ b/bridge/claude-to-im/evals/evals.json @@ -0,0 +1,29 @@ +{ + "skill_name": "claude-to-im", + "evals": [ + { + "id": 1, + "prompt": "帮我设置 Telegram 机器人,我想用手机给 Claude 发消息", + "expected_output": "Should trigger the skill, identify 'setup' subcommand, read setup-guides.md, and start the interactive Telegram setup wizard asking for Bot Token", + "files": [] + }, + { + "id": 2, + "prompt": "start bridge", + "expected_output": "Should trigger the skill, identify 'start' subcommand, check for config.env existence, and attempt to run daemon.sh start", + "files": [] + }, + { + "id": 3, + "prompt": "桥接服务好像挂了,帮我看看怎么回事", + "expected_output": "Should trigger the skill, identify 'doctor' subcommand, and run doctor.sh to diagnose issues", + "files": [] + }, + { + "id": 4, + "prompt": "查看日志 200", + "expected_output": "Should trigger the skill, identify 'logs' subcommand with N=200, and run daemon.sh logs 200", + "files": [] + } + ] +} diff --git a/bridge/claude-to-im/package-lock.json b/bridge/claude-to-im/package-lock.json new file mode 100644 index 0000000..c417f92 --- /dev/null +++ b/bridge/claude-to-im/package-lock.json @@ -0,0 +1,2440 @@ +{ + "name": "claude-to-im-skill", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-to-im-skill", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", + "claude-to-im": "github:op7418/claude-to-im" + }, + "devDependencies": { + "@types/node": "^22", + "esbuild": "^0.25.0", + "tsx": "^4.21.0", + "typescript": "^5" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@openai/codex-sdk": "^0.110.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.69", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.69.tgz", + "integrity": "sha512-d1ZadIwC5PUBMQRK4Y/EC/Fm9xv/nvZ4g0XXa+vC0p+vKOCxhf4USdVKjuDzPM0z0f2qqZZZdxMjFuwa7paKRA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@larksuiteoapi/node-sdk": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.59.0.tgz", + "integrity": "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==", + "license": "MIT", + "dependencies": { + "axios": "~1.13.3", + "lodash.identity": "^3.0.0", + "lodash.merge": "^4.6.2", + "lodash.pickby": "^4.6.0", + "protobufjs": "^7.2.6", + "qs": "^6.14.2", + "ws": "^8.19.0" + } + }, + "node_modules/@openai/codex": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0.tgz", + "integrity": "sha512-BhhiFkLAWwFZ4vo2WK7rlV5PoicndbIjONamPsHyp6xz5bvLQC5l2IgB4FgGpkuRywLxc5MnNlrziyhABNblcg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.110.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.110.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.110.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.110.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.110.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.110.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.110.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-darwin-arm64.tgz", + "integrity": "sha512-yCrO3HLpohNM5zqqEzJhqtBBAH4r9nHb+h7F0+2/T0qbCJwxzL6XtgjQonvKW9Ol6qc3kqlW7uQVFEX+z13Ydg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.110.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-darwin-x64.tgz", + "integrity": "sha512-UgDkVR+9Ffky60Paygc48NjGjMpEi8HBl/GtVFuhYprvcFep0qodjcL6/K3NmmIpDOBoF4PtkjUFB/sATklfvw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.110.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-linux-arm64.tgz", + "integrity": "sha512-Mswcq2dmt1S+d2DeaxLUOu9AfVjs0O5b4M8fqvNzSeWozY6dw905JnU+nOKPlqP4xN4K6OK+H+zjSDFJXsmS6w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.110.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-linux-x64.tgz", + "integrity": "sha512-o9xIcgU9U5mpljQx2b6v763qz3dU3eugxWCZMid6YU8hc1VZvR9hkWMFL0kaboYOKFkpZPLGg1bUFSWfsmZN+A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-sdk": { + "version": "0.110.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.110.0.tgz", + "integrity": "sha512-UT8OaFAOWBP6uXdpJkpKO1vPO/AqUqaCJMrIbazOuRC92zrMqhdX2DlyJJ+lN6ewHXLgNZBQ6yh/WUaoPXj/2w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@openai/codex": "0.110.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.110.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-win32-arm64.tgz", + "integrity": "sha512-eFJtx66V+ohXbHPGz2aRdhYMfIFOL1dLwLvdGcBELBQu5CfNzsJNq+r1YKntHV2BwdwkUHcJYe+8B9/bzGXDXg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.110.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.110.0-win32-x64.tgz", + "integrity": "sha512-kPdxMh6xDhn7kgknHzC6X0nOr5y5gE9HP/d5GcI95yN1jEGmY2maMGuB2iT2ZyWrD/8xknkaAXY+S9NMVKWbdQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/claude-to-im": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/op7418/claude-to-im.git#18c367379ef74587fe54f531538664a578a5f476", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.62", + "@larksuiteoapi/node-sdk": "^1.59.0", + "discord.js": "^14.25.1", + "markdown-it": "^14.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.40", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.40.tgz", + "integrity": "sha512-P/His8cotqZgQqrt+hzrocp9L8RhQQz1GkrCnC9TMJ8Uw2q0tg8YyqJyGULxhXn/8kxHETN4IppmOv+P2m82lQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.identity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz", + "integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/bridge/claude-to-im/package.json b/bridge/claude-to-im/package.json new file mode 100644 index 0000000..7449bf1 --- /dev/null +++ b/bridge/claude-to-im/package.json @@ -0,0 +1,28 @@ +{ + "name": "claude-to-im-skill", + "version": "0.1.0", + "description": "Claude Code Skill: Bridge IM platforms (Telegram, Discord, Feishu, QQ) 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" + } +} diff --git a/bridge/claude-to-im/references/setup-guides.md b/bridge/claude-to-im/references/setup-guides.md new file mode 100644 index 0000000..50ef860 --- /dev/null +++ b/bridge/claude-to-im/references/setup-guides.md @@ -0,0 +1,251 @@ +# 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 like `new - 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`) + +### Phase 1: Permissions + Bot capability + +> Complete Phase 1 and publish before moving to Phase 2. Feishu requires a published version for permissions to take effect, and the bridge service needs active permissions to establish its WebSocket connection. + +**Step A — Batch-add required permissions** + +1. On the app page, go to **"Permissions & Scopes"** +2. Use **batch configuration** (click **"Batch switch to configure by dependency"** or find the JSON editor) +3. Paste the following JSON (required for streaming cards and interactive buttons): + +```json +{ + "scopes": { + "tenant": [ + "im:message:send_as_bot", + "im:message:readonly", + "im:message.p2p_msg:readonly", + "im:message.group_at_msg:readonly", + "im:message:update", + "im:message.reactions:read", + "im:message.reactions:write_only", + "im:chat:read", + "im:resource", + "cardkit:card:write", + "cardkit:card:read" + ], + "user": [] + } +} +``` + +4. Click **"Save"** to apply all permissions + +If the batch import UI is not available, add each scope manually via the search box. + +**Step B — Enable the bot** + +1. Go to **"Add Features"** → enable **"Bot"** +2. Set the bot name and description + +**Step C — First publish (makes permissions + bot effective)** + +1. Go to **"Version Management & Release"** → click **"Create Version"** +2. Fill in version `1.0.0` and a description → click **"Save"** → **"Submit for Review"** +3. Admin approves in **Feishu Admin Console** → **App Review** (self-approve if you are the admin) + +**The bot will NOT work until this version is approved.** + +### Phase 2: Event subscription (requires running bridge) + +> The bridge service must be running before configuring events. Feishu validates the WebSocket connection when saving event subscription — if the bridge is not running, you'll get "未检测到应用连接信息" (connection not detected) error. + +**Step D — Start the bridge service** + +Run `/claude-to-im start` in Claude Code. This establishes the WebSocket long connection that Feishu needs to detect. + +**Step E — 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: + - `im.message.receive_v1` — Receive messages +4. Click **"Add Callback"** and add: + - `card.action.trigger` — Card interaction callback (for permission approval buttons) +5. Click **"Save"** + +**Step F — Second publish (makes event subscription effective)** + +1. Go to **"Version Management & Release"** → click **"Create Version"** +2. Fill in version `1.1.0` → **"Save"** → **"Submit for Review"** → Admin approves +3. After approval, the bot can receive and respond to messages + +> **Ongoing rule:** Any change to permissions, events, or capabilities requires a new version publish + admin approval. + +### Upgrading from a previous version + +If you already have a Feishu app configured, you need to: + +1. **Add new permissions**: Go to Permissions & Scopes, add these scopes: + - `cardkit:card:write`, `cardkit:card:read` — Streaming cards + - `im:message:update` — Real-time card content updates + - `im:message.reactions:read`, `im:message.reactions:write_only` — Typing indicator +2. **Publish a new version** — Permission changes only take effect after a new version is approved +3. **Start (or restart) the bridge** — Run `/claude-to-im start` so the WebSocket connection is active +4. **Add callback**: Go to Events & Callbacks, add `card.action.trigger` callback (card interaction for permission buttons). This step requires the bridge to be running — Feishu validates the WebSocket connection when saving. +5. **Publish again** — The new callback requires another version publish + admin approval +6. **Restart the bridge** — Run `/claude-to-im stop` then `/claude-to-im start` to pick up the new capabilities + +### 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. + +--- + +## QQ + +> **Note:** QQ first version only supports **C2C private chat** (sandbox access). Group chat and channel are not supported yet. + +### App ID and App Secret (required) + +**How to get QQ Bot credentials:** +1. Go to https://q.qq.com/qqbot/openclaw +2. Log in and enter the QQ Bot / OpenClaw management page +3. Create a new QQ Bot or select an existing one +4. Find **App ID** and **App Secret** on the bot's credential page +5. Copy both values + +These are the only two required fields for QQ. + +### Sandbox private chat setup + +1. In the QQ Bot management page, configure sandbox access +2. Scan the QR code with QQ to add the bot as a friend +3. Send a message to the bot via QQ private chat to start using it + +### Allowed User OpenIDs (optional) + +**Important:** The value is `user_openid`, NOT QQ number. + +`user_openid` is an opaque identifier assigned by the QQ Bot platform to each user. You can obtain it from the bot's message logs after a user sends a message to the bot. + +If you don't have the openid yet, leave this field empty. You can add it later via `reconfigure`. + +Enter comma-separated openids to restrict access. Leave empty to allow all users who can message the bot. + +### Image Enabled (optional) + +Default: `true`. Set to `false` if the underlying LLM provider does not support image input. + +When enabled, images sent by users in QQ private chat will be forwarded to the AI agent. Image output (sending images back to QQ) is not supported in this version — only text replies. + +### Max Image Size MB (optional) + +Default: `20`. Maximum image file size in MB that will be forwarded to the AI agent. Images larger than this limit are ignored. diff --git a/bridge/claude-to-im/references/token-validation.md b/bridge/claude-to-im/references/token-validation.md new file mode 100644 index 0000000..90fe510 --- /dev/null +++ b/bridge/claude-to-im/references/token-validation.md @@ -0,0 +1,44 @@ +# Token Validation Commands + +After writing config.env, validate each enabled platform's credentials to catch typos and configuration errors early. + +## Telegram + +```bash +curl -s "https://api.telegram.org/bot${TOKEN}/getMe" +``` +Expected: response contains `"ok":true`. If not, the Bot Token is invalid — re-check with @BotFather. + +## Discord + +Verify token format matches: `[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+` + +A format mismatch means the token was copied incorrectly from the Discord Developer Portal. + +## Feishu / Lark + +```bash +curl -s -X POST "${DOMAIN}/open-apis/auth/v3/tenant_access_token/internal" \ + -H "Content-Type: application/json" \ + -d '{"app_id":"...","app_secret":"..."}' +``` +Expected: response contains `"code":0`. If not, check that App ID and App Secret match the Feishu Developer Console. + +## QQ + +Step 1 — Get access token: +```bash +curl -s -X POST "https://bots.qq.com/app/getAppAccessToken" \ + -H "Content-Type: application/json" \ + -d '{"appId":"...","clientSecret":"..."}' +``` +Expected: response contains `access_token`. + +Step 2 — Verify gateway connectivity: +```bash +curl -s "https://api.sgroup.qq.com/gateway" \ + -H "Authorization: QQBot " +``` +Expected: response contains a gateway URL. + +If either step fails, verify the App ID and App Secret from https://q.qq.com. diff --git a/bridge/claude-to-im/references/troubleshooting.md b/bridge/claude-to-im/references/troubleshooting.md new file mode 100644 index 0000000..d32299f --- /dev/null +++ b/bridge/claude-to-im/references/troubleshooting.md @@ -0,0 +1,69 @@ +# 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 start + ``` +3. 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: + ```bash + rm ~/.claude-to-im/runtime/bridge.pid + ``` +3. Run `/claude-to-im start` to launch a fresh instance diff --git a/bridge/claude-to-im/references/usage.md b/bridge/claude-to-im/references/usage.md new file mode 100644 index 0000000..0435d19 --- /dev/null +++ b/bridge/claude-to-im/references/usage.md @@ -0,0 +1,130 @@ +# 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 setup +``` + +The wizard will prompt you for: + +1. **Channels to enable** -- Enter comma-separated values: `telegram`, `discord`, `feishu`, `qq` +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: +Enter Discord bot 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.env +``` + +## start + +Starts the bridge daemon in the background. + +``` +/claude-to-im start +``` + +The 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 stop +``` + +Sends 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 status +``` + +Output 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 lines +``` + +Logs are stored in `~/.claude-to-im/logs/` and are automatically redacted to mask secrets. + +## reconfigure + +Interactively update the current configuration. + +``` +/claude-to-im reconfigure +``` + +Displays 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 start +``` + +## doctor + +Runs diagnostic checks and reports issues. + +``` +/claude-to-im doctor +``` + +Checks 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) +- QQ credentials and gateway reachability (if QQ enabled) +- Daemon process health +- Log directory writability + +### QQ notes + +QQ currently supports **C2C private chat only**: +- No inline approval buttons — permissions use text `/perm ...` commands +- No streaming preview +- Image inbound only (no image replies) +- No group/channel support yet +- Required config: `CTI_QQ_APP_ID`, `CTI_QQ_APP_SECRET` (obtain from https://q.qq.com/qqbot/openclaw) +- `CTI_QQ_ALLOWED_USERS` takes `user_openid` values, not QQ numbers +- Set `CTI_QQ_IMAGE_ENABLED=false` if the provider doesn't support image input diff --git a/bridge/claude-to-im/scripts/build.js b/bridge/claude-to-im/scripts/build.js new file mode 100644 index 0000000..e48e21a --- /dev/null +++ b/bridge/claude-to-im/scripts/build.js @@ -0,0 +1,26 @@ +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'); diff --git a/bridge/claude-to-im/scripts/daemon.ps1 b/bridge/claude-to-im/scripts/daemon.ps1 new file mode 100644 index 0000000..84e418a --- /dev/null +++ b/bridge/claude-to-im/scripts/daemon.ps1 @@ -0,0 +1,16 @@ +<# +.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 diff --git a/bridge/claude-to-im/scripts/daemon.sh b/bridge/claude-to-im/scripts/daemon.sh new file mode 100755 index 0000000..8660df0 --- /dev/null +++ b/bridge/claude-to-im/scripts/daemon.sh @@ -0,0 +1,225 @@ +#!/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 + # Check if any source file is newer than the bundle + 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 + # Also check if node_modules/claude-to-im was updated (npm update) + # — its code is bundled into dist, so changes require a rebuild + if [ "$need_build" = "0" ] && [ -d "$SKILL_DIR/node_modules/claude-to-im/src" ]; then + local newest_dep + newest_dep=$(find "$SKILL_DIR/node_modules/claude-to-im/src" -name '*.ts' -newer "$SKILL_DIR/dist/daemon.mjs" 2>/dev/null | head -1) + if [ -n "$newest_dep" ]; then + need_build=1 + fi + 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:-inherit}" + 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) + # Keep ANTHROPIC_* (from config.env) — needed for third-party API providers. + # Strip OPENAI_* to avoid cross-runtime leakage. + while IFS='=' read -r name _; do + case "$name" in OPENAI_*) unset "$name" 2>/dev/null || true ;; esac + done < <(env) + ;; + auto) + # Keep both ANTHROPIC_* and OPENAI_* for auto mode + ;; + 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 + + # Source config.env BEFORE clean_env so that CTI_ANTHROPIC_PASSTHROUGH + # and other CTI_* flags are available when clean_env checks them. + [ -f "$CTI_HOME/config.env" ] && set -a && source "$CTI_HOME/config.env" && set +a + + 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 diff --git a/bridge/claude-to-im/scripts/doctor.sh b/bridge/claude-to-im/scripts/doctor.sh new file mode 100755 index 0000000..c960a7d --- /dev/null +++ b/bridge/claude-to-im/scripts/doctor.sh @@ -0,0 +1,423 @@ +#!/usr/bin/env bash +set -euo pipefail +CTI_HOME="${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 + +# --- Helper: read a value from config.env --- +get_config() { grep "^$1=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | sed 's/^["'"'"']//;s/["'"'"']$//'; } + +# --- Read runtime setting --- +SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CTI_RUNTIME=$(get_config CTI_RUNTIME) +CTI_RUNTIME="${CTI_RUNTIME:-claude}" +echo "Runtime: $CTI_RUNTIME" +echo "" + +# --- Claude CLI available (claude/auto modes) --- +if [ "$CTI_RUNTIME" = "claude" ] || [ "$CTI_RUNTIME" = "auto" ]; then + # Resolve CLI path matching the daemon's checkCliCompatibility logic: + # - Version >= 2.x AND all required flags present + # - Skip candidates that fail either check (same as resolveClaudeCliPath) + CLAUDE_PATH="" + CLAUDE_VER="" + CLAUDE_COMPAT=1 + REQUIRED_FLAGS="output-format input-format permission-mode setting-sources" + + # Helper: check if a candidate passes both version and flags checks. + # Sets CLAUDE_PATH/CLAUDE_VER/CLAUDE_COMPAT on success. + try_candidate() { + local cand="$1" + [ -x "$cand" ] || return 1 + local ver + ver=$("$cand" --version 2>/dev/null || true) + [ -z "$ver" ] && return 1 + local major + major=$(echo "$ver" | sed -E -n 's/^[^0-9]*([0-9]+)\..*/\1/p' | head -1) + if [ -z "$major" ] || ! [ "$major" -ge 2 ] 2>/dev/null; then + echo " (skipping $cand — version $ver is too old, need >= 2.x)" + return 1 + fi + # Version OK — check flags + local help_text + help_text=$("$cand" --help 2>&1 || true) + for flag in $REQUIRED_FLAGS; do + if ! echo "$help_text" | grep -q "$flag"; then + echo " (skipping $cand — version $ver OK but missing --$flag)" + return 1 + fi + done + # Fully compatible + CLAUDE_PATH="$cand" + CLAUDE_VER="$ver" + CLAUDE_COMPAT=0 + return 0 + } + + # 1. Explicit env var — if set, daemon uses it unconditionally (no fallback). + # Doctor must mirror this: report on this path only, never scan further. + CTI_EXE=$(get_config CTI_CLAUDE_CODE_EXECUTABLE 2>/dev/null || true) + if [ -n "$CTI_EXE" ]; then + if [ -x "$CTI_EXE" ]; then + if ! try_candidate "$CTI_EXE"; then + # Explicit path is set but incompatible — daemon WILL use it and fail. + # Report it as the selected CLI so the user sees the real problem. + CLAUDE_PATH="$CTI_EXE" + CLAUDE_VER=$("$CTI_EXE" --version 2>/dev/null || echo "unknown") + # CLAUDE_COMPAT stays 1 (incompatible) — checks below will report failure + fi + else + CLAUDE_PATH="$CTI_EXE" + CLAUDE_VER="(not executable)" + fi + fi + + # 2. All PATH candidates (only if no explicit env var was set) + if [ -z "$CTI_EXE" ] && [ -z "$CLAUDE_PATH" ]; then + ALL_CLAUDES=$(which -a claude 2>/dev/null || true) + for cand in $ALL_CLAUDES; do + try_candidate "$cand" && break + done + fi + + # 3. Well-known locations (only if no explicit env var was set) + if [ -z "$CTI_EXE" ] && [ -z "$CLAUDE_PATH" ]; then + for cand in \ + "$HOME/.claude/local/claude" \ + "$HOME/.local/bin/claude" \ + "/usr/local/bin/claude" \ + "/opt/homebrew/bin/claude" \ + "$HOME/.npm-global/bin/claude"; do + try_candidate "$cand" && break + done + fi + + if [ -n "$CLAUDE_PATH" ] && [ "$CLAUDE_COMPAT" = "0" ]; then + check "Claude CLI compatible (${CLAUDE_VER} at ${CLAUDE_PATH})" 0 + elif [ -n "$CLAUDE_PATH" ]; then + # Path found but incompatible (too old, missing flags, or not executable) + check "Claude CLI compatible (${CLAUDE_VER} at ${CLAUDE_PATH} — incompatible, see above)" 1 + else + if [ "$CTI_RUNTIME" = "claude" ]; then + check "Claude CLI available (not found in PATH or common locations)" 1 + else + check "Claude CLI available (not found — will use Codex fallback)" 0 + fi + fi + + # --- Claude CLI authenticated --- + # Skip this check if third-party API credentials are configured in config.env. + # In that mode the bridge authenticates via ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN, + # not via `claude auth login`, so a missing interactive login is expected and harmless. + HAS_THIRD_PARTY_AUTH=false + if [ -f "$CONFIG_FILE" ] && grep -qE "^ANTHROPIC_(API_KEY|AUTH_TOKEN)=" "$CONFIG_FILE" 2>/dev/null; then + HAS_THIRD_PARTY_AUTH=true + fi + if [ -n "$CLAUDE_PATH" ] && [ "$CLAUDE_COMPAT" = "0" ]; then + if [ "$HAS_THIRD_PARTY_AUTH" = "true" ]; then + check "Claude CLI auth (skipped — using third-party API credentials from config.env)" 0 + else + AUTH_OUT=$("$CLAUDE_PATH" auth status 2>&1 || true) + if echo "$AUTH_OUT" | grep -qiE 'loggedIn.*true|logged.in'; then + check "Claude CLI authenticated" 0 + else + check "Claude CLI authenticated (run 'claude auth login')" 1 + fi + fi + fi + + # --- ANTHROPIC_* env reachability --- + # Check whether ANTHROPIC_* vars are configured in config.env. + # This is what matters for the daemon — the current shell env is irrelevant + # because on macOS the daemon runs under launchd with only plist env vars. + HAS_ANTHROPIC_CONFIG=false + if [ -f "$CONFIG_FILE" ]; then + if grep -q "^ANTHROPIC_" "$CONFIG_FILE" 2>/dev/null; then + HAS_ANTHROPIC_CONFIG=true + fi + fi + if [ "$HAS_ANTHROPIC_CONFIG" = "true" ]; then + check "ANTHROPIC_* vars in config.env (third-party API provider)" 0 + + LAUNCHD_LABEL="${CTI_LAUNCHD_LABEL:-com.claude-to-im.bridge}" + PLIST_FILE="$HOME/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" + + # On macOS, verify the launchd plist also has the vars + if [ "$(uname -s)" = "Darwin" ] && [ -f "$PLIST_FILE" ]; then + if grep -q "ANTHROPIC_" "$PLIST_FILE" 2>/dev/null; then + check "ANTHROPIC_* vars in launchd plist" 0 + else + check "ANTHROPIC_* vars in launchd plist (NOT present — restart bridge to regenerate plist)" 1 + fi + fi + + # If bridge is running, verify the LIVE process has the vars. + # The plist may be correct on disk but if the daemon hasn't been + # restarted since the plist was regenerated, it still runs with the + # old environment. + BRIDGE_PID=$(cat "$PID_FILE" 2>/dev/null || true) + if [ -n "$BRIDGE_PID" ] && kill -0 "$BRIDGE_PID" 2>/dev/null; then + # ps eww shows the process environment on macOS/Linux + PROC_ENV=$(ps eww -p "$BRIDGE_PID" 2>/dev/null || true) + if echo "$PROC_ENV" | grep -q "ANTHROPIC_"; then + check "Running bridge process has ANTHROPIC_* env vars" 0 + else + check "Running bridge process has ANTHROPIC_* env vars (NOT in process env — restart the bridge)" 1 + fi + fi + else + check "ANTHROPIC_* vars in config.env (not set — OK if using default Anthropic auth)" 0 + fi + + # --- SDK cli.js resolvable --- + SDK_CLI="" + for candidate in \ + "$SKILL_DIR/node_modules/@anthropic-ai/claude-agent-sdk/cli.js" \ + "$SKILL_DIR/node_modules/@anthropic-ai/claude-agent-sdk/dist/cli.js"; do + if [ -f "$candidate" ]; then + SDK_CLI="$candidate" + break + fi + done + if [ -n "$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 --- +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 + + # --- QQ --- + if echo "$CTI_CHANNELS" | grep -q qq; then + QQ_APP_ID=$(get_config CTI_QQ_APP_ID) + QQ_APP_SECRET=$(get_config CTI_QQ_APP_SECRET) + if [ -n "$QQ_APP_ID" ] && [ -n "$QQ_APP_SECRET" ]; then + QQ_TOKEN_RESULT=$(curl -s --max-time 10 -X POST "https://bots.qq.com/app/getAppAccessToken" \ + -H "Content-Type: application/json" \ + -d "{\"appId\":\"${QQ_APP_ID}\",\"clientSecret\":\"${QQ_APP_SECRET}\"}" 2>/dev/null || echo '{}') + QQ_ACCESS_TOKEN=$(echo "$QQ_TOKEN_RESULT" | sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + if [ -n "$QQ_ACCESS_TOKEN" ]; then + check "QQ app credentials are valid (access_token obtained)" 0 + # Verify gateway availability + QQ_GW_RESULT=$(curl -s --max-time 10 "https://api.sgroup.qq.com/gateway" \ + -H "Authorization: QQBot ${QQ_ACCESS_TOKEN}" 2>/dev/null || echo '{}') + if echo "$QQ_GW_RESULT" | grep -q '"url"'; then + check "QQ gateway is reachable" 0 + else + check "QQ gateway is reachable (GET /gateway failed)" 1 + fi + else + check "QQ app credentials are valid (getAppAccessToken failed)" 1 + fi + else + check "QQ 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 diff --git a/bridge/claude-to-im/scripts/install-codex.sh b/bridge/claude-to-im/scripts/install-codex.sh new file mode 100755 index 0000000..4558bb1 --- /dev/null +++ b/bridge/claude-to-im/scripts/install-codex.sh @@ -0,0 +1,66 @@ +#!/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" diff --git a/bridge/claude-to-im/scripts/supervisor-linux.sh b/bridge/claude-to-im/scripts/supervisor-linux.sh new file mode 100755 index 0000000..f58e62d --- /dev/null +++ b/bridge/claude-to-im/scripts/supervisor-linux.sh @@ -0,0 +1,49 @@ +#!/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" +} diff --git a/bridge/claude-to-im/scripts/supervisor-macos.sh b/bridge/claude-to-im/scripts/supervisor-macos.sh new file mode 100755 index 0000000..0519df7 --- /dev/null +++ b/bridge/claude-to-im/scripts/supervisor-macos.sh @@ -0,0 +1,154 @@ +#!/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="${CTI_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}${var}\n${indent}${val}\n" + done + + # Forward CTI_* vars + while IFS='=' read -r name val; do + case "$name" in CTI_*) + dict+="${indent}${name}\n${indent}${val}\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}${var}\n${indent}${val}\n" + done + ;; + esac + case "$runtime" in + claude|auto) + # Auto-forward all ANTHROPIC_* env vars (sourced from config.env by daemon.sh). + # Third-party API providers need these to reach the CLI subprocess. + while IFS='=' read -r name val; do + case "$name" in ANTHROPIC_*) + dict+="${indent}${name}\n${indent}${val}\n" + ;; esac + done < <(env) + ;; + 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" < + + + + Label + ${LAUNCHD_LABEL} + + ProgramArguments + + ${node_path} + ${SKILL_DIR}/dist/daemon.mjs + + + WorkingDirectory + ${SKILL_DIR} + + StandardOutPath + ${LOG_FILE} + StandardErrorPath + ${LOG_FILE} + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 10 + + EnvironmentVariables + +${env_dict} + + +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" +} diff --git a/bridge/claude-to-im/scripts/supervisor-windows.ps1 b/bridge/claude-to-im/scripts/supervisor-windows.ps1 new file mode 100644 index 0000000..12c666c --- /dev/null +++ b/bridge/claude-to-im/scripts/supervisor-windows.ps1 @@ -0,0 +1,403 @@ +<# +.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 + @" + + $ServiceName + Claude-to-IM Bridge + Claude-to-IM bridge daemon + $nodePath + $DaemonMjs + $SkillDir + + $currentUser + $([System.Security.SecurityElement]::Escape($plainPwd)) + true + + + + + + + $(Join-Path $CtiHome 'logs') + + bridge-service.log + + + + + +"@ | 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" + } +} diff --git a/bridge/claude-to-im/src/__tests__/codex-provider.test.ts b/bridge/claude-to-im/src/__tests__/codex-provider.test.ts new file mode 100644 index 0000000..b4bc486 --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/codex-provider.test.ts @@ -0,0 +1,613 @@ +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): Promise { + 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; + + (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; + + (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; + + (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; + + (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; + + (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; + + (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; + + (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 | 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) => { + 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 | 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) => { + 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>; + 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>; + 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'); + }); +}); diff --git a/bridge/claude-to-im/src/__tests__/config.test.ts b/bridge/claude-to-im/src/__tests__/config.test.ts new file mode 100644 index 0000000..6b91adc --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/config.test.ts @@ -0,0 +1,197 @@ +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('sets bridge_qq_enabled based on enabledChannels', () => { + const m = configToSettings({ ...base, enabledChannels: ['qq'] }); + assert.equal(m.get('bridge_qq_enabled'), 'true'); + assert.equal(m.get('bridge_telegram_enabled'), 'false'); + }); + + it('defaults bridge_qq_enabled to false', () => { + const m = configToSettings(base); + assert.equal(m.get('bridge_qq_enabled'), 'false'); + }); + + it('maps qq config fields', () => { + const m = configToSettings({ + ...base, + enabledChannels: ['qq'], + qqAppId: 'qq-app-id', + qqAppSecret: 'qq-secret', + qqAllowedUsers: ['openid1', 'openid2'], + }); + assert.equal(m.get('bridge_qq_app_id'), 'qq-app-id'); + assert.equal(m.get('bridge_qq_app_secret'), 'qq-secret'); + assert.equal(m.get('bridge_qq_allowed_users'), 'openid1,openid2'); + }); + + it('maps qq image settings', () => { + const m = configToSettings({ + ...base, + enabledChannels: ['qq'], + qqAppId: 'id', + qqAppSecret: 'secret', + qqImageEnabled: false, + qqMaxImageSize: 10, + }); + assert.equal(m.get('bridge_qq_image_enabled'), 'false'); + assert.equal(m.get('bridge_qq_max_image_size'), '10'); + }); + + it('omits qq image settings when not set', () => { + const m = configToSettings({ + ...base, + enabledChannels: ['qq'], + qqAppId: 'id', + qqAppSecret: 'secret', + }); + assert.equal(m.has('bridge_qq_image_enabled'), false); + assert.equal(m.has('bridge_qq_max_image_size'), false); + }); + + 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'); + assert.equal(m.get('bridge_qq_enabled'), 'false'); + }); +}); diff --git a/bridge/claude-to-im/src/__tests__/llm-provider.test.ts b/bridge/claude-to-im/src/__tests__/llm-provider.test.ts new file mode 100644 index 0000000..c5b45f1 --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/llm-provider.test.ts @@ -0,0 +1,330 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + isAuthError, + classifyAuthError, + isNonClaudeModel, + parseCliMajorVersion, + handleMessage, +} from '../llm-provider.js'; +import type { StreamState } from '../llm-provider.js'; +import { sseEvent } from '../sse-utils.js'; + +// ── Helpers ── + +/** Collect enqueued SSE strings from a fake controller. */ +function makeFakeController() { + const chunks: string[] = []; + const controller = { + enqueue(data: string) { chunks.push(data); }, + close() { /* no-op */ }, + error() { /* no-op */ }, + desiredSize: 1, + } as unknown as ReadableStreamDefaultController; + return { controller, chunks }; +} + +function freshState(): StreamState { + return { hasReceivedResult: false, hasStreamedText: false, lastAssistantText: '' }; +} + +// ── classifyAuthError ── + +describe('classifyAuthError', () => { + it('returns "cli" for local login errors', () => { + assert.equal(classifyAuthError('Error: Not logged in'), 'cli'); + assert.equal(classifyAuthError('Please run /login'), 'cli'); + assert.equal(classifyAuthError('loggedIn:false'), 'cli'); + }); + + it('returns "api" for remote credential errors', () => { + assert.equal(classifyAuthError('Error: Unauthorized'), 'api'); + assert.equal(classifyAuthError('invalid API key provided'), 'api'); + assert.equal(classifyAuthError('authentication has failed'), 'api'); + assert.equal(classifyAuthError('HTTP 401 Unauthorized'), 'api'); + assert.equal(classifyAuthError('does not have access to Claude'), 'api'); + }); + + it('returns false for non-auth errors', () => { + assert.equal(classifyAuthError('process exited with code 1'), false); + assert.equal(classifyAuthError('ECONNREFUSED'), false); + assert.equal(classifyAuthError(''), false); + }); + + it('returns false for local permission / generic 403 (not API auth)', () => { + assert.equal(classifyAuthError('permission denied: /usr/local/bin'), false); + assert.equal(classifyAuthError('HTTP 403 Forbidden'), false); + assert.equal(classifyAuthError('EACCES: permission denied, open /etc/hosts'), false); + }); + + it('prefers "cli" when both patterns match', () => { + // "Not logged in" should be cli even if "unauthorized" is also present + assert.equal(classifyAuthError('Not logged in, unauthorized'), 'cli'); + }); +}); + +// ── isAuthError (backwards compat) ── + +describe('isAuthError', () => { + it('detects "Not logged in" in error message', () => { + assert.equal(isAuthError('Error: Not logged in · Please run /login'), true); + }); + + it('detects "Please run /login" in stderr', () => { + assert.equal(isAuthError('some preamble\nPlease run /login\n'), true); + }); + + it('detects loggedIn: false in JSON output', () => { + assert.equal(isAuthError('{"loggedIn": false, "user": null}'), true); + }); + + it('detects loggedIn:false without spaces', () => { + assert.equal(isAuthError('loggedIn:false'), true); + }); + + it('detects "unauthorized" (case-insensitive)', () => { + assert.equal(isAuthError('Error: Unauthorized access'), true); + }); + + it('detects "invalid api key"', () => { + assert.equal(isAuthError('Error: invalid API key provided'), true); + assert.equal(isAuthError('invalid api-key'), true); + }); + + it('detects "authentication failed"', () => { + assert.equal(isAuthError('authentication has failed'), true); + }); + + it('detects HTTP 401', () => { + assert.equal(isAuthError('HTTP error 401'), true); + assert.equal(isAuthError('status: 401 Unauthorized'), true); + }); + + it('returns false for non-auth errors', () => { + assert.equal(isAuthError('Claude Code process exited with code 1'), false); + }); + + it('returns false for empty string', () => { + assert.equal(isAuthError(''), false); + }); + + it('returns false for generic network error', () => { + assert.equal(isAuthError('ECONNREFUSED 127.0.0.1:443'), false); + }); + + it('returns false for HTTP 400 or 500', () => { + assert.equal(isAuthError('HTTP error 400 Bad Request'), false); + assert.equal(isAuthError('HTTP error 500 Internal Server Error'), false); + }); +}); + +// ── isNonClaudeModel ── + +describe('isNonClaudeModel', () => { + it('detects gpt- prefixed models', () => { + assert.equal(isNonClaudeModel('gpt-5-codex'), true); + assert.equal(isNonClaudeModel('gpt-4o'), true); + }); + + it('detects o1/o3 prefixed models', () => { + assert.equal(isNonClaudeModel('o1-preview'), true); + assert.equal(isNonClaudeModel('o3-mini'), true); + }); + + it('detects codex- prefixed models', () => { + assert.equal(isNonClaudeModel('codex-mini'), true); + }); + + it('detects openai/ prefixed models', () => { + assert.equal(isNonClaudeModel('openai/gpt-4o'), true); + }); + + it('returns false for claude models', () => { + assert.equal(isNonClaudeModel('claude-opus-4-6'), false); + assert.equal(isNonClaudeModel('claude-sonnet-4-6'), false); + }); + + it('returns false for undefined/empty', () => { + assert.equal(isNonClaudeModel(undefined), false); + assert.equal(isNonClaudeModel(''), false); + }); +}); + +// ── parseCliMajorVersion ── + +describe('parseCliMajorVersion', () => { + it('parses "2.3.1" to 2', () => { + assert.equal(parseCliMajorVersion('2.3.1'), 2); + }); + + it('parses "claude 2.3.1" to 2', () => { + assert.equal(parseCliMajorVersion('claude 2.3.1'), 2); + }); + + it('parses "1.0.17" to 1', () => { + assert.equal(parseCliMajorVersion('1.0.17'), 1); + }); + + it('parses "@anthropic-ai/claude-code: 1.0.3" to 1', () => { + assert.equal(parseCliMajorVersion('@anthropic-ai/claude-code: 1.0.3'), 1); + }); + + it('returns undefined for non-version strings', () => { + assert.equal(parseCliMajorVersion('unknown'), undefined); + assert.equal(parseCliMajorVersion(''), undefined); + }); +}); + +// ── handleMessage + StreamState ── + +describe('handleMessage state tracking', () => { + it('sets hasStreamedText on text_delta', () => { + const { controller } = makeFakeController(); + const state = freshState(); + + handleMessage({ + type: 'stream_event', + event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello' } }, + } as any, controller, state); + + assert.equal(state.hasStreamedText, true); + assert.equal(state.hasReceivedResult, false); + }); + + it('captures assistant text without emitting it', () => { + const { controller, chunks } = makeFakeController(); + const state = freshState(); + + handleMessage({ + type: 'assistant', + message: { content: [{ type: 'text', text: 'org has no access' }] }, + } as any, controller, state); + + assert.equal(state.lastAssistantText, 'org has no access'); + // No text SSE should be emitted — only tool_use blocks get forwarded + const textEvents = chunks.filter(c => c.includes('"type":"text"') || c.includes('"type":"text"')); + // Parse more carefully + const hasTextEvent = chunks.some(c => { + try { const d = JSON.parse(c.replace('data: ', '')); return d.type === 'text'; } + catch { return false; } + }); + assert.equal(hasTextEvent, false, 'assistant text should NOT be emitted directly'); + }); + + it('sets hasReceivedResult on success result', () => { + const { controller } = makeFakeController(); + const state = freshState(); + + handleMessage({ + type: 'result', + subtype: 'success', + session_id: 'sess1', + is_error: false, + usage: { input_tokens: 10, output_tokens: 20 }, + total_cost_usd: 0.001, + } as any, controller, state); + + assert.equal(state.hasReceivedResult, true); + }); + + it('sets hasReceivedResult on error result', () => { + const { controller } = makeFakeController(); + const state = freshState(); + + handleMessage({ + type: 'result', + subtype: 'error', + errors: ['something went wrong'], + } as any, controller, state); + + assert.equal(state.hasReceivedResult, true); + }); + + it('emits tool_use from assistant block', () => { + const { controller, chunks } = makeFakeController(); + const state = freshState(); + + handleMessage({ + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Let me check' }, + { type: 'tool_use', id: 'tu1', name: 'Read', input: { path: '/foo' } }, + ], + }, + } as any, controller, state); + + assert.equal(state.lastAssistantText, 'Let me check'); + assert.equal(chunks.length, 1); // only tool_use, no text + assert.ok(chunks[0].includes('tool_use')); + }); +}); + +describe('catch block error suppression logic', () => { + // These tests verify the logic expressed in the catch block by testing + // the state conditions that drive its behavior. + + it('result received + exit code → should suppress (transport noise)', () => { + const state: StreamState = { hasReceivedResult: true, hasStreamedText: true, lastAssistantText: '' }; + const errorMsg = 'Claude Code process exited with code 1'; + const isTransportExit = errorMsg.includes('process exited with code'); + + // This is the condition in the catch block: + const shouldSuppress = state.hasReceivedResult && isTransportExit; + assert.equal(shouldSuppress, true); + }); + + it('partial text + exit code (no result) → should NOT suppress (real crash)', () => { + const state: StreamState = { hasReceivedResult: false, hasStreamedText: true, lastAssistantText: '' }; + const errorMsg = 'Claude Code process exited with code 1'; + const isTransportExit = errorMsg.includes('process exited with code'); + + const shouldSuppress = state.hasReceivedResult && isTransportExit; + assert.equal(shouldSuppress, false, 'partial output crash must NOT be suppressed'); + }); + + it('assistant text with recognised auth error → should surface as business error', () => { + const state: StreamState = { + hasReceivedResult: false, + hasStreamedText: false, + lastAssistantText: 'Your organization does not have access to Claude', + }; + + // Case 2 condition: lastAssistantText must be a recognised auth/access error + const shouldSurface = !!state.lastAssistantText && classifyAuthError(state.lastAssistantText) !== false; + assert.equal(shouldSurface, true); + }); + + it('assistant text with normal content + crash → should NOT surface as business error', () => { + const state: StreamState = { + hasReceivedResult: false, + hasStreamedText: false, + lastAssistantText: 'Here is my analysis of the code...', + }; + + // Normal response text is not a recognised auth error — must fall through to error handling + const shouldSurface = !!state.lastAssistantText && classifyAuthError(state.lastAssistantText) !== false; + assert.equal(shouldSurface, false, 'normal assistant text must NOT be treated as business error'); + }); + + it('no streaming + no assistant text → should show full error', () => { + const state: StreamState = { hasReceivedResult: false, hasStreamedText: false, lastAssistantText: '' }; + + const shouldSurface = !!state.lastAssistantText && classifyAuthError(state.lastAssistantText) !== false; + const shouldSuppress = state.hasReceivedResult; + assert.equal(shouldSurface, false); + assert.equal(shouldSuppress, false); + // This means the catch block falls through to building the full error message + }); + + it('streaming + result + exit code → should suppress', () => { + // Normal successful flow that ends with exit code 0 won't throw, + // but some edge cases might. Verify suppression. + const state: StreamState = { hasReceivedResult: true, hasStreamedText: true, lastAssistantText: 'some response' }; + const isTransportExit = true; + + const shouldSuppress = state.hasReceivedResult && isTransportExit; + assert.equal(shouldSuppress, true); + }); +}); diff --git a/bridge/claude-to-im/src/__tests__/logger.test.ts b/bridge/claude-to-im/src/__tests__/logger.test.ts new file mode 100644 index 0000000..e7883c8 --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/logger.test.ts @@ -0,0 +1,61 @@ +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')); + }); +}); diff --git a/bridge/claude-to-im/src/__tests__/permission.test.ts b/bridge/claude-to-im/src/__tests__/permission.test.ts new file mode 100644 index 0000000..52499b5 --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/permission.test.ts @@ -0,0 +1,70 @@ +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); + }); +}); diff --git a/bridge/claude-to-im/src/__tests__/store.test.ts b/bridge/claude-to-im/src/__tests__/store.test.ts new file mode 100644 index 0000000..85ff18b --- /dev/null +++ b/bridge/claude-to-im/src/__tests__/store.test.ts @@ -0,0 +1,331 @@ +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 { + 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); + }); + + it('listPendingPermissionLinksByChat returns only unresolved links for the chat', () => { + const store = new JsonFileStore(makeSettings()); + store.insertPermissionLink({ + permissionRequestId: 'pr-a', + channelType: 'qq', + chatId: 'chat-1', + messageId: 'msg-a', + toolName: 'Bash', + suggestions: '', + }); + store.insertPermissionLink({ + permissionRequestId: 'pr-b', + channelType: 'qq', + chatId: 'chat-1', + messageId: 'msg-b', + toolName: 'Read', + suggestions: '', + }); + store.insertPermissionLink({ + permissionRequestId: 'pr-c', + channelType: 'qq', + chatId: 'chat-2', + messageId: 'msg-c', + toolName: 'Bash', + suggestions: '', + }); + // Resolve one + store.markPermissionLinkResolved('pr-a'); + const pending = store.listPendingPermissionLinksByChat('chat-1'); + assert.equal(pending.length, 1); + assert.equal(pending[0].permissionRequestId, 'pr-b'); + // Different chat + const pending2 = store.listPendingPermissionLinksByChat('chat-2'); + assert.equal(pending2.length, 1); + assert.equal(pending2[0].permissionRequestId, 'pr-c'); + // No permissions for unknown chat + assert.equal(store.listPendingPermissionLinksByChat('chat-unknown').length, 0); + }); + + // ── 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); + }); +}); diff --git a/bridge/claude-to-im/src/codex-provider.ts b/bridge/claude-to-im/src/codex-provider.ts new file mode 100644 index 0000000..9639b6a --- /dev/null +++ b/bridge/claude-to-im/src/codex-provider.ts @@ -0,0 +1,368 @@ +/** + * Codex Provider — LLMProvider implementation backed by @openai/codex-sdk. + * + * Maps Codex SDK thread events to the SSE stream format consumed by + * the bridge conversation engine, making Codex a drop-in alternative + * to the Claude Code SDK backend. + * + * Requires `@openai/codex-sdk` to be installed (optionalDependency). + * The provider lazily imports the SDK at first use and throws a clear + * error if it is not available. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { LLMProvider, StreamChatParams } from 'claude-to-im/src/lib/bridge/host.js'; +import type { PendingPermissions } from './permission-gateway.js'; +import { sseEvent } from './sse-utils.js'; + +/** MIME → file extension for temp image files. */ +const MIME_EXT: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', +}; + +// All SDK types kept as `any` because @openai/codex-sdk is optional. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CodexModule = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CodexInstance = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ThreadInstance = any; + +/** + * Map bridge permission modes to Codex approval policies. + * - 'acceptEdits' (code mode) → 'on-failure' (auto-approve most things) + * - 'plan' → 'on-request' (ask before executing) + * - 'default' (ask mode) → 'on-request' + */ +function toApprovalPolicy(permissionMode?: string): string { + switch (permissionMode) { + case 'acceptEdits': return 'on-failure'; + case 'plan': return 'on-request'; + case 'default': return 'on-request'; + default: return 'on-request'; + } +} + +/** Whether to forward bridge model to Codex CLI. Default: false (use Codex current/default model). */ +function shouldPassModelToCodex(): boolean { + return process.env.CTI_CODEX_PASS_MODEL === 'true'; +} + +function looksLikeClaudeModel(model?: string): boolean { + return !!model && /^claude[-_]/i.test(model); +} + +function shouldRetryFreshThread(message: string): boolean { + const lower = message.toLowerCase(); + return ( + lower.includes('resuming session with different model') || + lower.includes('no such session') || + (lower.includes('resume') && lower.includes('session')) + ); +} + +export class CodexProvider implements LLMProvider { + private sdk: CodexModule | null = null; + private codex: CodexInstance | null = null; + + /** Maps session IDs to Codex thread IDs for resume. */ + private threadIds = new Map(); + + constructor(private pendingPerms: PendingPermissions) {} + + /** + * Lazily load the Codex SDK. Throws a clear error if not installed. + */ + private async ensureSDK(): Promise<{ sdk: CodexModule; codex: CodexInstance }> { + if (this.sdk && this.codex) { + return { sdk: this.sdk, codex: this.codex }; + } + + try { + this.sdk = await (Function('return import("@openai/codex-sdk")')() as Promise); + } catch { + throw new Error( + '[CodexProvider] @openai/codex-sdk is not installed. ' + + 'Install it with: npm install @openai/codex-sdk' + ); + } + + // Resolve API key: CTI_CODEX_API_KEY > CODEX_API_KEY > OPENAI_API_KEY > (login auth) + const apiKey = process.env.CTI_CODEX_API_KEY + || process.env.CODEX_API_KEY + || process.env.OPENAI_API_KEY + || undefined; + const baseUrl = process.env.CTI_CODEX_BASE_URL || undefined; + + const CodexClass = this.sdk.Codex; + this.codex = new CodexClass({ + ...(apiKey ? { apiKey } : {}), + ...(baseUrl ? { baseUrl } : {}), + }); + + return { sdk: this.sdk, codex: this.codex }; + } + + streamChat(params: StreamChatParams): ReadableStream { + const self = this; + + return new ReadableStream({ + start(controller) { + (async () => { + const tempFiles: string[] = []; + try { + const { codex } = await self.ensureSDK(); + + // Resolve or create thread + let savedThreadId = params.sdkSessionId + ? self.threadIds.get(params.sessionId) || params.sdkSessionId + : undefined; + + // Cross-runtime migration safety: + // when a persisted Claude-model session leaks into Codex runtime, + // resuming it can fail immediately with model/session mismatch. + if (savedThreadId && looksLikeClaudeModel(params.model)) { + console.warn('[codex-provider] Ignoring stale Claude-like sdkSessionId in Codex runtime; starting fresh thread'); + savedThreadId = undefined; + } + + const approvalPolicy = toApprovalPolicy(params.permissionMode); + const passModel = shouldPassModelToCodex(); + + const threadOptions: Record = { + ...(passModel && params.model ? { model: params.model } : {}), + ...(params.workingDirectory ? { workingDirectory: params.workingDirectory } : {}), + approvalPolicy, + }; + + // Build input: Codex SDK UserInput supports { type: "text" } and + // { type: "local_image", path: string }. We write base64 data to + // temp files so the SDK can read them as local images. + const imageFiles = params.files?.filter( + f => f.type.startsWith('image/') + ) ?? []; + + let input: string | Array>; + if (imageFiles.length > 0) { + const parts: Array> = [ + { type: 'text', text: params.prompt }, + ]; + for (const file of imageFiles) { + const ext = MIME_EXT[file.type] || '.png'; + const tmpPath = path.join(os.tmpdir(), `cti-img-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`); + fs.writeFileSync(tmpPath, Buffer.from(file.data, 'base64')); + tempFiles.push(tmpPath); + parts.push({ type: 'local_image', path: tmpPath }); + } + input = parts; + } else { + input = params.prompt; + } + + let retryFresh = false; + + while (true) { + let thread: ThreadInstance; + if (savedThreadId) { + try { + thread = codex.resumeThread(savedThreadId, threadOptions); + } catch { + thread = codex.startThread(threadOptions); + } + } else { + thread = codex.startThread(threadOptions); + } + + let sawAnyEvent = false; + try { + const { events } = await thread.runStreamed(input); + + for await (const event of events) { + sawAnyEvent = true; + if (params.abortController?.signal.aborted) { + break; + } + + switch (event.type) { + case 'thread.started': { + const threadId = event.thread_id as string; + self.threadIds.set(params.sessionId, threadId); + + controller.enqueue(sseEvent('status', { + session_id: threadId, + })); + break; + } + + case 'item.completed': { + const item = event.item as Record; + self.handleCompletedItem(controller, item); + break; + } + + case 'turn.completed': { + const usage = event.usage as Record | undefined; + const threadId = self.threadIds.get(params.sessionId); + + controller.enqueue(sseEvent('result', { + usage: usage ? { + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: usage.cached_input_tokens ?? 0, + } : undefined, + ...(threadId ? { session_id: threadId } : {}), + })); + break; + } + + case 'turn.failed': { + const error = (event as { message?: string }).message; + controller.enqueue(sseEvent('error', error || 'Turn failed')); + break; + } + + case 'error': { + const error = (event as { message?: string }).message; + controller.enqueue(sseEvent('error', error || 'Thread error')); + break; + } + + // item.started, item.updated, turn.started — no action needed + } + } + break; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (savedThreadId && !retryFresh && !sawAnyEvent && shouldRetryFreshThread(message)) { + console.warn('[codex-provider] Resume failed, retrying with a fresh thread:', message); + savedThreadId = undefined; + retryFresh = true; + continue; + } + throw err; + } + } + + controller.close(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[codex-provider] Error:', err instanceof Error ? err.stack || err.message : err); + try { + controller.enqueue(sseEvent('error', message)); + controller.close(); + } catch { + // Controller already closed + } + } finally { + // Clean up temp image files + for (const tmp of tempFiles) { + try { fs.unlinkSync(tmp); } catch { /* ignore */ } + } + } + })(); + }, + }); + } + + /** + * Map a completed Codex item to SSE events. + */ + private handleCompletedItem( + controller: ReadableStreamDefaultController, + item: Record, + ): void { + const itemType = item.type as string; + + switch (itemType) { + case 'agent_message': { + const text = (item.text as string) || ''; + if (text) { + controller.enqueue(sseEvent('text', text)); + } + break; + } + + case 'command_execution': { + const toolId = (item.id as string) || `tool-${Date.now()}`; + const command = item.command as string || ''; + const output = item.aggregated_output as string || ''; + const exitCode = item.exit_code as number | undefined; + const isError = exitCode != null && exitCode !== 0; + + controller.enqueue(sseEvent('tool_use', { + id: toolId, + name: 'Bash', + input: { command }, + })); + + const resultContent = output || (isError ? `Exit code: ${exitCode}` : 'Done'); + controller.enqueue(sseEvent('tool_result', { + tool_use_id: toolId, + content: resultContent, + is_error: isError, + })); + break; + } + + case 'file_change': { + const toolId = (item.id as string) || `tool-${Date.now()}`; + const changes = item.changes as Array<{ path: string; kind: string }> || []; + const summary = changes.map(c => `${c.kind}: ${c.path}`).join('\n'); + + controller.enqueue(sseEvent('tool_use', { + id: toolId, + name: 'Edit', + input: { files: changes }, + })); + + controller.enqueue(sseEvent('tool_result', { + tool_use_id: toolId, + content: summary || 'File changes applied', + is_error: false, + })); + break; + } + + case 'mcp_tool_call': { + const toolId = (item.id as string) || `tool-${Date.now()}`; + const server = item.server as string || ''; + const tool = item.tool as string || ''; + const args = item.arguments as unknown; + const result = item.result as { content?: unknown; structured_content?: unknown } | undefined; + const error = item.error as { message?: string } | undefined; + + const resultContent = result?.content ?? result?.structured_content; + const resultText = typeof resultContent === 'string' ? resultContent : (resultContent ? JSON.stringify(resultContent) : undefined); + + controller.enqueue(sseEvent('tool_use', { + id: toolId, + name: `mcp__${server}__${tool}`, + input: args, + })); + + controller.enqueue(sseEvent('tool_result', { + tool_use_id: toolId, + content: error?.message || resultText || 'Done', + is_error: !!error, + })); + break; + } + + case 'reasoning': { + // Reasoning is internal; emit as status + const text = (item.text as string) || ''; + if (text) { + controller.enqueue(sseEvent('status', { reasoning: text })); + } + break; + } + } + } +} diff --git a/bridge/claude-to-im/src/config.ts b/bridge/claude-to-im/src/config.ts new file mode 100644 index 0000000..d8b2256 --- /dev/null +++ b/bridge/claude-to-im/src/config.ts @@ -0,0 +1,253 @@ +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[]; + // QQ + qqAppId?: string; + qqAppSecret?: string; + qqAllowedUsers?: string[]; + qqImageEnabled?: boolean; + qqMaxImageSize?: number; + // 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 { + const entries = new Map(); + 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(); + 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")), + qqAppId: env.get("CTI_QQ_APP_ID") || undefined, + qqAppSecret: env.get("CTI_QQ_APP_SECRET") || undefined, + qqAllowedUsers: splitCsv(env.get("CTI_QQ_ALLOWED_USERS")), + qqImageEnabled: env.has("CTI_QQ_IMAGE_ENABLED") + ? env.get("CTI_QQ_IMAGE_ENABLED") === "true" + : undefined, + qqMaxImageSize: env.get("CTI_QQ_MAX_IMAGE_SIZE") + ? Number(env.get("CTI_QQ_MAX_IMAGE_SIZE")) + : undefined, + 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(",") + ); + out += formatEnvLine("CTI_QQ_APP_ID", config.qqAppId); + out += formatEnvLine("CTI_QQ_APP_SECRET", config.qqAppSecret); + out += formatEnvLine( + "CTI_QQ_ALLOWED_USERS", + config.qqAllowedUsers?.join(",") + ); + if (config.qqImageEnabled !== undefined) + out += formatEnvLine("CTI_QQ_IMAGE_ENABLED", String(config.qqImageEnabled)); + if (config.qqMaxImageSize !== undefined) + out += formatEnvLine("CTI_QQ_MAX_IMAGE_SIZE", String(config.qqMaxImageSize)); + + 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 { + const m = new Map(); + 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(",")); + + // ── QQ ── + // Upstream keys: bridge_qq_enabled, bridge_qq_app_id, bridge_qq_app_secret, + // bridge_qq_allowed_users, bridge_qq_image_enabled, bridge_qq_max_image_size + m.set( + "bridge_qq_enabled", + config.enabledChannels.includes("qq") ? "true" : "false" + ); + if (config.qqAppId) m.set("bridge_qq_app_id", config.qqAppId); + if (config.qqAppSecret) m.set("bridge_qq_app_secret", config.qqAppSecret); + if (config.qqAllowedUsers) + m.set("bridge_qq_allowed_users", config.qqAllowedUsers.join(",")); + if (config.qqImageEnabled !== undefined) + m.set("bridge_qq_image_enabled", String(config.qqImageEnabled)); + if (config.qqMaxImageSize !== undefined) + m.set("bridge_qq_max_image_size", String(config.qqMaxImageSize)); + + // ── 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; +} diff --git a/bridge/claude-to-im/src/llm-provider.ts b/bridge/claude-to-im/src/llm-provider.ts new file mode 100644 index 0000000..31aedd2 --- /dev/null +++ b/bridge/claude-to-im/src/llm-provider.ts @@ -0,0 +1,723 @@ +/** + * LLM Provider using @anthropic-ai/claude-agent-sdk query() function. + * + * Converts SDK stream events into the SSE format expected by + * the claude-to-im bridge conversation engine. + */ + +import fs from 'node:fs'; +import { execSync } from 'node:child_process'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { SDKMessage, PermissionResult } from '@anthropic-ai/claude-agent-sdk'; +import type { LLMProvider, StreamChatParams, FileAttachment } from 'claude-to-im/src/lib/bridge/host.js'; +import type { PendingPermissions } from './permission-gateway.js'; + +import { sseEvent } from './sse-utils.js'; + +// ── Environment isolation ── + +/** Env vars always passed through to the CLI subprocess. */ +const ENV_WHITELIST = new Set([ + 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', + 'LANG', 'LC_ALL', 'LC_CTYPE', + 'TMPDIR', 'TEMP', 'TMP', + 'TERM', 'COLORTERM', + 'NODE_PATH', 'NODE_EXTRA_CA_CERTS', + 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', + 'SSH_AUTH_SOCK', +]); + +/** Prefixes that are always stripped (even in inherit mode). */ +const ENV_ALWAYS_STRIP = ['CLAUDECODE']; + +// ── Auth/credential-error detection ── + +/** Patterns indicating the local CLI is not logged in (fixable via `claude auth login`). */ +const CLI_AUTH_PATTERNS = [ + /not logged in/i, + /please run \/login/i, + /loggedIn['":\s]*false/i, +]; + +/** + * Patterns indicating an API-level credential failure (wrong key, expired token, org restriction). + * Must be specific to API/auth context — avoid matching local file permissions, tool denials, + * or generic HTTP 403s that may have non-auth causes. + */ +const API_AUTH_PATTERNS = [ + /unauthorized/i, + /invalid.*api.?key/i, + /authentication.*failed/i, + /does not have access/i, + /401\b/, +]; + +export type AuthErrorKind = 'cli' | 'api' | false; + +/** + * Classify an error message as a CLI login issue, an API credential issue, or neither. + * Returns 'cli' for local auth problems, 'api' for remote credential problems, false otherwise. + */ +export function classifyAuthError(text: string): AuthErrorKind { + if (CLI_AUTH_PATTERNS.some(re => re.test(text))) return 'cli'; + if (API_AUTH_PATTERNS.some(re => re.test(text))) return 'api'; + return false; +} + +/** Backwards-compatible: returns true for any auth/credential error. */ +export function isAuthError(text: string): boolean { + return classifyAuthError(text) !== false; +} + +const CLI_AUTH_USER_MESSAGE = + 'Claude CLI is not logged in. Run `claude auth login`, then restart the bridge.'; + +const API_AUTH_USER_MESSAGE = + 'API credential error. Check your ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN in config.env, ' + + 'or verify your organization has access to the requested model.'; + +// ── Cross-runtime model guard ── + +const NON_CLAUDE_MODEL_RE = /^(gpt-|o[1-9][-_]|codex[-_]|davinci|text-|openai\/)/i; + +/** Return true if a model name clearly belongs to a non-Claude provider. */ +export function isNonClaudeModel(model?: string): boolean { + return !!model && NON_CLAUDE_MODEL_RE.test(model); +} + +/** + * Build a clean env for the CLI subprocess. + * + * CTI_ENV_ISOLATION (default "inherit"): + * "inherit" — full parent env minus CLAUDECODE (recommended; daemon + * already runs in a clean launchd/setsid environment) + * "strict" — only whitelist + CTI_* + ANTHROPIC_* from config.env + */ +export function buildSubprocessEnv(): Record { + const mode = process.env.CTI_ENV_ISOLATION || 'inherit'; + const out: Record = {}; + + if (mode === 'inherit') { + // Pass everything except always-stripped vars + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined) continue; + if (ENV_ALWAYS_STRIP.includes(k)) continue; + out[k] = v; + } + } else { + // Strict: whitelist only + for (const [k, v] of Object.entries(process.env)) { + if (v === undefined) continue; + if (ENV_WHITELIST.has(k)) { out[k] = v; continue; } + // Pass through CTI_* so skill config is available + if (k.startsWith('CTI_')) { out[k] = v; continue; } + } + // Always pass through ANTHROPIC_* in claude/auto runtime — + // third-party API providers need these to reach the CLI subprocess. + const runtime = process.env.CTI_RUNTIME || 'claude'; + if (runtime === 'claude' || runtime === 'auto') { + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && k.startsWith('ANTHROPIC_')) out[k] = v; + } + } + + // In codex/auto mode, pass through OPENAI_* / CODEX_* env vars + if (runtime === 'codex' || runtime === 'auto') { + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && (k.startsWith('OPENAI_') || k.startsWith('CODEX_'))) out[k] = v; + } + } + } + + return out; +} + +// ── Claude CLI preflight check ── + +/** Minimum major version of Claude CLI required by the SDK. */ +const MIN_CLI_MAJOR = 2; + +/** + * Parse a version string like "2.3.1" or "claude 2.3.1" into a major number. + * Returns undefined if parsing fails. + */ +export function parseCliMajorVersion(versionOutput: string): number | undefined { + const m = versionOutput.match(/(\d+)\.\d+/); + return m ? parseInt(m[1], 10) : undefined; +} + +/** + * Run `claude --version` at a given path and return the version string. + * Returns undefined on failure. + */ +function getCliVersion(cliPath: string, env?: Record): string | undefined { + try { + return execSync(`"${cliPath}" --version`, { + encoding: 'utf-8', + timeout: 10_000, + env: env || buildSubprocessEnv(), + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + return undefined; + } +} + +/** + * Flags that the SDK passes to the CLI subprocess. + * If `claude --help` doesn't mention these, the CLI build is incompatible. + */ +const REQUIRED_CLI_FLAGS = ['output-format', 'input-format', 'permission-mode', 'setting-sources']; + +/** + * Check `claude --help` for required flags. + * Returns the list of missing flags (empty = all present). + */ +function checkRequiredFlags(cliPath: string, env?: Record): string[] { + let helpText: string; + try { + helpText = execSync(`"${cliPath}" --help`, { + encoding: 'utf-8', + timeout: 10_000, + env: env || buildSubprocessEnv(), + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + // Can't run --help; don't block on this — version check is primary + return []; + } + return REQUIRED_CLI_FLAGS.filter(flag => !helpText.includes(flag)); +} + +/** + * Check if a CLI path points to a compatible (>= 2.x) Claude CLI + * with the required flags for SDK integration. + * Returns { compatible, version, ... } or undefined if the CLI cannot run at all. + */ +export function checkCliCompatibility(cliPath: string, env?: Record): { + compatible: boolean; + version: string; + major: number | undefined; + missingFlags?: string[]; +} | undefined { + const version = getCliVersion(cliPath, env); + if (!version) return undefined; + const major = parseCliMajorVersion(version); + if (major === undefined || major < MIN_CLI_MAJOR) { + return { compatible: false, version, major }; + } + // Version OK — verify required flags exist + const missing = checkRequiredFlags(cliPath, env); + return { + compatible: missing.length === 0, + version, + major, + missingFlags: missing.length > 0 ? missing : undefined, + }; +} + +/** + * Run a lightweight preflight check to verify the claude CLI can start + * and supports the flags required by the SDK. + * Returns { ok, version?, error? }. + */ +export function preflightCheck(cliPath: string): { ok: boolean; version?: string; error?: string } { + const cleanEnv = buildSubprocessEnv(); + const compat = checkCliCompatibility(cliPath, cleanEnv); + if (!compat) { + return { ok: false, error: `claude CLI at "${cliPath}" failed to execute` }; + } + if (compat.major !== undefined && compat.major < MIN_CLI_MAJOR) { + return { + ok: false, + version: compat.version, + error: `claude CLI version ${compat.version} is too old (need >= ${MIN_CLI_MAJOR}.x). ` + + `This is likely an npm-installed 1.x CLI. Install the native CLI: https://docs.anthropic.com/en/docs/claude-code`, + }; + } + if (compat.missingFlags) { + return { + ok: false, + version: compat.version, + error: `claude CLI ${compat.version} is missing required flags: ${compat.missingFlags.join(', ')}. ` + + `Update the CLI: npm update -g @anthropic-ai/claude-code`, + }; + } + return { ok: true, version: compat.version }; +} + +// ── Claude CLI path resolution ── + +function isExecutable(p: string): boolean { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Resolve all `claude` executables found in PATH (Unix only). + * Returns an array of absolute paths. + */ +function findAllInPath(): string[] { + if (process.platform === 'win32') { + try { + return execSync('where claude', { encoding: 'utf-8', timeout: 3000 }) + .trim().split('\n').map(s => s.trim()).filter(Boolean); + } catch { return []; } + } + try { + // `which -a` lists all matches, not just the first + return execSync('which -a claude', { encoding: 'utf-8', timeout: 3000 }) + .trim().split('\n').map(s => s.trim()).filter(Boolean); + } catch { return []; } +} + +/** + * Resolve the path to the `claude` CLI executable. + * + * Priority: + * 1. CTI_CLAUDE_CODE_EXECUTABLE env var (explicit override) + * 2. All `claude` executables in PATH — pick first compatible (>= 2.x) + * 3. Common install locations — pick first compatible (>= 2.x) + * + * This multi-candidate approach handles the common scenario where + * nvm/npm puts an old 1.x claude in PATH before the native 2.x CLI. + */ +export function resolveClaudeCliPath(): string | undefined { + // 1. Explicit env var — trust the user + const fromEnv = process.env.CTI_CLAUDE_CODE_EXECUTABLE; + if (fromEnv && isExecutable(fromEnv)) return fromEnv; + + // 2. Gather all candidates + const isWindows = process.platform === 'win32'; + const pathCandidates = findAllInPath(); + const wellKnown = isWindows + ? [ + process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}\\Programs\\claude\\claude.exe` : '', + 'C:\\Program Files\\claude\\claude.exe', + ].filter(Boolean) + : [ + `${process.env.HOME}/.claude/local/claude`, + `${process.env.HOME}/.local/bin/claude`, + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + `${process.env.HOME}/.npm-global/bin/claude`, + ]; + + // Deduplicate while preserving order + const seen = new Set(); + const allCandidates: string[] = []; + for (const p of [...pathCandidates, ...wellKnown]) { + if (p && !seen.has(p)) { + seen.add(p); + allCandidates.push(p); + } + } + + // 3. Pick the first compatible candidate + let firstUnverifiable: string | undefined; + for (const p of allCandidates) { + if (!isExecutable(p)) continue; + + const compat = checkCliCompatibility(p); + if (compat?.compatible) { + if (p !== pathCandidates[0] && pathCandidates.length > 0) { + console.log(`[llm-provider] Skipping incompatible CLI at "${pathCandidates[0]}", using "${p}" (${compat.version})`); + } + return p; + } + if (compat) { + // Version detected but too old — skip it entirely, do NOT fall back + console.warn(`[llm-provider] CLI at "${p}" is version ${compat.version} (need >= ${MIN_CLI_MAJOR}.x), skipping`); + } else if (!firstUnverifiable) { + // Executable exists but --version failed (timeout, crash, etc.) + // Keep as last-resort fallback only if NO candidate had a parseable version + firstUnverifiable = p; + } + } + + // Only fall back to an unverifiable executable — never to a known-old one. + // This avoids silently using a 1.x CLI that will crash on first message. + return firstUnverifiable; +} + +// ── Multi-modal prompt builder ── + +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + +const SUPPORTED_IMAGE_TYPES = new Set([ + 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', +]); + +/** + * Build a prompt for query(). When files are present, returns an async + * iterable that yields a single SDKUserMessage with multi-modal content + * (image blocks + text). Otherwise returns the plain text string. + */ +function buildPrompt( + text: string, + files?: FileAttachment[], +): string | AsyncIterable<{ type: 'user'; message: { role: 'user'; content: unknown[] }; parent_tool_use_id: null; session_id: string }> { + const imageFiles = files?.filter(f => SUPPORTED_IMAGE_TYPES.has(f.type)); + if (!imageFiles || imageFiles.length === 0) return text; + + const contentBlocks: unknown[] = []; + + for (const file of imageFiles) { + contentBlocks.push({ + type: 'image', + source: { + type: 'base64', + media_type: (file.type === 'image/jpg' ? 'image/jpeg' : file.type) as ImageMediaType, + data: file.data, + }, + }); + } + + if (text.trim()) { + contentBlocks.push({ type: 'text', text }); + } + + const msg = { + type: 'user' as const, + message: { role: 'user' as const, content: contentBlocks }, + parent_tool_use_id: null, + session_id: '', + }; + + return (async function* () { yield msg; })(); +} + +/** + * Mutable state shared between the streaming loop and catch block. + * + * Key distinction: + * hasReceivedResult — set when the SDK delivers a `result` message + * (success OR structured error). This means the CLI completed its + * business logic; any subsequent "process exited with code 1" is + * just the transport tearing down and should be suppressed. + * + * hasStreamedText — set when at least one text_delta was emitted. + * Used to distinguish "partial output + crash" (real failure, must + * emit error) from "business error only in assistant block" (use + * lastAssistantText instead of generic error). + */ +export interface StreamState { + /** True once a `result` message (success or error subtype) has been processed. */ + hasReceivedResult: boolean; + /** True once any text_delta has been emitted via stream_event. */ + hasStreamedText: boolean; + /** + * Full text captured from the final `assistant` message. + * NOT emitted during normal flow (stream_event deltas handle that). + * Used by the catch block to surface business errors that arrived + * as assistant text but were followed by a CLI crash. + */ + lastAssistantText: string; +} + +export class SDKLLMProvider implements LLMProvider { + private cliPath: string | undefined; + private autoApprove: boolean; + + constructor(private pendingPerms: PendingPermissions, cliPath?: string, autoApprove = false) { + this.cliPath = cliPath; + this.autoApprove = autoApprove; + } + + streamChat(params: StreamChatParams): ReadableStream { + const pendingPerms = this.pendingPerms; + const cliPath = this.cliPath; + const autoApprove = this.autoApprove; + + return new ReadableStream({ + start(controller) { + (async () => { + // Ring-buffer for recent stderr output (max 4 KB) + const MAX_STDERR = 4096; + let stderrBuf = ''; + const state: StreamState = { hasReceivedResult: false, hasStreamedText: false, lastAssistantText: '' }; + + try { + const cleanEnv = buildSubprocessEnv(); + + // Cross-runtime migration safety: drop non-Claude model names + // that may linger in session data from a previous Codex runtime. + let model = params.model; + if (isNonClaudeModel(model)) { + console.warn(`[llm-provider] Ignoring non-Claude model "${model}", using CLI default`); + model = undefined; + } + + // Only pass model to CLI if explicitly configured via CTI_DEFAULT_MODEL. + // Letting the CLI choose its own default avoids exit-code-1 failures + // when a stored model is inaccessible on the current machine/plan. + const passModel = !!process.env.CTI_DEFAULT_MODEL; + if (model && !passModel) { + console.log(`[llm-provider] Skipping model "${model}", using CLI default (set CTI_DEFAULT_MODEL to override)`); + model = undefined; + } + + const queryOptions: Record = { + cwd: params.workingDirectory, + model, + resume: params.sdkSessionId || undefined, + abortController: params.abortController, + permissionMode: (params.permissionMode as 'default' | 'acceptEdits' | 'plan') || undefined, + includePartialMessages: true, + env: cleanEnv, + stderr: (data: string) => { + stderrBuf += data; + if (stderrBuf.length > MAX_STDERR) { + stderrBuf = stderrBuf.slice(-MAX_STDERR); + } + }, + canUseTool: async ( + toolName: string, + input: Record, + opts: { toolUseID: string; suggestions?: string[] }, + ): Promise => { + // Auto-approve if configured (useful for channels without + // interactive permission UI, e.g. Feishu WebSocket mode) + if (autoApprove) { + return { behavior: 'allow' as const, updatedInput: input }; + } + + // Emit permission_request SSE event for the bridge + controller.enqueue( + sseEvent('permission_request', { + permissionRequestId: opts.toolUseID, + toolName, + toolInput: input, + suggestions: opts.suggestions || [], + }), + ); + + // Block until IM user responds + const result = await pendingPerms.waitFor(opts.toolUseID); + + if (result.behavior === 'allow') { + return { behavior: 'allow' as const, updatedInput: input }; + } + return { + behavior: 'deny' as const, + message: result.message || 'Denied by user', + }; + }, + }; + if (cliPath) { + queryOptions.pathToClaudeCodeExecutable = cliPath; + } + + const prompt = buildPrompt(params.prompt, params.files); + const q = query({ + prompt: prompt as Parameters[0]['prompt'], + options: queryOptions as Parameters[0]['options'], + }); + + for await (const msg of q) { + handleMessage(msg, controller, state); + } + + controller.close(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[llm-provider] SDK query error:', err instanceof Error ? err.stack || err.message : err); + if (stderrBuf) { + console.error('[llm-provider] stderr from CLI:', stderrBuf.trim()); + } + + const isTransportExit = message.includes('process exited with code'); + + // ── Case 1: Result already received ── + // The SDK delivered a proper result (success or structured error). + // A trailing "process exited with code 1" is transport teardown noise. + if (state.hasReceivedResult && isTransportExit) { + console.log('[llm-provider] Suppressing transport error — result already received'); + controller.close(); + return; + } + + // ── Case 2: Recognised business error in assistant text ── + // The CLI returned an assistant message with text that matches + // a known auth/access error pattern (e.g. "Your organization + // does not have access to Claude"). Forward it as-is — it's + // more informative than the generic transport error. + // Only activate when the text is a recognised error; otherwise + // a normal response that crashed before result would be silently + // presented as if it succeeded. + if (state.lastAssistantText && classifyAuthError(state.lastAssistantText)) { + controller.enqueue(sseEvent('text', state.lastAssistantText)); + controller.close(); + return; + } + + // ── Case 3: Partial output + crash ── + // Text was streamed but no result arrived — the response was + // truncated by a real crash. Always emit an error so the user + // knows the output is incomplete. + + // ── Build user-facing error message ── + const authKind = classifyAuthError(message) || classifyAuthError(stderrBuf); + let userMessage: string; + if (authKind === 'cli') { + userMessage = CLI_AUTH_USER_MESSAGE; + } else if (authKind === 'api') { + userMessage = API_AUTH_USER_MESSAGE; + } else if (isTransportExit) { + const stderrSummary = stderrBuf.trim(); + const lines = [message]; + if (stderrSummary) { + lines.push('', 'CLI stderr:', stderrSummary.slice(-1024)); + } + lines.push( + '', + 'Possible causes:', + '• Claude CLI not authenticated — run: claude auth login', + '• Claude CLI version too old (need >= 2.x) — run: claude --version', + '• Missing ANTHROPIC_* env vars in daemon — check config.env', + '', + 'Run `/claude-to-im doctor` to diagnose.', + ); + userMessage = lines.join('\n'); + } else { + userMessage = message; + } + + controller.enqueue(sseEvent('error', userMessage)); + controller.close(); + } + })(); + }, + }); + } +} + +/** @internal Exported for testing. */ +export function handleMessage( + msg: SDKMessage, + controller: ReadableStreamDefaultController, + state: StreamState, +): void { + switch (msg.type) { + case 'stream_event': { + const event = msg.event; + if ( + event.type === 'content_block_delta' && + event.delta.type === 'text_delta' + ) { + // Emit delta text — the bridge accumulates on its side + controller.enqueue(sseEvent('text', event.delta.text)); + state.hasStreamedText = true; + } + if ( + event.type === 'content_block_start' && + event.content_block.type === 'tool_use' + ) { + controller.enqueue( + sseEvent('tool_use', { + id: event.content_block.id, + name: event.content_block.name, + input: {}, + }), + ); + } + break; + } + + case 'assistant': { + // Full assistant message — capture text but do NOT emit it. + // Text deltas are already streamed via stream_event above; emitting + // the full text block here would duplicate the entire response. + // + // The captured text is used by the catch block to surface business + // errors (e.g. "Your organization does not have access") that the + // CLI returned as assistant text without prior streaming deltas. + if (msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + state.lastAssistantText += (state.lastAssistantText ? '\n' : '') + block.text; + } else if (block.type === 'tool_use') { + controller.enqueue( + sseEvent('tool_use', { + id: block.id, + name: block.name, + input: block.input, + }), + ); + } + } + } + break; + } + + case 'user': { + // User messages contain tool_result blocks from completed tool calls + const content = msg.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === 'object' && block !== null && 'type' in block && block.type === 'tool_result') { + const rb = block as { tool_use_id: string; content?: unknown; is_error?: boolean }; + const text = typeof rb.content === 'string' + ? rb.content + : JSON.stringify(rb.content ?? ''); + controller.enqueue( + sseEvent('tool_result', { + tool_use_id: rb.tool_use_id, + content: text, + is_error: rb.is_error || false, + }), + ); + } + } + } + break; + } + + case 'result': { + state.hasReceivedResult = true; + if (msg.subtype === 'success') { + controller.enqueue( + sseEvent('result', { + session_id: msg.session_id, + is_error: msg.is_error, + usage: { + input_tokens: msg.usage.input_tokens, + output_tokens: msg.usage.output_tokens, + cache_read_input_tokens: msg.usage.cache_read_input_tokens ?? 0, + cache_creation_input_tokens: msg.usage.cache_creation_input_tokens ?? 0, + cost_usd: msg.total_cost_usd, + }, + }), + ); + } else { + // Error result from SDK (distinct from transport errors in catch) + const errors = + 'errors' in msg && Array.isArray(msg.errors) + ? msg.errors.join('; ') + : 'Unknown error'; + controller.enqueue(sseEvent('error', errors)); + } + break; + } + + case 'system': { + if (msg.subtype === 'init') { + controller.enqueue( + sseEvent('status', { + session_id: msg.session_id, + model: msg.model, + }), + ); + } + break; + } + + default: + // Ignore other message types (auth_status, task_notification, etc.) + break; + } +} diff --git a/bridge/claude-to-im/src/logger.ts b/bridge/claude-to-im/src/logger.ts new file mode 100644 index 0000000..a566fc6 --- /dev/null +++ b/bridge/claude-to-im/src/logger.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { CTI_HOME } from './config.js'; + +const MASK_PATTERNS: RegExp[] = [ + /(?:token|secret|password|api_key)["']?\s*[:=]\s*["']?([^\s"',]+)/gi, + /bot\d+:[A-Za-z0-9_-]{35}/g, + /Bearer\s+[A-Za-z0-9._-]+/g, +]; + +export function maskSecrets(text: string): string { + let result = text; + for (const pattern of MASK_PATTERNS) { + pattern.lastIndex = 0; + result = result.replace(pattern, (match) => { + if (match.length <= 4) return match; + return '*'.repeat(match.length - 4) + match.slice(-4); + }); + } + return result; +} + +const LOG_DIR = path.join(CTI_HOME, 'logs'); +const LOG_PATH = path.join(LOG_DIR, 'bridge.log'); +const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_ROTATED = 3; + +let logStream: fs.WriteStream | null = null; + +function openLogStream(): fs.WriteStream { + return fs.createWriteStream(LOG_PATH, { flags: 'a' }); +} + +function rotateIfNeeded(): void { + try { + const stat = fs.statSync(LOG_PATH); + if (stat.size < MAX_LOG_SIZE) return; + } catch { + return; // file doesn't exist yet + } + + // Close current stream + if (logStream) { + logStream.end(); + logStream = null; + } + + // Rotate: delete .3, shift .2→.3, .1→.2, current→.1 + const path3 = `${LOG_PATH}.${MAX_ROTATED}`; + if (fs.existsSync(path3)) fs.unlinkSync(path3); + + for (let i = MAX_ROTATED - 1; i >= 1; i--) { + const src = `${LOG_PATH}.${i}`; + const dst = `${LOG_PATH}.${i + 1}`; + if (fs.existsSync(src)) fs.renameSync(src, dst); + } + + fs.renameSync(LOG_PATH, `${LOG_PATH}.1`); + logStream = openLogStream(); +} + +export function setupLogger(): void { + fs.mkdirSync(LOG_DIR, { recursive: true }); + logStream = openLogStream(); + + const write = (level: string, args: unknown[]) => { + const timestamp = new Date().toISOString(); + const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' '); + const formatted = `[${timestamp}] [${level}] ${message}`; + const masked = maskSecrets(formatted); + + rotateIfNeeded(); + logStream?.write(masked + '\n'); + }; + + console.log = (...args: unknown[]) => write('INFO', args); + console.error = (...args: unknown[]) => write('ERROR', args); + console.warn = (...args: unknown[]) => write('WARN', args); +} diff --git a/bridge/claude-to-im/src/main.ts b/bridge/claude-to-im/src/main.ts new file mode 100644 index 0000000..ccc2998 --- /dev/null +++ b/bridge/claude-to-im/src/main.ts @@ -0,0 +1,206 @@ +/** + * Daemon entry point for claude-to-im-skill. + * + * Assembles all DI implementations and starts the bridge. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +import { initBridgeContext } from 'claude-to-im/src/lib/bridge/context.js'; +import * as bridgeManager from 'claude-to-im/src/lib/bridge/bridge-manager.js'; +// Side-effect import to trigger adapter self-registration +import 'claude-to-im/src/lib/bridge/adapters/index.js'; + +import type { LLMProvider } from 'claude-to-im/src/lib/bridge/host.js'; +import { loadConfig, configToSettings, CTI_HOME } from './config.js'; +import type { Config } from './config.js'; +import { JsonFileStore } from './store.js'; +import { SDKLLMProvider, resolveClaudeCliPath, preflightCheck } from './llm-provider.js'; +import { PendingPermissions } from './permission-gateway.js'; +import { setupLogger } from './logger.js'; + +const RUNTIME_DIR = path.join(CTI_HOME, 'runtime'); +const STATUS_FILE = path.join(RUNTIME_DIR, 'status.json'); +const PID_FILE = path.join(RUNTIME_DIR, 'bridge.pid'); + +/** + * Resolve the LLM provider based on the runtime setting. + * - 'claude' (default): uses Claude Code SDK via SDKLLMProvider + * - 'codex': uses @openai/codex-sdk via CodexProvider + * - 'auto': tries Claude first, falls back to Codex + */ +async function resolveProvider(config: Config, pendingPerms: PendingPermissions): Promise { + const runtime = config.runtime; + + if (runtime === 'codex') { + const { CodexProvider } = await import('./codex-provider.js'); + return new CodexProvider(pendingPerms); + } + + if (runtime === 'auto') { + const cliPath = resolveClaudeCliPath(); + if (cliPath) { + // Auto mode: preflight the resolved CLI before committing to it. + const check = preflightCheck(cliPath); + if (check.ok) { + console.log(`[claude-to-im] Auto: using Claude CLI at ${cliPath} (${check.version})`); + return new SDKLLMProvider(pendingPerms, cliPath, config.autoApprove); + } + // Preflight failed — fall through to Codex instead of silently using a broken CLI + console.warn( + `[claude-to-im] Auto: Claude CLI at ${cliPath} failed preflight: ${check.error}\n` + + ` Falling back to Codex.`, + ); + } else { + console.log('[claude-to-im] Auto: Claude CLI not found, falling back to Codex'); + } + const { CodexProvider } = await import('./codex-provider.js'); + return new CodexProvider(pendingPerms); + } + + // Default: claude + const cliPath = resolveClaudeCliPath(); + if (!cliPath) { + console.error( + '[claude-to-im] FATAL: Cannot find the `claude` CLI executable.\n' + + ' Tried: CTI_CLAUDE_CODE_EXECUTABLE env, /usr/local/bin/claude, /opt/homebrew/bin/claude, ~/.npm-global/bin/claude, ~/.local/bin/claude\n' + + ' Fix: Install Claude Code CLI (https://docs.anthropic.com/en/docs/claude-code) or set CTI_CLAUDE_CODE_EXECUTABLE=/path/to/claude\n' + + ' Or: Set CTI_RUNTIME=codex to use Codex instead', + ); + process.exit(1); + } + + // Preflight: verify the CLI can actually run in the daemon environment. + // In claude runtime this is fatal — starting with a broken CLI would just + // defer the error to the first user message, which is harder to diagnose. + const check = preflightCheck(cliPath); + if (check.ok) { + console.log(`[claude-to-im] CLI preflight OK: ${cliPath} (${check.version})`); + } else { + console.error( + `[claude-to-im] FATAL: Claude CLI preflight check failed.\n` + + ` Path: ${cliPath}\n` + + ` Error: ${check.error}\n` + + ` Fix:\n` + + ` 1. Install Claude Code CLI >= 2.x: https://docs.anthropic.com/en/docs/claude-code\n` + + ` 2. Or set CTI_CLAUDE_CODE_EXECUTABLE=/path/to/correct/claude\n` + + ` 3. Or set CTI_RUNTIME=auto to fall back to Codex`, + ); + process.exit(1); + } + + return new SDKLLMProvider(pendingPerms, cliPath, config.autoApprove); +} + +interface StatusInfo { + running: boolean; + pid?: number; + runId?: string; + startedAt?: string; + channels?: string[]; + lastExitReason?: string; +} + +function writeStatus(info: StatusInfo): void { + fs.mkdirSync(RUNTIME_DIR, { recursive: true }); + // Merge with existing status to preserve fields like lastExitReason + let existing: Record = {}; + try { existing = JSON.parse(fs.readFileSync(STATUS_FILE, 'utf-8')); } catch { /* first write */ } + const merged = { ...existing, ...info }; + const tmp = STATUS_FILE + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf-8'); + fs.renameSync(tmp, STATUS_FILE); +} + +async function main(): Promise { + const config = loadConfig(); + setupLogger(); + + const runId = crypto.randomUUID(); + console.log(`[claude-to-im] Starting bridge (run_id: ${runId})`); + + const settings = configToSettings(config); + const store = new JsonFileStore(settings); + const pendingPerms = new PendingPermissions(); + const llm = await resolveProvider(config, pendingPerms); + console.log(`[claude-to-im] Runtime: ${config.runtime}`); + + const gateway = { + resolvePendingPermission: (id: string, resolution: { behavior: 'allow' | 'deny'; message?: string }) => + pendingPerms.resolve(id, resolution), + }; + + initBridgeContext({ + store, + llm, + permissions: gateway, + lifecycle: { + onBridgeStart: () => { + // Write authoritative PID from the actual process (not shell $!) + fs.mkdirSync(RUNTIME_DIR, { recursive: true }); + fs.writeFileSync(PID_FILE, String(process.pid), 'utf-8'); + writeStatus({ + running: true, + pid: process.pid, + runId, + startedAt: new Date().toISOString(), + channels: config.enabledChannels, + }); + console.log(`[claude-to-im] Bridge started (PID: ${process.pid}, channels: ${config.enabledChannels.join(', ')})`); + }, + onBridgeStop: () => { + writeStatus({ running: false }); + console.log('[claude-to-im] Bridge stopped'); + }, + }, + }); + + await bridgeManager.start(); + + // Graceful shutdown + let shuttingDown = false; + const shutdown = async (signal?: string) => { + if (shuttingDown) return; + shuttingDown = true; + const reason = signal ? `signal: ${signal}` : 'shutdown requested'; + console.log(`[claude-to-im] Shutting down (${reason})...`); + pendingPerms.denyAll(); + await bridgeManager.stop(); + writeStatus({ running: false, lastExitReason: reason }); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGHUP', () => shutdown('SIGHUP')); + + // ── Exit diagnostics ── + process.on('unhandledRejection', (reason) => { + console.error('[claude-to-im] unhandledRejection:', reason instanceof Error ? reason.stack || reason.message : reason); + writeStatus({ running: false, lastExitReason: `unhandledRejection: ${reason instanceof Error ? reason.message : String(reason)}` }); + }); + process.on('uncaughtException', (err) => { + console.error('[claude-to-im] uncaughtException:', err.stack || err.message); + writeStatus({ running: false, lastExitReason: `uncaughtException: ${err.message}` }); + process.exit(1); + }); + process.on('beforeExit', (code) => { + console.log(`[claude-to-im] beforeExit (code: ${code})`); + }); + process.on('exit', (code) => { + console.log(`[claude-to-im] exit (code: ${code})`); + }); + + // ── Heartbeat to keep event loop alive ── + // setInterval is ref'd by default, preventing Node from exiting + // when the event loop would otherwise be empty. + setInterval(() => { /* keepalive */ }, 45_000); +} + +main().catch((err) => { + console.error('[claude-to-im] Fatal error:', err instanceof Error ? err.stack || err.message : err); + try { writeStatus({ running: false, lastExitReason: `fatal: ${err instanceof Error ? err.message : String(err)}` }); } catch { /* ignore */ } + process.exit(1); +}); diff --git a/bridge/claude-to-im/src/permission-gateway.ts b/bridge/claude-to-im/src/permission-gateway.ts new file mode 100644 index 0000000..e838ee8 --- /dev/null +++ b/bridge/claude-to-im/src/permission-gateway.ts @@ -0,0 +1,52 @@ +export interface PermissionResult { + behavior: 'allow' | 'deny'; + message?: string; +} + +export interface PermissionResolution { + behavior: 'allow' | 'deny'; + message?: string; +} + +export class PendingPermissions { + private pending = new Map void; + timer: NodeJS.Timeout; + }>(); + private timeoutMs = 5 * 60 * 1000; // 5 minutes + + waitFor(toolUseID: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(toolUseID); + resolve({ behavior: 'deny', message: 'Permission request timed out' }); + }, this.timeoutMs); + this.pending.set(toolUseID, { resolve, timer }); + }); + } + + resolve(permissionRequestId: string, resolution: PermissionResolution): boolean { + const entry = this.pending.get(permissionRequestId); + if (!entry) return false; + clearTimeout(entry.timer); + if (resolution.behavior === 'allow') { + entry.resolve({ behavior: 'allow' }); + } else { + entry.resolve({ behavior: 'deny', message: resolution.message || 'Denied by user' }); + } + this.pending.delete(permissionRequestId); + return true; + } + + denyAll(): void { + for (const [, entry] of this.pending) { + clearTimeout(entry.timer); + entry.resolve({ behavior: 'deny', message: 'Bridge shutting down' }); + } + this.pending.clear(); + } + + get size(): number { + return this.pending.size; + } +} diff --git a/bridge/claude-to-im/src/sse-utils.ts b/bridge/claude-to-im/src/sse-utils.ts new file mode 100644 index 0000000..af26b17 --- /dev/null +++ b/bridge/claude-to-im/src/sse-utils.ts @@ -0,0 +1,11 @@ +/** + * 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`; +} diff --git a/bridge/claude-to-im/src/store.ts b/bridge/claude-to-im/src/store.ts new file mode 100644 index 0000000..51e14e4 --- /dev/null +++ b/bridge/claude-to-im/src/store.ts @@ -0,0 +1,473 @@ +/** + * JSON file-backed BridgeStore implementation. + * + * Uses in-memory Maps as cache with write-through persistence + * to JSON files in ~/.claude-to-im/data/. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import type { + BridgeStore, + BridgeSession, + BridgeMessage, + BridgeApiProvider, + AuditLogInput, + PermissionLinkInput, + PermissionLinkRecord, + OutboundRefInput, + UpsertChannelBindingInput, +} from 'claude-to-im/src/lib/bridge/host.js'; +import type { ChannelBinding, ChannelType } from 'claude-to-im/src/lib/bridge/types.js'; +import { CTI_HOME } from './config.js'; + +const DATA_DIR = path.join(CTI_HOME, 'data'); +const MESSAGES_DIR = path.join(DATA_DIR, 'messages'); + +// ── Helpers ── + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +function atomicWrite(filePath: string, data: string): void { + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, data, 'utf-8'); + fs.renameSync(tmp, filePath); +} + +function readJson(filePath: string, fallback: T): T { + try { + const raw = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +function writeJson(filePath: string, data: unknown): void { + atomicWrite(filePath, JSON.stringify(data, null, 2)); +} + +function uuid(): string { + return crypto.randomUUID(); +} + +function now(): string { + return new Date().toISOString(); +} + +// ── Lock entry ── + +interface LockEntry { + lockId: string; + owner: string; + expiresAt: number; +} + +// ── Store ── + +export class JsonFileStore implements BridgeStore { + private settings: Map; + private sessions = new Map(); + private bindings = new Map(); + private messages = new Map(); + private permissionLinks = new Map(); + private offsets = new Map(); + private dedupKeys = new Map(); + private locks = new Map(); + private auditLog: Array = []; + + constructor(settingsMap: Map) { + this.settings = settingsMap; + ensureDir(DATA_DIR); + ensureDir(MESSAGES_DIR); + this.loadAll(); + } + + // ── Persistence ── + + private loadAll(): void { + // Sessions + const sessions = readJson>( + path.join(DATA_DIR, 'sessions.json'), + {}, + ); + for (const [id, s] of Object.entries(sessions)) { + this.sessions.set(id, s); + } + + // Bindings + const bindings = readJson>( + path.join(DATA_DIR, 'bindings.json'), + {}, + ); + for (const [key, b] of Object.entries(bindings)) { + this.bindings.set(key, b); + } + + // Permission links + const perms = readJson>( + path.join(DATA_DIR, 'permissions.json'), + {}, + ); + for (const [id, p] of Object.entries(perms)) { + this.permissionLinks.set(id, p); + } + + // Offsets + const offsets = readJson>( + path.join(DATA_DIR, 'offsets.json'), + {}, + ); + for (const [k, v] of Object.entries(offsets)) { + this.offsets.set(k, v); + } + + // Dedup + const dedup = readJson>( + path.join(DATA_DIR, 'dedup.json'), + {}, + ); + for (const [k, v] of Object.entries(dedup)) { + this.dedupKeys.set(k, v); + } + + // Audit + this.auditLog = readJson(path.join(DATA_DIR, 'audit.json'), []); + } + + private persistSessions(): void { + writeJson( + path.join(DATA_DIR, 'sessions.json'), + Object.fromEntries(this.sessions), + ); + } + + private persistBindings(): void { + writeJson( + path.join(DATA_DIR, 'bindings.json'), + Object.fromEntries(this.bindings), + ); + } + + private persistPermissions(): void { + writeJson( + path.join(DATA_DIR, 'permissions.json'), + Object.fromEntries(this.permissionLinks), + ); + } + + private persistOffsets(): void { + writeJson( + path.join(DATA_DIR, 'offsets.json'), + Object.fromEntries(this.offsets), + ); + } + + private persistDedup(): void { + writeJson( + path.join(DATA_DIR, 'dedup.json'), + Object.fromEntries(this.dedupKeys), + ); + } + + private persistAudit(): void { + writeJson(path.join(DATA_DIR, 'audit.json'), this.auditLog); + } + + private persistMessages(sessionId: string): void { + const msgs = this.messages.get(sessionId) || []; + writeJson(path.join(MESSAGES_DIR, `${sessionId}.json`), msgs); + } + + private loadMessages(sessionId: string): BridgeMessage[] { + if (this.messages.has(sessionId)) { + return this.messages.get(sessionId)!; + } + const msgs = readJson( + path.join(MESSAGES_DIR, `${sessionId}.json`), + [], + ); + this.messages.set(sessionId, msgs); + return msgs; + } + + // ── Settings ── + + getSetting(key: string): string | null { + return this.settings.get(key) ?? null; + } + + // ── Channel Bindings ── + + getChannelBinding(channelType: string, chatId: string): ChannelBinding | null { + return this.bindings.get(`${channelType}:${chatId}`) ?? null; + } + + upsertChannelBinding(data: UpsertChannelBindingInput): ChannelBinding { + const key = `${data.channelType}:${data.chatId}`; + const existing = this.bindings.get(key); + if (existing) { + const updated: ChannelBinding = { + ...existing, + codepilotSessionId: data.codepilotSessionId, + workingDirectory: data.workingDirectory, + model: data.model, + updatedAt: now(), + }; + this.bindings.set(key, updated); + this.persistBindings(); + return updated; + } + const binding: ChannelBinding = { + id: uuid(), + channelType: data.channelType, + chatId: data.chatId, + codepilotSessionId: data.codepilotSessionId, + sdkSessionId: '', + workingDirectory: data.workingDirectory, + model: data.model, + mode: (this.settings.get('bridge_default_mode') as 'code' | 'plan' | 'ask') || 'code', + active: true, + createdAt: now(), + updatedAt: now(), + }; + this.bindings.set(key, binding); + this.persistBindings(); + return binding; + } + + updateChannelBinding(id: string, updates: Partial): void { + for (const [key, b] of this.bindings) { + if (b.id === id) { + this.bindings.set(key, { ...b, ...updates, updatedAt: now() }); + this.persistBindings(); + break; + } + } + } + + listChannelBindings(channelType?: ChannelType): ChannelBinding[] { + const all = Array.from(this.bindings.values()); + if (!channelType) return all; + return all.filter((b) => b.channelType === channelType); + } + + // ── Sessions ── + + getSession(id: string): BridgeSession | null { + return this.sessions.get(id) ?? null; + } + + createSession( + _name: string, + model: string, + systemPrompt?: string, + cwd?: string, + _mode?: string, + ): BridgeSession { + const session: BridgeSession = { + id: uuid(), + working_directory: cwd || this.settings.get('bridge_default_work_dir') || process.cwd(), + model, + system_prompt: systemPrompt, + }; + this.sessions.set(session.id, session); + this.persistSessions(); + return session; + } + + updateSessionProviderId(sessionId: string, providerId: string): void { + const s = this.sessions.get(sessionId); + if (s) { + s.provider_id = providerId; + this.persistSessions(); + } + } + + // ── Messages ── + + addMessage(sessionId: string, role: string, content: string, _usage?: string | null): void { + const msgs = this.loadMessages(sessionId); + msgs.push({ role, content }); + this.persistMessages(sessionId); + } + + getMessages(sessionId: string, opts?: { limit?: number }): { messages: BridgeMessage[] } { + const msgs = this.loadMessages(sessionId); + if (opts?.limit && opts.limit > 0) { + return { messages: msgs.slice(-opts.limit) }; + } + return { messages: [...msgs] }; + } + + // ── Session Locking ── + + acquireSessionLock(sessionId: string, lockId: string, owner: string, ttlSecs: number): boolean { + const existing = this.locks.get(sessionId); + if (existing && existing.expiresAt > Date.now()) { + // Lock held by someone else + if (existing.lockId !== lockId) return false; + } + this.locks.set(sessionId, { + lockId, + owner, + expiresAt: Date.now() + ttlSecs * 1000, + }); + return true; + } + + renewSessionLock(sessionId: string, lockId: string, ttlSecs: number): void { + const lock = this.locks.get(sessionId); + if (lock && lock.lockId === lockId) { + lock.expiresAt = Date.now() + ttlSecs * 1000; + } + } + + releaseSessionLock(sessionId: string, lockId: string): void { + const lock = this.locks.get(sessionId); + if (lock && lock.lockId === lockId) { + this.locks.delete(sessionId); + } + } + + setSessionRuntimeStatus(_sessionId: string, _status: string): void { + // no-op for file-based store + } + + // ── SDK Session ── + + updateSdkSessionId(sessionId: string, sdkSessionId: string): void { + const s = this.sessions.get(sessionId); + if (s) { + // Store sdkSessionId on the session object + (s as unknown as Record)['sdk_session_id'] = sdkSessionId; + this.persistSessions(); + } + // Also update any bindings that reference this session + for (const [key, b] of this.bindings) { + if (b.codepilotSessionId === sessionId) { + this.bindings.set(key, { ...b, sdkSessionId, updatedAt: now() }); + } + } + this.persistBindings(); + } + + updateSessionModel(sessionId: string, model: string): void { + const s = this.sessions.get(sessionId); + if (s) { + s.model = model; + this.persistSessions(); + } + } + + syncSdkTasks(_sessionId: string, _todos: unknown): void { + // no-op + } + + // ── Provider ── + + getProvider(_id: string): BridgeApiProvider | undefined { + return undefined; + } + + getDefaultProviderId(): string | null { + return null; + } + + // ── Audit & Dedup ── + + insertAuditLog(entry: AuditLogInput): void { + this.auditLog.push({ + ...entry, + id: uuid(), + createdAt: now(), + }); + // Ring buffer: keep last 1000 + if (this.auditLog.length > 1000) { + this.auditLog = this.auditLog.slice(-1000); + } + this.persistAudit(); + } + + checkDedup(key: string): boolean { + const ts = this.dedupKeys.get(key); + if (ts === undefined) return false; + // 5 minute window + if (Date.now() - ts > 5 * 60 * 1000) { + this.dedupKeys.delete(key); + return false; + } + return true; + } + + insertDedup(key: string): void { + this.dedupKeys.set(key, Date.now()); + this.persistDedup(); + } + + cleanupExpiredDedup(): void { + const cutoff = Date.now() - 5 * 60 * 1000; + let changed = false; + for (const [key, ts] of this.dedupKeys) { + if (ts < cutoff) { + this.dedupKeys.delete(key); + changed = true; + } + } + if (changed) this.persistDedup(); + } + + insertOutboundRef(_ref: OutboundRefInput): void { + // no-op for file-based store + } + + // ── Permission Links ── + + insertPermissionLink(link: PermissionLinkInput): void { + const record: PermissionLinkRecord = { + permissionRequestId: link.permissionRequestId, + chatId: link.chatId, + messageId: link.messageId, + resolved: false, + suggestions: link.suggestions, + }; + this.permissionLinks.set(link.permissionRequestId, record); + this.persistPermissions(); + } + + getPermissionLink(permissionRequestId: string): PermissionLinkRecord | null { + return this.permissionLinks.get(permissionRequestId) ?? null; + } + + markPermissionLinkResolved(permissionRequestId: string): boolean { + const link = this.permissionLinks.get(permissionRequestId); + if (!link || link.resolved) return false; + link.resolved = true; + this.persistPermissions(); + return true; + } + + listPendingPermissionLinksByChat(chatId: string): PermissionLinkRecord[] { + const result: PermissionLinkRecord[] = []; + for (const link of this.permissionLinks.values()) { + if (link.chatId === chatId && !link.resolved) { + result.push(link); + } + } + return result; + } + + // ── Channel Offsets ── + + getChannelOffset(key: string): string { + return this.offsets.get(key) ?? '0'; + } + + setChannelOffset(key: string, offset: string): void { + this.offsets.set(key, offset); + this.persistOffsets(); + } +} diff --git a/bridge/claude-to-im/tsconfig.json b/bridge/claude-to-im/tsconfig.json new file mode 100644 index 0000000..d141834 --- /dev/null +++ b/bridge/claude-to-im/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/docs/feishu-bridge-migration-report.md b/docs/feishu-bridge-migration-report.md new file mode 100644 index 0000000..cf55d72 --- /dev/null +++ b/docs/feishu-bridge-migration-report.md @@ -0,0 +1,156 @@ +# Feishu Bridge Migration Report + +Date: 2026-03-16 + +## 1. Goal + +Integrate a full Feishu bridge workflow into `CodexClaw`, including card-based permission approval interactions, by migrating the `claude-to-im` bridge runtime into this repository while keeping existing Telegram behavior unchanged. + +## 2. Scope Completed + +### 2.1 Vendored bridge runtime + +Added full bridge runtime under: + +- `bridge/claude-to-im/` + +This includes: + +- Feishu adapter and callback flow (`card.action.trigger`) +- permission request / allow / deny interaction pipeline +- daemon lifecycle scripts (`start`, `stop`, `status`, `logs`) +- diagnostics script (`doctor`) +- setup references and guides + +### 2.2 Root-level command integration + +Updated root scripts in `package.json`: + +- `feishu:install` +- `feishu:setup` +- `feishu:start` +- `feishu:stop` +- `feishu:status` +- `feishu:logs` +- `feishu:doctor` + +### 2.3 Setup helper + +Added: + +- `scripts/feishuBridgeSetup.sh` + +Behavior: + +- initializes bridge runtime home +- creates config from example if missing +- sets defaults for this repo: + - `CTI_RUNTIME=codex` + - `CTI_ENABLED_CHANNELS=feishu` + - `CTI_DEFAULT_WORKDIR=/absolute/path/to/CodexClaw` + +### 2.4 Runtime isolation to avoid conflict + +Configured wrapper commands to use isolated defaults: + +- `CTI_HOME=~/.codexclaw-bridge` +- `CTI_LAUNCHD_LABEL=com.codexclaw.feishu.bridge` + +Patched scripts to respect custom label/home: + +- `bridge/claude-to-im/scripts/supervisor-macos.sh` +- `bridge/claude-to-im/scripts/doctor.sh` + +### 2.5 Documentation + +Added: + +- `docs/feishu-bridge.md` +- `docs/feishu-bridge-migration-report.md` (this file) + +Updated: + +- `README.md` (new Feishu bridge section + doc link) +- `.gitignore` (`bridge/claude-to-im/dist/`) + +## 3. Verification Performed + +### 3.1 Bridge checks + +- `npm run feishu:install` passed +- `npm --prefix bridge/claude-to-im run build` passed +- `npm run feishu:status` works with isolated runtime path +- `npm run feishu:doctor` works; current remaining prerequisite is Codex auth + +Doctor current expected blocker: + +- Codex auth not configured (`codex auth login` or `OPENAI_API_KEY`) + +### 3.2 Existing project regression checks + +- `npm run check` passed +- `npm run lint` passed +- `npm test` passed (`107` passed, `0` failed) + +## 4. Files Changed + +- `.gitignore` +- `README.md` +- `package.json` +- `scripts/feishuBridgeSetup.sh` +- `docs/feishu-bridge.md` +- `docs/feishu-bridge-migration-report.md` +- `bridge/claude-to-im/**` (vendored runtime + small script patches) + +Also added planning/log files during migration: + +- `task_plan.md` +- `findings.md` +- `progress.md` + +## 5. Notes And Risks + +1. Vendored bridge code is large. Reviewers should focus on root integration points first, then sampled bridge scripts. +2. Runtime now has two independent operation modes: + - existing Telegram bot (`npm run start`) + - Feishu bridge runtime (`npm run feishu:*`) +3. Feishu requires two-phase publish in Feishu Open Platform for callback/card interaction to become effective. +4. Third-party code attribution/license must be preserved (`bridge/claude-to-im/LICENSE`, MIT). + +## 6. Recommended Branch + PR Flow + +Use a feature branch before merging into `main`: + +```bash +git checkout -b feature/feishu-bridge-migration +git add .gitignore README.md package.json scripts/feishuBridgeSetup.sh docs/feishu-bridge.md docs/feishu-bridge-migration-report.md bridge/claude-to-im +git commit -m "feat: migrate full feishu bridge with card-based permission workflow" +git push -u origin feature/feishu-bridge-migration +``` + +Then open PR: + +- Base: `main` +- Compare: `feature/feishu-bridge-migration` +- Title suggestion: + - `feat: integrate feishu bridge runtime with card-based approval workflow` + +PR checklist: + +1. Confirm `npm run check`, `npm run lint`, and `npm test` are green. +2. Confirm `npm run feishu:doctor` output only shows expected environment-specific items (auth/credentials). +3. Confirm no secrets are committed (`.env`, tokens, IDs). +4. Keep PR description explicit that bridge runtime is vendored under `bridge/claude-to-im`. + +## 7. Post-Merge Operator Steps + +On deployment host: + +1. `npm install` +2. `npm run feishu:install` +3. `npm run feishu:setup` +4. Edit `~/.codexclaw-bridge/config.env` with Feishu credentials +5. `codex auth login` (or set API key) +6. `npm run feishu:start` +7. `npm run feishu:status` +8. `npm run feishu:logs` diff --git a/docs/feishu-bridge.md b/docs/feishu-bridge.md new file mode 100644 index 0000000..2f9f57c --- /dev/null +++ b/docs/feishu-bridge.md @@ -0,0 +1,64 @@ +# Feishu Bridge (claude-to-im Migration) + +This repository now includes a full `claude-to-im` bridge runtime at: + +- `bridge/claude-to-im` + +The bridge keeps the original Feishu card-based permission approval flow, including: + +- tool permission request cards +- `Allow` / `Deny` card interaction callbacks +- stream updates and message routing + +## Quick Start + +1. Initialize local bridge config: + +```bash +npm run feishu:setup +``` + +2. Edit `~/.codexclaw-bridge/config.env`: + +- `CTI_RUNTIME=codex` +- `CTI_ENABLED_CHANNELS=feishu` +- `CTI_DEFAULT_WORKDIR=/absolute/path/to/CodexClaw` +- `CTI_FEISHU_APP_ID=...` +- `CTI_FEISHU_APP_SECRET=...` +- Optional: + - `CTI_FEISHU_DOMAIN=https://open.feishu.cn` + - `CTI_FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy` + +3. Install bridge dependencies: + +```bash +npm run feishu:install +``` + +4. Start and verify: + +```bash +npm run feishu:start +npm run feishu:status +npm run feishu:logs +``` + +5. Diagnose issues: + +```bash +npm run feishu:doctor +``` + +## Feishu Two-Phase Publish Checklist + +Use the original guide at: + +- `bridge/claude-to-im/references/setup-guides.md` + +High-level sequence: + +1. Phase 1: add permissions + enable bot + publish/approve. +2. Start bridge (`npm run feishu:start`). +3. Phase 2: configure long connection events and callback (`card.action.trigger`) + publish/approve again. + +This two-phase publish is required for Feishu callback validation and card button interaction. diff --git a/package.json b/package.json index a74b7be..05b9376 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,13 @@ "scripts": { "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts", + "feishu:install": "npm --prefix bridge/claude-to-im install", + "feishu:setup": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} bash scripts/feishuBridgeSetup.sh", + "feishu:start": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} CTI_LAUNCHD_LABEL=${CTI_LAUNCHD_LABEL:-com.codexclaw.feishu.bridge} bash bridge/claude-to-im/scripts/daemon.sh start", + "feishu:stop": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} CTI_LAUNCHD_LABEL=${CTI_LAUNCHD_LABEL:-com.codexclaw.feishu.bridge} bash bridge/claude-to-im/scripts/daemon.sh stop", + "feishu:status": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} CTI_LAUNCHD_LABEL=${CTI_LAUNCHD_LABEL:-com.codexclaw.feishu.bridge} bash bridge/claude-to-im/scripts/daemon.sh status", + "feishu:logs": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} CTI_LAUNCHD_LABEL=${CTI_LAUNCHD_LABEL:-com.codexclaw.feishu.bridge} bash bridge/claude-to-im/scripts/daemon.sh logs 100", + "feishu:doctor": "CTI_HOME=${CTI_HOME:-$HOME/.codexclaw-bridge} CTI_LAUNCHD_LABEL=${CTI_LAUNCHD_LABEL:-com.codexclaw.feishu.bridge} bash bridge/claude-to-im/scripts/doctor.sh", "check": "npm run typecheck", "typecheck": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.ts", diff --git a/scripts/feishuBridgeSetup.sh b/scripts/feishuBridgeSetup.sh new file mode 100644 index 0000000..782ebdc --- /dev/null +++ b/scripts/feishuBridgeSetup.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +CTI_HOME="${CTI_HOME:-$HOME/.codexclaw-bridge}" +CONFIG_FILE="$CTI_HOME/config.env" +EXAMPLE_FILE="$(cd "$(dirname "$0")/.." && pwd)/bridge/claude-to-im/config.env.example" + +mkdir -p "$CTI_HOME"/{data,logs,runtime,data/messages} + +upsert_kv() { + local key="$1" + local value="$2" + local file="$3" + local tmp="${file}.tmp" + + awk -v k="$key" -v v="$value" ' + BEGIN { updated = 0 } + { + if ($0 ~ ("^" k "=")) { + print k "=" v + updated = 1 + } else { + print $0 + } + } + END { + if (!updated) { + print k "=" v + } + } + ' "$file" > "$tmp" + + mv "$tmp" "$file" +} + +if [ ! -f "$CONFIG_FILE" ]; then + cp "$EXAMPLE_FILE" "$CONFIG_FILE" + upsert_kv "CTI_RUNTIME" "codex" "$CONFIG_FILE" + upsert_kv "CTI_ENABLED_CHANNELS" "feishu" "$CONFIG_FILE" + upsert_kv "CTI_DEFAULT_WORKDIR" "$(pwd)" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" + echo "Created $CONFIG_FILE from example." +else + echo "Config already exists: $CONFIG_FILE" +fi + +echo "" +echo "Next steps:" +echo "1) Edit config: ${EDITOR:-vim} $CONFIG_FILE" +echo " Required for Feishu:" +echo " - CTI_RUNTIME=codex" +echo " - CTI_ENABLED_CHANNELS=feishu" +echo " - CTI_DEFAULT_WORKDIR=$(pwd)" +echo " - CTI_FEISHU_APP_ID=..." +echo " - CTI_FEISHU_APP_SECRET=..." +echo "2) Install bridge deps: npm run feishu:install" +echo "3) Start bridge: npm run feishu:start" +echo "4) Check status: npm run feishu:status"