@tuuject/surowan-utils 0.0.0-stage → 0.0.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/dist/run.js ADDED
@@ -0,0 +1,195 @@
1
+ import process from 'node:process';
2
+ import { parseCli, parseTimeout, resolveCli } from "./args.js";
3
+ import { AdminClient, isRecord, parseEndpoint } from "./client.js";
4
+ import { executeCommand } from "./commands.js";
5
+ import { credentialsPath, readCredentials, writeCredentials } from "./credentials.js";
6
+ import { CliError, usageError } from "./errors.js";
7
+ import { promptLoginField, readLoginKey } from "./login.js";
8
+ export const USAGE = `surowan-utils - Plastic Wan Admin API 工具客户端
9
+
10
+ 用法:
11
+ surowan-utils login [--endpoint <url>] [--api-key-stdin] [--json]
12
+ surowan-utils doctor [--json]
13
+ surowan-utils models list [--json]
14
+ surowan-utils config show [--source active|file] [--json]
15
+ surowan-utils prompt get global [--source active|file] [--json]
16
+ surowan-utils prompt get group --chat <id> [--source active|file] [--json]
17
+ surowan-utils invocation list [--limit N] [--cursor ID] [--state STATE] [--chat ID] [--search <keyword>] [--at <time>] [--from <time>] [--to <time>] [--json]
18
+ surowan-utils invocation get <id> [--json]
19
+ surowan-utils invocation prompts <id> [--json]
20
+ surowan-utils invocation preflight <id> [--before-send <send-id>] [--provider <alias> --model <id>] [--thinking-level <level>] [--json]
21
+ surowan-utils invocation media <id> [--variant original|preview] [--json]
22
+ surowan-utils invocation replay <id> [--before-send <send-id> --confirm-paid] [--provider <alias> --model <id>] [--thinking-level <level>] [--global-prompt <file|->] [--group-prompt <file|->] [--json]
23
+
24
+ 全局选项:
25
+ --endpoint <url> Admin Panel 基地址(覆盖环境变量与登录文件);明文 http 仅允许 loopback
26
+ --api-key <key> API key(覆盖 SUROWAN_API_KEY 与登录文件;建议隐藏输入、stdin 或安全环境注入)
27
+ --api-key-stdin 仅 login:读取标准输入至 EOF,接受单行 API key,不回显
28
+ --source <active|file> config show / prompt get:读取运行时生效配置或配置文件(默认 active)
29
+ --variant <original|preview> 仅 invocation media:下载原始文件或预览(默认 original)
30
+ --provider <alias> --model <id> 仅 invocation preflight/replay:临时选用已启用模型,两项必须成对
31
+ --thinking-level <level> 仅 invocation preflight/replay:临时思考级别(off|minimal|low|medium|high|xhigh|max)
32
+ --global-prompt <file|-> 仅 invocation replay:替换 global prompt;- 表示 stdin
33
+ --group-prompt <file|-> 仅 invocation replay:替换 group prompt(允许空);- 表示 stdin
34
+ --search <keyword> 仅 invocation list:按字面关键词过滤消息(1-100 字符,不解释通配符)
35
+ --at <time> 仅 invocation list:筛选该时刻的 Invocation,与 --from/--to 互斥
36
+ --from <time> / --to <time> 仅 invocation list:筛选时间范围
37
+ --before-send <send-id> 仅 invocation preflight/replay:从该 telegram_sends 内部 id(已成功发送)之前开始回放
38
+ --confirm-paid 仅 invocation replay:切片重放(--before-send)会真实调用模型并计费,需显式确认,否则在发请求前拒绝
39
+ --timeout-ms <ms> 请求/每次输入超时;默认非 replay 命令 30000,replay 300000,不自动重试
40
+ --json 输出稳定 JSON
41
+ -h, --help 显示本帮助
42
+
43
+ 时间格式: YYYY-MM-DD[ |T]HH:mm[:ss[.1-3位]][Z|±HH:mm];日期必须有效。不带 Z/offset 时由服务端按 --chat 对应群或全局时区解释。
44
+ 登录文件: ~/.config/surowan-utils/credentials.json(未加密,包含 API key)。
45
+ login 只保存凭据;doctor 用只读请求验证连接与鉴权,不调用模型。
46
+ config show 与 prompt get 只读取脱敏后的配置与 prompt,不修改服务端状态。
47
+ models list 只列当前生效、已注册且接受文本的模型及支持的 thinking_levels,不探测上游或调用模型。
48
+ 临时模型和思考级别不写配置;指定模型但未指定 thinking-level 时用目标模型支持的最低级别。预检与 POST 使用同一模型选择,未覆盖时继承当前群配置。
49
+ invocation media 顺序下载到 mkdtemp 新建目录,stdout 输出 manifest,每个文件不超过 20MiB、总计不超过 100MiB 且至多 32 项。
50
+ invocation list 默认保持旧输出;服务端返回 matched_messages 时人类可读输出会追加命中摘要。
51
+ invocation replay 先请求 replay-preflight;不可重放或不允许 prompt 覆盖时不发送 replay 请求;--before-send 只选择回放边界,不属于 prompt 覆盖。切片重放必须显式给出 --confirm-paid:缺失时在读取 stdin 与发送任何请求前以 confirm_paid_required(退出码 2)拒绝;未切片的 replay 无需该 flag。
52
+ 退出码: 0 成功;1 请求、replay、超时或凭据文件失败;2 参数/输入/凭据不合法。错误以 JSON 写到 stderr。`;
53
+ export async function runCli(argv, options = {}) {
54
+ const env = options.env ?? process.env;
55
+ const io = options.io ?? defaultIo();
56
+ const secrets = [env.SUROWAN_API_KEY, ...argumentKeys(argv)];
57
+ try {
58
+ const parsed = parseCli(argv);
59
+ if (parsed.help) {
60
+ io.stdout(`${USAGE}\n`);
61
+ return 0;
62
+ }
63
+ const path = credentialsPath(options.homeDir);
64
+ if (parsed.command?.kind === 'login') {
65
+ return await login(parsed, env, io, path, secrets);
66
+ }
67
+ const explicitEndpoint = parsed.endpointRaw ?? env.SUROWAN_ENDPOINT;
68
+ const explicitKey = parsed.apiKeyRaw ?? env.SUROWAN_API_KEY;
69
+ const saved = explicitEndpoint !== undefined && explicitKey !== undefined ? undefined : await readCredentials(path);
70
+ secrets.push(saved?.apiKey);
71
+ const resolved = resolveCli(parsed, env, saved);
72
+ secrets.push(resolved.apiKey);
73
+ const client = new AdminClient({
74
+ baseUrl: resolved.endpoint,
75
+ apiKey: resolved.apiKey,
76
+ defaultTimeoutMs: resolved.timeoutMs,
77
+ });
78
+ // Everything except `invocation list` writes JSON documents, so string
79
+ // tokens can be redacted without touching the document structure.
80
+ const output = redactingIo(io, secrets, resolved.json || resolved.command.kind !== 'list');
81
+ if (resolved.command.kind === 'doctor') {
82
+ const raw = await client.get('api/invocations', new URLSearchParams({ limit: '1' }));
83
+ if (!isRecord(raw) ||
84
+ !Array.isArray(raw.items) ||
85
+ (raw.next_cursor !== null && typeof raw.next_cursor !== 'string')) {
86
+ throw new CliError('invalid_response', 'doctor expected an invocation list response from the Admin API');
87
+ }
88
+ const result = { status: 'ok', endpoint: resolved.endpoint.href, credential_sources: resolved.credentialSources };
89
+ output.stdout(resolved.json
90
+ ? `${JSON.stringify(result)}\n`
91
+ : `Login is valid; Admin API connection and authentication succeeded (${resolved.endpoint.href}).\n`);
92
+ return 0;
93
+ }
94
+ if (resolved.command.kind === 'login') {
95
+ throw usageError('invalid_command', 'login cannot execute an invocation request');
96
+ }
97
+ return await executeCommand(resolved.command, {
98
+ json: resolved.json,
99
+ io: output,
100
+ timeoutMs: resolved.timeoutMs,
101
+ redact: (text) => redactSecrets(text, secrets),
102
+ }, client);
103
+ }
104
+ catch (error) {
105
+ return reportError(error, io, secrets);
106
+ }
107
+ }
108
+ async function login(parsed, env, io, path, secrets) {
109
+ if (parsed.command?.kind !== 'login') {
110
+ throw usageError('invalid_command', 'expected login');
111
+ }
112
+ const timeoutMs = parseTimeout(parsed.timeoutRaw, false);
113
+ let endpointRaw = parsed.endpointRaw ?? env.SUROWAN_ENDPOINT;
114
+ let apiKey = parsed.apiKeyRaw ?? env.SUROWAN_API_KEY;
115
+ if (parsed.command.apiKeyStdin && apiKey !== undefined) {
116
+ throw usageError('conflicting_key_input', '--api-key-stdin cannot be combined with --api-key or SUROWAN_API_KEY');
117
+ }
118
+ if (endpointRaw === undefined) {
119
+ endpointRaw = await promptLoginField('Admin API endpoint: ', false, timeoutMs);
120
+ }
121
+ const endpoint = parseEndpoint(endpointRaw).href;
122
+ if (parsed.command.apiKeyStdin) {
123
+ apiKey = await readLoginKey(timeoutMs);
124
+ }
125
+ else if (apiKey === undefined) {
126
+ apiKey = await promptLoginField('API key (hidden): ', true, timeoutMs);
127
+ }
128
+ secrets.push(apiKey);
129
+ await writeCredentials(path, { endpoint, apiKey });
130
+ const output = redactingIo(io, secrets, parsed.json);
131
+ output.stdout(parsed.json
132
+ ? `${JSON.stringify({ status: 'saved', endpoint, credentials_file: path })}\n`
133
+ : 'Login credentials saved. Run surowan-utils doctor to verify connection and authentication.\n');
134
+ return 0;
135
+ }
136
+ /** parseArgs can fail before ParsedCli exists; retain even raw key flags for redaction. */
137
+ function argumentKeys(argv) {
138
+ const keys = [];
139
+ for (let index = 0; index < argv.length; index += 1) {
140
+ const token = argv[index];
141
+ if (token?.startsWith('--api-key=')) {
142
+ keys.push(token.slice('--api-key='.length));
143
+ }
144
+ else if (token === '--api-key' && argv[index + 1] !== undefined) {
145
+ keys.push(argv[index + 1] ?? '');
146
+ }
147
+ }
148
+ return keys;
149
+ }
150
+ /** Redact JSON string tokens without touching structural quotes, braces or numbers. */
151
+ function redactingIo(io, secrets, jsonOutput) {
152
+ return {
153
+ stdout: (text) => io.stdout(jsonOutput
154
+ ? text.replace(/"(?:\\.|[^"\\])*"/g, (token) => JSON.stringify(redactSecrets(JSON.parse(token), secrets)))
155
+ : redactSecrets(text, secrets)),
156
+ stderr: (text) => io.stderr(redactSecrets(text, secrets)),
157
+ };
158
+ }
159
+ function reportError(error, io, secrets) {
160
+ const cli = error instanceof CliError ? error : undefined;
161
+ // A server-provided error string becomes the CliError code; it is just as
162
+ // capable of echoing the key as the message is.
163
+ const code = redactSecrets(cli?.code ?? 'internal_error', secrets);
164
+ const rawMessage = cli?.message ?? (error instanceof Error ? error.message : 'unexpected failure');
165
+ io.stderr(`${JSON.stringify({ error: code, message: redactSecrets(rawMessage, secrets) })}\n`);
166
+ return cli?.exitCode ?? 1;
167
+ }
168
+ /**
169
+ * The API key must never appear in output, even if a server echoes it back.
170
+ * Also used for files the CLI persists (the media manifest), so both stdout and
171
+ * on-disk metadata share one redaction contract.
172
+ */
173
+ export function redactSecrets(text, secrets) {
174
+ let redacted = text;
175
+ for (const secret of secrets) {
176
+ if (secret === undefined || secret.length === 0) {
177
+ continue;
178
+ }
179
+ // Replace the JSON-escaped form first: inside a JSON document that is the
180
+ // form actually written, and an escaped match cannot cut through a
181
+ // structural quote (a raw match on a key ending in `\` can).
182
+ const escaped = JSON.stringify(secret).slice(1, -1);
183
+ if (escaped !== secret) {
184
+ redacted = redacted.split(escaped).join('[redacted]');
185
+ }
186
+ redacted = redacted.split(secret).join('[redacted]');
187
+ }
188
+ return redacted;
189
+ }
190
+ function defaultIo() {
191
+ return {
192
+ stdout: (text) => process.stdout.write(text),
193
+ stderr: (text) => process.stderr.write(text),
194
+ };
195
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@tuuject/surowan-utils",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Plastic Wan Admin API 工具客户端",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/tuuject/surowan.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "registry": "https://registry.npmjs.org/"
15
+ },
16
+ "engines": {
17
+ "node": ">=24.0.0"
18
+ },
19
+ "bin": {
20
+ "surowan-utils": "./dist/bin.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json"
28
+ }
6
29
  }