@krimto-labs/krimto 0.2.34 → 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,28 +9,59 @@ 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.18** is the current published release — a consolidated UX redesign over
13
- > the v0.2.16 storage / index / access layers (which are all unchanged). Everything from v0.2.16
14
- > (markdown-in-git storage, `user → team → org` hierarchy, hybrid retrieval, server-enforced access,
15
- > two-way git sync, MCP over stdio + HTTP, the Docker image, the web UI, the complete CLI) is still
16
- > here, plus a substantial UX redesign on top. What's new in v0.2.18:
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
+ > surface. The v0.2.16 architecture (markdown-in-git storage, `user → team → org` hierarchy,
15
+ > hybrid retrieval, server-enforced access, two-way git sync, MCP over stdio + HTTP, the Docker
16
+ > image, the web UI) is unchanged. What you get on top of v0.2.16:
17
17
  >
18
- > - **One-command interactive setup wizard** (`krimto init`) — five questions with preselected
19
- > defaults; absorbs `connect`, `init`, `setup-remote`, and `setup-embeddings` into one flow.
20
- > - **Team-mode wizard** (`krimto team init` / `krimto join` / `krimto team disband`) — admins
21
- > onboard their team in one command; teammates join with a single line from a DM template.
22
- > - **Per-note CLI** (`krimto notes` / `edit` / `mv` / `supersede` / `tag`) browse, search,
23
- > and edit notes from the terminal without opening the browser.
24
- > - **Settings shortcuts** (`krimto editors` / `search` / `service` / `reset`) — change one
25
- > thing without re-running the whole wizard. `reset` cleanly disconnects + uninstalls; `--wipe-notes`
26
- > moves data to a recoverable trash sibling, never `rm -rf`.
27
- > - **Notes-app `/ui`** — plain-English scope labels (Just me / Team name / Org name), inline
28
- > Edit + Move + Delete on every note, and a consolidated **Settings** page for the engineering
29
- > panels.
18
+ > **The setup story (v0.2.17 → v0.2.21).**
19
+ > - **One-command interactive setup wizard** (`krimto init`) five questions, preselected
20
+ > defaults. Reads `git config user.email` for identity; detects editors at both project
21
+ > level (`.cursor/`, `CLAUDE.md`) and machine level (`~/.cursor/`, `~/.claude.json`).
22
+ > - **Reconfigure menu** re-runs of `krimto init` show a "Krimto on this machine:"
23
+ > summary with a real **Service:** line driven by lock + launchctl reality (not just
24
+ > config snapshot). "Keep current" prompts let you Enter-through.
30
25
  >
31
- > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and
32
- > [docs/krimto-v0.2.17-maria-journey.html](docs/krimto-v0.2.17-maria-journey.html) for the design
33
- > rationale and what each release added.
26
+ > **The teardown story (v0.2.32 → v0.2.33).**
27
+ > - **`krimto stop` / `start` / `restart`** — first-class verbs for the off-ramp the original
28
+ > wizard forgot. Idempotent. `stop` keeps the plist on disk so `start` can reload it.
29
+ > - **`krimto reset [--yes] [--wipe-notes]`** — aggressive sweep across all editors (Cursor
30
+ > JSON + Claude Code CLI scopes user/project/local) + launchd/systemd uninstall + lock
31
+ > cleanup. `--wipe-notes` collapses to one named-consequence prompt.
32
+ >
33
+ > **The runtime story (v0.2.26 → v0.2.30).**
34
+ > - **`/ui` notes-app redesign** — warm-paper Fraunces serif aesthetic, scope cards with
35
+ > emoji icons (📔 Just me / 📓 Team / 🏢 Org), notes timeline with per-row Edit / Move /
36
+ > Delete / View file. `krimto ui` opens it.
37
+ > - **Single reconciled runtime view** (`inspectRuntime`) — every read-side command (status,
38
+ > verify-connection, whoami, reconfigure menu) routes through the same lock + launchctl +
39
+ > editor-config probe. No more "status says one thing, verify says another."
40
+ > - **Service-first install ordering + port-ready probe** — wizard installs the service
41
+ > first, waits for `:8080` to accept TCP, then writes editor configs. Cursor's file
42
+ > watcher never fires into an unbound port (the v0.2.27/28 ECONNREFUSED fix).
43
+ >
44
+ > **The agent story (v0.2.34 → v0.2.36).**
45
+ > - **Phase B agent flags** — `editors --add cursor`, `service --always`, `search --keyword`,
46
+ > `reset --yes`, `remote --set <url>`, `folder --to <path>`. Every command that used to
47
+ > open an interactive prompt now has a flag form.
48
+ > - **Non-TTY guards** — interactive commands without flags exit 2 with copy-pasteable
49
+ > usage instead of hanging on an unanswerable prompt.
50
+ > - **`krimto whoami` + `set identity <email>`** + a **`krimto_whoami` MCP tool** so agents
51
+ > stop hallucinating identity.
52
+ > - **Editor attribution via User-Agent sniffing** — facts saved over HTTP automatically
53
+ > carry `source: "cursor"` / `"claude-code"` / etc., so the dashboard's "saved from a
54
+ > Cursor chat" line works without prompting agents.
55
+ > - **Cursor `alwaysApply: true` frontmatter** so `.cursor/rules/krimto.mdc` auto-attaches
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`).
61
+ >
62
+ > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
63
+ > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
64
+ > for the design rationale + what each patch caught.
34
65
 
35
66
  ## Try it in 90 seconds (solo, no account)
36
67
 
@@ -76,6 +107,33 @@ what your agent has been calling.
76
107
  **Power-user / CI:** `npx @krimto-labs/krimto init --yes` skips all prompts and applies
77
108
  defaults non-interactively. `--all` and `--minimal` keep their v0.2.16 meaning.
78
109
 
110
+ ### Setting Krimto up programmatically (AI agents, CI)
111
+
112
+ Krimto is designed to be driven by Claude Code, Cursor, Codex, and similar AI agents that
113
+ don't have a stdin TTY. Every interactive command has a flag form:
114
+
115
+ ```bash
116
+ # The one-command full install (defaults: detected editors, as-needed run mode):
117
+ krimto init --yes
118
+
119
+ # Or pick the pieces:
120
+ krimto editors --add cursor --add claude-code --yes # wire editors
121
+ krimto service --always # install background service
122
+ krimto search --keyword # keyword search (default)
123
+ krimto search --openai --api-key sk-... # semantic search
124
+ krimto remote --set git@github.com:acme/krimto.git # cross-machine sync
125
+ krimto status # machine-readable check
126
+ krimto stop / start / restart # idempotent service control
127
+ ```
128
+
129
+ Run any interactive command (`editors`, `service`, `search`, `remote`, `folder`, `reset`)
130
+ without a flag from a non-TTY shell and you get `exit 2` with copy-pasteable flag usage,
131
+ not a hung-prompt warning. `krimto --help` has a dedicated **For AI agents (no TTY)**
132
+ block at the top.
133
+
134
+ The HTTP MCP handler also **sniffs `User-Agent`** to auto-stamp facts with `source:
135
+ "cursor"` / `"claude-code"` / etc. — no agent-prompt convention needed.
136
+
79
137
  ## Connect your agent
80
138
 
81
139
  Krimto is one MCP server — point any client at `http://localhost:8080/mcp`. Verified for **Claude Code**
@@ -136,66 +194,77 @@ The in-product **Connect** page (`/ui/connect`) shows this same rule with a copy
136
194
 
137
195
  Everything is reachable via `npx`. Run `npx @krimto-labs/krimto --help` for the full list. All
138
196
  commands print clean, sectioned output with ✅ / ⚠️ / 🟢 status indicators and copy-paste shell
139
- commands. Grouped by purpose:
197
+ commands. The seven groups below match `--help`'s structure.
140
198
 
141
199
  **Get connected (start here)**
142
200
 
143
201
  | Command | What it does |
144
202
  |---|---|
145
- | `init` | Interactive setup wizard — five questions, preselected defaults. Connects detected editors, applies the standing rule, optionally installs a background service. `--yes` skips prompts; legacy `--all` / `--minimal` still work. |
146
- | `serve` | Start the HTTP server (port 8080) + browser `/ui` dashboard |
147
- | `connect` | Print copy-paste config for Claude Code & Cursor (manual path) |
148
- | `uninit` | Strip the standing rule from this project's rules files |
203
+ | `init [--yes]` | Interactive setup wizard — five questions, preselected defaults. Connects detected editors, applies the standing rule, optionally installs a background service. `--yes` skips prompts; legacy `--all` / `--minimal` still work. |
204
+ | `connect` | Print copy-paste config for Claude Code & Cursor (manual path) substitutes your real `git config user.email` for the identity. |
205
+ | `uninit [--also-stop | --keep-running]` | Strip the standing rule from this project's rules files. Offers to stop the service too (or skip the prompt with the flags). |
149
206
 
150
- **Daily use** (v0.2.17-2 Phase D)
207
+ **Look at your notes**
151
208
 
152
209
  | Command | What it does |
153
210
  |---|---|
154
211
  | `notes [query]` | List notes grouped by plain-English scope (`Just me` / team name / org name). With a query: ranked search via `krimto_recall`. |
155
- | `edit <id>` | Open the fact's `.md` in `$EDITOR`; reindexes on save. Validates frontmatter; restores immutable fields. |
156
- | `mv <id> <scope>` | Move a note between scopes (id preserved). Refuses if `canWrite` fails on either side. `user/me` resolves to the caller's identity. |
157
- | `supersede <id>` | Replace a note with a new version. Old version stays in git history + index (hidden from recall). |
158
- | `tag <id> +new -old ...` | Add or remove tags via frontmatter rewrite. Lowercase kebab-case enforced. |
212
+ | `ui` | Open `http://localhost:8080/ui` in your browser. |
213
+ | `open` | Reveal the notes folder in your OS file manager (`open` / `xdg-open` / `explorer`). |
214
+ | `edit <id>` | Open the fact's `.md` in `$EDITOR`; reindexes on save. |
215
+ | `mv <id> <scope>` | Move a note between scopes (id preserved). |
216
+ | `supersede <id>` | Replace a note with a new version. Old stays in git. |
217
+ | `tag <id> +new -old ...` | Add or remove tags via frontmatter rewrite. |
218
+ | `rm <id>` | Delete a fact (file + index + git deletion commit). |
159
219
 
160
- **Team mode** (v0.2.17.1 Phase C)
220
+ **Stop & reset** (v0.2.32+)
161
221
 
162
222
  | Command | What it does |
163
223
  |---|---|
164
- | `team init` | Admin-side wizard: admin email, team slug, optional git remote, initial teammates. Prints the admin key + per-teammate keys + a copy-paste DM template. |
165
- | `join --server <url> --key <key>` | Teammate-side: detects editors, writes HTTP MCP config with the bearer header + the standing rule. |
166
- | `team disband [--yes]` | Per-machine step-back to solo mode: rewrites HTTP MCP entries as stdio. Notes / `members.yaml` / git history untouched. |
224
+ | `stop` | Stop the running krimto (uninstalls/unloads service if installed, SIGTERMs an ad-hoc PID). Plist stays on disk so `start` can reload it. Idempotent. |
225
+ | `start` | Start krimto (re-bootstraps the existing plist). Honest message when no service is configured. |
226
+ | `restart` | `stop` + `start`. Atomic on always-running mode via `launchctl kickstart -k`. |
227
+ | `uninit` | Project-only undo (rule files only). Offers to stop the service too. |
228
+ | `reset [--yes] [--wipe-notes]` | Disconnect every editor (Cursor JSON + Claude Code CLI across user/project/local scopes) + uninstall service + wipe API-key store. `--wipe-notes` collapses to one named-consequence prompt. |
167
229
 
168
- **Change settings** (v0.2.17-4 Phase B)
230
+ **Is it working?**
169
231
 
170
232
  | Command | What it does |
171
233
  |---|---|
172
- | `editors` | Add or remove editor connections (checkbox prompt with current state preselected). |
173
- | `search` | Flip between Keyword and OpenAI search. Verifies the OpenAI key before persisting. |
174
- | `service` | Switch run mode (as-needed / always-running / manual). Installs or uninstalls the platform service. |
175
- | `reset [--yes] [--wipe-notes]` | Disconnect from all editors + uninstall service + wipe local keys. `--wipe-notes` atomically moves the data dir to a timestamped trash sibling (recoverable). |
234
+ | `status` | One-screen consolidator: running PID + mode + launched-by, editors, storage, add-ons, recent activity, hijack warning. Nudges toward `service --always` when running ad-hoc. |
235
+ | `whoami` | Print the active `KRIMTO_IDENTITY` and every place it's set (each editor's MCP config + the service unit env). Flags mismatches. |
236
+ | `verify-connection` / `where` / `storage` / `usage` | Back-compat aliases preserve their original stdout, point at `status` on stderr. |
176
237
 
177
- **Diagnose**
238
+ **Configure (after first run)** — every command accepts flags so agents can drive them non-interactively.
178
239
 
179
240
  | Command | What it does |
180
241
  |---|---|
181
- | `status` | One-screen consolidator (v0.2.17): connections, storage, optional add-ons, recent activity, hijack warning. |
182
- | `verify-connection` / `where` / `storage` / `usage` | Legacy verbs still work, point at `status` for the consolidated view. |
183
- | `setup-remote <url>` | Wire the data dir to a git remote and verify the initial push. |
184
- | `setup-embeddings` | Send a real test embedding to verify a `KRIMTO_EMBED_*` config. |
242
+ | `editors --add <name> / --remove <name> / --set <list>` | Wire / unwire editors (Cursor / Claude Code / Codex / Gemini). `--list` prints current connections. No-flag form prompts interactively when a TTY is available. |
243
+ | `service --as-needed / --always / --manual` | Switch run mode. Flag form skips the prompt. `service stop` / `service start` are aliases for `stop` / `start`. |
244
+ | `search --keyword / --openai --api-key sk-...` | Switch search provider. The OpenAI path verifies the key before persisting. |
245
+ | `remote --show / --set <url> / --remove` | Manage the git remote (also reachable as `setup-remote <url>`). |
246
+ | `folder --to <path> [--yes]` | Guided move of the data dir. Stops service, atomic rename (cross-fs fallback), reinstalls service with new env, prints `export KRIMTO_DATA=` hint. |
247
+ | `set identity <email>` | Change `KRIMTO_IDENTITY` everywhere atomically (editor MCP configs + service env). Preserves other env keys. |
185
248
 
186
- **Storage**
249
+ **Team**
187
250
 
188
251
  | Command | What it does |
189
252
  |---|---|
190
- | `rm <id>` | Delete a fact (file + index + git deletion commit). Refuses while a server holds the lock. |
191
- | `reindex` | Rebuild `index.db` from the markdown files (fixes orphans left by manual `rm` of .md files). |
253
+ | `team init` | Admin-side wizard: admin email, team slug, optional git remote, initial teammates. Prints admin key + per-teammate keys + a DM template. |
254
+ | `team disband [--yes]` | Per-machine step-back to solo mode. Notes / `members.yaml` / git history untouched. |
255
+ | `join --server <url> --key <key>` | Teammate-side: detects editors, writes HTTP MCP config with the bearer header + the standing rule. |
192
256
 
193
- **Other**
257
+ **Advanced / scripting**
194
258
 
195
259
  | Command | What it does |
196
260
  |---|---|
197
- | (no args) | Start the stdio MCP server (default; for MCP clients to launch) |
198
- | `--help`, `-h` | Show the full CLI surface |
261
+ | `serve` | Start the HTTP server in the foreground (port 8080) + browser `/ui` dashboard. |
262
+ | `setup-remote <url>` | One-shot git remote setup (verifies the initial push). |
263
+ | `setup-embeddings` | Send a real test embedding to verify a `KRIMTO_EMBED_*` config. |
264
+ | `reindex` | Rebuild `index.db` from markdown (fixes orphans). |
265
+ | `delete <id>` | Alias for `rm`. |
266
+ | (no args) | Start the stdio MCP server (default; for MCP clients to launch). |
267
+ | `--help`, `-h` | Show the full CLI surface — includes a `For AI agents (no TTY)` block at the top with copy-paste flag examples. |
199
268
 
200
269
  The stdio entrypoint enforces a **single-writer lock** on the data dir (`.krimto/lock.json`) — two
201
270
  Krimto processes can no longer race on the same `~/.krimto`. A second `serve`/stdio launch is
@@ -344,14 +413,14 @@ admin surface.
344
413
 
345
414
  ### Option C — Docker (HTTP + bearer auth, containerized)
346
415
 
347
- 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:
348
418
 
349
419
  ```bash
350
- docker build -t krimto .
351
420
  docker run -d --name krimto -p 8080:8080 \
352
421
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com \
353
422
  -v ~/.krimto:/data \
354
- krimto
423
+ ghcr.io/krimto-labs/krimto:latest
355
424
  docker logs krimto | grep "admin API key" # the key is printed once
356
425
  ```
357
426
 
@@ -359,17 +428,19 @@ The container serves MCP at `http://localhost:8080/mcp` (bearer auth) and health
359
428
  `/health/ready`; facts persist in the mounted `/data` volume. Point your agent at it with the same
360
429
  `"url"` + `Bearer` config as Option B.
361
430
 
362
- **Pulling a published image (no local build):** pushing a `v*` git tag runs
363
- [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml), which publishes the
364
- image to `ghcr.io/krimto-labs/krimto`. After the first release tag you can skip `docker build` and run
365
- the published image directly:
431
+ **Building locally** only needed if you're developing Krimto or pinning to an unreleased
432
+ commit:
366
433
 
367
434
  ```bash
435
+ docker build -t krimto .
368
436
  docker run -d --name krimto -p 8080:8080 \
369
437
  -e KRIMTO_BOOTSTRAP_ADMIN=you@acme.com -v ~/.krimto:/data \
370
- ghcr.io/krimto-labs/krimto:latest
438
+ krimto
371
439
  ```
372
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
+
373
444
  ### Web UI (humans)
374
445
 
375
446
  When the HTTP server is running, open `http://localhost:8080/ui`. In local mode (no
@@ -431,9 +502,9 @@ Cline — is table stakes today, so Krimto ships it but doesn't lead with it.
431
502
 
432
503
  ## Roadmap
433
504
 
434
- `v0.2` (teams) → `v0.2.18` (UX redesign — wizards, per-note CLI, notes-app `/ui` — published) →
435
- `v0.3` (OAuth + PR approval flow) → `v1.0` (Krimto Cloud). See [ROADMAP.md](ROADMAP.md) for the
436
- 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.
437
508
 
438
509
  ## License
439
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.34",
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];
@@ -148,25 +148,62 @@ export async function writeMcpConfig(
148
148
  * matching `mcp remove`, but we don't depend on it).
149
149
  */
150
150
  export async function removeMcpConfig(env: EditorEnvironment): Promise<{ removed: boolean }> {
151
- if (env.mcpWire === null || env.mcpWire.method !== "json") return { removed: false };
152
- let text: string;
153
- try {
154
- text = await fs.readFile(env.mcpWire.path, "utf8");
155
- } catch {
156
- return { removed: false };
151
+ if (env.mcpWire === null) return { removed: false };
152
+
153
+ if (env.mcpWire.method === "json") {
154
+ let text: string;
155
+ try {
156
+ text = await fs.readFile(env.mcpWire.path, "utf8");
157
+ } catch {
158
+ return { removed: false };
159
+ }
160
+ let parsed: Record<string, unknown>;
161
+ try {
162
+ parsed = JSON.parse(text) as Record<string, unknown>;
163
+ } catch {
164
+ return { removed: false };
165
+ }
166
+ const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
167
+ if (!servers || !("krimto" in servers)) return { removed: false };
168
+ const { krimto: _krimto, ...rest } = servers; // eslint-disable-line @typescript-eslint/no-unused-vars
169
+ parsed[env.mcpWire.key] = rest;
170
+ await fs.writeFile(env.mcpWire.path, JSON.stringify(parsed, null, 2) + "\n", "utf8");
171
+ return { removed: true };
157
172
  }
158
- let parsed: Record<string, unknown>;
159
- try {
160
- parsed = JSON.parse(text) as Record<string, unknown>;
161
- } catch {
162
- return { removed: false };
173
+
174
+ // v0.2.35 — CLI method (Claude Code). The smoke-6 transcript caught reset reporting "No
175
+ // editors were connected" while `claude mcp list` still showed krimto. Root cause: this
176
+ // function used to bail at the top with `method !== "json"`, so the CLI path was never
177
+ // exercised. The reset SWEEP rule (always run cleanup, never trust detection) covers the
178
+ // intent here too — we shell out to `claude mcp remove krimto` across the three scopes
179
+ // Claude Code supports (local / user / project). Each call is idempotent: if krimto
180
+ // isn't registered at a scope, claude prints "MCP server krimto not found" and exits
181
+ // non-zero, which we swallow. We mark removed=true when ANY scope succeeds.
182
+ if (env.mcpWire.method === "cli") {
183
+ return { removed: await removeClaudeMcpAllScopes(env.mcpWire.command) };
184
+ }
185
+
186
+ return { removed: false };
187
+ }
188
+
189
+ /**
190
+ * v0.2.35 — try `claude mcp remove krimto -s <scope>` for each known scope. Returns true
191
+ * when at least one succeeds. The user's krimto entry may live in any scope depending on
192
+ * which directory they were in when they originally ran `claude mcp add`, so we sweep all
193
+ * three. Errors are best-effort (no-op when nothing's registered at that scope).
194
+ */
195
+ async function removeClaudeMcpAllScopes(claudeBinary: string): Promise<boolean> {
196
+ const scopes = ["local", "user", "project"] as const;
197
+ let anyRemoved = false;
198
+ for (const scope of scopes) {
199
+ try {
200
+ await exec(claudeBinary, ["mcp", "remove", "krimto", "-s", scope]);
201
+ anyRemoved = true;
202
+ } catch {
203
+ // "MCP server krimto not found" at this scope — fine, try the next.
204
+ }
163
205
  }
164
- const servers = parsed[env.mcpWire.key] as Record<string, unknown> | undefined;
165
- if (!servers || !("krimto" in servers)) return { removed: false };
166
- const { krimto: _krimto, ...rest } = servers; // eslint-disable-line @typescript-eslint/no-unused-vars
167
- parsed[env.mcpWire.key] = rest;
168
- await fs.writeFile(env.mcpWire.path, JSON.stringify(parsed, null, 2) + "\n", "utf8");
169
- return { removed: true };
206
+ return anyRemoved;
170
207
  }
171
208
 
172
209
  // --- internal helpers -------------------------------------------------------
@@ -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
@@ -25,6 +25,10 @@ import {
25
25
  } from "./init";
26
26
  import { runSetupEmbeddings } from "./setupEmbeddings";
27
27
  import { KRIMTO_VERSION } from "../server/index";
28
+ import { inspectRuntime, type RuntimeState } from "./inspectRuntime";
29
+ import { applyService } from "./serviceCmd";
30
+ import * as os from "node:os";
31
+ import * as path from "node:path";
28
32
 
29
33
  const EDITOR_LABEL: Record<EditorKind, string> = {
30
34
  cursor: "Cursor",
@@ -68,46 +72,88 @@ export async function runInitWizard(
68
72
  }
69
73
  }
70
74
 
71
- /** §06 — "Krimto is already set up on this machine. What would you like to do?" */
75
+ /**
76
+ * §06 — reconfigure menu shown when `krimto init` is re-run on a configured machine.
77
+ *
78
+ * v0.2.35: drops the ambiguous "Krimto is already set up on this machine" header (users
79
+ * read "set up" as "running"). The new header is neutral — "Krimto on this machine:" —
80
+ * and adds a real **Service:** line driven by `inspectRuntime` (config-snapshot ≠ runtime).
81
+ * That way the user always knows whether a process is actually serving right now, not just
82
+ * whether a config exists on disk. A fifth menu choice — "Start it running continuously"
83
+ * — appears only when no service is currently loaded, so users who want a daemon find the
84
+ * button without leaving the menu.
85
+ */
72
86
  async function runReconfigureMenu(
73
87
  cwd: string,
74
88
  snapshot: SetupSnapshot,
75
89
  opts: RunWizardOptions,
76
90
  io: WizardIO,
77
91
  ): Promise<ApplyResult | null> {
92
+ // Match applyWizardAnswers's pattern: derive dataDir from homeDir when not explicitly
93
+ // passed. Tests rely on this so the runtime probe hits a test temp dir, not the user's
94
+ // real ~/.krimto.
95
+ const homeDir = opts.homeDir ?? os.homedir();
96
+ const dataDir = opts.dataDir ?? path.join(homeDir, ".krimto");
97
+ const runtime = await inspectRuntime(dataDir, { cwd, homeDir });
98
+
78
99
  io.out("\n");
79
- io.out("Krimto is already set up on this machine.\n");
80
- io.out(` Editors: ${snapshot.registeredEditors.map((e) => EDITOR_LABEL[e]).join(", ") || "(none)"}\n`);
81
- io.out(` Run mode: ${runModeLabel(snapshot.runMode)}\n`);
100
+ io.out("Krimto on this machine:\n");
101
+ io.out(
102
+ ` Editors: ${
103
+ snapshot.registeredEditors.map((e) => EDITOR_LABEL[e]).join(", ") || "(none)"
104
+ }\n`,
105
+ );
106
+ io.out(` Service: ${formatServiceLine(runtime)}\n`);
82
107
  io.out(` Search: ${searchLabel(snapshot.searchProvider)}\n`);
83
108
  io.out("\n");
84
109
 
110
+ // The "Start it running continuously" choice only makes sense when no daemon is alive.
111
+ // (When already running as a service, there's nothing to start; when running ad-hoc, the
112
+ // user is better served by `krimto service --always` AFTER stopping the ad-hoc one, which
113
+ // is more state than this menu wants to manage.)
114
+ const canStartService =
115
+ !runtime.service.loaded && !(runtime.lock?.alive ?? false);
116
+
117
+ const choices: Array<{
118
+ value: "refresh" | "reconfigure" | "status" | "start-service" | "quit";
119
+ name: string;
120
+ description: string;
121
+ }> = [
122
+ {
123
+ value: "refresh",
124
+ name: "Just refresh the standing rule in this project",
125
+ description:
126
+ "Re-applies the 'always use Krimto' rule to CLAUDE.md / .cursor/rules/ here.\nUse this when you just cloned a new repo.",
127
+ },
128
+ {
129
+ value: "reconfigure",
130
+ name: "Change settings (reconfigure)",
131
+ description:
132
+ "Re-runs the setup wizard with your current answers pre-filled.\nSkip anything you don't want to change.",
133
+ },
134
+ {
135
+ value: "status",
136
+ name: "View status",
137
+ description: "Show what's working, what's configured, recent activity.",
138
+ },
139
+ ];
140
+ if (canStartService) {
141
+ choices.push({
142
+ value: "start-service",
143
+ name: "Start it running continuously (install as a background service)",
144
+ description:
145
+ "Runs Krimto as a launchd / systemd-user / schtasks service so it stays up\nacross reboots. Equivalent to `krimto service --always`.",
146
+ });
147
+ }
148
+ choices.push({
149
+ value: "quit",
150
+ name: "Quit",
151
+ description: "Leave Krimto as it is.",
152
+ });
153
+
85
154
  const choice = await select({
86
155
  message: "What would you like to do?",
87
- choices: [
88
- {
89
- value: "refresh" as const,
90
- name: "Just refresh the standing rule in this project",
91
- description:
92
- "Re-applies the 'always use Krimto' rule to CLAUDE.md / .cursor/rules/ here.\nUse this when you just cloned a new repo.",
93
- },
94
- {
95
- value: "reconfigure" as const,
96
- name: "Change settings (reconfigure)",
97
- description:
98
- "Re-runs the setup wizard with your current answers pre-filled.\nSkip anything you don't want to change.",
99
- },
100
- {
101
- value: "status" as const,
102
- name: "View status",
103
- description: "Show what's working, what's configured, recent activity.",
104
- },
105
- {
106
- value: "quit" as const,
107
- name: "Quit",
108
- description: "Leave Krimto as it is.",
109
- },
110
- ],
156
+ choices,
111
157
  });
112
158
 
113
159
  if (choice === "quit") {
@@ -139,10 +185,70 @@ async function runReconfigureMenu(
139
185
  return null;
140
186
  }
141
187
 
188
+ if (choice === "start-service") {
189
+ // v0.2.35 — install + load the always-running service from inside the menu so users
190
+ // who want a daemon don't have to drop back to the shell and remember the flag name.
191
+ // Delegates to applyService which handles the platform-specific install + port probe.
192
+ io.out("\nInstalling background service...\n");
193
+ const installResult = await applyService("always-running", {
194
+ ...(opts.dataDir ? { dataDir: opts.dataDir } : {}),
195
+ ...(opts.homeDir ? { homeDir: opts.homeDir } : {}),
196
+ ...(opts.dryRun ? { dryRun: true } : {}),
197
+ });
198
+ if (installResult.install?.activated) {
199
+ const portMsg =
200
+ installResult.install.portReady === false
201
+ ? " ⚠ Service installed but the HTTP port didn't come up within 10s. Check\n /tmp/com.krimto.server.err.log.\n"
202
+ : " ✓ Service started, port accepting connections.\n";
203
+ io.out("\n✅ Background service is now running.\n" + portMsg + "\n");
204
+ } else {
205
+ io.out("\n(Service was configured but not activated — likely a dry-run.)\n");
206
+ }
207
+ return null;
208
+ }
209
+
142
210
  // "reconfigure" — re-run the five questions with snapshot as defaults
143
211
  return runFreshWizard(cwd, opts, io, snapshot);
144
212
  }
145
213
 
214
+ /**
215
+ * v0.2.35 — turn the reconciled runtime view into one human line for the reconfigure menu.
216
+ * Four cases (in priority order):
217
+ * 1. A live process holds the lock AND launched-by="service" → "Running as background service (PID …, started Nm ago)"
218
+ * 2. A live process holds the lock, launched-by="ad-hoc" → "Running ad-hoc (PID … — started by `krimto serve` or an editor)"
219
+ * 3. Service unit on disk but not loaded → "⚠ Installed but not running — `krimto start` to load it"
220
+ * 4. Nothing running, no unit on disk → "Not running (your editor launches it on demand via stdio)"
221
+ *
222
+ * This is the line that the smoke-6 user wanted: "is Krimto serving RIGHT NOW?" — driven
223
+ * by lock + launchctl/systemctl reality, not by the static config snapshot.
224
+ */
225
+ function formatServiceLine(runtime: RuntimeState): string {
226
+ const lock = runtime.lock;
227
+ if (lock?.alive) {
228
+ const ago = humanAgoForLock(lock.started);
229
+ if (runtime.effectiveLaunchedBy === "service") {
230
+ return `Running as background service (PID ${lock.pid}, started ${ago})`;
231
+ }
232
+ return `Running ad-hoc (PID ${lock.pid} — started ${ago} by \`krimto serve\` or an editor)`;
233
+ }
234
+ if (runtime.service.installed) {
235
+ return "⚠ Installed but not running — `krimto start` to load it";
236
+ }
237
+ return "Not running (your editor launches it on demand via stdio)";
238
+ }
239
+
240
+ function humanAgoForLock(iso: string): string {
241
+ const t = Date.parse(iso);
242
+ if (Number.isNaN(t)) return iso;
243
+ const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
244
+ if (secs < 60) return `${secs}s ago`;
245
+ const mins = Math.round(secs / 60);
246
+ if (mins < 60) return `${mins}m ago`;
247
+ const hrs = Math.round(mins / 60);
248
+ if (hrs < 24) return `${hrs}h ago`;
249
+ return `${Math.round(hrs / 24)}d ago`;
250
+ }
251
+
146
252
  /** §03 — the fresh five-question flow. `snapshot` (when given) seeds the defaults. */
147
253
  async function runFreshWizard(
148
254
  cwd: string,
@@ -152,7 +258,7 @@ async function runFreshWizard(
152
258
  ): Promise<ApplyResult | null> {
153
259
  io.out(`\nKrimto — Setting up your AI's memory · v${KRIMTO_VERSION}\n\n`);
154
260
  const envs = await detectEditorEnvironments(cwd, opts.homeDir);
155
- printScan(envs, io);
261
+ printScan(envs, snapshot?.registeredEditors ?? [], io);
156
262
 
157
263
  // v0.2.31 — Gap D, "Keep current" intermediate prompt. On reconfigure runs (snapshot !==
158
264
  // null), each question is wrapped in a two-stage "Keep current / Reconfigure..." select so
@@ -392,16 +498,22 @@ async function askSearch(
392
498
 
393
499
  // === Pretty printing ========================================================
394
500
 
395
- 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);
396
506
  io.out(" Scanning your machine ...\n\n");
397
507
  for (const env of envs) {
398
- // v0.2.21: three states — project-level, machine-level only, not found.
399
- const dot = env.present ? "✓" : env.installed ? "~" : "–";
400
- const note = env.present
401
- ? "detected (in this project)"
402
- : env.installed
403
- ? "installed (machine-wide)"
404
- : "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";
405
517
  io.out(` ${dot} ${EDITOR_LABEL[env.editor].padEnd(14)} ${note}\n`);
406
518
  }
407
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.34";
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 = {