@monadeo.com/grimoire-cli 0.3.7 → 0.4.0

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/index.js CHANGED
@@ -10,7 +10,7 @@ import { join as join6 } from "node:path";
10
10
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
- var DEFAULT_API_BASE = "https://grimoire-api.monadeo.com";
13
+ var DEFAULT_API_BASE = "https://cli.grimoire.monadeo.com";
14
14
  function globalConfigPath() {
15
15
  return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "grimoire", "config.json");
16
16
  }
@@ -99,28 +99,38 @@ async function fetchWithTimeout(url, init = {}) {
99
99
  }
100
100
 
101
101
  // ../core/dist/auth.js
102
+ function configDir() {
103
+ return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire");
104
+ }
102
105
  function credentialsPath() {
103
- return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire", "credentials.json");
106
+ return join2(configDir(), "credentials.json");
104
107
  }
105
- function storeRefreshToken(token) {
108
+ function storeSession(session) {
106
109
  const path = credentialsPath();
107
110
  mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
108
- writeFileSync2(path, JSON.stringify({ refresh_token: token }) + "\n", { mode: 384 });
111
+ writeFileSync2(path, JSON.stringify(session) + "\n", { mode: 384 });
109
112
  }
110
- function readRefreshToken() {
113
+ function readSession() {
111
114
  try {
112
115
  const parsed = JSON.parse(readFileSync2(credentialsPath(), "utf8"));
113
- return typeof parsed.refresh_token === "string" && parsed.refresh_token !== "" ? parsed.refresh_token : void 0;
116
+ if (typeof parsed.refresh_token === "string" && parsed.refresh_token !== "" && typeof parsed.supabase_url === "string" && typeof parsed.supabase_anon_key === "string") {
117
+ return {
118
+ refresh_token: parsed.refresh_token,
119
+ supabase_url: parsed.supabase_url,
120
+ supabase_anon_key: parsed.supabase_anon_key
121
+ };
122
+ }
123
+ return void 0;
114
124
  } catch {
115
125
  return void 0;
116
126
  }
117
127
  }
118
- function clearRefreshToken() {
128
+ function clearSession() {
119
129
  if (existsSync2(credentialsPath()))
120
130
  rmSync(credentialsPath());
121
131
  }
122
132
  function machineTokenPath() {
123
- return join2(process.env.XDG_CONFIG_HOME ?? join2(homedir2(), ".config"), "grimoire", "machine-token");
133
+ return join2(configDir(), "machine-token");
124
134
  }
125
135
  function storeMachineToken(token) {
126
136
  const path = machineTokenPath();
@@ -139,56 +149,136 @@ function clearMachineToken() {
139
149
  if (existsSync2(machineTokenPath()))
140
150
  rmSync(machineTokenPath());
141
151
  }
152
+ async function fetchAuthConfig(apiBase) {
153
+ const res = await fetchWithTimeout(`${apiBase.replace(/\/+$/, "")}/v1/auth/config`);
154
+ if (!res.ok)
155
+ throw new ApiError(res.status, "auth_config_unavailable", await res.text());
156
+ const body = await res.json();
157
+ if (typeof body.supabase_url !== "string" || typeof body.supabase_anon_key !== "string" || typeof body.oauth_provider !== "string" || !Array.isArray(body.redirect_urls) || body.redirect_urls.length === 0) {
158
+ throw new ApiError(res.status, "auth_config_malformed", body);
159
+ }
160
+ return {
161
+ supabase_url: body.supabase_url,
162
+ supabase_anon_key: body.supabase_anon_key,
163
+ oauth_provider: body.oauth_provider,
164
+ redirect_urls: body.redirect_urls.map(String)
165
+ };
166
+ }
167
+ function parseDetail(text) {
168
+ try {
169
+ return JSON.parse(text);
170
+ } catch {
171
+ return text;
172
+ }
173
+ }
174
+ async function gotrueToken(supabaseUrl, anonKey, grantType, body) {
175
+ const res = await fetchWithTimeout(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/token?grant_type=${grantType}`, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json", apikey: anonKey },
178
+ body: JSON.stringify(body)
179
+ });
180
+ const text = await res.text();
181
+ if (!res.ok) {
182
+ throw new ApiError(res.status, grantType === "pkce" ? "login_failed" : "refresh_failed", parseDetail(text));
183
+ }
184
+ const parsed = JSON.parse(text);
185
+ if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string" || typeof parsed.expires_in !== "number") {
186
+ throw new ApiError(res.status, "token_malformed", parsed);
187
+ }
188
+ return {
189
+ access_token: parsed.access_token,
190
+ refresh_token: parsed.refresh_token,
191
+ expires_in: parsed.expires_in
192
+ };
193
+ }
142
194
  function base64url(buf) {
143
195
  return buf.toString("base64url");
144
196
  }
145
- async function browserLogin(apiBase, openBrowser2) {
146
- const broker = apiBase.replace(/\/+$/, "");
197
+ async function bindCallback(server, redirectUrls) {
198
+ for (const candidate of redirectUrls) {
199
+ const port = Number(new URL(candidate).port);
200
+ const bound = await new Promise((resolve) => {
201
+ const onError = () => {
202
+ server.off("error", onError);
203
+ resolve(false);
204
+ };
205
+ server.once("error", onError);
206
+ server.listen(port, "127.0.0.1", () => {
207
+ server.off("error", onError);
208
+ resolve(true);
209
+ });
210
+ });
211
+ if (bound)
212
+ return candidate;
213
+ }
214
+ throw new Error(`No free login callback port among: ${redirectUrls.join(", ")}`);
215
+ }
216
+ async function browserLogin(apiBase, openBrowser, timeoutMs = 3e5) {
217
+ const config = await fetchAuthConfig(apiBase);
147
218
  const verifier = base64url(randomBytes(32));
148
219
  const challenge = base64url(createHash("sha256").update(verifier).digest());
149
220
  const state = base64url(randomBytes(16));
150
221
  const code = await new Promise((resolve, reject) => {
151
- const server = createServer((req, res2) => {
222
+ let timer;
223
+ const finish2 = (outcome) => {
224
+ if (timer)
225
+ clearTimeout(timer);
226
+ server.closeAllConnections();
227
+ server.close();
228
+ if ("code" in outcome)
229
+ resolve(outcome.code);
230
+ else
231
+ reject(outcome.error);
232
+ };
233
+ const server = createServer((req, res) => {
152
234
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
235
+ if (req.method !== "GET" || url.pathname !== `/callback/${state}`) {
236
+ res.writeHead(404).end();
237
+ return;
238
+ }
239
+ const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error");
153
240
  const received = url.searchParams.get("code");
154
- if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state || !received) {
155
- res2.writeHead(404).end();
241
+ if (failure || !received) {
242
+ res.writeHead(400, { "Content-Type": "text/plain" }).end(`Login failed: ${failure ?? "no code"}`);
243
+ finish2({ error: new Error(`Login failed: ${failure ?? "no code returned"}`) });
156
244
  return;
157
245
  }
158
- res2.writeHead(302, { Location: `${broker}/auth/cli/success` }).end();
159
- clearTimeout(timer);
160
- server.close();
161
- resolve(received);
246
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("Logged in to Grimoire. You can close this tab.");
247
+ finish2({ code: received });
162
248
  });
163
- server.listen(0, "127.0.0.1", () => {
164
- const port = server.address().port;
165
- const redirect = `http://127.0.0.1:${port}/callback`;
166
- openBrowser2(`${broker}/auth/cli/start?code_challenge=${challenge}&code_challenge_method=S256&state=${state}&redirect_uri=${encodeURIComponent(redirect)}`);
167
- });
168
- const timer = setTimeout(() => {
169
- server.close();
170
- reject(new Error("Login timed out"));
171
- }, 3e5);
249
+ bindCallback(server, config.redirect_urls).then((redirectBase) => {
250
+ timer = setTimeout(() => finish2({ error: new Error("Login timed out") }), timeoutMs);
251
+ const authorize = new URL(`${config.supabase_url.replace(/\/+$/, "")}/auth/v1/authorize`);
252
+ authorize.searchParams.set("provider", config.oauth_provider);
253
+ authorize.searchParams.set("redirect_to", `${redirectBase.replace(/\/+$/, "")}/${state}`);
254
+ authorize.searchParams.set("code_challenge", challenge);
255
+ authorize.searchParams.set("code_challenge_method", "s256");
256
+ openBrowser(authorize.toString());
257
+ }).catch((err) => finish2({ error: err }));
172
258
  });
173
- const res = await fetchWithTimeout(`${broker}/auth/cli/exchange`, {
174
- method: "POST",
175
- headers: { "Content-Type": "application/json" },
176
- body: JSON.stringify({ code, code_verifier: verifier })
259
+ const tokens = await gotrueToken(config.supabase_url, config.supabase_anon_key, "pkce", {
260
+ auth_code: code,
261
+ code_verifier: verifier
262
+ });
263
+ storeSession({
264
+ refresh_token: tokens.refresh_token,
265
+ supabase_url: config.supabase_url,
266
+ supabase_anon_key: config.supabase_anon_key
177
267
  });
178
- if (!res.ok)
179
- throw new Error(`Token exchange failed: ${res.status}`);
180
- const { refresh_token } = await res.json();
181
- if (typeof refresh_token !== "string" || refresh_token.length === 0) {
182
- throw new Error("Token exchange succeeded but returned no refresh token");
183
- }
184
- storeRefreshToken(refresh_token);
185
268
  }
186
269
 
187
270
  // ../core/dist/client.js
271
+ var CODES = {
272
+ 401: "unauthorized",
273
+ 403: "forbidden",
274
+ 404: "not_found",
275
+ 422: "invalid_request",
276
+ 429: "quota_exceeded"
277
+ };
188
278
  var GrimoireClient = class {
189
279
  baseUrl;
190
280
  machineToken;
191
- cachedIdToken;
281
+ cachedAccessToken;
192
282
  refreshInFlight;
193
283
  constructor(opts = {}) {
194
284
  this.baseUrl = (opts.baseUrl ?? loadGlobalConfig().apiBaseUrl).replace(/\/+$/, "");
@@ -197,38 +287,36 @@ var GrimoireClient = class {
197
287
  async bearer() {
198
288
  if (this.machineToken)
199
289
  return this.machineToken;
200
- if (this.cachedIdToken && this.cachedIdToken.expiresAt > Date.now() + 6e4) {
201
- return this.cachedIdToken.token;
290
+ if (this.cachedAccessToken && this.cachedAccessToken.expiresAt > Date.now() + 6e4) {
291
+ return this.cachedAccessToken.token;
202
292
  }
203
- this.refreshInFlight ??= this.refreshIdToken().finally(() => {
293
+ this.refreshInFlight ??= this.refreshAccessToken().finally(() => {
204
294
  this.refreshInFlight = void 0;
205
295
  });
206
296
  return this.refreshInFlight;
207
297
  }
208
- // Exchange the Firebase refresh token for a fresh ID token (silent refresh).
209
- async refreshIdToken() {
210
- const refresh = readRefreshToken();
211
- if (!refresh)
298
+ // Exchange the stored refresh token for a fresh access token. GoTrue rotates
299
+ // refresh tokens, so the new one replaces the stored one every time.
300
+ async refreshAccessToken() {
301
+ const session = readSession();
302
+ if (!session)
212
303
  throw new ApiError(401, "not_logged_in", "Run `grimoire login`");
213
- const res = await fetchWithTimeout(`${this.baseUrl}/auth/cli/refresh`, {
214
- method: "POST",
215
- headers: { "Content-Type": "application/json" },
216
- body: JSON.stringify({ refresh_token: refresh })
217
- });
218
- if (!res.ok)
219
- throw new ApiError(res.status, "refresh_failed", "Run `grimoire login`");
220
- let payload;
304
+ let tokens;
221
305
  try {
222
- payload = await res.json();
223
- } catch {
224
- throw new ApiError(res.status, "refresh_failed", "Malformed refresh response");
225
- }
226
- const { id_token, expires_in } = payload;
227
- if (typeof id_token !== "string" || typeof expires_in !== "number") {
228
- throw new ApiError(res.status, "refresh_failed", "Malformed refresh response");
306
+ tokens = await gotrueToken(session.supabase_url, session.supabase_anon_key, "refresh_token", {
307
+ refresh_token: session.refresh_token
308
+ });
309
+ } catch (err) {
310
+ if (err instanceof ApiError)
311
+ throw new ApiError(err.status, "refresh_failed", "Run `grimoire login`");
312
+ throw err;
229
313
  }
230
- this.cachedIdToken = { token: id_token, expiresAt: Date.now() + expires_in * 1e3 };
231
- return id_token;
314
+ storeSession({ ...session, refresh_token: tokens.refresh_token });
315
+ this.cachedAccessToken = {
316
+ token: tokens.access_token,
317
+ expiresAt: Date.now() + tokens.expires_in * 1e3
318
+ };
319
+ return tokens.access_token;
232
320
  }
233
321
  async refreshSession() {
234
322
  await this.bearer();
@@ -245,11 +333,12 @@ var GrimoireClient = class {
245
333
  async request(path, init = {}, auth = true) {
246
334
  let res = await this.send(path, init, auth);
247
335
  if (res.status === 401 && auth && !this.machineToken) {
248
- this.cachedIdToken = void 0;
336
+ this.cachedAccessToken = void 0;
249
337
  res = await this.send(path, init, auth);
250
338
  }
251
339
  return this.parseResponse(res);
252
340
  }
341
+ // FastAPI errors carry {"detail": "..."} (string) or {"detail": [...]} (422).
253
342
  async parseResponse(res) {
254
343
  const isJson = res.headers.get("content-type")?.includes("json") ?? false;
255
344
  const text = await res.text();
@@ -264,33 +353,28 @@ var GrimoireClient = class {
264
353
  }
265
354
  }
266
355
  if (!res.ok) {
267
- const code = parsed && typeof body === "object" && body !== null ? body.error ?? "error" : "error";
268
- throw new ApiError(res.status, code, parsed ? body : text);
356
+ const detail = parsed && typeof body === "object" && body !== null ? body.detail : void 0;
357
+ throw new ApiError(res.status, CODES[res.status] ?? "error", detail ?? (parsed ? body : text));
269
358
  }
270
359
  if (isJson)
271
360
  return parsed ? body : {};
272
361
  return text;
273
362
  }
274
363
  search(input) {
275
- return this.request("/v1/search", {
276
- method: "POST",
277
- body: JSON.stringify(input)
278
- });
364
+ return this.request("/v1/search", { method: "POST", body: JSON.stringify(input) });
279
365
  }
280
- listSources(q) {
281
- return this.request(`/v1/sources${q ? `?q=${encodeURIComponent(q)}` : ""}`);
366
+ listSources() {
367
+ return this.request("/v1/sources");
282
368
  }
283
- listVersions(sourceId) {
284
- return this.request(`/v1/sources/${encodeURIComponent(sourceId)}/versions`);
369
+ listVersions(product) {
370
+ return this.request(`/v1/sources/${encodeURIComponent(product)}/versions`);
285
371
  }
286
- getContext(chunkId, window = 2) {
287
- return this.request(`/v1/documents/${encodeURIComponent(chunkId)}/context?window=${window}`);
372
+ getDoc(pointId, window = 2) {
373
+ return this.request(`/v1/doc/${encodeURIComponent(pointId)}?window=${window}`);
288
374
  }
289
- reportResult(chunkId, verdict, note) {
290
- return this.request("/v1/feedback", {
291
- method: "POST",
292
- body: JSON.stringify({ chunk_id: chunkId, verdict, note })
293
- });
375
+ reportResult(pointId, verdict, note) {
376
+ const body = { point_id: pointId, verdict, ...note ? { note } : {} };
377
+ return this.request("/v1/report", { method: "POST", body: JSON.stringify(body) });
294
378
  }
295
379
  submitSource(body) {
296
380
  return this.request("/v1/sources", { method: "POST", body: JSON.stringify(body) });
@@ -298,12 +382,49 @@ var GrimoireClient = class {
298
382
  getJob(jobId) {
299
383
  return this.request(`/v1/jobs/${encodeURIComponent(jobId)}`);
300
384
  }
385
+ me() {
386
+ return this.request("/v1/me");
387
+ }
388
+ reviewQueue() {
389
+ return this.request("/v1/staff/review-queue");
390
+ }
391
+ approveJob(jobId) {
392
+ return this.request(`/v1/staff/jobs/${encodeURIComponent(jobId)}/approve`, { method: "POST" });
393
+ }
394
+ listUsers() {
395
+ return this.request("/v1/staff/users");
396
+ }
397
+ grantUser(subject, name) {
398
+ return this.request("/v1/staff/users", {
399
+ method: "POST",
400
+ body: JSON.stringify({ subject, name })
401
+ });
402
+ }
403
+ revokeUser(subject) {
404
+ return this.request(`/v1/staff/users/${encodeURIComponent(subject)}`, { method: "DELETE" });
405
+ }
406
+ rejectJob(jobId, reason) {
407
+ return this.request(`/v1/staff/jobs/${encodeURIComponent(jobId)}/reject`, {
408
+ method: "POST",
409
+ body: JSON.stringify({ reason })
410
+ });
411
+ }
301
412
  };
302
413
 
303
414
  // src/args.ts
304
415
  var UsageError = class extends Error {
305
416
  };
306
- var BOOL_FLAGS = /* @__PURE__ */ new Set(["json", "compact", "watch", "private", "names", "http", "unset"]);
417
+ var BOOL_FLAGS = /* @__PURE__ */ new Set([
418
+ "json",
419
+ "compact",
420
+ "watch",
421
+ "names",
422
+ "http",
423
+ "unset",
424
+ "rolling",
425
+ "debug",
426
+ "no-launch-browser"
427
+ ]);
307
428
  function parseArgs(argv, aliases = {}, allowed) {
308
429
  const positionals = [];
309
430
  const flags = {};
@@ -354,39 +475,39 @@ function requireFlagOneOf(parsed, name, allowed, usage) {
354
475
 
355
476
  // src/output.ts
356
477
  var EXIT = { ok: 0, apiError: 1, authRequired: 2, quota: 3, notFound: 4 };
357
- function noteRerank(status) {
358
- if (status === void 0) return;
359
- const label = status === "used" ? "reranker: used" : status === "degraded" ? "reranker: degraded (fused order)" : "reranker: disabled";
360
- process.stderr.write(`${label}
478
+ function preamble(res) {
479
+ const versions = Object.entries(res.resolved_versions).map(([product, version]) => `${product}@${version}`).join(", ");
480
+ process.stderr.write(`sources: ${versions} \xB7 retrievals remaining: ${res.retrievals_remaining}
361
481
  `);
362
- }
363
- function warnWeak(confidence) {
364
- if (confidence === "weak") {
365
- process.stderr.write(
366
- "note: low-confidence results \u2014 the docs may not cover this; tell the user rather than guessing.\n"
367
- );
482
+ process.stderr.write(`note: ${res.untrusted_content_notice}
483
+ `);
484
+ if (res.results.length === 0) {
485
+ process.stderr.write("note: no result passed the relevance threshold \u2014 the docs may not cover this.\n");
368
486
  }
369
487
  }
370
- function printResults(results, confidence, rerankStatus) {
371
- noteRerank(rerankStatus);
372
- warnWeak(confidence);
373
- for (const r of results) {
374
- const path = (r.heading_path ?? []).join(" \u203A ");
375
- process.stdout.write(`
376
- ${r.score.toFixed(3)} ${r.source}@${r.version} ${path}
377
- ${r.origin_url}
378
- `);
488
+ function heading(r) {
489
+ return r.heading_path.join(" \u203A ");
490
+ }
491
+ function printResults(res) {
492
+ preamble(res);
493
+ for (const r of res.results) {
494
+ process.stdout.write(
495
+ `
496
+ ${r.score.toFixed(3)} ${r.product}@${r.version} ${heading(r)}
497
+ ${r.source_url}
498
+ point_id: ${r.point_id}
499
+ `
500
+ );
379
501
  const preview = r.text.length > 500 ? `${r.text.slice(0, 500)}\u2026` : r.text;
380
502
  process.stdout.write(`${preview}
381
503
  `);
382
504
  }
383
505
  }
384
- function printCompact(results, confidence, rerankStatus) {
385
- noteRerank(rerankStatus);
386
- warnWeak(confidence);
387
- for (const r of results) {
506
+ function printCompact(res) {
507
+ preamble(res);
508
+ for (const r of res.results) {
388
509
  process.stdout.write(
389
- `${r.score.toFixed(3)} | ${r.source}@${r.version} | ${(r.heading_path ?? []).join(" \u203A ")} | ${r.origin_url}
510
+ `${r.score.toFixed(3)} | ${r.product}@${r.version} | ${heading(r)} | ${r.source_url} | ${r.point_id}
390
511
  `
391
512
  );
392
513
  }
@@ -438,7 +559,7 @@ function setupCodex() {
438
559
  function hasSession() {
439
560
  if (process.env.GRIMOIRE_AUTH_TOKEN) return true;
440
561
  try {
441
- return readMachineToken() !== void 0 || readRefreshToken() !== void 0;
562
+ return readMachineToken() !== void 0 || readSession() !== void 0;
442
563
  } catch {
443
564
  return false;
444
565
  }
@@ -538,7 +659,7 @@ var CONFIG_KEYS = {
538
659
  describe: `API origin without a path (default ${DEFAULT_API_BASE}; env GRIMOIRE_API_URL overrides)`,
539
660
  parse: (raw) => {
540
661
  if (!/^https?:\/\/[^/]+$/.test(raw)) {
541
- throw new UsageError("api-url must be a bare origin, e.g. https://grimoire-api.monadeo.com");
662
+ throw new UsageError("api-url must be a bare origin, e.g. https://api.example.com");
542
663
  }
543
664
  return raw;
544
665
  }
@@ -547,23 +668,10 @@ var CONFIG_KEYS = {
547
668
  prop: "updateCheckHours",
548
669
  describe: "hours between CLI update checks (default 24, 0 disables)",
549
670
  parse: (raw) => intInRange("update-check-hours", raw, 0)
550
- },
551
- language: {
552
- prop: "defaultLanguage",
553
- describe: "default search language filter",
554
- parse: (raw) => {
555
- if (!raw) throw new UsageError("language must be non-empty");
556
- return raw;
557
- }
558
- },
559
- "max-response-tokens": {
560
- prop: "maxResponseTokens",
561
- describe: "search response token budget",
562
- parse: (raw) => intInRange("max-response-tokens", raw, 1)
563
671
  }
564
672
  };
565
673
  var AUTH_TOKEN_KEY = "auth-token";
566
- var MACHINE_TOKEN_RE = /^mt_[0-9a-f]{64}$/;
674
+ var MACHINE_TOKEN_RE = /^mt_[A-Za-z0-9_-]{32,}$/;
567
675
  var USAGE = "Usage: grimoire config [<key>] [<value>] [--unset] (keys: " + [...Object.keys(CONFIG_KEYS), AUTH_TOKEN_KEY].join(", ") + ")";
568
676
  function runConfig(args) {
569
677
  const [key, value] = args.positionals;
@@ -595,7 +703,7 @@ function runConfig(args) {
595
703
  return EXIT.ok;
596
704
  }
597
705
  if (!MACHINE_TOKEN_RE.test(value)) {
598
- throw new UsageError("auth-token must be a machine token, e.g. mt_<64 hex chars>");
706
+ throw new UsageError("auth-token must be a machine token starting with mt_");
599
707
  }
600
708
  storeMachineToken(value);
601
709
  process.stdout.write(`${AUTH_TOKEN_KEY} set
@@ -668,6 +776,33 @@ function runUpdate() {
668
776
  return result.status === 0 ? EXIT.ok : EXIT.apiError;
669
777
  }
670
778
 
779
+ // src/commands/ingest.ts
780
+ function submissionFromArgs(url, args) {
781
+ const usage = "Usage: grimoire ingest <url> --product <name> (--rolling | --fixed <version> | --npm <pkg> | --pypi <pkg> | --github <owner/repo>)";
782
+ const product = args.flags.product?.[0];
783
+ if (!product) throw new UsageError(usage);
784
+ const fixed = args.flags.fixed?.[0];
785
+ const npm = args.flags.npm?.[0];
786
+ const pypi = args.flags.pypi?.[0];
787
+ const github = args.flags.github?.[0];
788
+ const probes = [npm, pypi, github].filter(Boolean).length;
789
+ if (probes > 1) throw new UsageError("Pass only one of --npm, --pypi, --github");
790
+ const body = {
791
+ url,
792
+ product,
793
+ version_rule: { kind: "rolling" },
794
+ include_patterns: args.flags.include ?? [],
795
+ exclude_patterns: args.flags.exclude ?? []
796
+ };
797
+ if (args.bools.has("rolling")) return body;
798
+ if (fixed) return { ...body, version_rule: { kind: "fixed", value: fixed } };
799
+ if (npm) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "npm", package: npm } };
800
+ if (pypi) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "pypi", package: pypi } };
801
+ if (github) return { ...body, version_rule: { kind: "fixed" }, probe: { kind: "github", repo: github } };
802
+ throw new UsageError(`${usage}
803
+ A version rule is mandatory: --rolling for unversioned docs, --fixed, or a release probe.`);
804
+ }
805
+
671
806
  // src/updatecheck.ts
672
807
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
673
808
  import { homedir as homedir4 } from "node:os";
@@ -724,15 +859,21 @@ async function refreshUpdateState(current, configuredHours) {
724
859
  }
725
860
 
726
861
  // src/index.ts
727
- function openBrowser(url) {
728
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
729
- try {
730
- execFileSync(cmd, [url], { stdio: "ignore" });
731
- } catch {
732
- process.stdout.write(`Open this URL to log in:
733
- ${url}
862
+ function loginOpener(launchBrowser) {
863
+ return (url) => {
864
+ process.stdout.write(`${url}
734
865
  `);
735
- }
866
+ if (!launchBrowser) {
867
+ process.stderr.write("Open the URL above in a browser to log in; waiting for the callback\u2026\n");
868
+ return;
869
+ }
870
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
871
+ try {
872
+ execFileSync(cmd, [url], { stdio: "ignore" });
873
+ } catch {
874
+ process.stderr.write("Could not open a browser; open the URL above manually.\n");
875
+ }
876
+ };
736
877
  }
737
878
  function parseSourceFlags(values) {
738
879
  return (values ?? []).map((v) => {
@@ -740,33 +881,39 @@ function parseSourceFlags(values) {
740
881
  return version ? { source, version } : { source };
741
882
  });
742
883
  }
743
- var VERSION = true ? "0.3.7" : "dev";
884
+ function toSelectors(pins) {
885
+ return pins.map((p) => ({ product: p.source, ...p.version ? { version: p.version } : {} }));
886
+ }
887
+ var VERSION = true ? "0.4.0" : "dev";
744
888
  var HELP = `grimoire ${VERSION} \u2014 documentation retrieval for AI agents
745
889
 
746
- grimoire login | logout | whoami
890
+ grimoire login [--no-launch-browser] | logout | whoami
747
891
  grimoire setup <claude-code|cursor|windsurf|codex>
748
892
  grimoire init
749
- grimoire search "<query>" [-s nextjs@15 -s react] [--lang en] [--top-k 8] [--json|--compact]
893
+ grimoire search "<query>" [-s nextjs@15 -s react] [--json|--compact] [--debug]
750
894
  grimoire sources [--q <kw>] [--names|--json]
751
- grimoire versions <source> [--json]
895
+ grimoire versions <product> [--json]
752
896
  grimoire config [<key>] [<value>] [--unset]
753
897
  grimoire update
754
- grimoire doc <chunk_id> [--window 2]
755
- grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]
756
- grimoire ingest <url> [--version 15.2] [--private] [--webhook URL] [--watch]
898
+ grimoire doc <point_id> [--window 2] [--json]
899
+ grimoire report <point_id> --verdict helpful|incorrect|outdated [--note "..."]
900
+ grimoire ingest <url> --product <name> (--rolling | --fixed <version> | --npm <pkg> | --pypi <pkg> | --github <owner/repo>)
901
+ [--include <pattern>]... [--exclude <pattern>]... [--watch]
757
902
  grimoire jobs <job_id> [--watch]
903
+ grimoire staff queue [--json] | approve <job_id> | reject <job_id> --reason "..."
904
+ grimoire staff users [--json] | grant <subject> --name "..." | revoke <subject>
758
905
  grimoire mcp [--http]
759
906
  grimoire help | --help | -h
760
907
  grimoire version | --version | -v
761
908
 
762
909
  env: GRIMOIRE_AUTH_TOKEN \u2014 machine token (CI, instead of login)
763
- GRIMOIRE_API_URL \u2014 API origin without a path, e.g. https://grimoire-api-qa.monadeo.com
910
+ GRIMOIRE_API_URL \u2014 API origin without a path
764
911
  `;
765
912
  var COMMAND_FLAGS = {
766
913
  version: [],
767
914
  "--version": [],
768
915
  "-v": [],
769
- login: [],
916
+ login: ["no-launch-browser"],
770
917
  logout: [],
771
918
  setup: [],
772
919
  init: [],
@@ -777,14 +924,23 @@ var COMMAND_FLAGS = {
777
924
  mcp: ["http"],
778
925
  config: ["unset"],
779
926
  update: [],
780
- search: ["source", "lang", "top-k", "json", "compact"],
927
+ search: ["source", "json", "compact", "debug"],
781
928
  sources: ["q", "names", "json"],
782
929
  versions: ["json"],
783
- doc: ["window"],
930
+ doc: ["window", "json"],
784
931
  report: ["verdict", "note"],
785
- ingest: ["version", "private", "webhook", "watch"],
786
- jobs: ["watch"]
932
+ ingest: ["product", "rolling", "fixed", "npm", "pypi", "github", "include", "exclude", "watch"],
933
+ jobs: ["watch"],
934
+ staff: ["reason", "name", "json"]
787
935
  };
936
+ function describeError(err) {
937
+ const detail = typeof err.body === "string" ? err.body : err.body !== void 0 && err.body !== null ? JSON.stringify(err.body) : "";
938
+ return `error: ${err.code}${detail ? ` \u2014 ${detail}` : ""}`;
939
+ }
940
+ function describeJob(job) {
941
+ const extra = [job.reason ? `reason: ${job.reason}` : "", job.source_id ? `source: ${job.source_id}` : ""].filter(Boolean).join(" ");
942
+ return `${job.state}${extra ? ` ${extra}` : ""}`;
943
+ }
788
944
  async function main(argv) {
789
945
  const [command, ...rest] = argv;
790
946
  const args = parseArgs(rest, { "-s": "source", "-q": "q" }, command !== void 0 ? COMMAND_FLAGS[command] : []);
@@ -797,12 +953,14 @@ async function main(argv) {
797
953
  `);
798
954
  return EXIT.ok;
799
955
  case "login": {
800
- await browserLogin(loadGlobalConfig().apiBaseUrl, openBrowser);
956
+ const launch = !args.bools.has("no-launch-browser");
957
+ process.stderr.write(launch ? "Opening your browser to log in\u2026\n" : "Login URL:\n");
958
+ await browserLogin(loadGlobalConfig().apiBaseUrl, loginOpener(launch));
801
959
  process.stdout.write("Logged in.\n");
802
960
  return EXIT.ok;
803
961
  }
804
962
  case "logout":
805
- clearRefreshToken();
963
+ clearSession();
806
964
  process.stdout.write("Logged out.\n");
807
965
  return EXIT.ok;
808
966
  case "setup":
@@ -827,33 +985,22 @@ async function main(argv) {
827
985
  try {
828
986
  switch (command) {
829
987
  case "whoami": {
830
- if (process.env.GRIMOIRE_AUTH_TOKEN) {
831
- process.stdout.write("machine token configured (GRIMOIRE_AUTH_TOKEN)\n");
832
- return EXIT.ok;
833
- }
834
- if (readMachineToken()) {
835
- process.stdout.write("machine token configured (grimoire config auth-token)\n");
836
- return EXIT.ok;
837
- }
838
- if (!readRefreshToken()) {
988
+ const via = process.env.GRIMOIRE_AUTH_TOKEN ? "GRIMOIRE_AUTH_TOKEN" : readMachineToken() ? "grimoire config auth-token" : readSession() ? "browser login" : void 0;
989
+ if (!via) {
839
990
  process.stderr.write("not logged in \u2014 run `grimoire login`\n");
840
991
  return EXIT.authRequired;
841
992
  }
842
- try {
843
- await client.refreshSession();
844
- process.stdout.write("logged in (browser session)\n");
845
- return EXIT.ok;
846
- } catch (err) {
847
- const reason = err instanceof ApiError ? err.code : err.message;
848
- process.stderr.write(`not logged in (${reason}) \u2014 run \`grimoire login\`
849
- `);
850
- return EXIT.authRequired;
851
- }
993
+ const me = await client.me();
994
+ process.stdout.write(
995
+ `${me.kind} ${me.subject}${me.is_staff ? " staff" : ""} quota ${me.quota_per_day}/day via ${via}
996
+ `
997
+ );
998
+ return EXIT.ok;
852
999
  }
853
1000
  case "search": {
854
1001
  const query = args.positionals[0];
855
1002
  if (!query) {
856
- process.stderr.write('Usage: grimoire search "<query>" -s <source>\n');
1003
+ process.stderr.write('Usage: grimoire search "<query>" -s <product>[@version]\n');
857
1004
  return EXIT.apiError;
858
1005
  }
859
1006
  const explicit = parseSourceFlags(args.flags.source);
@@ -861,99 +1008,73 @@ async function main(argv) {
861
1008
  if (sources.length === 0) {
862
1009
  const initHelps = existsSync6(join6(process.cwd(), "package.json")) || existsSync6(join6(process.cwd(), "requirements.txt"));
863
1010
  process.stderr.write(
864
- 'No sources selected. List what is indexed:\n grimoire sources\nthen scope the search:\n grimoire search "<query>" -s <source>[@version]\n' + (initHelps ? "or pin this project's sources from its dependencies:\n grimoire init\n" : "")
1011
+ 'No sources selected. List what is indexed:\n grimoire sources\nthen scope the search:\n grimoire search "<query>" -s <product>[@version]\n' + (initHelps ? "or pin this project's sources from its dependencies:\n grimoire init\n" : "")
865
1012
  );
866
1013
  return EXIT.apiError;
867
1014
  }
868
- const res = await client.search({
869
- query,
870
- sources,
871
- language: args.flags.lang?.[0],
872
- top_k: intFlag(args, "top-k")
873
- });
1015
+ const res = await client.search({ query, sources: toSelectors(sources), debug: args.bools.has("debug") });
874
1016
  if (json) process.stdout.write(JSON.stringify(res, null, 2) + "\n");
875
- else if (args.bools.has("compact")) printCompact(res.results, res.confidence, res.rerank_status);
876
- else printResults(res.results, res.confidence, res.rerank_status);
1017
+ else if (args.bools.has("compact")) printCompact(res);
1018
+ else printResults(res);
877
1019
  return EXIT.ok;
878
1020
  }
879
1021
  case "sources": {
880
- const res = await client.listSources(args.flags.q?.[0]);
881
- const sources = res.sources ?? [];
1022
+ const needle = args.flags.q?.[0]?.toLowerCase();
1023
+ const sources = (await client.listSources()).filter(
1024
+ (s) => !needle || s.product.toLowerCase().includes(needle) || s.base_url.toLowerCase().includes(needle)
1025
+ );
882
1026
  if (json) {
883
1027
  process.stdout.write(JSON.stringify(sources, null, 2) + "\n");
884
1028
  } else if (args.bools.has("names")) {
885
- for (const s of sources) process.stdout.write(`${s.source_id}
1029
+ for (const s of sources) process.stdout.write(`${s.product}
886
1030
  `);
887
1031
  } else {
888
1032
  for (const s of sources) {
889
- const latest = s.latest_semver ?? s.latest_version ?? "-";
890
- const vis = s.visibility === "private" ? " (private)" : "";
891
- const meta = [
892
- s.latest_chunks != null ? `${s.latest_chunks} chunks` : "",
893
- s.latest_pages != null ? `${s.latest_pages} pages` : "",
894
- s.latest_crawled_at ? `crawled ${s.latest_crawled_at.slice(0, 10)}` : ""
895
- ].filter(Boolean).join(" \xB7 ");
896
- process.stdout.write(
897
- [`${s.source_id}@${latest}${vis}`, meta, s.origin_url ?? ""].filter(Boolean).join(" ") + "\n"
898
- );
1033
+ const versions = s.versions.length > 0 ? s.versions.join(", ") : "(not indexed yet)";
1034
+ process.stdout.write(`${s.product} ${versions} ${s.base_url}
1035
+ `);
899
1036
  }
900
1037
  }
901
1038
  return EXIT.ok;
902
1039
  }
903
1040
  case "versions": {
904
- const source = requirePositional(args, 0, "Usage: grimoire versions <source> [--json]");
905
- const res = await client.listVersions(source);
906
- const versions = res.versions ?? [];
1041
+ const product = requirePositional(args, 0, "Usage: grimoire versions <product> [--json]");
1042
+ const res = await client.listVersions(product);
907
1043
  if (json) {
908
- process.stdout.write(JSON.stringify(versions, null, 2) + "\n");
1044
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
909
1045
  return EXIT.ok;
910
1046
  }
911
- const available = versions.filter((v) => v.status === "active");
912
- if (available.length === 0) {
913
- process.stdout.write(`no active versions for ${source}
1047
+ for (const v of res.versions) {
1048
+ process.stdout.write(`${v.version}${v.version === res.latest ? " latest" : ""} ${v.chunk_count} chunks
914
1049
  `);
915
- return EXIT.ok;
916
- }
917
- for (const v of available) {
918
- const label = v.semver ?? v.version_id;
919
- const parts = [
920
- label,
921
- v.is_latest ? "latest" : "",
922
- `${v.chunk_count ?? 0} chunks`,
923
- `crawled ${(v.ingested_at ?? "").slice(0, 10)}`,
924
- v.semver ? `(${v.version_id})` : ""
925
- ].filter(Boolean);
926
- process.stdout.write(parts.join(" ") + "\n");
927
1050
  }
928
1051
  return EXIT.ok;
929
1052
  }
930
1053
  case "doc": {
931
- const chunkId = requirePositional(args, 0, "Usage: grimoire doc <chunk_id> [--window 2]");
1054
+ const pointId = requirePositional(args, 0, "Usage: grimoire doc <point_id> [--window 2] [--json]");
932
1055
  const window = intFlag(args, "window", { min: 0, max: 5 }) ?? 2;
933
- const res = await client.getContext(chunkId, window);
934
- process.stdout.write(JSON.stringify(res.chunks ?? [], null, 2) + "\n");
1056
+ const res = await client.getDoc(pointId, window);
1057
+ if (json) {
1058
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
1059
+ return EXIT.ok;
1060
+ }
1061
+ process.stderr.write(`${res.product}@${res.version} ${res.heading_path.join(" \u203A ")}
1062
+ ${res.source_url}
1063
+ `);
1064
+ process.stdout.write(res.text + "\n");
935
1065
  return EXIT.ok;
936
1066
  }
937
1067
  case "report": {
938
- const usage = 'Usage: grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]';
939
- const chunkId = requirePositional(args, 0, usage);
1068
+ const usage = 'Usage: grimoire report <point_id> --verdict helpful|incorrect|outdated [--note "..."]';
1069
+ const pointId = requirePositional(args, 0, usage);
940
1070
  const verdict = requireFlagOneOf(args, "verdict", ["helpful", "incorrect", "outdated"], usage);
941
- await client.reportResult(chunkId, verdict, args.flags.note?.[0]);
1071
+ await client.reportResult(pointId, verdict, args.flags.note?.[0]);
942
1072
  process.stdout.write("Reported.\n");
943
1073
  return EXIT.ok;
944
1074
  }
945
1075
  case "ingest": {
946
- const url = requirePositional(
947
- args,
948
- 0,
949
- "Usage: grimoire ingest <url> [--version 15.2] [--private] [--webhook URL] [--watch]"
950
- );
951
- const res = await client.submitSource({
952
- url,
953
- version: args.flags.version?.[0],
954
- visibility: args.bools.has("private") ? "private" : "public",
955
- webhook_url: args.flags.webhook?.[0]
956
- });
1076
+ const url = requirePositional(args, 0, "Usage: grimoire ingest <url> --product <name> --rolling|--fixed|--npm|--pypi|--github");
1077
+ const res = await client.submitSource(submissionFromArgs(url, args));
957
1078
  process.stdout.write(`Job: ${res.job_id}
958
1079
  `);
959
1080
  if (args.bools.has("watch")) return watchJob(client, res.job_id);
@@ -963,6 +1084,54 @@ async function main(argv) {
963
1084
  const jobId = requirePositional(args, 0, "Usage: grimoire jobs <job_id> [--watch]");
964
1085
  return args.bools.has("watch") ? watchJob(client, jobId) : printJob(client, jobId);
965
1086
  }
1087
+ case "staff": {
1088
+ const usage = 'Usage: grimoire staff queue [--json] | approve <job_id> | reject <job_id> --reason "..." | users [--json] | grant <subject> --name "..." | revoke <subject>';
1089
+ const [action, jobId] = args.positionals;
1090
+ if (action === "users") {
1091
+ const users = await client.listUsers();
1092
+ if (json) process.stdout.write(JSON.stringify(users, null, 2) + "\n");
1093
+ else for (const u of users) process.stdout.write(`${u.subject} ${u.status} ${u.name} by ${u.granted_by}
1094
+ `);
1095
+ return EXIT.ok;
1096
+ }
1097
+ if (action === "grant") {
1098
+ const name = args.flags.name?.[0];
1099
+ if (!jobId || !name) throw new UsageError(usage);
1100
+ const granted = await client.grantUser(jobId, name);
1101
+ process.stdout.write(`${granted.subject} ${granted.status} ${granted.name}
1102
+ `);
1103
+ return EXIT.ok;
1104
+ }
1105
+ if (action === "revoke") {
1106
+ if (!jobId) throw new UsageError(usage);
1107
+ const revoked = await client.revokeUser(jobId);
1108
+ process.stdout.write(`${revoked.subject} ${revoked.status}
1109
+ `);
1110
+ return EXIT.ok;
1111
+ }
1112
+ if (action === "queue") {
1113
+ const queue = await client.reviewQueue();
1114
+ if (json) process.stdout.write(JSON.stringify(queue, null, 2) + "\n");
1115
+ else if (queue.length === 0) process.stdout.write("review queue is empty\n");
1116
+ else for (const job of queue) process.stdout.write(`${job.id} ${describeJob(job)}
1117
+ `);
1118
+ return EXIT.ok;
1119
+ }
1120
+ if (!jobId) throw new UsageError(usage);
1121
+ if (action === "approve") {
1122
+ process.stdout.write(`${describeJob(await client.approveJob(jobId))}
1123
+ `);
1124
+ return EXIT.ok;
1125
+ }
1126
+ if (action === "reject") {
1127
+ const reason = args.flags.reason?.[0];
1128
+ if (!reason) throw new UsageError(usage);
1129
+ process.stdout.write(`${describeJob(await client.rejectJob(jobId, reason))}
1130
+ `);
1131
+ return EXIT.ok;
1132
+ }
1133
+ throw new UsageError(usage);
1134
+ }
966
1135
  default:
967
1136
  process.stderr.write(`Unknown command: ${command}
968
1137
  ${HELP}`);
@@ -970,14 +1139,10 @@ ${HELP}`);
970
1139
  }
971
1140
  } catch (err) {
972
1141
  if (err instanceof ApiError) {
973
- const body = typeof err.body === "object" && err.body !== null ? err.body : {};
974
- process.stderr.write(`error: ${err.code}${body.message ? ` \u2014 ${body.message}` : ""}
975
- `);
976
- if (body.available?.length) process.stderr.write(`available: ${body.available.join(", ")}
977
- `);
978
- if (body.did_you_mean?.length) process.stderr.write(`did you mean: ${body.did_you_mean.join(", ")}
1142
+ process.stderr.write(`${describeError(err)}
979
1143
  `);
980
1144
  if (err.status === 401) return EXIT.authRequired;
1145
+ if (err.status === 403) return EXIT.authRequired;
981
1146
  if (err.status === 429) return EXIT.quota;
982
1147
  if (err.status === 404) return EXIT.notFound;
983
1148
  return EXIT.apiError;
@@ -985,14 +1150,13 @@ ${HELP}`);
985
1150
  throw err;
986
1151
  }
987
1152
  }
988
- var TERMINAL_STATUSES = ["complete", "failed", "rejected"];
989
- var FAILED_STATUSES = ["failed", "rejected"];
1153
+ var TERMINAL_STATES = ["accepted", "done", "rejected", "failed", "pending_review"];
1154
+ var FAILED_STATES = ["rejected", "failed"];
990
1155
  async function printJob(client, jobId) {
991
1156
  const job = await client.getJob(jobId);
992
- const status = job.status ?? "unknown";
993
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1157
+ process.stdout.write(`${describeJob(job)}
994
1158
  `);
995
- return FAILED_STATUSES.includes(status) ? EXIT.apiError : EXIT.ok;
1159
+ return FAILED_STATES.includes(job.state) ? EXIT.apiError : EXIT.ok;
996
1160
  }
997
1161
  var WATCH_POLL_MS = 5e3;
998
1162
  var WATCH_BACKOFF_CAP_MS = 3e4;
@@ -1006,11 +1170,13 @@ async function watchJob(client, jobId) {
1006
1170
  while (Date.now() < deadline) {
1007
1171
  try {
1008
1172
  const job = await client.getJob(jobId);
1009
- const status = job.status ?? "unknown";
1010
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1173
+ process.stdout.write(`${describeJob(job)}
1011
1174
  `);
1012
- if (TERMINAL_STATUSES.includes(status)) {
1013
- return FAILED_STATUSES.includes(status) ? EXIT.apiError : EXIT.ok;
1175
+ if (TERMINAL_STATES.includes(job.state)) {
1176
+ if (job.state === "pending_review") {
1177
+ process.stderr.write("Parked for staff review: grimoire staff queue\n");
1178
+ }
1179
+ return FAILED_STATES.includes(job.state) ? EXIT.apiError : EXIT.ok;
1014
1180
  }
1015
1181
  backoff = WATCH_POLL_MS;
1016
1182
  await sleep(WATCH_POLL_MS);