@empirical-gauzza/cli 1.0.36 → 1.0.38
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/bin/empirical.js +6 -0
- package/lib/attachmentCommands.js +388 -0
- package/lib/auth.js +16 -2
- package/lib/blueprintCommands.js +23 -3
- package/lib/constants.js +722 -670
- package/lib/empiricalTokensFormat.js +68 -0
- package/lib/help.js +23 -1
- package/lib/http.js +171 -3
- package/lib/installWizard.js +3 -1
- package/lib/installers/coreMemoryBlock.js +2 -1
- package/lib/installers/hooks.js +1312 -1312
- package/lib/installers/nativePlugin.js +1 -1
- package/lib/installers/promptBlocks.js +9 -0
- package/lib/installers/skill-variants/SKILL.both.md +20 -0
- package/lib/installers/skill-variants/SKILL.cli.md +231 -221
- package/lib/installers/skill-variants/SKILL.mcp.md +18 -0
- package/lib/memoryCommands.js +1267 -1220
- package/lib/memoryResolve.js +112 -0
- package/lib/pluginCommands.js +3 -2
- package/lib/setupCommands.js +751 -31
- package/lib/usageCommands.js +52 -0
- package/lib/workspaceCommands.js +72 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +1 -1
package/bin/empirical.js
CHANGED
|
@@ -25,6 +25,7 @@ const { showVersion } = require('../lib/help');
|
|
|
25
25
|
const { isPluginCommand, runPluginCommand } = require('../lib/pluginCommands');
|
|
26
26
|
const { runPolicyCommand } = require('../lib/policyCommands');
|
|
27
27
|
const { runSetupCommand } = require('../lib/setupCommands');
|
|
28
|
+
const { runUsageCommand } = require('../lib/usageCommands');
|
|
28
29
|
const { runBlueprintCommand } = require('../lib/blueprintCommands');
|
|
29
30
|
const { checkNativePluginUpdates } = require('../lib/pluginUpdateCheck');
|
|
30
31
|
|
|
@@ -112,6 +113,11 @@ async function main() {
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
if (args[0] === 'usage') {
|
|
117
|
+
await runUsageCommand(args.slice(1));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
if (args[0] === 'setup') {
|
|
116
122
|
await runSetupCommand(args.slice(1));
|
|
117
123
|
return;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `empirical memory attachment <list|add|get|delete|restore>` over the four REST routes in
|
|
4
|
+
// packages/server/src/routes/memories.ts (POST/GET/DELETE /:id/attachments[/:attachmentId] and
|
|
5
|
+
// the restore POST). Attachments shipped REST-only; this is the first non-REST door.
|
|
6
|
+
//
|
|
7
|
+
// Two things here are not style choices:
|
|
8
|
+
// 1. `get` writes BYTES. It never goes through httpJson, which decodes utf8 and would corrupt
|
|
9
|
+
// every non-text file, and it never prints the body to stdout unless asked (--out -), so a
|
|
10
|
+
// stray download cannot dump a binary into a terminal or a piped JSON stream.
|
|
11
|
+
// 2. The per-file cap is checked locally BEFORE the upload. The server refuses an oversized
|
|
12
|
+
// body at ingress, but only after the client has pushed 25MB+ up the wire for nothing.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { log, warn } = require('./ui');
|
|
17
|
+
const { httpJson, httpMultipart, httpDownload, printApiError } = require('./http');
|
|
18
|
+
const { parseOption, parseFlag } = require('./env');
|
|
19
|
+
const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
|
|
20
|
+
const { resolveMemoryByQuery, parseMatchFilters, printMemoryResolveError } = require('./memoryResolve');
|
|
21
|
+
|
|
22
|
+
// Mirrors MAX_ATTACHMENT_BYTES / MAX_ATTACHMENTS_PER_MEMORY in packages/shared/src/
|
|
23
|
+
// subscriptionPlans.ts. The CLI is published as its own npm package and cannot depend on the
|
|
24
|
+
// workspace, so these are copied; they are used for a friendlier local error only, never to
|
|
25
|
+
// decide anything the server does not re-check.
|
|
26
|
+
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
// Enough to label the common cases. The server sniffs magic bytes and overrides anything
|
|
29
|
+
// renderable or executable, so a wrong guess here is corrected, never trusted.
|
|
30
|
+
const MIME_BY_EXTENSION = {
|
|
31
|
+
'.txt': 'text/plain',
|
|
32
|
+
'.md': 'text/markdown',
|
|
33
|
+
'.json': 'application/json',
|
|
34
|
+
'.csv': 'text/csv',
|
|
35
|
+
'.pdf': 'application/pdf',
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.jpg': 'image/jpeg',
|
|
38
|
+
'.jpeg': 'image/jpeg',
|
|
39
|
+
'.gif': 'image/gif',
|
|
40
|
+
'.webp': 'image/webp',
|
|
41
|
+
'.svg': 'image/svg+xml',
|
|
42
|
+
'.zip': 'application/zip',
|
|
43
|
+
'.mp3': 'audio/mpeg',
|
|
44
|
+
'.m4a': 'audio/mp4',
|
|
45
|
+
'.wav': 'audio/wav',
|
|
46
|
+
'.mp4': 'video/mp4'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ATTACHMENT_SUBCOMMAND_USAGE = {
|
|
50
|
+
list: 'Usage: empirical memory attachment list (--memory-id <id> | --match <query>) [--include-deleted] [--format table] [--pretty]',
|
|
51
|
+
add: 'Usage: empirical memory attachment add (--memory-id <id> | --match <query>) --file <path> [--author-type user|agent] [--min-score 0.35]',
|
|
52
|
+
get: 'Usage: empirical memory attachment get (--memory-id <id> | --match <query>) --attachment-id <id> [--out <path>] [--force]\n --out - writes the bytes to stdout.',
|
|
53
|
+
delete: 'Usage: empirical memory attachment delete (--memory-id <id> | --match <query>) --attachment-id <id> [--confirm]\n Soft delete: the file is restorable for 30 days, then purged.',
|
|
54
|
+
restore: 'Usage: empirical memory attachment restore (--memory-id <id> | --match <query>) --attachment-id <id>'
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function printAttachmentHelp(sub) {
|
|
58
|
+
if (sub && ATTACHMENT_SUBCOMMAND_USAGE[sub]) {
|
|
59
|
+
log(ATTACHMENT_SUBCOMMAND_USAGE[sub]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
log('Files attached to a memory. Up to 20 per memory, 25MB each; a delete is reversible for 30 days.');
|
|
63
|
+
log('');
|
|
64
|
+
for (const usage of Object.values(ATTACHMENT_SUBCOMMAND_USAGE)) log(usage);
|
|
65
|
+
log('');
|
|
66
|
+
log('Shorthands: `memory attach` = `memory attachment add`, `memory attachments` = `memory attachment list`.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function guessContentType(filename) {
|
|
70
|
+
return MIME_BY_EXTENSION[path.extname(String(filename || '')).toLowerCase()] || 'application/octet-stream';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fail(code, message, extra = {}) {
|
|
74
|
+
warn(JSON.stringify({ error: true, code, message, ...extra }));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Every subcommand takes the same target: an explicit id, or a --match query resolved the same
|
|
79
|
+
// way `memory note` and `memory delete` resolve theirs.
|
|
80
|
+
async function resolveTargetMemoryId(args, accessToken, baseUrl, usage) {
|
|
81
|
+
const memoryId = parseOption(args, '--memory-id', '');
|
|
82
|
+
if (memoryId) return memoryId;
|
|
83
|
+
|
|
84
|
+
const match = parseOption(args, '--match', '');
|
|
85
|
+
if (!match) {
|
|
86
|
+
warn(usage);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const minScore = Number(parseOption(args, '--min-score', '0.35'));
|
|
91
|
+
const result = await resolveMemoryByQuery(match, minScore, accessToken, baseUrl, parseMatchFilters(args));
|
|
92
|
+
if (!result.found) {
|
|
93
|
+
printMemoryResolveError(result);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
return result.memory.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function attachmentsUrl(baseUrl, memoryId, suffix = '') {
|
|
100
|
+
return new URL(`/api/v1/memories/${encodeURIComponent(memoryId)}/attachments${suffix}`, baseUrl).toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderAttachmentsTable(rows) {
|
|
104
|
+
const header = ['ID', 'FILENAME', 'BYTES', 'TYPE', 'STATE'];
|
|
105
|
+
const body = rows.map((row) => [
|
|
106
|
+
String(row.id || ''),
|
|
107
|
+
String(row.filename || ''),
|
|
108
|
+
String(row.bytes != null ? row.bytes : ''),
|
|
109
|
+
String(row.mimeType || ''),
|
|
110
|
+
row.deletedAt ? `deleted, purges ${String(row.purgeAfter || '').slice(0, 10)}` : 'live'
|
|
111
|
+
]);
|
|
112
|
+
const widths = header.map((_h, i) => Math.max(header[i].length, ...body.map((r) => r[i].length), 1));
|
|
113
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
|
|
114
|
+
log(line(header));
|
|
115
|
+
log(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
116
|
+
for (const row of body) log(line(row));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runAttachmentList(args) {
|
|
120
|
+
const accessToken = requireAccessToken();
|
|
121
|
+
const baseUrl = resolveApiBaseUrl();
|
|
122
|
+
const memoryId = await resolveTargetMemoryId(args, accessToken, baseUrl, ATTACHMENT_SUBCOMMAND_USAGE.list);
|
|
123
|
+
|
|
124
|
+
const url = attachmentsUrl(baseUrl, memoryId) + (parseFlag(args, '--include-deleted') ? '?includeDeleted=true' : '');
|
|
125
|
+
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(accessToken));
|
|
129
|
+
} catch (error) {
|
|
130
|
+
printApiError(error);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// This route answers with `attachments` at the TOP level, not under `data` like most of the
|
|
135
|
+
// memory routes; reading response.data here would silently print nothing.
|
|
136
|
+
const attachments = Array.isArray(response && response.attachments) ? response.attachments : [];
|
|
137
|
+
|
|
138
|
+
if (parseOption(args, '--format', '') === 'table') {
|
|
139
|
+
if (!attachments.length) {
|
|
140
|
+
log('No attachments on this memory.');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
renderAttachmentsTable(attachments);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const payload = {
|
|
148
|
+
memoryId: response.memoryId || memoryId,
|
|
149
|
+
attachmentsCount: response.attachmentsCount != null ? response.attachmentsCount : attachments.length,
|
|
150
|
+
attachments
|
|
151
|
+
};
|
|
152
|
+
log(parseFlag(args, '--pretty') ? JSON.stringify(payload, null, 2) : JSON.stringify(payload));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function runAttachmentAdd(args) {
|
|
156
|
+
const filePath = parseOption(args, '--file', '');
|
|
157
|
+
if (!filePath) {
|
|
158
|
+
warn(ATTACHMENT_SUBCOMMAND_USAGE.add);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const resolved = path.resolve(filePath);
|
|
163
|
+
let stat;
|
|
164
|
+
try {
|
|
165
|
+
stat = fs.statSync(resolved);
|
|
166
|
+
} catch (_error) {
|
|
167
|
+
fail('file_not_found', `No such file: ${resolved}`);
|
|
168
|
+
}
|
|
169
|
+
if (!stat.isFile()) {
|
|
170
|
+
fail('not_a_file', `Not a file: ${resolved}`);
|
|
171
|
+
}
|
|
172
|
+
if (stat.size === 0) {
|
|
173
|
+
fail('empty_file', `File is empty: ${resolved}`);
|
|
174
|
+
}
|
|
175
|
+
// Local pre-check, see note 2 at the top of this file.
|
|
176
|
+
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
|
177
|
+
fail('attachment_too_large', `File is ${stat.size} bytes; the per-file limit is ${MAX_ATTACHMENT_BYTES} bytes (25MB).`, {
|
|
178
|
+
bytes: stat.size,
|
|
179
|
+
maxBytes: MAX_ATTACHMENT_BYTES
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const authorType = parseOption(args, '--author-type', '');
|
|
184
|
+
if (authorType && !['user', 'agent', 'system'].includes(authorType)) {
|
|
185
|
+
fail('invalid_author_type', `Invalid --author-type "${authorType}". Valid: user, agent, system`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const accessToken = requireAccessToken();
|
|
189
|
+
const baseUrl = resolveApiBaseUrl();
|
|
190
|
+
const memoryId = await resolveTargetMemoryId(args, accessToken, baseUrl, ATTACHMENT_SUBCOMMAND_USAGE.add);
|
|
191
|
+
|
|
192
|
+
const filename = path.basename(resolved);
|
|
193
|
+
let response;
|
|
194
|
+
try {
|
|
195
|
+
response = await httpMultipart(
|
|
196
|
+
attachmentsUrl(baseUrl, memoryId),
|
|
197
|
+
'POST',
|
|
198
|
+
{
|
|
199
|
+
// Same authorship contract as `memory record`: `source` is observable truth, authorType
|
|
200
|
+
// is only declared when the caller says so, never guessed.
|
|
201
|
+
fields: { source: 'cli', ...(authorType ? { authorType } : {}) },
|
|
202
|
+
file: { field: 'file', filename, contentType: guessContentType(filename), buffer: fs.readFileSync(resolved) }
|
|
203
|
+
},
|
|
204
|
+
buildApiAuthHeaders(accessToken)
|
|
205
|
+
);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
printApiError(error);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const data = (response && response.data) ? response.data : response;
|
|
212
|
+
log(JSON.stringify({ attached: true, ...data, ...(response.warnings ? { warnings: response.warnings } : {}) }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Content-Disposition from the download route is always the RFC 5987 form
|
|
216
|
+
// (filename*=UTF-8''<pct-encoded>), so that is what is parsed; the plain filename="..." form is
|
|
217
|
+
// accepted too in case a proxy rewrites it.
|
|
218
|
+
function filenameFromContentDisposition(header) {
|
|
219
|
+
const value = String(header || '');
|
|
220
|
+
const extended = value.match(/filename\*=UTF-8''([^;]+)/i);
|
|
221
|
+
if (extended) {
|
|
222
|
+
try {
|
|
223
|
+
return decodeURIComponent(extended[1]);
|
|
224
|
+
} catch (_error) {
|
|
225
|
+
// Fall through to the plain form below.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const plain = value.match(/filename="([^"]+)"/i);
|
|
229
|
+
return plain ? plain[1] : '';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function runAttachmentGet(args) {
|
|
233
|
+
const attachmentId = parseOption(args, '--attachment-id', '');
|
|
234
|
+
if (!attachmentId) {
|
|
235
|
+
warn(ATTACHMENT_SUBCOMMAND_USAGE.get);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const accessToken = requireAccessToken();
|
|
240
|
+
const baseUrl = resolveApiBaseUrl();
|
|
241
|
+
const memoryId = await resolveTargetMemoryId(args, accessToken, baseUrl, ATTACHMENT_SUBCOMMAND_USAGE.get);
|
|
242
|
+
|
|
243
|
+
let result;
|
|
244
|
+
try {
|
|
245
|
+
result = await httpDownload(
|
|
246
|
+
attachmentsUrl(baseUrl, memoryId, `/${encodeURIComponent(attachmentId)}`),
|
|
247
|
+
buildApiAuthHeaders(accessToken)
|
|
248
|
+
);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
printApiError(error);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const out = parseOption(args, '--out', '');
|
|
255
|
+
if (out === '-') {
|
|
256
|
+
process.stdout.write(result.buffer);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The server's filename is only ever used as a BASENAME. It is attacker-supplied as far as
|
|
261
|
+
// this process is concerned, and joining a path that carries .. or a drive letter straight
|
|
262
|
+
// onto cwd would let a download write outside the directory the user chose.
|
|
263
|
+
const suggested = path.basename(filenameFromContentDisposition(result.headers['content-disposition']) || '') || 'attachment';
|
|
264
|
+
let target = out ? path.resolve(out) : path.resolve(process.cwd(), suggested);
|
|
265
|
+
if (out) {
|
|
266
|
+
try {
|
|
267
|
+
if (fs.statSync(target).isDirectory()) target = path.join(target, suggested);
|
|
268
|
+
} catch (_error) {
|
|
269
|
+
// --out names a file that does not exist yet, which is the normal case.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (fs.existsSync(target) && !parseFlag(args, '--force')) {
|
|
274
|
+
fail('file_exists', `${target} already exists. Pass --force to overwrite, or --out <path> to write elsewhere.`, { path: target });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(target, result.buffer);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
fail('write_failed', `Could not write ${target}: ${error.message}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
log(JSON.stringify({ downloaded: true, memoryId, attachmentId, path: target, bytes: result.buffer.length }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runAttachmentDelete(args) {
|
|
287
|
+
const attachmentId = parseOption(args, '--attachment-id', '');
|
|
288
|
+
if (!attachmentId) {
|
|
289
|
+
warn(ATTACHMENT_SUBCOMMAND_USAGE.delete);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const accessToken = requireAccessToken();
|
|
294
|
+
const baseUrl = resolveApiBaseUrl();
|
|
295
|
+
const memoryId = await resolveTargetMemoryId(args, accessToken, baseUrl, ATTACHMENT_SUBCOMMAND_USAGE.delete);
|
|
296
|
+
|
|
297
|
+
// Same gate as `memory delete`: without --confirm this only reports what it would do.
|
|
298
|
+
if (!parseFlag(args, '--confirm')) {
|
|
299
|
+
log(JSON.stringify({ dryRun: true, wouldDelete: true, memoryId, attachmentId, note: 'Re-run with --confirm. The file stays restorable for 30 days.' }));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let response;
|
|
304
|
+
try {
|
|
305
|
+
response = await httpJson(
|
|
306
|
+
attachmentsUrl(baseUrl, memoryId, `/${encodeURIComponent(attachmentId)}`),
|
|
307
|
+
'DELETE',
|
|
308
|
+
null,
|
|
309
|
+
buildApiAuthHeaders(accessToken)
|
|
310
|
+
);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
printApiError(error);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const data = (response && response.data) ? response.data : response;
|
|
317
|
+
log(JSON.stringify({ deleted: true, ...data }));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function runAttachmentRestore(args) {
|
|
321
|
+
const attachmentId = parseOption(args, '--attachment-id', '');
|
|
322
|
+
if (!attachmentId) {
|
|
323
|
+
warn(ATTACHMENT_SUBCOMMAND_USAGE.restore);
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const accessToken = requireAccessToken();
|
|
328
|
+
const baseUrl = resolveApiBaseUrl();
|
|
329
|
+
const memoryId = await resolveTargetMemoryId(args, accessToken, baseUrl, ATTACHMENT_SUBCOMMAND_USAGE.restore);
|
|
330
|
+
|
|
331
|
+
let response;
|
|
332
|
+
try {
|
|
333
|
+
response = await httpJson(
|
|
334
|
+
attachmentsUrl(baseUrl, memoryId, `/${encodeURIComponent(attachmentId)}/restore`),
|
|
335
|
+
'POST',
|
|
336
|
+
{},
|
|
337
|
+
buildApiAuthHeaders(accessToken)
|
|
338
|
+
);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
printApiError(error);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const data = (response && response.data) ? response.data : response;
|
|
345
|
+
log(JSON.stringify({ restored: true, ...data }));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const ATTACHMENT_SUBCOMMANDS = {
|
|
349
|
+
list: runAttachmentList,
|
|
350
|
+
ls: runAttachmentList,
|
|
351
|
+
add: runAttachmentAdd,
|
|
352
|
+
upload: runAttachmentAdd,
|
|
353
|
+
get: runAttachmentGet,
|
|
354
|
+
download: runAttachmentGet,
|
|
355
|
+
delete: runAttachmentDelete,
|
|
356
|
+
rm: runAttachmentDelete,
|
|
357
|
+
remove: runAttachmentDelete,
|
|
358
|
+
restore: runAttachmentRestore
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
async function runAttachmentCommand(subArgs) {
|
|
362
|
+
const sub = subArgs[0] && !subArgs[0].startsWith('-') ? subArgs[0] : undefined;
|
|
363
|
+
if (!sub || subArgs.includes('--help') || subArgs.includes('-h')) {
|
|
364
|
+
printAttachmentHelp(sub);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const handler = ATTACHMENT_SUBCOMMANDS[sub];
|
|
369
|
+
if (!handler) {
|
|
370
|
+
warn('Unknown attachment subcommand: ' + sub);
|
|
371
|
+
printAttachmentHelp();
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return handler(subArgs.slice(1));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
ATTACHMENT_SUBCOMMAND_USAGE,
|
|
380
|
+
guessContentType,
|
|
381
|
+
filenameFromContentDisposition,
|
|
382
|
+
runAttachmentList,
|
|
383
|
+
runAttachmentAdd,
|
|
384
|
+
runAttachmentGet,
|
|
385
|
+
runAttachmentDelete,
|
|
386
|
+
runAttachmentRestore,
|
|
387
|
+
runAttachmentCommand
|
|
388
|
+
};
|
package/lib/auth.js
CHANGED
|
@@ -361,7 +361,7 @@ function inspectToken(token) {
|
|
|
361
361
|
// Gathers the same identity/workspace facts `doctor` reports (token presence/type/expiry, plus
|
|
362
362
|
// user + workspace from /api/v1/users/me), formatted for `auth status` / `whoami` as a single
|
|
363
363
|
// JSON object instead of doctor's multi-check report.
|
|
364
|
-
async function gatherAuthStatus() {
|
|
364
|
+
async function gatherAuthStatus({ includeEmpiricalTokens = false } = {}) {
|
|
365
365
|
const token = resolveAccessToken();
|
|
366
366
|
const tokenInfo = inspectToken(token);
|
|
367
367
|
|
|
@@ -398,6 +398,18 @@ async function gatherAuthStatus() {
|
|
|
398
398
|
cliWorkspace = null;
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
// `whoami` / `auth status` add one Empirical Tokens line while they are enforced (spec 8.4).
|
|
402
|
+
// Best effort with a short timeout; doctor does not ask for it.
|
|
403
|
+
let empiricalTokens = null;
|
|
404
|
+
if (includeEmpiricalTokens && token && !error) {
|
|
405
|
+
try {
|
|
406
|
+
const { fetchBillingMe, empiricalTokensLine } = require('./usageCommands');
|
|
407
|
+
empiricalTokens = empiricalTokensLine((await fetchBillingMe(3000)).empiricalTokenUsage);
|
|
408
|
+
} catch (_error) {
|
|
409
|
+
empiricalTokens = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
401
413
|
return {
|
|
402
414
|
authenticated: Boolean(token) && !error && tokenInfo.expired !== true,
|
|
403
415
|
tokenPresent: tokenInfo.present,
|
|
@@ -407,6 +419,8 @@ async function gatherAuthStatus() {
|
|
|
407
419
|
user,
|
|
408
420
|
workspaceId,
|
|
409
421
|
cliWorkspace,
|
|
422
|
+
// Present only while enforced for this account: a dark account's JSON has no such key.
|
|
423
|
+
...(empiricalTokens ? { empiricalTokens } : {}),
|
|
410
424
|
error
|
|
411
425
|
};
|
|
412
426
|
}
|
|
@@ -422,7 +436,7 @@ async function runAuthCommand(args) {
|
|
|
422
436
|
}
|
|
423
437
|
|
|
424
438
|
if (action === 'status' || action === 'whoami') {
|
|
425
|
-
const status = await gatherAuthStatus();
|
|
439
|
+
const status = await gatherAuthStatus({ includeEmpiricalTokens: true });
|
|
426
440
|
log(JSON.stringify(status));
|
|
427
441
|
if (!status.authenticated) {
|
|
428
442
|
process.exitCode = 1;
|
package/lib/blueprintCommands.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
const { httpJson, printApiError } = require('./http');
|
|
4
4
|
const { parseOption } = require('./env');
|
|
5
5
|
const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
|
|
6
|
-
const { warn, log } = require('./ui');
|
|
6
|
+
const { warn, log, createPrompter } = require('./ui');
|
|
7
|
+
const { sanitizeForTerminal } = require('./setupCommands');
|
|
8
|
+
const { confirmCostOrExit, isCostPreview, isInteractiveTerminal } = require('./empiricalTokensFormat');
|
|
7
9
|
|
|
8
10
|
function apiUrl(pathSuffix) {
|
|
9
11
|
return new URL(`/api/v1/blueprints${pathSuffix}`, resolveApiBaseUrl()).toString();
|
|
@@ -61,17 +63,35 @@ async function runClaim(args) {
|
|
|
61
63
|
async function runInstall(args) {
|
|
62
64
|
const blueprintId = args[0] && !args[0].startsWith('--') ? args[0] : '';
|
|
63
65
|
if (!blueprintId) {
|
|
64
|
-
warn('Usage: empirical blueprint install <blueprintId> [--version <v>]');
|
|
66
|
+
warn('Usage: empirical blueprint install <blueprintId> [--version <v>] [--accept-cost]');
|
|
65
67
|
process.exit(1);
|
|
66
68
|
}
|
|
67
69
|
const version = parseOption(args, '--version', '');
|
|
70
|
+
const acceptCost = args.includes('--accept-cost');
|
|
68
71
|
|
|
69
72
|
const detail = await callApi('GET', `/${blueprintId}`, undefined);
|
|
70
73
|
if (!detail.owned && detail.priceModel === 'free') {
|
|
71
74
|
await callApi('POST', `/${blueprintId}/claim`, {});
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
const
|
|
77
|
+
const installBody = { ...(version ? { version } : {}), ...(acceptCost ? { acceptEmpiricalTokenCost: true } : {}) };
|
|
78
|
+
let installResult = await callApi('POST', `/${blueprintId}/install`, installBody);
|
|
79
|
+
if (isCostPreview(installResult)) {
|
|
80
|
+
// A priced blueprint in enforce answers 200 with the cost and starts nothing (no installId yet).
|
|
81
|
+
// Acceptance is sent only from --accept-cost or an explicit y at a real terminal, never for the person.
|
|
82
|
+
const prompter = isInteractiveTerminal() ? createPrompter(process.stderr) : null;
|
|
83
|
+
try {
|
|
84
|
+
await confirmCostOrExit(installResult, { prompter, warn, sanitize: sanitizeForTerminal });
|
|
85
|
+
} finally {
|
|
86
|
+
if (prompter) prompter.close();
|
|
87
|
+
}
|
|
88
|
+
installResult = await callApi('POST', `/${blueprintId}/install`, { ...installBody, acceptEmpiricalTokenCost: true });
|
|
89
|
+
}
|
|
90
|
+
if (!installResult || !installResult.installId) {
|
|
91
|
+
// Never poll /installs/undefined: a reply with no install id is not a started install.
|
|
92
|
+
log(JSON.stringify(installResult, null, 2));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
75
95
|
const finalStatus = await waitForInstall(installResult.installId);
|
|
76
96
|
log(JSON.stringify(finalStatus, null, 2));
|
|
77
97
|
}
|