@krimto-labs/krimto 0.2.32 → 0.2.35

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,55 @@ 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.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
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.35).**
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
+ >
58
+ > See [ROADMAP.md](ROADMAP.md), [CHANGELOG.md](CHANGELOG.md), and the proposal-vs-reality
59
+ > diff in [docs/krimto-v0.2.17-maria-journey.html §09](docs/krimto-v0.2.17-maria-journey.html)
60
+ > for the design rationale + what each patch caught.
34
61
 
35
62
  ## Try it in 90 seconds (solo, no account)
36
63
 
@@ -76,6 +103,33 @@ what your agent has been calling.
76
103
  **Power-user / CI:** `npx @krimto-labs/krimto init --yes` skips all prompts and applies
77
104
  defaults non-interactively. `--all` and `--minimal` keep their v0.2.16 meaning.
78
105
 
106
+ ### Setting Krimto up programmatically (AI agents, CI)
107
+
108
+ Krimto is designed to be driven by Claude Code, Cursor, Codex, and similar AI agents that
109
+ don't have a stdin TTY. Every interactive command has a flag form:
110
+
111
+ ```bash
112
+ # The one-command full install (defaults: detected editors, as-needed run mode):
113
+ krimto init --yes
114
+
115
+ # Or pick the pieces:
116
+ krimto editors --add cursor --add claude-code --yes # wire editors
117
+ krimto service --always # install background service
118
+ krimto search --keyword # keyword search (default)
119
+ krimto search --openai --api-key sk-... # semantic search
120
+ krimto remote --set git@github.com:acme/krimto.git # cross-machine sync
121
+ krimto status # machine-readable check
122
+ krimto stop / start / restart # idempotent service control
123
+ ```
124
+
125
+ Run any interactive command (`editors`, `service`, `search`, `remote`, `folder`, `reset`)
126
+ without a flag from a non-TTY shell and you get `exit 2` with copy-pasteable flag usage,
127
+ not a hung-prompt warning. `krimto --help` has a dedicated **For AI agents (no TTY)**
128
+ block at the top.
129
+
130
+ The HTTP MCP handler also **sniffs `User-Agent`** to auto-stamp facts with `source:
131
+ "cursor"` / `"claude-code"` / etc. — no agent-prompt convention needed.
132
+
79
133
  ## Connect your agent
80
134
 
81
135
  Krimto is one MCP server — point any client at `http://localhost:8080/mcp`. Verified for **Claude Code**
@@ -136,66 +190,77 @@ The in-product **Connect** page (`/ui/connect`) shows this same rule with a copy
136
190
 
137
191
  Everything is reachable via `npx`. Run `npx @krimto-labs/krimto --help` for the full list. All
138
192
  commands print clean, sectioned output with ✅ / ⚠️ / 🟢 status indicators and copy-paste shell
139
- commands. Grouped by purpose:
193
+ commands. The seven groups below match `--help`'s structure.
140
194
 
141
195
  **Get connected (start here)**
142
196
 
143
197
  | Command | What it does |
144
198
  |---|---|
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 |
199
+ | `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. |
200
+ | `connect` | Print copy-paste config for Claude Code & Cursor (manual path) substitutes your real `git config user.email` for the identity. |
201
+ | `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
202
 
150
- **Daily use** (v0.2.17-2 Phase D)
203
+ **Look at your notes**
151
204
 
152
205
  | Command | What it does |
153
206
  |---|---|
154
207
  | `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. |
208
+ | `ui` | Open `http://localhost:8080/ui` in your browser. |
209
+ | `open` | Reveal the notes folder in your OS file manager (`open` / `xdg-open` / `explorer`). |
210
+ | `edit <id>` | Open the fact's `.md` in `$EDITOR`; reindexes on save. |
211
+ | `mv <id> <scope>` | Move a note between scopes (id preserved). |
212
+ | `supersede <id>` | Replace a note with a new version. Old stays in git. |
213
+ | `tag <id> +new -old ...` | Add or remove tags via frontmatter rewrite. |
214
+ | `rm <id>` | Delete a fact (file + index + git deletion commit). |
159
215
 
160
- **Team mode** (v0.2.17.1 Phase C)
216
+ **Stop & reset** (v0.2.32+)
161
217
 
162
218
  | Command | What it does |
163
219
  |---|---|
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. |
220
+ | `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. |
221
+ | `start` | Start krimto (re-bootstraps the existing plist). Honest message when no service is configured. |
222
+ | `restart` | `stop` + `start`. Atomic on always-running mode via `launchctl kickstart -k`. |
223
+ | `uninit` | Project-only undo (rule files only). Offers to stop the service too. |
224
+ | `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
225
 
168
- **Change settings** (v0.2.17-4 Phase B)
226
+ **Is it working?**
169
227
 
170
228
  | Command | What it does |
171
229
  |---|---|
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). |
230
+ | `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. |
231
+ | `whoami` | Print the active `KRIMTO_IDENTITY` and every place it's set (each editor's MCP config + the service unit env). Flags mismatches. |
232
+ | `verify-connection` / `where` / `storage` / `usage` | Back-compat aliases preserve their original stdout, point at `status` on stderr. |
176
233
 
177
- **Diagnose**
234
+ **Configure (after first run)** — every command accepts flags so agents can drive them non-interactively.
178
235
 
179
236
  | Command | What it does |
180
237
  |---|---|
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. |
238
+ | `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. |
239
+ | `service --as-needed / --always / --manual` | Switch run mode. Flag form skips the prompt. `service stop` / `service start` are aliases for `stop` / `start`. |
240
+ | `search --keyword / --openai --api-key sk-...` | Switch search provider. The OpenAI path verifies the key before persisting. |
241
+ | `remote --show / --set <url> / --remove` | Manage the git remote (also reachable as `setup-remote <url>`). |
242
+ | `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. |
243
+ | `set identity <email>` | Change `KRIMTO_IDENTITY` everywhere atomically (editor MCP configs + service env). Preserves other env keys. |
185
244
 
186
- **Storage**
245
+ **Team**
187
246
 
188
247
  | Command | What it does |
189
248
  |---|---|
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). |
249
+ | `team init` | Admin-side wizard: admin email, team slug, optional git remote, initial teammates. Prints admin key + per-teammate keys + a DM template. |
250
+ | `team disband [--yes]` | Per-machine step-back to solo mode. Notes / `members.yaml` / git history untouched. |
251
+ | `join --server <url> --key <key>` | Teammate-side: detects editors, writes HTTP MCP config with the bearer header + the standing rule. |
192
252
 
193
- **Other**
253
+ **Advanced / scripting**
194
254
 
195
255
  | Command | What it does |
196
256
  |---|---|
197
- | (no args) | Start the stdio MCP server (default; for MCP clients to launch) |
198
- | `--help`, `-h` | Show the full CLI surface |
257
+ | `serve` | Start the HTTP server in the foreground (port 8080) + browser `/ui` dashboard. |
258
+ | `setup-remote <url>` | One-shot git remote setup (verifies the initial push). |
259
+ | `setup-embeddings` | Send a real test embedding to verify a `KRIMTO_EMBED_*` config. |
260
+ | `reindex` | Rebuild `index.db` from markdown (fixes orphans). |
261
+ | `delete <id>` | Alias for `rm`. |
262
+ | (no args) | Start the stdio MCP server (default; for MCP clients to launch). |
263
+ | `--help`, `-h` | Show the full CLI surface — includes a `For AI agents (no TTY)` block at the top with copy-paste flag examples. |
199
264
 
200
265
  The stdio entrypoint enforces a **single-writer lock** on the data dir (`.krimto/lock.json`) — two
201
266
  Krimto processes can no longer race on the same `~/.krimto`. A second `serve`/stdio launch is
package/bin/krimto.mjs CHANGED
@@ -6,6 +6,24 @@
6
6
  import process from "node:process";
7
7
  import { tsImport } from "tsx/esm/api";
8
8
 
9
+ /**
10
+ * v0.2.34 — collect every value passed via a repeating flag. Used by `editors --add cursor
11
+ * --add codex` and similar. Accepts both `--flag value` and `--flag=value` forms; ignores
12
+ * the flag itself.
13
+ */
14
+ function collectFlagValues(flags, name) {
15
+ const values = [];
16
+ for (let i = 0; i < flags.length; i++) {
17
+ const f = flags[i];
18
+ if (f === name) {
19
+ if (typeof flags[i + 1] === "string") values.push(flags[i + 1]);
20
+ } else if (typeof f === "string" && f.startsWith(`${name}=`)) {
21
+ values.push(f.slice(name.length + 1));
22
+ }
23
+ }
24
+ return values;
25
+ }
26
+
9
27
  try {
10
28
  // Two-word command support (v0.2.17.1): `team init`, `team disband`. Collapse argv[2]+argv[3]
11
29
  // into one cmd string when argv[2] is one of the namespaced verbs.
@@ -363,16 +381,84 @@ try {
363
381
  process.stderr.write("\n→ `krimto verify-connection` is now part of `krimto status` (one command, four answers).\n");
364
382
  if (result.status === "none") process.exitCode = 1;
365
383
  } else if (cmd === "editors") {
366
- // `krimto editors` — one-question shortcut to add/remove editor connections (Phase B).
367
- const { runEditors } = await tsImport("../src/cli/editors.ts", import.meta.url);
368
- const result = await runEditors();
369
- if (result === null) process.exitCode = 1;
384
+ // `krimto editors` — Phase B shortcut. v0.2.34 added flag forms for AI-agent + CI use:
385
+ // --add <name> Connect one editor (merge with current set). Repeatable.
386
+ // --remove <name> Disconnect one editor (merge with current set). Repeatable.
387
+ // --set <list> Replace the entire connected set (comma-separated).
388
+ // --list Print current connected editors, one per line.
389
+ // No flags + TTY → interactive checkbox wizard (unchanged).
390
+ // No flags + no TTY → the new assertInteractiveOrUsage guard prints flag usage and exits 2.
391
+ const flags = process.argv.slice(3);
392
+ if (flags.includes("--list")) {
393
+ const { listConnectedEditors } = await tsImport("../src/cli/editors.ts", import.meta.url);
394
+ const connected = await listConnectedEditors();
395
+ for (const e of connected) process.stdout.write(`${e}\n`);
396
+ } else {
397
+ const adds = collectFlagValues(flags, "--add");
398
+ const removes = collectFlagValues(flags, "--remove");
399
+ const setIdx = flags.indexOf("--set");
400
+ const setValue = setIdx >= 0 ? flags[setIdx + 1] : undefined;
401
+ const yes = flags.includes("--yes");
402
+ const { runEditors, parseEditorList, listConnectedEditors } = await tsImport(
403
+ "../src/cli/editors.ts",
404
+ import.meta.url,
405
+ );
406
+ if (adds.length > 0 || removes.length > 0 || setValue !== undefined) {
407
+ // Programmatic path — compute the target set and call applyEditors directly through
408
+ // the wrapper. `editors` option short-circuits the prompt.
409
+ let target;
410
+ try {
411
+ if (setValue !== undefined) {
412
+ target = parseEditorList([setValue]);
413
+ } else {
414
+ const current = await listConnectedEditors();
415
+ const toAdd = parseEditorList(adds);
416
+ const toRemove = new Set(parseEditorList(removes));
417
+ target = [...current];
418
+ for (const a of toAdd) if (!target.includes(a)) target.push(a);
419
+ target = target.filter((e) => !toRemove.has(e));
420
+ }
421
+ } catch (err) {
422
+ process.stderr.write(`krimto editors: ${err instanceof Error ? err.message : String(err)}\n`);
423
+ process.exit(2);
424
+ }
425
+ const result = await runEditors({ editors: target, ...(yes ? { yes: true } : {}) });
426
+ if (result === null) process.exitCode = 1;
427
+ } else {
428
+ // No flags — TTY user gets the interactive checkbox; agents get the guard's usage.
429
+ const result = await runEditors();
430
+ if (result === null) process.exitCode = 1;
431
+ }
432
+ }
370
433
  } else if (cmd === "search") {
371
434
  // `krimto search` — change the search provider (Keyword vs OpenAI) without re-running the
372
- // whole setup wizard (Phase B).
435
+ // whole setup wizard (Phase B). v0.2.34 added flag forms for agent / CI use:
436
+ // --keyword Switch to keyword search (default, no API key).
437
+ // --openai --api-key sk-... Switch to OpenAI semantic search (key verified first).
438
+ // No flags + TTY → interactive select; no flags + no TTY → guard prints usage + exit 2.
439
+ const flags = process.argv.slice(3);
440
+ const keyword = flags.includes("--keyword");
441
+ const openai = flags.includes("--openai");
442
+ const apiKeyIdx = flags.indexOf("--api-key");
443
+ const keyIdx = flags.indexOf("--key");
444
+ const apiKey =
445
+ apiKeyIdx >= 0 ? flags[apiKeyIdx + 1] : keyIdx >= 0 ? flags[keyIdx + 1] : undefined;
373
446
  const { runSearchSettings } = await tsImport("../src/cli/searchSettings.ts", import.meta.url);
374
- const result = await runSearchSettings();
375
- if (result === null) process.exitCode = 1;
447
+ if (keyword) {
448
+ const result = await runSearchSettings({ provider: "keyword" });
449
+ if (result === null) process.exitCode = 1;
450
+ } else if (openai) {
451
+ if (!apiKey) {
452
+ process.stderr.write("krimto search --openai requires --api-key <sk-...>\n");
453
+ process.exit(2);
454
+ }
455
+ const result = await runSearchSettings({ provider: "openai", apiKey });
456
+ if (result === null) process.exitCode = 1;
457
+ } else {
458
+ // No flags — TTY user gets the interactive select; agents get the guard's usage.
459
+ const result = await runSearchSettings();
460
+ if (result === null) process.exitCode = 1;
461
+ }
376
462
  } else if (cmd === "service") {
377
463
  // `krimto service` — change run mode (as-needed / always-running / manual). Installs or
378
464
  // uninstalls the platform service to match (Phase B). v0.2.32: accepts `--as-needed`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.32",
3
+ "version": "0.2.35",
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",
@@ -24,7 +24,7 @@ import {
24
24
  writeMcpConfig,
25
25
  type WriteAction,
26
26
  } from "./mcpConfig";
27
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
27
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
28
28
 
29
29
  const EDITOR_LABEL: Record<EditorKind, string> = {
30
30
  cursor: "Cursor",
@@ -89,6 +89,13 @@ export async function applyEditors(
89
89
 
90
90
  export async function runEditors(opts: EditorsOptions = {}): Promise<EditorsResult | null> {
91
91
  const io = opts.io ?? defaultIO;
92
+ // v0.2.34 — when no editor list was supplied programmatically, we'd open the checkbox
93
+ // prompt. Without a TTY (AI-agent Bash, CI) that prompt would hang then crash with the
94
+ // cryptic "unsettled top-level await" warning. Detect and surface the right flags
95
+ // instead, so agents get a clean exit + actionable usage.
96
+ if (!opts.editors) {
97
+ assertInteractiveOrUsage(EDITORS_USAGE);
98
+ }
92
99
  try {
93
100
  const cwd = opts.cwd ?? process.cwd();
94
101
  const envs = await detectEditorEnvironments(cwd, opts.homeDir);
@@ -114,6 +121,62 @@ export async function runEditors(opts: EditorsOptions = {}): Promise<EditorsResu
114
121
  }
115
122
  }
116
123
 
124
+ /** Non-interactive usage shown by the TTY guard when an agent runs `krimto editors` cold. */
125
+ const EDITORS_USAGE =
126
+ "For non-interactive use (AI agents / CI):\n" +
127
+ " krimto editors --add cursor [--yes] Connect one editor (repeat or comma-list ok)\n" +
128
+ " krimto editors --remove cursor [--yes] Disconnect one editor\n" +
129
+ " krimto editors --set cursor,claude-code [--yes] Replace the full connected set\n" +
130
+ " krimto editors --list Print current connections (one per line)";
131
+
132
+ /**
133
+ * v0.2.34 — parse a comma-separated / repeated CLI value into a deduped EditorKind list.
134
+ * Accepts the canonical slugs plus common variants. Throws (with a clear message) on
135
+ * unknown names so an agent passing a typo learns immediately instead of silently no-op'ing.
136
+ */
137
+ export function parseEditorList(values: string[]): EditorKind[] {
138
+ const aliases: Record<string, EditorKind> = {
139
+ cursor: "cursor",
140
+ "claude-code": "claude-code",
141
+ claudecode: "claude-code",
142
+ claude_code: "claude-code",
143
+ claude: "claude-code",
144
+ codex: "codex",
145
+ gemini: "gemini-cli",
146
+ "gemini-cli": "gemini-cli",
147
+ geminicli: "gemini-cli",
148
+ };
149
+ const out: EditorKind[] = [];
150
+ const seen = new Set<EditorKind>();
151
+ for (const raw of values) {
152
+ for (const part of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
153
+ const key = part.toLowerCase();
154
+ const kind = aliases[key];
155
+ if (!kind) {
156
+ throw new Error(
157
+ `Unknown editor "${part}". Expected one of: cursor, claude-code, codex, gemini-cli.`,
158
+ );
159
+ }
160
+ if (!seen.has(kind)) {
161
+ out.push(kind);
162
+ seen.add(kind);
163
+ }
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * v0.2.34 — programmatic helpers the bin uses to compute the target editor set without
171
+ * spawning the checkbox prompt. `--add` / `--remove` are merge ops over the current
172
+ * snapshot; `--set` replaces the list outright.
173
+ */
174
+ export async function listConnectedEditors(opts: { cwd?: string; homeDir?: string } = {}): Promise<EditorKind[]> {
175
+ const cwd = opts.cwd ?? process.cwd();
176
+ const snapshot = await detectExistingSetup(cwd, opts.homeDir);
177
+ return snapshot.registeredEditors;
178
+ }
179
+
117
180
  async function askEditorsList(
118
181
  envs: EditorEnvironment[],
119
182
  current: EditorKind[],
@@ -27,7 +27,7 @@ import {
27
27
  type InstallResult,
28
28
  } from "./service";
29
29
  import { defaultIdentity } from "./init";
30
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
30
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
31
31
 
32
32
  export interface FolderCmdOptions {
33
33
  io?: WizardIO;
@@ -56,6 +56,12 @@ export async function runFolderCmd(opts: FolderCmdOptions): Promise<FolderCmdRes
56
56
  const io = opts.io ?? defaultIO;
57
57
  const fromDir = path.resolve(opts.from);
58
58
 
59
+ // v0.2.34 — when --to was NOT supplied we'd open an input prompt for the destination.
60
+ // Without a TTY (AI agent / CI) that hangs. Surface the flag form.
61
+ if (!opts.to) {
62
+ assertInteractiveOrUsage(FOLDER_USAGE);
63
+ }
64
+
59
65
  try {
60
66
  io.out("\nKrimto — Move the notes folder\n\n");
61
67
  io.out(` Current location: ${fromDir}\n\n`);
@@ -214,3 +220,8 @@ async function classifyDestination(dir: string): Promise<"absent" | "empty" | "n
214
220
  return "non-empty";
215
221
  }
216
222
  }
223
+
224
+ /** v0.2.34 — non-interactive usage shown by the TTY guard. */
225
+ const FOLDER_USAGE =
226
+ "For non-interactive use (AI agents / CI):\n" +
227
+ " krimto folder --to /new/absolute/path --yes Move the notes folder";
package/src/cli/help.ts CHANGED
@@ -19,6 +19,20 @@ export function formatHelp(version: string): string {
19
19
  " AUTO MODE Your agent uses Krimto automatically (no prefix needed).",
20
20
  " Setup: `init` walks the whole flow in one command.",
21
21
  "",
22
+ "━━ For AI agents (no TTY) ━━",
23
+ "",
24
+ " Setting Krimto up programmatically? Most commands accept flags so you can",
25
+ " skip the interactive prompts. The canonical one-shot for a full install:",
26
+ "",
27
+ " $ krimto init --yes Full setup, all defaults",
28
+ " $ krimto status Machine-readable check (stdout)",
29
+ " $ krimto editors --add cursor [--yes] Wire one editor",
30
+ " $ krimto service --always | --as-needed | --manual Switch run mode",
31
+ " $ krimto search --keyword | --openai --api-key sk-...",
32
+ " $ krimto stop / start / restart Service control (idempotent)",
33
+ "",
34
+ " Interactive commands without flags will exit 2 with usage instead of hanging.",
35
+ "",
22
36
  "━━ Usage ━━",
23
37
  "",
24
38
  " $ npx @krimto-labs/krimto [command]",
@@ -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 -------------------------------------------------------
@@ -22,3 +22,27 @@ export const defaultIO: WizardIO = {
22
22
  export function isExitPrompt(e: unknown): boolean {
23
23
  return e instanceof Error && e.name === "ExitPromptError";
24
24
  }
25
+
26
+ /**
27
+ * v0.2.34 — guard for the Phase B commands (`editors`, `service`, `search`, `reset`,
28
+ * `remote`, `folder`) when about to spawn an `@inquirer/prompts` UI. If the process has no
29
+ * TTY on stdin (typical for an AI-agent shell tool), the prompt would hang forever, then
30
+ * Node would surface the cryptic "Detected unsettled top-level await" warning and abort.
31
+ *
32
+ * Instead, we detect the no-TTY case here, print a copy-pasteable usage block, and exit
33
+ * 2 cleanly. The TTY case is a no-op — the interactive wizard runs as before. Each caller
34
+ * supplies its own `usage` text naming the flags an agent should use.
35
+ *
36
+ * NOTE: this is only called BEFORE the first prompt would run. When a flag like
37
+ * `--always` / `--add cursor` / `--keyword` IS passed, the caller skips the prompt entirely
38
+ * and never invokes this — the non-interactive path stays open.
39
+ */
40
+ export function assertInteractiveOrUsage(usage: string): void {
41
+ if (process.stdin.isTTY === true) return;
42
+ process.stderr.write(
43
+ "\nℹ️ No interactive terminal detected — this command needs flags for non-interactive use.\n\n" +
44
+ usage +
45
+ "\n\nSee `krimto --help` for the full list.\n",
46
+ );
47
+ process.exit(2);
48
+ }
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
10
10
 
11
11
  import { readDataDirGitInfo } from "../storage/git";
12
12
  import { runSetupRemote, type SetupRemoteResult } from "./setupRemote";
13
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
13
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
14
14
 
15
15
  const exec = promisify(execFile);
16
16
 
@@ -40,6 +40,12 @@ export async function runRemoteCmd(opts: RemoteCmdOptions): Promise<RemoteCmdRes
40
40
  const io = opts.io ?? defaultIO;
41
41
  const dataDir = opts.dataDir;
42
42
 
43
+ // v0.2.34 — guard against agents calling `krimto remote` cold. Without an action
44
+ // we open a select prompt; without a TTY that hangs. Surface the flag forms.
45
+ if (!opts.action) {
46
+ assertInteractiveOrUsage(REMOTE_USAGE);
47
+ }
48
+
43
49
  try {
44
50
  const gitInfo = await readDataDirGitInfo(dataDir);
45
51
  const current = gitInfo.remote;
@@ -137,3 +143,10 @@ export async function runRemoteCmd(opts: RemoteCmdOptions): Promise<RemoteCmdRes
137
143
  throw e;
138
144
  }
139
145
  }
146
+
147
+ /** v0.2.34 — non-interactive usage shown by the TTY guard. */
148
+ const REMOTE_USAGE =
149
+ "For non-interactive use (AI agents / CI):\n" +
150
+ " krimto remote --show Print the current remote URL\n" +
151
+ " krimto remote --set git@host:repo.git Wire a remote (verifies the first push)\n" +
152
+ " krimto remote --remove --yes Unwire the remote";
package/src/cli/reset.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type EditorKind,
25
25
  } from "./init";
26
26
  import { removeMcpConfig } from "./mcpConfig";
27
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
27
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
28
28
  import { detectPlatform, uninstallService } from "./service";
29
29
 
30
30
  const EDITOR_LABEL: Record<EditorKind, string> = {
@@ -152,41 +152,50 @@ export async function applyReset(opts: ResetOptions = {}): Promise<ResetResult>
152
152
 
153
153
  export async function runReset(opts: ResetOptions = {}): Promise<ResetResult | null> {
154
154
  const io = opts.io ?? defaultIO;
155
+ // v0.2.34 — when --yes was NOT passed we'd open a confirm prompt. Without a TTY (AI
156
+ // agent / CI) the prompt would hang. Surface the flag form instead.
157
+ if (!opts.yes) {
158
+ assertInteractiveOrUsage(RESET_USAGE);
159
+ }
155
160
  try {
156
161
  const dataDir = opts.dataDir ?? path.join(opts.homeDir ?? "", ".krimto");
157
162
 
163
+ // v0.2.33 — single-prompt UX. The original flow asked "Proceed with reset?" first
164
+ // (default N), and only AFTER that asked the wipe-notes-specific confirmation. Users
165
+ // who passed `--wipe-notes` were tripped up by the first prompt: they'd typed the flag,
166
+ // hit Enter at "Proceed?", and got "No changes made" without realising the flag they
167
+ // passed had no consent baked in. The fix: when `--wipe-notes` is passed, collapse the
168
+ // two confirmations into ONE that names the worst thing explicitly. Without the flag,
169
+ // the single-prompt flow stays exactly as it was.
158
170
  io.out("\nKrimto — Reset machine-level config\n\n");
159
- io.out(" This will:\n");
160
- io.out(" Disconnect Krimto from all editors (MCP config + standing rule)\n");
161
- io.out(" • Stop and uninstall the background service (if installed)\n");
162
- io.out(" • Wipe the local API-key store\n\n");
163
- io.out(" This will NOT touch:\n");
164
- io.out(` • Your notes folder (${dataDir}) unless you pass --wipe-notes\n`);
165
- io.out(" • The team's git history or members.yaml\n\n");
171
+ if (opts.wipeNotes) {
172
+ io.out(" ⚠️ --wipe-notes this will:\n");
173
+ io.out(" • Disconnect Krimto from all editors (MCP config + standing rule)\n");
174
+ io.out(" • Stop and uninstall the background service (if installed)\n");
175
+ io.out(" Wipe the local API-key store\n");
176
+ io.out(` • MOVE your notes folder (${dataDir}) to a timestamped trash sibling\n`);
177
+ io.out(` ${dataDir}.trash-<ts> stays on disk until you delete it manually.\n\n`);
178
+ io.out(" This will NOT touch:\n");
179
+ io.out(" • The team's git history or members.yaml on the remote\n\n");
180
+ } else {
181
+ io.out(" This will:\n");
182
+ io.out(" • Disconnect Krimto from all editors (MCP config + standing rule)\n");
183
+ io.out(" • Stop and uninstall the background service (if installed)\n");
184
+ io.out(" • Wipe the local API-key store\n\n");
185
+ io.out(" This will NOT touch:\n");
186
+ io.out(` • Your notes folder (${dataDir}) — pass --wipe-notes to also move it\n`);
187
+ io.out(" • The team's git history or members.yaml\n\n");
188
+ }
166
189
 
167
- const ok = opts.yes ?? (await confirm({ message: "Proceed with reset?", default: false }));
190
+ const promptMessage = opts.wipeNotes
191
+ ? "Wipe notes folder AND disconnect everything?"
192
+ : "Proceed with reset?";
193
+ const ok = opts.yes ?? (await confirm({ message: promptMessage, default: false }));
168
194
  if (!ok) {
169
195
  io.out("\nNo changes made.\n");
170
196
  return null;
171
197
  }
172
198
 
173
- // The --wipe-notes path needs its OWN confirmation — the default reset is reversible
174
- // (just re-run `krimto init`), but wiping notes is data loss.
175
- if (opts.wipeNotes && !opts.yes) {
176
- io.out(
177
- `\n⚠️ --wipe-notes will MOVE ${dataDir} to a timestamped trash sibling.\n` +
178
- ` The notes stay on disk (recoverable) until you delete the trash dir manually.\n`,
179
- );
180
- const wipeOk = await confirm({
181
- message: `Confirm: also move ${dataDir} to ${dataDir}.trash-<ts>?`,
182
- default: false,
183
- });
184
- if (!wipeOk) {
185
- io.out("\n(Reset proceeding without --wipe-notes — notes preserved.)\n");
186
- opts = { ...opts, wipeNotes: false };
187
- }
188
- }
189
-
190
199
  const result = await applyReset(opts);
191
200
  printResetResult(result, io);
192
201
  return result;
@@ -272,3 +281,9 @@ function printResetResult(res: ResetResult, io: WizardIO): void {
272
281
  }
273
282
  io.out("\nRestart your editor(s) so they pick up the changes.\n");
274
283
  }
284
+
285
+ /** v0.2.34 — non-interactive usage shown by the TTY guard. */
286
+ const RESET_USAGE =
287
+ "For non-interactive use (AI agents / CI):\n" +
288
+ " krimto reset --yes Disconnect everything (notes preserved)\n" +
289
+ " krimto reset --yes --wipe-notes Also MOVE notes to a trash sibling";
@@ -14,7 +14,7 @@ import {
14
14
  detectExistingSetup,
15
15
  type SearchProvider,
16
16
  } from "./init";
17
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
17
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
18
18
  import { runSetupEmbeddings } from "./setupEmbeddings";
19
19
 
20
20
  export interface SearchOptions {
@@ -83,6 +83,11 @@ export async function applySearch(
83
83
 
84
84
  export async function runSearchSettings(opts: SearchOptions = {}): Promise<SearchResult | null> {
85
85
  const io = opts.io ?? defaultIO;
86
+ // v0.2.34 — when no provider was supplied programmatically we'd open a select prompt.
87
+ // Without a TTY (AI agent / CI) that prompt hangs. Surface the flag form instead.
88
+ if (!opts.provider) {
89
+ assertInteractiveOrUsage(SEARCH_USAGE);
90
+ }
86
91
  try {
87
92
  const snapshot = await detectExistingSetup(opts.cwd ?? process.cwd(), opts.homeDir);
88
93
  io.out("\nKrimto — Search settings\n\n");
@@ -145,3 +150,10 @@ export async function runSearchSettings(opts: SearchOptions = {}): Promise<Searc
145
150
  throw e;
146
151
  }
147
152
  }
153
+
154
+ /** Non-interactive usage shown by the v0.2.34 TTY guard. */
155
+ const SEARCH_USAGE =
156
+ "For non-interactive use (AI agents / CI):\n" +
157
+ " krimto search --keyword Use keyword search (default, free)\n" +
158
+ " krimto search --openai --api-key sk-... Use OpenAI semantic search (key verified)";
159
+
@@ -18,7 +18,7 @@ import {
18
18
  type UninstallResult,
19
19
  } from "./service";
20
20
  import { defaultIdentity, type RunMode } from "./init";
21
- import { defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
21
+ import { assertInteractiveOrUsage, defaultIO, isExitPrompt, type WizardIO } from "./promptHelpers";
22
22
 
23
23
  export interface ServiceCmdOptions {
24
24
  io?: WizardIO;
@@ -75,6 +75,12 @@ export async function applyService(
75
75
 
76
76
  export async function runServiceCmd(opts: ServiceCmdOptions = {}): Promise<ServiceCmdResult | null> {
77
77
  const io = opts.io ?? defaultIO;
78
+ // v0.2.34 — guard against agents calling `krimto service` cold. Without a mode flag we
79
+ // would spawn a select prompt; without a TTY that hangs and then crashes with "unsettled
80
+ // top-level await". Surface the flag forms instead.
81
+ if (!opts.mode) {
82
+ assertInteractiveOrUsage(SERVICE_USAGE);
83
+ }
78
84
  try {
79
85
  const platform = detectPlatform();
80
86
  const current = await isServiceInstalled(platform, opts.homeDir);
@@ -144,3 +150,10 @@ function runModeLabel(m: RunMode): string {
144
150
  return "Manual (`krimto serve`)";
145
151
  }
146
152
  }
153
+
154
+ /** v0.2.34 — non-interactive usage shown by the TTY guard. */
155
+ const SERVICE_USAGE =
156
+ "For non-interactive use (AI agents / CI):\n" +
157
+ " krimto service --as-needed Editor launches Krimto on demand (stdio)\n" +
158
+ " krimto service --always Run continuously as a background service\n" +
159
+ " krimto service --manual Don't auto-start; user runs `krimto serve`";
package/src/cli/status.ts CHANGED
@@ -134,9 +134,20 @@ function headerLine(
134
134
  ): string {
135
135
  if (status === "ok") {
136
136
  if (lock?.alive) {
137
- return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}, ${effectiveLaunchedBy ?? lock.info.launchedBy}), started ${humanAgo(lock.info.started, now)}\n`;
137
+ // v0.2.34 when the running process is ad-hoc (a foreground `krimto serve` or an
138
+ // editor's stdio launcher), nudge the user toward `krimto service --always` so the
139
+ // service survives reboots without them having to dig for the verb.
140
+ const adHocHint =
141
+ (effectiveLaunchedBy ?? lock.info.launchedBy) === "service"
142
+ ? ""
143
+ : " → To run continuously across reboots: krimto service --always\n";
144
+ return `\n✅ Krimto is working · v${KRIMTO_VERSION}\n PID ${lock.info.pid} (${lock.info.mode}, ${effectiveLaunchedBy ?? lock.info.launchedBy}), started ${humanAgo(lock.info.started, now)}\n${adHocHint}`;
138
145
  }
139
- return `\n✅ Krimto is configured · v${KRIMTO_VERSION}\n No active server right now — it will be launched on demand by your editor.\n`;
146
+ return (
147
+ `\n✅ Krimto is configured · v${KRIMTO_VERSION}\n` +
148
+ ` No active server right now — it will be launched on demand by your editor.\n` +
149
+ ` → To run continuously across reboots: krimto service --always\n`
150
+ );
140
151
  }
141
152
  if (status === "warning") {
142
153
  return `\n⚠️ Krimto needs attention · v${KRIMTO_VERSION}\n`;
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,
@@ -49,7 +49,7 @@ import { type Requester } from "../access/scope";
49
49
 
50
50
  export type RequesterResolver = (extra: { authInfo?: AuthInfo }) => Requester;
51
51
 
52
- export const KRIMTO_VERSION = "0.2.32";
52
+ export const KRIMTO_VERSION = "0.2.35";
53
53
 
54
54
  export function resolveDataDir(): string {
55
55
  return process.env.KRIMTO_DATA ?? path.join(homedir(), ".krimto");