#!/usr/bin/env node // Pocka command line: put files and text on your Pocka shelf and take them, from any // machine with Node.js 18+. One file, no dependencies. https://pocka.app/cli import { webcrypto as crypto, randomUUID } from "node:crypto"; import { parseArgs } from "node:util"; import { chmod, mkdir, open, readFile, rename, rmdir, stat, unlink, writeFile } from "node:fs/promises"; import { existsSync, realpathSync, statSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { basename, dirname, extname, join, resolve } from "node:path"; const VERSION = "0.3.0"; const MB = 1024 * 1024; const CHUNK = 256 * 1024; const chunkSize = (size) => (size > 10 * MB ? 8 * MB : CHUNK); const ITERATIONS = 600000; const HELP = `pocka ${VERSION} — your Pocka shelf from the command line Usage: pocka login [--name NAME] [--no-wait] Connect this machine to your account pocka logout Sign this machine out pocka whoami Show the account, plan and limits pocka ls List what is on the shelf pocka send FILE... Put files on the shelf pocka send -t "TEXT" Put a text note on the shelf (-t - reads stdin) pocka send - --name NAME Put stdin on the shelf as a file pocka take [ITEM] [-o PATH] Take an item (newest if ITEM is left out) pocka take --all [-o DIR] Take everything, oldest first pocka rm ITEM Delete an item without taking it pocka skill install [--dir DIR] Teach AI agents (Claude Code, Codex) to use pocka pocka skill uninstall Remove the skill again pocka update Update this program ITEM is a number from \`pocka ls\` (1 = newest), the start of an item ID, or a file name. Options: -o, --output PATH Where to save: a folder, a file name, or - for stdout --keep Download but leave the item on the shelf --lock Encrypt with your personal key before upload --json Machine-readable output (one JSON object per line) -q, --quiet No progress or hints on stderr -h, --help Show this help -v, --version Show the version Environment: POCKA_KEY Personal key for locked items (otherwise asked for) POCKA_TOKEN Use this token instead of the saved login POCKA_SERVER Server URL (default https://pocka.app) The command line is part of Pocka Pro: https://pocka.app/pricing Exit codes: 0 ok, 1 error, 2 usage error, 3 nothing on the shelf, 4 not signed in, 5 the account isn't on Pro. `; class CliError extends Error { constructor(message, code = 1) { super(message); this.code = code; } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* ---------- arguments ---------- */ let parsed; try { parsed = parseArgs({ allowPositionals: true, options: { name: { type: "string" }, "no-wait": { type: "boolean" }, text: { type: "string", short: "t" }, output: { type: "string", short: "o" }, keep: { type: "boolean" }, all: { type: "boolean" }, lock: { type: "boolean" }, dir: { type: "string" }, json: { type: "boolean" }, quiet: { type: "boolean", short: "q" }, force: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, }, }); } catch (e) { process.stderr.write(`pocka: ${e.message}\nRun pocka --help for usage.\n`); process.exit(2); } const opts = parsed.values; const [command, ...args] = parsed.positionals; const jsonMode = !!opts.json; const quiet = !!opts.quiet; const tty = process.stderr.isTTY && !quiet && !jsonMode; const info = (s) => { if (!quiet) process.stderr.write(s + "\n"); }; const out = (obj, human) => { if (jsonMode) process.stdout.write(JSON.stringify(obj) + "\n"); else if (human !== undefined) process.stdout.write(human + "\n"); }; /* ---------- config ---------- */ const configDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "pocka"); const configFile = join(configDir, "config.json"); let config = {}; try { config = JSON.parse(await readFile(configFile, "utf8")); } catch {} async function saveConfig() { await mkdir(configDir, { recursive: true, mode: 0o700 }); await writeFile(configFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); await chmod(configFile, 0o600); } const server = (process.env.POCKA_SERVER || config.server || "https://pocka.app").replace(/\/+$/, ""); const token = () => process.env.POCKA_TOKEN || (config.server === server || !config.server ? config.token : ""); /* ---------- HTTP ---------- */ const userAgent = `pocka-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`; // Retries rate limits always, and network or server errors when the request is safe to repeat. async function call(path, { method = "GET", body, raw = false, headers = {}, auth = true, retry } = {}) { const repeatable = retry ?? (method === "GET" || method === "PUT"); for (let attempt = 0; ; attempt++) { const h = { "User-Agent": userAgent, ...headers }; if (auth) { const t = token(); if (!t) throw new CliError("Not signed in. Run: pocka login", 4); h.Authorization = `Bearer ${t}`; } let payload; if (body instanceof Uint8Array) { payload = body; h["Content-Type"] = "application/octet-stream"; } else if (body !== undefined) { payload = JSON.stringify(body); h["Content-Type"] = "application/json"; } let res; try { res = await fetch(server + "/api" + path, { method, headers: h, body: payload }); } catch (e) { if (repeatable && attempt < 5) { await sleep(800 * 2 ** attempt); continue; } throw new CliError(`Can't reach ${server}: ${e.cause?.code || e.message}`); } const wait = Number(res.headers.get("retry-after")) * 1000 || 2000 * 2 ** Math.min(attempt, 4); if ((res.status === 429 || (repeatable && res.status >= 500)) && attempt < 6) { await res.body?.cancel(); await sleep(wait); continue; } if (raw && res.ok) return { status: res.status, bytes: new Uint8Array(await res.arrayBuffer()) }; const text = await res.text(); let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { data = { error: res.ok ? "" : `Unexpected response (HTTP ${res.status}).` }; } if (res.status === 402) throw new CliError(data.error || "The command line is part of Pocka Pro.", 5); if (res.status === 401 && auth) { const message = data.error || "Not signed in."; throw new CliError(message.includes("pocka login") ? message : `${message} Run: pocka login`, 4); } return { status: res.status, data }; } } async function ok(path, init, accept = []) { const r = await call(path, init); if (r.status >= 400 && !accept.includes(r.status)) throw new CliError(r.data.error || `Request failed (HTTP ${r.status}).`); return r; } /* ---------- encryption (same format as the web app) ---------- */ const enc = new TextEncoder(); const b64 = (bytes) => Buffer.from(bytes).toString("base64"); const unb64 = (s) => new Uint8Array(Buffer.from(s, "base64")); const metaContext = (user, id) => `pocka:v1:${user}:${id}:meta`; const chunkContext = (user, id, index, size) => `pocka:v1:${user}:${id}:${index}:${size}`; const vaultContext = (user) => `pocka:v1:${user}:vault`; async function sealBytes(key, bytes, context) { const iv = crypto.getRandomValues(new Uint8Array(12)); const cipher = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(context), tagLength: 128 }, key, bytes), ); const result = new Uint8Array(12 + cipher.length); result.set(iv); result.set(cipher, 12); return result; } async function openBytes(key, value, context) { const bytes = typeof value === "string" ? unb64(value) : value; if (bytes.length < 28) throw new CliError("Damaged encrypted data."); return new Uint8Array( await crypto.subtle.decrypt( { name: "AES-GCM", iv: bytes.subarray(0, 12), additionalData: enc.encode(context), tagLength: 128 }, key, bytes.subarray(12), ), ); } async function askHidden(question) { if (!process.stdin.isTTY) throw new CliError("This item is locked. Set POCKA_KEY to your personal key."); process.stderr.write(question); const stdin = process.stdin; stdin.setRawMode(true); stdin.resume(); stdin.setEncoding("utf8"); return new Promise((done) => { let value = ""; const finish = () => { stdin.setRawMode(false); stdin.pause(); stdin.off("data", onData); process.stderr.write("\n"); }; const onData = (chunk) => { for (const c of chunk) { if (c === "\r" || c === "\n") return finish(), done(value); if (c === "\u0003") return finish(), process.exit(130); if (c === "\u007f" || c === "\b") value = value.slice(0, -1); else value += c; } }; stdin.on("data", onData); }); } let vaultKey = null; async function key(user, ask = true) { if (vaultKey) return vaultKey; if (!user.vaultSalt || !user.vaultCheck) throw new CliError("No personal key is set up yet. Set one up in the browser first (the key icon on your shelf)."); let password = process.env.POCKA_KEY; if (!password) { if (!ask) return null; password = await askHidden("Personal key: "); } const material = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]); const k = await crypto.subtle.deriveKey( { name: "PBKDF2", hash: "SHA-256", salt: unb64(user.vaultSalt), iterations: ITERATIONS }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); try { const check = new TextDecoder().decode(await openBytes(k, user.vaultCheck, vaultContext(user.id))); if (check !== "pocka-vault-v1") throw Error(); } catch { throw new CliError("That personal key is not right."); } return (vaultKey = k); } /* ---------- helpers ---------- */ const units = ["B", "KB", "MB", "GB", "TB"]; function size(n) { let i = 0; while (n >= 1024 && i < units.length - 1) (n /= 1024), i++; return `${i ? n.toFixed(n < 10 ? 1 : 0) : n} ${units[i]}`; } function age(seconds) { const s = Math.max(0, Math.floor(Date.now() / 1000) - seconds); if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)}m ago`; return `${Math.floor(s / 3600)}h ago`; } const MIME = { ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".json": "application/json", ".html": "text/html", ".pdf": "application/pdf", ".zip": "application/zip", ".gz": "application/gzip", ".tgz": "application/gzip", ".tar": "application/x-tar", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".heic": "image/heic", ".mp4": "video/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".log": "text/plain", ".py": "text/x-python", ".js": "text/javascript", ".sh": "text/x-shellscript", }; const mimeOf = (name) => MIME[extname(name).toLowerCase()] || "application/octet-stream"; // A name from the shelf, made safe to use as a file name here. function safeName(name) { const n = basename(String(name).replace(/\\/g, "/")).replace(/[\u0000-\u001f\u007f]/g, "").trim(); return !n || n === "." || n === ".." ? "pocka-item" : n.slice(0, 200); } function freePath(path) { if (!existsSync(path)) return path; const ext = extname(path), stem = path.slice(0, path.length - ext.length); for (let i = 2; ; i++) if (!existsSync(`${stem} (${i})${ext}`)) return `${stem} (${i})${ext}`; } function progress(label, done, total) { if (!tty) return; const pct = total ? Math.floor((done / total) * 100) : 100; process.stderr.write(`\r\x1b[K${label} ${pct}% ${size(done)} / ${size(total)}`); } const endProgress = () => tty && process.stderr.write("\r\x1b[K"); async function me() { return (await ok("/me")).data; } // Items with their details (decrypted when POCKA_KEY is set), newest first. async function shelf(user, askKey = false) { const { data } = await ok("/transfers"); const items = []; for (const [i, item] of data.items.entries()) { let details = null; try { if (item.encrypted) { const k = await key(user, askKey); if (k) details = JSON.parse(new TextDecoder().decode(await openBytes(k, item.meta, metaContext(user.id, item.id)))); } else details = JSON.parse(Buffer.from(item.meta, "base64").toString("utf8")); } catch (e) { if (e instanceof CliError) throw e; } items.push({ ...item, index: i + 1, details }); } return { items, usage: data.usage }; } const describe = (item) => ({ index: item.index, id: item.id, name: item.details?.name ?? null, type: item.details?.type ?? null, mime: item.details?.mime ?? null, size: item.size, state: item.state, locked: !!item.encrypted, preview: item.details?.type === "text" ? item.details.preview ?? null : undefined, createdAt: item.created_at, expiresAt: item.expires_at, }); function pick(items, selector) { if (!items.length) throw new CliError("Nothing on the shelf.", 3); if (selector === undefined) return items[0]; const s = String(selector).replace(/^#/, ""); if (/^\d{1,3}$/.test(s)) { const item = items[Number(s) - 1]; if (!item) throw new CliError(`There is no item ${s}. Run pocka ls to see the shelf.`); return item; } const found = items.filter((i) => i.id.startsWith(s.toLowerCase())); if (found.length === 1) return found[0]; // Otherwise by file name: the newest item with exactly this name. const named = items.find((i) => i.details?.name === String(selector)); if (!found.length && named) return named; throw new CliError(found.length ? `"${s}" matches more than one item; use more of the ID.` : `No item matches "${s}".`); } /* ---------- commands ---------- */ async function login() { if (token() && !opts.force) { try { const { user } = await me(); out({ status: "signed_in", email: user.email, server }, `Already signed in as ${user.email}. (pocka logout to switch)`); return; } catch (e) { if (e.code !== 4) throw e; } } const now = Date.now() / 1000; let pending = config.pending?.server === server && config.pending.expiresAt > now + 15 ? config.pending : null; if (!pending) { const name = (opts.name || hostname() || "Command line").slice(0, 64); const { data } = await ok("/cli/login", { method: "POST", auth: false, body: { name } }); pending = { server, deviceCode: data.deviceCode, userCode: data.userCode, url: data.url, interval: data.interval || 5, expiresAt: Math.floor(now + data.expiresIn), }; config.pending = pending; await saveConfig(); } const minutes = Math.max(1, Math.round((pending.expiresAt - now) / 60)); const hint = `To connect this machine, open this link in a browser where you're signed in to Pocka:\n\n ${pending.url}\n\nand check that it shows the code ${pending.userCode}. The link works for ${minutes} more min.`; if (opts["no-wait"]) { out({ status: "pending", url: pending.url, code: pending.userCode, expiresAt: pending.expiresAt }, hint + "\nThen run pocka login again to finish."); return; } info(hint + "\n\nWaiting for approval… (Ctrl+C to stop; run pocka login again to resume)"); while (Date.now() / 1000 < pending.expiresAt) { await sleep(pending.interval * 1000); const r = await call("/cli/token", { method: "POST", auth: false, body: { deviceCode: pending.deviceCode }, retry: true }); if (r.status === 428) continue; if (r.status === 200 && r.data.token) { config = { server, token: r.data.token, email: r.data.email, name: r.data.name }; await saveConfig(); out({ status: "signed_in", email: r.data.email, server }, `Signed in as ${r.data.email}.`); return; } delete config.pending; await saveConfig(); throw new CliError(r.data.error || "Sign-in failed. Run pocka login again."); } delete config.pending; await saveConfig(); throw new CliError("The sign-in link expired. Run pocka login again."); } async function logout() { if (config.token) { try { await call("/cli/logout", { method: "POST" }); } catch {} } const was = config.email; config = {}; await saveConfig(); out({ status: "signed_out" }, was ? `Signed out ${was} on this machine.` : "Signed out."); } async function whoami() { const { user, pro, limits } = await me(); out( { email: user.email, plan: pro ? "pro" : "free", limits, server, personalKey: !!user.vaultSalt }, `${user.email}\nPlan: ${pro ? "Pro" : "Free"} · up to ${size(limits.file)} per item, ${size(limits.storage)} on the shelf\nServer: ${server}`, ); } async function list() { const { user } = await me(); const { items, usage } = await shelf(user); if (jsonMode) { out({ items: items.map(describe), usage }); return; } if (!items.length) return out(null, "The shelf is empty."); const rows = items.map((i) => { const d = i.details; const name = d ? (d.type === "text" ? JSON.stringify((d.preview || "").replace(/\s+/g, " ").slice(0, 50)) : d.name) : "(locked, set POCKA_KEY to see the name)"; const state = i.state === "uploading" ? " uploading…" : ""; return [String(i.index), i.id.slice(0, 8), d?.type || "locked", size(i.size), age(i.created_at), name + state]; }); const widths = [0, 1, 2, 3, 4].map((c) => Math.max(...rows.map((r) => r[c].length))); out(null, rows.map((r) => r.map((v, c) => (c < 5 ? v.padEnd(widths[c]) : v)).join(" ")).join("\n")); info(`\n${items.length} item${items.length === 1 ? "" : "s"}, ${size(usage.bytes)} · take one with: pocka take `); } async function readStdin(max) { const parts = []; let total = 0; for await (const chunk of process.stdin) { total += chunk.length; if (total > max) throw new CliError(`Input is larger than your plan's ${size(max)} per item.`); parts.push(chunk); } return new Uint8Array(Buffer.concat(parts)); } async function upload(user, limits, task) { if (task.size > limits.file) throw new CliError(`${task.name} is ${size(task.size)}; your plan takes up to ${size(limits.file)} per item.`); const locked = !!opts.lock; const k = locked ? await key(user) : null; const id = randomUUID(); const cs = chunkSize(task.size); const n = Math.max(1, Math.ceil(task.size / cs)); const meta = { v: 1, name: task.name.slice(0, 255), type: task.type, mime: task.mime.slice(0, 150), size: task.size, chunks: n, preview: task.preview || "" }; const metaBytes = enc.encode(JSON.stringify(meta)); await ok("/transfers", { method: "POST", body: { id, size: task.size, chunks: n, encrypted: locked, meta: locked ? b64(await sealBytes(k, metaBytes, metaContext(user.id, id))) : b64(metaBytes) }, }); try { let next = 0, sent = 0; const worker = async () => { while (next < n) { const i = next++; const bytes = await task.read(i * cs, Math.min(cs, task.size - i * cs)); const data = locked ? await sealBytes(k, bytes, chunkContext(user.id, id, i, task.size)) : bytes; await ok(`/transfers/${id}/chunks/${i}`, { method: "PUT", body: data }); sent += bytes.length; progress(task.name, sent, task.size); } }; await Promise.all(Array.from({ length: Math.min(4, n) }, worker)); // 404: another device already took it while it was uploading. await ok(`/transfers/${id}/commit`, { method: "POST" }, [404]); } catch (e) { endProgress(); await call(`/transfers/${id}`, { method: "DELETE" }).catch(() => {}); throw e; } finally { await task.close?.(); } endProgress(); out({ status: "sent", id, name: meta.name, type: meta.type, size: task.size, locked }, `Put on the shelf: ${meta.name} (${size(task.size)})${locked ? " · locked" : ""}`); } async function send() { const { user, limits } = await me(); const tasks = []; if (opts.text !== undefined) { const text = opts.text === "-" ? new TextDecoder().decode(await readStdin(limits.file)) : opts.text; if (!text) throw new CliError("The text is empty.", 2); const bytes = enc.encode(text); tasks.push({ name: opts.name || "Note", type: "text", mime: "text/plain", size: bytes.length, preview: text.slice(0, 120), read: async (o, l) => bytes.subarray(o, o + l) }); } for (const arg of args) { if (arg === "-") { const bytes = await readStdin(limits.file); const name = opts.name || "stdin"; const mime = mimeOf(name); tasks.push({ name, type: mime.startsWith("image/") ? "image" : "file", mime, size: bytes.length, read: async (o, l) => bytes.subarray(o, o + l) }); continue; } const path = resolve(arg); let s; try { s = await stat(path); } catch { throw new CliError(`No such file: ${arg}`, 2); } if (s.isDirectory()) throw new CliError(`${arg} is a folder. Pack it first, e.g.: tar czf ${basename(path)}.tgz ${arg} && pocka send ${basename(path)}.tgz`, 2); const name = (args.length === 1 && opts.name) || basename(path); const mime = mimeOf(name); let handle; // one shared handle; the parallel chunk uploads all read from it tasks.push({ name, type: mime.startsWith("image/") ? "image" : "file", mime, size: s.size, read: async (o, l) => { handle ||= open(path, "r"); const h = await handle; const buf = new Uint8Array(l); let got = 0; while (got < l) { const { bytesRead } = await h.read(buf, got, l - got, o + got); if (!bytesRead) throw new CliError(`${arg} changed while it was being sent.`); got += bytesRead; } return buf; }, close: async () => (await handle)?.close(), }); } if (!tasks.length) throw new CliError('Nothing to send. Try: pocka send FILE, or pocka send -t "text"', 2); for (const task of tasks) await upload(user, limits, task); } async function takeOne(user, item) { if (item.encrypted && !item.details) { const k = await key(user); item.details = JSON.parse(new TextDecoder().decode(await openBytes(k, item.meta, metaContext(user.id, item.id)))); } const d = item.details; if (!d || d.v !== 1 || d.size !== item.size || d.chunks !== item.chunks) throw new CliError("This item's details are damaged. It stays on the shelf."); const k = item.encrypted ? await key(user) : null; const isText = d.type === "text" && opts.output === undefined; const toStdout = opts.output === "-"; let target = null, temp = null, handle = null; const parts = []; if (!isText && !toStdout) { const o = opts.output ? resolve(opts.output) : process.cwd(); const isDir = !opts.output || opts.output.endsWith("/") || (existsSync(o) && statSync(o).isDirectory()); if (isDir) await mkdir(o, { recursive: true }); target = freePath(isDir ? join(o, safeName(d.type === "text" ? "note.txt" : d.name)) : o); temp = join(dirname(target), `.${basename(target)}.${item.id.slice(0, 8)}.part`); handle = await open(temp, "w", 0o600); } const claim = async (lease) => (await ok(`/transfers/${item.id}/claim`, { method: "POST", headers: lease ? { "X-Pocka-Lease": lease } : {} })).data.lease; let lease = await claim(); let claimedAt = Date.now(); const leaseHeader = () => ({ "X-Pocka-Lease": lease }); try { const cs = chunkSize(item.size); let got = 0; for (let i = 0; i < item.chunks; i++) { let waited = 0; let bytes; while (true) { if (Date.now() - claimedAt > 4 * 60 * 1000) { lease = await claim(lease); claimedAt = Date.now(); } const r = await call(`/transfers/${item.id}/chunks/${i}`, { raw: true, headers: leaseHeader() }); if (r.bytes) { bytes = r.bytes; break; } if (r.status !== 425) throw new CliError(r.data.error || `Download failed (HTTP ${r.status}).`); // Still uploading on the other device: wait for this chunk. if (waited > 10 * 60 * 1000) throw new CliError("The upload on the other device stopped. The item stays on the shelf."); const pause = Math.min(4000, 800 + waited / 4); await sleep(pause); waited += pause; } if (k) bytes = await openBytes(k, bytes, chunkContext(user.id, item.id, i, item.size)); const expected = Math.min(cs, Math.max(0, item.size - i * cs)); if (bytes.length !== expected) throw new CliError("The item is damaged. It stays on the shelf."); if (handle) await handle.write(bytes); else if (toStdout) await new Promise((r, j) => process.stdout.write(bytes, (e) => (e ? j(e) : r()))); else parts.push(bytes); got += bytes.length; progress(d.name, got, item.size); } endProgress(); if (handle) { await handle.close(); handle = null; await rename(temp, target); temp = null; } } catch (e) { endProgress(); if (handle) await handle.close().catch(() => {}); if (temp) await unlink(temp).catch(() => {}); await call(`/transfers/${item.id}/release`, { method: "POST", headers: leaseHeader() }).catch(() => {}); throw e; } if (opts.keep) await call(`/transfers/${item.id}/release`, { method: "POST", headers: leaseHeader() }).catch(() => {}); else await ok(`/transfers/${item.id}/ack`, { method: "POST", headers: leaseHeader() }); const left = opts.keep ? " (left on the shelf)" : ""; const result = { status: opts.keep ? "downloaded" : "taken", id: item.id, name: d.name, type: d.type, size: item.size }; if (isText) { const text = new TextDecoder().decode(Buffer.concat(parts)); if (jsonMode) out({ ...result, text }); else { process.stdout.write(text + (process.stdout.isTTY && !text.endsWith("\n") ? "\n" : "")); if (opts.keep) info(left.trim()); } } else if (toStdout) { if (jsonMode) process.stderr.write(JSON.stringify(result) + "\n"); } else out({ ...result, path: target }, `Took ${d.name} → ${target} (${size(item.size)})${left}`); } async function take() { const { user } = await me(); const { items } = await shelf(user); if (opts.all) { if (!items.length) throw new CliError("Nothing on the shelf.", 3); if (opts.output === "-") throw new CliError("--all can't write to stdout; give a folder with -o.", 2); for (const item of [...items].reverse()) await takeOne(user, item); return; } await takeOne(user, pick(items, args[0])); } async function remove() { if (!args.length) throw new CliError("Which item? Give its number from pocka ls or the start of its ID.", 2); const { user } = await me(); const { items } = await shelf(user); for (const sel of args) { const item = pick(items, sel); await ok(`/transfers/${item.id}`, { method: "DELETE" }); out({ status: "deleted", id: item.id }, `Deleted ${item.details?.name || item.id.slice(0, 8)}.`); } } // Where agents look for user-level skills. Both use the same SKILL.md format (agentskills.io). const SKILL_DIRS = [ { agent: "Claude Code", dir: join(homedir(), ".claude", "skills", "pocka") }, { agent: "Codex", dir: join(homedir(), ".agents", "skills", "pocka") }, ]; // The real path of this script, through npm's bin symlinks. const selfPath = () => { try { return realpathSync(process.argv[1]); } catch { return process.argv[1]; } }; // The skill that ships with this version (npm package), else the one on the server. async function skillText() { try { const text = await readFile(join(dirname(selfPath()), "SKILL.md"), "utf8"); if (text.startsWith("---")) return text; } catch {} const res = await fetch(`${server}/skill.md`, { headers: { "User-Agent": userAgent } }).catch((e) => { throw new CliError(`Can't reach ${server}: ${e.cause?.code || e.message}`); }); if (!res.ok) throw new CliError(`Couldn't download the skill (HTTP ${res.status}).`); return res.text(); } async function skill() { const targets = opts.dir ? [{ agent: "", dir: resolve(opts.dir) }] : SKILL_DIRS; if (args[0] === "install") { const text = await skillText(); const paths = []; for (const t of targets) { await mkdir(t.dir, { recursive: true }); await writeFile(join(t.dir, "SKILL.md"), text); paths.push(join(t.dir, "SKILL.md")); } out( { status: "installed", paths }, `Installed the Pocka skill:\n${targets.map((t, i) => ` ${paths[i]}${t.agent ? ` (${t.agent})` : ""}`).join("\n")}\nRestart the agent if it's running, then ask it to send or take something with Pocka.`, ); return; } if (args[0] === "uninstall") { const removed = []; for (const t of targets) { const file = join(t.dir, "SKILL.md"); if (!existsSync(file)) continue; await unlink(file); removed.push(file); await rmdir(t.dir).catch(() => {}); // only if now empty } out({ status: "uninstalled", paths: removed }, removed.length ? `Removed:\n${removed.map((f) => ` ${f}`).join("\n")}` : "The skill wasn't installed."); return; } throw new CliError("Usage: pocka skill install | pocka skill uninstall [--dir DIR]", 2); } async function update() { const self = selfPath(); // Installed with npm (global or npx): npm owns the file, so let npm update it. if (self.split(/[\\/]/).includes("node_modules")) { const npx = /[\\/]_npx[\\/]/.test(self); out( { status: "npm", command: npx ? "npx @pockaapp/pocka@latest" : "npm install -g @pockaapp/pocka@latest" }, npx ? "Run npx @pockaapp/pocka@latest to use the newest version." : "Installed with npm. Update with:\n npm install -g @pockaapp/pocka@latest", ); return; } const res = await fetch(`${server}/pocka.mjs`, { headers: { "User-Agent": userAgent } }); if (!res.ok) throw new CliError(`Couldn't download the update (HTTP ${res.status}).`); const body = await res.text(); if (!body.startsWith("#!/usr/bin/env node")) throw new CliError("The download doesn't look like the Pocka CLI; nothing was changed."); const version = body.match(/const VERSION = "([^"]+)"/)?.[1] || "?"; const temp = `${self}.new`; await writeFile(temp, body, { mode: 0o755 }); await rename(temp, self); out({ status: "updated", version }, version === VERSION ? `Already up to date (${VERSION}).` : `Updated ${VERSION} → ${version}.`); } /* ---------- main ---------- */ const commands = { login, logout, whoami, ls: list, list, send, put: send, take, get: take, rm: remove, delete: remove, skill, update }; try { if (opts.version) out({ version: VERSION }, VERSION); else if (opts.help || !command || command === "help") process.stdout.write(HELP); else if (!commands[command]) throw new CliError(`Unknown command "${command}". Run pocka --help for usage.`, 2); else await commands[command](); } catch (e) { const err = e instanceof CliError ? e : new CliError(e?.message || String(e)); if (jsonMode) process.stdout.write(JSON.stringify({ error: err.message, code: err.code }) + "\n"); process.stderr.write(`pocka: ${err.message}\n`); process.exitCode = err.code; }