@gramatr/mcp 0.13.71 → 0.13.74
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/bin/brain.d.ts +103 -22
- package/dist/bin/brain.d.ts.map +1 -1
- package/dist/bin/brain.js +399 -149
- package/dist/bin/brain.js.map +1 -1
- package/dist/hooks/git-gate.d.ts +42 -2
- package/dist/hooks/git-gate.d.ts.map +1 -1
- package/dist/hooks/git-gate.js +333 -16
- package/dist/hooks/git-gate.js.map +1 -1
- package/package.json +1 -1
package/dist/bin/brain.js
CHANGED
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* brain — CLI subcommand for bulk file upload to the grāmatr brain.
|
|
3
3
|
*
|
|
4
|
-
* Reads local files (txt, md, csv, pdf, docx)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Reads local files (txt, md, csv, pdf, docx) and uploads them to the server
|
|
5
|
+
* via the `bulk_upload` MCP tool. The server is the single source of truth
|
|
6
|
+
* for validation, idempotency (sha256-based dedup), virus scanning (ClamAV),
|
|
7
|
+
* and entity/observation creation. The CLI is a thin wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Migrated in epic #1904 PR 3 from the legacy `create_entity` + `add_observation`
|
|
10
|
+
* chain. The orphan-fix that PR 1 added to `resolveEntityId` is now obsolete:
|
|
11
|
+
* idempotency is enforced server-side via `on_duplicate`.
|
|
8
12
|
*
|
|
9
13
|
* Usage:
|
|
10
|
-
* gramatr brain upload <file>
|
|
11
|
-
* gramatr brain upload --dir <dir>
|
|
12
|
-
* gramatr brain upload --dir <dir> --recursive
|
|
13
|
-
* gramatr brain upload <file> --entity-id <id>
|
|
14
|
-
* gramatr brain upload <file> --entity-name <name>
|
|
14
|
+
* gramatr brain upload <file> [flags]
|
|
15
|
+
* gramatr brain upload --dir <dir> [--recursive] [flags]
|
|
15
16
|
* gramatr brain --help
|
|
17
|
+
*
|
|
18
|
+
* New flags (PR 3):
|
|
19
|
+
* --scope <user|team|org|public> Sharing scope (default: user)
|
|
20
|
+
* --project-id <id> Optional project association
|
|
21
|
+
* --team-id <id> Required when --scope=team
|
|
22
|
+
* --org-id <id> Required when --scope=org
|
|
23
|
+
* --public Shorthand for --scope=public
|
|
24
|
+
* --entity-type <type> Entity type (default: reference)
|
|
25
|
+
* --metadata <k=v> Repeatable. Adds to entity metadata
|
|
26
|
+
* --tags <t1,t2,...> Comma-separated topic tags
|
|
27
|
+
* --on-duplicate <skip|reuse|new-version>
|
|
28
|
+
* Dedup policy (default: reuse)
|
|
29
|
+
*
|
|
30
|
+
* Exit codes:
|
|
31
|
+
* 0 — all files uploaded (or skipped via on-duplicate=skip)
|
|
32
|
+
* 1 — at least one non-validation error (network/server 5xx)
|
|
33
|
+
* 2 — config error (bad flag combination, missing required value)
|
|
34
|
+
* 3 — at least one file rejected by a validator (oversize, av_*, etc.)
|
|
35
|
+
*
|
|
36
|
+
* NOTE: Until ClamAV is deployed in production (n90-co/prod-argocd-v2#409),
|
|
37
|
+
* every CLI upload returns `av_unconfigured` from the server. This is
|
|
38
|
+
* intentional fail-closed behaviour. There is no client-side bypass.
|
|
16
39
|
*/
|
|
17
40
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
41
|
import { extname, basename, join, resolve } from 'node:path';
|
|
19
42
|
import { callTool } from '../proxy/local-client.js';
|
|
20
43
|
// ── Supported extensions ────────────────────────────────────────────────────
|
|
21
44
|
export const SUPPORTED_EXTENSIONS = new Set(['.txt', '.md', '.csv', '.pdf', '.docx']);
|
|
22
|
-
// ── Text extraction
|
|
45
|
+
// ── Text extraction (kept for backwards-compatibility tests) ────────────────
|
|
23
46
|
/**
|
|
24
47
|
* Extract plain text from a file buffer based on its extension.
|
|
25
|
-
* Returns null if the extension is unsupported
|
|
48
|
+
* Returns null if the extension is unsupported.
|
|
49
|
+
*
|
|
50
|
+
* NOTE: as of PR 3, the bulk_upload pipeline ships the raw bytes to the server
|
|
51
|
+
* (which performs its own validation + storage). This helper is preserved for
|
|
52
|
+
* existing callers and unit-test scaffolding only.
|
|
26
53
|
*/
|
|
27
54
|
export async function extractText(filePath, buffer) {
|
|
28
55
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -30,7 +57,6 @@ export async function extractText(filePath, buffer) {
|
|
|
30
57
|
return buffer.toString('utf8');
|
|
31
58
|
}
|
|
32
59
|
if (ext === '.pdf') {
|
|
33
|
-
// Dynamic import so the module is only loaded when a PDF is actually processed.
|
|
34
60
|
const pdfParse = (await import('pdf-parse')).default;
|
|
35
61
|
const result = await pdfParse(buffer);
|
|
36
62
|
return result.text;
|
|
@@ -42,7 +68,7 @@ export async function extractText(filePath, buffer) {
|
|
|
42
68
|
}
|
|
43
69
|
return null;
|
|
44
70
|
}
|
|
45
|
-
// ── Entity
|
|
71
|
+
// ── Entity-name derivation ──────────────────────────────────────────────────
|
|
46
72
|
/**
|
|
47
73
|
* Derive an entity name from a file path when no explicit name is given.
|
|
48
74
|
* Strips the extension from the base name.
|
|
@@ -52,61 +78,272 @@ export function deriveEntityName(filePath) {
|
|
|
52
78
|
const ext = extname(base);
|
|
53
79
|
return ext ? base.slice(0, -ext.length) : base;
|
|
54
80
|
}
|
|
81
|
+
// ── Validation-error → user-message mapping ─────────────────────────────────
|
|
55
82
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* 1. --entity-id <id> — use the ID directly (no server call)
|
|
60
|
-
* 2. --entity-name <name> — create_entity with that name
|
|
61
|
-
* 3. fallback — create_entity using the filename (without extension)
|
|
62
|
-
*
|
|
63
|
-
* Returns the entity ID as a string, or throws on failure.
|
|
83
|
+
* Map a server-side `UploadValidationError.reason` to the documented
|
|
84
|
+
* user-friendly CLI message. Reasons not in the table are passed through
|
|
85
|
+
* verbatim so unexpected server errors are still visible.
|
|
64
86
|
*/
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
export const VALIDATION_REASON_MESSAGES = {
|
|
88
|
+
av_unconfigured: 'Server has no antivirus scanner configured. Upload rejected. Contact your admin to deploy ClamAV.',
|
|
89
|
+
av_unavailable: 'Antivirus scanner is unreachable from the server. Upload rejected (fail-closed). Try again shortly.',
|
|
90
|
+
av_infected: 'File rejected: malware detected. Upload denied.',
|
|
91
|
+
oversize: 'File exceeds the upload size limit (25 MB by default). Reduce the file or contact your admin.',
|
|
92
|
+
magic_mismatch: 'File content type does not match the declared extension. Possible polyglot. Upload denied.',
|
|
93
|
+
docx_macro: 'DOCX contains macros (vbaProject.bin). Macros are not allowed. Upload denied.',
|
|
94
|
+
pdf_javascript: 'PDF contains embedded JavaScript. Not allowed. Upload denied.',
|
|
95
|
+
pdf_launch: 'PDF contains a /Launch action. Not allowed. Upload denied.',
|
|
96
|
+
};
|
|
97
|
+
const VALIDATION_REASONS = new Set(Object.keys(VALIDATION_REASON_MESSAGES));
|
|
98
|
+
/**
|
|
99
|
+
* Render the user-friendly message for a validation reason. For `av_infected`
|
|
100
|
+
* the signature is appended when supplied.
|
|
101
|
+
*/
|
|
102
|
+
export function formatValidationMessage(reason, opts) {
|
|
103
|
+
const base = VALIDATION_REASON_MESSAGES[reason];
|
|
104
|
+
if (!base)
|
|
105
|
+
return `Upload rejected — ${reason}`;
|
|
106
|
+
if (reason === 'av_infected' && opts?.signature) {
|
|
107
|
+
return `File rejected: malware detected (signature: ${opts.signature}). Upload denied.`;
|
|
108
|
+
}
|
|
109
|
+
return base;
|
|
110
|
+
}
|
|
111
|
+
// ── Flag parsing ────────────────────────────────────────────────────────────
|
|
112
|
+
const ALLOWED_SCOPES = new Set(['user', 'team', 'org', 'public']);
|
|
113
|
+
const ALLOWED_ON_DUPLICATE = new Set(['skip', 'reuse', 'new-version']);
|
|
114
|
+
// Flags that take a value (consume the next arg).
|
|
115
|
+
const VALUE_FLAGS = new Set([
|
|
116
|
+
'--entity-id',
|
|
117
|
+
'--entity-name',
|
|
118
|
+
'--dir',
|
|
119
|
+
'--scope',
|
|
120
|
+
'--project-id',
|
|
121
|
+
'--team-id',
|
|
122
|
+
'--org-id',
|
|
123
|
+
'--entity-type',
|
|
124
|
+
'--metadata',
|
|
125
|
+
'--tags',
|
|
126
|
+
'--on-duplicate',
|
|
127
|
+
]);
|
|
128
|
+
// Repeatable flags — collect every occurrence into an array.
|
|
129
|
+
const REPEATABLE_FLAGS = new Set(['--metadata']);
|
|
130
|
+
export function parseUploadArgs(args) {
|
|
131
|
+
const out = {
|
|
132
|
+
positionals: [],
|
|
133
|
+
recursive: false,
|
|
134
|
+
dryRun: false,
|
|
135
|
+
publicShorthand: false,
|
|
136
|
+
metadata: [],
|
|
137
|
+
};
|
|
138
|
+
let i = 0;
|
|
139
|
+
while (i < args.length) {
|
|
140
|
+
const arg = args[i];
|
|
141
|
+
if (REPEATABLE_FLAGS.has(arg)) {
|
|
142
|
+
const v = args[i + 1];
|
|
143
|
+
if (v === undefined) {
|
|
144
|
+
// Treat missing value as empty so downstream validation reports it.
|
|
145
|
+
out.metadata.push('');
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
out.metadata.push(v);
|
|
149
|
+
}
|
|
150
|
+
i += 2;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (VALUE_FLAGS.has(arg)) {
|
|
154
|
+
const v = args[i + 1];
|
|
155
|
+
switch (arg) {
|
|
156
|
+
case '--entity-id':
|
|
157
|
+
out.entityId = v;
|
|
158
|
+
break;
|
|
159
|
+
case '--entity-name':
|
|
160
|
+
out.entityName = v;
|
|
161
|
+
break;
|
|
162
|
+
case '--dir':
|
|
163
|
+
out.dir = v;
|
|
164
|
+
break;
|
|
165
|
+
case '--scope':
|
|
166
|
+
out.scope = v;
|
|
167
|
+
break;
|
|
168
|
+
case '--project-id':
|
|
169
|
+
out.projectId = v;
|
|
170
|
+
break;
|
|
171
|
+
case '--team-id':
|
|
172
|
+
out.teamId = v;
|
|
173
|
+
break;
|
|
174
|
+
case '--org-id':
|
|
175
|
+
out.orgId = v;
|
|
176
|
+
break;
|
|
177
|
+
case '--entity-type':
|
|
178
|
+
out.entityType = v;
|
|
179
|
+
break;
|
|
180
|
+
case '--tags':
|
|
181
|
+
out.tags = v;
|
|
182
|
+
break;
|
|
183
|
+
case '--on-duplicate':
|
|
184
|
+
out.onDuplicate = v;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
i += 2;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (arg === '--recursive') {
|
|
191
|
+
out.recursive = true;
|
|
192
|
+
i += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (arg === '--dry-run') {
|
|
196
|
+
out.dryRun = true;
|
|
197
|
+
i += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (arg === '--public') {
|
|
201
|
+
out.publicShorthand = true;
|
|
202
|
+
i += 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (arg.startsWith('--')) {
|
|
206
|
+
i += 1;
|
|
207
|
+
continue;
|
|
208
|
+
} // unknown boolean flag — ignore
|
|
209
|
+
out.positionals.push(arg);
|
|
210
|
+
i += 1;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Validate the parsed flag combination. Returns either a normalized config or
|
|
216
|
+
* a structured error message. Pure — does not write to stderr or exit.
|
|
217
|
+
*/
|
|
218
|
+
export function validateUploadConfig(parsed) {
|
|
219
|
+
if (parsed.entityId && parsed.entityName) {
|
|
220
|
+
return { ok: false, message: '--entity-id and --entity-name are mutually exclusive' };
|
|
221
|
+
}
|
|
222
|
+
// Resolve scope — handle --public shorthand.
|
|
223
|
+
let scope;
|
|
224
|
+
if (parsed.publicShorthand) {
|
|
225
|
+
if (parsed.scope && parsed.scope !== 'public') {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
message: `--public conflicts with --scope=${parsed.scope}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
scope = 'public';
|
|
232
|
+
}
|
|
233
|
+
else if (parsed.scope === undefined) {
|
|
234
|
+
scope = 'user';
|
|
235
|
+
}
|
|
236
|
+
else if (ALLOWED_SCOPES.has(parsed.scope)) {
|
|
237
|
+
scope = parsed.scope;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
message: `--scope must be one of: user | team | org | public (got "${parsed.scope}")`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (scope === 'team' && !parsed.teamId) {
|
|
246
|
+
return { ok: false, message: '--scope=team requires --team-id <uuid>' };
|
|
247
|
+
}
|
|
248
|
+
if (scope === 'org' && !parsed.orgId) {
|
|
249
|
+
return { ok: false, message: '--scope=org requires --org-id <uuid>' };
|
|
250
|
+
}
|
|
251
|
+
// on-duplicate
|
|
252
|
+
let onDuplicate;
|
|
253
|
+
if (parsed.onDuplicate === undefined) {
|
|
254
|
+
onDuplicate = 'reuse';
|
|
255
|
+
}
|
|
256
|
+
else if (ALLOWED_ON_DUPLICATE.has(parsed.onDuplicate)) {
|
|
257
|
+
onDuplicate = parsed.onDuplicate;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
message: `--on-duplicate must be one of: skip | reuse | new-version (got "${parsed.onDuplicate}")`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// metadata k=v
|
|
266
|
+
const metadata = {};
|
|
267
|
+
for (const raw of parsed.metadata) {
|
|
268
|
+
const eq = raw.indexOf('=');
|
|
269
|
+
if (eq <= 0) {
|
|
270
|
+
return { ok: false, message: `--metadata "${raw}" must be in key=value form with non-empty key` };
|
|
271
|
+
}
|
|
272
|
+
const key = raw.slice(0, eq).trim();
|
|
273
|
+
const value = raw.slice(eq + 1);
|
|
274
|
+
if (key.length === 0) {
|
|
275
|
+
return { ok: false, message: `--metadata "${raw}" has empty key` };
|
|
276
|
+
}
|
|
277
|
+
metadata[key] = value;
|
|
278
|
+
}
|
|
279
|
+
// tags
|
|
280
|
+
const tags = [];
|
|
281
|
+
if (parsed.tags) {
|
|
282
|
+
for (const t of parsed.tags.split(',')) {
|
|
283
|
+
const trimmed = t.trim();
|
|
284
|
+
if (trimmed.length > 0)
|
|
285
|
+
tags.push(trimmed);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
config: {
|
|
291
|
+
scope,
|
|
292
|
+
projectId: parsed.projectId,
|
|
293
|
+
teamId: parsed.teamId,
|
|
294
|
+
orgId: parsed.orgId,
|
|
295
|
+
isPublic: scope === 'public',
|
|
296
|
+
entityType: parsed.entityType ?? 'reference',
|
|
297
|
+
metadata,
|
|
298
|
+
tags,
|
|
299
|
+
onDuplicate,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Build the bulk_upload tool input for a given file. Exported so tests can
|
|
305
|
+
* assert the exact shape forwarded to the server.
|
|
306
|
+
*/
|
|
307
|
+
export function buildBulkUploadInput(filePath, buffer, options) {
|
|
308
|
+
const filename = basename(filePath);
|
|
309
|
+
const input = {
|
|
310
|
+
content_base64: buffer.toString('base64'),
|
|
311
|
+
filename,
|
|
312
|
+
entity_type: options.config.entityType,
|
|
313
|
+
scope: options.config.scope,
|
|
314
|
+
on_duplicate: options.config.onDuplicate,
|
|
315
|
+
is_public: options.config.isPublic,
|
|
316
|
+
};
|
|
317
|
+
if (options.entityId)
|
|
318
|
+
input.entity_id = options.entityId;
|
|
319
|
+
else if (options.entityName)
|
|
320
|
+
input.entity_name = options.entityName;
|
|
321
|
+
else
|
|
322
|
+
input.entity_name = deriveEntityName(filePath);
|
|
323
|
+
if (options.config.projectId)
|
|
324
|
+
input.project_id = options.config.projectId;
|
|
325
|
+
if (options.config.teamId)
|
|
326
|
+
input.team_id = options.config.teamId;
|
|
327
|
+
if (options.config.orgId)
|
|
328
|
+
input.org_id = options.config.orgId;
|
|
329
|
+
if (Object.keys(options.config.metadata).length > 0)
|
|
330
|
+
input.metadata = options.config.metadata;
|
|
331
|
+
if (options.config.tags.length > 0)
|
|
332
|
+
input.tags = options.config.tags;
|
|
333
|
+
return input;
|
|
334
|
+
}
|
|
335
|
+
function parseToolBody(text) {
|
|
81
336
|
try {
|
|
82
|
-
|
|
337
|
+
return JSON.parse(text);
|
|
83
338
|
}
|
|
84
339
|
catch {
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
if (parsed?.error === 'DuplicateEntityError' && parsed.existing_entity_id) {
|
|
88
|
-
// Idempotent reuse — same name + type already exists for this user. Treat
|
|
89
|
-
// it as a successful resolve so the caller can attach observations.
|
|
90
|
-
return parsed.existing_entity_id;
|
|
340
|
+
return undefined;
|
|
91
341
|
}
|
|
92
|
-
if (result.isError) {
|
|
93
|
-
// gramatr-allow: B1 — CLI bin command, no @gramatr/core dependency in MCP package
|
|
94
|
-
throw new Error(`create_entity failed: ${text || 'unknown error'}`);
|
|
95
|
-
}
|
|
96
|
-
if (!parsed) {
|
|
97
|
-
// gramatr-allow: B1 — CLI bin command, no @gramatr/core dependency in MCP package
|
|
98
|
-
throw new Error(`could not parse entity id from response: ${text}`);
|
|
99
|
-
}
|
|
100
|
-
const id = parsed.id ?? parsed.entity?.id;
|
|
101
|
-
// gramatr-allow: B1 — CLI bin command, no @gramatr/core dependency in MCP package
|
|
102
|
-
if (!id)
|
|
103
|
-
throw new Error('no id in create_entity response');
|
|
104
|
-
return id;
|
|
105
342
|
}
|
|
106
343
|
/**
|
|
107
|
-
* Upload a single file
|
|
344
|
+
* Upload a single file via the `bulk_upload` MCP tool.
|
|
108
345
|
*/
|
|
109
|
-
export async function uploadFile(filePath, options
|
|
346
|
+
export async function uploadFile(filePath, options) {
|
|
110
347
|
const ext = extname(filePath).toLowerCase();
|
|
111
348
|
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
112
349
|
process.stderr.write(`[gramatr] skipping ${filePath} (unsupported type: ${ext})\n`);
|
|
@@ -121,47 +358,38 @@ export async function uploadFile(filePath, options = {}) {
|
|
|
121
358
|
process.stderr.write(`[gramatr] ✗ ${filePath}: ${message}\n`);
|
|
122
359
|
return { file: filePath, error: message };
|
|
123
360
|
}
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
text = await extractText(filePath, buffer);
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
130
|
-
process.stderr.write(`[gramatr] ✗ ${filePath}: failed to extract text — ${message}\n`);
|
|
131
|
-
return { file: filePath, error: message };
|
|
132
|
-
}
|
|
133
|
-
if (text === null) {
|
|
134
|
-
// extractText returns null for unsupported extensions — already guarded above
|
|
135
|
-
process.stderr.write(`[gramatr] skipping ${filePath} (unsupported type: ${ext})\n`);
|
|
136
|
-
return { file: filePath, skipped: true };
|
|
137
|
-
}
|
|
138
|
-
if (text.trim().length === 0) {
|
|
361
|
+
if (buffer.length === 0) {
|
|
139
362
|
process.stderr.write(`[gramatr] skipping ${filePath} (empty content)\n`);
|
|
140
363
|
return { file: filePath, skipped: true };
|
|
141
364
|
}
|
|
142
|
-
process.stderr.write(`[gramatr] uploading ${basename(filePath)} (${
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
365
|
+
process.stderr.write(`[gramatr] uploading ${basename(filePath)} (${buffer.length} bytes)...\n`);
|
|
366
|
+
const input = buildBulkUploadInput(filePath, buffer, options);
|
|
367
|
+
const result = await callTool('bulk_upload', input);
|
|
368
|
+
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
|
|
369
|
+
const parsed = parseToolBody(text);
|
|
370
|
+
if (result.isError) {
|
|
371
|
+
if (parsed?.type === 'UploadValidationError' && parsed.reason) {
|
|
372
|
+
const userMsg = formatValidationMessage(parsed.reason, { signature: parsed.signature });
|
|
373
|
+
process.stderr.write(`[gramatr] ✗ ${basename(filePath)}: ${userMsg}\n`);
|
|
374
|
+
return { file: filePath, error: userMsg, validationReason: parsed.reason };
|
|
375
|
+
}
|
|
376
|
+
const errMsg = parsed?.error ?? text ?? 'unknown error';
|
|
377
|
+
process.stderr.write(`[gramatr] ✗ ${basename(filePath)}: bulk_upload failed — ${errMsg}\n`);
|
|
378
|
+
return { file: filePath, error: errMsg };
|
|
146
379
|
}
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
process.stderr.write(`[gramatr] ✗ ${filePath}: ${
|
|
150
|
-
return { file: filePath, error:
|
|
380
|
+
if (!parsed?.entity_id) {
|
|
381
|
+
const errMsg = `bulk_upload returned no entity_id: ${text}`;
|
|
382
|
+
process.stderr.write(`[gramatr] ✗ ${basename(filePath)}: ${errMsg}\n`);
|
|
383
|
+
return { file: filePath, error: errMsg };
|
|
151
384
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
process.stderr.write(`[gramatr] ✗ ${filePath}: add_observation failed — ${errorText}\n`);
|
|
161
|
-
return { file: filePath, entityId, error: errorText };
|
|
162
|
-
}
|
|
163
|
-
process.stderr.write(`[gramatr] ✓ ${basename(filePath)} → entity ${entityId}\n`);
|
|
164
|
-
return { file: filePath, entityId };
|
|
385
|
+
const dupSuffix = parsed.was_duplicate ? ' (duplicate)' : '';
|
|
386
|
+
process.stderr.write(`[gramatr] ✓ ${basename(filePath)} → entity ${parsed.entity_id}${dupSuffix}\n`);
|
|
387
|
+
return {
|
|
388
|
+
file: filePath,
|
|
389
|
+
entityId: parsed.entity_id,
|
|
390
|
+
observationId: parsed.observation_id,
|
|
391
|
+
wasDuplicate: parsed.was_duplicate === true,
|
|
392
|
+
};
|
|
165
393
|
}
|
|
166
394
|
// ── Directory collection ────────────────────────────────────────────────────
|
|
167
395
|
/**
|
|
@@ -189,20 +417,10 @@ export function collectFiles(dir, recursive) {
|
|
|
189
417
|
}
|
|
190
418
|
return files;
|
|
191
419
|
}
|
|
192
|
-
// ── Arg helpers ─────────────────────────────────────────────────────────────
|
|
193
|
-
function getFlag(args, name) {
|
|
194
|
-
const idx = args.indexOf(name);
|
|
195
|
-
if (idx === -1 || idx + 1 >= args.length)
|
|
196
|
-
return undefined;
|
|
197
|
-
return args[idx + 1];
|
|
198
|
-
}
|
|
199
|
-
function hasFlag(args, name) {
|
|
200
|
-
return args.includes(name);
|
|
201
|
-
}
|
|
202
420
|
// ── Help text ────────────────────────────────────────────────────────────────
|
|
203
421
|
function printBrainHelp() {
|
|
204
422
|
process.stderr.write(`
|
|
205
|
-
gramatr brain — Upload local files to the grāmatr brain
|
|
423
|
+
gramatr brain — Upload local files to the grāmatr brain (via bulk_upload)
|
|
206
424
|
|
|
207
425
|
Usage:
|
|
208
426
|
brain upload <file> Upload a single file
|
|
@@ -213,29 +431,57 @@ function printBrainHelp() {
|
|
|
213
431
|
Supported file types:
|
|
214
432
|
.txt .md .csv .pdf .docx
|
|
215
433
|
|
|
216
|
-
Entity options
|
|
217
|
-
--entity-id <id>
|
|
218
|
-
--entity-name <name>
|
|
219
|
-
|
|
434
|
+
Entity options:
|
|
435
|
+
--entity-id <id> Attach to an existing entity by ID
|
|
436
|
+
--entity-name <name> Use this entity name (server creates if missing)
|
|
437
|
+
Default: filename without extension
|
|
438
|
+
--entity-type <type> Entity type (default: reference)
|
|
439
|
+
|
|
440
|
+
Sharing / scope:
|
|
441
|
+
--scope <user|team|org|public> Default: user
|
|
442
|
+
--project-id <uuid> Optional project association
|
|
443
|
+
--team-id <uuid> Required when --scope=team
|
|
444
|
+
--org-id <uuid> Required when --scope=org
|
|
445
|
+
--public Shorthand for --scope=public
|
|
446
|
+
|
|
447
|
+
Metadata:
|
|
448
|
+
--metadata key=value Repeatable. Adds entries to entity metadata
|
|
449
|
+
--tags t1,t2,t3 Comma-separated topic tags
|
|
450
|
+
|
|
451
|
+
Dedup policy:
|
|
452
|
+
--on-duplicate <skip|reuse|new-version>
|
|
453
|
+
Server-side dedup behaviour (default: reuse)
|
|
454
|
+
skip — leave existing entity untouched
|
|
455
|
+
reuse — append observation to existing entity
|
|
456
|
+
new-version — create "<name> v2" / v3 / ...
|
|
220
457
|
|
|
221
458
|
Other flags:
|
|
222
|
-
--dry-run
|
|
223
|
-
--recursive
|
|
459
|
+
--dry-run Preflight only — show what would be uploaded
|
|
460
|
+
--recursive With --dir, walk subdirectories
|
|
461
|
+
|
|
462
|
+
Exit codes:
|
|
463
|
+
0 all files uploaded (or skipped via on-duplicate=skip)
|
|
464
|
+
1 at least one non-validation error (network / 5xx)
|
|
465
|
+
2 config error (bad flag combination)
|
|
466
|
+
3 at least one file rejected by a server-side validator
|
|
224
467
|
|
|
225
468
|
Examples:
|
|
226
469
|
brain upload project-brief.pdf
|
|
227
|
-
brain upload notes.md --entity-name "Q3 Research"
|
|
228
|
-
brain upload contract.docx --entity-id abc-123
|
|
229
|
-
brain upload --dir ./docs
|
|
230
|
-
brain upload --dir ./research --recursive
|
|
231
|
-
brain upload
|
|
470
|
+
brain upload notes.md --entity-name "Q3 Research" --tags strategy,2026
|
|
471
|
+
brain upload contract.docx --entity-id abc-123 --on-duplicate new-version
|
|
472
|
+
brain upload --dir ./docs --scope team --team-id <team-uuid>
|
|
473
|
+
brain upload --dir ./research --recursive --metadata source=arxiv --metadata year=2026
|
|
474
|
+
brain upload report.pdf --public
|
|
475
|
+
|
|
476
|
+
NOTE: bulk_upload requires ClamAV to be deployed on the server. Until then,
|
|
477
|
+
every upload returns av_unconfigured (fail-closed by design).
|
|
232
478
|
|
|
233
479
|
`);
|
|
234
480
|
}
|
|
235
481
|
// ── Main entry point ─────────────────────────────────────────────────────────
|
|
236
482
|
/**
|
|
237
483
|
* runBrain — entry point for `gramatr brain <args>` subcommand.
|
|
238
|
-
* Returns an exit code (0
|
|
484
|
+
* Returns an exit code (0/1/2/3 — see top of file).
|
|
239
485
|
*/
|
|
240
486
|
export async function runBrain(args) {
|
|
241
487
|
const subcommand = args[0];
|
|
@@ -250,35 +496,21 @@ export async function runBrain(args) {
|
|
|
250
496
|
return 1;
|
|
251
497
|
}
|
|
252
498
|
// -- upload subcommand --
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Collect the positional (non-flag) arguments as file paths
|
|
260
|
-
const positionals = [];
|
|
261
|
-
let i = 0;
|
|
262
|
-
while (i < uploadArgs.length) {
|
|
263
|
-
const arg = uploadArgs[i];
|
|
264
|
-
if (arg === '--entity-id' || arg === '--entity-name' || arg === '--dir') {
|
|
265
|
-
i += 2; // skip flag + value
|
|
266
|
-
}
|
|
267
|
-
else if (arg.startsWith('--')) {
|
|
268
|
-
i += 1; // boolean flag
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
positionals.push(arg);
|
|
272
|
-
i += 1;
|
|
273
|
-
}
|
|
499
|
+
const parsed = parseUploadArgs(args.slice(1));
|
|
500
|
+
const validation = validateUploadConfig(parsed);
|
|
501
|
+
if (!validation.ok) {
|
|
502
|
+
process.stderr.write(`[gramatr] ✗ ${validation.message}\n`);
|
|
503
|
+
process.stderr.write(' Run: gramatr brain --help\n');
|
|
504
|
+
return 2;
|
|
274
505
|
}
|
|
506
|
+
const { config } = validation;
|
|
275
507
|
// Build the list of files to upload
|
|
276
508
|
const filePaths = [];
|
|
277
|
-
if (
|
|
278
|
-
const absDir = resolve(
|
|
509
|
+
if (parsed.dir) {
|
|
510
|
+
const absDir = resolve(parsed.dir);
|
|
279
511
|
let dirFiles;
|
|
280
512
|
try {
|
|
281
|
-
dirFiles = collectFiles(absDir, recursive);
|
|
513
|
+
dirFiles = collectFiles(absDir, parsed.recursive);
|
|
282
514
|
}
|
|
283
515
|
catch (err) {
|
|
284
516
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -291,7 +523,7 @@ export async function runBrain(args) {
|
|
|
291
523
|
}
|
|
292
524
|
filePaths.push(...dirFiles);
|
|
293
525
|
}
|
|
294
|
-
for (const p of positionals) {
|
|
526
|
+
for (const p of parsed.positionals) {
|
|
295
527
|
filePaths.push(resolve(p));
|
|
296
528
|
}
|
|
297
529
|
if (filePaths.length === 0) {
|
|
@@ -299,25 +531,43 @@ export async function runBrain(args) {
|
|
|
299
531
|
process.stderr.write(' Run: gramatr brain --help\n');
|
|
300
532
|
return 1;
|
|
301
533
|
}
|
|
302
|
-
if (dryRun) {
|
|
534
|
+
if (parsed.dryRun) {
|
|
303
535
|
process.stderr.write(`[gramatr] dry-run — would upload ${filePaths.length} file(s):\n`);
|
|
536
|
+
process.stderr.write(` scope=${config.scope} entity_type=${config.entityType} on_duplicate=${config.onDuplicate}\n`);
|
|
304
537
|
for (const filePath of filePaths) {
|
|
305
538
|
const ext = extname(filePath).toLowerCase();
|
|
306
539
|
const supported = SUPPORTED_EXTENSIONS.has(ext);
|
|
307
|
-
const
|
|
540
|
+
const target = parsed.entityId
|
|
541
|
+
? `(reuse entity ${parsed.entityId})`
|
|
542
|
+
: `entity_name=${parsed.entityName ?? deriveEntityName(filePath)}`;
|
|
308
543
|
const status = supported ? 'OK' : `SKIP (unsupported: ${ext})`;
|
|
309
|
-
process.stderr.write(` [${status}] ${filePath} → ${
|
|
544
|
+
process.stderr.write(` [${status}] ${filePath} → ${target}\n`);
|
|
310
545
|
}
|
|
311
546
|
process.stderr.write('[gramatr] dry-run complete — no changes written\n');
|
|
312
547
|
return 0;
|
|
313
548
|
}
|
|
314
|
-
const options = {
|
|
549
|
+
const options = {
|
|
550
|
+
entityId: parsed.entityId,
|
|
551
|
+
entityName: parsed.entityName,
|
|
552
|
+
config,
|
|
553
|
+
};
|
|
315
554
|
let hasError = false;
|
|
555
|
+
let hasValidationReject = false;
|
|
316
556
|
for (const filePath of filePaths) {
|
|
317
557
|
const result = await uploadFile(filePath, options);
|
|
318
|
-
if (result.
|
|
558
|
+
if (result.validationReason) {
|
|
559
|
+
hasValidationReject = true;
|
|
560
|
+
}
|
|
561
|
+
else if (result.error) {
|
|
319
562
|
hasError = true;
|
|
563
|
+
}
|
|
320
564
|
}
|
|
321
|
-
|
|
565
|
+
// Validator rejection takes precedence — distinct exit code so scripts can
|
|
566
|
+
// differentiate "server said no" from "transport blew up".
|
|
567
|
+
if (hasValidationReject)
|
|
568
|
+
return 3;
|
|
569
|
+
if (hasError)
|
|
570
|
+
return 1;
|
|
571
|
+
return 0;
|
|
322
572
|
}
|
|
323
573
|
//# sourceMappingURL=brain.js.map
|