@empirical-gauzza/cli 1.0.38 → 1.0.40

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.
@@ -1,388 +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
- };
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
+ };