owlmem 0.4.1 → 0.6.0

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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/owlmem.mjs +371 -16
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Command line for [OwlMem](https://owlmem.com) — shared memory and an end-to-end encrypted vault for your AI agents.
4
4
 
5
+ ## Connect your AI apps
6
+
7
+ ```sh
8
+ npx -y owlmem install
9
+ ```
10
+
11
+ Finds Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and Claude Desktop on this computer and adds
12
+ OwlMem (`https://mcp.owlmem.com/mcp`) to each one. Config files are merged, not overwritten, and every file it
13
+ changes is backed up first (`<file>.bak-owlmem-<time>`). It also adds a short "Team memory" rules block to
14
+ `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` and `~/.gemini/GEMINI.md` (between `<!-- owlmem:start -->` markers).
15
+
16
+ | Option | |
17
+ |---|---|
18
+ | `--dry-run` | show the plan, change nothing |
19
+ | `--yes` | don't ask (when stdin isn't a terminal — an AI running it — it never asks) |
20
+ | `--only claude-code,cursor` | only these apps (`claude-code`, `codex`, `cursor`, `vscode`, `vscode-insiders`, `windsurf`, `gemini`, `claude-desktop`) |
21
+ | `--no-rules` | leave the instruction files alone |
22
+ | `--uninstall` | remove the OwlMem entries and rules block again |
23
+
24
+ Signing in is done by each app (standard MCP OAuth): the first time it uses OwlMem it opens the owlmem.com
25
+ page, where you sign in and click **Allow**. AI agents can read the setup guide at https://mcp.owlmem.com.
26
+
27
+ ## Vault
28
+
5
29
  ```sh
6
30
  npx owlmem login # paste the agent's token from the OwlMem console
7
31
  npx owlmem vault unlock # paste your recovery code, once per machine
@@ -13,6 +37,13 @@ npx owlmem vault import bitwarden_export.json # also 1Password / KeePass CSV,
13
37
  Vault items are encrypted on your machine with a key only you hold; OwlMem stores ciphertext it cannot read.
14
38
  Every read or write needs the space owner's approval in the console (passkey, authenticator app or GitHub).
15
39
 
40
+ For unattended jobs (scheduled tasks, overnight deploys) the owner can set a **standing grant**: one agent, on one machine,
41
+ may read chosen items or a folder without asking each time, until a set date (90 days at most), optionally only during set hours.
42
+ Run `npx owlmem device` on that machine to register it and print its fingerprint. The program signs every vault request with a
43
+ device key kept next to its config, so the same token on another machine gets nothing. By default a standing grant covers
44
+ `owlmem run` / `vault_run` only (the secret goes into a command); viewing in plain text must be allowed separately.
45
+ Saving, deleting and exporting always need approval.
46
+
16
47
  Let your AI use the vault directly through a local MCP server:
17
48
 
18
49
  ```sh
@@ -22,3 +53,6 @@ claude mcp add owlmem-vault -- npx -y owlmem mcp
22
53
  Tools: `vault_list`, `vault_run` (secrets are injected into the command and masked in its output), `vault_get`, `vault_put`.
23
54
 
24
55
  Requires Node 18+. No dependencies.
56
+
57
+ `owlmem vault unlock` opens your browser only when talking to owlmem.com; set `OWLMEM_NO_BROWSER=1` (or `BROWSER=none`)
58
+ to just print the link instead.
package/owlmem.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  // server only ever sees ciphertext. Your space key comes from the recovery code shown once when the vault
6
6
  // was set up in the console; `owlmem vault unlock` stores it on this machine (0600).
7
7
  //
8
+ // owlmem install [--dry-run] [--yes] [--only a,b] [--no-rules] [--uninstall]
9
+ // connect Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and
10
+ // Claude Desktop on this computer to OwlMem (npx -y owlmem install)
8
11
  // owlmem login save this agent's token (from the console)
9
12
  // owlmem vault unlock paste the recovery code once per machine
10
13
  // owlmem vault ls
@@ -15,19 +18,26 @@
15
18
  // owlmem vault export [--format bitwarden-json|keepass-csv|env] > file
16
19
  // owlmem run --env VAR=<name>[:field] [...] -- <command> [args] run a command with secrets in its environment
17
20
  // owlmem vault keygen (setup without a browser) print a new recovery code and its key check
21
+ // owlmem device register this machine and show its fingerprint (for standing grants)
18
22
  // owlmem mcp local MCP server for your AI (vault_list, vault_run, vault_get, vault_put)
19
23
  // e.g. claude mcp add owlmem-vault -- node ~/owlmem.mjs mcp
20
24
  //
21
25
  // Reading, writing, deleting and exporting need the space owner's approval in the console; this program waits
22
26
  // for it (up to 5 minutes). Secrets go to stdout only; messages go to stderr.
27
+ //
28
+ // Standing grants (for unattended jobs): the owner can let one agent, on one machine, read chosen items without
29
+ // asking each time — until a set date, optionally only during set hours. This program signs every vault request
30
+ // with a device key kept on this machine (device.json next to config.json), and says whether the secret goes into a
31
+ // command (`owlmem run`, vault_run) or is shown (`vault get`, vault_get). A token copied to another machine has no
32
+ // device key there, so standing grants don't apply. Writing, deleting and exporting always need approval.
23
33
 
24
34
  import { webcrypto as crypto } from "node:crypto";
25
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
26
- import { homedir } from "node:os";
35
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync } from "node:fs";
36
+ import { homedir, hostname } from "node:os";
27
37
  import { join, dirname } from "node:path";
28
- import { spawn } from "node:child_process";
38
+ import { spawn, spawnSync } from "node:child_process";
29
39
 
30
- export const VERSION = "0.4.1";
40
+ export const VERSION = "0.6.0";
31
41
  const BASE = (process.env.OWLMEM_URL || "https://owlmem.com").replace(/\/$/, "");
32
42
  const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
33
43
  const enc = new TextEncoder(), dec = new TextDecoder();
@@ -169,11 +179,42 @@ function spaceKey(space) {
169
179
  return unb64(readFileSync(p, "utf8").trim());
170
180
  }
171
181
 
182
+ // ── this machine's device key (standing grants are bound to it) ──
183
+ // ECDSA P-256, made on first use, kept next to config.json (0600). The server only ever sees the public key and signatures.
184
+ let DEVICE = null;
185
+ async function device() {
186
+ if (DEVICE) return DEVICE;
187
+ const p = join(DIR, "device.json");
188
+ let d = null;
189
+ try { d = JSON.parse(readFileSync(p, "utf8")); } catch {}
190
+ if (!d || !d.pkcs8 || !d.spki) {
191
+ const kp = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
192
+ d = { v: 1, spki: b64url(new Uint8Array(await crypto.subtle.exportKey("spki", kp.publicKey))), pkcs8: b64(new Uint8Array(await crypto.subtle.exportKey("pkcs8", kp.privateKey))), created: new Date().toISOString() };
193
+ saveFile(p, JSON.stringify(d, null, 2));
194
+ }
195
+ const key = await crypto.subtle.importKey("pkcs8", unb64(d.pkcs8), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
196
+ return (DEVICE = { spki: d.spki, key });
197
+ }
198
+ export async function deviceFingerprint(spki) {
199
+ const h = [...new Uint8Array(await crypto.subtle.digest("SHA-256", unb64(spki.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - spki.length % 4) % 4))))].map((x) => x.toString(16).padStart(2, "0")).join("").slice(0, 16).toUpperCase();
200
+ return h.match(/.{4}/g).join("-");
201
+ }
202
+ // mode: "run" = the secret goes into a command; "view" = it is shown (stdout / the AI's conversation); "other" = anything else
203
+ async function signHeaders(method, path, mode) {
204
+ const d = await device();
205
+ const ts = String(Math.floor(Date.now() / 1000)), nonce = b64url(crypto.getRandomValues(new Uint8Array(18)));
206
+ const msg = ["owlmem-device-v1", method.toUpperCase(), new URL(BASE + "/v1/vault/" + path).pathname, mode, ts, nonce].join("\n");
207
+ const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, d.key, enc.encode(msg)));
208
+ return { "X-OwlMem-Device": d.spki, "X-OwlMem-Device-Name": (process.env.OWLMEM_DEVICE_NAME || hostname()).replace(/[^\w .@()-]/g, "").slice(0, 60),
209
+ "X-OwlMem-Mode": mode, "X-OwlMem-Time": ts, "X-OwlMem-Nonce": nonce, "X-OwlMem-Sig": b64url(sig) };
210
+ }
211
+
172
212
  // ── http ──
173
- async function call(method, path, body) {
213
+ async function call(method, path, body, mode = "other") {
174
214
  let r;
175
215
  try {
176
- r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION },
216
+ const sh = await signHeaders(method, path, mode).catch(() => ({})); // can't write the device key: no standing grants, everything else still works
217
+ r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION, ...sh },
177
218
  body: body === undefined ? undefined : JSON.stringify(body) });
178
219
  } catch (e) { throw new Fail("Can't reach OwlMem (" + BASE + "): " + e.message); }
179
220
  const data = await r.json().catch(() => ({}));
@@ -181,10 +222,10 @@ async function call(method, path, body) {
181
222
  }
182
223
  // Ask, and if the owner has to approve, wait for it (polling every 3 s, up to 5 minutes), then ask again.
183
224
  let WAIT_MS = 5 * 60 * 1000; // 命令行等 5 分钟;MCP 里只等 30 秒,免得 AI 客户端的工具调用超时
184
- async function approved(method, path, body, kind = "access") {
225
+ async function approved(method, path, body, kind = "access", mode = "other") {
185
226
  let extra = "";
186
227
  for (let round = 0; round < 3; round++) {
187
- const r = await call(method, path + extra, body);
228
+ const r = await call(method, path + extra, body, mode);
188
229
  if (r.status < 400) return r.data;
189
230
  if (r.data.error !== "approval_required") throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
190
231
  const id = r.data.request;
@@ -196,7 +237,7 @@ async function approved(method, path, body, kind = "access") {
196
237
  st = (await call("GET", "requests/" + id)).data.status;
197
238
  if (st !== "pending") break;
198
239
  }
199
- if (st === "pending") throw new Fail(`Waiting for approval (request ${id}). Ask the space owner to approve it in the OwlMem console (Vault), then try again.`);
240
+ if (st === "pending") throw new Fail(`Waiting for approval (request ${id}). Ask the space owner to approve it in the OwlMem console (Vault), then try again. For unattended jobs, ask them for a standing grant for this agent and this machine (run: owlmem device).`);
200
241
  if (st !== "approved") throw new Fail(st === "denied" ? "The request was denied." : "The request expired before it was approved.");
201
242
  say("Approved.");
202
243
  if (kind !== "access") extra = (path.includes("?") ? "&" : "?") + "request=" + encodeURIComponent(id);
@@ -311,6 +352,21 @@ export function guessCSV(text) {
311
352
  }
312
353
  // <<< vault core
313
354
 
355
+ // ── opening a browser ──
356
+ // Only for the real service: OWLMEM_NO_BROWSER=1 or BROWSER=none turns it off, and so does any OWLMEM_URL that isn't owlmem.com
357
+ // (tests and local previews run on 127.0.0.1 — 10-24 a test run kept opening tabs in the owner's browser). The link is always printed.
358
+ export function browserAllowed(env = process.env) {
359
+ if (env.OWLMEM_NO_BROWSER && env.OWLMEM_NO_BROWSER !== "0") return false;
360
+ if (String(env.BROWSER || "").toLowerCase() === "none") return false;
361
+ let host = "";
362
+ try { const u = new URL(env.OWLMEM_URL || "https://owlmem.com"); if (u.protocol !== "https:") return false; host = u.hostname; } catch { return false; }
363
+ return host === "owlmem.com" || host.endsWith(".owlmem.com");
364
+ }
365
+ function openBrowser(url) {
366
+ if (!browserAllowed()) return false;
367
+ try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); return true; } catch { return false; }
368
+ }
369
+
314
370
  // ── unlock via the console ──
315
371
  async function unlockInBrowser(space) {
316
372
  const kp = await unlockKeypair();
@@ -318,7 +374,7 @@ async function unlockInBrowser(space) {
318
374
  if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
319
375
  const fp = await unlockFingerprint(kp.pubkey);
320
376
  say("Open this link, sign in, and approve with your passkey:\n\n " + r.data.url + "\n\nThe console will show a code — check it matches: " + fp + "\nWaiting (up to 10 minutes)…");
321
- try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [r.data.url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {}
377
+ openBrowser(r.data.url);
322
378
  const wait = Number(process.env.OWLMEM_POLL_MS || 2000), until = Date.now() + 10 * 60 * 1000;
323
379
  while (Date.now() < until) {
324
380
  await new Promise((res) => setTimeout(res, wait));
@@ -350,9 +406,9 @@ function ask(question, hidden = true) {
350
406
 
351
407
  // ── commands ──
352
408
  function flag(args, name, dflt) { const i = args.indexOf(name); if (i < 0) return dflt; const v = args[i + 1]; args.splice(i, 2); return v; }
353
- async function fetchItem(name) {
409
+ async function fetchItem(name, mode = "view") {
354
410
  const sp = await space(); const raw = spaceKey(sp.space);
355
- const it = await approved("GET", itemPath(name));
411
+ const it = await approved("GET", itemPath(name), undefined, "access", mode);
356
412
  return open(raw, sp.space, name, it.nonce, it.ciphertext);
357
413
  }
358
414
  function pick(body, field) {
@@ -366,7 +422,7 @@ function pick(body, field) {
366
422
  const MCP_TOOLS = [
367
423
  { name: "vault_list", description: "List the credentials in the team vault (names and descriptions only; no secrets).", inputSchema: { type: "object", properties: {} } },
368
424
  { name: "vault_run",
369
- description: "PREFERRED way to use a secret: run a shell command with vault items injected as environment variables. The secret values are masked as **** in the output returned to you, so they never enter the conversation. Needs the space owner's approval in the OwlMem console the first time (then 15 min – 2 h).",
425
+ description: "PREFERRED way to use a secret: run a shell command with vault items injected as environment variables. The secret values are masked as **** in the output returned to you, so they never enter the conversation. Needs the space owner's approval in the OwlMem console the first time (then 15 min – 2 h), unless they set a standing grant for you on this machine — standing grants cover vault_run only (not vault_get) unless the owner also allowed viewing.",
370
426
  inputSchema: { type: "object", properties: {
371
427
  command: { type: "string", description: "Shell command to run (sh -c)" },
372
428
  env: { type: "object", description: 'Map of ENV_VAR to "item" or "item:field", e.g. {"DB_PASSWORD": "prod/db:password"}', additionalProperties: { type: "string" } },
@@ -396,7 +452,7 @@ async function mcpCall(name, a) {
396
452
  for (const [k, ref] of Object.entries(a.env || {})) {
397
453
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Fail("Bad variable name " + k);
398
454
  const [item, field] = String(ref).split(":");
399
- const v = pick(await fetchItem(item), field); env[k] = v; if (v && v.length >= 4) secrets.push(v);
455
+ const v = pick(await fetchItem(item, "run"), field); env[k] = v; if (v && v.length >= 4) secrets.push(v);
400
456
  }
401
457
  const mask = (t) => secrets.sort((x, y) => y.length - x.length).reduce((acc, v) => acc.split(v).join("****"), t);
402
458
  const out = await new Promise((res) => {
@@ -434,10 +490,309 @@ async function mcpServe() {
434
490
  await new Promise((res) => process.stdin.on("end", res));
435
491
  }
436
492
 
493
+ // ── install: connect the AI apps on this machine to OwlMem's MCP server ──
494
+ // The snippets here must say the same as the website (design/owlmem/site.py CODE); tests/test_owlmem_install.py compares them.
495
+ export const MCP_URL = "https://mcp.owlmem.com/mcp";
496
+ export const RULES = `## Team memory (OwlMem)
497
+ - Before starting a task, use kb_search (documents) and kb_recall (facts) for the topic.
498
+ - When you learn something reusable — a fix, a gotcha, a decision — save it with kb_write
499
+ under lessons/ or projects/<name>/. Search first and update the existing page instead of
500
+ creating a duplicate.
501
+ - Store facts that change (prices, versions, status) with kb_retain and snapshot=true or an
502
+ expiry, so they retire on their own.
503
+ - Never put passwords or API keys in documents or memory. Use the OwlMem vault.`;
504
+ const MARK_START = "<!-- owlmem:start -->", MARK_END = "<!-- owlmem:end -->";
505
+ const DOCS = "https://owlmem.com/docs#";
506
+ // What each app's config file gets under its servers key (same shapes as on owlmem.com/docs)
507
+ export const ENTRIES = {
508
+ cursor: { url: MCP_URL },
509
+ vscode: { type: "http", url: MCP_URL },
510
+ windsurf: { serverUrl: MCP_URL },
511
+ gemini: { httpUrl: MCP_URL },
512
+ "claude-desktop": { command: "npx", args: ["-y", "mcp-remote", MCP_URL] },
513
+ };
514
+ export const CLAUDE_ADD = ["mcp", "add", "--transport", "http", "--scope", "user", "owlmem", MCP_URL];
515
+ export const CODEX_TOML = `[mcp_servers.owlmem]\nurl = "${MCP_URL}"`;
516
+
517
+ function onPath(name) {
518
+ const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
519
+ for (const d of (process.env.PATH || "").split(process.platform === "win32" ? ";" : ":")) {
520
+ if (!d) continue;
521
+ for (const e of exts) { const p = join(d, name + e); if (existsSync(p)) return p; }
522
+ }
523
+ return null;
524
+ }
525
+ function home() { return process.env.HOME || process.env.USERPROFILE || homedir(); }
526
+ function platform() { return process.env.OWLMEM_PLATFORM || process.platform; } // OWLMEM_PLATFORM: tests only
527
+ function appData() { // per-user application data folder (VS Code, Claude Desktop)
528
+ const p = platform(), h = home();
529
+ if (p === "darwin") return join(h, "Library", "Application Support");
530
+ if (p === "win32") return process.env.APPDATA || join(h, "AppData", "Roaming");
531
+ return process.env.XDG_CONFIG_HOME || join(h, ".config");
532
+ }
533
+ const readText = (f) => { try { return readFileSync(f, "utf8"); } catch { return null; } };
534
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
535
+
536
+ // Client table. detect(): is this app on this machine? Each plan returns {state, ops, note}.
537
+ // state: "ok" (already set up) | "add" | "update" | "remove" | "absent" (nothing to remove) | "fail"
538
+ // ops: [{type:"write", file, text} | {type:"run", argv}]
539
+ function jsonClient(id, name, file, key, entry) {
540
+ return { id, name, file, key, entry, docs: DOCS + ({ windsurf: "others", gemini: "others", "claude-desktop": "claude" }[id] || id) };
541
+ }
542
+ export function clients() {
543
+ const h = home(), ad = appData();
544
+ const codexHome = process.env.CODEX_HOME || join(h, ".codex");
545
+ const list = [
546
+ { id: "claude-code", name: "Claude Code", docs: DOCS + "claude-code", rules: join(process.env.CLAUDE_CONFIG_DIR || join(h, ".claude"), "CLAUDE.md"),
547
+ detect: () => !!onPath("claude") },
548
+ { id: "codex", name: "Codex", docs: DOCS + "codex", file: join(codexHome, "config.toml"), rules: join(codexHome, "AGENTS.md"),
549
+ detect: () => !!onPath("codex") || existsSync(codexHome) },
550
+ Object.assign(jsonClient("cursor", "Cursor", join(h, ".cursor", "mcp.json"), "mcpServers", ENTRIES.cursor),
551
+ { detect: () => existsSync(join(h, ".cursor")) || !!onPath("cursor") }),
552
+ Object.assign(jsonClient("vscode", "VS Code", join(ad, "Code", "User", "mcp.json"), "servers", ENTRIES.vscode),
553
+ { detect: () => existsSync(join(ad, "Code", "User")) }),
554
+ Object.assign(jsonClient("vscode-insiders", "VS Code Insiders", join(ad, "Code - Insiders", "User", "mcp.json"), "servers", ENTRIES.vscode),
555
+ { detect: () => existsSync(join(ad, "Code - Insiders", "User")), docs: DOCS + "vscode", optional: true }),
556
+ Object.assign(jsonClient("windsurf", "Windsurf", join(h, ".codeium", "windsurf", "mcp_config.json"), "mcpServers", ENTRIES.windsurf),
557
+ { detect: () => existsSync(join(h, ".codeium", "windsurf")) }),
558
+ Object.assign(jsonClient("gemini", "Gemini CLI", join(h, ".gemini", "settings.json"), "mcpServers", ENTRIES.gemini),
559
+ { detect: () => existsSync(join(h, ".gemini")) || !!onPath("gemini"), rules: join(h, ".gemini", "GEMINI.md") }),
560
+ Object.assign(jsonClient("claude-desktop", "Claude Desktop", join(ad, "Claude", "claude_desktop_config.json"), "mcpServers", ENTRIES["claude-desktop"]),
561
+ { detect: () => existsSync(join(ad, "Claude")) }),
562
+ ];
563
+ return list;
564
+ }
565
+ export const CLIENT_IDS = ["claude-code", "codex", "cursor", "vscode", "vscode-insiders", "windsurf", "gemini", "claude-desktop"];
566
+
567
+ function planJSON(c, uninstall) {
568
+ const text = readText(c.file);
569
+ let obj = {};
570
+ if (text !== null && text.trim()) {
571
+ try { obj = JSON.parse(text); } catch {
572
+ const hint = /\/\/|\/\*/.test(text) ? "it has comments or isn't plain JSON" : "it isn't valid JSON";
573
+ return { state: "fail", ops: [], note: `${c.file}: ${hint}, so it was left alone. Add OwlMem by hand: ${c.docs}` };
574
+ }
575
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return { state: "fail", ops: [], note: `${c.file}: unexpected content, left alone. Add OwlMem by hand: ${c.docs}` };
576
+ }
577
+ const servers = obj[c.key] && typeof obj[c.key] === "object" && !Array.isArray(obj[c.key]) ? obj[c.key] : null;
578
+ const cur = servers ? servers.owlmem : undefined;
579
+ if (uninstall) {
580
+ if (cur === undefined) return { state: "absent", ops: [] };
581
+ const next = { ...obj, [c.key]: { ...servers } }; delete next[c.key].owlmem;
582
+ return { state: "remove", ops: [{ type: "write", file: c.file, text: JSON.stringify(next, null, 2) + "\n" }] };
583
+ }
584
+ if (same(cur, c.entry)) return { state: "ok", ops: [] };
585
+ const next = { ...obj, [c.key]: { ...(servers || {}), owlmem: c.entry } };
586
+ return { state: cur === undefined ? "add" : "update", ops: [{ type: "write", file: c.file, text: JSON.stringify(next, null, 2) + "\n" }],
587
+ note: cur === undefined ? "" : "replaced the existing owlmem entry " + JSON.stringify(cur) };
588
+ }
589
+
590
+ // Codex: ~/.codex/config.toml. Only the [mcp_servers.owlmem] table (and its sub-tables) is touched; everything else is kept as is.
591
+ export function tomlSplit(text) {
592
+ const rest = [], section = []; let mine = false;
593
+ for (const l of text.split("\n")) {
594
+ if (/^\s*\[\[/.test(l)) mine = false;
595
+ else { const m = /^\s*\[\s*([^\]]+?)\s*\]\s*(#.*)?$/.exec(l); if (m) { const n = m[1].replace(/["'\s]/g, ""); mine = n === "mcp_servers.owlmem" || n.startsWith("mcp_servers.owlmem."); } }
596
+ (mine ? section : rest).push(l);
597
+ }
598
+ return { rest: rest.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""), section: section.join("\n").trim(), found: section.length > 0 };
599
+ }
600
+ function planCodex(c, uninstall) {
601
+ const text = readText(c.file) ?? "";
602
+ if (/^\s*mcp_servers\s*[.=]|^\s*owlmem\s*=\s*\{/m.test(text))
603
+ return { state: "fail", ops: [], note: `${c.file} defines MCP servers in a form this installer doesn't edit; left alone. Add OwlMem by hand: ${c.docs}` };
604
+ const { rest, section, found } = tomlSplit(text);
605
+ if (uninstall) return found ? { state: "remove", ops: [{ type: "write", file: c.file, text: rest ? rest + "\n" : "" }] } : { state: "absent", ops: [] };
606
+ const norm = (s) => s.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).join("\n");
607
+ if (found && norm(section) === norm(CODEX_TOML)) return { state: "ok", ops: [] };
608
+ return { state: found ? "update" : "add", ops: [{ type: "write", file: c.file, text: (rest ? rest + "\n\n" : "") + CODEX_TOML + "\n" }],
609
+ note: found ? "replaced the existing [mcp_servers.owlmem] table" : "" };
610
+ }
611
+
612
+ // Claude Code: through its own command (`claude mcp add … --scope user`); ~/.claude.json tells us what is there already.
613
+ function planClaude(c, uninstall) {
614
+ let cur;
615
+ try { cur = (JSON.parse(readText(join(home(), ".claude.json")) || "{}").mcpServers || {}).owlmem; } catch { cur = undefined; }
616
+ const rm = { type: "run", argv: ["claude", "mcp", "remove", "owlmem", "--scope", "user"], ignoreFail: !uninstall };
617
+ if (uninstall) return cur === undefined ? { state: "absent", ops: [] } : { state: "remove", ops: [rm] };
618
+ if (cur && cur.url === MCP_URL && (cur.type === "http" || cur.type === "streamable-http") && !cur.headers) return { state: "ok", ops: [] };
619
+ return { state: cur === undefined ? "add" : "update", ops: [...(cur === undefined ? [] : [rm]), { type: "run", argv: ["claude", ...CLAUDE_ADD], retryWith: rm }],
620
+ note: cur === undefined ? "" : "replaced the existing owlmem server " + JSON.stringify(cur) };
621
+ }
622
+
623
+ export function withRules(text, uninstall) {
624
+ const t = text ?? "";
625
+ const re = new RegExp("\\n*" + MARK_START + "[\\s\\S]*?" + MARK_END + "\\n?");
626
+ if (uninstall) return re.test(t) ? t.replace(re, "\n").replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n") : t;
627
+ const block = MARK_START + "\n" + RULES + "\n" + MARK_END + "\n";
628
+ if (re.test(t)) return t.replace(re, (m) => (m.startsWith("\n") ? "\n\n" : "") + block);
629
+ return t.trim() ? t.replace(/\s*$/, "\n\n") + block : block;
630
+ }
631
+
632
+ export function plan(opts = {}) {
633
+ const only = opts.only ? new Set(opts.only) : null;
634
+ const out = [];
635
+ for (const c of clients()) {
636
+ if (only && !only.has(c.id) && !(c.id === "vscode-insiders" && only.has("vscode"))) continue;
637
+ const r = { id: c.id, name: c.name, docs: c.docs, file: c.file, ops: [], note: "" };
638
+ if (!c.detect()) { if (!c.optional || only) Object.assign(r, { state: "missing" }); else continue; out.push(r); continue; }
639
+ Object.assign(r, c.id === "claude-code" ? planClaude(c, opts.uninstall) : c.id === "codex" ? planCodex(c, opts.uninstall) : planJSON(c, opts.uninstall));
640
+ if (c.rules && !opts.noRules && r.state !== "fail") {
641
+ const before = readText(c.rules), after = withRules(before, opts.uninstall);
642
+ if (after !== (before ?? "") && !(opts.uninstall && before === null)) { r.ops.push({ type: "write", file: c.rules, text: after, rules: true }); r.rulesChanged = true; }
643
+ r.rulesFile = c.rules;
644
+ }
645
+ out.push(r);
646
+ }
647
+ return out;
648
+ }
649
+
650
+ function stamp() { const d = new Date(), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; }
651
+ function runCmd(argv) {
652
+ const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", shell: process.platform === "win32", timeout: 60000 });
653
+ return { ok: r.status === 0, out: ((r.stderr || "") + (r.stdout || "")).trim() || (r.error ? r.error.message : "") };
654
+ }
655
+ export function apply(items) {
656
+ const ts = stamp();
657
+ for (const r of items) {
658
+ r.backups = [];
659
+ try {
660
+ for (const op of r.ops) {
661
+ if (op.type === "write") {
662
+ mkdirSync(dirname(op.file), { recursive: true });
663
+ if (existsSync(op.file)) { const b = op.file + ".bak-owlmem-" + ts; copyFileSync(op.file, b); r.backups.push(b); }
664
+ writeFileSync(op.file, op.text);
665
+ } else {
666
+ let res = runCmd(op.argv);
667
+ if (!res.ok && op.retryWith && /already exists/i.test(res.out)) { runCmd(op.retryWith.argv); res = runCmd(op.argv); }
668
+ if (!res.ok && !op.ignoreFail) throw new Error("`" + op.argv.join(" ") + "` failed: " + (res.out.split("\n").slice(-3).join(" ") || "no output"));
669
+ }
670
+ }
671
+ } catch (e) { r.state = "fail"; r.note = (e.message || String(e)) + " — set it up by hand: " + r.docs; }
672
+ }
673
+ return items;
674
+ }
675
+
676
+ const tilde = (f) => { const h = home(); return f && h && f.startsWith(h + (platform() === "win32" ? "\\" : "/")) ? "~" + f.slice(h.length) : f; };
677
+ const SYM = { ok: "✓", add: "✓", update: "↺", remove: "✓", absent: "–", missing: "–", fail: "✗" };
678
+ function describe(r, done, uninstall) {
679
+ const verb = {
680
+ ok: "already set up", add: done ? "added" : "will add OwlMem", update: done ? "updated" : "will update the owlmem entry",
681
+ remove: done ? "removed" : "will remove OwlMem", absent: "not configured", missing: "not installed", fail: "failed",
682
+ }[r.state];
683
+ const sym = !done && r.state === "add" ? "+" : !done && r.state === "remove" ? "-" : SYM[r.state];
684
+ const lines = [`${sym} ${r.name.padEnd(17)} ${verb}${r.state !== "missing" && r.file && r.state !== "fail" ? " (" + tilde(r.file) + ")" : ""}`];
685
+ if (r.note) lines.push(" " + r.note);
686
+ for (const op of r.ops) {
687
+ if (op.type === "run" && !done) lines.push(" run: " + op.argv.join(" "));
688
+ if (op.rules) lines.push(` ${done ? "" : "will "}${uninstall ? (done ? "removed" : "remove") : (done ? "wrote" : "write")} the OwlMem rules block in ${tilde(op.file)}`);
689
+ }
690
+ if (done && r.backups?.length) lines.push(" backup: " + r.backups.join(", "));
691
+ return lines.join("\n");
692
+ }
693
+
694
+ const LOGIN = {
695
+ "claude-code": "Claude Code: run /mcp, pick owlmem, choose Authenticate.",
696
+ codex: "Codex: run `codex mcp login owlmem`.",
697
+ cursor: "Cursor: Settings → MCP, click \"Needs login\" next to owlmem.",
698
+ vscode: "VS Code: it asks you to sign in the first time Copilot uses owlmem (or run \"MCP: List Servers\" → owlmem → Start).",
699
+ "vscode-insiders": "VS Code Insiders: same as VS Code.",
700
+ windsurf: "Windsurf: open Settings → MCP servers and refresh; sign in when it asks.",
701
+ gemini: "Gemini CLI: run `/mcp auth owlmem`.",
702
+ "claude-desktop": "Claude Desktop: restart it; the sign-in page opens on the first start (via mcp-remote).",
703
+ };
704
+
705
+ async function install(args) {
706
+ const opts = { dryRun: false, yes: false, noRules: false, uninstall: false, only: null };
707
+ for (let i = 0; i < args.length; i++) {
708
+ const a = args[i];
709
+ if (a === "--dry-run") opts.dryRun = true;
710
+ else if (a === "--yes" || a === "-y") opts.yes = true;
711
+ else if (a === "--no-rules") opts.noRules = true;
712
+ else if (a === "--uninstall") opts.uninstall = true;
713
+ else if (a === "--only" || a.startsWith("--only=")) {
714
+ const v = a.includes("=") ? a.slice(7) : args[++i];
715
+ opts.only = String(v || "").split(",").map((s) => s.trim()).filter(Boolean);
716
+ const bad = opts.only.filter((x) => !CLIENT_IDS.includes(x));
717
+ if (bad.length || !opts.only.length) throw new Fail(`--only takes a comma-separated list of: ${CLIENT_IDS.join(", ")}`);
718
+ } else if (a === "--help" || a === "-h") { console.log(INSTALL_HELP); return; }
719
+ else throw new Fail("Unknown option " + a + "\n\n" + INSTALL_HELP);
720
+ }
721
+ const items = plan(opts);
722
+ const changes = items.filter((r) => r.ops.length);
723
+ const out = (s = "") => console.log(s);
724
+ out(opts.uninstall ? "OwlMem uninstall — " + (opts.dryRun ? "dry run, nothing is changed" : "plan") : "OwlMem install — connects the AI apps on this computer to " + MCP_URL + (opts.dryRun ? " (dry run, nothing is changed)" : ""));
725
+ out();
726
+ for (const r of items) out(describe(r, false, opts.uninstall));
727
+ out();
728
+ if (!changes.length) {
729
+ out(items.some((r) => r.state === "ok") || opts.uninstall ? "Nothing to change." : "No supported AI apps found. Manual setup for every app: https://owlmem.com/docs");
730
+ if (!opts.uninstall && items.some((r) => r.state === "ok")) nextSteps(items, out);
731
+ process.exitCode = items.some((r) => r.state === "fail") ? 1 : 0;
732
+ return;
733
+ }
734
+ if (opts.dryRun) { out(`Dry run: ${changes.length} app(s) would change. Run again without --dry-run to apply.`); return; }
735
+ const tty = process.stdin.isTTY && process.stdout.isTTY;
736
+ if (tty && !opts.yes) {
737
+ const ans = (await ask("Apply these changes? [Y/n] ", false)).toLowerCase();
738
+ if (ans && !ans.startsWith("y")) { out("Nothing changed."); return; }
739
+ }
740
+ apply(changes);
741
+ out();
742
+ for (const r of changes) out(describe(r, true, opts.uninstall));
743
+ out();
744
+ if (opts.uninstall) out("OwlMem was removed from those apps. Backups of every changed file sit next to it (*.bak-owlmem-*).");
745
+ else {
746
+ nextSteps(items, out);
747
+ const codex = items.find((r) => r.id === "codex" && r.state !== "fail" && r.state !== "missing");
748
+ if (codex && tty && !opts.yes && onPath("codex") && browserAllowed()) { // codex mcp login opens a browser: same rule as openBrowser
749
+ const ans = (await ask("Sign Codex in now? This runs `codex mcp login owlmem` and opens your browser. [y/N] ", false)).toLowerCase();
750
+ if (ans.startsWith("y")) await new Promise((res) => spawn("codex", ["mcp", "login", "owlmem"], { stdio: "inherit", shell: process.platform === "win32" }).on("exit", res).on("error", res));
751
+ }
752
+ }
753
+ if (items.some((r) => r.state === "fail")) process.exitCode = 1;
754
+ }
755
+
756
+ function nextSteps(items, out) {
757
+ const live = items.filter((r) => ["ok", "add", "update"].includes(r.state));
758
+ if (!live.length) return;
759
+ out("Next: sign in once in each app. The first time an app uses OwlMem it opens the owlmem.com sign-in page —");
760
+ out("sign in with your email (new accounts get a free space) and click \"Allow\". An AI can't click it for you.");
761
+ for (const r of live) out(" • " + LOGIN[r.id]);
762
+ if (live.some((r) => ["cursor", "vscode", "vscode-insiders", "windsurf"].includes(r.id)))
763
+ out("Cursor, VS Code and Windsurf have no global rules file: add the rules from https://owlmem.com/docs#instructions to your project rules.");
764
+ if (live.some((r) => r.id === "claude-desktop")) out("Claude Desktop: restart it to load OwlMem. (Or add https://mcp.owlmem.com/mcp under Settings → Connectors instead.)");
765
+ out("Already-running sessions don't see new MCP servers: start a new session, then ask your AI to run kb_search.");
766
+ }
767
+
768
+ const INSTALL_HELP = `Usage: npx -y owlmem install [options]
769
+
770
+ Connects Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI and Claude Desktop on this computer to
771
+ OwlMem (${MCP_URL}). Config files are merged, never overwritten; each changed file is backed up first
772
+ as <file>.bak-owlmem-<time>. Also adds the OwlMem usage rules to CLAUDE.md / AGENTS.md / GEMINI.md.
773
+
774
+ --dry-run show what would change, change nothing
775
+ --yes, -y don't ask (non-interactive runs, e.g. an AI running this, never ask)
776
+ --only a,b only these apps: ${CLIENT_IDS.join(", ")}
777
+ --no-rules don't touch CLAUDE.md / AGENTS.md / GEMINI.md
778
+ --uninstall remove OwlMem entries and the rules block (with backups)
779
+
780
+ Signing in happens in each app the first time it uses OwlMem (standard MCP OAuth): you click "Allow" once.`;
781
+
437
782
  export async function main(argv) {
438
783
  const [cmd, sub, ...rest] = argv;
439
784
  if (cmd === "--version" || cmd === "version") { console.log(VERSION); return; }
785
+ if (cmd === "install") { await install(argv.slice(1)); return; }
786
+ if (cmd === "uninstall") { await install(["--uninstall", ...argv.slice(1)]); return; }
440
787
  if (cmd === "mcp") { await mcpServe(); return; }
788
+ if (cmd === "device") { // register this machine for standing grants and show its fingerprint
789
+ const r = await call("POST", "device", {});
790
+ if (r.status >= 400) throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
791
+ say("This machine: " + (process.env.OWLMEM_DEVICE_NAME || hostname()));
792
+ console.log(r.data.device);
793
+ say("Registered for " + r.data.agent + ". In the OwlMem console (Vault → Standing grants) pick the machine with this fingerprint.");
794
+ return;
795
+ }
441
796
  if (cmd === "login") {
442
797
  const t = (process.env.OWLMEM_TOKEN || await ask("Paste this agent's token (om_…, shown as dots), then Enter: ")).replace(/\x1b\[[0-9;]*[~A-Za-z]/g, "").replace(/[^A-Za-z0-9_-]/g, "");
443
798
  if (!/^om_[A-Za-z0-9_-]{20,80}$/.test(t)) throw new Fail("That doesn't look like an OwlMem agent token (om_…).");
@@ -455,13 +810,13 @@ export async function main(argv) {
455
810
  if (opts[i] !== "--env") throw new Fail("Unknown option " + opts[i]);
456
811
  const m = /^([A-Za-z_][A-Za-z0-9_]*)=([^:]+)(?::(.+))?$/.exec(opts[++i] || "");
457
812
  if (!m) throw new Fail("Use --env VAR=item or --env VAR=item:field");
458
- env[m[1]] = pick(await fetchItem(m[2]), m[3] || (undefined));
813
+ env[m[1]] = pick(await fetchItem(m[2], "run"), m[3] || (undefined));
459
814
  }
460
815
  const child = spawn(command[0], command.slice(1), { stdio: "inherit", env });
461
816
  await new Promise((res) => child.on("exit", (code, sig) => { process.exitCode = sig ? 1 : code; res(); }));
462
817
  return;
463
818
  }
464
- if (cmd !== "vault") { say(readFileSync(new URL(import.meta.url), "utf8").split("\n").filter((l) => l.startsWith("//")).slice(0, 26).map((l) => l.slice(3)).join("\n")); process.exitCode = cmd ? 2 : 0; return; }
819
+ if (cmd !== "vault") { { const ls = readFileSync(new URL(import.meta.url), "utf8").split("\n").slice(1); say(ls.slice(0, ls.findIndex((l) => !l.startsWith("//"))).map((l) => l.slice(3)).join("\n")); } process.exitCode = cmd ? 2 : 0; return; }
465
820
  const args = [...rest];
466
821
  if (sub === "keygen") {
467
822
  const sp = process.env.OWLMEM_SPACE || (await space().catch(() => ({}))).space;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.4.1",
4
- "description": "OwlMem command line: end-to-end encrypted vault for your AI agents, and a local MCP server.",
3
+ "version": "0.6.0",
4
+ "description": "OwlMem command line: connect your AI apps to OwlMem in one command (npx -y owlmem install), an end-to-end encrypted vault for your AI agents, and a local MCP server.",
5
5
  "type": "module",
6
6
  "bin": { "owlmem": "owlmem.mjs" },
7
7
  "files": ["owlmem.mjs", "README.md", "LICENSE"],