@spritesensei/wechat-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # wechat-cli
2
+
3
+ 基于 `@wechatbot/wechatbot` SDK 的微信命令行工具,类似 lark-cli 风格。
4
+
5
+ ```bash
6
+ npm install -g @spritesensei/wechat-cli
7
+ wechat-cli --help
8
+ ```
9
+
10
+ ---
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ # 全局安装(推荐)
16
+ npm install -g @spritesensei/wechat-cli
17
+
18
+ # 或从源码
19
+ git clone https://github.com/snowball-dev/wechat-cli.git
20
+ cd wechat-cli
21
+ npm install
22
+ npm link
23
+ ```
24
+
25
+ ## AI Agent Skill
26
+
27
+ wechat-cli 提供 AI Agent Skill,支持 Claude Code、Cursor、Windsurf、Gemini CLI 等 70+ AI 工具安装后通过 `/wechat-cli` 调出用法。
28
+
29
+ skill 源文件在项目的 `skills/wechat-cli/SKILL.md`,可通过 npm 包分发安装。
30
+
31
+ ### 安装 Agent Skill
32
+
33
+ ```bash
34
+ # 全局安装(推荐,本机所有对话可用)
35
+ npx skills add @spritesensei/wechat-cli -g
36
+
37
+ # 项目级安装(仅该项目目录下可用)
38
+ npx skills add @spritesensei/wechat-cli
39
+ ```
40
+
41
+ `skills` CLI 的两种模式:
42
+
43
+ | 模式 | 安装位置 | 作用范围 |
44
+ |------|---------|---------|
45
+ | 默认(不加 `-g`) | `项目目录/.agents/skills/` | 仅该项目 |
46
+ | `-g`(全局) | `~/.agents/skills/` | 本机所有会话 |
47
+
48
+ 安装后,在对话中输入 `/wechat-cli` 即可获取使用指南。
49
+
50
+ ## 使用
51
+
52
+ ### 登录(只需一次)
53
+
54
+ ```bash
55
+ wechat-cli login
56
+ # 生成二维码 PNG → 自动打开图片 → 手机微信扫码
57
+ # 凭证自动保存到 ~/.wechat-cli/
58
+ ```
59
+
60
+ 加 `--force` 强制重新登录(已有凭证时)。
61
+
62
+ ### 发消息
63
+
64
+ ```bash
65
+ # 文本
66
+ wechat-cli send <用户ID> text "你好"
67
+
68
+ # 图片
69
+ wechat-cli send <用户ID> image ./照片.png
70
+
71
+ # 视频
72
+ wechat-cli send <用户ID> video ./视频.mp4
73
+
74
+ # 文件(音频也当文件发)
75
+ wechat-cli send <用户ID> file ./文档.pdf
76
+ ```
77
+
78
+ 可选的 `--caption "说明文字"` 参数。
79
+
80
+ **注意:** 必须先收到过对方消息才能主动发(SDK 需要 context_token)。如果遇到 `NO_CONTEXT` 错误,先跑:
81
+
82
+ ```bash
83
+ wechat-cli messages --count 1
84
+ # 让对方发一条消息过来 → context 缓存 → 之后就能发了
85
+ ```
86
+
87
+ ### 拉取消息
88
+
89
+ ```bash
90
+ wechat-cli messages --count 5
91
+ # 启动长轮询,收集到 5 条消息后自动停止
92
+ # --timeout 60 超时(秒),超时未收满也返回已收集的
93
+ ```
94
+
95
+ 默认输出 JSON,`--pretty` 美化。
96
+
97
+ ### 其他
98
+
99
+ ```bash
100
+ wechat-cli status # 查看登录状态
101
+ wechat-cli logout # 清除凭证
102
+ wechat-cli --help # 查看所有命令
103
+ wechat-cli <命令> --help # 查看子命令帮助
104
+ ```
105
+
106
+ ## 目录结构
107
+
108
+ ```
109
+ wechat-cli/
110
+ ├── bin/wechat-cli.js # 入口
111
+ ├── src/main.js # Commander 配置
112
+ ├── src/core/bot.js # createBot 工厂
113
+ ├── src/commands/
114
+ │ ├── login.js # 登录
115
+ │ ├── send.js # 发送(text/image/video/file)
116
+ │ ├── messages.js # 拉取消息
117
+ │ ├── status.js # 状态查询
118
+ │ └── logout.js # 登出
119
+ ├── src/utils/
120
+ │ ├── output.js # JSON 格式化输出
121
+ │ └── qr.js # 二维码生成
122
+ ├── skills/
123
+ │ └── wechat-cli/
124
+ │ └── SKILL.md # AI Agent Skill
125
+ ├── package.json
126
+ └── README.md
127
+ ```
128
+
129
+ ## 常见问题
130
+
131
+ **Q: 发消息报 `NO_CONTEXT`?**
132
+ A: 先 `wechat-cli messages --count 1`,让对方发条消息过来,刷新 context token。
133
+
134
+ **Q: 凭证存在哪里?**
135
+ A: `~/.wechat-cli/credentials.json`。SDK 默认是 `~/.wechatbot/`,可以复制过去复用。
136
+
137
+ **Q: 语音消息?**
138
+ A: iLink Bot API 不支持机器人发语音类型消息。音频当文件发:`send <uid> file ./audio.wav`。
139
+
140
+ **Q: 报 `ret=-2` 错误?**
141
+ A: 通常是会话过期或频率限制。重新 `login` 即可。
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import '../src/main.js';
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@spritesensei/wechat-cli",
3
+ "version": "1.0.0",
4
+ "description": "WeChat CLI — send/receive WeChat messages via @wechatbot/wechatbot SDK",
5
+ "type": "module",
6
+ "bin": {
7
+ "wechat-cli": "bin/wechat-cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "skills",
13
+ "README.md"
14
+ ],
15
+ "preferGlobal": true,
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "wechat",
21
+ "weixin",
22
+ "cli",
23
+ "chatbot",
24
+ "ilink"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/spritesensei/wechat-cli.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/snowball-dev/wechat-cli/issues"
32
+ },
33
+ "homepage": "https://github.com/snowball-dev/wechat-cli#readme",
34
+ "license": "MIT",
35
+ "agents": {
36
+ "skills": {
37
+ "wechat-cli": {
38
+ "description": "WeChat CLI tool — send/receive messages via @wechatbot/wechatbot SDK",
39
+ "path": "./skills/wechat-cli/SKILL.md"
40
+ }
41
+ }
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "dependencies": {
47
+ "@wechatbot/wechatbot": "^2.1.1",
48
+ "commander": "^13.0.0",
49
+ "open": "^10.0.0",
50
+ "qrcode": "^1.5.0"
51
+ }
52
+ }
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: wechat-cli
3
+ description: Use when the user asks to send/receive WeChat messages, or when the project is wechat-cli — a CLI tool built on @wechatbot/wechatbot SDK for WeChat iLink Bot messaging
4
+ ---
5
+
6
+ # wechat-cli
7
+
8
+ 基于 `@wechatbot/wechatbot` SDK 的微信命令行工具,类似 lark-cli 风格。
9
+
10
+ ## 安装
11
+
12
+ ```bash
13
+ # 全局安装(推荐,安装后直接 wechat-cli 命令)
14
+ npm install -g @spritesensei/wechat-cli
15
+
16
+ # 或从源码
17
+ git clone https://github.com/snowball-dev/wechat-cli.git
18
+ cd wechat-cli
19
+ npm install
20
+ npm link
21
+ ```
22
+
23
+ ## 登录
24
+
25
+ ```bash
26
+ wechat-cli login
27
+ # 自动生成二维码图片并打开,手机微信扫码即可
28
+ # 凭证自动保存到 ~/.wechat-cli/
29
+ ```
30
+
31
+ 加 `--force` 可强制重新登录。
32
+
33
+ ## 发消息
34
+
35
+ 必须先收到过对方消息(context token),否则会报 `NO_CONTEXT`。
36
+
37
+ ```bash
38
+ # 先收一条消息刷新 context
39
+ wechat-cli messages --count 1
40
+
41
+ # 文本
42
+ wechat-cli send <userId> text "内容"
43
+
44
+ # 图片
45
+ wechat-cli send <userId> image ./图片.png
46
+
47
+ # 视频
48
+ wechat-cli send <userId> video ./视频.mp4
49
+
50
+ # 文件(音频也当文件发)
51
+ wechat-cli send <userId> file ./文件.pdf --caption "说明"
52
+ ```
53
+
54
+ ## 拉取消息
55
+
56
+ ```bash
57
+ wechat-cli messages --count 5 --timeout 30
58
+ # 长轮询收集,到 count 或超时后自动停止,输出 JSON
59
+ ```
60
+
61
+ ## 其他命令
62
+
63
+ ```bash
64
+ wechat-cli status # 查看登录状态
65
+ wechat-cli logout # 清除凭证
66
+ wechat-cli --help # 所有命令
67
+ wechat-cli <cmd> --help # 子命令详情
68
+ ```
69
+
70
+ ## 注意事项
71
+
72
+ - **context_token:** 需要先收消息才能主动发。`messages --count 1` 先让对方发一条过来
73
+ - **会话过期:** `ret=-2` 错误通常表示会话过期或频率限制,重新 login
74
+ - **语音:** iLink Bot API 不支持机器人发语音类型消息,音频请当文件发
75
+
76
+ ## 全局选项
77
+
78
+ 所有命令支持 `--pretty` 美化 JSON 输出。
@@ -0,0 +1,56 @@
1
+ import { createBot } from '../core/bot.js';
2
+ import { renderQrCode } from '../utils/qr.js';
3
+ import { outputJson, outputError } from '../utils/output.js';
4
+
5
+ let qrShown = false;
6
+
7
+ export function register(program) {
8
+ program
9
+ .command('login')
10
+ .description('Scan QR code to log in to WeChat')
11
+ .option('--force', 'Force re-login even if credentials exist')
12
+ .action(async (options, cmd) => {
13
+ try {
14
+ const loginCallbacks = {
15
+ onQrUrl: async (url) => {
16
+ if (qrShown) return;
17
+ qrShown = true;
18
+ try {
19
+ process.stderr.write('Opening QR code in image viewer...\n');
20
+ await renderQrCode(url);
21
+ process.stderr.write('Please scan the QR code with WeChat.\n');
22
+ } catch (err) {
23
+ process.stderr.write(`Failed to render QR code: ${err.message}\n`);
24
+ }
25
+ },
26
+ onScanned: () => {
27
+ process.stderr.write('QR code scanned! Confirm on your phone...\n');
28
+ },
29
+ onExpired: () => {
30
+ process.stderr.write('QR code expired, refreshing...\n');
31
+ qrShown = false;
32
+ },
33
+ };
34
+
35
+ const bot = await createBot({
36
+ loginCallbacks,
37
+ force: !!options.force,
38
+ });
39
+ const creds = bot.getCredentials();
40
+ await bot.stop();
41
+
42
+ if (!creds) {
43
+ outputError('Login failed — no credentials returned', 'LOGIN_FAILED');
44
+ return;
45
+ }
46
+
47
+ outputJson({
48
+ success: true,
49
+ userId: creds.userId,
50
+ accountId: creds.accountId,
51
+ }, { pretty: cmd.optsWithGlobals().pretty });
52
+ } catch (err) {
53
+ outputError(err.message, 'LOGIN_FAILED');
54
+ }
55
+ });
56
+ }
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { outputJson } from '../utils/output.js';
4
+ import { DEFAULT_STORAGE_DIR } from '../core/bot.js';
5
+
6
+ export function register(program) {
7
+ program
8
+ .command('logout')
9
+ .description('Clear stored credentials and log out')
10
+ .action(async (options, cmd) => {
11
+ let removed = 0;
12
+
13
+ if (fs.existsSync(DEFAULT_STORAGE_DIR)) {
14
+ for (const file of fs.readdirSync(DEFAULT_STORAGE_DIR)) {
15
+ fs.rmSync(path.join(DEFAULT_STORAGE_DIR, file), { force: true, recursive: true });
16
+ removed++;
17
+ }
18
+ }
19
+
20
+ outputJson({
21
+ success: true,
22
+ removedFiles: removed,
23
+ storageDir: DEFAULT_STORAGE_DIR,
24
+ }, { pretty: cmd.optsWithGlobals().pretty });
25
+ });
26
+ }
@@ -0,0 +1,69 @@
1
+ import { createBot } from '../core/bot.js';
2
+ import { outputJson, outputError } from '../utils/output.js';
3
+
4
+ export function register(program) {
5
+ program
6
+ .command('messages')
7
+ .description('Pull recent messages (listens for N new messages, then stops)')
8
+ .option('-c, --count <number>', 'Number of messages to collect', '5')
9
+ .option('-t, --timeout <seconds>', 'Max wait time in seconds', '60')
10
+ .action(async (options, cmd) => {
11
+ const count = parseInt(options.count, 10);
12
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
13
+
14
+ if (isNaN(count) || count < 1) {
15
+ outputError('--count must be a positive integer', 'INVALID_COUNT');
16
+ return;
17
+ }
18
+ if (isNaN(timeoutMs) || timeoutMs < 0) {
19
+ outputError('--timeout must be a non-negative integer', 'INVALID_TIMEOUT');
20
+ return;
21
+ }
22
+
23
+ try {
24
+ const bot = await createBot({});
25
+ const messages = [];
26
+ let finished = false;
27
+
28
+ bot.onMessage((msg) => {
29
+ messages.push({
30
+ userId: msg.userId,
31
+ text: msg.text,
32
+ type: msg.type,
33
+ timestamp: msg.timestamp,
34
+ images: msg.images?.map(i => ({ url: i.url, width: i.width, height: i.height })) ?? [],
35
+ files: msg.files?.map(f => ({ fileName: f.fileName, size: f.size, md5: f.md5 })) ?? [],
36
+ videos: msg.videos?.map(v => ({ durationMs: v.durationMs, width: v.width, height: v.height })) ?? [],
37
+ voices: msg.voices?.map(v => ({ durationMs: v.durationMs, text: v.text })) ?? [],
38
+ });
39
+
40
+ if (!finished && messages.length >= count) {
41
+ stopAndOutput();
42
+ }
43
+ });
44
+
45
+ const timer = setTimeout(() => {
46
+ stopAndOutput();
47
+ }, timeoutMs);
48
+
49
+ function stopAndOutput() {
50
+ if (finished) return;
51
+ finished = true;
52
+ clearTimeout(timer);
53
+ try { bot.stop(); } catch {}
54
+ outputJson({
55
+ messages,
56
+ total: messages.length,
57
+ requested: count,
58
+ timedOut: messages.length < count,
59
+ }, { pretty: cmd.optsWithGlobals().pretty });
60
+ }
61
+
62
+ await bot.start();
63
+ // bot.start() resolves when bot.stop() is called
64
+
65
+ } catch (err) {
66
+ outputError(err.message, 'MESSAGES_FAILED');
67
+ }
68
+ });
69
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createBot } from '../core/bot.js';
4
+ import { outputJson, outputError } from '../utils/output.js';
5
+
6
+ export function register(program) {
7
+ program
8
+ .command('send')
9
+ .description('Send a message to a user')
10
+ .argument('<userId>', 'Target user ID (e.g. xxx@im.wechat)')
11
+ .argument('<type>', 'Message type: text | image | video | file')
12
+ .argument('<content>', 'Text content (for text type) or file path (for image/video/file)')
13
+ .option('-c, --caption <text>', 'Caption for image/video/file')
14
+ .action(async (userId, type, content, options, cmd) => {
15
+ try {
16
+ const validTypes = ['text', 'image', 'video', 'file'];
17
+ if (!validTypes.includes(type)) {
18
+ outputError(`Invalid type "${type}". Must be one of: ${validTypes.join(', ')}`, 'INVALID_TYPE');
19
+ return;
20
+ }
21
+
22
+ const bot = await createBot({});
23
+ await bot.contextStore.load();
24
+
25
+ if (type === 'text') {
26
+ await bot.send(userId, { text: content });
27
+ await bot.stop();
28
+ outputJson({ success: true, userId, type }, { pretty: cmd.optsWithGlobals().pretty });
29
+ return;
30
+ }
31
+
32
+ // For media types: read file
33
+ const filePath = path.resolve(content);
34
+ if (!fs.existsSync(filePath)) {
35
+ outputError(`File not found: ${filePath}`, 'FILE_NOT_FOUND');
36
+ return;
37
+ }
38
+ const data = fs.readFileSync(filePath);
39
+ const fileName = path.basename(filePath);
40
+
41
+ if (type === 'image') {
42
+ await bot.send(userId, { image: data, caption: options.caption });
43
+ } else if (type === 'video') {
44
+ await bot.send(userId, { video: data, caption: options.caption });
45
+ } else {
46
+ await bot.send(userId, { file: data, fileName, caption: options.caption });
47
+ }
48
+
49
+ await bot.stop();
50
+
51
+ outputJson({
52
+ success: true,
53
+ userId,
54
+ type,
55
+ }, { pretty: cmd.optsWithGlobals().pretty });
56
+
57
+ } catch (err) {
58
+ if (err.name === 'NoContextError' || err.code === 'NO_CONTEXT') {
59
+ outputError(
60
+ `No context token for user "${userId}". First receive a message from them via "wechat-cli messages"`,
61
+ 'NO_CONTEXT', 2);
62
+ } else {
63
+ outputError(err.message, 'SEND_FAILED');
64
+ }
65
+ }
66
+ });
67
+ }
@@ -0,0 +1,39 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { outputJson } from '../utils/output.js';
4
+ import { DEFAULT_STORAGE_DIR } from '../core/bot.js';
5
+
6
+ export function register(program) {
7
+ program
8
+ .command('status')
9
+ .description('Show login status')
10
+ .action(async (options, cmd) => {
11
+ const credentialsPath = path.join(DEFAULT_STORAGE_DIR, 'credentials.json');
12
+ const credsExist = fs.existsSync(credentialsPath);
13
+
14
+ let credentials = null;
15
+ let contextCount = 0;
16
+
17
+ if (credsExist) {
18
+ try {
19
+ const raw = fs.readFileSync(credentialsPath, 'utf-8');
20
+ credentials = JSON.parse(raw);
21
+ // Count cached context tokens
22
+ const tokensPath = path.join(DEFAULT_STORAGE_DIR, 'context_tokens.json');
23
+ if (fs.existsSync(tokensPath)) {
24
+ const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8'));
25
+ contextCount = Object.keys(tokens).length;
26
+ }
27
+ } catch {}
28
+ }
29
+
30
+ outputJson({
31
+ loggedIn: !!credentials,
32
+ userId: credentials?.userId ?? null,
33
+ accountId: credentials?.accountId ?? null,
34
+ savedAt: credentials?.savedAt ?? null,
35
+ storageDir: DEFAULT_STORAGE_DIR,
36
+ cachedUsers: contextCount,
37
+ }, { pretty: cmd.optsWithGlobals().pretty });
38
+ });
39
+ }
@@ -0,0 +1,27 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { WeChatBot } from '@wechatbot/wechatbot';
4
+
5
+ export const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.wechat-cli');
6
+
7
+ /**
8
+ * Create a WeChatBot instance, auto-load stored credentials.
9
+ * Returns the bot ready to use (logged in, not yet started).
10
+ * Does NOT call bot.start() — caller decides whether to poll.
11
+ *
12
+ * @param {object} [options]
13
+ * @param {string} [options.storageDir] - Storage directory (default: ~/.wechat-cli)
14
+ * @param {object} [options.loginCallbacks] - QR login callbacks
15
+ * @param {boolean} [options.force] - Force re-login even if credentials exist
16
+ */
17
+ export async function createBot({ storageDir = DEFAULT_STORAGE_DIR, loginCallbacks = {}, force } = {}) {
18
+ const bot = new WeChatBot({
19
+ storage: 'file',
20
+ storageDir,
21
+ logLevel: 'warn',
22
+ loginCallbacks,
23
+ });
24
+
25
+ await bot.login({ force, callbacks: loginCallbacks });
26
+ return bot;
27
+ }
package/src/main.js ADDED
@@ -0,0 +1,30 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ import { register as registerLogin } from './commands/login.js';
7
+ import { register as registerSend } from './commands/send.js';
8
+ import { register as registerMessages } from './commands/messages.js';
9
+ import { register as registerStatus } from './commands/status.js';
10
+ import { register as registerLogout } from './commands/logout.js';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('wechat-cli')
19
+ .description('WeChat CLI — send/receive messages via @wechatbot/wechatbot')
20
+ .version(pkg.version)
21
+ .option('--pretty', 'Pretty-print JSON output');
22
+
23
+ // Register all commands
24
+ registerLogin(program);
25
+ registerSend(program);
26
+ registerMessages(program);
27
+ registerStatus(program);
28
+ registerLogout(program);
29
+
30
+ program.parse(process.argv);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Write JSON result to stdout.
3
+ * @param {*} data - data to serialize
4
+ * @param {{ pretty?: boolean }} options
5
+ */
6
+ export function outputJson(data, options = {}) {
7
+ const space = options.pretty ? 2 : undefined;
8
+ const text = JSON.stringify(data, null, space) + '\n';
9
+ process.stdout.write(text);
10
+ }
11
+
12
+ /**
13
+ * Write error JSON to stderr and exit.
14
+ * @param {string} message
15
+ * @param {string} [code]
16
+ * @param {number} [exitCode]
17
+ */
18
+ export function outputError(message, code = 'UNKNOWN_ERROR', exitCode = 1) {
19
+ const error = { error: true, code, message };
20
+ const text = JSON.stringify(error) + '\n';
21
+ process.stderr.write(text);
22
+ process.exit(exitCode);
23
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import QRCode from 'qrcode';
5
+ import open from 'open';
6
+
7
+ /**
8
+ * Generate a QR code PNG from a URL, save to temp dir, and open it.
9
+ * Falls back to printing the URL if the image viewer cannot be launched.
10
+ * @param {string} url - the QR URL to encode
11
+ * @returns {Promise<{ filePath: string }>}
12
+ */
13
+ export async function renderQrCode(url) {
14
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wechat-cli-qr-'));
15
+ const filePath = path.join(tmpDir, 'wechat-login.png');
16
+
17
+ await QRCode.toFile(filePath, url, {
18
+ type: 'png',
19
+ width: 400,
20
+ margin: 2,
21
+ color: { dark: '#000000', light: '#ffffff' },
22
+ });
23
+
24
+ try {
25
+ await open(filePath);
26
+ } catch {
27
+ // Fallback: print the QR URL if open fails (e.g. headless/CI)
28
+ process.stderr.write(`QR code image saved to: ${filePath}\n`);
29
+ process.stderr.write(`QR code URL: ${url}\n`);
30
+ process.stderr.write('Please open this URL in a browser to scan with WeChat.\n');
31
+ }
32
+
33
+ return { filePath };
34
+ }