@krimto-labs/krimto 0.2.22 → 0.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/krimto.mjs +65 -3
- package/package.json +1 -1
- package/src/cli/cliRuntime.ts +1 -0
- package/src/cli/connect.ts +36 -5
- package/src/cli/deleteFact.ts +1 -0
- package/src/cli/init.ts +45 -16
- package/src/cli/inspectRuntime.ts +146 -0
- package/src/cli/reindex.ts +1 -0
- package/src/cli/reset.ts +68 -14
- package/src/cli/service.ts +183 -7
- package/src/cli/status.ts +12 -25
- package/src/cli/usage.ts +3 -1
- package/src/cli/verifyConnection.ts +21 -5
- package/src/cli/wizard.ts +20 -6
- package/src/server/connect.ts +2 -1
- package/src/server/index.ts +20 -1
- package/src/server/lock.ts +11 -1
- package/src/server/tools.ts +40 -1
package/bin/krimto.mjs
CHANGED
|
@@ -56,6 +56,21 @@ try {
|
|
|
56
56
|
const minimal = flags.includes("--minimal");
|
|
57
57
|
const yes = flags.includes("--yes");
|
|
58
58
|
const isTty = process.stdin.isTTY === true;
|
|
59
|
+
|
|
60
|
+
// v0.2.24 — fix for the "AI agent runs krimto init and silently gets the legacy writer"
|
|
61
|
+
// gap. When invoked from a non-TTY context (e.g. Claude Code's Bash tool) with no flags,
|
|
62
|
+
// the interactive wizard CAN'T run; the legacy rule-only writer would proceed silently
|
|
63
|
+
// and the caller wouldn't realize MCP wiring + service install were skipped. Print a
|
|
64
|
+
// clear notice up front so the user (or AI relaying the result) knows what just happened.
|
|
65
|
+
if (!isTty && !all && !minimal && !yes) {
|
|
66
|
+
process.stderr.write(
|
|
67
|
+
"ℹ️ No interactive terminal detected — running lightweight init (rules only).\n" +
|
|
68
|
+
" For the FULL setup (editor wiring + service install), either:\n" +
|
|
69
|
+
" • Run from a real terminal: $ npx @krimto-labs/krimto init\n" +
|
|
70
|
+
" • Or pass --yes here: $ npx @krimto-labs/krimto init --yes\n\n",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
const legacyMode = all || minimal || (!isTty && !yes);
|
|
60
75
|
|
|
61
76
|
if (legacyMode) {
|
|
@@ -77,18 +92,51 @@ try {
|
|
|
77
92
|
: !minimal
|
|
78
93
|
? ` Default: wrote all 4 supported rule files (safer than detecting one editor and\n missing the actual one). Pass --minimal to write only matched editors next time.\n\n`
|
|
79
94
|
: "";
|
|
95
|
+
|
|
96
|
+
// v0.2.25 — show both the files we wrote AND the files we skipped (already current).
|
|
97
|
+
// Before, "wrote 2 of 4" was opaque: the user couldn't tell whether the other 2 were
|
|
98
|
+
// intentionally skipped or silently failed. `res.considered` is the full target list
|
|
99
|
+
// for this invocation, so the skipped set is just considered \ written.
|
|
100
|
+
const writtenSet = new Set(res.written);
|
|
101
|
+
const skipped = res.considered.filter((f) => !writtenSet.has(f));
|
|
102
|
+
const skippedBlock = skipped.length > 0
|
|
103
|
+
? "\n Already current (no change):\n" + skipped.map((f) => ` • ${f}`).join("\n") + "\n"
|
|
104
|
+
: "";
|
|
105
|
+
|
|
106
|
+
// v0.2.25 — Gap 9. Rules-only init writes "always use krimto_*" instructions, but if
|
|
107
|
+
// no editor has Krimto wired into its MCP config, those instructions reference tools
|
|
108
|
+
// that won't exist in chat. Detect and warn so the user doesn't think the chat side
|
|
109
|
+
// is mysteriously broken later.
|
|
110
|
+
let mcpWarning = "";
|
|
111
|
+
try {
|
|
112
|
+
const { detectExistingSetup } = await tsImport("../src/cli/init.ts", import.meta.url);
|
|
113
|
+
const snap = await detectExistingSetup(process.cwd());
|
|
114
|
+
if (snap.registeredEditors.length === 0) {
|
|
115
|
+
mcpWarning =
|
|
116
|
+
"\n⚠️ Rule files written, but NO editor is wired to Krimto yet.\n" +
|
|
117
|
+
" The rules tell your AI to use krimto_*, but those tools won't be available\n" +
|
|
118
|
+
" until you register the MCP server. From a terminal:\n" +
|
|
119
|
+
" $ npx @krimto-labs/krimto init # full interactive wizard\n" +
|
|
120
|
+
" $ npx @krimto-labs/krimto connect # print copy-paste snippets\n";
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
/* best-effort — don't block the success path if detection fails */
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
process.stderr.write(
|
|
81
127
|
"\n✅ AUTO MODE on — rule written to " + res.written.length + " file" +
|
|
82
128
|
(res.written.length === 1 ? "" : "s") + "\n" +
|
|
83
129
|
"\n" +
|
|
84
130
|
res.written.map((f) => ` ${f}`).join("\n") + "\n" +
|
|
131
|
+
skippedBlock +
|
|
85
132
|
"\n" +
|
|
86
133
|
detectedLine +
|
|
87
134
|
"━━ Next steps ━━\n" +
|
|
88
135
|
"\n" +
|
|
89
|
-
" 1. Restart your editor (so it loads the new rule)\n" +
|
|
136
|
+
" 1. Restart your editor (so it loads the new rule + MCP tools)\n" +
|
|
90
137
|
" 2. Test in chat: \"Remember that we use pnpm in this repo\"\n" +
|
|
91
138
|
" 3. Verify it landed: $ npx @krimto-labs/krimto verify-connection\n" +
|
|
139
|
+
mcpWarning +
|
|
92
140
|
"\n" +
|
|
93
141
|
"To undo: $ npx @krimto-labs/krimto uninit\n" +
|
|
94
142
|
"Manual: delete the block between <!-- krimto:start --> and <!-- krimto:end -->\n\n",
|
|
@@ -99,9 +147,23 @@ try {
|
|
|
99
147
|
const { runInitNonInteractive } = await tsImport("../src/cli/wizard.ts", import.meta.url);
|
|
100
148
|
const result = await runInitNonInteractive(process.cwd());
|
|
101
149
|
const wired = result.editorOutcomes.map((o) => o.editor).join(", ") || "(none)";
|
|
150
|
+
// v0.2.27 — surface the port-readiness probe result. If the wizard installed an
|
|
151
|
+
// always-running service AND the port came up, the editor can reconnect immediately.
|
|
152
|
+
// If portReady is false, the wizard prints a warning so the CI/agent caller knows
|
|
153
|
+
// the service is up but its HTTP listener didn't bind in time.
|
|
154
|
+
let serviceLine = ` Run mode: as-needed\n`;
|
|
155
|
+
if (result.serviceInstall) {
|
|
156
|
+
if (result.serviceInstall.portReady === false) {
|
|
157
|
+
serviceLine =
|
|
158
|
+
` Run mode: always-running ⚠ port did NOT come up within 10s\n` +
|
|
159
|
+
` Check /tmp/com.krimto.server.err.log for boot errors.\n`;
|
|
160
|
+
} else {
|
|
161
|
+
serviceLine = ` Run mode: always-running · port ready\n`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
102
164
|
process.stderr.write(
|
|
103
165
|
`\n✅ Krimto set up (non-interactive). Editors: ${wired}\n` +
|
|
104
|
-
|
|
166
|
+
serviceLine +
|
|
105
167
|
` Search: ${result.embeddingsConfigured ? "OpenAI" : "keyword"}\n` +
|
|
106
168
|
` Data: ${result.dataDir}\n\n` +
|
|
107
169
|
"Restart your editor so it loads the new rule.\n\n",
|
|
@@ -362,7 +424,7 @@ try {
|
|
|
362
424
|
// `krimto connect` — print stdio connect snippets (the npx on-ramp shape), so a solo user
|
|
363
425
|
// doesn't have to chase the README. Honors KRIMTO_IDENTITY when set.
|
|
364
426
|
const { formatConnect } = await tsImport("../src/cli/connect.ts", import.meta.url);
|
|
365
|
-
process.stdout.write(formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
|
|
427
|
+
process.stdout.write(await formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
|
|
366
428
|
} else if (cmd === "whoami") {
|
|
367
429
|
// `krimto whoami` — show the active KRIMTO_IDENTITY and every place it's currently set
|
|
368
430
|
// (each editor's MCP config + the always-running service unit). Surfaces drift between
|
package/package.json
CHANGED
package/src/cli/cliRuntime.ts
CHANGED
|
@@ -54,6 +54,7 @@ export async function getLockHolder(dataDir: string): Promise<LockInfo | null> {
|
|
|
54
54
|
pid: parsed.pid,
|
|
55
55
|
started: typeof parsed.started === "string" ? parsed.started : "unknown",
|
|
56
56
|
mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
|
|
57
|
+
launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
} catch {
|
package/src/cli/connect.ts
CHANGED
|
@@ -5,13 +5,24 @@
|
|
|
5
5
|
import { stdioConnectSnippets } from "../server/connect";
|
|
6
6
|
|
|
7
7
|
export interface ConnectOpts {
|
|
8
|
-
/** Identity to embed in the Cursor env block. Defaults to a generic placeholder. */
|
|
8
|
+
/** Identity to embed in the Cursor env block. Defaults to git config user.email, falling back to a generic placeholder. */
|
|
9
9
|
identity?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Build the multi-line, copy-pasteable output `krimto connect` prints to stdout.
|
|
14
|
+
*
|
|
15
|
+
* v0.2.24 — identity falls back to {@link defaultIdentity} (git config user.email) when not
|
|
16
|
+
* supplied. Before this, the printed snippet always showed `you@acme.com`, so users
|
|
17
|
+
* pasting the snippet ended up with the placeholder as their literal KRIMTO_IDENTITY.
|
|
18
|
+
*
|
|
19
|
+
* v0.2.24 — the printed `claude mcp add` line is now preceded by `claude mcp remove krimto`
|
|
20
|
+
* so a copy-paste rerun doesn't fail with "MCP server krimto already exists in local config"
|
|
21
|
+
* (the same idempotency fix v0.2.19 made to the wizard's MCP writer).
|
|
22
|
+
*/
|
|
23
|
+
export async function formatConnect(opts: ConnectOpts = {}): Promise<string> {
|
|
24
|
+
const identity = opts.identity ?? (await defaultIdentityForConnect());
|
|
25
|
+
const { claude, cursorJson } = stdioConnectSnippets({ identity });
|
|
15
26
|
const indentedJson = cursorJson.split("\n").map((line) => ` ${line}`).join("\n");
|
|
16
27
|
return [
|
|
17
28
|
"",
|
|
@@ -19,7 +30,8 @@ export function formatConnect(opts: ConnectOpts = {}): string {
|
|
|
19
30
|
"",
|
|
20
31
|
"━━ 1. Paste the config ━━",
|
|
21
32
|
"",
|
|
22
|
-
" Claude Code:",
|
|
33
|
+
" Claude Code (the `remove` clears any prior entry so re-runs don't error):",
|
|
34
|
+
" $ claude mcp remove krimto 2>/dev/null; true",
|
|
23
35
|
` $ ${claude}`,
|
|
24
36
|
"",
|
|
25
37
|
" Cursor (add to ~/.cursor/mcp.json, then restart Cursor):",
|
|
@@ -61,3 +73,22 @@ export function formatConnect(opts: ConnectOpts = {}): string {
|
|
|
61
73
|
"",
|
|
62
74
|
].join("\n");
|
|
63
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Local copy of init.ts's git-config lookup, scoped to the connect command. We don't import
|
|
79
|
+
* `defaultIdentity` directly from init.ts to keep `connect` cheap (init.ts pulls in
|
|
80
|
+
* `applyRule`, service installer, MCP writer, etc.). Same regex; same fallback.
|
|
81
|
+
*/
|
|
82
|
+
async function defaultIdentityForConnect(): Promise<string> {
|
|
83
|
+
try {
|
|
84
|
+
const { execFile } = await import("node:child_process");
|
|
85
|
+
const { promisify } = await import("node:util");
|
|
86
|
+
const exec = promisify(execFile);
|
|
87
|
+
const { stdout } = await exec("git", ["config", "--global", "user.email"]);
|
|
88
|
+
const email = stdout.trim();
|
|
89
|
+
if (email && /^[^@\s]+@[^@\s]+$/.test(email)) return email;
|
|
90
|
+
} catch {
|
|
91
|
+
/* git missing or unconfigured — fall through to the legacy placeholder */
|
|
92
|
+
}
|
|
93
|
+
return "you@acme.com";
|
|
94
|
+
}
|
package/src/cli/deleteFact.ts
CHANGED
|
@@ -35,6 +35,7 @@ async function checkLock(dataDir: string): Promise<LockInfo | null> {
|
|
|
35
35
|
pid: parsed.pid,
|
|
36
36
|
started: typeof parsed.started === "string" ? parsed.started : "unknown",
|
|
37
37
|
mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
|
|
38
|
+
launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
} catch {
|
package/src/cli/init.ts
CHANGED
|
@@ -435,26 +435,40 @@ export async function detectExistingSetup(
|
|
|
435
435
|
let searchProvider: SearchProvider = "keyword";
|
|
436
436
|
|
|
437
437
|
for (const env of envs) {
|
|
438
|
-
if (env.mcpWire
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
text
|
|
442
|
-
|
|
438
|
+
if (env.mcpWire === null) continue;
|
|
439
|
+
|
|
440
|
+
if (env.mcpWire.method === "json") {
|
|
441
|
+
let text: string;
|
|
442
|
+
try {
|
|
443
|
+
text = await fs.readFile(env.mcpWire.path, "utf8");
|
|
444
|
+
} catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
let parsed: Record<string, unknown>;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(text) as Record<string, unknown>;
|
|
450
|
+
} catch {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
|
|
454
|
+
if (!servers || !("krimto" in servers)) continue;
|
|
455
|
+
registeredEditors.push(env.editor);
|
|
456
|
+
const krimto = servers.krimto as { env?: Record<string, string> };
|
|
457
|
+
if (krimto.env?.KRIMTO_EMBED_PROVIDER === "openai") {
|
|
458
|
+
searchProvider = "openai";
|
|
459
|
+
}
|
|
443
460
|
continue;
|
|
444
461
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
462
|
+
|
|
463
|
+
// v0.2.26 — Gap-3 root-cause fix. Claude Code uses `claude mcp` CLI; the registration
|
|
464
|
+
// doesn't live in a JSON file we scan. Without this branch every read-side surface
|
|
465
|
+
// (reconfigure menu, status, reset, whoami) was invisibly mis-reporting "Cursor only"
|
|
466
|
+
// even after the wizard had successfully wired both Cursor AND Claude Code.
|
|
467
|
+
if (env.mcpWire.method === "cli") {
|
|
468
|
+
const present = await isClaudeMcpRegistered(env.mcpWire.command);
|
|
469
|
+
if (present) registeredEditors.push(env.editor);
|
|
449
470
|
continue;
|
|
450
471
|
}
|
|
451
|
-
const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
|
|
452
|
-
if (!servers || !("krimto" in servers)) continue;
|
|
453
|
-
registeredEditors.push(env.editor);
|
|
454
|
-
const krimto = servers.krimto as { env?: Record<string, string> };
|
|
455
|
-
if (krimto.env?.KRIMTO_EMBED_PROVIDER === "openai") {
|
|
456
|
-
searchProvider = "openai";
|
|
457
|
-
}
|
|
458
472
|
}
|
|
459
473
|
|
|
460
474
|
const service = await isServiceInstalled(undefined, homeDir);
|
|
@@ -468,6 +482,21 @@ export async function detectExistingSetup(
|
|
|
468
482
|
};
|
|
469
483
|
}
|
|
470
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Check whether `claude mcp list` knows about krimto. Best-effort: any error (CLI missing,
|
|
487
|
+
* timeout, parse failure) returns false rather than blocking the caller. The output format
|
|
488
|
+
* is one server per line, "<name>:<space><url-or-spec>" — we just look for "krimto:" with a
|
|
489
|
+
* non-failing line, which covers both stdio (`krimto: npx -y ...`) and HTTP (`krimto: http://...`).
|
|
490
|
+
*/
|
|
491
|
+
async function isClaudeMcpRegistered(claudeBinary: string): Promise<boolean> {
|
|
492
|
+
try {
|
|
493
|
+
const { stdout } = await exec(claudeBinary, ["mcp", "list"], { timeout: 8000 });
|
|
494
|
+
return /^krimto:\s/m.test(stdout);
|
|
495
|
+
} catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
471
500
|
// === Legacy runInit (v0.2.16, kept for --all / --minimal back-compat) ======
|
|
472
501
|
|
|
473
502
|
export interface RunInitOptions {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// v0.2.26 — single reconciled view of "what is Krimto doing right now". Replaces the prior
|
|
2
|
+
// ad-hoc detection scattered across status.ts, verifyConnection.ts, whoami.ts, and the
|
|
3
|
+
// wizard's reconfigure menu, which disagreed in the smoke-6 transcript audit:
|
|
4
|
+
// • Cursor mcp.json had krimto, Claude Code's `claude mcp` had krimto, launchctl showed
|
|
5
|
+
// the service loaded — but reconfigure menu said "Editors: Cursor", verify-connection
|
|
6
|
+
// said "Launched by: ad-hoc", and reset said "No changes made".
|
|
7
|
+
//
|
|
8
|
+
// `inspectRuntime` reads every source we care about and reconciles them. Downstream
|
|
9
|
+
// commands consume one consistent view instead of running their own subset of checks.
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { isProcessAlive, type LaunchedBy, type LockInfo, type LockMode } from "../server/lock";
|
|
16
|
+
import {
|
|
17
|
+
detectEditorEnvironments,
|
|
18
|
+
detectExistingSetup,
|
|
19
|
+
type EditorKind,
|
|
20
|
+
type RunMode,
|
|
21
|
+
type SearchProvider,
|
|
22
|
+
type SetupSnapshot,
|
|
23
|
+
} from "./init";
|
|
24
|
+
import { probeServiceState } from "./service";
|
|
25
|
+
|
|
26
|
+
export interface RuntimeLock {
|
|
27
|
+
pid: number;
|
|
28
|
+
started: string;
|
|
29
|
+
mode: LockMode;
|
|
30
|
+
/**
|
|
31
|
+
* The lock file's own `launchedBy` value. Pre-v0.2.25 processes didn't write this field,
|
|
32
|
+
* so we default to "ad-hoc". The reconciled answer (correct even for pre-v0.2.25 runs)
|
|
33
|
+
* is in {@link RuntimeState.effectiveLaunchedBy}.
|
|
34
|
+
*/
|
|
35
|
+
launchedBy: LaunchedBy;
|
|
36
|
+
alive: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RuntimeService {
|
|
40
|
+
installed: boolean;
|
|
41
|
+
loaded: boolean;
|
|
42
|
+
runningPid: number | null;
|
|
43
|
+
unitPath: string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RuntimeState {
|
|
47
|
+
/** Lock file state (parsed + alive-check), or null when no readable lock. */
|
|
48
|
+
lock: RuntimeLock | null;
|
|
49
|
+
/** Cross-platform service probe (unit-on-disk + actually-loaded + running PID). */
|
|
50
|
+
service: RuntimeService;
|
|
51
|
+
/**
|
|
52
|
+
* Reconciled launch source. Trusts launchctl/systemctl over the lock file: if the
|
|
53
|
+
* running PID matches the service's `runningPid`, the process IS service-launched,
|
|
54
|
+
* even when the lock file lacks the `launchedBy` field (pre-v0.2.25 runs).
|
|
55
|
+
*/
|
|
56
|
+
effectiveLaunchedBy: LaunchedBy | null;
|
|
57
|
+
/** Snapshot from detectExistingSetup (registered editors, run mode, search provider). */
|
|
58
|
+
snapshot: SetupSnapshot;
|
|
59
|
+
/** Detected editor environments (all 4, with present/installed flags). */
|
|
60
|
+
editors: Awaited<ReturnType<typeof detectEditorEnvironments>>;
|
|
61
|
+
/** Editors registered with Krimto right now. Same as snapshot.registeredEditors. */
|
|
62
|
+
registeredEditors: EditorKind[];
|
|
63
|
+
/** Convenience: the configured run mode (always-running iff a service unit exists). */
|
|
64
|
+
runMode: RunMode;
|
|
65
|
+
/** Convenience: the configured search provider. */
|
|
66
|
+
searchProvider: SearchProvider;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface InspectOptions {
|
|
70
|
+
cwd?: string;
|
|
71
|
+
homeDir?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a single, reconciled {@link RuntimeState}. Cheap enough to call from every read-side
|
|
76
|
+
* command (one fs read for lock, one launchctl/systemctl print, one `claude mcp list`, one
|
|
77
|
+
* pass over editor MCP configs).
|
|
78
|
+
*/
|
|
79
|
+
export async function inspectRuntime(dataDir: string, opts: InspectOptions = {}): Promise<RuntimeState> {
|
|
80
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
81
|
+
const homeDir = opts.homeDir ?? os.homedir();
|
|
82
|
+
|
|
83
|
+
const lock = await readLock(dataDir);
|
|
84
|
+
const snapshot = await detectExistingSetup(cwd, homeDir);
|
|
85
|
+
const editors = await detectEditorEnvironments(cwd, homeDir);
|
|
86
|
+
const service = await probeServiceState(undefined, homeDir);
|
|
87
|
+
|
|
88
|
+
// The reconciliation step. Two signals:
|
|
89
|
+
// 1. The lock file's self-reported launchedBy (pre-v0.2.25 runs default to "ad-hoc").
|
|
90
|
+
// 2. Whether the running PID matches launchd's program-PID.
|
|
91
|
+
// If (2) says yes, we know the process was service-launched regardless of what (1) claims.
|
|
92
|
+
let effectiveLaunchedBy: LaunchedBy | null = null;
|
|
93
|
+
if (lock && lock.alive) {
|
|
94
|
+
if (service.loaded && service.runningPid === lock.pid) {
|
|
95
|
+
effectiveLaunchedBy = "service";
|
|
96
|
+
} else {
|
|
97
|
+
effectiveLaunchedBy = lock.launchedBy;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
lock,
|
|
103
|
+
service: {
|
|
104
|
+
installed: service.unitPresent,
|
|
105
|
+
loaded: service.loaded,
|
|
106
|
+
runningPid: service.runningPid,
|
|
107
|
+
unitPath: undefined, // service.ts's isServiceInstalled returns this; we don't surface it in v0.2.26
|
|
108
|
+
},
|
|
109
|
+
effectiveLaunchedBy,
|
|
110
|
+
snapshot,
|
|
111
|
+
editors,
|
|
112
|
+
registeredEditors: snapshot.registeredEditors,
|
|
113
|
+
runMode: snapshot.runMode,
|
|
114
|
+
searchProvider: snapshot.searchProvider,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readLock(dataDir: string): Promise<RuntimeLock | null> {
|
|
119
|
+
const file = path.join(dataDir, ".krimto", "lock.json");
|
|
120
|
+
let raw: string;
|
|
121
|
+
try {
|
|
122
|
+
raw = await fs.readFile(file, "utf8");
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
let parsed: Partial<LockInfo>;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(raw) as Partial<LockInfo>;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
typeof parsed.pid !== "number" ||
|
|
134
|
+
typeof parsed.started !== "string" ||
|
|
135
|
+
(parsed.mode !== "stdio" && parsed.mode !== "http")
|
|
136
|
+
) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
pid: parsed.pid,
|
|
141
|
+
started: parsed.started,
|
|
142
|
+
mode: parsed.mode,
|
|
143
|
+
launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
|
|
144
|
+
alive: isProcessAlive(parsed.pid),
|
|
145
|
+
};
|
|
146
|
+
}
|
package/src/cli/reindex.ts
CHANGED
|
@@ -32,6 +32,7 @@ async function checkLock(dataDir: string): Promise<LockInfo | null> {
|
|
|
32
32
|
pid: parsed.pid,
|
|
33
33
|
started: typeof parsed.started === "string" ? parsed.started : "unknown",
|
|
34
34
|
mode: (parsed.mode as LockInfo["mode"]) ?? "stdio",
|
|
35
|
+
launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
} catch {
|
package/src/cli/reset.ts
CHANGED
|
@@ -20,17 +20,12 @@ import * as path from "node:path";
|
|
|
20
20
|
import { removeRule } from "../agentRule";
|
|
21
21
|
import {
|
|
22
22
|
detectEditorEnvironments,
|
|
23
|
-
detectExistingSetup,
|
|
24
23
|
type EditorEnvironment,
|
|
25
24
|
type EditorKind,
|
|
26
25
|
} from "./init";
|
|
27
26
|
import { removeMcpConfig } from "./mcpConfig";
|
|
28
27
|
import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
|
|
29
|
-
import {
|
|
30
|
-
detectPlatform,
|
|
31
|
-
isServiceInstalled,
|
|
32
|
-
uninstallService,
|
|
33
|
-
} from "./service";
|
|
28
|
+
import { detectPlatform, uninstallService } from "./service";
|
|
34
29
|
|
|
35
30
|
const EDITOR_LABEL: Record<EditorKind, string> = {
|
|
36
31
|
cursor: "Cursor",
|
|
@@ -65,33 +60,52 @@ export async function applyReset(opts: ResetOptions = {}): Promise<ResetResult>
|
|
|
65
60
|
const homeDir = opts.homeDir;
|
|
66
61
|
const dataDir = opts.dataDir ?? path.join(homeDir ?? "", ".krimto");
|
|
67
62
|
|
|
68
|
-
//
|
|
63
|
+
// v0.2.26 — reset is now "always sweep, never trust detection". The smoke-6 transcript
|
|
64
|
+
// caught reset reporting "No changes made" while a service was actually loaded and
|
|
65
|
+
// Cursor's mcp.json still had a krimto entry: detection was wrong (Claude Code was
|
|
66
|
+
// invisible to CLI-method scans), so reset's gated-by-detection cleanup ran nothing.
|
|
67
|
+
// Now every cleanup path runs best-effort regardless of what detection thinks. We can
|
|
68
|
+
// distinguish "ran but found nothing" from "intentionally skipped" by inspecting the
|
|
69
|
+
// result type — `editorsDisconnected` only includes editors whose removeMcpConfig
|
|
70
|
+
// returned removed=true, so an empty list still means "nothing to remove" cleanly.
|
|
71
|
+
|
|
69
72
|
const envs = await detectEditorEnvironments(cwd, homeDir);
|
|
70
|
-
|
|
73
|
+
|
|
74
|
+
// 1. Try to disconnect every editor we know about, regardless of what detection says.
|
|
75
|
+
// removeMcpConfig is already idempotent — it returns removed=false if there was nothing
|
|
76
|
+
// to remove, so blanket-running it is safe.
|
|
71
77
|
const editorsDisconnected: EditorKind[] = [];
|
|
72
78
|
for (const env of envs) {
|
|
73
|
-
if (!snapshot.registeredEditors.includes(env.editor)) continue;
|
|
74
79
|
const res = await removeMcpConfig(env);
|
|
75
80
|
if (res.removed) editorsDisconnected.push(env.editor);
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
// 2. Strip the standing rule from each detected rule file in CWD.
|
|
83
|
+
// 2. Strip the standing rule from each detected rule file in CWD (already idempotent).
|
|
79
84
|
const rulesStripped: string[] = [];
|
|
80
85
|
for (const env of envs) {
|
|
81
86
|
const stripped = await stripRule(cwd, env);
|
|
82
87
|
if (stripped) rulesStripped.push(env.rulesPath);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
// 3. Uninstall the background service if
|
|
90
|
+
// 3. Uninstall the background service ALWAYS (even if isServiceInstalled said no). The
|
|
91
|
+
// inner uninstall is best-effort: the platform CLI errors when nothing's loaded, but
|
|
92
|
+
// we swallow them. This catches the case where a stale plist exists without an active
|
|
93
|
+
// launchctl entry (or vice versa) — both halves get cleaned in one pass.
|
|
86
94
|
const platform = detectPlatform();
|
|
87
|
-
const current = await isServiceInstalled(platform, homeDir);
|
|
88
95
|
let serviceRemoved = false;
|
|
89
|
-
|
|
96
|
+
try {
|
|
90
97
|
const res = await uninstallService({ dryRun: opts.dryRun, platform, homeDir });
|
|
91
98
|
serviceRemoved = res.removed;
|
|
99
|
+
} catch {
|
|
100
|
+
/* uninstall path can throw on unsupported platforms or missing CLIs — we're sweeping, not validating */
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
// 4.
|
|
103
|
+
// 4. Kill any live ad-hoc Krimto process holding the lock — otherwise a `krimto serve`
|
|
104
|
+
// that's still running keeps the data dir busy and the next init will conflict on the
|
|
105
|
+
// lock. Best-effort: SIGTERM, give it 500ms, SIGKILL if still alive.
|
|
106
|
+
await terminateLockHolder(dataDir);
|
|
107
|
+
|
|
108
|
+
// 5. Wipe the keys store (best-effort — file may not exist).
|
|
95
109
|
const keysPath = path.join(dataDir, ".krimto", "keys.json");
|
|
96
110
|
let keysWiped = false;
|
|
97
111
|
try {
|
|
@@ -101,6 +115,15 @@ export async function applyReset(opts: ResetOptions = {}): Promise<ResetResult>
|
|
|
101
115
|
/* no keys file — fine */
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
// 6. Wipe the lock file itself so the next init starts from a known-clean slate. Without
|
|
119
|
+
// this, a stale lock from a process we just killed can confuse subsequent commands.
|
|
120
|
+
const lockPath = path.join(dataDir, ".krimto", "lock.json");
|
|
121
|
+
try {
|
|
122
|
+
await fs.unlink(lockPath);
|
|
123
|
+
} catch {
|
|
124
|
+
/* no lock — fine */
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
// 5. Optionally move the data dir to a timestamped trash sibling.
|
|
105
128
|
let notesTrashedTo: string | undefined;
|
|
106
129
|
if (opts.wipeNotes) {
|
|
@@ -177,6 +200,37 @@ export async function runReset(opts: ResetOptions = {}): Promise<ResetResult | n
|
|
|
177
200
|
}
|
|
178
201
|
}
|
|
179
202
|
|
|
203
|
+
/**
|
|
204
|
+
* If the lock file points at a live PID, send SIGTERM then SIGKILL (after 500ms). Best-effort;
|
|
205
|
+
* we don't return the outcome because reset's contract is "do the cleanup, don't validate".
|
|
206
|
+
* v0.2.26 — without this step, a leftover `krimto serve` PID survived `reset` and the next
|
|
207
|
+
* init would either reuse the dead lock OR conflict on the live port (whichever came first).
|
|
208
|
+
*/
|
|
209
|
+
async function terminateLockHolder(dataDir: string): Promise<void> {
|
|
210
|
+
const lockPath = path.join(dataDir, ".krimto", "lock.json");
|
|
211
|
+
let pid: number | null = null;
|
|
212
|
+
try {
|
|
213
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
214
|
+
const parsed = JSON.parse(raw) as { pid?: unknown };
|
|
215
|
+
if (typeof parsed.pid === "number" && parsed.pid > 0) pid = parsed.pid;
|
|
216
|
+
} catch {
|
|
217
|
+
return; // no lock — nothing to terminate
|
|
218
|
+
}
|
|
219
|
+
if (pid === null) return;
|
|
220
|
+
try {
|
|
221
|
+
process.kill(pid, "SIGTERM");
|
|
222
|
+
} catch {
|
|
223
|
+
return; // already gone
|
|
224
|
+
}
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 0); // existence probe
|
|
228
|
+
process.kill(pid, "SIGKILL");
|
|
229
|
+
} catch {
|
|
230
|
+
/* exited cleanly on SIGTERM */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
180
234
|
async function stripRule(cwd: string, env: EditorEnvironment): Promise<boolean> {
|
|
181
235
|
const rulePath = path.join(cwd, env.rulesPath);
|
|
182
236
|
let existing: string;
|
package/src/cli/service.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
23
|
import { promises as fs } from "node:fs";
|
|
24
|
+
import * as net from "node:net";
|
|
24
25
|
import * as os from "node:os";
|
|
25
26
|
import * as path from "node:path";
|
|
26
27
|
import { promisify } from "node:util";
|
|
@@ -55,6 +56,16 @@ export interface InstallResult {
|
|
|
55
56
|
activateCommand?: { command: string; args: string[] };
|
|
56
57
|
/** True when the platform CLI was actually executed (false in dry-run mode). */
|
|
57
58
|
activated: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* v0.2.27 — only meaningful when `activated: true` and the service config sets
|
|
61
|
+
* `KRIMTO_HTTP_PORT`. `true` = TCP connection to `localhost:<port>` was accepted before
|
|
62
|
+
* we returned, so the server is actually serving and clients (Cursor / Claude Code) won't
|
|
63
|
+
* hit ECONNREFUSED if they reconnect immediately. `false` = the probe timed out; the
|
|
64
|
+
* service IS installed and launchd reports it running, but the server hasn't bound the
|
|
65
|
+
* port — usually a misconfig surfaced in the stderr log file. `undefined` = no probe ran
|
|
66
|
+
* (dry run, stdio mode, or no HTTP port in the env block).
|
|
67
|
+
*/
|
|
68
|
+
portReady?: boolean;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export interface UninstallResult {
|
|
@@ -74,6 +85,14 @@ export interface ServiceOptions {
|
|
|
74
85
|
* CI runner without `process.platform` rewiring.
|
|
75
86
|
*/
|
|
76
87
|
platform?: ServicePlatform;
|
|
88
|
+
/**
|
|
89
|
+
* v0.2.27 — dependency-injected port probe. Default uses real `net.connect`. Tests pass
|
|
90
|
+
* a stub that resolves immediately (so they don't open real TCP sockets). When omitted,
|
|
91
|
+
* `installService` polls `localhost:<KRIMTO_HTTP_PORT>` until accept or `probeTimeoutMs`.
|
|
92
|
+
*/
|
|
93
|
+
probePort?: (port: number) => Promise<boolean>;
|
|
94
|
+
/** Maximum time (ms) to wait for the HTTP port to start accepting connections. Default 10000. */
|
|
95
|
+
probeTimeoutMs?: number;
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
/** Map `process.platform` → the four service flavors Krimto knows about. */
|
|
@@ -120,6 +139,70 @@ export async function isServiceInstalled(
|
|
|
120
139
|
}
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/**
|
|
143
|
+
* v0.2.26 — richer probe than {@link isServiceInstalled}. Distinguishes three states the
|
|
144
|
+
* smoke-6 transcript proved we needed:
|
|
145
|
+
* • `unitPresent: true, loaded: false` — plist on disk but not bootstrapped (failed install left over)
|
|
146
|
+
* • `unitPresent: true, loaded: true` — fully active service
|
|
147
|
+
* • `unitPresent: false, loaded: false` — clean machine (or service was uninstalled)
|
|
148
|
+
*
|
|
149
|
+
* Also returns the PID launchd is currently running, so the caller can correlate it against
|
|
150
|
+
* the lock file — when the running Krimto PID equals launchd's `pid`, the process IS the
|
|
151
|
+
* service-launched one, even if its lock file is missing the `launchedBy` field (because the
|
|
152
|
+
* process started under an old version).
|
|
153
|
+
*/
|
|
154
|
+
export async function probeServiceState(
|
|
155
|
+
platform: ServicePlatform = detectPlatform(),
|
|
156
|
+
homeDir: string = os.homedir(),
|
|
157
|
+
): Promise<{
|
|
158
|
+
platform: ServicePlatform;
|
|
159
|
+
unitPresent: boolean;
|
|
160
|
+
loaded: boolean;
|
|
161
|
+
runningPid: number | null;
|
|
162
|
+
}> {
|
|
163
|
+
const base = await isServiceInstalled(platform, homeDir);
|
|
164
|
+
const unitPresent = base.installed;
|
|
165
|
+
|
|
166
|
+
if (platform === "darwin") {
|
|
167
|
+
const uid = process.getuid?.() ?? 501;
|
|
168
|
+
try {
|
|
169
|
+
const { stdout } = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]);
|
|
170
|
+
const pidMatch = stdout.match(/\bpid\s*=\s*(\d+)/);
|
|
171
|
+
const pid = pidMatch && pidMatch[1] ? Number.parseInt(pidMatch[1], 10) : null;
|
|
172
|
+
return { platform, unitPresent, loaded: true, runningPid: pid };
|
|
173
|
+
} catch {
|
|
174
|
+
return { platform, unitPresent, loaded: false, runningPid: null };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (platform === "linux") {
|
|
178
|
+
try {
|
|
179
|
+
// `systemctl --user is-active <name>` exits 0 with "active" when running.
|
|
180
|
+
const { stdout } = await exec("systemctl", ["--user", "is-active", SERVICE_NAME]);
|
|
181
|
+
const active = stdout.trim() === "active";
|
|
182
|
+
if (!active) return { platform, unitPresent, loaded: false, runningPid: null };
|
|
183
|
+
// Best-effort PID lookup.
|
|
184
|
+
try {
|
|
185
|
+
const { stdout: pidOut } = await exec("systemctl", ["--user", "show", "--property=MainPID", "--value", SERVICE_NAME]);
|
|
186
|
+
const pid = Number.parseInt(pidOut.trim(), 10);
|
|
187
|
+
return { platform, unitPresent, loaded: true, runningPid: Number.isFinite(pid) && pid > 0 ? pid : null };
|
|
188
|
+
} catch {
|
|
189
|
+
return { platform, unitPresent, loaded: true, runningPid: null };
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
return { platform, unitPresent, loaded: false, runningPid: null };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (platform === "win32") {
|
|
196
|
+
try {
|
|
197
|
+
await exec("schtasks", ["/Query", "/TN", SERVICE_NAME]);
|
|
198
|
+
return { platform, unitPresent: true, loaded: true, runningPid: null };
|
|
199
|
+
} catch {
|
|
200
|
+
return { platform, unitPresent: false, loaded: false, runningPid: null };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { platform, unitPresent: false, loaded: false, runningPid: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
123
206
|
/**
|
|
124
207
|
* Install the service for the current platform. Writes the unit file (where applicable) and
|
|
125
208
|
* activates the service via the platform's CLI. Pass `opts.dryRun=true` to skip activation
|
|
@@ -132,14 +215,82 @@ export async function installService(
|
|
|
132
215
|
const platform = opts.platform ?? detectPlatform();
|
|
133
216
|
const homeDir = config.homeDir ?? os.homedir();
|
|
134
217
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
218
|
+
// v0.2.25 — Gap 8 provenance. Every service-launched Krimto stamps `launchedBy: "service"`
|
|
219
|
+
// into its lock file. We inject the marker env var here (vs every caller remembering to)
|
|
220
|
+
// so the wizard, the `service` shortcut, and any future installer share the same shape.
|
|
221
|
+
const configWithMarker: ServiceConfig = {
|
|
222
|
+
...config,
|
|
223
|
+
env: { KRIMTO_LAUNCHED_BY: "service", ...(config.env ?? {}) },
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
let result: InstallResult;
|
|
227
|
+
if (platform === "darwin") {
|
|
228
|
+
result = await installLaunchd(configWithMarker, homeDir, opts);
|
|
229
|
+
} else if (platform === "linux") {
|
|
230
|
+
result = await installSystemd(configWithMarker, homeDir, opts);
|
|
231
|
+
} else if (platform === "win32") {
|
|
232
|
+
result = await installSchtasks(configWithMarker, opts);
|
|
233
|
+
} else {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Krimto's "Always running" mode isn't supported on platform "${process.platform}" yet. ` +
|
|
236
|
+
`Use "As needed" mode (the wizard default) or run \`krimto serve\` manually.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
138
239
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
240
|
+
// v0.2.27 — port-readiness probe. The smoke-6 transcript showed Cursor failing with
|
|
241
|
+
// ECONNREFUSED in the ~3-second window between launchd accepting the bootstrap and the
|
|
242
|
+
// Node process actually binding the HTTP port. The wizard's "Background service installed
|
|
243
|
+
// and started" line ran during that window, so users restarted their editor right when
|
|
244
|
+
// the port was still unbound. Now we wait for the port to accept a TCP connection before
|
|
245
|
+
// returning success; if it doesn't come up within `probeTimeoutMs`, the wizard surfaces
|
|
246
|
+
// the warning instead of giving false confidence.
|
|
247
|
+
if (result.activated && !opts.dryRun) {
|
|
248
|
+
const portStr = configWithMarker.env?.KRIMTO_HTTP_PORT;
|
|
249
|
+
const port = portStr ? Number.parseInt(portStr, 10) : NaN;
|
|
250
|
+
if (Number.isFinite(port) && port > 0) {
|
|
251
|
+
const probe = opts.probePort ?? defaultPortProbe;
|
|
252
|
+
result.portReady = await waitForPort(port, probe, opts.probeTimeoutMs ?? 10000);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Poll the port via the injected probe every 250ms until it accepts or the timeout elapses.
|
|
260
|
+
* Returns true on first successful connect, false on timeout. v0.2.27.
|
|
261
|
+
*/
|
|
262
|
+
async function waitForPort(
|
|
263
|
+
port: number,
|
|
264
|
+
probe: (port: number) => Promise<boolean>,
|
|
265
|
+
timeoutMs: number,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
while (Date.now() - start < timeoutMs) {
|
|
269
|
+
if (await probe(port)) return true;
|
|
270
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Real port probe: open a TCP socket to 127.0.0.1:<port>, resolve true on `connect`, false on
|
|
277
|
+
* error or 500ms timeout. The probe itself is cheap (kernel-level connect refusal), so a tight
|
|
278
|
+
* 250ms poll loop over a 10s window is fine.
|
|
279
|
+
*/
|
|
280
|
+
async function defaultPortProbe(port: number): Promise<boolean> {
|
|
281
|
+
return new Promise<boolean>((resolve) => {
|
|
282
|
+
const socket = net.connect({ port, host: "127.0.0.1" });
|
|
283
|
+
let done = false;
|
|
284
|
+
const finish = (ok: boolean): void => {
|
|
285
|
+
if (done) return;
|
|
286
|
+
done = true;
|
|
287
|
+
socket.destroy();
|
|
288
|
+
resolve(ok);
|
|
289
|
+
};
|
|
290
|
+
socket.once("connect", () => finish(true));
|
|
291
|
+
socket.once("error", () => finish(false));
|
|
292
|
+
socket.setTimeout(500, () => finish(false));
|
|
293
|
+
});
|
|
143
294
|
}
|
|
144
295
|
|
|
145
296
|
/** Uninstall the service. Removes the unit file + invokes the platform CLI to deactivate it. */
|
|
@@ -212,6 +363,31 @@ async function installLaunchd(
|
|
|
212
363
|
if (opts.dryRun) {
|
|
213
364
|
return { platform: "darwin", unitPath, unitContents, activateCommand, activated: false };
|
|
214
365
|
}
|
|
366
|
+
// v0.2.26 — supersedes the v0.2.23 bootout+bootstrap pattern. The previous fix raced
|
|
367
|
+
// against launchd's still-tearing-down state: `launchctl bootout` returns when the unload
|
|
368
|
+
// is QUEUED, not when it completes, so the subsequent `bootstrap` could still hit EIO.
|
|
369
|
+
// Correct pattern:
|
|
370
|
+
// • If the service is currently loaded → `launchctl kickstart -k gui/<uid>/<label>`
|
|
371
|
+
// atomically kills + restarts the running process. Because we already overwrote the
|
|
372
|
+
// plist above, the new process starts with the latest env / argv.
|
|
373
|
+
// • If the service is NOT loaded → plain `bootstrap`. No race possible.
|
|
374
|
+
// `launchctl print` returning exit-0 is the canonical "is it loaded" probe.
|
|
375
|
+
const printRes = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]).then(
|
|
376
|
+
() => ({ loaded: true }),
|
|
377
|
+
() => ({ loaded: false }),
|
|
378
|
+
);
|
|
379
|
+
if (printRes.loaded) {
|
|
380
|
+
// Kickstart -k: send SIGTERM, wait for clean exit, then restart from the plist on disk.
|
|
381
|
+
// launchctl returns from kickstart only AFTER the new process is up, so no follow-on race.
|
|
382
|
+
await exec("launchctl", ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`]);
|
|
383
|
+
return {
|
|
384
|
+
platform: "darwin",
|
|
385
|
+
unitPath,
|
|
386
|
+
unitContents,
|
|
387
|
+
activateCommand: { command: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`] },
|
|
388
|
+
activated: true,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
215
391
|
await exec(activateCommand.command, activateCommand.args);
|
|
216
392
|
return { platform: "darwin", unitPath, unitContents, activateCommand, activated: true };
|
|
217
393
|
}
|
package/src/cli/status.ts
CHANGED
|
@@ -16,14 +16,14 @@ import * as path from "node:path";
|
|
|
16
16
|
import { promisify } from "node:util";
|
|
17
17
|
|
|
18
18
|
import { ActivityLog, type ActivityEntry } from "../server/activity";
|
|
19
|
-
import {
|
|
19
|
+
import { type LockInfo } from "../server/lock";
|
|
20
20
|
import { KRIMTO_VERSION } from "../server/index";
|
|
21
21
|
import {
|
|
22
22
|
detectEditorEnvironments,
|
|
23
|
-
detectExistingSetup,
|
|
24
23
|
type EditorKind,
|
|
25
24
|
type SetupSnapshot,
|
|
26
25
|
} from "./init";
|
|
26
|
+
import { inspectRuntime } from "./inspectRuntime";
|
|
27
27
|
|
|
28
28
|
const exec = promisify(execFile);
|
|
29
29
|
|
|
@@ -66,9 +66,13 @@ export async function runStatus(
|
|
|
66
66
|
const homeDir = opts.homeDir ?? os.homedir();
|
|
67
67
|
const now = opts.now ?? new Date();
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// v0.2.26 — single source of truth. status used to read 4 sources separately and the
|
|
70
|
+
// results disagreed (smoke-6 transcript). Now everything flows through inspectRuntime,
|
|
71
|
+
// which reconciles lock + launchctl + editor configs into one consistent view.
|
|
72
|
+
const runtime = await inspectRuntime(dataDir, { cwd, homeDir });
|
|
73
|
+
const snapshot = runtime.snapshot;
|
|
74
|
+
const envs = runtime.editors;
|
|
75
|
+
const lock = runtime.lock ? { info: { pid: runtime.lock.pid, started: runtime.lock.started, mode: runtime.lock.mode, launchedBy: runtime.lock.launchedBy }, alive: runtime.lock.alive } : null;
|
|
72
76
|
const log = new ActivityLog(dataDir);
|
|
73
77
|
const recent = await log.tail(5);
|
|
74
78
|
const stats = await log.stats(5 * 60 * 1000, now);
|
|
@@ -76,7 +80,7 @@ export async function runStatus(
|
|
|
76
80
|
const gitInfo = await readGitInfo(dataDir);
|
|
77
81
|
|
|
78
82
|
const overall = pickOverall(snapshot, lock, stats);
|
|
79
|
-
const header = headerLine(overall, lock, now);
|
|
83
|
+
const header = headerLine(overall, lock, runtime.effectiveLaunchedBy, now);
|
|
80
84
|
|
|
81
85
|
const connectionsBlock = renderConnections(envs, snapshot, recent);
|
|
82
86
|
const storageBlock = renderStorage(dataDir, gitInfo, indexStats);
|
|
@@ -99,24 +103,6 @@ export async function runStatus(
|
|
|
99
103
|
|
|
100
104
|
// === Helpers ===============================================================
|
|
101
105
|
|
|
102
|
-
async function readLock(dataDir: string): Promise<{ info: LockInfo; alive: boolean } | null> {
|
|
103
|
-
try {
|
|
104
|
-
const raw = await fs.readFile(path.join(dataDir, ".krimto", "lock.json"), "utf8");
|
|
105
|
-
const parsed = JSON.parse(raw) as Partial<LockInfo>;
|
|
106
|
-
if (
|
|
107
|
-
typeof parsed.pid === "number" &&
|
|
108
|
-
typeof parsed.started === "string" &&
|
|
109
|
-
(parsed.mode === "stdio" || parsed.mode === "http")
|
|
110
|
-
) {
|
|
111
|
-
const info: LockInfo = { pid: parsed.pid, started: parsed.started, mode: parsed.mode };
|
|
112
|
-
return { info, alive: isProcessAlive(info.pid) };
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
/* no lock file */
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
106
|
async function readIndexStats(dataDir: string): Promise<{ exists: boolean; modified?: Date }> {
|
|
121
107
|
try {
|
|
122
108
|
const s = await fs.stat(path.join(dataDir, "index.db"));
|
|
@@ -171,11 +157,12 @@ function pickOverall(
|
|
|
171
157
|
function headerLine(
|
|
172
158
|
status: "ok" | "warning" | "error",
|
|
173
159
|
lock: { info: LockInfo; alive: boolean } | null,
|
|
160
|
+
effectiveLaunchedBy: import("./inspectRuntime").RuntimeState["effectiveLaunchedBy"],
|
|
174
161
|
now: Date,
|
|
175
162
|
): string {
|
|
176
163
|
if (status === "ok") {
|
|
177
164
|
if (lock?.alive) {
|
|
178
|
-
return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}), started ${humanAgo(lock.info.started, now)}\n`;
|
|
165
|
+
return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}, ${effectiveLaunchedBy ?? lock.info.launchedBy}), started ${humanAgo(lock.info.started, now)}\n`;
|
|
179
166
|
}
|
|
180
167
|
return `\n✅ Krimto is configured · v${KRIMTO_VERSION}\n No active server right now — it will be launched on demand by your editor.\n`;
|
|
181
168
|
}
|
package/src/cli/usage.ts
CHANGED
|
@@ -13,6 +13,7 @@ const EXPECTED: readonly string[] = [
|
|
|
13
13
|
"krimto_read",
|
|
14
14
|
"krimto_supersede",
|
|
15
15
|
"krimto_list_scopes",
|
|
16
|
+
"krimto_whoami",
|
|
16
17
|
];
|
|
17
18
|
if (MCP_TOOL_NAMES.length !== EXPECTED.length || MCP_TOOL_NAMES.some((t, i) => t !== EXPECTED[i])) {
|
|
18
19
|
throw new Error("src/cli/usage.ts examples are out of sync with src/server/connect.ts MCP_TOOL_NAMES");
|
|
@@ -24,13 +25,14 @@ export function formatUsage(version: string): string {
|
|
|
24
25
|
"",
|
|
25
26
|
`✅ How to use Krimto (v${version})`,
|
|
26
27
|
"",
|
|
27
|
-
"━━ The
|
|
28
|
+
"━━ The 6 tools ━━",
|
|
28
29
|
"",
|
|
29
30
|
" krimto_write Save a fact",
|
|
30
31
|
" krimto_recall Search facts",
|
|
31
32
|
" krimto_read Open one fact by id",
|
|
32
33
|
" krimto_supersede Replace a fact with a new version (old kept in git)",
|
|
33
34
|
" krimto_list_scopes See which scopes you can read",
|
|
35
|
+
" krimto_whoami Ask Krimto which identity you're writing as (and your scopes)",
|
|
34
36
|
"",
|
|
35
37
|
"━━ DEFAULT MODE — explicit (\"use krimto to ...\") ━━",
|
|
36
38
|
"",
|
|
@@ -8,6 +8,7 @@ import { promises as fs } from "node:fs";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
|
|
10
10
|
import { ActivityLog, type ActivityEntry } from "../server/activity";
|
|
11
|
+
import { inspectRuntime } from "./inspectRuntime";
|
|
11
12
|
import { isProcessAlive, type LockInfo } from "../server/lock";
|
|
12
13
|
|
|
13
14
|
export interface VerifyConnectionResult {
|
|
@@ -39,7 +40,12 @@ export async function runVerifyConnection(dataDir: string, now: Date = new Date(
|
|
|
39
40
|
const raw = await fs.readFile(lockPath, "utf8");
|
|
40
41
|
const parsed = JSON.parse(raw) as Partial<LockInfo>;
|
|
41
42
|
if (typeof parsed.pid === "number" && typeof parsed.started === "string" && (parsed.mode === "stdio" || parsed.mode === "http")) {
|
|
42
|
-
lock = {
|
|
43
|
+
lock = {
|
|
44
|
+
pid: parsed.pid,
|
|
45
|
+
started: parsed.started,
|
|
46
|
+
mode: parsed.mode,
|
|
47
|
+
launchedBy: parsed.launchedBy === "service" ? "service" : "ad-hoc",
|
|
48
|
+
};
|
|
43
49
|
} else {
|
|
44
50
|
lockMalformed = true;
|
|
45
51
|
}
|
|
@@ -56,12 +62,22 @@ export async function runVerifyConnection(dataDir: string, now: Date = new Date(
|
|
|
56
62
|
let header: string;
|
|
57
63
|
if (lock && isProcessAlive(lock.pid)) {
|
|
58
64
|
status = "running";
|
|
65
|
+
// v0.2.26 — reconcile the lock's self-reported launchedBy against launchctl's actual
|
|
66
|
+
// state. Without this, a pre-v0.2.25 service-launched process reports "ad-hoc" because
|
|
67
|
+
// its lock file was written before the field existed.
|
|
68
|
+
const runtime = await inspectRuntime(dataDir);
|
|
69
|
+
const effective = runtime.effectiveLaunchedBy ?? lock.launchedBy;
|
|
70
|
+
const launchedByLabel =
|
|
71
|
+
effective === "service"
|
|
72
|
+
? "service (launchd/systemd/schtasks — survives reboot)"
|
|
73
|
+
: "ad-hoc (started by `krimto serve` or an editor's stdio launcher)";
|
|
59
74
|
header =
|
|
60
75
|
`\n🟢 Krimto running\n` +
|
|
61
|
-
` PID:
|
|
62
|
-
` Mode:
|
|
63
|
-
` Started:
|
|
64
|
-
`
|
|
76
|
+
` PID: ${lock.pid}\n` +
|
|
77
|
+
` Mode: ${lock.mode}\n` +
|
|
78
|
+
` Started: ${humanAgo(lock.started, now)}\n` +
|
|
79
|
+
` Launched by: ${launchedByLabel}\n` +
|
|
80
|
+
` Data: ${dataDir}\n`;
|
|
65
81
|
} else if (lock) {
|
|
66
82
|
status = "stale";
|
|
67
83
|
header =
|
package/src/cli/wizard.ts
CHANGED
|
@@ -357,11 +357,22 @@ function printApplyResult(res: ApplyResult, io: WizardIO): void {
|
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
359
|
if (res.serviceInstall) {
|
|
360
|
-
|
|
361
|
-
res.serviceInstall.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
360
|
+
if (!res.serviceInstall.activated) {
|
|
361
|
+
io.out(` ✓ Background service configured (${res.serviceInstall.platform}; dry-run)\n`);
|
|
362
|
+
} else if (res.serviceInstall.portReady === false) {
|
|
363
|
+
// v0.2.27 — install succeeded but the HTTP port didn't come up within the probe
|
|
364
|
+
// window. Editors that auto-reconnect on MCP-config change will hit ECONNREFUSED.
|
|
365
|
+
// Surface the warning + pointer to the log file instead of giving false confidence.
|
|
366
|
+
io.out(
|
|
367
|
+
` ⚠ Background service installed (${res.serviceInstall.platform}) but the HTTP port\n` +
|
|
368
|
+
` didn't come up within 10s. Check /tmp/com.krimto.server.err.log for boot errors.\n` +
|
|
369
|
+
` Editors may fail to connect until the server binds the port.\n`,
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
// portReady === true OR undefined (no HTTP port configured — stdio-only install).
|
|
373
|
+
const readinessNote = res.serviceInstall.portReady === true ? " · port accepting connections" : "";
|
|
374
|
+
io.out(` ✓ Background service installed and started (${res.serviceInstall.platform})${readinessNote}\n`);
|
|
375
|
+
}
|
|
365
376
|
}
|
|
366
377
|
if (res.embeddingsConfigured) io.out(" ✓ Semantic search enabled (OpenAI)\n");
|
|
367
378
|
// Tell the user where their data will live. The folder itself is created lazily on first save,
|
|
@@ -388,7 +399,10 @@ function printApplyResult(res: ApplyResult, io: WizardIO): void {
|
|
|
388
399
|
}
|
|
389
400
|
}
|
|
390
401
|
|
|
391
|
-
io.out(
|
|
402
|
+
io.out(
|
|
403
|
+
"Restart your editor once so it loads the new MCP server (the krimto_*\n" +
|
|
404
|
+
"tools won't appear in chat until you do) and picks up the standing rule.\n",
|
|
405
|
+
);
|
|
392
406
|
}
|
|
393
407
|
|
|
394
408
|
function printRefreshSummary(res: ApplyResult, io: WizardIO): void {
|
package/src/server/connect.ts
CHANGED
|
@@ -74,13 +74,14 @@ export function cursorDeeplink(host: string): string {
|
|
|
74
74
|
return `cursor://anysphere.cursor-deeplink/mcp/install?name=krimto&config=${config}`;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/** The
|
|
77
|
+
/** The six MCP tools Krimto exposes (kept in lockstep with src/server/index.ts registrations). */
|
|
78
78
|
export const MCP_TOOL_NAMES = [
|
|
79
79
|
"krimto_write",
|
|
80
80
|
"krimto_recall",
|
|
81
81
|
"krimto_read",
|
|
82
82
|
"krimto_supersede",
|
|
83
83
|
"krimto_list_scopes",
|
|
84
|
+
"krimto_whoami",
|
|
84
85
|
] as const;
|
|
85
86
|
|
|
86
87
|
/** The transport-level contract for wiring up any MCP client we haven't shipped a verified snippet for. */
|
package/src/server/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
krimtoRead,
|
|
42
42
|
krimtoRecall,
|
|
43
43
|
krimtoSupersede,
|
|
44
|
+
krimtoWhoami,
|
|
44
45
|
krimtoWrite,
|
|
45
46
|
type ToolContext,
|
|
46
47
|
} from "./tools";
|
|
@@ -48,7 +49,7 @@ import { type Requester } from "../access/scope";
|
|
|
48
49
|
|
|
49
50
|
export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
|
|
50
51
|
|
|
51
|
-
export const KRIMTO_VERSION = "0.2.
|
|
52
|
+
export const KRIMTO_VERSION = "0.2.27";
|
|
52
53
|
|
|
53
54
|
export function resolveDataDir(): string {
|
|
54
55
|
return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
|
|
@@ -192,6 +193,24 @@ export function buildServer(ctx: ToolContext, resolveRequester?: RequesterResolv
|
|
|
192
193
|
},
|
|
193
194
|
);
|
|
194
195
|
|
|
196
|
+
server.registerTool(
|
|
197
|
+
"krimto_whoami",
|
|
198
|
+
{
|
|
199
|
+
description:
|
|
200
|
+
"Report the caller's identity plus the scopes they can read and write. Call this before " +
|
|
201
|
+
"claiming to know the user's email or which team scopes exist — Krimto knows; the agent doesn't.",
|
|
202
|
+
inputSchema: {},
|
|
203
|
+
},
|
|
204
|
+
async (_args, extra) => {
|
|
205
|
+
try {
|
|
206
|
+
const requester = resolveRequester ? resolveRequester(extra) : ctx.requester;
|
|
207
|
+
return ok(await krimtoWhoami({ ...ctx, requester }));
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return fail(e);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
195
214
|
return server;
|
|
196
215
|
}
|
|
197
216
|
|
package/src/server/lock.ts
CHANGED
|
@@ -16,11 +16,19 @@ import { promises as fs } from "node:fs";
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
|
|
18
18
|
export type LockMode = "stdio" | "http";
|
|
19
|
+
/**
|
|
20
|
+
* How this Krimto process was launched. "service" = via launchd / systemd / schtasks (the
|
|
21
|
+
* always-running setup); "ad-hoc" = direct invocation (`krimto serve`, editor-spawned stdio,
|
|
22
|
+
* tests). The service installers inject KRIMTO_LAUNCHED_BY=service into the unit env, so this
|
|
23
|
+
* field is just `process.env.KRIMTO_LAUNCHED_BY` at acquire time with a safe default.
|
|
24
|
+
*/
|
|
25
|
+
export type LaunchedBy = "service" | "ad-hoc";
|
|
19
26
|
|
|
20
27
|
export interface LockInfo {
|
|
21
28
|
pid: number;
|
|
22
29
|
started: string;
|
|
23
30
|
mode: LockMode;
|
|
31
|
+
launchedBy: LaunchedBy;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
export interface LockHandle {
|
|
@@ -73,6 +81,7 @@ export async function acquireLock(dataDir: string, mode: LockMode): Promise<Lock
|
|
|
73
81
|
pid: existing.pid,
|
|
74
82
|
started: typeof existing.started === "string" ? existing.started : "unknown",
|
|
75
83
|
mode: (existing.mode as LockMode) ?? "stdio",
|
|
84
|
+
launchedBy: existing.launchedBy === "service" ? "service" : "ad-hoc",
|
|
76
85
|
},
|
|
77
86
|
file,
|
|
78
87
|
);
|
|
@@ -83,7 +92,8 @@ export async function acquireLock(dataDir: string, mode: LockMode): Promise<Lock
|
|
|
83
92
|
// File missing / unreadable / malformed — treat as no lock and continue.
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
const
|
|
95
|
+
const launchedBy: LaunchedBy = process.env.KRIMTO_LAUNCHED_BY === "service" ? "service" : "ad-hoc";
|
|
96
|
+
const info: LockInfo = { pid: process.pid, started: new Date().toISOString(), mode, launchedBy };
|
|
87
97
|
await fs.writeFile(file, JSON.stringify(info, null, 2), "utf8");
|
|
88
98
|
|
|
89
99
|
return {
|
package/src/server/tools.ts
CHANGED
|
@@ -109,6 +109,20 @@ export interface SupersedeResult {
|
|
|
109
109
|
|
|
110
110
|
export interface ListScopesResult {
|
|
111
111
|
scopes: { path: string; fact_count: number; last_updated: string | null }[];
|
|
112
|
+
/** Present only when `scopes` is empty — tells the calling agent how to make a scope appear. */
|
|
113
|
+
hint?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* v0.2.25 — Gap 3. The smoke-6 transcript showed an agent in chat inventing a wrong identity
|
|
118
|
+
* (`lpd.themes@gmail.com` instead of `lpdthemes@gmail.com`) because it had no MCP-side way to
|
|
119
|
+
* ask "who am I writing as?". `krimto_whoami` returns the resolved identity plus the scopes
|
|
120
|
+
* the caller can read and write, so the agent never has to guess.
|
|
121
|
+
*/
|
|
122
|
+
export interface WhoamiResult {
|
|
123
|
+
identity: string;
|
|
124
|
+
readable_scopes: string[];
|
|
125
|
+
writable_scopes: string[];
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
function clock(ctx: ToolContext): Date {
|
|
@@ -329,17 +343,42 @@ export async function krimtoSupersede(
|
|
|
329
343
|
});
|
|
330
344
|
}
|
|
331
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Return the caller's identity plus the scopes they can read and write. The agent in chat uses
|
|
348
|
+
* this to avoid hallucinating identity (Gap 3 from the smoke-6 transcript audit).
|
|
349
|
+
*/
|
|
350
|
+
export async function krimtoWhoami(ctx: ToolContext): Promise<WhoamiResult> {
|
|
351
|
+
const readable = readableScopesFor(ctx);
|
|
352
|
+
const writable = writableScopesFor(ctx);
|
|
353
|
+
if (ctx.activity) await ctx.activity.record("krimto_whoami", ctx.requester.identity, ctx.requester.identity);
|
|
354
|
+
return {
|
|
355
|
+
identity: ctx.requester.identity,
|
|
356
|
+
readable_scopes: readable,
|
|
357
|
+
writable_scopes: writable,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
332
361
|
/** Discover the scopes that exist and what they contain. */
|
|
333
362
|
export async function krimtoListScopes(ctx: ToolContext): Promise<ListScopesResult> {
|
|
334
363
|
const scopes = ctx.index.listScopes(readableScopesFor(ctx));
|
|
335
364
|
if (ctx.activity) await ctx.activity.record("krimto_list_scopes", ctx.requester.identity, `${scopes.length} scope(s)`);
|
|
336
|
-
|
|
365
|
+
const result: ListScopesResult = {
|
|
337
366
|
scopes: scopes.map((s) => ({
|
|
338
367
|
path: s.path,
|
|
339
368
|
fact_count: s.factCount,
|
|
340
369
|
last_updated: s.lastUpdated,
|
|
341
370
|
})),
|
|
342
371
|
};
|
|
372
|
+
// v0.2.24 — empty result is the #1 first-impression confuser ("Krimto must be broken").
|
|
373
|
+
// Surface the next step in the response itself, so an agent in chat can relay it verbatim
|
|
374
|
+
// instead of inventing a hallucinated explanation about "scopes aren't configured".
|
|
375
|
+
if (result.scopes.length === 0) {
|
|
376
|
+
result.hint =
|
|
377
|
+
`No scopes exist yet for ${ctx.requester.identity}. Scopes are created on the first ` +
|
|
378
|
+
`write — try krimto_write with a small fact (e.g. "we use pnpm in this repo") and ` +
|
|
379
|
+
`your user/<email> scope will appear here.`;
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
343
382
|
}
|
|
344
383
|
|
|
345
384
|
/** Build a Requester from a validated bearer token's AuthInfo (its `extra` carries identity+teams). */
|