owlmem 0.4.0 → 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 +409 -17
  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,20 +18,27 @@
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.0";
31
- const BASE = (process.env.OWLMEM_URL || "https://app.owlmem.com").replace(/\/$/, "");
40
+ export const VERSION = "0.6.0";
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();
34
44
  const say = (...a) => process.stderr.write(a.join(" ") + "\n");
@@ -111,6 +121,43 @@ export async function sealForMachine(pubkey, rawKey, space) {
111
121
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(space) }, await unlockAesKey(eph.privateKey, pub, space, "encrypt"), rawKey);
112
122
  return { epk: b64(new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey))), nonce: b64(iv), ct: b64(new Uint8Array(ct)) };
113
123
  }
124
+ // ── passkey unlock (browser only: navigator.credentials) ──
125
+ // A passkey's PRF extension returns 32 bytes only that passkey can compute (the server never sees them). They're stretched (HKDF) into a
126
+ // wrapping key that seals the vault key; OwlMem stores only the sealed box (vault_key_wraps). The page that calls these runs on owlmem.com.
127
+ const b64url = (u8) => b64(u8).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
128
+ const unb64url = (s) => { s = String(s).replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; return unb64(s); };
129
+ async function prfWrapKey(prf) {
130
+ const k = await crypto.subtle.importKey("raw", prf, "HKDF", false, ["deriveKey"]);
131
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: enc.encode("owlmem-vault-wrap-v1") }, k, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
132
+ }
133
+ const wrapAad = (space, user, credId) => enc.encode(space + "\n" + user + "\n" + credId);
134
+ // challenge(可选):服务器发的先验挑战。给了的话这一次刷 Passkey 同时就是「验明正身」——返回的 credential 交给服务器验签(10-24:导出要一步到位)
135
+ export async function passkeyPrf(creds, challenge) { // creds: [{cred_id, salt}] → {cred_id, prf, credential}
136
+ if (!globalThis.PublicKeyCredential) throw new Fail("This browser doesn't support passkeys.");
137
+ const map = Object.fromEntries(creds.map((c) => [c.cred_id, { first: unb64(c.salt) }]));
138
+ const prf = creds.length === 1 ? { eval: { first: unb64(creds[0].salt) } } : { evalByCredential: map };
139
+ const cred = await navigator.credentials.get({ publicKey: { challenge: challenge ? unb64url(challenge) : crypto.getRandomValues(new Uint8Array(32)), rpId: location.hostname, userVerification: "required",
140
+ timeout: 60000, allowCredentials: creds.map((c) => ({ type: "public-key", id: unb64url(c.cred_id) })), extensions: { prf } } });
141
+ const out = cred.getClientExtensionResults()?.prf?.results?.first;
142
+ if (!out) throw new Fail("This passkey can't unlock the vault (its device doesn't support the PRF feature). Use another passkey or the recovery code.");
143
+ const u = (b) => b64url(new Uint8Array(b));
144
+ return { cred_id: u(cred.rawId), prf: out, credential: { id: cred.id, type: cred.type, response: { clientDataJSON: u(cred.response.clientDataJSON),
145
+ authenticatorData: u(cred.response.authenticatorData), signature: u(cred.response.signature) } } };
146
+ }
147
+ export async function wrapVaultKey(prf, rawKey, space, user, credId) {
148
+ const iv = crypto.getRandomValues(new Uint8Array(12));
149
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: wrapAad(space, user, credId) }, await prfWrapKey(prf), rawKey);
150
+ return { nonce: b64(iv), wrapped: b64(new Uint8Array(ct)) };
151
+ }
152
+ export async function unlockWithPasskey(wraps, space, user, keyCheck, challenge) { // → {raw, cred_id, credential}
153
+ const r = await passkeyPrf(wraps, challenge);
154
+ const w = wraps.find((x) => x.cred_id === r.cred_id);
155
+ const raw = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(w.nonce), additionalData: wrapAad(space, user, w.cred_id) }, await prfWrapKey(r.prf), unb64(w.wrapped)));
156
+ if (!(await verifyKeyCheck(raw, space, keyCheck))) throw new Fail("Passkey unlock is out of date (the vault was reset). Use the recovery code and set the passkey up again.");
157
+ return { raw, cred_id: w.cred_id, credential: r.credential };
158
+ }
159
+ export function newSalt() { return b64(crypto.getRandomValues(new Uint8Array(32))); }
160
+
114
161
  export async function openFromBrowser(privateKey, space, box) {
115
162
  const epk = await crypto.subtle.importKey("raw", unb64(box.epk), ECDH, false, []);
116
163
  const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(box.nonce), additionalData: enc.encode(space) }, await unlockAesKey(privateKey, epk, space, "decrypt"), unb64(box.ct));
@@ -132,11 +179,42 @@ function spaceKey(space) {
132
179
  return unb64(readFileSync(p, "utf8").trim());
133
180
  }
134
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
+
135
212
  // ── http ──
136
- async function call(method, path, body) {
213
+ async function call(method, path, body, mode = "other") {
137
214
  let r;
138
215
  try {
139
- 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 },
140
218
  body: body === undefined ? undefined : JSON.stringify(body) });
141
219
  } catch (e) { throw new Fail("Can't reach OwlMem (" + BASE + "): " + e.message); }
142
220
  const data = await r.json().catch(() => ({}));
@@ -144,10 +222,10 @@ async function call(method, path, body) {
144
222
  }
145
223
  // Ask, and if the owner has to approve, wait for it (polling every 3 s, up to 5 minutes), then ask again.
146
224
  let WAIT_MS = 5 * 60 * 1000; // 命令行等 5 分钟;MCP 里只等 30 秒,免得 AI 客户端的工具调用超时
147
- async function approved(method, path, body, kind = "access") {
225
+ async function approved(method, path, body, kind = "access", mode = "other") {
148
226
  let extra = "";
149
227
  for (let round = 0; round < 3; round++) {
150
- const r = await call(method, path + extra, body);
228
+ const r = await call(method, path + extra, body, mode);
151
229
  if (r.status < 400) return r.data;
152
230
  if (r.data.error !== "approval_required") throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
153
231
  const id = r.data.request;
@@ -159,7 +237,7 @@ async function approved(method, path, body, kind = "access") {
159
237
  st = (await call("GET", "requests/" + id)).data.status;
160
238
  if (st !== "pending") break;
161
239
  }
162
- 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).`);
163
241
  if (st !== "approved") throw new Fail(st === "denied" ? "The request was denied." : "The request expired before it was approved.");
164
242
  say("Approved.");
165
243
  if (kind !== "access") extra = (path.includes("?") ? "&" : "?") + "request=" + encodeURIComponent(id);
@@ -274,6 +352,21 @@ export function guessCSV(text) {
274
352
  }
275
353
  // <<< vault core
276
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
+
277
370
  // ── unlock via the console ──
278
371
  async function unlockInBrowser(space) {
279
372
  const kp = await unlockKeypair();
@@ -281,7 +374,7 @@ async function unlockInBrowser(space) {
281
374
  if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
282
375
  const fp = await unlockFingerprint(kp.pubkey);
283
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)…");
284
- 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);
285
378
  const wait = Number(process.env.OWLMEM_POLL_MS || 2000), until = Date.now() + 10 * 60 * 1000;
286
379
  while (Date.now() < until) {
287
380
  await new Promise((res) => setTimeout(res, wait));
@@ -313,9 +406,9 @@ function ask(question, hidden = true) {
313
406
 
314
407
  // ── commands ──
315
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; }
316
- async function fetchItem(name) {
409
+ async function fetchItem(name, mode = "view") {
317
410
  const sp = await space(); const raw = spaceKey(sp.space);
318
- const it = await approved("GET", itemPath(name));
411
+ const it = await approved("GET", itemPath(name), undefined, "access", mode);
319
412
  return open(raw, sp.space, name, it.nonce, it.ciphertext);
320
413
  }
321
414
  function pick(body, field) {
@@ -329,7 +422,7 @@ function pick(body, field) {
329
422
  const MCP_TOOLS = [
330
423
  { name: "vault_list", description: "List the credentials in the team vault (names and descriptions only; no secrets).", inputSchema: { type: "object", properties: {} } },
331
424
  { name: "vault_run",
332
- 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.",
333
426
  inputSchema: { type: "object", properties: {
334
427
  command: { type: "string", description: "Shell command to run (sh -c)" },
335
428
  env: { type: "object", description: 'Map of ENV_VAR to "item" or "item:field", e.g. {"DB_PASSWORD": "prod/db:password"}', additionalProperties: { type: "string" } },
@@ -359,7 +452,7 @@ async function mcpCall(name, a) {
359
452
  for (const [k, ref] of Object.entries(a.env || {})) {
360
453
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Fail("Bad variable name " + k);
361
454
  const [item, field] = String(ref).split(":");
362
- 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);
363
456
  }
364
457
  const mask = (t) => secrets.sort((x, y) => y.length - x.length).reduce((acc, v) => acc.split(v).join("****"), t);
365
458
  const out = await new Promise((res) => {
@@ -397,10 +490,309 @@ async function mcpServe() {
397
490
  await new Promise((res) => process.stdin.on("end", res));
398
491
  }
399
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
+
400
782
  export async function main(argv) {
401
783
  const [cmd, sub, ...rest] = argv;
402
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; }
403
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
+ }
404
796
  if (cmd === "login") {
405
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, "");
406
798
  if (!/^om_[A-Za-z0-9_-]{20,80}$/.test(t)) throw new Fail("That doesn't look like an OwlMem agent token (om_…).");
@@ -418,13 +810,13 @@ export async function main(argv) {
418
810
  if (opts[i] !== "--env") throw new Fail("Unknown option " + opts[i]);
419
811
  const m = /^([A-Za-z_][A-Za-z0-9_]*)=([^:]+)(?::(.+))?$/.exec(opts[++i] || "");
420
812
  if (!m) throw new Fail("Use --env VAR=item or --env VAR=item:field");
421
- env[m[1]] = pick(await fetchItem(m[2]), m[3] || (undefined));
813
+ env[m[1]] = pick(await fetchItem(m[2], "run"), m[3] || (undefined));
422
814
  }
423
815
  const child = spawn(command[0], command.slice(1), { stdio: "inherit", env });
424
816
  await new Promise((res) => child.on("exit", (code, sig) => { process.exitCode = sig ? 1 : code; res(); }));
425
817
  return;
426
818
  }
427
- 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; }
428
820
  const args = [...rest];
429
821
  if (sub === "keygen") {
430
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.0",
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"],