@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.21",
3
+ "version": "0.2.26",
4
4
  "description": "Open-source team memory layer for AI agents — markdown files in git, user/team/org hierarchy, cross-vendor MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 {
@@ -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
- /** Build the multi-line, copy-pasteable output `krimto connect` prints to stdout. */
13
- export function formatConnect(opts: ConnectOpts = {}): string {
14
- const { claude, cursorJson } = stdioConnectSnippets(opts);
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
+ }
@@ -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?.method !== "json") continue;
439
- let text: string;
440
- try {
441
- text = await fs.readFile(env.mcpWire.path, "utf8");
442
- } catch {
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
- let parsed: Record<string, unknown>;
446
- try {
447
- parsed = JSON.parse(text) as Record<string, unknown>;
448
- } catch {
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
+ }
@@ -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
- // 1. Disconnect every detected editor.
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
- const snapshot = await detectExistingSetup(cwd, homeDir);
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 installed.
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
- if (current.installed) {
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. Wipe the keys store (best-effortfile may not exist).
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;