owlmem 0.0.0-stage → 0.3.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -2
  3. package/owlmem.mjs +435 -0
  4. package/package.json +10 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OwlMem
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # owlmem
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command line for [OwlMem](https://owlmem.com) — shared memory and an end-to-end encrypted vault for your AI agents.
4
+
5
+ ```sh
6
+ npx owlmem login # paste the agent's token from the OwlMem console
7
+ npx owlmem vault unlock # paste your recovery code, once per machine
8
+ npx owlmem vault ls
9
+ npx owlmem run --env DB_PASSWORD=prod/db:password -- ./deploy.sh
10
+ npx owlmem vault import bitwarden_export.json # also 1Password / KeePass CSV, .env
11
+ ```
12
+
13
+ Vault items are encrypted on your machine with a key only you hold; OwlMem stores ciphertext it cannot read.
14
+ Every read or write needs the space owner's approval in the console (passkey, authenticator app or GitHub).
15
+
16
+ Let your AI use the vault directly through a local MCP server:
17
+
18
+ ```sh
19
+ claude mcp add owlmem-vault -- npx -y owlmem mcp
20
+ ```
21
+
22
+ Tools: `vault_list`, `vault_run` (secrets are injected into the command and masked in its output), `vault_get`, `vault_put`.
23
+
24
+ Requires Node 18+. No dependencies.
package/owlmem.mjs ADDED
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+ // owlmem — OwlMem command line. Zero dependencies (Node 18+ WebCrypto).
3
+ //
4
+ // The vault is end-to-end encrypted: items are encrypted here with your space key (AES-256-GCM) and the
5
+ // server only ever sees ciphertext. Your space key comes from the recovery code shown once when the vault
6
+ // was set up in the console; `owlmem vault unlock` stores it on this machine (0600).
7
+ //
8
+ // owlmem login save this agent's token (from the console)
9
+ // owlmem vault unlock paste the recovery code once per machine
10
+ // owlmem vault ls
11
+ // owlmem vault get <name> [field] prints the item (or one field) to stdout
12
+ // owlmem vault put <name> [--desc text] < value-or-json
13
+ // owlmem vault rm <name>
14
+ // owlmem vault import <file> [--format bitwarden-json|bitwarden-csv|1password-csv|keepass-csv|env] [--prefix folder/]
15
+ // owlmem vault export [--format bitwarden-json|keepass-csv|env] > file
16
+ // owlmem run --env VAR=<name>[:field] [...] -- <command> [args] run a command with secrets in its environment
17
+ // owlmem vault keygen (setup without a browser) print a new recovery code and its key check
18
+ // owlmem mcp local MCP server for your AI (vault_list, vault_run, vault_get, vault_put)
19
+ // e.g. claude mcp add owlmem-vault -- node ~/owlmem.mjs mcp
20
+ //
21
+ // Reading, writing, deleting and exporting need the space owner's approval in the console; this program waits
22
+ // for it (up to 5 minutes). Secrets go to stdout only; messages go to stderr.
23
+
24
+ import { webcrypto as crypto } from "node:crypto";
25
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, dirname } from "node:path";
28
+ import { spawn } from "node:child_process";
29
+
30
+ export const VERSION = "0.3.0";
31
+ const BASE = (process.env.OWLMEM_URL || "https://app.owlmem.com").replace(/\/$/, "");
32
+ const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
33
+ const enc = new TextEncoder(), dec = new TextDecoder();
34
+ const say = (...a) => process.stderr.write(a.join(" ") + "\n");
35
+ class Fail extends Error {}
36
+
37
+ // ── encoding ──
38
+ const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
39
+ export function b32encode(bytes) {
40
+ let bits = 0, val = 0, out = "";
41
+ for (const b of bytes) { val = (val << 8) | b; bits += 8; while (bits >= 5) { out += B32[(val >>> (bits - 5)) & 31]; bits -= 5; } }
42
+ if (bits > 0) out += B32[(val << (5 - bits)) & 31];
43
+ return out;
44
+ }
45
+ export function b32decode(s) {
46
+ let bits = 0, val = 0; const out = [];
47
+ for (const ch of s) { const i = B32.indexOf(ch); if (i < 0) throw new Fail("That recovery code has a character that doesn't belong in it."); val = (val << 5) | i; bits += 5; if (bits >= 8) { out.push((val >>> (bits - 8)) & 255); bits -= 8; } }
48
+ return new Uint8Array(out);
49
+ }
50
+ const b64 = (u8) => Buffer.from(u8).toString("base64");
51
+ const unb64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
52
+
53
+ // ── keys ──
54
+ export function recoveryCode(key) { return "OWL-" + b32encode(key).match(/.{1,4}/g).join("-"); }
55
+ export function parseRecovery(code) {
56
+ // 终端「括号粘贴」会在前后加 ESC[200~ / ESC[201~,里面的 2、0 会被当成 base32 字符(10-23 站长解锁失败):先去掉控制序列;
57
+ // 再只取 OWL- 后面那一段,前后多粘了别的也认得出
58
+ let s = String(code || "").replace(/\x1b\[[0-9;]*[~A-Za-z]/g, "").replace(/[\x00-\x1f\x7f]/g, "").toUpperCase();
59
+ const m = /OWL[-\s]*((?:[A-Z2-7]{4}[-\s]*){12}[A-Z2-7]{4})/.exec(s);
60
+ s = (m ? m[1] : s.replace(/^\s*OWL/, "")).replace(/[^A-Z2-7]/g, "");
61
+ if (s.length !== 52) throw new Fail(`That doesn't look like a complete recovery code: got ${s.length} code characters, a recovery code has 52 after "OWL-". Copy the whole code and try again.`);
62
+ const key = b32decode(s);
63
+ if (key.length !== 32) throw new Fail("That doesn't look like a complete recovery code.");
64
+ return key;
65
+ }
66
+ const aesKey = (raw) => crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
67
+ // AAD binds a ciphertext to its space and item name, so a stored ciphertext can't be moved to another name.
68
+ const aad = (space, name) => enc.encode(space + "\n" + name);
69
+ export async function seal(raw, space, name, obj) {
70
+ const iv = crypto.getRandomValues(new Uint8Array(12));
71
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad(space, name) }, await aesKey(raw), enc.encode(JSON.stringify(obj)));
72
+ return { nonce: b64(iv), ciphertext: b64(new Uint8Array(ct)) };
73
+ }
74
+ export async function open(raw, space, name, nonce, ciphertext) {
75
+ try {
76
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(nonce), additionalData: aad(space, name) }, await aesKey(raw), unb64(ciphertext));
77
+ return JSON.parse(dec.decode(pt));
78
+ } catch { throw new Fail(`Can't decrypt "${name}": wrong space key, or the item was tampered with.`); }
79
+ }
80
+ const KEY_CHECK = "!key-check";
81
+ export async function keyCheck(raw, space) { const s = await seal(raw, space, KEY_CHECK, { owlmem: "key-check-v1" }); return s.nonce + "." + s.ciphertext; }
82
+ export async function verifyKeyCheck(raw, space, kc) {
83
+ const [n, c] = String(kc).split(".");
84
+ try { return (await open(raw, space, KEY_CHECK, n, c)).owlmem === "key-check-v1"; } catch { return false; }
85
+ }
86
+
87
+ // ── config ──
88
+ function cfg() { try { return JSON.parse(readFileSync(join(DIR, "config.json"), "utf8")); } catch { return {}; } }
89
+ function saveFile(path, text) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, text, { mode: 0o600 }); chmodSync(path, 0o600); }
90
+ function token() {
91
+ const t = process.env.OWLMEM_TOKEN || cfg().token;
92
+ if (!t) throw new Fail("No token. Create one for this agent in the console (AI agents & tokens), then run: owlmem login");
93
+ return t;
94
+ }
95
+ function spaceKey(space) {
96
+ const p = join(DIR, "keys", space + ".key");
97
+ if (!existsSync(p)) throw new Fail("This machine doesn't have the space key yet. Run: owlmem vault unlock");
98
+ return unb64(readFileSync(p, "utf8").trim());
99
+ }
100
+
101
+ // ── http ──
102
+ async function call(method, path, body) {
103
+ let r;
104
+ try {
105
+ r = await fetch(BASE + "/v1/vault/" + path, { method, headers: { Authorization: "Bearer " + token(), "Content-Type": "application/json", "User-Agent": "owlmem-cli/" + VERSION },
106
+ body: body === undefined ? undefined : JSON.stringify(body) });
107
+ } catch (e) { throw new Fail("Can't reach OwlMem (" + BASE + "): " + e.message); }
108
+ const data = await r.json().catch(() => ({}));
109
+ return { status: r.status, data };
110
+ }
111
+ // Ask, and if the owner has to approve, wait for it (polling every 3 s, up to 5 minutes), then ask again.
112
+ let WAIT_MS = 5 * 60 * 1000; // 命令行等 5 分钟;MCP 里只等 30 秒,免得 AI 客户端的工具调用超时
113
+ async function approved(method, path, body, kind = "access") {
114
+ let extra = "";
115
+ for (let round = 0; round < 3; round++) {
116
+ const r = await call(method, path + extra, body);
117
+ if (r.status < 400) return r.data;
118
+ if (r.data.error !== "approval_required") throw new Fail(r.data.detail || r.data.error || "HTTP " + r.status);
119
+ const id = r.data.request;
120
+ say(`Waiting for approval in the OwlMem console (request ${id}${kind === "access" ? "" : ", " + kind}) …`);
121
+ const until = Date.now() + WAIT_MS;
122
+ let st = "pending";
123
+ while (Date.now() < until) {
124
+ await new Promise((res) => setTimeout(res, Number(process.env.OWLMEM_POLL_MS || 3000)));
125
+ st = (await call("GET", "requests/" + id)).data.status;
126
+ if (st !== "pending") break;
127
+ }
128
+ 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.`);
129
+ if (st !== "approved") throw new Fail(st === "denied" ? "The request was denied." : "The request expired before it was approved.");
130
+ say("Approved.");
131
+ if (kind !== "access") extra = (path.includes("?") ? "&" : "?") + "request=" + encodeURIComponent(id);
132
+ }
133
+ throw new Fail("Still not allowed after approval; try again.");
134
+ }
135
+ async function space() { const r = await call("GET", "key-check"); if (r.status >= 400) throw new Fail(r.data.detail || r.data.error); return r.data; }
136
+ const itemPath = (name) => "items/" + name.split("/").map(encodeURIComponent).join("/");
137
+
138
+ // ── import / export formats ──
139
+ export function parseCSV(text) {
140
+ const rows = []; let row = [], f = "", q = false;
141
+ for (let i = 0; i < text.length; i++) {
142
+ const c = text[i];
143
+ if (q) { if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; } else f += c; }
144
+ else if (c === '"') q = true;
145
+ else if (c === ",") { row.push(f); f = ""; }
146
+ else if (c === "\n" || c === "\r") { if (c === "\r" && text[i + 1] === "\n") i++; row.push(f); rows.push(row); row = []; f = ""; }
147
+ else f += c;
148
+ }
149
+ if (f !== "" || row.length) { row.push(f); rows.push(row); }
150
+ const [head, ...rest] = rows.filter((r) => r.some((x) => x !== ""));
151
+ return (rest || []).map((r) => Object.fromEntries((head || []).map((h, i) => [h.trim(), r[i] ?? ""])));
152
+ }
153
+ const csvCell = (v) => /[",\n\r]/.test(String(v ?? "")) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v ?? "");
154
+ export function slug(s) { return String(s || "item").toLowerCase().normalize("NFKD").replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 90) || "item"; }
155
+ const BW_TYPES = { 1: "login", 2: "note", 3: "card", 4: "identity" };
156
+
157
+ export function fromBitwardenJSON(text) {
158
+ const j = JSON.parse(text);
159
+ if (j.encrypted) throw new Fail("That is an encrypted Bitwarden export. Export again as JSON without a password (the unencrypted format).");
160
+ return (j.items || []).map((it) => {
161
+ const body = { type: BW_TYPES[it.type] || "note", title: it.name || "", notes: it.notes || "" };
162
+ if (it.login) Object.assign(body, { username: it.login.username || "", password: it.login.password || "", totp: it.login.totp || "", uris: (it.login.uris || []).map((u) => u.uri).filter(Boolean) });
163
+ if (it.card) body.card = it.card;
164
+ if (it.identity) body.identity = it.identity;
165
+ if (it.fields?.length) body.fields = Object.fromEntries(it.fields.map((f) => [f.name, f.value]));
166
+ return { name: slug(it.name), description: it.login?.uris?.[0]?.uri || body.type, body };
167
+ });
168
+ }
169
+ export function fromBitwardenCSV(text) {
170
+ return parseCSV(text).map((r) => {
171
+ const fields = Object.fromEntries((r.fields || "").split("\n").filter(Boolean).map((l) => { const i = l.indexOf(": "); return i < 0 ? [l, ""] : [l.slice(0, i), l.slice(i + 2)]; }));
172
+ const body = { type: r.type === "note" ? "note" : "login", title: r.name || "", notes: r.notes || "", username: r.login_username || "", password: r.login_password || "",
173
+ totp: r.login_totp || "", uris: (r.login_uri || "").split(",").filter(Boolean) };
174
+ if (Object.keys(fields).length) body.fields = fields;
175
+ return { name: slug(r.name), description: r.login_uri || body.type, body };
176
+ });
177
+ }
178
+ export function fromEnv(text, prefix = "env/") {
179
+ return text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && l.includes("=")).map((l) => {
180
+ const i = l.indexOf("="); const key = l.slice(0, i).replace(/^export\s+/, "").trim();
181
+ let value = l.slice(i + 1).trim();
182
+ if (/^(['"]).*\1$/.test(value)) value = value.slice(1, -1);
183
+ return { name: slug(prefix + key), description: "env " + key, body: { type: "env", key, value } };
184
+ });
185
+ }
186
+ // 1Password CSV export (Title, Website/URL, Username, Password, One-time password/OTPAuth, Notes, plus any extra columns)
187
+ export function from1PasswordCSV(text) {
188
+ const pick = (r, ...ks) => { for (const k of ks) { const hit = Object.keys(r).find((x) => x.toLowerCase() === k.toLowerCase()); if (hit && r[hit]) return r[hit]; } return ""; };
189
+ const known = new Set(["title", "website", "url", "urls", "username", "password", "otpauth", "one-time password", "notes", "notesplain", "type", "tags"]);
190
+ return parseCSV(text).map((r) => {
191
+ const title = pick(r, "Title"); const url = pick(r, "Website", "URL", "URLs");
192
+ const fields = Object.fromEntries(Object.entries(r).filter(([k, v]) => v && !known.has(k.toLowerCase())));
193
+ const body = { type: pick(r, "Password", "Username") ? "login" : "note", title, notes: pick(r, "Notes", "notesPlain"), username: pick(r, "Username"),
194
+ password: pick(r, "Password"), totp: pick(r, "OTPAuth", "One-time password"), uris: url ? [url] : [] };
195
+ if (Object.keys(fields).length) body.fields = fields;
196
+ return { name: slug(title), description: url || body.type, body };
197
+ });
198
+ }
199
+ // KeePass 2 / KeePassXC CSV export ("Group","Title","Username","Password","URL","Notes","TOTP",…)
200
+ export function fromKeePassCSV(text) {
201
+ return parseCSV(text).map((r) => {
202
+ const group = (r.Group || r.Account || "").replace(/^Root\/?/i, "");
203
+ const body = { type: "login", title: r.Title || "", notes: r.Notes || "", username: r.Username || r["Login Name"] || "", password: r.Password || "",
204
+ totp: r.TOTP || "", uris: (r.URL || r["Web Site"] || "") ? [r.URL || r["Web Site"]] : [] };
205
+ if (group) body.group = group;
206
+ return { name: slug((group ? group + "/" : "") + (r.Title || "item")), description: body.uris[0] || "login", body };
207
+ });
208
+ }
209
+ export function toKeePassCSV(items) {
210
+ const head = ["Group", "Title", "Username", "Password", "URL", "Notes", "TOTP"];
211
+ const rows = items.map(({ name, body: b }) => [b.group || name.split("/").slice(0, -1).join("/"), b.title || name.split("/").pop(), b.username || b.key || "",
212
+ b.password ?? (b.type === "env" || b.type === "secret" ? String(b.value ?? "") : ""), (b.uris || [])[0] || "", b.notes || "", b.totp || ""]);
213
+ return [head, ...rows].map((r) => r.map(csvCell).join(",")).join("\n") + "\n";
214
+ }
215
+ export function toBitwardenJSON(items) {
216
+ const T = { login: 1, note: 2, card: 3, identity: 4 };
217
+ return JSON.stringify({ encrypted: false, folders: [], items: items.map(({ name, body: b }) => {
218
+ const it = { id: crypto.randomUUID(), organizationId: null, folderId: null, type: T[b.type] || 2, name: b.title || name, notes: b.notes || (b.type === "env" || b.type === "secret" ? String(b.value ?? "") : ""),
219
+ favorite: false, fields: Object.entries(b.fields || {}).map(([k, v]) => ({ name: k, value: v, type: 0 })) };
220
+ if (b.type === "env") it.fields.push({ name: b.key, value: b.value, type: 1 });
221
+ if (it.type === 1) it.login = { username: b.username || "", password: b.password || "", totp: b.totp || null, uris: (b.uris || []).map((u) => ({ match: null, uri: u })) };
222
+ if (it.type === 2) it.secureNote = { type: 0 };
223
+ if (b.card) it.card = b.card;
224
+ if (b.identity) it.identity = b.identity;
225
+ return it;
226
+ }) }, null, 2);
227
+ }
228
+ export function toEnv(items) {
229
+ return items.filter(({ body }) => body.type === "env" || body.type === "secret")
230
+ .map(({ name, body }) => `${body.key || name.split("/").pop().toUpperCase().replace(/[^A-Z0-9_]/g, "_")}=${JSON.stringify(String(body.value ?? ""))}`).join("\n") + "\n";
231
+ }
232
+
233
+ // which CSV is this? by its header row
234
+ export function guessCSV(text) {
235
+ const head = text.split(/\r?\n/, 1)[0].toLowerCase();
236
+ if (head.includes("login_username") || head.includes("login_uri")) return "bitwarden-csv";
237
+ if (head.includes('"group"') || head.startsWith("group,") || head.includes("last modified")) return "keepass-csv";
238
+ return "1password-csv";
239
+ }
240
+
241
+ // ── prompts ──
242
+ function readStdin() { return new Promise((res) => { let s = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (s += d)); process.stdin.on("end", () => res(s)); }); }
243
+ function ask(question, hidden = true) {
244
+ if (!process.stdin.isTTY) return readStdin().then((s) => s.split("\n")[0].trim());
245
+ return new Promise((res) => {
246
+ process.stderr.write(question);
247
+ const stdin = process.stdin; let s = "";
248
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding("utf8");
249
+ const on = (ch) => {
250
+ if (ch === "\r" || ch === "\n" || ch === "\u0004") { stdin.setRawMode(false); stdin.pause(); stdin.off("data", on); process.stderr.write("\n"); res(s.trim()); }
251
+ else if (ch === "\u0003") { process.stderr.write("\n"); process.exit(130); }
252
+ else if (ch === "\u007f" || ch === "\b") { if (s) { s = s.slice(0, -1); process.stderr.write("\b \b"); } }
253
+ else { for (const c of ch) { if (c < " ") continue; s += c; process.stderr.write(hidden ? "•" : c); } } // 粘贴时整段一起来;隐藏输入也给个「•」,免得以为卡住了
254
+ };
255
+ stdin.on("data", on);
256
+ });
257
+ }
258
+
259
+ // ── commands ──
260
+ 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; }
261
+ async function fetchItem(name) {
262
+ const sp = await space(); const raw = spaceKey(sp.space);
263
+ const it = await approved("GET", itemPath(name));
264
+ return open(raw, sp.space, name, it.nonce, it.ciphertext);
265
+ }
266
+ function pick(body, field) {
267
+ if (!field) return body.type === "env" || body.type === "secret" ? String(body.value ?? "") : JSON.stringify(body, null, 2);
268
+ const v = body[field] ?? body.fields?.[field];
269
+ if (v === undefined) throw new Fail(`No field "${field}". Fields: ${[...Object.keys(body), ...Object.keys(body.fields || {})].join(", ")}`);
270
+ return typeof v === "string" ? v : JSON.stringify(v);
271
+ }
272
+
273
+ // ── local MCP server (stdio): lets Claude Code, Codex, Cursor… use the vault. Decryption happens here, on this machine. ──
274
+ const MCP_TOOLS = [
275
+ { name: "vault_list", description: "List the credentials in the team vault (names and descriptions only; no secrets).", inputSchema: { type: "object", properties: {} } },
276
+ { name: "vault_run",
277
+ 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).",
278
+ inputSchema: { type: "object", properties: {
279
+ command: { type: "string", description: "Shell command to run (sh -c)" },
280
+ env: { type: "object", description: 'Map of ENV_VAR to "item" or "item:field", e.g. {"DB_PASSWORD": "prod/db:password"}', additionalProperties: { type: "string" } },
281
+ cwd: { type: "string", description: "Working directory (optional)" },
282
+ timeout_seconds: { type: "integer", description: "Kill the command after this many seconds (default 600)" } }, required: ["command", "env"] } },
283
+ { name: "vault_get",
284
+ description: "Read one credential and return it IN PLAIN TEXT into this conversation. Only use it when a secret must be shown or pasted somewhere; to use a secret in a command, call vault_run instead. Needs the owner's approval.",
285
+ inputSchema: { type: "object", properties: { name: { type: "string" }, field: { type: "string", description: "Optional field, e.g. password or username" } }, required: ["name"] } },
286
+ { name: "vault_put",
287
+ description: "Store a credential in the team vault (encrypted on this machine before it is sent). value is a string, or a JSON object such as {\"type\":\"login\",\"username\":\"…\",\"password\":\"…\"}. Needs the owner's approval.",
288
+ inputSchema: { type: "object", properties: { name: { type: "string", description: "lowercase, e.g. prod/db or env/api_key" }, value: { description: "string or object" }, description: { type: "string" } }, required: ["name", "value"] } },
289
+ ];
290
+ async function mcpCall(name, a) {
291
+ if (name === "vault_list") {
292
+ const r = await call("GET", "items"); if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
293
+ return r.data.items.length ? r.data.items.map((i) => `${i.name} — ${i.description || ""}`).join("\n") : "The vault is empty.";
294
+ }
295
+ if (name === "vault_get") return pick(await fetchItem(String(a.name || "")), a.field ? String(a.field) : undefined);
296
+ if (name === "vault_put") {
297
+ const nm = String(a.name || ""); const body = typeof a.value === "object" && a.value ? a.value : { type: "secret", value: String(a.value ?? "") };
298
+ const sp = await space(); const raw = spaceKey(sp.space);
299
+ await approved("PUT", itemPath(nm), { description: String(a.description || ""), ...(await seal(raw, sp.space, nm, body)) });
300
+ return "Saved " + nm + ".";
301
+ }
302
+ if (name === "vault_run") {
303
+ const env = { ...process.env }, secrets = [];
304
+ for (const [k, ref] of Object.entries(a.env || {})) {
305
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Fail("Bad variable name " + k);
306
+ const [item, field] = String(ref).split(":");
307
+ const v = pick(await fetchItem(item), field); env[k] = v; if (v && v.length >= 4) secrets.push(v);
308
+ }
309
+ const mask = (t) => secrets.sort((x, y) => y.length - x.length).reduce((acc, v) => acc.split(v).join("****"), t);
310
+ const out = await new Promise((res) => {
311
+ const ch = spawn("sh", ["-c", String(a.command || "")], { env, cwd: a.cwd || process.cwd(), stdio: ["ignore", "pipe", "pipe"] });
312
+ let o = "", e = ""; const cap = 64 * 1024;
313
+ ch.stdout.on("data", (d) => { if (o.length < cap) o += d; }); ch.stderr.on("data", (d) => { if (e.length < cap) e += d; });
314
+ const timer = setTimeout(() => ch.kill("SIGKILL"), 1000 * Number(a.timeout_seconds || 600));
315
+ ch.on("close", (code, sig) => { clearTimeout(timer); res({ code, sig, o, e }); });
316
+ });
317
+ return `exit code: ${out.sig ? "killed (" + out.sig + ")" : out.code}\n--- stdout ---\n${mask(out.o)}${out.e ? "\n--- stderr ---\n" + mask(out.e) : ""}`;
318
+ }
319
+ throw new Fail("Unknown tool " + name);
320
+ }
321
+ async function mcpServe() {
322
+ WAIT_MS = Number(process.env.OWLMEM_MCP_WAIT_MS || 30000);
323
+ const reply = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
324
+ let buf = "";
325
+ process.stdin.setEncoding("utf8");
326
+ process.stdin.on("data", async (chunk) => {
327
+ buf += chunk; let i;
328
+ while ((i = buf.indexOf("\n")) >= 0) {
329
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line) continue;
330
+ let req; try { req = JSON.parse(line); } catch { reply({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }); continue; }
331
+ const { id, method, params = {} } = req;
332
+ if (id === undefined || id === null) continue; // notifications
333
+ if (method === "initialize") reply({ jsonrpc: "2.0", id, result: { protocolVersion: params.protocolVersion || "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "owlmem-vault", version: VERSION } } });
334
+ else if (method === "ping") reply({ jsonrpc: "2.0", id, result: {} });
335
+ else if (method === "tools/list") reply({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } });
336
+ else if (method === "tools/call") {
337
+ try { reply({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: await mcpCall(params.name, params.arguments || {}) }], isError: false } }); }
338
+ catch (e) { reply({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: e instanceof Fail ? e.message : "Error: " + (e?.message || e) }], isError: true } }); }
339
+ } else reply({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found: " + method } });
340
+ }
341
+ });
342
+ await new Promise((res) => process.stdin.on("end", res));
343
+ }
344
+
345
+ export async function main(argv) {
346
+ const [cmd, sub, ...rest] = argv;
347
+ if (cmd === "--version" || cmd === "version") { console.log(VERSION); return; }
348
+ if (cmd === "mcp") { await mcpServe(); return; }
349
+ if (cmd === "login") {
350
+ 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, "");
351
+ if (!/^om_[A-Za-z0-9_-]{20,80}$/.test(t)) throw new Fail("That doesn't look like an OwlMem agent token (om_…).");
352
+ process.env.OWLMEM_TOKEN = t;
353
+ const r = await call("GET", "key-check");
354
+ if (r.status === 401) throw new Fail("The server didn't accept that token.");
355
+ saveFile(join(DIR, "config.json"), JSON.stringify({ ...cfg(), token: t }, null, 2));
356
+ say("Saved. " + (r.status === 404 ? "The vault isn't set up for this space yet (do that in the console)." : "Next: owlmem vault unlock"));
357
+ return;
358
+ }
359
+ if (cmd === "run") {
360
+ const dash = argv.indexOf("--"); if (dash < 0 || dash === argv.length - 1) throw new Fail("Usage: owlmem run --env VAR=item[:field] ... -- command [args]");
361
+ const opts = argv.slice(1, dash), command = argv.slice(dash + 1), env = { ...process.env };
362
+ for (let i = 0; i < opts.length; i++) {
363
+ if (opts[i] !== "--env") throw new Fail("Unknown option " + opts[i]);
364
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=([^:]+)(?::(.+))?$/.exec(opts[++i] || "");
365
+ if (!m) throw new Fail("Use --env VAR=item or --env VAR=item:field");
366
+ env[m[1]] = pick(await fetchItem(m[2]), m[3] || (undefined));
367
+ }
368
+ const child = spawn(command[0], command.slice(1), { stdio: "inherit", env });
369
+ await new Promise((res) => child.on("exit", (code, sig) => { process.exitCode = sig ? 1 : code; res(); }));
370
+ return;
371
+ }
372
+ 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; }
373
+ const args = [...rest];
374
+ if (sub === "keygen") {
375
+ const sp = process.env.OWLMEM_SPACE || (await space().catch(() => ({}))).space;
376
+ if (!sp) throw new Fail("Set OWLMEM_SPACE to the space id (shown in the console).");
377
+ const raw = crypto.getRandomValues(new Uint8Array(32));
378
+ console.log(JSON.stringify({ space: sp, recovery_code: recoveryCode(raw), key_check: await keyCheck(raw, sp) }, null, 2));
379
+ return;
380
+ }
381
+ if (sub === "unlock") {
382
+ const sp = await space();
383
+ const raw = parseRecovery(await ask("Paste your recovery code (OWL-…, shown as dots), then Enter: "));
384
+ if (!(await verifyKeyCheck(raw, sp.space, sp.key_check))) throw new Fail("That recovery code doesn't match this space's vault.");
385
+ saveFile(join(DIR, "keys", sp.space + ".key"), b64(raw));
386
+ say("Unlocked: this machine can now decrypt the vault of space " + sp.space + ".");
387
+ return;
388
+ }
389
+ if (sub === "ls") {
390
+ const r = await call("GET", "items"); if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
391
+ for (const it of r.data.items) console.log(`${it.name.padEnd(36)} ${it.description}`);
392
+ return;
393
+ }
394
+ if (sub === "get") { const [name, field] = args; if (!name) throw new Fail("Usage: owlmem vault get <name> [field]"); process.stdout.write(pick(await fetchItem(name), field) + (process.stdout.isTTY ? "\n" : "")); return; }
395
+ if (sub === "put") {
396
+ const desc = flag(args, "--desc", ""), [name] = args; if (!name) throw new Fail("Usage: owlmem vault put <name> [--desc text] < value-or-json");
397
+ const text = (await readStdin()).replace(/\n$/, ""); let body;
398
+ try { body = JSON.parse(text); if (typeof body !== "object" || Array.isArray(body) || !body) throw 0; } catch { body = { type: "secret", value: text }; }
399
+ const sp = await space(); const raw = spaceKey(sp.space);
400
+ await approved("PUT", itemPath(name), { description: desc, ...(await seal(raw, sp.space, name, body)) });
401
+ say("Saved " + name + ".");
402
+ return;
403
+ }
404
+ if (sub === "rm") { const [name] = args; if (!name) throw new Fail("Usage: owlmem vault rm <name>"); await approved("DELETE", itemPath(name), undefined, "delete"); say("Deleted " + name + " (it stays in the vault history)."); return; }
405
+ if (sub === "import") {
406
+ const format = flag(args, "--format", null), prefix = flag(args, "--prefix", ""), [file] = args; if (!file) throw new Fail("Usage: owlmem vault import <file> [--format …] [--prefix folder/]");
407
+ const text = readFileSync(file, "utf8");
408
+ const fmt = format || (file.endsWith(".json") ? "bitwarden-json" : file.endsWith(".csv") ? guessCSV(text) : "env");
409
+ const items = fmt === "bitwarden-json" ? fromBitwardenJSON(text) : fmt === "bitwarden-csv" ? fromBitwardenCSV(text) : fmt === "1password-csv" ? from1PasswordCSV(text)
410
+ : fmt === "keepass-csv" ? fromKeePassCSV(text) : fmt === "env" ? fromEnv(text, prefix || "env/") : null;
411
+ if (!items) throw new Fail("Formats: bitwarden-json, bitwarden-csv, 1password-csv, keepass-csv, env");
412
+ const sp = await space(); const raw = spaceKey(sp.space); const seen = new Set();
413
+ for (const it of items) {
414
+ let name = slug((fmt === "env" ? "" : prefix) + it.name), n = 2; while (seen.has(name)) name = slug(it.name) + "-" + n++; seen.add(name);
415
+ await approved("PUT", itemPath(name), { description: it.description, ...(await seal(raw, sp.space, name, it.body)) });
416
+ say(" imported " + name);
417
+ }
418
+ say(`Imported ${items.length} item(s).`);
419
+ return;
420
+ }
421
+ if (sub === "export") {
422
+ const format = flag(args, "--format", "bitwarden-json");
423
+ const sp = await space(); const raw = spaceKey(sp.space);
424
+ const data = await approved("GET", "export", undefined, "export");
425
+ const items = []; for (const it of data.items) items.push({ name: it.name, body: await open(raw, sp.space, it.name, it.nonce, it.ciphertext) });
426
+ process.stdout.write(format === "env" ? toEnv(items) : format === "keepass-csv" ? toKeePassCSV(items) : toBitwardenJSON(items));
427
+ say(`Exported ${items.length} item(s). This file is NOT encrypted — keep it safe and delete it when done.`);
428
+ return;
429
+ }
430
+ throw new Fail("Unknown command. Run owlmem without arguments for help.");
431
+ }
432
+
433
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/owlmem") || process.argv[1]?.endsWith("owlmem.mjs")) {
434
+ main(process.argv.slice(2)).catch((e) => { say(e instanceof Fail ? e.message : "Error: " + (e?.stack || e)); process.exitCode = 1; });
435
+ }
package/package.json CHANGED
@@ -1,6 +1,12 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "OwlMem command line: end-to-end encrypted vault for your AI agents, and a local MCP server.",
5
+ "type": "module",
6
+ "bin": { "owlmem": "owlmem.mjs" },
7
+ "files": ["owlmem.mjs", "README.md", "LICENSE"],
8
+ "engines": { "node": ">=18" },
9
+ "homepage": "https://owlmem.com",
10
+ "keywords": ["mcp", "ai-agents", "vault", "secrets", "claude", "codex", "cursor"],
11
+ "license": "MIT"
12
+ }