@krimto-labs/krimto 0.2.27 → 0.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/krimto.mjs CHANGED
@@ -6,16 +6,40 @@
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.
12
30
  const rawCmd = process.argv[2];
31
+ const sub = process.argv[3];
32
+ // v0.2.32 — `service stop` / `service start` are explicit, scriptable subverbs (no prompt).
33
+ // `service` alone still launches the interactive wizard. Mirrors the team/set two-word shape.
34
+ const serviceSubverbs = ["stop", "start"];
13
35
  const cmd =
14
- rawCmd === "team" && typeof process.argv[3] === "string"
15
- ? `team ${process.argv[3]}`
16
- : rawCmd === "set" && typeof process.argv[3] === "string"
17
- ? `set ${process.argv[3]}`
18
- : rawCmd;
36
+ rawCmd === "team" && typeof sub === "string"
37
+ ? `team ${sub}`
38
+ : rawCmd === "set" && typeof sub === "string"
39
+ ? `set ${sub}`
40
+ : rawCmd === "service" && typeof sub === "string" && serviceSubverbs.includes(sub)
41
+ ? `service ${sub}`
42
+ : rawCmd;
19
43
 
20
44
  // Guard: `krimto team` alone (or with an unknown subverb) shouldn't fall through to the stdio
21
45
  // MCP server. Print usage and exit instead.
@@ -138,8 +162,13 @@ try {
138
162
  " 3. Verify it landed: $ npx @krimto-labs/krimto verify-connection\n" +
139
163
  mcpWarning +
140
164
  "\n" +
141
- "To undo: $ npx @krimto-labs/krimto uninit\n" +
142
- "Manual: delete the block between <!-- krimto:start --> and <!-- krimto:end -->\n\n",
165
+ // v0.2.32 three honest off-ramps, three blast radii. The old single line said
166
+ // "To undo: krimto uninit" which only stripped this project's rule files; users
167
+ // were stranded thinking they had a working stop button when the service kept
168
+ // running on their machine.
169
+ "To stop the service: $ npx @krimto-labs/krimto stop\n" +
170
+ "To undo this project only: $ npx @krimto-labs/krimto uninit\n" +
171
+ "To disconnect everything: $ npx @krimto-labs/krimto reset (notes preserved)\n\n",
143
172
  );
144
173
  }
145
174
  } else if (yes) {
@@ -211,6 +240,16 @@ try {
211
240
  } else if (cmd === "uninit") {
212
241
  // `krimto uninit` — remove the always-use-Krimto rule from this project's rules files,
213
242
  // flipping the project back from AUTO MODE to DEFAULT MODE.
243
+ //
244
+ // v0.2.32: the smoke-6 audit caught users assuming `uninit` was the full undo button —
245
+ // it wasn't (the background service kept running). After rule removal, if a service
246
+ // and/or a live krimto process is detected on this machine, we now ask whether the
247
+ // user also wants to stop it. Default is No (the service is machine-wide; other
248
+ // projects may use it). The flag `--also-stop` skips the prompt; `--keep-running`
249
+ // explicitly suppresses it (for scripted runs).
250
+ const flags = process.argv.slice(3);
251
+ const alsoStopFlag = flags.includes("--also-stop");
252
+ const keepRunningFlag = flags.includes("--keep-running");
214
253
  const { runUninit } = await tsImport("../src/cli/uninit.ts", import.meta.url);
215
254
  const res = await runUninit(process.cwd());
216
255
  if (res.cleaned.length === 0) {
@@ -232,11 +271,83 @@ try {
232
271
  body += " Run `krimto init` to switch back to AUTO MODE.\n";
233
272
  body += "\n Restart your editor so it picks up the change.\n\n";
234
273
  process.stderr.write(body);
274
+
275
+ // Now offer to stop the service. Skip the prompt if either flag was passed.
276
+ if (!keepRunningFlag) {
277
+ const { isServiceInstalled, detectPlatform } = await tsImport("../src/cli/service.ts", import.meta.url);
278
+ const svc = await isServiceInstalled(detectPlatform());
279
+ if (svc.installed) {
280
+ let stop = alsoStopFlag;
281
+ if (!alsoStopFlag && process.stdin.isTTY === true) {
282
+ const { confirmStop } = await tsImport("../src/cli/stopCmd.ts", import.meta.url);
283
+ process.stderr.write(
284
+ "ℹ️ The background service is still running on this machine — other projects may use it.\n",
285
+ );
286
+ stop = await confirmStop();
287
+ }
288
+ if (stop) {
289
+ const { runStop } = await tsImport("../src/cli/stopCmd.ts", import.meta.url);
290
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
291
+ const stopRes = await runStop({ dataDir: resolveDataDir() });
292
+ process.stdout.write(stopRes.message);
293
+ }
294
+ }
295
+ }
235
296
  }
236
297
  } else if (cmd === "where") {
237
- // `krimto where` — print the data directory (honors KRIMTO_DATA), so files aren't a surprise.
298
+ // `krimto where` — print the data directory. v0.2.31: deprecated in favour of
299
+ // `krimto status` (which shows the data dir + everything else in one screen). Output is
300
+ // preserved for scripts that grep for the path; deprecation hint goes to stderr so it
301
+ // doesn't break pipes like `cd "$(krimto where)"`.
238
302
  const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
239
303
  process.stdout.write(`${resolveDataDir()}\n`);
304
+ process.stderr.write("\n→ `krimto where` is now part of `krimto status` (the data-dir is in the Storage block).\n");
305
+ } else if (cmd === "folder") {
306
+ // `krimto folder` — guided move of the data dir. v0.2.31. Stops the service (if any),
307
+ // moves the dir (atomic when same filesystem; cp+rm fallback for EXDEV), reinstalls the
308
+ // service with the new KRIMTO_DATA env, prints an export hint for the user's shell.
309
+ const flags = process.argv.slice(3);
310
+ const toIdx = flags.indexOf("--to");
311
+ const to = toIdx >= 0 ? flags[toIdx + 1] : undefined;
312
+ const yes = flags.includes("--yes");
313
+ const { runFolderCmd } = await tsImport("../src/cli/folderCmd.ts", import.meta.url);
314
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
315
+ const result = await runFolderCmd({
316
+ from: resolveDataDir(),
317
+ ...(to ? { to } : {}),
318
+ yes,
319
+ });
320
+ if (result !== null) {
321
+ process.stdout.write(result.message);
322
+ if (result.status === "error") process.exitCode = 1;
323
+ }
324
+ } else if (cmd === "remote") {
325
+ // `krimto remote` — friendly wrapper around setup-remote: show current / set new / remove.
326
+ // v0.2.31. Reuses runSetupRemote for the set path so URL validation + first-push verification
327
+ // happen in one place.
328
+ const flags = process.argv.slice(3);
329
+ const action = flags.includes("--show")
330
+ ? "show"
331
+ : flags.includes("--remove")
332
+ ? "remove"
333
+ : flags.includes("--set")
334
+ ? "set"
335
+ : undefined;
336
+ const setIdx = flags.indexOf("--set");
337
+ const url = setIdx >= 0 ? flags[setIdx + 1] : undefined;
338
+ const yes = flags.includes("--yes");
339
+ const { runRemoteCmd } = await tsImport("../src/cli/remoteCmd.ts", import.meta.url);
340
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
341
+ const result = await runRemoteCmd({
342
+ dataDir: resolveDataDir(),
343
+ ...(action ? { action } : {}),
344
+ ...(url ? { url } : {}),
345
+ yes,
346
+ });
347
+ if (result !== null) {
348
+ process.stdout.write(result.message);
349
+ if (result.setupResult && result.setupResult.status !== "ok") process.exitCode = 1;
350
+ }
240
351
  } else if (cmd === "setup-remote") {
241
352
  // `krimto setup-remote <url>` — point the data dir's git repo at a remote and verify a push.
242
353
  // Krimto must NOT be running while this is invoked (locks the .git/ index).
@@ -260,31 +371,139 @@ try {
260
371
  process.stdout.write(result.message);
261
372
  if (result.status === "error") process.exitCode = 1;
262
373
  } else if (cmd === "verify-connection") {
263
- // `krimto verify-connection` — read the lockfile + activity JSONL to answer "is my agent
264
- // actually calling Krimto right now?" Works from any terminal regardless of how Krimto launched.
374
+ // `krimto verify-connection` — v0.2.31: deprecated in favour of `krimto status` (which
375
+ // includes the same lock + activity + sync info as one of its blocks). Existing output
376
+ // preserved verbatim so existing scripts/READMEs keep working; deprecation hint to stderr.
265
377
  const { runVerifyConnection } = await tsImport("../src/cli/verifyConnection.ts", import.meta.url);
266
378
  const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
267
379
  const result = await runVerifyConnection(resolveDataDir());
268
380
  process.stdout.write(result.message);
381
+ process.stderr.write("\n→ `krimto verify-connection` is now part of `krimto status` (one command, four answers).\n");
269
382
  if (result.status === "none") process.exitCode = 1;
270
383
  } else if (cmd === "editors") {
271
- // `krimto editors` — one-question shortcut to add/remove editor connections (Phase B).
272
- const { runEditors } = await tsImport("../src/cli/editors.ts", import.meta.url);
273
- const result = await runEditors();
274
- 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
+ }
275
433
  } else if (cmd === "search") {
276
434
  // `krimto search` — change the search provider (Keyword vs OpenAI) without re-running the
277
- // 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;
278
446
  const { runSearchSettings } = await tsImport("../src/cli/searchSettings.ts", import.meta.url);
279
- const result = await runSearchSettings();
280
- 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
+ }
281
462
  } else if (cmd === "service") {
282
463
  // `krimto service` — change run mode (as-needed / always-running / manual). Installs or
283
- // uninstalls the platform service to match (Phase B).
464
+ // uninstalls the platform service to match (Phase B). v0.2.32: accepts `--as-needed`,
465
+ // `--always` (alias for --always-running), or `--manual` to skip the prompt for scripts.
466
+ const flags = process.argv.slice(3);
467
+ const flagMode = flags.includes("--as-needed")
468
+ ? "as-needed"
469
+ : flags.includes("--always") || flags.includes("--always-running")
470
+ ? "always-running"
471
+ : flags.includes("--manual")
472
+ ? "manual"
473
+ : undefined;
284
474
  const { runServiceCmd } = await tsImport("../src/cli/serviceCmd.ts", import.meta.url);
285
475
  const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
286
- const result = await runServiceCmd({ dataDir: resolveDataDir() });
476
+ const result = await runServiceCmd({
477
+ dataDir: resolveDataDir(),
478
+ ...(flagMode ? { mode: flagMode } : {}),
479
+ });
287
480
  if (result === null) process.exitCode = 1;
481
+ } else if (cmd === "stop" || cmd === "service stop") {
482
+ // `krimto stop` — v0.2.32 first-class teardown verb. Uninstalls the launchd/systemd
483
+ // service (if installed) and SIGTERMs whatever PID is holding the lock. Idempotent.
484
+ // `service stop` is the same code path, named for users coming via `service` discovery.
485
+ const { runStop } = await tsImport("../src/cli/stopCmd.ts", import.meta.url);
486
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
487
+ const result = await runStop({ dataDir: resolveDataDir() });
488
+ process.stdout.write(result.message);
489
+ } else if (cmd === "start" || cmd === "service start") {
490
+ // `krimto start` — v0.2.32 counterpart to stop. If a service plist exists on disk,
491
+ // reinstall + bootstrap (goes through the v0.2.26 kickstart-or-bootstrap path). If no
492
+ // service is configured, prints an instructive message instead of doing a brittle
493
+ // background-detached spawn.
494
+ const { runStart } = await tsImport("../src/cli/stopCmd.ts", import.meta.url);
495
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
496
+ const result = await runStart({ dataDir: resolveDataDir() });
497
+ process.stdout.write(result.message);
498
+ if (result.status === "no-service-configured" || result.status === "error") process.exitCode = 1;
499
+ } else if (cmd === "restart") {
500
+ // `krimto restart` — v0.2.32. stop + start. On always-running mode this is effectively
501
+ // `launchctl kickstart -k` via installService's v0.2.26 reload path — atomic, no
502
+ // port-unbound window.
503
+ const { runRestart } = await tsImport("../src/cli/stopCmd.ts", import.meta.url);
504
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
505
+ const result = await runRestart({ dataDir: resolveDataDir() });
506
+ process.stdout.write(result.message);
288
507
  } else if (cmd === "reset") {
289
508
  // `krimto reset` — disconnect from all editors + uninstall service + wipe local key store.
290
509
  // `--wipe-notes` adds a second confirmation and moves the data dir to a trash sibling.
@@ -403,11 +622,12 @@ try {
403
622
  process.stdout.write(result.message + "\n");
404
623
  if (result.status !== "ok") process.exitCode = 1;
405
624
  } else if (cmd === "storage") {
406
- // `krimto storage` — explain where Krimto keeps data (markdown / git / index) in plain English,
407
- // so the "you own your data" half of Krimto's pitch is reachable without reading the README.
625
+ // `krimto storage` — v0.2.31: deprecated in favour of `krimto status` (Storage block).
626
+ // Existing output preserved; deprecation hint to stderr.
408
627
  const { formatStorage } = await tsImport("../src/cli/storage.ts", import.meta.url);
409
628
  const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
410
629
  process.stdout.write(formatStorage(resolveDataDir()));
630
+ process.stderr.write("\n→ `krimto storage` is now part of `krimto status` (look for the Storage block).\n");
411
631
  } else if (cmd === "serve") {
412
632
  // `krimto serve` — boot the HTTP server (with /ui and /ui/connect) from the npx on-ramp,
413
633
  // so a stranger doesn't have to clone the repo or install Docker just to see the dashboard.
@@ -415,11 +635,38 @@ try {
415
635
  if (!process.env.KRIMTO_HTTP_PORT) process.env.KRIMTO_HTTP_PORT = "8080";
416
636
  const mod = await tsImport("../src/server/index.ts", import.meta.url);
417
637
  await mod.main();
638
+ } else if (cmd === "ui") {
639
+ // `krimto ui` — open the browser dashboard. The Maria-journey doc names this as one of the
640
+ // four user-facing verbs; the implementation is a one-liner over the platform "open this URL"
641
+ // command. If no krimto server is running, the browser will hit ECONNREFUSED — surface a
642
+ // pointer rather than a cryptic error.
643
+ const port = process.env.KRIMTO_HTTP_PORT ?? "8080";
644
+ const url = `http://localhost:${port}/ui`;
645
+ const { spawn } = await import("node:child_process");
646
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
647
+ spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
648
+ process.stdout.write(`Opening ${url}\n`);
649
+ process.stdout.write(`If the page doesn't load, start the server first: $ krimto serve\n`);
650
+ } else if (cmd === "open") {
651
+ // `krimto open` — reveal the notes folder in the OS file manager. Companion to `krimto ui`
652
+ // for users who want to inspect / back up the markdown directly. macOS uses `open`, Linux
653
+ // `xdg-open`, Windows `explorer`. We deliberately do NOT do this from a browser button on
654
+ // /ui (cross-origin POST + a process running as the user can `open arbitrary://` URLs);
655
+ // the CLI is the right surface.
656
+ const { resolveDataDir } = await tsImport("../src/server/index.ts", import.meta.url);
657
+ const dataDir = resolveDataDir();
658
+ const { spawn } = await import("node:child_process");
659
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
660
+ spawn(opener, [dataDir], { detached: true, stdio: "ignore" }).unref();
661
+ process.stdout.write(`Revealing ${dataDir} in your file manager.\n`);
418
662
  } else if (cmd === "usage") {
419
- // `krimto usage` — the long-form guide: the five tools, both modes, copy-paste examples.
663
+ // `krimto usage` — the long-form guide. v0.2.31: kept (the guide is genuinely long and
664
+ // doesn't fit in `krimto status`) but still flagged so users who want the dashboard know
665
+ // where to find it.
420
666
  const { formatUsage } = await tsImport("../src/cli/usage.ts", import.meta.url);
421
667
  const { KRIMTO_VERSION } = await tsImport("../src/server/index.ts", import.meta.url);
422
668
  process.stdout.write(formatUsage(KRIMTO_VERSION));
669
+ process.stderr.write("\n→ For runtime status (is Krimto running, recent calls, where data lives) use `krimto status`.\n");
423
670
  } else if (cmd === "connect") {
424
671
  // `krimto connect` — print stdio connect snippets (the npx on-ramp shape), so a solo user
425
672
  // doesn't have to chase the README. Honors KRIMTO_IDENTITY when set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krimto-labs/krimto",
3
- "version": "0.2.27",
3
+ "version": "0.2.34",
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",
@@ -58,6 +58,14 @@ export interface Requester {
58
58
  identity: string;
59
59
  /** Team slugs the requester belongs to. */
60
60
  teams: string[];
61
+ /**
62
+ * v0.2.31 — best-effort editor attribution. Set by the HTTP MCP handler from User-Agent
63
+ * sniffing ("Cursor/1.x" → "cursor", "claude-code/x" → "claude-code", etc.) so that fact
64
+ * frontmatter can record "saved from a Cursor chat" without each MCP-tool caller needing
65
+ * to pass `source` explicitly. Undefined over stdio transport (no UA available) and
66
+ * whenever the User-Agent is unrecognised.
67
+ */
68
+ source?: string;
61
69
  }
62
70
 
63
71
  export type ScopeRelation = "own-user" | "own-team" | "org" | "other";
package/src/agentRule.ts CHANGED
@@ -29,6 +29,17 @@ Don't save secrets, transient state, or one-off chatter.`;
29
29
  const START = "<!-- krimto:start -->";
30
30
  const END = "<!-- krimto:end -->";
31
31
 
32
+ /**
33
+ * v0.2.29 — Cursor's `.cursor/rules/*.mdc` files require YAML frontmatter to be auto-applied.
34
+ * Without `alwaysApply: true`, Cursor treats the rule as MANUAL-attach only — the agent only
35
+ * loads it when the user explicitly says "krimto" (or `@krimto`) in their prompt. The smoke-6
36
+ * cross-editor test showed this: Claude Code (which auto-reads CLAUDE.md with no frontmatter
37
+ * needed) saved facts correctly, but Cursor wouldn't recall them until the user typed "krimto".
38
+ * Other editors (CLAUDE.md, AGENTS.md, GEMINI.md) are plain markdown — they don't use this
39
+ * convention, so the frontmatter is added ONLY for the cursor target.
40
+ */
41
+ const CURSOR_FRONTMATTER = "---\nalwaysApply: true\n---\n";
42
+
32
43
  /** The rule wrapped in stable markers, so it can be found and updated in place later. */
33
44
  export function ruleBlock(): string {
34
45
  return `${START}\n${AGENT_RULE}\n${END}`;
@@ -40,19 +51,34 @@ export function ruleBlock(): string {
40
51
  * - existing WITHOUT our markers → append the block, preserving all existing content
41
52
  * - existing WITH our markers → replace only the marked block, preserving the rest
42
53
  * Re-applying the same rule yields identical content (so callers can detect a no-op).
54
+ *
55
+ * `opts.cursorMdc` prepends the Cursor-required YAML frontmatter (`alwaysApply: true`) so
56
+ * `.cursor/rules/krimto.mdc` is auto-loaded by Cursor on every prompt instead of being
57
+ * manual-attach-only. Idempotent: if frontmatter already exists at the top, it's preserved.
43
58
  */
44
- export function applyRule(existing: string | null): string {
59
+ export function applyRule(
60
+ existing: string | null,
61
+ opts: { cursorMdc?: boolean } = {},
62
+ ): string {
45
63
  const block = ruleBlock();
46
- if (!existing || existing.trim() === "") return `${block}\n`;
64
+
65
+ // Helper: ensure the result starts with `---\nalwaysApply: true\n---\n` when requested.
66
+ const withFrontmatter = (content: string): string => {
67
+ if (!opts.cursorMdc) return content;
68
+ if (content.startsWith("---\n")) return content; // user-supplied frontmatter — leave alone
69
+ return CURSOR_FRONTMATTER + content;
70
+ };
71
+
72
+ if (!existing || existing.trim() === "") return withFrontmatter(`${block}\n`);
47
73
 
48
74
  const startIdx = existing.indexOf(START);
49
75
  const endIdx = existing.indexOf(END);
50
76
  if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
51
- return existing.slice(0, startIdx) + block + existing.slice(endIdx + END.length);
77
+ return withFrontmatter(existing.slice(0, startIdx) + block + existing.slice(endIdx + END.length));
52
78
  }
53
79
 
54
80
  const sep = existing.endsWith("\n") ? "\n" : "\n\n";
55
- return `${existing}${sep}${block}\n`;
81
+ return withFrontmatter(`${existing}${sep}${block}\n`);
56
82
  }
57
83
 
58
84
  /**
@@ -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[],
@@ -140,7 +203,8 @@ async function askEditorsList(
140
203
  async function applyRuleToFile(cwd: string, env: EditorEnvironment): Promise<boolean> {
141
204
  const rulePath = path.join(cwd, env.rulesPath);
142
205
  const existing = await readMaybe(rulePath);
143
- const next = applyRule(existing);
206
+ // v0.2.29 Cursor's .mdc rules need `alwaysApply: true` frontmatter.
207
+ const next = applyRule(existing, { cursorMdc: env.editor === "cursor" });
144
208
  if (next === existing) return false;
145
209
  await fs.mkdir(path.dirname(rulePath), { recursive: true });
146
210
  await fs.writeFile(rulePath, next, "utf8");