@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/src/cli/service.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 {
|
|
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 =
|
|
@@ -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
|
+
}
|