@tuuject/surowan-utils 0.0.0-canary.24.1.g5ba29720fe68
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/LICENSE +201 -0
- package/README.md +250 -0
- package/dist/args.d.ts +96 -0
- package/dist/args.js +618 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/client.d.ts +36 -0
- package/dist/client.js +228 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +403 -0
- package/dist/credentials.d.ts +40 -0
- package/dist/credentials.js +172 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +15 -0
- package/dist/login.d.ts +2 -0
- package/dist/login.js +65 -0
- package/dist/media.d.ts +9 -0
- package/dist/media.js +220 -0
- package/dist/run.d.ts +14 -0
- package/dist/run.js +195 -0
- package/package.json +29 -0
package/dist/args.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { parseEndpoint } from "./client.js";
|
|
3
|
+
import { validateApiKey } from "./credentials.js";
|
|
4
|
+
import { usageError } from "./errors.js";
|
|
5
|
+
export const MAX_PAGE_LIMIT = 100;
|
|
6
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
+
export const DEFAULT_REPLAY_TIMEOUT_MS = 300_000;
|
|
8
|
+
export const MAX_TIMEOUT_MS = 3_600_000;
|
|
9
|
+
const MAX_SIGNED_64 = 9223372036854775807n;
|
|
10
|
+
const MIN_SIGNED_64 = -9223372036854775808n;
|
|
11
|
+
const DECIMAL_PATTERN = /^\d{1,19}$/;
|
|
12
|
+
const SIGNED_DECIMAL_PATTERN = /^-?\d{1,19}$/;
|
|
13
|
+
const STATE_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
14
|
+
const TIMEOUT_PATTERN = /^\d{1,8}$/;
|
|
15
|
+
/** `YYYY-MM-DD[ |T]HH:mm[:ss[.1-3f]][Z|±HH:mm]`; the offset is optional. */
|
|
16
|
+
const TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|[+-]\d{2}:\d{2})?$/;
|
|
17
|
+
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
18
|
+
export const MAX_SEARCH_LENGTH = 100;
|
|
19
|
+
const OPTIONS = {
|
|
20
|
+
endpoint: { type: 'string' },
|
|
21
|
+
'api-key': { type: 'string' },
|
|
22
|
+
'api-key-stdin': { type: 'boolean' },
|
|
23
|
+
json: { type: 'boolean' },
|
|
24
|
+
'timeout-ms': { type: 'string' },
|
|
25
|
+
limit: { type: 'string' },
|
|
26
|
+
cursor: { type: 'string' },
|
|
27
|
+
state: { type: 'string' },
|
|
28
|
+
chat: { type: 'string' },
|
|
29
|
+
search: { type: 'string' },
|
|
30
|
+
at: { type: 'string' },
|
|
31
|
+
from: { type: 'string' },
|
|
32
|
+
to: { type: 'string' },
|
|
33
|
+
source: { type: 'string' },
|
|
34
|
+
variant: { type: 'string' },
|
|
35
|
+
'global-prompt': { type: 'string' },
|
|
36
|
+
'group-prompt': { type: 'string' },
|
|
37
|
+
'before-send': { type: 'string' },
|
|
38
|
+
provider: { type: 'string' },
|
|
39
|
+
model: { type: 'string' },
|
|
40
|
+
'thinking-level': { type: 'string' },
|
|
41
|
+
'confirm-paid': { type: 'boolean' },
|
|
42
|
+
help: { type: 'boolean', short: 'h' },
|
|
43
|
+
};
|
|
44
|
+
// Telegram chat ids are negative; parseArgs would read a separate `-123` token
|
|
45
|
+
// as a dangling option, so a negative numeric value is folded into `--opt=-123`.
|
|
46
|
+
const NEGATIVE_NUMBER_PATTERN = /^-\d+$/;
|
|
47
|
+
const NEGATIVE_VALUE_OPTIONS = new Set(['--chat', '--cursor', '--limit', '--timeout-ms', '--before-send']);
|
|
48
|
+
const MODEL_FLAGS = ['provider', 'model', 'thinking-level'];
|
|
49
|
+
const INSPECTION_FLAGS = [
|
|
50
|
+
...MODEL_FLAGS,
|
|
51
|
+
'limit',
|
|
52
|
+
'cursor',
|
|
53
|
+
'state',
|
|
54
|
+
'chat',
|
|
55
|
+
'source',
|
|
56
|
+
'variant',
|
|
57
|
+
'global-prompt',
|
|
58
|
+
'group-prompt',
|
|
59
|
+
'search',
|
|
60
|
+
'at',
|
|
61
|
+
'from',
|
|
62
|
+
'to',
|
|
63
|
+
'before-send',
|
|
64
|
+
];
|
|
65
|
+
const PROMPT_PART_FLAGS = ['global-prompt', 'group-prompt'];
|
|
66
|
+
/** `invocation list` time filters; every other subcommand rejects them. */
|
|
67
|
+
const TIME_FILTER_FLAGS = ['search', 'at', 'from', 'to'];
|
|
68
|
+
/** Only `invocation preflight` and `invocation replay` accept `--before-send`. */
|
|
69
|
+
const PREFLIGHT_REJECTED_FLAGS = INSPECTION_FLAGS.filter((flag) => flag !== 'before-send' && !MODEL_FLAGS.includes(flag));
|
|
70
|
+
/**
|
|
71
|
+
* Only `invocation replay` accepts `--confirm-paid`: a sliced replay
|
|
72
|
+
* (`--before-send`) is a real, billed model call, so the acknowledgement is
|
|
73
|
+
* meaningful nowhere else — every read-only subcommand rejects it, and the
|
|
74
|
+
* replay itself refuses a slice without it before any prompt read or request.
|
|
75
|
+
*/
|
|
76
|
+
const CONFIRM_PAID_FLAGS = ['confirm-paid'];
|
|
77
|
+
export function parseCli(argv) {
|
|
78
|
+
const parsed = parseArgsStrict(argv);
|
|
79
|
+
const values = {
|
|
80
|
+
endpoint: parsed.values.endpoint,
|
|
81
|
+
apiKey: parsed.values['api-key'],
|
|
82
|
+
timeout: parsed.values['timeout-ms'],
|
|
83
|
+
json: parsed.values.json === true,
|
|
84
|
+
help: parsed.values.help === true,
|
|
85
|
+
flags: {
|
|
86
|
+
'api-key-stdin': parsed.values['api-key-stdin'],
|
|
87
|
+
provider: parsed.values.provider,
|
|
88
|
+
model: parsed.values.model,
|
|
89
|
+
'thinking-level': parsed.values['thinking-level'],
|
|
90
|
+
source: parsed.values.source,
|
|
91
|
+
variant: parsed.values.variant,
|
|
92
|
+
'global-prompt': parsed.values['global-prompt'],
|
|
93
|
+
'group-prompt': parsed.values['group-prompt'],
|
|
94
|
+
limit: parsed.values.limit,
|
|
95
|
+
cursor: parsed.values.cursor,
|
|
96
|
+
state: parsed.values.state,
|
|
97
|
+
chat: parsed.values.chat,
|
|
98
|
+
search: parsed.values.search,
|
|
99
|
+
at: parsed.values.at,
|
|
100
|
+
from: parsed.values.from,
|
|
101
|
+
to: parsed.values.to,
|
|
102
|
+
'before-send': parsed.values['before-send'],
|
|
103
|
+
'confirm-paid': parsed.values['confirm-paid'],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
help: values.help,
|
|
108
|
+
json: values.json,
|
|
109
|
+
command: values.help ? undefined : parseCommand(parsed.positionals, values.flags),
|
|
110
|
+
endpointRaw: values.endpoint,
|
|
111
|
+
apiKeyRaw: values.apiKey,
|
|
112
|
+
timeoutRaw: values.timeout,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function resolveCli(parsed, env, saved) {
|
|
116
|
+
const command = parsed.command;
|
|
117
|
+
if (command === undefined) {
|
|
118
|
+
throw usageError('missing_command', 'usage: surowan-utils login|doctor|models|config|prompt|invocation');
|
|
119
|
+
}
|
|
120
|
+
const endpointRaw = parsed.endpointRaw ?? env.SUROWAN_ENDPOINT ?? saved?.endpoint;
|
|
121
|
+
if (endpointRaw === undefined || endpointRaw.trim().length === 0) {
|
|
122
|
+
throw usageError('missing_endpoint', 'run surowan-utils login or set --endpoint / SUROWAN_ENDPOINT');
|
|
123
|
+
}
|
|
124
|
+
const endpoint = parseEndpoint(endpointRaw);
|
|
125
|
+
const explicitKey = parsed.apiKeyRaw ?? env.SUROWAN_API_KEY;
|
|
126
|
+
// A saved key belongs to its saved endpoint. An endpoint override alone must
|
|
127
|
+
// never send that key to another service, even if both URLs use HTTPS.
|
|
128
|
+
const savedKey = saved !== undefined && parseEndpoint(saved.endpoint).href === endpoint.href ? saved.apiKey : undefined;
|
|
129
|
+
const apiKey = explicitKey ?? savedKey;
|
|
130
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
131
|
+
throw usageError('missing_api_key', 'run surowan-utils login or supply an API key for this endpoint');
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
command,
|
|
135
|
+
endpoint,
|
|
136
|
+
apiKey: validateApiKey(apiKey),
|
|
137
|
+
json: parsed.json,
|
|
138
|
+
timeoutMs: parseTimeout(parsed.timeoutRaw, command.kind === 'replay'),
|
|
139
|
+
credentialSources: {
|
|
140
|
+
endpoint: parsed.endpointRaw !== undefined ? 'argument' : env.SUROWAN_ENDPOINT !== undefined ? 'environment' : 'file',
|
|
141
|
+
api_key: parsed.apiKeyRaw !== undefined ? 'argument' : env.SUROWAN_API_KEY !== undefined ? 'environment' : 'file',
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function parseArgsStrict(argv) {
|
|
146
|
+
try {
|
|
147
|
+
return parseArgs({ args: normalizeArgv(argv), options: OPTIONS, allowPositionals: true, strict: true });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw usageError('invalid_arguments', sanitizeArgumentMessage(error));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function normalizeArgv(argv) {
|
|
154
|
+
const normalized = [];
|
|
155
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
156
|
+
const token = argv[index];
|
|
157
|
+
if (token === undefined) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const next = argv[index + 1];
|
|
161
|
+
if (NEGATIVE_VALUE_OPTIONS.has(token) && next !== undefined && NEGATIVE_NUMBER_PATTERN.test(next)) {
|
|
162
|
+
normalized.push(`${token}=${next}`);
|
|
163
|
+
index += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
normalized.push(token);
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
/** Node's parse error can quote an `=value`; that value is never echoed back. */
|
|
171
|
+
function sanitizeArgumentMessage(error) {
|
|
172
|
+
const message = error instanceof Error ? error.message : 'invalid arguments';
|
|
173
|
+
const sanitized = message.replace(/=([^\s'"]*)/g, '').trim();
|
|
174
|
+
return sanitized.length > 0 ? sanitized : 'invalid arguments';
|
|
175
|
+
}
|
|
176
|
+
function parseCommand(positionals, flags) {
|
|
177
|
+
const [group, subcommand, ...rest] = positionals;
|
|
178
|
+
if (group === undefined) {
|
|
179
|
+
throw usageError('missing_command', 'usage: surowan-utils login|doctor|models|config|prompt|invocation');
|
|
180
|
+
}
|
|
181
|
+
if (group === 'login' || group === 'doctor') {
|
|
182
|
+
if (positionals.length !== 1) {
|
|
183
|
+
throw usageError('unexpected_argument', `${group} takes no positional arguments`);
|
|
184
|
+
}
|
|
185
|
+
rejectUnsupportedFlags(flags, [...INSPECTION_FLAGS, ...CONFIRM_PAID_FLAGS]);
|
|
186
|
+
if (group === 'doctor') {
|
|
187
|
+
rejectUnsupportedFlags(flags, ['api-key-stdin']);
|
|
188
|
+
return { kind: 'doctor' };
|
|
189
|
+
}
|
|
190
|
+
return { kind: 'login', apiKeyStdin: flags['api-key-stdin'] === true };
|
|
191
|
+
}
|
|
192
|
+
rejectUnsupportedFlags(flags, ['api-key-stdin']);
|
|
193
|
+
switch (group) {
|
|
194
|
+
case 'models':
|
|
195
|
+
if (subcommand === undefined) {
|
|
196
|
+
throw usageError('missing_subcommand', 'usage: surowan-utils models list');
|
|
197
|
+
}
|
|
198
|
+
if (subcommand !== 'list') {
|
|
199
|
+
throw usageError('unknown_subcommand', 'models subcommand must be list');
|
|
200
|
+
}
|
|
201
|
+
if (rest.length > 0) {
|
|
202
|
+
throw usageError('unexpected_argument', 'models list takes no positional arguments');
|
|
203
|
+
}
|
|
204
|
+
rejectUnsupportedFlags(flags, [...INSPECTION_FLAGS, ...CONFIRM_PAID_FLAGS]);
|
|
205
|
+
return { kind: 'models-list' };
|
|
206
|
+
case 'config':
|
|
207
|
+
return parseConfigShow(subcommand, rest, flags);
|
|
208
|
+
case 'prompt':
|
|
209
|
+
return parsePromptGet(subcommand, rest, flags);
|
|
210
|
+
case 'invocation':
|
|
211
|
+
return parseInvocation(subcommand, rest, flags);
|
|
212
|
+
default:
|
|
213
|
+
throw usageError('unknown_command', 'command must be login, doctor, models, config, prompt, or invocation');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function parseConfigShow(subcommand, rest, flags) {
|
|
217
|
+
if (subcommand === undefined) {
|
|
218
|
+
throw usageError('missing_subcommand', 'usage: surowan-utils config show');
|
|
219
|
+
}
|
|
220
|
+
if (subcommand !== 'show') {
|
|
221
|
+
throw usageError('unknown_subcommand', 'config subcommand must be show');
|
|
222
|
+
}
|
|
223
|
+
if (rest.length > 0) {
|
|
224
|
+
throw usageError('unexpected_argument', 'config show takes no positional arguments');
|
|
225
|
+
}
|
|
226
|
+
rejectUnsupportedFlags(flags, [
|
|
227
|
+
...MODEL_FLAGS,
|
|
228
|
+
'limit',
|
|
229
|
+
'cursor',
|
|
230
|
+
'state',
|
|
231
|
+
'chat',
|
|
232
|
+
'variant',
|
|
233
|
+
...PROMPT_PART_FLAGS,
|
|
234
|
+
...TIME_FILTER_FLAGS,
|
|
235
|
+
'before-send',
|
|
236
|
+
...CONFIRM_PAID_FLAGS,
|
|
237
|
+
]);
|
|
238
|
+
return { kind: 'config-show', source: parseSource(flags.source) };
|
|
239
|
+
}
|
|
240
|
+
function parsePromptGet(subcommand, rest, flags) {
|
|
241
|
+
if (subcommand === undefined) {
|
|
242
|
+
throw usageError('missing_subcommand', 'usage: surowan-utils prompt get global|group');
|
|
243
|
+
}
|
|
244
|
+
if (subcommand !== 'get') {
|
|
245
|
+
throw usageError('unknown_subcommand', 'prompt subcommand must be get');
|
|
246
|
+
}
|
|
247
|
+
const [scope, ...extra] = rest;
|
|
248
|
+
if (scope !== 'global' && scope !== 'group') {
|
|
249
|
+
throw usageError('unknown_subcommand', 'prompt get scope must be global or group');
|
|
250
|
+
}
|
|
251
|
+
if (extra.length > 0) {
|
|
252
|
+
throw usageError('unexpected_argument', 'prompt get takes exactly one scope');
|
|
253
|
+
}
|
|
254
|
+
rejectUnsupportedFlags(flags, [
|
|
255
|
+
...MODEL_FLAGS,
|
|
256
|
+
'limit',
|
|
257
|
+
'cursor',
|
|
258
|
+
'state',
|
|
259
|
+
'variant',
|
|
260
|
+
...PROMPT_PART_FLAGS,
|
|
261
|
+
...TIME_FILTER_FLAGS,
|
|
262
|
+
'before-send',
|
|
263
|
+
...CONFIRM_PAID_FLAGS,
|
|
264
|
+
]);
|
|
265
|
+
const source = parseSource(flags.source);
|
|
266
|
+
if (scope === 'global') {
|
|
267
|
+
if (flags.chat !== undefined) {
|
|
268
|
+
throw usageError('unexpected_option', '--chat is only valid for prompt get group');
|
|
269
|
+
}
|
|
270
|
+
return { kind: 'prompt-get', scope, chat: undefined, source };
|
|
271
|
+
}
|
|
272
|
+
if (flags.chat === undefined) {
|
|
273
|
+
throw usageError('missing_argument', 'prompt get group requires --chat <telegram chat id>');
|
|
274
|
+
}
|
|
275
|
+
return { kind: 'prompt-get', scope, chat: parseDecimalId(flags.chat, 'chat', true), source };
|
|
276
|
+
}
|
|
277
|
+
function parseInvocation(subcommand, rest, flags) {
|
|
278
|
+
switch (subcommand) {
|
|
279
|
+
case 'list':
|
|
280
|
+
return parseList(rest, flags);
|
|
281
|
+
case 'get':
|
|
282
|
+
return parseGet(rest, flags);
|
|
283
|
+
case 'prompts':
|
|
284
|
+
return parseIdOnly('prompts', rest, flags);
|
|
285
|
+
case 'preflight':
|
|
286
|
+
return parseIdOnly('preflight', rest, flags);
|
|
287
|
+
case 'media':
|
|
288
|
+
return parseMedia(rest, flags);
|
|
289
|
+
case 'replay':
|
|
290
|
+
return parseReplay(rest, flags);
|
|
291
|
+
case undefined:
|
|
292
|
+
throw usageError('missing_subcommand', 'usage: surowan-utils invocation list|get|prompts|preflight|media|replay');
|
|
293
|
+
default:
|
|
294
|
+
throw usageError('unknown_subcommand', 'invocation subcommand must be list, get, prompts, preflight, media, or replay');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function parseList(rest, flags) {
|
|
298
|
+
if (rest.length > 0) {
|
|
299
|
+
throw usageError('unexpected_argument', 'invocation list takes no positional arguments');
|
|
300
|
+
}
|
|
301
|
+
rejectUnsupportedFlags(flags, [
|
|
302
|
+
...MODEL_FLAGS,
|
|
303
|
+
'source',
|
|
304
|
+
'variant',
|
|
305
|
+
...PROMPT_PART_FLAGS,
|
|
306
|
+
'before-send',
|
|
307
|
+
...CONFIRM_PAID_FLAGS,
|
|
308
|
+
]);
|
|
309
|
+
const at = parseTimeFilter(flags.at, 'at');
|
|
310
|
+
const from = parseTimeFilter(flags.from, 'from');
|
|
311
|
+
const to = parseTimeFilter(flags.to, 'to');
|
|
312
|
+
if (at !== undefined && (from !== undefined || to !== undefined)) {
|
|
313
|
+
throw usageError('invalid_time_range', '--at cannot be combined with --from or --to');
|
|
314
|
+
}
|
|
315
|
+
validateTimeRange(from, to);
|
|
316
|
+
return {
|
|
317
|
+
kind: 'list',
|
|
318
|
+
limit: flags.limit === undefined ? undefined : parseLimit(flags.limit),
|
|
319
|
+
cursor: flags.cursor === undefined ? undefined : parseDecimalId(flags.cursor, 'cursor', false),
|
|
320
|
+
state: flags.state === undefined ? undefined : parseState(flags.state),
|
|
321
|
+
chat: flags.chat === undefined ? undefined : parseDecimalId(flags.chat, 'chat', true),
|
|
322
|
+
search: parseSearch(flags.search),
|
|
323
|
+
at,
|
|
324
|
+
from,
|
|
325
|
+
to,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function parseGet(rest, flags) {
|
|
329
|
+
const [id, ...extra] = rest;
|
|
330
|
+
if (id === undefined) {
|
|
331
|
+
throw usageError('missing_argument', 'invocation get requires an invocation id');
|
|
332
|
+
}
|
|
333
|
+
if (extra.length > 0) {
|
|
334
|
+
throw usageError('unexpected_argument', 'invocation get takes exactly one id');
|
|
335
|
+
}
|
|
336
|
+
rejectUnsupportedFlags(flags, [...INSPECTION_FLAGS, ...CONFIRM_PAID_FLAGS]);
|
|
337
|
+
return { kind: 'get', id: parseDecimalId(id, 'id', false) };
|
|
338
|
+
}
|
|
339
|
+
function parseIdOnly(kind, rest, flags) {
|
|
340
|
+
const [id, ...extra] = rest;
|
|
341
|
+
if (id === undefined) {
|
|
342
|
+
throw usageError('missing_argument', `invocation ${kind} requires an invocation id`);
|
|
343
|
+
}
|
|
344
|
+
if (extra.length > 0) {
|
|
345
|
+
throw usageError('unexpected_argument', `invocation ${kind} takes exactly one id`);
|
|
346
|
+
}
|
|
347
|
+
if (kind === 'preflight') {
|
|
348
|
+
rejectUnsupportedFlags(flags, [...PREFLIGHT_REJECTED_FLAGS, ...CONFIRM_PAID_FLAGS]);
|
|
349
|
+
return {
|
|
350
|
+
kind,
|
|
351
|
+
id: parseDecimalId(id, 'id', false),
|
|
352
|
+
beforeSendId: parseBeforeSend(flags['before-send']),
|
|
353
|
+
...parseReplayModel(flags),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
rejectUnsupportedFlags(flags, [...INSPECTION_FLAGS, ...CONFIRM_PAID_FLAGS]);
|
|
357
|
+
return { kind, id: parseDecimalId(id, 'id', false) };
|
|
358
|
+
}
|
|
359
|
+
function parseMedia(rest, flags) {
|
|
360
|
+
const [id, ...extra] = rest;
|
|
361
|
+
if (id === undefined) {
|
|
362
|
+
throw usageError('missing_argument', 'invocation media requires an invocation id');
|
|
363
|
+
}
|
|
364
|
+
if (extra.length > 0) {
|
|
365
|
+
throw usageError('unexpected_argument', 'invocation media takes exactly one id');
|
|
366
|
+
}
|
|
367
|
+
rejectUnsupportedFlags(flags, [
|
|
368
|
+
...MODEL_FLAGS,
|
|
369
|
+
'limit',
|
|
370
|
+
'cursor',
|
|
371
|
+
'state',
|
|
372
|
+
'chat',
|
|
373
|
+
'source',
|
|
374
|
+
...PROMPT_PART_FLAGS,
|
|
375
|
+
...TIME_FILTER_FLAGS,
|
|
376
|
+
'before-send',
|
|
377
|
+
...CONFIRM_PAID_FLAGS,
|
|
378
|
+
]);
|
|
379
|
+
return {
|
|
380
|
+
kind: 'media',
|
|
381
|
+
id: parseDecimalId(id, 'id', false),
|
|
382
|
+
variant: parseVariant(flags.variant),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function parseReplay(rest, flags) {
|
|
386
|
+
const [id, ...extra] = rest;
|
|
387
|
+
if (id === undefined) {
|
|
388
|
+
throw usageError('missing_argument', 'invocation replay requires an invocation id');
|
|
389
|
+
}
|
|
390
|
+
if (extra.length > 0) {
|
|
391
|
+
throw usageError('unexpected_argument', 'invocation replay takes exactly one id');
|
|
392
|
+
}
|
|
393
|
+
rejectUnsupportedFlags(flags, ['limit', 'cursor', 'state', 'chat', 'source', 'variant', ...TIME_FILTER_FLAGS]);
|
|
394
|
+
if (flags['global-prompt'] === '-' && flags['group-prompt'] === '-') {
|
|
395
|
+
throw usageError('conflicting_prompt_input', 'only one of --global-prompt and --group-prompt can read from stdin');
|
|
396
|
+
}
|
|
397
|
+
const beforeSendId = parseBeforeSend(flags['before-send']);
|
|
398
|
+
// A slice rebuilds a real window and replays it against the current model,
|
|
399
|
+
// so it is billed; refuse it here — before any prompt read or HTTP request —
|
|
400
|
+
// unless the operator explicitly acknowledged the cost. An unsliced replay
|
|
401
|
+
// keeps its pre-existing behavior and never requires the flag.
|
|
402
|
+
if (beforeSendId !== undefined && flags['confirm-paid'] !== true) {
|
|
403
|
+
throw usageError('confirm_paid_required', 'invocation replay --before-send requires --confirm-paid to confirm the billed model call');
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
kind: 'replay',
|
|
407
|
+
id: parseDecimalId(id, 'id', false),
|
|
408
|
+
globalPromptSource: flags['global-prompt'],
|
|
409
|
+
groupPromptSource: flags['group-prompt'],
|
|
410
|
+
beforeSendId,
|
|
411
|
+
...parseReplayModel(flags),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function parseReplayModel(flags) {
|
|
415
|
+
if ((flags.provider === undefined) !== (flags.model === undefined)) {
|
|
416
|
+
throw usageError('invalid_model_override', '--provider and --model must be supplied together');
|
|
417
|
+
}
|
|
418
|
+
for (const key of ['provider', 'model']) {
|
|
419
|
+
const value = flags[key];
|
|
420
|
+
if (value !== undefined && (value.trim().length === 0 || value.length > 256 || /\p{Cc}/u.test(value))) {
|
|
421
|
+
throw usageError('invalid_model_override', `${key} must be 1 to 256 characters without control characters`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const thinkingLevel = flags['thinking-level'];
|
|
425
|
+
if (thinkingLevel !== undefined &&
|
|
426
|
+
!['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(thinkingLevel)) {
|
|
427
|
+
throw usageError('invalid_thinking_level', 'thinking-level must be off, minimal, low, medium, high, xhigh, or max');
|
|
428
|
+
}
|
|
429
|
+
return { provider: flags.provider, model: flags.model, thinkingLevel };
|
|
430
|
+
}
|
|
431
|
+
function rejectUnsupportedFlags(flags, unsupported) {
|
|
432
|
+
for (const key of unsupported) {
|
|
433
|
+
if (flags[key] !== undefined) {
|
|
434
|
+
throw usageError('unexpected_option', `--${key} is not valid for this subcommand`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function parseSource(text) {
|
|
439
|
+
if (text === undefined) {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
if (text !== 'active' && text !== 'file') {
|
|
443
|
+
throw usageError('invalid_source', 'source must be active or file');
|
|
444
|
+
}
|
|
445
|
+
return text;
|
|
446
|
+
}
|
|
447
|
+
function parseVariant(text) {
|
|
448
|
+
if (text === undefined || text === 'original') {
|
|
449
|
+
return 'original';
|
|
450
|
+
}
|
|
451
|
+
if (text !== 'preview') {
|
|
452
|
+
throw usageError('invalid_variant', 'variant must be original or preview');
|
|
453
|
+
}
|
|
454
|
+
return text;
|
|
455
|
+
}
|
|
456
|
+
function parseLimit(text) {
|
|
457
|
+
if (!DECIMAL_PATTERN.test(text)) {
|
|
458
|
+
throw usageError('invalid_limit', 'limit must be a decimal integer');
|
|
459
|
+
}
|
|
460
|
+
const limit = Number(text);
|
|
461
|
+
if (limit < 1 || limit > MAX_PAGE_LIMIT) {
|
|
462
|
+
throw usageError('invalid_limit', `limit must be between 1 and ${MAX_PAGE_LIMIT}`);
|
|
463
|
+
}
|
|
464
|
+
return limit;
|
|
465
|
+
}
|
|
466
|
+
function parseSearch(text) {
|
|
467
|
+
if (text === undefined) {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
if (text.length === 0 || text.length > MAX_SEARCH_LENGTH) {
|
|
471
|
+
throw usageError('invalid_search', `search must be 1 to ${MAX_SEARCH_LENGTH} characters`);
|
|
472
|
+
}
|
|
473
|
+
return text;
|
|
474
|
+
}
|
|
475
|
+
function parseTimeFilter(text, label) {
|
|
476
|
+
if (text === undefined) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (parseTimeValue(text) === undefined) {
|
|
480
|
+
throw usageError(`invalid_${label}`, `${label} must be a valid date-time: YYYY-MM-DD[ |T]HH:mm[:ss[.1-3 digits]][Z|±HH:mm]`);
|
|
481
|
+
}
|
|
482
|
+
return text;
|
|
483
|
+
}
|
|
484
|
+
function parseTimeValue(text) {
|
|
485
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText, fractionText, offsetText] = TIME_PATTERN.exec(text) ?? [];
|
|
486
|
+
if (yearText === undefined ||
|
|
487
|
+
monthText === undefined ||
|
|
488
|
+
dayText === undefined ||
|
|
489
|
+
hourText === undefined ||
|
|
490
|
+
minuteText === undefined) {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
const year = Number(yearText);
|
|
494
|
+
const month = Number(monthText);
|
|
495
|
+
const day = Number(dayText);
|
|
496
|
+
if (year < 1000 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
const hour = Number(hourText);
|
|
500
|
+
const minute = Number(minuteText);
|
|
501
|
+
const second = secondText === undefined ? 0 : Number(secondText);
|
|
502
|
+
if (hour > 23 || minute > 59 || second > 59) {
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
const offsetMinutes = offsetText === undefined ? undefined : parseOffsetMinutes(offsetText);
|
|
506
|
+
if (offsetText !== undefined && offsetMinutes === undefined) {
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
year,
|
|
511
|
+
month,
|
|
512
|
+
day,
|
|
513
|
+
hour,
|
|
514
|
+
minute,
|
|
515
|
+
second,
|
|
516
|
+
millisecond: fractionText === undefined ? 0 : Number(fractionText.padEnd(3, '0')),
|
|
517
|
+
offsetMinutes,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function daysInMonth(year, month) {
|
|
521
|
+
if (month === 2 && isLeapYear(year)) {
|
|
522
|
+
return 29;
|
|
523
|
+
}
|
|
524
|
+
return MONTH_DAYS[month - 1] ?? 0;
|
|
525
|
+
}
|
|
526
|
+
function isLeapYear(year) {
|
|
527
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
528
|
+
}
|
|
529
|
+
function parseOffsetMinutes(text) {
|
|
530
|
+
if (text === 'Z') {
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
const hours = Number(text.slice(1, 3));
|
|
534
|
+
const minutes = Number(text.slice(4, 6));
|
|
535
|
+
// The Admin API accepts real zone offsets only: -14:00 through +14:00.
|
|
536
|
+
if (hours > 14 || minutes > 59 || hours * 60 + minutes > 14 * 60) {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
return (text.startsWith('-') ? -1 : 1) * (hours * 60 + minutes);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Two values are only comparable here when both carry an explicit offset. A
|
|
543
|
+
* value without one is interpreted by the server against the active chat
|
|
544
|
+
* timezone (or the global default), so the CLI must not guess an order for it.
|
|
545
|
+
* The server requires `from < to`; an empty or inverted range is rejected by
|
|
546
|
+
* both layers with the same code.
|
|
547
|
+
*/
|
|
548
|
+
function validateTimeRange(fromText, toText) {
|
|
549
|
+
if (fromText === undefined || toText === undefined) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const from = parseTimeValue(fromText);
|
|
553
|
+
const to = parseTimeValue(toText);
|
|
554
|
+
if (from === undefined || to === undefined) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (from.offsetMinutes === undefined || to.offsetMinutes === undefined) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (timeToEpochMs(to) <= timeToEpochMs(from)) {
|
|
561
|
+
throw usageError('invalid_time_range', '--from must be earlier than --to when both carry an explicit offset');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function timeToEpochMs(value) {
|
|
565
|
+
const date = new Date(0);
|
|
566
|
+
// setUTCFullYear keeps four-digit years (including years below 100) exact,
|
|
567
|
+
// unlike the Date constructor's 1900-based two-digit year handling.
|
|
568
|
+
date.setUTCFullYear(value.year, value.month - 1, value.day);
|
|
569
|
+
date.setUTCHours(value.hour, value.minute, value.second, value.millisecond);
|
|
570
|
+
return date.getTime() - (value.offsetMinutes ?? 0) * 60_000;
|
|
571
|
+
}
|
|
572
|
+
function parseBeforeSend(text) {
|
|
573
|
+
if (text === undefined) {
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
if (!DECIMAL_PATTERN.test(text)) {
|
|
577
|
+
throw usageError('invalid_before_send', 'before-send must be a positive signed 64-bit decimal integer');
|
|
578
|
+
}
|
|
579
|
+
const value = BigInt(text);
|
|
580
|
+
if (value < 1n || value > MAX_SIGNED_64) {
|
|
581
|
+
throw usageError('invalid_before_send', 'before-send must be a positive signed 64-bit decimal integer');
|
|
582
|
+
}
|
|
583
|
+
// The Admin API compares the canonical decimal form, so `0042` addresses `42`.
|
|
584
|
+
return value.toString();
|
|
585
|
+
}
|
|
586
|
+
function parseDecimalId(text, label, allowNegative) {
|
|
587
|
+
const pattern = allowNegative ? SIGNED_DECIMAL_PATTERN : DECIMAL_PATTERN;
|
|
588
|
+
if (!pattern.test(text)) {
|
|
589
|
+
throw usageError(`invalid_${label}`, `${label} must be a decimal integer`);
|
|
590
|
+
}
|
|
591
|
+
const value = BigInt(text);
|
|
592
|
+
if (value > MAX_SIGNED_64 || value < MIN_SIGNED_64) {
|
|
593
|
+
throw usageError(`invalid_${label}`, `${label} is out of signed 64-bit range`);
|
|
594
|
+
}
|
|
595
|
+
// The Admin API parses ids as signed 64-bit integers and echoes them back in
|
|
596
|
+
// canonical decimal (`42`, not `042`), so every caller must address and
|
|
597
|
+
// compare ids in that same form.
|
|
598
|
+
return value.toString();
|
|
599
|
+
}
|
|
600
|
+
function parseState(text) {
|
|
601
|
+
if (!STATE_PATTERN.test(text)) {
|
|
602
|
+
throw usageError('invalid_state', 'state filter is invalid');
|
|
603
|
+
}
|
|
604
|
+
return text;
|
|
605
|
+
}
|
|
606
|
+
export function parseTimeout(text, replay) {
|
|
607
|
+
if (text === undefined) {
|
|
608
|
+
return replay ? DEFAULT_REPLAY_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
|
609
|
+
}
|
|
610
|
+
if (!TIMEOUT_PATTERN.test(text)) {
|
|
611
|
+
throw usageError('invalid_timeout', `timeout must be an integer between 1 and ${MAX_TIMEOUT_MS} ms`);
|
|
612
|
+
}
|
|
613
|
+
const timeout = Number(text);
|
|
614
|
+
if (timeout < 1 || timeout > MAX_TIMEOUT_MS) {
|
|
615
|
+
throw usageError('invalid_timeout', `timeout must be an integer between 1 and ${MAX_TIMEOUT_MS} ms`);
|
|
616
|
+
}
|
|
617
|
+
return timeout;
|
|
618
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
/**
|
|
3
|
+
* Validates an endpoint. Plaintext http is only accepted on loopback: the API
|
|
4
|
+
* key travels in a header and must not cross the network unencrypted. URL
|
|
5
|
+
* credentials are rejected so the key can never be smuggled through a URL.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseEndpoint(raw: string): URL;
|
|
8
|
+
export interface AdminClientOptions {
|
|
9
|
+
readonly baseUrl: URL;
|
|
10
|
+
readonly apiKey: string;
|
|
11
|
+
readonly defaultTimeoutMs: number;
|
|
12
|
+
}
|
|
13
|
+
/** A successfully downloaded media body plus the headers the CLI validates. */
|
|
14
|
+
export interface MediaContent {
|
|
15
|
+
readonly bytes: Buffer;
|
|
16
|
+
readonly contentType: string | null;
|
|
17
|
+
readonly variant: string | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Minimal JSON client for the Admin API. Requests never follow redirects and
|
|
21
|
+
* are never retried; the timeout aborts the in-flight request and the response
|
|
22
|
+
* body read that follows it.
|
|
23
|
+
*/
|
|
24
|
+
export declare class AdminClient {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(options: AdminClientOptions);
|
|
27
|
+
get(path: string, query?: URLSearchParams): Promise<unknown>;
|
|
28
|
+
post(path: string, body: unknown): Promise<unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Downloads a media body. The same redirect/timeout rules as JSON requests
|
|
31
|
+
* apply; the body is bounded by `maxBytes` so an oversized or stalled stream
|
|
32
|
+
* fails fast instead of filling memory or disk.
|
|
33
|
+
*/
|
|
34
|
+
getMedia(path: string, query: URLSearchParams, maxBytes: number): Promise<MediaContent>;
|
|
35
|
+
}
|
|
36
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|