@krimto-labs/krimto 0.2.21 → 0.2.26
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 +93 -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/help.ts +4 -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 +100 -3
- package/src/cli/setIdentity.ts +232 -0
- package/src/cli/status.ts +12 -25
- package/src/cli/usage.ts +3 -1
- package/src/cli/verifyConnection.ts +21 -5
- package/src/cli/whoami.ts +172 -0
- package/src/cli/wizard.ts +4 -1
- 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
|
@@ -13,7 +13,9 @@ try {
|
|
|
13
13
|
const cmd =
|
|
14
14
|
rawCmd === "team" && typeof process.argv[3] === "string"
|
|
15
15
|
? `team ${process.argv[3]}`
|
|
16
|
-
: rawCmd
|
|
16
|
+
: rawCmd === "set" && typeof process.argv[3] === "string"
|
|
17
|
+
? `set ${process.argv[3]}`
|
|
18
|
+
: rawCmd;
|
|
17
19
|
|
|
18
20
|
// Guard: `krimto team` alone (or with an unknown subverb) shouldn't fall through to the stdio
|
|
19
21
|
// MCP server. Print usage and exit instead.
|
|
@@ -27,6 +29,16 @@ try {
|
|
|
27
29
|
process.exit(2);
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
// Same guard for `krimto set <subverb>`.
|
|
33
|
+
const knownSetCmds = ["set identity"];
|
|
34
|
+
if (rawCmd === "set" && !knownSetCmds.includes(cmd)) {
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
"Usage: krimto set <identity> <value>\n" +
|
|
37
|
+
" identity <email> Change the identity used for new fact writes\n",
|
|
38
|
+
);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
if (cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
31
43
|
// `krimto --help` — surface every subcommand so a user who didn't read the README can still
|
|
32
44
|
// discover them. Version is read from the server module so it never drifts from KRIMTO_VERSION.
|
|
@@ -44,6 +56,21 @@ try {
|
|
|
44
56
|
const minimal = flags.includes("--minimal");
|
|
45
57
|
const yes = flags.includes("--yes");
|
|
46
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
|
+
|
|
47
74
|
const legacyMode = all || minimal || (!isTty && !yes);
|
|
48
75
|
|
|
49
76
|
if (legacyMode) {
|
|
@@ -65,18 +92,51 @@ try {
|
|
|
65
92
|
: !minimal
|
|
66
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`
|
|
67
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
|
+
|
|
68
126
|
process.stderr.write(
|
|
69
127
|
"\n✅ AUTO MODE on — rule written to " + res.written.length + " file" +
|
|
70
128
|
(res.written.length === 1 ? "" : "s") + "\n" +
|
|
71
129
|
"\n" +
|
|
72
130
|
res.written.map((f) => ` ${f}`).join("\n") + "\n" +
|
|
131
|
+
skippedBlock +
|
|
73
132
|
"\n" +
|
|
74
133
|
detectedLine +
|
|
75
134
|
"━━ Next steps ━━\n" +
|
|
76
135
|
"\n" +
|
|
77
|
-
" 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" +
|
|
78
137
|
" 2. Test in chat: \"Remember that we use pnpm in this repo\"\n" +
|
|
79
138
|
" 3. Verify it landed: $ npx @krimto-labs/krimto verify-connection\n" +
|
|
139
|
+
mcpWarning +
|
|
80
140
|
"\n" +
|
|
81
141
|
"To undo: $ npx @krimto-labs/krimto uninit\n" +
|
|
82
142
|
"Manual: delete the block between <!-- krimto:start --> and <!-- krimto:end -->\n\n",
|
|
@@ -350,7 +410,37 @@ try {
|
|
|
350
410
|
// `krimto connect` — print stdio connect snippets (the npx on-ramp shape), so a solo user
|
|
351
411
|
// doesn't have to chase the README. Honors KRIMTO_IDENTITY when set.
|
|
352
412
|
const { formatConnect } = await tsImport("../src/cli/connect.ts", import.meta.url);
|
|
353
|
-
process.stdout.write(formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
|
|
413
|
+
process.stdout.write(await formatConnect({ identity: process.env.KRIMTO_IDENTITY }));
|
|
414
|
+
} else if (cmd === "whoami") {
|
|
415
|
+
// `krimto whoami` — show the active KRIMTO_IDENTITY and every place it's currently set
|
|
416
|
+
// (each editor's MCP config + the always-running service unit). Surfaces drift between
|
|
417
|
+
// sources so users notice before notes start splitting across two scopes.
|
|
418
|
+
const { runWhoami } = await tsImport("../src/cli/whoami.ts", import.meta.url);
|
|
419
|
+
const result = await runWhoami();
|
|
420
|
+
process.stdout.write(result.message);
|
|
421
|
+
if (result.mismatch) process.exitCode = 1;
|
|
422
|
+
} else if (cmd === "set identity") {
|
|
423
|
+
// `krimto set identity <email>` — update KRIMTO_IDENTITY across every registered editor
|
|
424
|
+
// and the always-running service. Preserves other env keys (KRIMTO_EMBED_*). Existing
|
|
425
|
+
// notes do NOT move — that's an intentional separate step.
|
|
426
|
+
const newIdentity = process.argv[4];
|
|
427
|
+
const flags = process.argv.slice(5);
|
|
428
|
+
const yes = flags.includes("--yes");
|
|
429
|
+
if (!newIdentity) {
|
|
430
|
+
process.stderr.write(
|
|
431
|
+
"Usage: krimto set identity <email> [--yes]\n e.g. krimto set identity alice@acme.com\n",
|
|
432
|
+
);
|
|
433
|
+
process.exit(2);
|
|
434
|
+
}
|
|
435
|
+
const { runSetIdentity } = await tsImport("../src/cli/setIdentity.ts", import.meta.url);
|
|
436
|
+
const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
|
|
437
|
+
const result = await runSetIdentity({
|
|
438
|
+
identity: newIdentity,
|
|
439
|
+
dataDir: resolveDataDir(),
|
|
440
|
+
yes,
|
|
441
|
+
});
|
|
442
|
+
process.stdout.write(result.message);
|
|
443
|
+
if (result.status === "error") process.exitCode = 1;
|
|
354
444
|
} else {
|
|
355
445
|
const mod = await tsImport("../src/server/index.ts", import.meta.url);
|
|
356
446
|
await mod.main();
|
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/help.ts
CHANGED
|
@@ -37,6 +37,10 @@ export function formatHelp(version: string): string {
|
|
|
37
37
|
" rm <id> Delete a fact (file + index + git deletion commit)",
|
|
38
38
|
" reindex Rebuild index.db from markdown (fixes manual-delete orphans)",
|
|
39
39
|
"",
|
|
40
|
+
" Identity:",
|
|
41
|
+
" whoami Show which email Krimto is using (and where it's set)",
|
|
42
|
+
" set identity <e> Change the identity used for new fact writes",
|
|
43
|
+
"",
|
|
40
44
|
" Diagnose:",
|
|
41
45
|
" verify-connection Is my agent actually calling Krimto? (live status + last 5 calls)",
|
|
42
46
|
" setup-remote <url> Wire data dir to a git remote, verify the push works",
|
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;
|