@monadeo.com/grimoire-cli 0.3.6 → 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,116 +99,224 @@ 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
  }
132
+ function machineTokenPath() {
133
+ return join2(configDir(), "machine-token");
134
+ }
135
+ function storeMachineToken(token) {
136
+ const path = machineTokenPath();
137
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
138
+ writeFileSync2(path, token + "\n", { mode: 384 });
139
+ }
140
+ function readMachineToken() {
141
+ try {
142
+ const token = readFileSync2(machineTokenPath(), "utf8").trim();
143
+ return token !== "" ? token : void 0;
144
+ } catch {
145
+ return void 0;
146
+ }
147
+ }
148
+ function clearMachineToken() {
149
+ if (existsSync2(machineTokenPath()))
150
+ rmSync(machineTokenPath());
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
+ }
122
194
  function base64url(buf) {
123
195
  return buf.toString("base64url");
124
196
  }
125
- async function browserLogin(apiBase, openBrowser2) {
126
- 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);
127
218
  const verifier = base64url(randomBytes(32));
128
219
  const challenge = base64url(createHash("sha256").update(verifier).digest());
129
220
  const state = base64url(randomBytes(16));
130
221
  const code = await new Promise((resolve, reject) => {
131
- 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) => {
132
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");
133
240
  const received = url.searchParams.get("code");
134
- if (req.method !== "GET" || url.pathname !== "/callback" || url.searchParams.get("state") !== state || !received) {
135
- 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"}`) });
136
244
  return;
137
245
  }
138
- res2.writeHead(302, { Location: `${broker}/auth/cli/success` }).end();
139
- clearTimeout(timer);
140
- server.close();
141
- resolve(received);
246
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("Logged in to Grimoire. You can close this tab.");
247
+ finish2({ code: received });
142
248
  });
143
- server.listen(0, "127.0.0.1", () => {
144
- const port = server.address().port;
145
- const redirect = `http://127.0.0.1:${port}/callback`;
146
- openBrowser2(`${broker}/auth/cli/start?code_challenge=${challenge}&code_challenge_method=S256&state=${state}&redirect_uri=${encodeURIComponent(redirect)}`);
147
- });
148
- const timer = setTimeout(() => {
149
- server.close();
150
- reject(new Error("Login timed out"));
151
- }, 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 }));
152
258
  });
153
- const res = await fetchWithTimeout(`${broker}/auth/cli/exchange`, {
154
- method: "POST",
155
- headers: { "Content-Type": "application/json" },
156
- 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
157
267
  });
158
- if (!res.ok)
159
- throw new Error(`Token exchange failed: ${res.status}`);
160
- const { refresh_token } = await res.json();
161
- if (typeof refresh_token !== "string" || refresh_token.length === 0) {
162
- throw new Error("Token exchange succeeded but returned no refresh token");
163
- }
164
- storeRefreshToken(refresh_token);
165
268
  }
166
269
 
167
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
+ };
168
278
  var GrimoireClient = class {
169
279
  baseUrl;
170
280
  machineToken;
171
- cachedIdToken;
281
+ cachedAccessToken;
172
282
  refreshInFlight;
173
283
  constructor(opts = {}) {
174
284
  this.baseUrl = (opts.baseUrl ?? loadGlobalConfig().apiBaseUrl).replace(/\/+$/, "");
175
- this.machineToken = opts.machineToken ?? process.env.GRIMOIRE_AUTH_TOKEN;
285
+ this.machineToken = opts.machineToken ?? process.env.GRIMOIRE_AUTH_TOKEN ?? readMachineToken();
176
286
  }
177
287
  async bearer() {
178
288
  if (this.machineToken)
179
289
  return this.machineToken;
180
- if (this.cachedIdToken && this.cachedIdToken.expiresAt > Date.now() + 6e4) {
181
- return this.cachedIdToken.token;
290
+ if (this.cachedAccessToken && this.cachedAccessToken.expiresAt > Date.now() + 6e4) {
291
+ return this.cachedAccessToken.token;
182
292
  }
183
- this.refreshInFlight ??= this.refreshIdToken().finally(() => {
293
+ this.refreshInFlight ??= this.refreshAccessToken().finally(() => {
184
294
  this.refreshInFlight = void 0;
185
295
  });
186
296
  return this.refreshInFlight;
187
297
  }
188
- // Exchange the Firebase refresh token for a fresh ID token (silent refresh).
189
- async refreshIdToken() {
190
- const refresh = readRefreshToken();
191
- 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)
192
303
  throw new ApiError(401, "not_logged_in", "Run `grimoire login`");
193
- const res = await fetchWithTimeout(`${this.baseUrl}/auth/cli/refresh`, {
194
- method: "POST",
195
- headers: { "Content-Type": "application/json" },
196
- body: JSON.stringify({ refresh_token: refresh })
197
- });
198
- if (!res.ok)
199
- throw new ApiError(res.status, "refresh_failed", "Run `grimoire login`");
200
- let payload;
304
+ let tokens;
201
305
  try {
202
- payload = await res.json();
203
- } catch {
204
- throw new ApiError(res.status, "refresh_failed", "Malformed refresh response");
205
- }
206
- const { id_token, expires_in } = payload;
207
- if (typeof id_token !== "string" || typeof expires_in !== "number") {
208
- 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;
209
313
  }
210
- this.cachedIdToken = { token: id_token, expiresAt: Date.now() + expires_in * 1e3 };
211
- 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;
212
320
  }
213
321
  async refreshSession() {
214
322
  await this.bearer();
@@ -225,11 +333,12 @@ var GrimoireClient = class {
225
333
  async request(path, init = {}, auth = true) {
226
334
  let res = await this.send(path, init, auth);
227
335
  if (res.status === 401 && auth && !this.machineToken) {
228
- this.cachedIdToken = void 0;
336
+ this.cachedAccessToken = void 0;
229
337
  res = await this.send(path, init, auth);
230
338
  }
231
339
  return this.parseResponse(res);
232
340
  }
341
+ // FastAPI errors carry {"detail": "..."} (string) or {"detail": [...]} (422).
233
342
  async parseResponse(res) {
234
343
  const isJson = res.headers.get("content-type")?.includes("json") ?? false;
235
344
  const text = await res.text();
@@ -244,33 +353,28 @@ var GrimoireClient = class {
244
353
  }
245
354
  }
246
355
  if (!res.ok) {
247
- const code = parsed && typeof body === "object" && body !== null ? body.error ?? "error" : "error";
248
- 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));
249
358
  }
250
359
  if (isJson)
251
360
  return parsed ? body : {};
252
361
  return text;
253
362
  }
254
363
  search(input) {
255
- return this.request("/v1/search", {
256
- method: "POST",
257
- body: JSON.stringify(input)
258
- });
364
+ return this.request("/v1/search", { method: "POST", body: JSON.stringify(input) });
259
365
  }
260
- listSources(q) {
261
- return this.request(`/v1/sources${q ? `?q=${encodeURIComponent(q)}` : ""}`);
366
+ listSources() {
367
+ return this.request("/v1/sources");
262
368
  }
263
- listVersions(sourceId) {
264
- return this.request(`/v1/sources/${encodeURIComponent(sourceId)}/versions`);
369
+ listVersions(product) {
370
+ return this.request(`/v1/sources/${encodeURIComponent(product)}/versions`);
265
371
  }
266
- getContext(chunkId, window = 2) {
267
- 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}`);
268
374
  }
269
- reportResult(chunkId, verdict, note) {
270
- return this.request("/v1/feedback", {
271
- method: "POST",
272
- body: JSON.stringify({ chunk_id: chunkId, verdict, note })
273
- });
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) });
274
378
  }
275
379
  submitSource(body) {
276
380
  return this.request("/v1/sources", { method: "POST", body: JSON.stringify(body) });
@@ -278,12 +382,49 @@ var GrimoireClient = class {
278
382
  getJob(jobId) {
279
383
  return this.request(`/v1/jobs/${encodeURIComponent(jobId)}`);
280
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
+ }
281
412
  };
282
413
 
283
414
  // src/args.ts
284
415
  var UsageError = class extends Error {
285
416
  };
286
- 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
+ ]);
287
428
  function parseArgs(argv, aliases = {}, allowed) {
288
429
  const positionals = [];
289
430
  const flags = {};
@@ -334,39 +475,39 @@ function requireFlagOneOf(parsed, name, allowed, usage) {
334
475
 
335
476
  // src/output.ts
336
477
  var EXIT = { ok: 0, apiError: 1, authRequired: 2, quota: 3, notFound: 4 };
337
- function noteRerank(status) {
338
- if (status === void 0) return;
339
- const label = status === "used" ? "reranker: used" : status === "degraded" ? "reranker: degraded (fused order)" : "reranker: disabled";
340
- 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}
341
481
  `);
342
- }
343
- function warnWeak(confidence) {
344
- if (confidence === "weak") {
345
- process.stderr.write(
346
- "note: low-confidence results \u2014 the docs may not cover this; tell the user rather than guessing.\n"
347
- );
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");
348
486
  }
349
487
  }
350
- function printResults(results, confidence, rerankStatus) {
351
- noteRerank(rerankStatus);
352
- warnWeak(confidence);
353
- for (const r of results) {
354
- const path = (r.heading_path ?? []).join(" \u203A ");
355
- process.stdout.write(`
356
- ${r.score.toFixed(3)} ${r.source}@${r.version} ${path}
357
- ${r.origin_url}
358
- `);
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
+ );
359
501
  const preview = r.text.length > 500 ? `${r.text.slice(0, 500)}\u2026` : r.text;
360
502
  process.stdout.write(`${preview}
361
503
  `);
362
504
  }
363
505
  }
364
- function printCompact(results, confidence, rerankStatus) {
365
- noteRerank(rerankStatus);
366
- warnWeak(confidence);
367
- for (const r of results) {
506
+ function printCompact(res) {
507
+ preamble(res);
508
+ for (const r of res.results) {
368
509
  process.stdout.write(
369
- `${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}
370
511
  `
371
512
  );
372
513
  }
@@ -418,7 +559,7 @@ function setupCodex() {
418
559
  function hasSession() {
419
560
  if (process.env.GRIMOIRE_AUTH_TOKEN) return true;
420
561
  try {
421
- return readRefreshToken() !== void 0;
562
+ return readMachineToken() !== void 0 || readSession() !== void 0;
422
563
  } catch {
423
564
  return false;
424
565
  }
@@ -518,7 +659,7 @@ var CONFIG_KEYS = {
518
659
  describe: `API origin without a path (default ${DEFAULT_API_BASE}; env GRIMOIRE_API_URL overrides)`,
519
660
  parse: (raw) => {
520
661
  if (!/^https?:\/\/[^/]+$/.test(raw)) {
521
- 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");
522
663
  }
523
664
  return raw;
524
665
  }
@@ -527,22 +668,11 @@ var CONFIG_KEYS = {
527
668
  prop: "updateCheckHours",
528
669
  describe: "hours between CLI update checks (default 24, 0 disables)",
529
670
  parse: (raw) => intInRange("update-check-hours", raw, 0)
530
- },
531
- language: {
532
- prop: "defaultLanguage",
533
- describe: "default search language filter",
534
- parse: (raw) => {
535
- if (!raw) throw new UsageError("language must be non-empty");
536
- return raw;
537
- }
538
- },
539
- "max-response-tokens": {
540
- prop: "maxResponseTokens",
541
- describe: "search response token budget",
542
- parse: (raw) => intInRange("max-response-tokens", raw, 1)
543
671
  }
544
672
  };
545
- var USAGE = "Usage: grimoire config [<key>] [<value>] [--unset] (keys: " + Object.keys(CONFIG_KEYS).join(", ") + ")";
673
+ var AUTH_TOKEN_KEY = "auth-token";
674
+ var MACHINE_TOKEN_RE = /^mt_[A-Za-z0-9_-]{32,}$/;
675
+ var USAGE = "Usage: grimoire config [<key>] [<value>] [--unset] (keys: " + [...Object.keys(CONFIG_KEYS), AUTH_TOKEN_KEY].join(", ") + ")";
546
676
  function runConfig(args) {
547
677
  const [key, value] = args.positionals;
548
678
  if (key === void 0) {
@@ -554,6 +684,30 @@ function runConfig(args) {
554
684
  process.stdout.write(`${name} = ${current === void 0 ? "(unset)" : JSON.stringify(current)} # ${spec2.describe}
555
685
  `);
556
686
  }
687
+ process.stdout.write(
688
+ `${AUTH_TOKEN_KEY} = ${readMachineToken() ? "(set)" : "(unset)"} # machine token, stored 0600 (env GRIMOIRE_AUTH_TOKEN overrides)
689
+ `
690
+ );
691
+ return EXIT.ok;
692
+ }
693
+ if (key === AUTH_TOKEN_KEY) {
694
+ if (args.bools.has("unset")) {
695
+ clearMachineToken();
696
+ process.stdout.write(`${AUTH_TOKEN_KEY} unset
697
+ `);
698
+ return EXIT.ok;
699
+ }
700
+ if (value === void 0) {
701
+ process.stdout.write(`${readMachineToken() ? "(set)" : "(unset)"}
702
+ `);
703
+ return EXIT.ok;
704
+ }
705
+ if (!MACHINE_TOKEN_RE.test(value)) {
706
+ throw new UsageError("auth-token must be a machine token starting with mt_");
707
+ }
708
+ storeMachineToken(value);
709
+ process.stdout.write(`${AUTH_TOKEN_KEY} set
710
+ `);
557
711
  return EXIT.ok;
558
712
  }
559
713
  const spec = CONFIG_KEYS[key];
@@ -622,6 +776,33 @@ function runUpdate() {
622
776
  return result.status === 0 ? EXIT.ok : EXIT.apiError;
623
777
  }
624
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
+
625
806
  // src/updatecheck.ts
626
807
  import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "node:fs";
627
808
  import { homedir as homedir4 } from "node:os";
@@ -678,15 +859,21 @@ async function refreshUpdateState(current, configuredHours) {
678
859
  }
679
860
 
680
861
  // src/index.ts
681
- function openBrowser(url) {
682
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
683
- try {
684
- execFileSync(cmd, [url], { stdio: "ignore" });
685
- } catch {
686
- process.stdout.write(`Open this URL to log in:
687
- ${url}
862
+ function loginOpener(launchBrowser) {
863
+ return (url) => {
864
+ process.stdout.write(`${url}
688
865
  `);
689
- }
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
+ };
690
877
  }
691
878
  function parseSourceFlags(values) {
692
879
  return (values ?? []).map((v) => {
@@ -694,33 +881,39 @@ function parseSourceFlags(values) {
694
881
  return version ? { source, version } : { source };
695
882
  });
696
883
  }
697
- var VERSION = true ? "0.3.6" : "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";
698
888
  var HELP = `grimoire ${VERSION} \u2014 documentation retrieval for AI agents
699
889
 
700
- grimoire login | logout | whoami
890
+ grimoire login [--no-launch-browser] | logout | whoami
701
891
  grimoire setup <claude-code|cursor|windsurf|codex>
702
892
  grimoire init
703
- 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]
704
894
  grimoire sources [--q <kw>] [--names|--json]
705
- grimoire versions <source> [--json]
895
+ grimoire versions <product> [--json]
706
896
  grimoire config [<key>] [<value>] [--unset]
707
897
  grimoire update
708
- grimoire doc <chunk_id> [--window 2]
709
- grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]
710
- 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]
711
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>
712
905
  grimoire mcp [--http]
713
906
  grimoire help | --help | -h
714
907
  grimoire version | --version | -v
715
908
 
716
909
  env: GRIMOIRE_AUTH_TOKEN \u2014 machine token (CI, instead of login)
717
- 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
718
911
  `;
719
912
  var COMMAND_FLAGS = {
720
913
  version: [],
721
914
  "--version": [],
722
915
  "-v": [],
723
- login: [],
916
+ login: ["no-launch-browser"],
724
917
  logout: [],
725
918
  setup: [],
726
919
  init: [],
@@ -731,14 +924,23 @@ var COMMAND_FLAGS = {
731
924
  mcp: ["http"],
732
925
  config: ["unset"],
733
926
  update: [],
734
- search: ["source", "lang", "top-k", "json", "compact"],
927
+ search: ["source", "json", "compact", "debug"],
735
928
  sources: ["q", "names", "json"],
736
929
  versions: ["json"],
737
- doc: ["window"],
930
+ doc: ["window", "json"],
738
931
  report: ["verdict", "note"],
739
- ingest: ["version", "private", "webhook", "watch"],
740
- jobs: ["watch"]
932
+ ingest: ["product", "rolling", "fixed", "npm", "pypi", "github", "include", "exclude", "watch"],
933
+ jobs: ["watch"],
934
+ staff: ["reason", "name", "json"]
741
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
+ }
742
944
  async function main(argv) {
743
945
  const [command, ...rest] = argv;
744
946
  const args = parseArgs(rest, { "-s": "source", "-q": "q" }, command !== void 0 ? COMMAND_FLAGS[command] : []);
@@ -751,12 +953,14 @@ async function main(argv) {
751
953
  `);
752
954
  return EXIT.ok;
753
955
  case "login": {
754
- 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));
755
959
  process.stdout.write("Logged in.\n");
756
960
  return EXIT.ok;
757
961
  }
758
962
  case "logout":
759
- clearRefreshToken();
963
+ clearSession();
760
964
  process.stdout.write("Logged out.\n");
761
965
  return EXIT.ok;
762
966
  case "setup":
@@ -781,29 +985,22 @@ async function main(argv) {
781
985
  try {
782
986
  switch (command) {
783
987
  case "whoami": {
784
- if (process.env.GRIMOIRE_AUTH_TOKEN) {
785
- process.stdout.write("machine token configured (GRIMOIRE_AUTH_TOKEN)\n");
786
- return EXIT.ok;
787
- }
788
- 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) {
789
990
  process.stderr.write("not logged in \u2014 run `grimoire login`\n");
790
991
  return EXIT.authRequired;
791
992
  }
792
- try {
793
- await client.refreshSession();
794
- process.stdout.write("logged in (browser session)\n");
795
- return EXIT.ok;
796
- } catch (err) {
797
- const reason = err instanceof ApiError ? err.code : err.message;
798
- process.stderr.write(`not logged in (${reason}) \u2014 run \`grimoire login\`
799
- `);
800
- return EXIT.authRequired;
801
- }
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;
802
999
  }
803
1000
  case "search": {
804
1001
  const query = args.positionals[0];
805
1002
  if (!query) {
806
- process.stderr.write('Usage: grimoire search "<query>" -s <source>\n');
1003
+ process.stderr.write('Usage: grimoire search "<query>" -s <product>[@version]\n');
807
1004
  return EXIT.apiError;
808
1005
  }
809
1006
  const explicit = parseSourceFlags(args.flags.source);
@@ -811,99 +1008,73 @@ async function main(argv) {
811
1008
  if (sources.length === 0) {
812
1009
  const initHelps = existsSync6(join6(process.cwd(), "package.json")) || existsSync6(join6(process.cwd(), "requirements.txt"));
813
1010
  process.stderr.write(
814
- '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" : "")
815
1012
  );
816
1013
  return EXIT.apiError;
817
1014
  }
818
- const res = await client.search({
819
- query,
820
- sources,
821
- language: args.flags.lang?.[0],
822
- top_k: intFlag(args, "top-k")
823
- });
1015
+ const res = await client.search({ query, sources: toSelectors(sources), debug: args.bools.has("debug") });
824
1016
  if (json) process.stdout.write(JSON.stringify(res, null, 2) + "\n");
825
- else if (args.bools.has("compact")) printCompact(res.results, res.confidence, res.rerank_status);
826
- else printResults(res.results, res.confidence, res.rerank_status);
1017
+ else if (args.bools.has("compact")) printCompact(res);
1018
+ else printResults(res);
827
1019
  return EXIT.ok;
828
1020
  }
829
1021
  case "sources": {
830
- const res = await client.listSources(args.flags.q?.[0]);
831
- 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
+ );
832
1026
  if (json) {
833
1027
  process.stdout.write(JSON.stringify(sources, null, 2) + "\n");
834
1028
  } else if (args.bools.has("names")) {
835
- for (const s of sources) process.stdout.write(`${s.source_id}
1029
+ for (const s of sources) process.stdout.write(`${s.product}
836
1030
  `);
837
1031
  } else {
838
1032
  for (const s of sources) {
839
- const latest = s.latest_semver ?? s.latest_version ?? "-";
840
- const vis = s.visibility === "private" ? " (private)" : "";
841
- const meta = [
842
- s.latest_chunks != null ? `${s.latest_chunks} chunks` : "",
843
- s.latest_pages != null ? `${s.latest_pages} pages` : "",
844
- s.latest_crawled_at ? `crawled ${s.latest_crawled_at.slice(0, 10)}` : ""
845
- ].filter(Boolean).join(" \xB7 ");
846
- process.stdout.write(
847
- [`${s.source_id}@${latest}${vis}`, meta, s.origin_url ?? ""].filter(Boolean).join(" ") + "\n"
848
- );
1033
+ const versions = s.versions.length > 0 ? s.versions.join(", ") : "(not indexed yet)";
1034
+ process.stdout.write(`${s.product} ${versions} ${s.base_url}
1035
+ `);
849
1036
  }
850
1037
  }
851
1038
  return EXIT.ok;
852
1039
  }
853
1040
  case "versions": {
854
- const source = requirePositional(args, 0, "Usage: grimoire versions <source> [--json]");
855
- const res = await client.listVersions(source);
856
- const versions = res.versions ?? [];
1041
+ const product = requirePositional(args, 0, "Usage: grimoire versions <product> [--json]");
1042
+ const res = await client.listVersions(product);
857
1043
  if (json) {
858
- process.stdout.write(JSON.stringify(versions, null, 2) + "\n");
1044
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
859
1045
  return EXIT.ok;
860
1046
  }
861
- const available = versions.filter((v) => v.status === "active");
862
- if (available.length === 0) {
863
- 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
864
1049
  `);
865
- return EXIT.ok;
866
- }
867
- for (const v of available) {
868
- const label = v.semver ?? v.version_id;
869
- const parts = [
870
- label,
871
- v.is_latest ? "latest" : "",
872
- `${v.chunk_count ?? 0} chunks`,
873
- `crawled ${(v.ingested_at ?? "").slice(0, 10)}`,
874
- v.semver ? `(${v.version_id})` : ""
875
- ].filter(Boolean);
876
- process.stdout.write(parts.join(" ") + "\n");
877
1050
  }
878
1051
  return EXIT.ok;
879
1052
  }
880
1053
  case "doc": {
881
- 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]");
882
1055
  const window = intFlag(args, "window", { min: 0, max: 5 }) ?? 2;
883
- const res = await client.getContext(chunkId, window);
884
- 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");
885
1065
  return EXIT.ok;
886
1066
  }
887
1067
  case "report": {
888
- const usage = 'Usage: grimoire report <chunk_id> --verdict helpful|incorrect|outdated [--note "..."]';
889
- 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);
890
1070
  const verdict = requireFlagOneOf(args, "verdict", ["helpful", "incorrect", "outdated"], usage);
891
- await client.reportResult(chunkId, verdict, args.flags.note?.[0]);
1071
+ await client.reportResult(pointId, verdict, args.flags.note?.[0]);
892
1072
  process.stdout.write("Reported.\n");
893
1073
  return EXIT.ok;
894
1074
  }
895
1075
  case "ingest": {
896
- const url = requirePositional(
897
- args,
898
- 0,
899
- "Usage: grimoire ingest <url> [--version 15.2] [--private] [--webhook URL] [--watch]"
900
- );
901
- const res = await client.submitSource({
902
- url,
903
- version: args.flags.version?.[0],
904
- visibility: args.bools.has("private") ? "private" : "public",
905
- webhook_url: args.flags.webhook?.[0]
906
- });
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));
907
1078
  process.stdout.write(`Job: ${res.job_id}
908
1079
  `);
909
1080
  if (args.bools.has("watch")) return watchJob(client, res.job_id);
@@ -913,6 +1084,54 @@ async function main(argv) {
913
1084
  const jobId = requirePositional(args, 0, "Usage: grimoire jobs <job_id> [--watch]");
914
1085
  return args.bools.has("watch") ? watchJob(client, jobId) : printJob(client, jobId);
915
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
+ }
916
1135
  default:
917
1136
  process.stderr.write(`Unknown command: ${command}
918
1137
  ${HELP}`);
@@ -920,14 +1139,10 @@ ${HELP}`);
920
1139
  }
921
1140
  } catch (err) {
922
1141
  if (err instanceof ApiError) {
923
- const body = typeof err.body === "object" && err.body !== null ? err.body : {};
924
- process.stderr.write(`error: ${err.code}${body.message ? ` \u2014 ${body.message}` : ""}
925
- `);
926
- if (body.available?.length) process.stderr.write(`available: ${body.available.join(", ")}
927
- `);
928
- if (body.did_you_mean?.length) process.stderr.write(`did you mean: ${body.did_you_mean.join(", ")}
1142
+ process.stderr.write(`${describeError(err)}
929
1143
  `);
930
1144
  if (err.status === 401) return EXIT.authRequired;
1145
+ if (err.status === 403) return EXIT.authRequired;
931
1146
  if (err.status === 429) return EXIT.quota;
932
1147
  if (err.status === 404) return EXIT.notFound;
933
1148
  return EXIT.apiError;
@@ -935,14 +1150,13 @@ ${HELP}`);
935
1150
  throw err;
936
1151
  }
937
1152
  }
938
- var TERMINAL_STATUSES = ["complete", "failed", "rejected"];
939
- var FAILED_STATUSES = ["failed", "rejected"];
1153
+ var TERMINAL_STATES = ["accepted", "done", "rejected", "failed", "pending_review"];
1154
+ var FAILED_STATES = ["rejected", "failed"];
940
1155
  async function printJob(client, jobId) {
941
1156
  const job = await client.getJob(jobId);
942
- const status = job.status ?? "unknown";
943
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1157
+ process.stdout.write(`${describeJob(job)}
944
1158
  `);
945
- return FAILED_STATUSES.includes(status) ? EXIT.apiError : EXIT.ok;
1159
+ return FAILED_STATES.includes(job.state) ? EXIT.apiError : EXIT.ok;
946
1160
  }
947
1161
  var WATCH_POLL_MS = 5e3;
948
1162
  var WATCH_BACKOFF_CAP_MS = 3e4;
@@ -956,11 +1170,13 @@ async function watchJob(client, jobId) {
956
1170
  while (Date.now() < deadline) {
957
1171
  try {
958
1172
  const job = await client.getJob(jobId);
959
- const status = job.status ?? "unknown";
960
- process.stdout.write(`${status} ${JSON.stringify(job.counters ?? {})}
1173
+ process.stdout.write(`${describeJob(job)}
961
1174
  `);
962
- if (TERMINAL_STATUSES.includes(status)) {
963
- 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;
964
1180
  }
965
1181
  backoff = WATCH_POLL_MS;
966
1182
  await sleep(WATCH_POLL_MS);