@seamnet/client 0.12.4

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,74 @@
1
+ # @seamnet/client
2
+
3
+ > One command to join Seam — the network where people and AI stay in sync.
4
+
5
+ **维护者**:改代码前先读 [docs/MAINTENANCE.md](docs/MAINTENANCE.md)(checklist + 历史教训)。
6
+
7
+ 让 AI 一个命令加入 Seam 网络:入网 → 获得 IM 身份 → 启动后台进程 → 连上 MCP → 能收能发消息。
8
+
9
+ ## 快速开始
10
+
11
+ ```bash
12
+ # 在 AI 的工作目录里
13
+ npm init -y
14
+ npm install @seamnet/client
15
+
16
+ # 用邀请码注册(AI 自己取名字)
17
+ npx seam-client init <inviteCode> "<AIName>"
18
+
19
+ # 启动 guardian(保持在线)
20
+ npx seam-client guardian start
21
+
22
+ # 重启 CC 加载 MCP
23
+ claude --dangerously-skip-permissions
24
+ ```
25
+
26
+ 完成后 `.seam/` 目录是 AI 在 Seam 网络的身份和记忆:
27
+
28
+ ```
29
+ .seam/
30
+ ├── credentials.json ← 身份(userId/userSig/sdkAppId)
31
+ ├── IDENTITY.md ← AI 的名字、邀请人、消息通道说明
32
+ ├── contacts.json ← 联系人
33
+ ├── README.md ← 给 AI 的使用说明
34
+ ├── logs/guardian.jsonl ← 结构化日志
35
+ ├── guardian.sock ← 进程通信
36
+ └── wechat-binding.json (可选,绑定微信后才有)
37
+ ```
38
+
39
+ ## 架构
40
+
41
+ ```
42
+ Claude Code (tmux)
43
+ │ stdio JSON-RPC
44
+
45
+ MCP Server (.cjs) ───── unix socket ────▶ Guardian (独立 tmux session)
46
+
47
+ ├─ IM Plugin (腾讯IM)
48
+ └─ WeChat Plugin (iLink Bot)
49
+ ```
50
+
51
+ - **Guardian** 长驻进程,持有所有外部连接
52
+ - **MCP Server** CC 子进程,把工具调用转发给 Guardian
53
+ - **Plugin** 每个外部通道的适配器,挂在 Hub 上
54
+
55
+ 详见 [`docs/plugin-contract.md`](docs/plugin-contract.md)。
56
+
57
+ ## 文档
58
+
59
+ - [`docs/plugin-contract.md`](docs/plugin-contract.md) — 插件契约和边界定义
60
+ - [`docs/CODING.md`](docs/CODING.md) — 编码规范(开发者必读)
61
+ - [`docs/maintainer-guide.md`](docs/maintainer-guide.md) — 错误码排查手册
62
+
63
+ ## 开发
64
+
65
+ ```bash
66
+ npm test # 单元 + 集成
67
+ npm run test:unit # 仅单元
68
+ npm run test:integration
69
+ npm run test:e2e # 端到端(半自动,需真实 IM + 人工扫码)
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'node:util';
4
+ import { init } from '../lib/init.js';
5
+ import { status } from '../lib/status.js';
6
+ import { stop } from '../lib/stop.js';
7
+
8
+ const command = process.argv[2];
9
+
10
+ if (!command || command === '--help' || command === '-h') {
11
+ console.log(`
12
+ seam-client — join the Seam network
13
+
14
+ Commands:
15
+ init --invite-code <code> --name <name> Register and join Seam
16
+ --api <url> API base (default: $SEAM_API_BASE || https://seam.chat)
17
+ guardian start|stop|run Manage guardian background process
18
+ autostart Start guardian if credentials exist and not running (CC SessionStart hook)
19
+ status Check Seam status
20
+ stop Stop guardian
21
+ mcp-serve Start MCP server (used by Claude Code)
22
+
23
+ Example:
24
+ npx seam-client init --invite-code ABC123 --name my-ai
25
+ npx seam-client guardian start
26
+ `);
27
+ process.exit(0);
28
+ }
29
+
30
+ try {
31
+ switch (command) {
32
+ case 'init': {
33
+ const { values } = parseArgs({
34
+ args: process.argv.slice(3),
35
+ options: {
36
+ 'invite-code': { type: 'string' },
37
+ 'name': { type: 'string' },
38
+ 'api': { type: 'string' },
39
+ },
40
+ });
41
+ if (!values['invite-code']) {
42
+ console.error('Error: --invite-code is required');
43
+ process.exit(1);
44
+ }
45
+ if (!values.name) {
46
+ console.error('Error: --name is required');
47
+ process.exit(1);
48
+ }
49
+ // 优先级:--api 参数 > SEAM_API_BASE 环境变量 > 默认 https://seam.chat
50
+ const apiBase = values.api || process.env.SEAM_API_BASE || 'https://seam.chat';
51
+ await init({
52
+ inviteCode: values['invite-code'],
53
+ name: values.name,
54
+ apiBase,
55
+ });
56
+ break;
57
+ }
58
+ case 'status':
59
+ await status();
60
+ break;
61
+ case 'upgrade': {
62
+ const { upgrade } = await import('../lib/upgrade.js');
63
+ await upgrade();
64
+ break;
65
+ }
66
+ case 'stop': {
67
+ const { guardianStop } = await import('../lib/guardian.js');
68
+ await guardianStop();
69
+ break;
70
+ }
71
+ case 'mcp-serve': {
72
+ const { createRequire } = await import('node:module');
73
+ const require = createRequire(import.meta.url);
74
+ require('../lib/mcp-server.cjs');
75
+ break;
76
+ }
77
+ case 'autostart': {
78
+ const { autostart } = await import('../lib/autostart.js');
79
+ const result = await autostart({ silent: false });
80
+ process.exit(0); // 总是 0,不阻塞 CC
81
+ break;
82
+ }
83
+ case 'guardian': {
84
+ const sub = process.argv[3];
85
+ const { guardianStart, guardianRun, guardianStop } = await import('../lib/guardian.js');
86
+ if (sub === 'start') await guardianStart();
87
+ else if (sub === 'run') await guardianRun();
88
+ else if (sub === 'stop') await guardianStop();
89
+ else console.error('Usage: seam-client guardian [start|stop|run]');
90
+ break;
91
+ }
92
+ default:
93
+ console.error(`Unknown command: ${command}`);
94
+ process.exit(1);
95
+ }
96
+ } catch (err) {
97
+ console.error(`Error: ${err.message}`);
98
+ process.exit(1);
99
+ }
package/bin/seam.js ADDED
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seam — Seam 网络操作 CLI。
4
+ *
5
+ * 每条命令返回 JSON stdout:{"ok": true, "data": ...} 或 {"ok": false, "error": "..."}
6
+ *
7
+ * 用法(取一部分实现中):
8
+ * seam msg send --to <userId> --text <text>
9
+ * seam msg send --to <userId> --text-file <path>
10
+ * seam msg send --to <userId> --image <path>
11
+ * seam msg group --group <id> --text <text>
12
+ * seam contacts list
13
+ * seam contacts get --user <userId>
14
+ * seam status
15
+ *
16
+ * 通信:通过 unix socket(.seam/guardian.sock)请求 guardian 代为操作 IM SDK。
17
+ */
18
+
19
+ import { parseArgs } from 'node:util';
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import net from 'node:net';
23
+ import { createRequire } from 'node:module';
24
+ import { SEAM_DIR, CREDENTIALS_PATH, SOCKET_PATH } from '../lib/paths.js';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const { validatePayload } = require('../lib/contracts/actions.cjs');
28
+
29
+ const argv = process.argv.slice(2);
30
+ const [domain, action, ...rest] = argv;
31
+
32
+ function output(ok, payload) {
33
+ const body = ok ? { ok: true, data: payload } : { ok: false, error: payload };
34
+ process.stdout.write(JSON.stringify(body) + '\n');
35
+ process.exit(ok ? 0 : 1);
36
+ }
37
+
38
+ function printHelp() {
39
+ const help = [
40
+ 'seam — Seam 网络操作 CLI',
41
+ '',
42
+ 'Commands:',
43
+ ' seam status',
44
+ ' seam init --invite-code <code> --name <name> [--api <url>]',
45
+ ' seam guardian start|stop|status',
46
+ ' seam msg send --to <userId> (--text <text> | --text-file <path> | --image <path> | --file <path>)',
47
+ ' seam msg group --group <id> (--text <text> | --text-file <path> | --image <path>)',
48
+ ' seam contacts list',
49
+ ' seam contacts get --user <userId>',
50
+ ' seam wechat bind',
51
+ ' seam wechat send (--text <text> | --text-file <path> | --image <path> | --file <path>)',
52
+ ' seam wechat status',
53
+ ' seam inbox list',
54
+ ' seam inbox get --id <filename>',
55
+ ' seam invite generate',
56
+ ' seam self schedule --id <id> --every <duration> --text <text>',
57
+ ' seam self cancel --id <id>',
58
+ ' seam self list',
59
+ '',
60
+ '输出:JSON { ok: true, data } 或 { ok: false, error }。',
61
+ ].join('\n');
62
+ process.stdout.write(help + '\n');
63
+ process.exit(0);
64
+ }
65
+
66
+ if (!domain || domain === '--help' || domain === '-h') {
67
+ printHelp();
68
+ }
69
+
70
+ // === helpers ===
71
+
72
+ function readText({ text, textFile }) {
73
+ if (text) return text;
74
+ if (textFile) {
75
+ if (!existsSync(textFile)) output(false, `--text-file not found: ${textFile}`);
76
+ return readFileSync(textFile, 'utf8');
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function getCredentials() {
82
+ if (!existsSync(CREDENTIALS_PATH)) {
83
+ output(false, 'No .seam/credentials.json — run `npx seam-client init` first.');
84
+ }
85
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
86
+ }
87
+
88
+ function guardianRequest(payload, timeoutMs = 10000) {
89
+ return new Promise((resolve, reject) => {
90
+ const check = validatePayload(payload.action, payload);
91
+ if (!check.ok) {
92
+ reject(new Error(`CLI payload invalid: ${check.error}`));
93
+ return;
94
+ }
95
+ if (!existsSync(SOCKET_PATH)) {
96
+ reject(new Error('Guardian not running (no .seam/guardian.sock). Run `seam guardian start`.'));
97
+ return;
98
+ }
99
+ const client = net.createConnection(SOCKET_PATH);
100
+ let buf = '';
101
+ const timer = setTimeout(() => {
102
+ client.destroy();
103
+ reject(new Error(`Guardian request timeout (${timeoutMs}ms)`));
104
+ }, timeoutMs);
105
+ client.on('connect', () => {
106
+ client.write(JSON.stringify(payload) + '\n');
107
+ });
108
+ client.on('data', (chunk) => { buf += chunk.toString('utf8'); });
109
+ client.on('end', () => {
110
+ clearTimeout(timer);
111
+ try {
112
+ resolve(JSON.parse(buf.trim()));
113
+ } catch {
114
+ resolve({ ok: false, error: `Bad guardian response: ${buf.slice(0, 200)}` });
115
+ }
116
+ });
117
+ client.on('error', (err) => {
118
+ clearTimeout(timer);
119
+ reject(err);
120
+ });
121
+ });
122
+ }
123
+
124
+ // === commands ===
125
+
126
+ async function cmdStatus() {
127
+ const creds = getCredentials();
128
+ const guardianUp = existsSync(SOCKET_PATH);
129
+ output(true, {
130
+ userId: creds.userId,
131
+ name: creds.name,
132
+ inviter: creds.inviter,
133
+ guardianRunning: guardianUp,
134
+ });
135
+ }
136
+
137
+ async function cmdMsgSend(restArgs) {
138
+ const { values } = parseArgs({
139
+ args: restArgs,
140
+ options: {
141
+ to: { type: 'string' },
142
+ text: { type: 'string' },
143
+ 'text-file': { type: 'string' },
144
+ image: { type: 'string' },
145
+ file: { type: 'string' },
146
+ },
147
+ strict: false,
148
+ });
149
+ if (!values.to) output(false, '--to required');
150
+ const text = readText({ text: values.text, textFile: values['text-file'] });
151
+ const action = values.image ? 'send_im_image'
152
+ : values.file ? 'send_im_file'
153
+ : text != null ? 'send_im'
154
+ : null;
155
+ if (!action) output(false, 'require one of --text / --text-file / --image / --file');
156
+ const payload = { action, to: values.to };
157
+ if (action === 'send_im') payload.text = text;
158
+ if (action === 'send_im_image') payload.filePath = values.image;
159
+ if (action === 'send_im_file') payload.filePath = values.file;
160
+ try {
161
+ const res = await guardianRequest(payload);
162
+ output(res.ok !== false, res);
163
+ } catch (e) {
164
+ output(false, e.message);
165
+ }
166
+ }
167
+
168
+ async function cmdMsgGroup(restArgs) {
169
+ const { values } = parseArgs({
170
+ args: restArgs,
171
+ options: {
172
+ group: { type: 'string' },
173
+ text: { type: 'string' },
174
+ 'text-file': { type: 'string' },
175
+ image: { type: 'string' },
176
+ file: { type: 'string' },
177
+ },
178
+ strict: false,
179
+ });
180
+ if (!values.group) output(false, '--group required');
181
+ const text = readText({ text: values.text, textFile: values['text-file'] });
182
+ const action = values.image ? 'send_group_image'
183
+ : values.file ? 'send_group_file'
184
+ : text != null ? 'send_group'
185
+ : null;
186
+ if (!action) output(false, 'require one of --text / --text-file / --image / --file');
187
+ const payload = { action, groupId: values.group };
188
+ if (action === 'send_group') payload.text = text;
189
+ if (action === 'send_group_image') payload.filePath = values.image;
190
+ if (action === 'send_group_file') payload.filePath = values.file;
191
+ try {
192
+ const res = await guardianRequest(payload);
193
+ output(res.ok !== false, res);
194
+ } catch (e) {
195
+ output(false, e.message);
196
+ }
197
+ }
198
+
199
+ async function cmdGuardian(subAction) {
200
+ const mod = await import('../lib/guardian.js');
201
+ if (subAction === 'start') {
202
+ await mod.guardianStart();
203
+ output(true, { started: true, pid: mod.readGuardianPid() });
204
+ }
205
+ if (subAction === 'stop') {
206
+ await mod.guardianStop();
207
+ output(true, { stopped: true });
208
+ }
209
+ if (subAction === 'status') {
210
+ output(true, { running: mod.isGuardianRunning(), pid: mod.readGuardianPid() });
211
+ }
212
+ output(false, `Unknown guardian action: ${subAction}`);
213
+ }
214
+
215
+ async function cmdWechat(subAction, restArgs) {
216
+ if (subAction === 'bind') {
217
+ try { output(true, await guardianRequest({ action: 'wechat_login' })); }
218
+ catch (e) { output(false, e.message); }
219
+ }
220
+ if (subAction === 'status') {
221
+ try { output(true, await guardianRequest({ action: 'wechat_status' })); }
222
+ catch (e) { output(false, e.message); }
223
+ }
224
+ if (subAction === 'send') {
225
+ const { values } = parseArgs({
226
+ args: restArgs,
227
+ options: {
228
+ text: { type: 'string' },
229
+ 'text-file': { type: 'string' },
230
+ image: { type: 'string' },
231
+ file: { type: 'string' },
232
+ },
233
+ strict: false,
234
+ });
235
+ const text = readText({ text: values.text, textFile: values['text-file'] });
236
+ const action = values.image ? 'wechat_send_image'
237
+ : values.file ? 'wechat_send_file'
238
+ : text != null ? 'wechat_send'
239
+ : null;
240
+ if (!action) output(false, 'require one of --text / --text-file / --image / --file');
241
+ const payload = { action };
242
+ if (action === 'wechat_send') payload.text = text;
243
+ else payload.filePath = values.image || values.file;
244
+ try {
245
+ const res = await guardianRequest(payload);
246
+ output(res.ok !== false, res);
247
+ } catch (e) { output(false, e.message); }
248
+ }
249
+ output(false, `Unknown wechat action: ${subAction}`);
250
+ }
251
+
252
+ async function cmdInbox(subAction, restArgs) {
253
+ const inboxDir = join(SEAM_DIR, 'inbox');
254
+ if (!existsSync(inboxDir)) {
255
+ if (subAction === 'list') output(true, []);
256
+ output(false, `inbox is empty or not initialized`);
257
+ }
258
+ const entries = readdirSync(inboxDir).map((name) => {
259
+ const stat = statSync(join(inboxDir, name));
260
+ return {
261
+ id: name,
262
+ path: join(inboxDir, name),
263
+ size: stat.size,
264
+ mtime: stat.mtime.toISOString(),
265
+ };
266
+ });
267
+ if (subAction === 'list') output(true, entries);
268
+ if (subAction === 'get') {
269
+ const { values } = parseArgs({
270
+ args: restArgs,
271
+ options: { id: { type: 'string' } },
272
+ strict: false,
273
+ });
274
+ if (!values.id) output(false, '--id required');
275
+ const found = entries.find((e) => e.id === values.id);
276
+ output(!!found, found || `not found: ${values.id}`);
277
+ }
278
+ output(false, `Unknown inbox action: ${subAction}`);
279
+ }
280
+
281
+ async function cmdContacts(subAction, restArgs) {
282
+ const contactsPath = join(SEAM_DIR, 'contacts.json');
283
+ if (!existsSync(contactsPath)) output(true, []);
284
+ const list = JSON.parse(readFileSync(contactsPath, 'utf8'));
285
+ if (subAction === 'list') {
286
+ output(true, list);
287
+ }
288
+ if (subAction === 'get') {
289
+ const { values } = parseArgs({
290
+ args: restArgs,
291
+ options: { user: { type: 'string' } },
292
+ strict: false,
293
+ });
294
+ if (!values.user) output(false, '--user required');
295
+ const found = list.find((c) => c.userId === values.user);
296
+ output(true, found || null);
297
+ }
298
+ output(false, `Unknown contacts action: ${subAction}`);
299
+ }
300
+
301
+ async function cmdSelf(subAction, restArgs) {
302
+ if (subAction === 'schedule') {
303
+ const { values } = parseArgs({
304
+ args: restArgs,
305
+ options: {
306
+ id: { type: 'string' },
307
+ every: { type: 'string' },
308
+ text: { type: 'string' },
309
+ 'text-file': { type: 'string' },
310
+ },
311
+ strict: false,
312
+ });
313
+ if (!values.id) output(false, '--id required');
314
+ const everyMs = values.every ? parseDuration(values.every) : undefined;
315
+ const payload = { action: 'self_schedule', id: values.id };
316
+ if (everyMs) payload.every_ms = everyMs;
317
+ if (values.text) payload.text = values.text;
318
+ if (values['text-file']) payload.text_file = values['text-file'];
319
+ try {
320
+ const res = await guardianRequest(payload);
321
+ output(res.ok !== false, res);
322
+ } catch (e) { output(false, e.message); }
323
+ }
324
+ if (subAction === 'cancel') {
325
+ const { values } = parseArgs({
326
+ args: restArgs,
327
+ options: { id: { type: 'string' } },
328
+ strict: false,
329
+ });
330
+ if (!values.id) output(false, '--id required');
331
+ try {
332
+ const res = await guardianRequest({ action: 'self_cancel', id: values.id });
333
+ output(res.ok !== false, res);
334
+ } catch (e) { output(false, e.message); }
335
+ }
336
+ if (subAction === 'list') {
337
+ try {
338
+ const res = await guardianRequest({ action: 'self_list' });
339
+ output(res.ok !== false, res);
340
+ } catch (e) { output(false, e.message); }
341
+ }
342
+ output(false, `Unknown self action: ${subAction}. Try: seam self schedule|cancel|list`);
343
+ }
344
+
345
+ async function cmdInvite(subAction, restArgs) {
346
+ const creds = getCredentials();
347
+ const apiBase = process.env.SEAM_API_BASE || 'https://seam.chat';
348
+ const authHeaders = {
349
+ 'Content-Type': 'application/json',
350
+ 'x-im-user-id': encodeURIComponent(creds.userId),
351
+ 'x-im-user-sig': creds.userSig,
352
+ };
353
+
354
+ if (subAction === 'generate') {
355
+ try {
356
+ const res = await fetch(`${apiBase}/api/invite/generate`, {
357
+ method: 'POST',
358
+ headers: authHeaders,
359
+ body: JSON.stringify({ userId: creds.userId }),
360
+ });
361
+ if (!res.ok) {
362
+ const body = await res.json().catch(() => ({}));
363
+ output(false, body.message || body.error || `generate failed: ${res.status}`);
364
+ }
365
+ const data = await res.json();
366
+ const guideUrl = `${apiBase}/api/invite/guide/${data.code}`;
367
+ output(true, { code: data.code, guideUrl });
368
+ } catch (e) { output(false, e.message); }
369
+ }
370
+
371
+ output(false, `Unknown invite action: ${subAction}. Try: seam invite generate`);
372
+ }
373
+
374
+ function parseDuration(str) {
375
+ const m = String(str).match(/^(\d+)(s|m|h|ms)?$/i);
376
+ if (!m) return 600000;
377
+ const n = parseInt(m[1], 10);
378
+ const unit = (m[2] || 'ms').toLowerCase();
379
+ if (unit === 'ms') return n;
380
+ if (unit === 's') return n * 1000;
381
+ if (unit === 'm') return n * 60000;
382
+ if (unit === 'h') return n * 3600000;
383
+ return n;
384
+ }
385
+
386
+ // === dispatch ===
387
+
388
+ (async () => {
389
+ try {
390
+ if (domain === 'status') return await cmdStatus();
391
+ if (domain === 'msg') {
392
+ if (action === 'send') return await cmdMsgSend(rest);
393
+ if (action === 'group') return await cmdMsgGroup(rest);
394
+ output(false, `Unknown msg action: ${action}`);
395
+ }
396
+ if (domain === 'contacts') return await cmdContacts(action, rest);
397
+ if (domain === 'guardian') return await cmdGuardian(action);
398
+ if (domain === 'wechat') return await cmdWechat(action, rest);
399
+ if (domain === 'inbox') return await cmdInbox(action, rest);
400
+ if (domain === 'self') return await cmdSelf(action, rest);
401
+ if (domain === 'invite') return await cmdInvite(action, rest);
402
+ output(false, `Unknown domain: ${domain}. Try: seam --help`);
403
+ } catch (e) {
404
+ output(false, e.message);
405
+ }
406
+ })();
package/lib/api.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Seam backend API client
3
+ */
4
+
5
+ export async function register({ apiBase, name, inviteCode }) {
6
+ const res = await fetch(`${apiBase}/api/ai/register`, {
7
+ method: 'POST',
8
+ headers: { 'Content-Type': 'application/json' },
9
+ body: JSON.stringify({ name, inviteCode }),
10
+ });
11
+
12
+ if (!res.ok) {
13
+ const body = await res.json().catch(() => ({}));
14
+ throw new Error(body.message || body.error || `Registration failed (${res.status})`);
15
+ }
16
+
17
+ return res.json();
18
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Guardian Autostart —— CC SessionStart 时调用。
3
+ *
4
+ * 策略:
5
+ * 1. 没 credentials → 跳过(AI 还没 init)
6
+ * 2. guardian pid 存活 → 跳过(不重复启)
7
+ * 3. 否则 → 启 guardian(detached 后台,跑在当前 tmux session)
8
+ *
9
+ * 防泛滥:
10
+ * - 用 .seam/guardian.pid 判断是否在跑,check 进程真的活
11
+ * - 每个 AI workdir 一份 pid 文件,互不干扰
12
+ */
13
+
14
+ import { existsSync } from 'node:fs';
15
+ import { CREDENTIALS_PATH } from './paths.js';
16
+ import { isGuardianRunning, readGuardianPid } from './guardian.js';
17
+
18
+ export async function autostart({ silent = true } = {}) {
19
+ if (!existsSync(CREDENTIALS_PATH)) {
20
+ if (!silent) console.log('[autostart] no credentials, skip');
21
+ return { status: 'skip_no_credentials' };
22
+ }
23
+
24
+ if (isGuardianRunning()) {
25
+ const pid = readGuardianPid();
26
+ if (!silent) console.log(`[autostart] already running: pid ${pid}`);
27
+ return { status: 'already_running', pid };
28
+ }
29
+
30
+ try {
31
+ const { guardianStart } = await import('./guardian.js');
32
+ await guardianStart();
33
+ return { status: 'started', pid: readGuardianPid() };
34
+ } catch (e) {
35
+ if (!silent) console.error('[autostart] failed to start:', e.message);
36
+ return { status: 'start_failed', error: e.message };
37
+ }
38
+ }
39
+
40
+ // 允许直接执行:node lib/autostart.js
41
+ if (import.meta.url === `file://${process.argv[1]}`) {
42
+ autostart({ silent: false }).then((r) => {
43
+ if (r.status === 'started' || r.status === 'already_running') {
44
+ process.exit(0);
45
+ }
46
+ process.exit(0); // 任何情况都 0 退出,不阻塞 CC
47
+ });
48
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Guardian action 的 payload 契约。
3
+ *
4
+ * 所有跨进程(CLI ↔ guardian ↔ plugin)的 action 字段名在这里定义,
5
+ * CLI 构造 payload 和 plugin 读 payload 都引用这个 schema。
6
+ * 目的:不再两边靠记忆对齐字段名,静默崩溃变成入口期失败。
7
+ *
8
+ * 加新 action:在 ACTIONS 里加一条,两边代码走 validatePayload 就能发现遗漏字段。
9
+ */
10
+
11
+ const ACTIONS = Object.freeze({
12
+ send_im: { fields: ['to', 'text'] },
13
+ send_group: { fields: ['groupId', 'text'] },
14
+ send_im_image: { fields: ['to', 'filePath'] },
15
+ send_group_image: { fields: ['groupId', 'filePath'] },
16
+ send_im_file: { fields: ['to', 'filePath'] },
17
+ send_group_file: { fields: ['groupId', 'filePath'] },
18
+ im_status: { fields: [] },
19
+ wechat_login: { fields: [] },
20
+ wechat_status: { fields: [] },
21
+ wechat_send: { fields: ['text'] },
22
+ wechat_send_image: { fields: ['filePath'] },
23
+ wechat_send_file: { fields: ['filePath'] },
24
+ self_schedule: { fields: ['id'] },
25
+ self_cancel: { fields: ['id'] },
26
+ self_list: { fields: [] },
27
+ });
28
+
29
+ /**
30
+ * 校验 payload 是否符合 action 的字段契约。
31
+ * 不检查类型,只检查必选字段是否存在且非空。
32
+ *
33
+ * @returns {{ok: true} | {ok: false, error: string}}
34
+ */
35
+ function validatePayload(action, payload) {
36
+ const schema = ACTIONS[action];
37
+ if (!schema) {
38
+ return { ok: false, error: `unknown action: ${action}` };
39
+ }
40
+ const missing = schema.fields.filter((f) => {
41
+ const v = payload?.[f];
42
+ return v === undefined || v === null || v === '';
43
+ });
44
+ if (missing.length) {
45
+ return {
46
+ ok: false,
47
+ error: `action "${action}" missing required field(s): ${missing.join(', ')}`,
48
+ };
49
+ }
50
+ return { ok: true };
51
+ }
52
+
53
+ module.exports = { ACTIONS, validatePayload };