@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.
@@ -120,6 +120,70 @@ export async function isServiceInstalled(
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * v0.2.26 — richer probe than {@link isServiceInstalled}. Distinguishes three states the
125
+ * smoke-6 transcript proved we needed:
126
+ * • `unitPresent: true, loaded: false` — plist on disk but not bootstrapped (failed install left over)
127
+ * • `unitPresent: true, loaded: true` — fully active service
128
+ * • `unitPresent: false, loaded: false` — clean machine (or service was uninstalled)
129
+ *
130
+ * Also returns the PID launchd is currently running, so the caller can correlate it against
131
+ * the lock file — when the running Krimto PID equals launchd's `pid`, the process IS the
132
+ * service-launched one, even if its lock file is missing the `launchedBy` field (because the
133
+ * process started under an old version).
134
+ */
135
+ export async function probeServiceState(
136
+ platform: ServicePlatform = detectPlatform(),
137
+ homeDir: string = os.homedir(),
138
+ ): Promise<{
139
+ platform: ServicePlatform;
140
+ unitPresent: boolean;
141
+ loaded: boolean;
142
+ runningPid: number | null;
143
+ }> {
144
+ const base = await isServiceInstalled(platform, homeDir);
145
+ const unitPresent = base.installed;
146
+
147
+ if (platform === "darwin") {
148
+ const uid = process.getuid?.() ?? 501;
149
+ try {
150
+ const { stdout } = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]);
151
+ const pidMatch = stdout.match(/\bpid\s*=\s*(\d+)/);
152
+ const pid = pidMatch && pidMatch[1] ? Number.parseInt(pidMatch[1], 10) : null;
153
+ return { platform, unitPresent, loaded: true, runningPid: pid };
154
+ } catch {
155
+ return { platform, unitPresent, loaded: false, runningPid: null };
156
+ }
157
+ }
158
+ if (platform === "linux") {
159
+ try {
160
+ // `systemctl --user is-active <name>` exits 0 with "active" when running.
161
+ const { stdout } = await exec("systemctl", ["--user", "is-active", SERVICE_NAME]);
162
+ const active = stdout.trim() === "active";
163
+ if (!active) return { platform, unitPresent, loaded: false, runningPid: null };
164
+ // Best-effort PID lookup.
165
+ try {
166
+ const { stdout: pidOut } = await exec("systemctl", ["--user", "show", "--property=MainPID", "--value", SERVICE_NAME]);
167
+ const pid = Number.parseInt(pidOut.trim(), 10);
168
+ return { platform, unitPresent, loaded: true, runningPid: Number.isFinite(pid) && pid > 0 ? pid : null };
169
+ } catch {
170
+ return { platform, unitPresent, loaded: true, runningPid: null };
171
+ }
172
+ } catch {
173
+ return { platform, unitPresent, loaded: false, runningPid: null };
174
+ }
175
+ }
176
+ if (platform === "win32") {
177
+ try {
178
+ await exec("schtasks", ["/Query", "/TN", SERVICE_NAME]);
179
+ return { platform, unitPresent: true, loaded: true, runningPid: null };
180
+ } catch {
181
+ return { platform, unitPresent: false, loaded: false, runningPid: null };
182
+ }
183
+ }
184
+ return { platform, unitPresent: false, loaded: false, runningPid: null };
185
+ }
186
+
123
187
  /**
124
188
  * Install the service for the current platform. Writes the unit file (where applicable) and
125
189
  * activates the service via the platform's CLI. Pass `opts.dryRun=true` to skip activation
@@ -132,9 +196,17 @@ export async function installService(
132
196
  const platform = opts.platform ?? detectPlatform();
133
197
  const homeDir = config.homeDir ?? os.homedir();
134
198
 
135
- if (platform === "darwin") return installLaunchd(config, homeDir, opts);
136
- if (platform === "linux") return installSystemd(config, homeDir, opts);
137
- if (platform === "win32") return installSchtasks(config, opts);
199
+ // v0.2.25 Gap 8 provenance. Every service-launched Krimto stamps `launchedBy: "service"`
200
+ // into its lock file. We inject the marker env var here (vs every caller remembering to)
201
+ // so the wizard, the `service` shortcut, and any future installer share the same shape.
202
+ const configWithMarker: ServiceConfig = {
203
+ ...config,
204
+ env: { KRIMTO_LAUNCHED_BY: "service", ...(config.env ?? {}) },
205
+ };
206
+
207
+ if (platform === "darwin") return installLaunchd(configWithMarker, homeDir, opts);
208
+ if (platform === "linux") return installSystemd(configWithMarker, homeDir, opts);
209
+ if (platform === "win32") return installSchtasks(configWithMarker, opts);
138
210
 
139
211
  throw new Error(
140
212
  `Krimto's "Always running" mode isn't supported on platform "${process.platform}" yet. ` +
@@ -212,6 +284,31 @@ async function installLaunchd(
212
284
  if (opts.dryRun) {
213
285
  return { platform: "darwin", unitPath, unitContents, activateCommand, activated: false };
214
286
  }
287
+ // v0.2.26 — supersedes the v0.2.23 bootout+bootstrap pattern. The previous fix raced
288
+ // against launchd's still-tearing-down state: `launchctl bootout` returns when the unload
289
+ // is QUEUED, not when it completes, so the subsequent `bootstrap` could still hit EIO.
290
+ // Correct pattern:
291
+ // • If the service is currently loaded → `launchctl kickstart -k gui/<uid>/<label>`
292
+ // atomically kills + restarts the running process. Because we already overwrote the
293
+ // plist above, the new process starts with the latest env / argv.
294
+ // • If the service is NOT loaded → plain `bootstrap`. No race possible.
295
+ // `launchctl print` returning exit-0 is the canonical "is it loaded" probe.
296
+ const printRes = await exec("launchctl", ["print", `gui/${uid}/${SERVICE_LABEL}`]).then(
297
+ () => ({ loaded: true }),
298
+ () => ({ loaded: false }),
299
+ );
300
+ if (printRes.loaded) {
301
+ // Kickstart -k: send SIGTERM, wait for clean exit, then restart from the plist on disk.
302
+ // launchctl returns from kickstart only AFTER the new process is up, so no follow-on race.
303
+ await exec("launchctl", ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`]);
304
+ return {
305
+ platform: "darwin",
306
+ unitPath,
307
+ unitContents,
308
+ activateCommand: { command: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${SERVICE_LABEL}`] },
309
+ activated: true,
310
+ };
311
+ }
215
312
  await exec(activateCommand.command, activateCommand.args);
216
313
  return { platform: "darwin", unitPath, unitContents, activateCommand, activated: true };
217
314
  }
@@ -0,0 +1,232 @@
1
+ // `krimto set identity <email>` — change KRIMTO_IDENTITY everywhere the wizard wrote it.
2
+ //
3
+ // Surfaces touched (in order):
4
+ // 1. Each registered editor's MCP config — for JSON-method editors, mutate
5
+ // `env.KRIMTO_IDENTITY` in place (other env keys like KRIMTO_EMBED_* are preserved).
6
+ // For CLI-method editors (Claude Code), rebuild a fresh stdio entry via writeMcpConfig
7
+ // (extra env keys are lost — known limitation noted below).
8
+ // 2. The always-running service (launchd/systemd/schtasks) — uninstall + reinstall with
9
+ // the new env. Same dataDir + port as before, mirroring serviceCmd.applyService.
10
+ //
11
+ // HTTP-transport MCP entries don't carry identity (it lives in the service env); they are
12
+ // left untouched.
13
+ //
14
+ // Existing notes do NOT migrate. The folder for the old identity stays under
15
+ // `~/.krimto/user/<old-email>/` — moving them is intentionally a separate step (would need
16
+ // a git rename + reindex; out of scope for this command).
17
+
18
+ import { confirm } from "@inquirer/prompts";
19
+ import { promises as fs } from "node:fs";
20
+ import * as path from "node:path";
21
+
22
+ import { stdioMcpEntry, type KrimtoMcpEntry } from "../server/connect";
23
+ import {
24
+ detectEditorEnvironments,
25
+ detectExistingSetup,
26
+ type EditorEnvironment,
27
+ type EditorKind,
28
+ } from "./init";
29
+ import { writeMcpConfig } from "./mcpConfig";
30
+ import {
31
+ detectPlatform,
32
+ installService,
33
+ isServiceInstalled,
34
+ uninstallService,
35
+ } from "./service";
36
+ import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
37
+ import { runWhoami } from "./whoami";
38
+
39
+ const EDITOR_LABEL: Record<EditorKind, string> = {
40
+ cursor: "Cursor",
41
+ "claude-code": "Claude Code",
42
+ codex: "Codex",
43
+ "gemini-cli": "Gemini CLI",
44
+ };
45
+
46
+ // Same permissive shape used by defaultIdentity() — accepts anything that looks like name@host.
47
+ const EMAIL_REGEX = /^[^@\s]+@[^@\s]+$/;
48
+
49
+ export interface SetIdentityOptions {
50
+ identity: string;
51
+ io?: WizardIO;
52
+ cwd?: string;
53
+ homeDir?: string;
54
+ dataDir?: string;
55
+ /** Skip the confirmation prompt (CI / scripted runs). */
56
+ yes?: boolean;
57
+ /** Forwarded to install/uninstallService for tests. */
58
+ dryRun?: boolean;
59
+ }
60
+
61
+ export interface SetIdentityResult {
62
+ status: "ok" | "no-change" | "error";
63
+ message: string;
64
+ updatedEditors: EditorKind[];
65
+ serviceUpdated: boolean;
66
+ }
67
+
68
+ export async function runSetIdentity(opts: SetIdentityOptions): Promise<SetIdentityResult> {
69
+ const io = opts.io ?? defaultIO;
70
+ const cwd = opts.cwd ?? process.cwd();
71
+
72
+ if (!EMAIL_REGEX.test(opts.identity)) {
73
+ return {
74
+ status: "error",
75
+ message:
76
+ `\n❌ Not a valid email: "${opts.identity}"\n` +
77
+ ` Expected something like name@example.com\n\n`,
78
+ updatedEditors: [],
79
+ serviceUpdated: false,
80
+ };
81
+ }
82
+
83
+ const current = await runWhoami({ cwd, homeDir: opts.homeDir });
84
+ const snapshot = await detectExistingSetup(cwd, opts.homeDir);
85
+
86
+ if (!snapshot.configured) {
87
+ return {
88
+ status: "error",
89
+ message: "\n❌ Krimto isn't set up here yet. Run `krimto init` first.\n\n",
90
+ updatedEditors: [],
91
+ serviceUpdated: false,
92
+ };
93
+ }
94
+
95
+ if (current.activeIdentity === opts.identity && !current.mismatch) {
96
+ return {
97
+ status: "no-change",
98
+ message: `\nNo change — identity is already ${opts.identity}.\n\n`,
99
+ updatedEditors: [],
100
+ serviceUpdated: false,
101
+ };
102
+ }
103
+
104
+ if (!opts.yes) {
105
+ io.out("\nKrimto — Set identity\n\n");
106
+ io.out(` Current identity: ${current.activeIdentity}\n`);
107
+ io.out(` New identity: ${opts.identity}\n\n`);
108
+ io.out(" Will update:\n");
109
+ for (const e of snapshot.registeredEditors) {
110
+ io.out(` • ${EDITOR_LABEL[e]} MCP config\n`);
111
+ }
112
+ if (snapshot.runMode === "always-running") {
113
+ io.out(" • Background service (launchd/systemd/schtasks)\n");
114
+ }
115
+ io.out("\n ⚠️ Existing notes will NOT move — they stay under\n");
116
+ io.out(` ~/.krimto/user/${current.activeIdentity}/\n`);
117
+ io.out(` New facts will go to ~/.krimto/user/${opts.identity}/\n\n`);
118
+ try {
119
+ const ok = await confirm({ message: "Apply this change?", default: true });
120
+ if (!ok) {
121
+ return {
122
+ status: "no-change",
123
+ message: "\nAborted. No changes were made.\n\n",
124
+ updatedEditors: [],
125
+ serviceUpdated: false,
126
+ };
127
+ }
128
+ } catch (e) {
129
+ if (isExitPrompt(e)) {
130
+ return {
131
+ status: "no-change",
132
+ message: "\nAborted. No changes were made.\n\n",
133
+ updatedEditors: [],
134
+ serviceUpdated: false,
135
+ };
136
+ }
137
+ throw e;
138
+ }
139
+ }
140
+
141
+ const envs = await detectEditorEnvironments(cwd, opts.homeDir);
142
+ const updatedEditors: EditorKind[] = [];
143
+ for (const env of envs) {
144
+ if (!snapshot.registeredEditors.includes(env.editor)) continue;
145
+ const ok = await updateEditorIdentity(env, opts.identity);
146
+ if (ok) updatedEditors.push(env.editor);
147
+ }
148
+
149
+ let serviceUpdated = false;
150
+ const platform = detectPlatform();
151
+ const service = await isServiceInstalled(platform, opts.homeDir);
152
+ if (service.installed) {
153
+ const dataDir = opts.dataDir ?? path.join(opts.homeDir ?? "", ".krimto");
154
+ await uninstallService({ platform, homeDir: opts.homeDir, dryRun: opts.dryRun });
155
+ await installService(
156
+ {
157
+ binPath: process.execPath,
158
+ args: [process.argv[1] ?? "krimto", "serve"],
159
+ env: { KRIMTO_IDENTITY: opts.identity, KRIMTO_DATA: dataDir, KRIMTO_HTTP_PORT: "8080" },
160
+ homeDir: opts.homeDir,
161
+ },
162
+ { dryRun: opts.dryRun, platform },
163
+ );
164
+ serviceUpdated = true;
165
+ }
166
+
167
+ const lines: string[] = ["", `✅ Identity changed → ${opts.identity}`, ""];
168
+ for (const e of updatedEditors) {
169
+ lines.push(` • Updated ${EDITOR_LABEL[e]} MCP config`);
170
+ }
171
+ if (serviceUpdated) lines.push(" • Restarted background service");
172
+ if (updatedEditors.length === 0 && !serviceUpdated) {
173
+ lines.push(" (Nothing to update — no editors registered + no service installed)");
174
+ }
175
+ lines.push("");
176
+ lines.push("Restart your editor(s) so they pick up the new identity.");
177
+ lines.push("");
178
+ lines.push(`New facts → ~/.krimto/user/${opts.identity}/`);
179
+ lines.push(`Old facts stay at ~/.krimto/user/${current.activeIdentity}/`);
180
+ lines.push("");
181
+
182
+ return {
183
+ status: "ok",
184
+ message: lines.join("\n"),
185
+ updatedEditors,
186
+ serviceUpdated,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Update KRIMTO_IDENTITY in this editor's MCP entry.
192
+ * • JSON method: surgical mutation preserves any other env keys (KRIMTO_EMBED_*).
193
+ * • CLI method (Claude Code): rebuild + re-add via writeMcpConfig. Extra env keys are lost.
194
+ * • HTTP entry (url-based): no-op — identity lives in the service env.
195
+ * • mcpWire === null: no automated wiring; skip silently.
196
+ */
197
+ async function updateEditorIdentity(env: EditorEnvironment, identity: string): Promise<boolean> {
198
+ if (env.mcpWire === null) return false;
199
+ if (env.mcpWire.method === "json") {
200
+ return updateIdentityInJson(env.mcpWire.path, env.mcpWire.key, identity);
201
+ }
202
+ const entry: KrimtoMcpEntry = { transport: "stdio", ...stdioMcpEntry({ identity }) };
203
+ await writeMcpConfig(env, entry);
204
+ return true;
205
+ }
206
+
207
+ async function updateIdentityInJson(
208
+ filePath: string,
209
+ key: string,
210
+ identity: string,
211
+ ): Promise<boolean> {
212
+ let text: string;
213
+ try {
214
+ text = await fs.readFile(filePath, "utf8");
215
+ } catch {
216
+ return false;
217
+ }
218
+ let parsed: Record<string, unknown>;
219
+ try {
220
+ parsed = JSON.parse(text) as Record<string, unknown>;
221
+ } catch {
222
+ return false;
223
+ }
224
+ const servers = parsed[key] as Record<string, unknown> | undefined;
225
+ if (!servers || !("krimto" in servers)) return false;
226
+ const krimto = servers.krimto as { env?: Record<string, string>; url?: string };
227
+ if (krimto.url) return false;
228
+ const nextEnv = { ...(krimto.env ?? {}), KRIMTO_IDENTITY: identity };
229
+ (servers.krimto as { env: Record<string, string> }).env = nextEnv;
230
+ await fs.writeFile(filePath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
231
+ return true;
232
+ }
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 { isProcessAlive, type LockInfo } from "../server/lock";
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
- const snapshot = await detectExistingSetup(cwd, homeDir);
70
- const envs = await detectEditorEnvironments(cwd, homeDir);
71
- const lock = await readLock(dataDir);
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 5 tools ━━",
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 = { pid: parsed.pid, started: parsed.started, mode: parsed.mode };
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: ${lock.pid}\n` +
62
- ` Mode: ${lock.mode}\n` +
63
- ` Started: ${humanAgo(lock.started, now)}\n` +
64
- ` Data: ${dataDir}\n`;
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 =
@@ -0,0 +1,172 @@
1
+ // `krimto whoami` — show the active KRIMTO_IDENTITY and every place it's set.
2
+ //
3
+ // Motivation: the v0.2.21 "data-location surprise" bug class — users end up with two scopes
4
+ // (`user/lpdthemes@gmail.com` and `user/user@localhost`) because different surfaces saw
5
+ // different identities and they only noticed weeks later. `whoami` makes drift visible
6
+ // before it leads to split notes.
7
+
8
+ import { promises as fs } from "node:fs";
9
+
10
+ import {
11
+ defaultIdentity,
12
+ detectEditorEnvironments,
13
+ type EditorEnvironment,
14
+ type EditorKind,
15
+ } from "./init";
16
+ import { detectPlatform, isServiceInstalled, type ServicePlatform } from "./service";
17
+
18
+ const EDITOR_LABEL: Record<EditorKind, string> = {
19
+ cursor: "Cursor",
20
+ "claude-code": "Claude Code",
21
+ codex: "Codex",
22
+ "gemini-cli": "Gemini CLI",
23
+ };
24
+
25
+ export interface WhoamiOptions {
26
+ cwd?: string;
27
+ homeDir?: string;
28
+ }
29
+
30
+ export interface IdentitySource {
31
+ label: string;
32
+ /** The literal value found; `null` when nothing readable, `"(http)"` for HTTP entries with no inline identity. */
33
+ identity: string | null;
34
+ detail?: string;
35
+ }
36
+
37
+ export interface WhoamiResult {
38
+ /** The identity Krimto would use for a write right now (best inference). */
39
+ activeIdentity: string;
40
+ sources: IdentitySource[];
41
+ /** True when registered surfaces disagree on identity. */
42
+ mismatch: boolean;
43
+ message: string;
44
+ }
45
+
46
+ export async function runWhoami(opts: WhoamiOptions = {}): Promise<WhoamiResult> {
47
+ const cwd = opts.cwd ?? process.cwd();
48
+ const homeDir = opts.homeDir;
49
+ const envs = await detectEditorEnvironments(cwd, homeDir);
50
+
51
+ const sources: IdentitySource[] = [];
52
+ const distinctIdentities = new Set<string>();
53
+
54
+ for (const env of envs) {
55
+ const found = await readIdentityFromEditor(env);
56
+ if (found === null) continue;
57
+ sources.push(found);
58
+ if (found.identity && found.identity !== "(http)") distinctIdentities.add(found.identity);
59
+ }
60
+
61
+ const platform = detectPlatform();
62
+ const service = await isServiceInstalled(platform, homeDir);
63
+ if (service.installed && service.unitPath) {
64
+ const id = await readIdentityFromServiceUnit(service.unitPath, platform);
65
+ if (id) {
66
+ sources.push({ label: "Background service", identity: id, detail: service.unitPath });
67
+ distinctIdentities.add(id);
68
+ }
69
+ }
70
+
71
+ const envOverride = process.env.KRIMTO_IDENTITY ?? null;
72
+ const gitDefault = await defaultIdentity();
73
+
74
+ // Active identity = first concrete source we found; fall back through env → git → server default.
75
+ const firstConcrete = sources.find((s) => s.identity && s.identity !== "(http)")?.identity;
76
+ const activeIdentity = firstConcrete ?? envOverride ?? gitDefault;
77
+ const mismatch = distinctIdentities.size > 1;
78
+
79
+ return {
80
+ activeIdentity,
81
+ sources,
82
+ mismatch,
83
+ message: formatWhoami({ activeIdentity, sources, mismatch, envOverride, gitDefault }),
84
+ };
85
+ }
86
+
87
+ async function readIdentityFromEditor(env: EditorEnvironment): Promise<IdentitySource | null> {
88
+ if (env.mcpWire === null || env.mcpWire.method !== "json") return null;
89
+ let text: string;
90
+ try {
91
+ text = await fs.readFile(env.mcpWire.path, "utf8");
92
+ } catch {
93
+ return null;
94
+ }
95
+ let parsed: Record<string, unknown>;
96
+ try {
97
+ parsed = JSON.parse(text) as Record<string, unknown>;
98
+ } catch {
99
+ return null;
100
+ }
101
+ const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
102
+ if (!servers || !("krimto" in servers)) return null;
103
+ const krimto = servers.krimto as { env?: Record<string, string>; url?: string };
104
+ const label = `${EDITOR_LABEL[env.editor]} MCP config`;
105
+ if (krimto.env?.KRIMTO_IDENTITY) {
106
+ return { label, identity: krimto.env.KRIMTO_IDENTITY, detail: env.mcpWire.path };
107
+ }
108
+ if (krimto.url) {
109
+ return { label, identity: "(http)", detail: env.mcpWire.path };
110
+ }
111
+ return null;
112
+ }
113
+
114
+ async function readIdentityFromServiceUnit(
115
+ unitPath: string,
116
+ platform: ServicePlatform,
117
+ ): Promise<string | null> {
118
+ let text: string;
119
+ try {
120
+ text = await fs.readFile(unitPath, "utf8");
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (platform === "darwin") {
125
+ // plist XML pattern written by installService — KRIMTO_IDENTITY appears as a <key>/<string> pair.
126
+ const m = text.match(/<key>KRIMTO_IDENTITY<\/key>\s*<string>([^<]+)<\/string>/);
127
+ return m && m[1] ? m[1] : null;
128
+ }
129
+ if (platform === "linux") {
130
+ const m = text.match(/KRIMTO_IDENTITY=([^"\s\n]+)/);
131
+ return m && m[1] ? m[1] : null;
132
+ }
133
+ // Windows: schtasks doesn't store env in a readable file we own. Skip.
134
+ return null;
135
+ }
136
+
137
+ function formatWhoami(opts: {
138
+ activeIdentity: string;
139
+ sources: IdentitySource[];
140
+ mismatch: boolean;
141
+ envOverride: string | null;
142
+ gitDefault: string;
143
+ }): string {
144
+ const lines: string[] = ["", "Krimto — Identity", ""];
145
+ lines.push(` Active identity: ${opts.activeIdentity}`, "");
146
+ lines.push(" Where it's set:");
147
+ if (opts.sources.length === 0) {
148
+ lines.push(" (no editors registered, no service installed — run `krimto init` first)");
149
+ } else {
150
+ for (const s of opts.sources) {
151
+ const display = s.identity === "(http)" ? "(uses service identity)" : (s.identity ?? "(unset)");
152
+ const marker = s.identity === "(http)" || s.identity === opts.activeIdentity ? "✓" : "⚠";
153
+ lines.push(` ${marker} ${s.label.padEnd(28)} ${display}`);
154
+ }
155
+ }
156
+ lines.push("");
157
+ lines.push(" Fallback chain (used when no source above sets it):");
158
+ lines.push(` KRIMTO_IDENTITY env ${opts.envOverride ?? "(unset)"}`);
159
+ lines.push(` git config user.email ${opts.gitDefault}`);
160
+ lines.push(" Server default user@localhost");
161
+ lines.push("");
162
+ if (opts.mismatch) {
163
+ lines.push(" ⚠️ Mismatch — different sources disagree on identity.");
164
+ lines.push(" Run `krimto set identity <email>` to sync them.");
165
+ } else if (opts.sources.length > 0) {
166
+ lines.push(" ✅ All sources agree.");
167
+ }
168
+ lines.push("");
169
+ lines.push("To change it: krimto set identity <email>");
170
+ lines.push("");
171
+ return lines.join("\n");
172
+ }