@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/README.md +68 -2
- package/bin/act-01.js +8 -0
- package/config-helper.html +220 -0
- package/package.json +37 -3
- package/src/main.js +506 -0
- package/src/mcp.js +197 -0
- package/src/redpacket.js +124 -0
- package/src/store.js +151 -0
- package/src/tcb.js +71 -0
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
|
+
}
|