@blod-nm/1kuan-act-01-cli 0.0.0-stage → 0.1.1

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/src/main.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * act-01 CLI 入口与分发。退出码:0 成功 / 1 失败 / 2 用法错误 / 130 取消。
3
+ */
4
+
5
+ import { parseArgs } from 'node:util'
6
+ import { configPath, loadConfig, saveConfig, decodeConfigString, encryptConfig } from './store.js'
7
+ import { ACCOUNT_SQL, execPgsql, resolveApikey, gatewayBase } from './tcb.js'
8
+ import { buildCreateSql, confirmSummary, normalizeCreateRedpacket } from './redpacket.js'
9
+ import { startMcp } from './mcp.js'
10
+
11
+ const VERSION = '0.1.0'
12
+
13
+ const USAGE = `act-01 —— operations CLI(invoke preset database functions)
14
+
15
+ 用法: act-01 <command> [options]
16
+
17
+ 命令:
18
+ config show 查看配置(apiKey 打码)
19
+ config set envId <环境ID> 写入环境 ID
20
+ config set apiKey 写入 API Key(值走 stdin;不放命令行)
21
+ config test 连接测试(SELECT 1,验证 envId/apiKey)
22
+ config export 导出加密配置串(口令交互输入或 ACT01_EXPORT_PASS)
23
+ config import <串|--stdin> 导入配置串(自动识别 加密/明文/网页格式)
24
+ mcp 启动 MCP 服务(stdio;接入 Claude 等 AI 客户端)
25
+ balance account balance
26
+ redpacket create create a record(pre-flight balance check, atomic deduct)
27
+ redpacket check validate params(side-effect free)
28
+ --mode random|equal 随机 / 均分
29
+ --total <积分> 总金额(正整数,不换算)
30
+ --count <份数>
31
+ --per-user <次数> 每人限领(≤ 份数)
32
+ --min <积分> --max <积分> 随机模式单份区间(均分自动计算)
33
+ --share-title --share-cover --note 可选
34
+ --require-group 群专属
35
+ --dry-run 只打印库函数调用,不执行
36
+ --yes 跳过确认
37
+
38
+ apiKey 等同数据库管理员:支持 ACT01_API_KEY 环境变量,永远不要放在命令行参数里。
39
+
40
+ 退出码: 0 成功 / 1 失败 / 2 用法错误 / 130 取消
41
+ `
42
+
43
+ function str(values, key) {
44
+ return typeof values[key] === 'string' ? values[key] : undefined
45
+ }
46
+
47
+ function num(values, key) {
48
+ const s = str(values, key)
49
+ if (s === undefined) return undefined
50
+ const n = Number(s)
51
+ if (!Number.isFinite(n)) {
52
+ const err = new Error(`--${key} 必须是数字`)
53
+ err.exitCode = 2
54
+ throw err
55
+ }
56
+ return n
57
+ }
58
+
59
+ async function readStdinText() {
60
+ const chunks = []
61
+ for await (const chunk of process.stdin) chunks.push(chunk)
62
+ return Buffer.concat(chunks).toString('utf8').trim()
63
+ }
64
+
65
+ function printJson(value) {
66
+ console.log(JSON.stringify(value, null, process.stdout.isTTY ? 2 : undefined))
67
+ }
68
+
69
+ function printTable(headers, rows) {
70
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)))
71
+ const line = (cells) => cells.map((c, i) => (c ?? '').padEnd(widths[i] ?? 0)).join(' ').trimEnd()
72
+ console.log(line(headers))
73
+ for (const row of rows) console.log(line(row))
74
+ }
75
+
76
+ /* ---------- config ---------- */
77
+
78
+ async function cmdConfig(argv) {
79
+ const { values, positionals } = parseArgs({
80
+ strict: false,
81
+ allowPositionals: true,
82
+ args: argv,
83
+ options: {
84
+ stdin: { type: 'boolean' },
85
+ json: { type: 'boolean', short: 'j' },
86
+ },
87
+ })
88
+ const sub = positionals[0] ?? 'show'
89
+
90
+ if (sub === 'show') {
91
+ const config = loadConfig()
92
+ if (values.json === true) {
93
+ printJson({ envId: config.envId ?? null, apiKeySet: !!config.apiKey })
94
+ } else {
95
+ printTable(
96
+ ['KEY', 'VALUE'],
97
+ [
98
+ ['envId', config.envId ?? '(未设置)'],
99
+ ['apiKey', config.apiKey ? `${config.apiKey.slice(0, 6)}…(打码)` : '(未设置)'],
100
+ ['configPath', configPath()],
101
+ ],
102
+ )
103
+ }
104
+ return 0
105
+ }
106
+
107
+ if (sub === 'set') {
108
+ const key = positionals[1]
109
+ const config = loadConfig()
110
+
111
+ if (key === 'envId') {
112
+ const value = positionals[2]
113
+ if (!value) {
114
+ const err = new Error('config set envId 需要 <环境ID>')
115
+ err.exitCode = 2
116
+ throw err
117
+ }
118
+ config.envId = value
119
+ saveConfig(config)
120
+ console.log(`envId 已写入: ${value}`)
121
+ return 0
122
+ }
123
+
124
+ if (key === 'apiKey') {
125
+ let secret = str(values, 'value') ?? positionals[2]
126
+ if (values.stdin === true || !secret) {
127
+ if (process.stdin.isTTY) {
128
+ const err = new Error('apiKey 是敏感信息:用 --stdin 通过管道传入,或设置 ACT01_API_KEY 环境变量')
129
+ err.exitCode = 2
130
+ throw err
131
+ }
132
+ secret = await readStdinText()
133
+ }
134
+ if (!secret) {
135
+ const err = new Error('apiKey 为空')
136
+ err.exitCode = 2
137
+ throw err
138
+ }
139
+ config.apiKey = secret
140
+ saveConfig(config)
141
+ console.log('apiKey 已写入(0600,打码保存)')
142
+ return 0
143
+ }
144
+
145
+ const err = new Error(`未知 config 键: ${key}(支持: envId / apiKey)`)
146
+ err.exitCode = 2
147
+ throw err
148
+ }
149
+
150
+ if (sub === 'export') {
151
+ const config = loadConfig()
152
+ if (!config.envId || !config.apiKey) {
153
+ const err = new Error('配置不完整:先 set envId 和 apiKey')
154
+ err.exitCode = 2
155
+ throw err
156
+ }
157
+ let passphrase = process.env.ACT01_EXPORT_PASS
158
+ if (!passphrase) {
159
+ if (!process.stdin.isTTY) {
160
+ const err = new Error('口令缺失:交互终端下会提示输入,脚本里用 --pass-stdin')
161
+ err.exitCode = 2
162
+ throw err
163
+ }
164
+ const readline = await import('node:readline')
165
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: false })
166
+ passphrase = await new Promise((resolve) => rl.question('口令(用于加密,导出端与导入端一致): ', (a) => resolve(a.trim())))
167
+ rl.close()
168
+ if (!passphrase) {
169
+ const err = new Error('口令为空')
170
+ err.exitCode = 2
171
+ throw err
172
+ }
173
+ } else {
174
+ passphrase = passphrase.trim()
175
+ }
176
+ const str = encryptConfig(config, passphrase)
177
+ if (values.json === true) printJson({ configString: str })
178
+ else console.log(str)
179
+ return 0
180
+ }
181
+
182
+ if (sub === 'import') {
183
+ let body = positionals[1]
184
+ if (!body && !process.stdin.isTTY) body = await readStdinText()
185
+ if (!body) {
186
+ const err = new Error('config import 需要 <配置串>(位置参数或 stdin)')
187
+ err.exitCode = 2
188
+ throw err
189
+ }
190
+ const decoded = decodeConfigString(body)
191
+ let cfg = decoded.config
192
+ if (decoded.encrypted) {
193
+ let passphrase = process.env.ACT01_EXPORT_PASS
194
+ if (!passphrase) {
195
+ if (!process.stdin.isTTY) {
196
+ const err = new Error('加密串需要口令:脚本里用 ACT01_EXPORT_PASS 传入')
197
+ err.exitCode = 2
198
+ throw err
199
+ }
200
+ const readline = await import('node:readline')
201
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: false })
202
+ passphrase = await new Promise((resolve) => rl.question('口令: ', (a) => resolve(a.trim())))
203
+ rl.close()
204
+ }
205
+ cfg = decoded.decode(passphrase)
206
+ }
207
+ if (!cfg.envId || !cfg.apiKey) {
208
+ const err = new Error('配置串内容不完整(缺 envId 或 apiKey)')
209
+ err.exitCode = 2
210
+ throw err
211
+ }
212
+ saveConfig({ envId: cfg.envId, apiKey: cfg.apiKey })
213
+ if (values.json === true) {
214
+ printJson({ imported: true, envId: cfg.envId, apiKeySet: true })
215
+ } else {
216
+ printTable(['导入', 'ENVID'], [['成功', cfg.envId]])
217
+ console.error('hint: apiKey 已打码写入 0600 配置文件')
218
+ }
219
+ return 0
220
+ }
221
+
222
+ const err = new Error(`未知 config 子命令: ${sub}`)
223
+ err.exitCode = 2
224
+ throw err
225
+ }
226
+
227
+ /* ---------- redpacket create ---------- */
228
+
229
+ const CREATE_FLAGS = {
230
+ mode: { type: 'string' },
231
+ total: { type: 'string' },
232
+ count: { type: 'string' },
233
+ 'per-user': { type: 'string' },
234
+ min: { type: 'string' },
235
+ max: { type: 'string' },
236
+ 'share-title': { type: 'string' },
237
+ 'share-cover': { type: 'string' },
238
+ note: { type: 'string' },
239
+ 'require-group': { type: 'boolean' },
240
+ }
241
+
242
+ function parseCreateValues(argv) {
243
+ return parseArgs({ strict: false, allowPositionals: true, args: argv, options: CREATE_FLAGS }).values
244
+ }
245
+
246
+ function findMissing(values) {
247
+ const missing = []
248
+ if (!str(values, 'mode')) missing.push('--mode random|equal')
249
+ if (num(values, 'total') === undefined) missing.push('--total <积分>')
250
+ if (num(values, 'count') === undefined) missing.push('--count <份数>')
251
+ if (num(values, 'per-user') === undefined) missing.push('--per-user <次数>')
252
+ if (str(values, 'mode') === 'random' && (num(values, 'min') === undefined || num(values, 'max') === undefined))
253
+ missing.push('--min <积分> --max <积分>')
254
+ return missing
255
+ }
256
+
257
+ async function wizardFill(values, missing) {
258
+ const readline = await import('node:readline')
259
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
260
+ const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())))
261
+ for (const item of missing) {
262
+ if (item.startsWith('--mode')) {
263
+ const v = await ask('type random|equal: ')
264
+ if (v) values.mode = v
265
+ } else if (item.startsWith('--total')) {
266
+ const v = await ask('总金额(整数积分): ')
267
+ if (v) values.total = v
268
+ } else if (item.startsWith('--count')) {
269
+ const v = await ask('份数: ')
270
+ if (v) values.count = v
271
+ } else if (item.startsWith('--per-user')) {
272
+ const v = await ask('每人限领次数: ')
273
+ if (v) values['per-user'] = v
274
+ } else if (item.startsWith('--min') || item.startsWith('--max')) {
275
+ if (values.min === undefined) {
276
+ const v = await ask('单份最小金额: ')
277
+ if (v) values.min = v
278
+ }
279
+ if (values.max === undefined) {
280
+ const v = await ask('单份最大金额: ')
281
+ if (v) values.max = v
282
+ }
283
+ }
284
+ }
285
+ rl.close()
286
+ }
287
+
288
+ function normalizeFromValues(values) {
289
+ const missing = findMissing(values)
290
+ if (missing.length) {
291
+ const err = new Error(`缺少必填参数: ${missing.join(', ')}`)
292
+ err.exitCode = 2
293
+ throw err
294
+ }
295
+ return normalizeCreateRedpacket({
296
+ mode: str(values, 'mode'),
297
+ total_points: num(values, 'total'),
298
+ count: num(values, 'count'),
299
+ per_user_limit: num(values, 'per-user'),
300
+ min_points: num(values, 'min'),
301
+ max_points: num(values, 'max'),
302
+ share_title: str(values, 'share-title'),
303
+ share_cover: str(values, 'share-cover'),
304
+ note: str(values, 'note'),
305
+ require_group: values['require-group'] === true,
306
+ })
307
+ }
308
+
309
+ /* ---------- redpacket check(零副作用参数校验) ---------- */
310
+
311
+ async function cmdCheck(argv) {
312
+ const values = parseCreateValues(argv)
313
+ let input
314
+ try {
315
+ input = normalizeFromValues(values)
316
+ } catch (err) {
317
+ console.error(`error: ${err.message}`)
318
+ console.error('hint: 本命令只校验参数,不执行、不扣账;修正后重跑即可')
319
+ return 1
320
+ }
321
+ if (values.json === true) {
322
+ printJson({ valid: true, input })
323
+ } else {
324
+ console.log('参数校验通过(未执行、不扣账):')
325
+ for (const line of confirmSummary(input)) console.log(` ${line}`)
326
+ }
327
+ return 0
328
+ }
329
+
330
+ async function cmdCreate(argv) {
331
+ const values = parseCreateValues(argv)
332
+ const interactive = process.stdin.isTTY === true
333
+
334
+ // 向导补齐仅限"真命令行直跑"(bin 入口打 ACT01_CLI_INVOKED 标记);
335
+ // 测试/程序内调用缺参一律确定性报错,防止向导抢 stdin
336
+ const missing = findMissing(values)
337
+ if (missing.length) {
338
+ if (!interactive || process.env.ACT01_CLI_INVOKED !== '1') {
339
+ const err = new Error(`缺少必填参数: ${missing.join(', ')}`)
340
+ err.exitCode = 2
341
+ throw err
342
+ }
343
+ await wizardFill(values, missing)
344
+ }
345
+
346
+ const input = normalizeFromValues(values)
347
+ const sql = buildCreateSql(input)
348
+
349
+ // --dry-run 无需任何配置(不碰网络)
350
+ if (values['dry-run'] === true) {
351
+ printJson({ dryRun: true, function: 'public.manage_act_01_redpacket_create($1..$11)', sql })
352
+ return 0
353
+ }
354
+
355
+ const config = loadConfig()
356
+ if (!config.envId) {
357
+ const err = new Error('缺少 envId')
358
+ err.exitCode = 2
359
+ err.hint = 'act-01 config set envId <环境ID>'
360
+ throw err
361
+ }
362
+
363
+ // 发之前先查总账余额:不足直接拦下,不等库函数报错
364
+ const apikey = resolveApikey(config, undefined)
365
+ if (!apikey) {
366
+ const err = new Error('缺少 apiKey')
367
+ err.exitCode = 2
368
+ err.hint = 'act-01 config set apiKey --stdin 或设置 ACT01_API_KEY'
369
+ throw err
370
+ }
371
+
372
+ console.error('checking account balance…')
373
+ const account = await execPgsql(config, apikey, ACCOUNT_SQL)
374
+ if (!account.ok) {
375
+ console.error(`error: balance query failed (${account.message})`)
376
+ console.error('hint: 确认 envId/apiKey 正确;或用 --yes 跳过预检(库函数仍会原子校验)')
377
+ return 1
378
+ }
379
+ const wallet =
380
+ account.rows?.[0]?.data?.wallet && typeof account.rows[0].data.wallet === 'object'
381
+ ? account.rows[0].data.wallet
382
+ : {}
383
+ const balance = Number(wallet.points || 0)
384
+ const label = String(wallet.label || 'account')
385
+ if (balance < input.totalPoints) {
386
+ console.error(`error: insufficient account balance (current ${balance} < required ${input.totalPoints})`)
387
+ console.error(`hint: 先到运营台充值(总账「${label}」),或调低 --total`)
388
+ return 1
389
+ }
390
+ console.error(`balance ${balance}, will deduct ${input.totalPoints}, remaining ${balance - input.totalPoints}`)
391
+
392
+ // 确认卡(总账扣减是真金白银)
393
+ if (values.yes !== true && interactive) {
394
+ console.log('about to deduct from account and create:')
395
+ for (const line of confirmSummary(input)) console.log(` ${line}`)
396
+ process.stdout.write('确认执行? [y/N] ')
397
+ const chunks = []
398
+ for await (const chunk of process.stdin) chunks.push(chunk)
399
+ const answer = Buffer.concat(chunks).toString('utf8').trim().toLowerCase()
400
+ if (answer !== 'y' && answer !== 'yes') {
401
+ console.log('已取消')
402
+ return 130
403
+ }
404
+ } else if (values.yes !== true) {
405
+ const err = new Error('this will deduct from the account; confirm interactively or pass --yes')
406
+ err.exitCode = 2
407
+ throw err
408
+ }
409
+
410
+ console.error('执行中…(库函数原子扣减总账)')
411
+ const r = await execPgsql(config, apikey, sql)
412
+
413
+ if (!r.ok) {
414
+ console.error(`error: create failed (HTTP ${r.status})`)
415
+ if (r.message) console.error(r.message)
416
+ return 1
417
+ }
418
+
419
+ const result = r.rows?.[0]?.result
420
+ if (values.json === true) {
421
+ printJson({ ok: true, result, balanceBefore: balance, balanceAfter: balance - input.totalPoints })
422
+ } else {
423
+ printTable(
424
+ ['RESULT', '余额变化'],
425
+ [[
426
+ typeof result === 'string' ? result : JSON.stringify(result),
427
+ `${balance} → ${balance - input.totalPoints}`,
428
+ ]],
429
+ )
430
+ console.error('hint: deducted; recall is not supported — lifecycle is TTL-driven')
431
+ }
432
+ return 0
433
+ }
434
+
435
+ /* ---------- balance ---------- */
436
+
437
+ async function cmdBalance(argv) {
438
+ const { values } = parseArgs({
439
+ strict: false,
440
+ allowPositionals: true,
441
+ args: argv,
442
+ options: { json: { type: 'boolean', short: 'j' } },
443
+ })
444
+ const config = loadConfig()
445
+ const apikey = resolveApikey(config, undefined)
446
+ if (!apikey) {
447
+ const err = new Error('缺少 apiKey')
448
+ err.exitCode = 2
449
+ err.hint = 'act-01 config set apiKey --stdin 或设置 ACT01_API_KEY'
450
+ throw err
451
+ }
452
+ const account = await execPgsql(config, apikey, ACCOUNT_SQL)
453
+ if (!account.ok) {
454
+ console.error(`error: balance query failed (${account.message})`)
455
+ return 1
456
+ }
457
+ const wallet =
458
+ account.rows?.[0]?.data?.wallet && typeof account.rows[0].data.wallet === 'object'
459
+ ? account.rows[0].data.wallet
460
+ : {}
461
+ const points = Number(wallet.points || 0)
462
+ const label = String(wallet.label || 'account')
463
+ if (values.json === true) {
464
+ printJson({ label, points, updatedAt: String(account.rows?.[0]?.updated_at ?? '') })
465
+ } else {
466
+ printTable(['ACCOUNT', 'BALANCE', 'UPDATED'], [[label, String(points), String(account.rows?.[0]?.updated_at ?? '')]])
467
+ }
468
+ return 0
469
+ }
470
+
471
+ /* ---------- 分发 ---------- */
472
+
473
+ export async function main(argv) {
474
+ const cmd = argv[0]
475
+
476
+ if (cmd === undefined || cmd === '-h' || cmd === '--help' || cmd === 'help') {
477
+ process.stdout.write(USAGE)
478
+ return cmd === undefined ? 2 : 0
479
+ }
480
+ if (cmd === '--version' || cmd === '-V' || cmd === 'version') {
481
+ process.stdout.write(`act-01 ${VERSION}\n`)
482
+ return 0
483
+ }
484
+
485
+ try {
486
+ if (cmd === 'config') return await cmdConfig(argv.slice(1))
487
+ if (cmd === 'balance') return await cmdBalance(argv.slice(1))
488
+ if (cmd === 'mcp') return await startMcp()
489
+ if (cmd === 'redpacket') {
490
+ const sub = argv[1]
491
+ if (sub === 'create') return await cmdCreate(argv.slice(2))
492
+ if (sub === 'check') return await cmdCheck(argv.slice(2))
493
+ const err = new Error(`未知 redpacket 子命令: ${sub ?? '(空)'}(支持: create / check)`)
494
+ err.exitCode = 2
495
+ throw err
496
+ }
497
+ const err = new Error(`未知命令: ${cmd}`)
498
+ err.exitCode = 2
499
+ err.hint = 'act-01 --help 查看命令列表'
500
+ throw err
501
+ } catch (err) {
502
+ console.error(`error: ${err.message}`)
503
+ if (err.hint) console.error(`hint: ${err.hint}`)
504
+ return err.exitCode ?? 1
505
+ }
506
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * act-01 MCP 服务(stdio,换行分隔 JSON-RPC 2.0,零依赖)。
3
+ *
4
+ * 启动: act-01 mcp(或 node bin/act-01.js mcp)
5
+ * 接入: claude mcp add act-01 -- node /path/to/bin/act-01.js mcp
6
+ *
7
+ * 工具: redpacket_create(有副作用:总账扣减,审批由客户端负责)
8
+ * redpacket_check(零副作用参数校验)
9
+ * balance(总账余额)
10
+ *
11
+ * 纪律: MCP 模式下 stdout 只输出 JSON-RPC 报文,任何日志走 stderr。
12
+ */
13
+
14
+ import { loadConfig } from './store.js'
15
+ import { resolveApikey, execPgsql, gatewayBase } from './tcb.js'
16
+ import { normalizeCreateRedpacket, buildCreateSql } from './redpacket.js'
17
+
18
+ const SERVER_INFO = { name: 'act-01', version: '0.1.0' }
19
+ const SUPPORTED = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
20
+ const LATEST = '2025-11-25'
21
+
22
+ const TOOLS = [
23
+ {
24
+ name: 'redpacket_create',
25
+ description:
26
+ 'create a record with atomic balance deduct (side effect; client should confirm with the user first).' +
27
+ 'mode=random 时需 min/max 单份区间且总额能被份数整除;mode=equal 时自动均分。' +
28
+ 'returns packet summary.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ mode: { type: 'string', enum: ['random', 'equal'], description: 'random / equal' },
33
+ total_points: { type: 'integer', description: 'total amount (integer units, no conversion)' },
34
+ count: { type: 'integer', description: 'count' },
35
+ per_user_limit: { type: 'integer', description: '每人限领次数,≤ 份数' },
36
+ min_points: { type: 'integer', description: '随机模式:单份最小金额' },
37
+ max_points: { type: 'integer', description: '随机模式:单份最大金额' },
38
+ share_title: { type: 'string', description: '可选分享标题,≤60 字' },
39
+ note: { type: 'string', description: '可选备注,≤20 字' },
40
+ require_group: { type: 'boolean' },
41
+ },
42
+ required: ['mode', 'total_points', 'count', 'per_user_limit'],
43
+ },
44
+ },
45
+ {
46
+ name: 'redpacket_check',
47
+ description: 'validate create params (side-effect free). Recommended before create.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ mode: { type: 'string', enum: ['random', 'equal'] },
52
+ total_points: { type: 'integer' },
53
+ count: { type: 'integer' },
54
+ per_user_limit: { type: 'integer' },
55
+ min_points: { type: 'integer' },
56
+ max_points: { type: 'integer' },
57
+ share_title: { type: 'string' },
58
+ note: { type: 'string' },
59
+ require_group: { type: 'boolean' },
60
+ },
61
+ required: ['mode', 'total_points', 'count', 'per_user_limit'],
62
+ },
63
+ },
64
+ {
65
+ name: 'balance',
66
+ description: 'query account balance.',
67
+ inputSchema: { type: 'object', properties: {} },
68
+ },
69
+ ]
70
+
71
+ function needConfig() {
72
+ const config = loadConfig()
73
+ const apikey = resolveApikey(config, undefined)
74
+ if (!config.envId || !apikey) {
75
+ const err = new Error('云开发配置不完整:先在终端执行 act-01 config set envId / apiKey(不进对话上下文)')
76
+ err.code = 'no-config'
77
+ throw err
78
+ }
79
+ return { config, apikey }
80
+ }
81
+
82
+ async function callTool(name, args) {
83
+ if (name === 'redpacket_create' || name === 'redpacket_check') {
84
+ const checkOnly = name === 'redpacket_check'
85
+ const input = normalizeCreateRedpacket(args) // 校验失败抛 ValidationError
86
+ const sql = buildCreateSql(input)
87
+ if (checkOnly) {
88
+ return {
89
+ valid: true,
90
+ summary: input,
91
+ sql,
92
+ note: '参数校验通过;这是校验结果,未执行任何写操作',
93
+ }
94
+ }
95
+ const { config, apikey } = needConfig()
96
+ const r = await execPgsql(config, apikey, sql)
97
+ if (!r.ok) {
98
+ const err = new Error(r.message || `HTTP ${r.status}`)
99
+ err.gateway = true
100
+ throw err
101
+ }
102
+ return r.rows?.[0]?.result ?? r.raw
103
+ }
104
+
105
+ if (name === 'balance') {
106
+ const { config, apikey } = needConfig()
107
+ const r = await execPgsql(config, apikey, ACCOUNT_SQL)
108
+ if (!r.ok) {
109
+ const err = new Error(r.message || `HTTP ${r.status}`)
110
+ err.gateway = true
111
+ throw err
112
+ }
113
+ const wallet =
114
+ r.rows?.[0]?.data?.wallet && typeof r.rows[0].data.wallet === 'object'
115
+ ? r.rows[0].data.wallet
116
+ : {}
117
+ return {
118
+ label: String(wallet.label || 'account'),
119
+ points: Number(wallet.points || 0),
120
+ updatedAt: String(r.rows?.[0]?.updated_at ?? ''),
121
+ }
122
+ }
123
+
124
+ const err = new Error(`未知工具: ${name}`)
125
+ err.code = 'unknown-tool'
126
+ throw err
127
+ }
128
+
129
+ function toolResult(payload) {
130
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] }
131
+ }
132
+
133
+ function toolError(message) {
134
+ return { content: [{ type: 'text', text: message }], isError: true }
135
+ }
136
+
137
+ export async function startMcp() {
138
+ const readline = await import('node:readline')
139
+ const rl = readline.createInterface({ input: process.stdin, terminal: false })
140
+
141
+ const reply = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`)
142
+
143
+ rl.on('line', async (line) => {
144
+ const text = line.trim()
145
+ if (!text) return
146
+ let msg
147
+ try {
148
+ msg = JSON.parse(text)
149
+ } catch {
150
+ reply({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
151
+ return
152
+ }
153
+ if (msg.id === undefined || msg.id === null) return // 通知:不回复
154
+
155
+ try {
156
+ if (msg.method === 'initialize') {
157
+ const requested = String(msg.params?.protocolVersion ?? '')
158
+ const version = SUPPORTED.includes(requested) ? requested : LATEST
159
+ reply({
160
+ jsonrpc: '2.0',
161
+ id: msg.id,
162
+ result: { protocolVersion: version, capabilities: { tools: {} }, serverInfo: SERVER_INFO },
163
+ })
164
+ } else if (msg.method === 'ping') {
165
+ reply({ jsonrpc: '2.0', id: msg.id, result: {} })
166
+ } else if (msg.method === 'tools/list') {
167
+ reply({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } })
168
+ } else if (msg.method === 'tools/call') {
169
+ const name = String(msg.params?.name ?? '')
170
+ const args = msg.params?.arguments ?? {}
171
+ try {
172
+ const payload = await callTool(name, args)
173
+ reply({ jsonrpc: '2.0', id: msg.id, result: toolResult(payload) })
174
+ } catch (err) {
175
+ reply({ jsonrpc: '2.0', id: msg.id, result: toolError(err.message) })
176
+ }
177
+ } else {
178
+ reply({
179
+ jsonrpc: '2.0',
180
+ id: msg.id,
181
+ error: { code: -32601, message: `Method not found: ${msg.method}` },
182
+ })
183
+ }
184
+ } catch (err) {
185
+ reply({
186
+ jsonrpc: '2.0',
187
+ id: msg.id,
188
+ error: { code: -32603, message: err instanceof Error ? err.message : String(err) },
189
+ })
190
+ }
191
+ })
192
+
193
+ rl.on('close', () => process.exit(0))
194
+ // MCP 模式下 stderr 才是日志通道
195
+ console.error(`act-01 MCP 服务已启动(${SERVER_INFO.version},stdio)`)
196
+ return new Promise(() => {/* 保持运行直到 stdin 关闭 */})
197
+ }