@krimto-labs/krimto 0.2.35 → 0.2.36

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/README.md CHANGED
@@ -9,8 +9,8 @@ place and reads the right slice of it — Alice's preferences override the team'
9
9
  conventions override the org's standards, and every fact carries a paper trail (author, source,
10
10
  timestamp, reviewer).
11
11
 
12
- > **Where we are:** **v0.2.35** is the current release — the v0.2.17 wizard redesign is now
13
- > shipped end-to-end, plus eighteen patch releases of correctness fixes and agent-friendly
12
+ > **Where we are:** **v0.2.36** is the current release — the v0.2.17 wizard redesign is now
13
+ > shipped end-to-end, plus nineteen patch releases of correctness fixes and agent-friendly
14
14
  > surface. The v0.2.16 architecture (markdown-in-git storage, `user → team → org` hierarchy,
15
15
  > hybrid retrieval, server-enforced access, two-way git sync, MCP over stdio + HTTP, the Docker
16
16
  > image, the web UI) is unchanged. What you get on top of v0.2.16:
@@ -41,7 +41,7 @@ timestamp, reviewer).
41
41
  > first, waits for `:8080` to accept TCP, then writes editor configs. Cursor's file
42
42
  > watcher never fires into an unbound port (the v0.2.27/28 ECONNREFUSED fix).
43
43
  >
44
- > **The agent story (v0.2.34 → v0.2.35).**
44
+ > **The agent story (v0.2.34 → v0.2.36).**
45
45
  > - **Phase B agent flags** — `editors --add cursor`, `service --always`, `search --keyword`,
46
46
  > `reset --yes`, `remote --set <url>`, `folder --to <path>`. Every command that used to
47
47
  > open an interactive prompt now has a flag form.
@@ -54,6 +54,10 @@ timestamp, reviewer).
54
54
  > Cursor chat" line works without prompting agents.
55
55
  > - **Cursor `alwaysApply: true` frontmatter** so `.cursor/rules/krimto.mdc` auto-attaches
56
56
  > instead of requiring the user to type "krimto" first.
57
+ > - **`krimto team init` lands you in team mode (v0.2.36)** — the wizard restarts the running
58
+ > service into team mode itself (no copy-paste recipe, no lock conflict), saves invite keys
59
+ > to a 0600 backup file, and validates the git remote URL at the prompt. `krimto notes` now
60
+ > works from any terminal (identity falls back to `git config user.email`).
57
61
  >
58
62
  > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
59
63
  > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
@@ -409,14 +413,14 @@ admin surface.
409
413
 
410
414
  ### Option C — Docker (HTTP + bearer auth, containerized)
411
415
 
412
- Build the image and run it (a published image is coming):
416
+ The published multi-arch image at `ghcr.io/krimto-labs/krimto:latest` (built for `linux/amd64` and
417
+ `linux/arm64`) is the default path:
413
418
 
414
419
  ```bash
415
- docker build -t krimto .
416
420
  docker run -d --name krimto -p 8080:8080 \
417
421
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com \
418
422
  -v ~/.krimto:/data \
419
- krimto
423
+ ghcr.io/krimto-labs/krimto:latest
420
424
  docker logs krimto | grep "admin API key" # the key is printed once
421
425
  ```
422
426
 
@@ -424,17 +428,19 @@ The container serves MCP at `http://localhost:8080/mcp` (bearer auth) and health
424
428
  `/health/ready`; facts persist in the mounted `/data` volume. Point your agent at it with the same
425
429
  `"url"` + `Bearer` config as Option B.
426
430
 
427
- **Pulling a published image (no local build):** pushing a `v*` git tag runs
428
- [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml), which publishes the
429
- image to `ghcr.io/krimto-labs/krimto`. After the first release tag you can skip `docker build` and run
430
- the published image directly:
431
+ **Building locally** only needed if you're developing Krimto or pinning to an unreleased
432
+ commit:
431
433
 
432
434
  ```bash
435
+ docker build -t krimto .
433
436
  docker run -d --name krimto -p 8080:8080 \
434
437
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com -v ~/.krimto:/data \
435
- ghcr.io/krimto-labs/krimto:latest
438
+ krimto
436
439
  ```
437
440
 
441
+ The published image is built and pushed by
442
+ [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) on every `v*` tag.
443
+
438
444
  ### Web UI (humans)
439
445
 
440
446
  When the HTTP server is running, open `http://localhost:8080/ui`. In local mode (no
@@ -496,9 +502,9 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
496
502
 
497
503
  ## Roadmap
498
504
 
499
- `v0.2` (teams) → `v0.2.18` (UX redesign — wizards, per-note CLI, notes-app `/ui` — published) →
500
- `v0.3` (OAuth + PR approval flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the
501
- per-release breakdown.
505
+ `v0.2` (teams, v0.2.5) → `v0.2.18` (v0.2.17 wizard redesign — published as one SemVer-clean
506
+ release) → `v0.2.36` (correctness + agent-friendly polish — current) → `v0.3` (OAuth + PR approval
507
+ flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the per-release breakdown.
502
508
 
503
509
  ## License
504
510
 
package/bin/krimto.mjs CHANGED
@@ -521,7 +521,7 @@ try {
521
521
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
522
522
  const result = await runNotes({
523
523
  dataDir: resolveDataDir(),
524
- identity: resolveIdentity(),
524
+ identity: await resolveIdentity(),
525
525
  query: typeof query === "string" && query.length > 0 ? query : undefined,
526
526
  });
527
527
  process.stdout.write(result.message);
@@ -534,7 +534,7 @@ try {
534
534
  }
535
535
  const { runEdit } = await tsImport("../src/cli/edit.ts", import.meta.url);
536
536
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
537
- const result = await runEdit({ dataDir: resolveDataDir(), identity: resolveIdentity(), id });
537
+ const result = await runEdit({ dataDir: resolveDataDir(), identity: await resolveIdentity(), id });
538
538
  process.stdout.write(result.message);
539
539
  if (result.status !== "ok" && result.status !== "no-change") process.exitCode = 1;
540
540
  } else if (cmd === "mv") {
@@ -551,7 +551,7 @@ try {
551
551
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
552
552
  const result = await runMv({
553
553
  dataDir: resolveDataDir(),
554
- identity: resolveIdentity(),
554
+ identity: await resolveIdentity(),
555
555
  id,
556
556
  newScope,
557
557
  });
@@ -568,7 +568,7 @@ try {
568
568
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
569
569
  const result = await runSupersede({
570
570
  dataDir: resolveDataDir(),
571
- identity: resolveIdentity(),
571
+ identity: await resolveIdentity(),
572
572
  id,
573
573
  });
574
574
  process.stdout.write(result.message);
@@ -587,7 +587,7 @@ try {
587
587
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
588
588
  const result = await runTag({
589
589
  dataDir: resolveDataDir(),
590
- identity: resolveIdentity(),
590
+ identity: await resolveIdentity(),
591
591
  id,
592
592
  changes,
593
593
  });
@@ -603,7 +603,7 @@ try {
603
603
  }
604
604
  const { runDeleteFact } = await tsImport("../src/cli/deleteFact.ts", import.meta.url);
605
605
  const { resolveDataDir, resolveIdentity } = await tsImport("../src/server/index.ts", import.meta.url);
606
- const result = await runDeleteFact(resolveDataDir(), resolveIdentity(), id);
606
+ const result = await runDeleteFact(resolveDataDir(), await resolveIdentity(), id);
607
607
  process.stdout.write(result.message);
608
608
  if (result.status !== "ok") process.exitCode = 1;
609
609
  } else if (cmd === "reindex") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
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",
package/src/cli/join.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { checkbox } from "@inquirer/prompts";
13
13
  import { promises as fs } from "node:fs";
14
+ import * as os from "node:os";
14
15
  import * as path from "node:path";
15
16
 
16
17
  import { applyRule } from "../agentRule";
@@ -20,6 +21,7 @@ import {
20
21
  type EditorEnvironment,
21
22
  type EditorKind,
22
23
  } from "./init";
24
+ import { inspectRuntime } from "./inspectRuntime";
23
25
  import { writeMcpConfig, type WriteAction } from "./mcpConfig";
24
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
25
27
 
@@ -137,6 +139,15 @@ export async function runJoin(args: JoinArgs, opts: JoinOptions = {}): Promise<J
137
139
  try {
138
140
  io.out("\nKrimto — Joining team server\n\n");
139
141
  io.out(` Server: ${normalizeServerUrl(args.server)}\n`);
142
+
143
+ // Smoke-6 follow-up: a teammate may have an EARLIER solo-mode Krimto service running on
144
+ // this machine. After join, their editor points at the admin's server (good) — but the
145
+ // local solo service keeps listening on localhost:8080 with no auth, serving whatever's
146
+ // in their solo data dir. Print one warning line so they can `krimto stop` first.
147
+ // Non-blocking, narrow: only fires when (a) a service-launched lock is alive, (b) the
148
+ // local /mcp returns anything other than 401 (i.e. not enforcing auth).
149
+ await warnIfLocalSoloServiceRunning(args.server, opts, io);
150
+
140
151
  io.out(" Detecting your editors...\n");
141
152
 
142
153
  const cwd = opts.cwd ?? process.cwd();
@@ -201,6 +212,61 @@ async function readMaybe(p: string): Promise<string | null> {
201
212
  }
202
213
  }
203
214
 
215
+ /**
216
+ * Soft guard for the Plan-agent-flagged Risk (b): a teammate's earlier solo-mode Krimto
217
+ * service may still be running on this machine after join, serving solo notes on
218
+ * localhost:8080 with no auth. Print one warning line so they can `krimto stop` first.
219
+ * Best-effort, non-blocking — never refuses to proceed (that's the architectural fix's job).
220
+ */
221
+ async function warnIfLocalSoloServiceRunning(
222
+ teamServerArg: string,
223
+ opts: JoinOptions,
224
+ io: WizardIO,
225
+ ): Promise<void> {
226
+ const dataDir = path.join(opts.homeDir ?? os.homedir(), ".krimto");
227
+ let runtime;
228
+ try {
229
+ runtime = await inspectRuntime(
230
+ dataDir,
231
+ opts.homeDir ? { homeDir: opts.homeDir } : {},
232
+ );
233
+ } catch {
234
+ return; // probe failed — say nothing rather than scare the user
235
+ }
236
+ if (!runtime.lock || !runtime.lock.alive || runtime.effectiveLaunchedBy !== "service") return;
237
+
238
+ // Same-machine joins (admin onboarding themselves) are NOT the case we want to warn about —
239
+ // their local service IS the team server. Skip the warning when the team-server URL is
240
+ // localhost / 127.0.0.1 / the host's own hostname.
241
+ const url = teamServerArg.toLowerCase();
242
+ if (
243
+ url.includes("localhost") ||
244
+ url.includes("127.0.0.1") ||
245
+ url.includes(os.hostname().toLowerCase())
246
+ ) {
247
+ return;
248
+ }
249
+
250
+ // Probe the local /mcp endpoint. 401 means auth is enforced (team mode) → no warning.
251
+ // Anything else (200, 405, timeout) → likely solo mode → warn.
252
+ let enforcingAuth = false;
253
+ try {
254
+ const res = await fetch("http://127.0.0.1:8080/mcp", {
255
+ signal: AbortSignal.timeout(1500),
256
+ });
257
+ enforcingAuth = res.status === 401;
258
+ } catch {
259
+ /* unreachable on :8080 — fall through and warn */
260
+ }
261
+ if (enforcingAuth) return;
262
+
263
+ io.err(
264
+ `\n⚠ A local Krimto service is also running on this machine (PID ${runtime.lock.pid}).\n` +
265
+ ` It serves notes in ${dataDir} on http://localhost:8080 with no auth.\n` +
266
+ ` If you don't need it: \`krimto stop\` then re-run \`krimto join …\`.\n\n`,
267
+ );
268
+ }
269
+
204
270
  function printApplyResult(res: JoinResult, io: WizardIO): void {
205
271
  for (const o of res.editorOutcomes) {
206
272
  const label = EDITOR_LABEL[o.editor];
@@ -14,13 +14,18 @@ export interface SetupRemoteResult {
14
14
  url: string;
15
15
  }
16
16
 
17
- // Minimal guard against obvious typos. Anything with whitespace or no `/`/`:` separator
18
- // (e.g. "not a url") is rejected up front; everything else is handed to git, which is the
19
- // authoritative URL parser (accepts ssh, https, file://, bare local paths, etc.).
20
- function looksLikeRemoteUrl(url: string): boolean {
17
+ // Reject anything that isn't an obvious git remote BEFORE we hand it to `git`. Smoke-6
18
+ // transcript showed `github.com/krimto-labs/foo.git` (no protocol, no user) reaching git and
19
+ // failing only at push time by which point the wizard had already printed success messages.
20
+ // Tightened to require one of the well-known transport prefixes (or an absolute filesystem
21
+ // path). git is still the authoritative parser for everything past this gate.
22
+ const VALID_REMOTE_PREFIXES = ["git@", "https://", "http://", "ssh://", "file://"];
23
+
24
+ export function looksLikeRemoteUrl(url: string): boolean {
21
25
  if (url.trim() !== url || url.length === 0) return false;
22
26
  if (/\s/.test(url)) return false;
23
- return /[:/]/.test(url);
27
+ if (url.startsWith("/")) return true;
28
+ return VALID_REMOTE_PREFIXES.some((p) => url.startsWith(p));
24
29
  }
25
30
 
26
31
  export async function runSetupRemote(dataDir: string, url: string): Promise<SetupRemoteResult> {
@@ -47,7 +52,7 @@ export async function runSetupRemote(dataDir: string, url: string): Promise<Setu
47
52
  `\n ${url}\n` +
48
53
  `\n━━ Next ━━\n` +
49
54
  `\n To also auto-pull teammates' edits (every 60s), set on next boot:\n` +
50
- ` $ export KRIMTO_GIT_REMOTE=${url}\n` +
55
+ ` export KRIMTO_GIT_REMOTE=${url}\n` +
51
56
  `\n The batcher will auto-push every commit from now on regardless.\n`,
52
57
  };
53
58
  }
@@ -22,8 +22,10 @@ import { ApiKeyStore } from "../access/auth";
22
22
  import { addUser, createTeam } from "../access/membershipStore";
23
23
  import { bootstrapAdmin } from "../server/bootstrap";
24
24
  import { defaultIdentity } from "./init";
25
+ import { inspectRuntime } from "./inspectRuntime";
25
26
  import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
26
- import { runSetupRemote } from "./setupRemote";
27
+ import { detectPlatform, installService } from "./service";
28
+ import { looksLikeRemoteUrl, runSetupRemote } from "./setupRemote";
27
29
 
28
30
  /** Everything the wizard collects before it calls {@link applyTeamInit}. */
29
31
  export interface TeamInitAnswers {
@@ -58,6 +60,13 @@ export interface TeamInitResult {
58
60
  serverHost: string;
59
61
  /** Resolved data dir the wizard wrote membership/keys into. */
60
62
  dataDir: string;
63
+ /**
64
+ * Path to the 0600-mode invite file written at apply time, when any keys were minted.
65
+ * Unset when there was nothing new to save (idempotent rerun where admin + all teammates
66
+ * already had keys). Admin's key is shown-once-only — losing it from scrollback used to
67
+ * require `reset-admin-key`; the file is the recoverable backup.
68
+ */
69
+ inviteFilePath?: string;
61
70
  }
62
71
 
63
72
  export interface TeamInitOptions {
@@ -68,6 +77,14 @@ export interface TeamInitOptions {
68
77
  keysPath?: string;
69
78
  /** Override KRIMTO_HTTP_PORT detection. */
70
79
  port?: number;
80
+ /** Override os.homedir() — passed to inspectRuntime + installService for tests. */
81
+ homeDir?: string;
82
+ /** When true, the post-apply service-restart step writes files but never invokes
83
+ * launchctl/systemctl/schtasks. Tests use this to assert the new env block without
84
+ * mutating CI's user services. */
85
+ dryRun?: boolean;
86
+ /** Suppress the post-apply service-restart probe entirely (tests that don't exercise it). */
87
+ skipServiceRestart?: boolean;
71
88
  /** Skip `runSetupRemote` even when a URL was given. Tests use this. */
72
89
  skipRemoteSetup?: boolean;
73
90
  }
@@ -123,6 +140,24 @@ export async function applyTeamInit(
123
140
  }
124
141
 
125
142
  const port = opts.port ?? Number(process.env.KRIMTO_HTTP_PORT ?? "8080");
143
+ const serverHost = `localhost:${port}`;
144
+
145
+ // 5. Save plaintext keys + DM template to a 0600 file. Admin's key is shown-once-only;
146
+ // teammates' keys are shown-once-each. Without this file the only recovery is
147
+ // `reset-admin-key` (admin) or re-issuing each invite (teammates) — both unnecessary churn.
148
+ let inviteFilePath: string | undefined;
149
+ if (adminKey || invites.length > 0) {
150
+ inviteFilePath = await writeInviteFile({
151
+ dataDir,
152
+ adminEmail: answers.adminEmail,
153
+ adminKey,
154
+ teamSlug: answers.teamSlug,
155
+ teamName: answers.teamName,
156
+ invites,
157
+ serverHost,
158
+ });
159
+ }
160
+
126
161
  return {
127
162
  adminEmail: answers.adminEmail,
128
163
  adminKey,
@@ -130,11 +165,60 @@ export async function applyTeamInit(
130
165
  teamName: answers.teamName,
131
166
  remote,
132
167
  invites,
133
- serverHost: `localhost:${port}`,
168
+ serverHost,
134
169
  dataDir,
170
+ ...(inviteFilePath ? { inviteFilePath } : {}),
135
171
  };
136
172
  }
137
173
 
174
+ /**
175
+ * Write the team-invite file to `<dataDir>/.krimto/team-invites-<ISO-timestamp>.txt` with
176
+ * mode 0o600. Filename's timestamp colons are replaced with dashes so the path is portable
177
+ * across filesystems. Returns the absolute path.
178
+ */
179
+ async function writeInviteFile(input: {
180
+ dataDir: string;
181
+ adminEmail: string;
182
+ adminKey: string | null;
183
+ teamSlug: string;
184
+ teamName?: string;
185
+ invites: InviteRecord[];
186
+ serverHost: string;
187
+ }): Promise<string> {
188
+ const ts = new Date().toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
189
+ const file = path.join(input.dataDir, ".krimto", `team-invites-${ts}.txt`);
190
+ const teamLabel = input.teamName ? `${input.teamSlug} ("${input.teamName}")` : input.teamSlug;
191
+ const adminBlock = input.adminKey
192
+ ? `━━ Admin ━━\n ${input.adminEmail.padEnd(28)} ${input.adminKey}\n\n`
193
+ : `━━ Admin ━━\n ${input.adminEmail} — existing key kept (no new one minted).\n\n`;
194
+ const inviteBlock =
195
+ input.invites.length > 0
196
+ ? "━━ Teammates (send each their key) ━━\n" +
197
+ input.invites.map((i) => ` ${i.email.padEnd(28)} ${i.key}`).join("\n") +
198
+ "\n\n"
199
+ : "";
200
+ const dmTemplate =
201
+ "━━ DM template (one per teammate) ━━\n" +
202
+ " 1. Install Krimto:\n" +
203
+ ` npx @krimto-labs/krimto join \\\n` +
204
+ ` --server http://${input.serverHost} \\\n` +
205
+ ` --key <your key from above>\n` +
206
+ " 2. Restart your editor.\n" +
207
+ " 3. Test: \"Remember our package manager is pnpm\" → new chat → \"What do we use?\"\n\n" +
208
+ (input.serverHost.startsWith("localhost:")
209
+ ? " Note: `localhost` only works if your teammates are on this same machine.\n" +
210
+ " Swap the --server URL for a reachable address before sharing.\n"
211
+ : "");
212
+ const body =
213
+ `Krimto team mode — saved ${new Date().toISOString()}\n` +
214
+ `Team: ${teamLabel}\n\n` +
215
+ adminBlock +
216
+ inviteBlock +
217
+ dmTemplate;
218
+ await fs.writeFile(file, body, { encoding: "utf8", mode: 0o600 });
219
+ return file;
220
+ }
221
+
138
222
  // === Interactive entry point ===============================================
139
223
 
140
224
  /**
@@ -166,7 +250,14 @@ export async function runTeamInit(opts: TeamInitOptions = {}): Promise<TeamInitR
166
250
  { adminEmail, teamSlug, teamName, gitRemote, teammates },
167
251
  opts,
168
252
  );
169
- printApplyResult(result, io);
253
+
254
+ // Probe runtime + offer to restart the always-running service so team-mode auth takes
255
+ // effect on the SAME machine, in the SAME wizard run. The smoke-6 transcript ended with
256
+ // a copy-paste "Next" recipe (`KRIMTO_BOOTSTRAP_ADMIN=... npx serve`) the user couldn't
257
+ // run because their existing service held the data-dir lock. This closes that gap.
258
+ const restartOutcome = await maybeRestartServiceForTeamMode(result, opts);
259
+
260
+ printApplyResult(result, io, restartOutcome);
170
261
  return result;
171
262
  } catch (e) {
172
263
  if (isExitPrompt(e)) {
@@ -231,7 +322,14 @@ async function askGitRemote(io: WizardIO): Promise<string | undefined> {
231
322
  const url = (
232
323
  await input({
233
324
  message: "Remote URL (e.g. git@github.com:acme/krimto-data.git)",
234
- validate: (v) => (v.trim().length > 0 ? true : "Please paste a URL or pick 'Not yet'"),
325
+ validate: (v) => {
326
+ const t = v.trim();
327
+ if (t.length === 0) return "Please paste a URL or pick 'Not yet'";
328
+ if (!looksLikeRemoteUrl(t)) {
329
+ return "URL must start with git@, https://, http://, ssh://, file://, or / (no bare 'github.com/...')";
330
+ }
331
+ return true;
332
+ },
235
333
  })
236
334
  ).trim();
237
335
  io.out("Will verify the push during apply.\n");
@@ -254,6 +352,74 @@ async function askTeammates(): Promise<string[]> {
254
352
  return list;
255
353
  }
256
354
 
355
+ // === Post-apply service restart =============================================
356
+
357
+ /**
358
+ * What happened when the wizard tried to flip the running service into team mode after apply.
359
+ * Drives the "Next" block in {@link printApplyResult}: when team mode is live, we don't print
360
+ * the copy-paste `KRIMTO_BOOTSTRAP_ADMIN=... npx serve` recipe.
361
+ */
362
+ export type RestartOutcome =
363
+ | { kind: "no-service" }
364
+ | { kind: "declined" }
365
+ | { kind: "restarted"; portReady: boolean }
366
+ | { kind: "failed"; error: string };
367
+
368
+ /**
369
+ * If the running Krimto IS the always-running service we installed, offer to restart it with
370
+ * `KRIMTO_BOOTSTRAP_ADMIN` baked in so team-mode auth takes effect immediately. Returns the
371
+ * outcome for the print step to render. Skipped silently when {@link TeamInitOptions.skipServiceRestart}
372
+ * is set (tests that don't want to exercise this path).
373
+ */
374
+ async function maybeRestartServiceForTeamMode(
375
+ result: TeamInitResult,
376
+ opts: TeamInitOptions,
377
+ ): Promise<RestartOutcome> {
378
+ if (opts.skipServiceRestart) return { kind: "no-service" };
379
+ const io = opts.io ?? defaultIO;
380
+
381
+ const runtime = await inspectRuntime(
382
+ result.dataDir,
383
+ opts.homeDir ? { homeDir: opts.homeDir } : {},
384
+ );
385
+ if (!runtime.service.loaded || runtime.effectiveLaunchedBy !== "service") {
386
+ return { kind: "no-service" };
387
+ }
388
+
389
+ const wantRestart = await confirm({
390
+ message: "Restart the running Krimto service so team mode is enforced now? (~3s of downtime)",
391
+ default: true,
392
+ });
393
+ if (!wantRestart) return { kind: "declined" };
394
+
395
+ io.out("\n Restarting service in team mode...\n");
396
+ try {
397
+ const install = await installService(
398
+ {
399
+ binPath: process.execPath,
400
+ args: [process.argv[1] ?? "krimto", "serve"],
401
+ env: {
402
+ KRIMTO_IDENTITY: result.adminEmail,
403
+ KRIMTO_DATA: result.dataDir,
404
+ KRIMTO_HTTP_PORT: "8080",
405
+ KRIMTO_BOOTSTRAP_ADMIN: result.adminEmail,
406
+ },
407
+ ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
408
+ },
409
+ {
410
+ platform: detectPlatform(),
411
+ ...(opts.dryRun ? { dryRun: opts.dryRun } : {}),
412
+ },
413
+ );
414
+ return { kind: "restarted", portReady: install.portReady !== false };
415
+ } catch (e) {
416
+ return { kind: "failed", error: e instanceof Error ? e.message : String(e) };
417
+ }
418
+ }
419
+
420
+ // Exposed for the integration test that asserts on the env block written to the plist.
421
+ export { maybeRestartServiceForTeamMode };
422
+
257
423
  // === Pretty printing ========================================================
258
424
 
259
425
  function printPreamble(dataDir: string, io: WizardIO): void {
@@ -273,7 +439,7 @@ function printSummary(a: TeamInitAnswers, io: WizardIO): void {
273
439
  io.out(` Teammates: ${a.teammates.length === 0 ? "(none yet)" : a.teammates.join(", ")}\n\n`);
274
440
  }
275
441
 
276
- function printApplyResult(res: TeamInitResult, io: WizardIO): void {
442
+ function printApplyResult(res: TeamInitResult, io: WizardIO, restart: RestartOutcome): void {
277
443
  io.out(" ✓ Admin promoted + members.yaml updated\n");
278
444
  io.out(` ✓ Team "${res.teamSlug}" created\n`);
279
445
  if (res.invites.length > 0) {
@@ -302,7 +468,7 @@ function printApplyResult(res: TeamInitResult, io: WizardIO): void {
302
468
  }
303
469
  io.out("\n━━ DM template for each teammate ━━\n\n");
304
470
  io.out(" 1. Install Krimto:\n");
305
- io.out(` $ npx @krimto-labs/krimto join \\\n`);
471
+ io.out(` npx @krimto-labs/krimto join \\\n`);
306
472
  io.out(` --server http://${res.serverHost} \\\n`);
307
473
  io.out(` --key <your key from above>\n`);
308
474
  io.out(" 2. Restart your editor.\n");
@@ -316,9 +482,31 @@ function printApplyResult(res: TeamInitResult, io: WizardIO): void {
316
482
  }
317
483
  }
318
484
 
485
+ if (res.inviteFilePath) {
486
+ io.out("━━ Backup ━━\n\n");
487
+ io.out(` All keys + the DM template are also saved to:\n ${res.inviteFilePath}\n`);
488
+ io.out(` (mode 0600 — read by you only. Delete it once teammates have their keys.)\n\n`);
489
+ }
490
+
491
+ // The "Next" block depends on whether the running service was just flipped into team mode.
492
+ // When it was, the user's already done — just hand them the dashboard URL. When it wasn't
493
+ // (no service, declined, or install failed), fall back to the original copy-paste recipe.
319
494
  io.out("━━ Next ━━\n\n");
320
- io.out(" • Start the server in team mode (if not already):\n");
321
- io.out(` $ KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
322
- io.out(" • View the dashboard at http://localhost:8080/ui/admin\n");
495
+ if (restart.kind === "restarted" && restart.portReady) {
496
+ io.out(` 🟢 Team mode is live on http://localhost:8080\n`);
497
+ io.out(` • View the dashboard at http://localhost:8080/ui/admin\n`);
498
+ } else if (restart.kind === "restarted" && !restart.portReady) {
499
+ io.out(` ⚠ Service restarted in team mode, but the port didn't come up in 10s.\n`);
500
+ io.out(` Check /tmp/com.krimto.server.err.log (macOS) or \`journalctl --user -u krimto\` (Linux).\n`);
501
+ io.out(` • View the dashboard at http://localhost:8080/ui/admin (once the port is up)\n`);
502
+ } else if (restart.kind === "failed") {
503
+ io.out(` ⚠ Service restart failed: ${restart.error}\n`);
504
+ io.out(` Start it yourself: KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
505
+ } else {
506
+ // "declined" or "no-service" — print the original recipe (still without the literal `$`).
507
+ io.out(" • Start the server in team mode (if not already):\n");
508
+ io.out(` KRIMTO_BOOTSTRAP_ADMIN=${res.adminEmail} npx @krimto-labs/krimto serve\n`);
509
+ io.out(" • View the dashboard at http://localhost:8080/ui/admin\n");
510
+ }
323
511
  io.out(" • Step back to solo with `krimto team disband` (data preserved)\n\n");
324
512
  }
package/src/cli/wizard.ts CHANGED
@@ -258,7 +258,7 @@ async function runFreshWizard(
258
258
  ): Promise<ApplyResult | null> {
259
259
  io.out(`\nKrimto — Setting up your AI's memory · v${KRIMTO_VERSION}\n\n`);
260
260
  const envs = await detectEditorEnvironments(cwd, opts.homeDir);
261
- printScan(envs, io);
261
+ printScan(envs, snapshot?.registeredEditors ?? [], io);
262
262
 
263
263
  // v0.2.31 — Gap D, "Keep current" intermediate prompt. On reconfigure runs (snapshot !==
264
264
  // null), each question is wrapped in a two-stage "Keep current / Reconfigure..." select so
@@ -498,16 +498,22 @@ async function askSearch(
498
498
 
499
499
  // === Pretty printing ========================================================
500
500
 
501
- function printScan(envs: EditorEnvironment[], io: WizardIO): void {
501
+ function printScan(envs: EditorEnvironment[], registered: EditorKind[], io: WizardIO): void {
502
+ // Four states. The crucial split is "detected" vs "connected to Krimto": the smoke-6 user saw
503
+ // four detected editors then "Keep current (Cursor, Claude Code)" and didn't realize the other
504
+ // two weren't actually wired to Krimto's MCP. Now the scan output names that distinction.
505
+ const wired = new Set(registered);
502
506
  io.out(" Scanning your machine ...\n\n");
503
507
  for (const env of envs) {
504
- // v0.2.21: three states — project-level, machine-level only, not found.
505
- const dot = env.present ? "✓" : env.installed ? "~" : "–";
506
- const note = env.present
507
- ? "detected (in this project)"
508
- : env.installed
509
- ? "installed (machine-wide)"
510
- : "not found";
508
+ const isWired = wired.has(env.editor);
509
+ const dot = isWired ? "✓" : env.present ? "✓" : env.installed ? "~" : "–";
510
+ const note = isWired
511
+ ? "connected to Krimto"
512
+ : env.present
513
+ ? "detected, not yet connected"
514
+ : env.installed
515
+ ? "installed (machine-wide), not connected"
516
+ : "not found";
511
517
  io.out(` ${dot} ${EDITOR_LABEL[env.editor].padEnd(14)} ${note}\n`);
512
518
  }
513
519
  io.out("\n");
@@ -3,15 +3,18 @@
3
3
 
4
4
  import { connectSnippets } from "./connect";
5
5
 
6
- /** The placeholder identity that resolves when KRIMTO_IDENTITY is unset. Keep in sync with resolveIdentity(). */
6
+ /** The placeholder identity that resolves when KRIMTO_IDENTITY is unset AND git config user.email is
7
+ * unset/invalid. Keep in sync with resolveIdentity()'s final fallback. */
7
8
  export const DEFAULT_IDENTITY = "user@localhost";
8
9
 
9
10
  /**
10
11
  * G2 — warn when the resolved identity is the unset-placeholder default. Two Krimto processes
11
12
  * on the same data dir (e.g. Cursor's stdio launch + a separate `serve` in her terminal) often
12
13
  * resolve to different identities — the MCP config sets one, the bare shell doesn't. The result
13
- * is scope mismatch: she writes facts under one identity and sees a different scope in /ui.
14
- * Returns an empty string when the identity was explicitly set.
14
+ * is scope mismatch: facts saved under one identity, viewed under another in /ui. After the
15
+ * smoke-6 fix, `resolveIdentity()` falls back to global git user.email before this placeholder,
16
+ * so the warning only fires when both sources are missing.
17
+ * Returns an empty string when a real identity was resolved.
15
18
  */
16
19
  export function identityWarning(identity: string): string {
17
20
  if (identity !== DEFAULT_IDENTITY) return "";
@@ -8,6 +8,11 @@ import { fileURLToPath } from "node:url";
8
8
  import { homedir } from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { promises as fs } from "node:fs";
11
+ import { execFile } from "node:child_process";
12
+ import { promisify } from "node:util";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const IDENTITY_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
11
16
 
12
17
  import { ApiKeyStore } from "../access/auth";
13
18
  import { bootstrapAdmin, reissueKey } from "./bootstrap";
@@ -49,14 +54,36 @@ import { type Requester } from "../access/scope";
49
54
 
50
55
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
51
56
 
52
- export const KRIMTO_VERSION = "0.2.35";
57
+ export const KRIMTO_VERSION = "0.2.36";
53
58
 
54
59
  export function resolveDataDir(): string {
55
60
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");
56
61
  }
57
62
 
58
- export function resolveIdentity(): string {
59
- return process.env.KRIMTO_IDENTITY ?? "user@localhost";
63
+ /**
64
+ * Resolve the caller's identity in three steps: explicit env override, then the user's global
65
+ * git identity, then a last-resort placeholder.
66
+ *
67
+ * The git fallback closes the smoke-6 UX gap. The wizard sets `KRIMTO_IDENTITY` in editor MCP
68
+ * configs and the service plist — but not in the user's shell rc. Without the git fallback,
69
+ * a plain-terminal `krimto notes` ran as `user@localhost` and couldn't see facts the editor
70
+ * had saved under the wizard-configured identity — same data dir, two answers depending on
71
+ * shell env. The CLI now infers the same identity the wizard would have captured.
72
+ *
73
+ * Stays async because the git lookup shells out; every call site is already inside an async
74
+ * handler. Malformed env values fall through (we never persist a non-email as an identity).
75
+ */
76
+ export async function resolveIdentity(): Promise<string> {
77
+ const env = process.env.KRIMTO_IDENTITY;
78
+ if (env && IDENTITY_EMAIL_RE.test(env)) return env;
79
+ try {
80
+ const { stdout } = await execFileAsync("git", ["config", "--global", "user.email"]);
81
+ const email = stdout.trim();
82
+ if (IDENTITY_EMAIL_RE.test(email)) return email;
83
+ } catch {
84
+ /* git missing, no global user.email — fall through to the placeholder */
85
+ }
86
+ return "user@localhost";
60
87
  }
61
88
 
62
89
  function ok(data: unknown): CallToolResult {
@@ -281,7 +308,7 @@ export async function main(): Promise<void> {
281
308
 
282
309
  // Load membership AFTER bootstrap so the new admin is present.
283
310
  let membership = await loadMembership(dataDir);
284
- const identity = resolveIdentity();
311
+ const identity = await resolveIdentity();
285
312
  const embedCfg = embeddingConfigFromEnv();
286
313
  const embeddingProvider = createEmbeddingProvider(embedCfg);
287
314
  const indexConfig: IndexConfig = {