🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@vruum/cli

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@vruum/cli - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+75
dist/commands/init.command.js
import { resolveApiUrl, resolveToken } from "../lib/config.js";
import { getContext } from "../lib/context.js";
import { printError, printResult, printStatus } from "../lib/output.js";
import { ADAPTERS, } from "../lib/harness.js";
import { ExitSignal } from "./auth.command.js";
export function register(program) {
program
.command("init")
.description("Wire Vruum into every detected AI coding harness (MCP + skills + instructions)")
.option("--project-dir <dir>", "project root for instruction files", process.cwd())
.action(async (opts) => {
const token = await resolveToken();
if (!token) {
printError("not logged in — run `vruum login` first (or set VRUUM_TOKEN)");
process.exitCode = 2;
throw new ExitSignal(2);
}
const apiUrl = await resolveApiUrl(getContext().apiUrl);
const initOpts = {
mcpUrl: `${apiUrl}/mcp`,
token,
projectRoot: opts.projectDir,
};
const results = [];
for (const adapter of ADAPTERS) {
if (!(await adapter.detect()))
continue;
// Each step is best-effort: one failing step (or harness) must not abort
// the rest, so the operator gets a full picture in a single run.
const steps = [
["mcp", (o) => adapter.configureMcp(o)],
["skills", (o) => adapter.linkSkills(o)],
["instructions", (o) => adapter.writeInstructions(o)],
];
const stepResults = [];
for (const [name, fn] of steps) {
try {
stepResults.push(await fn(initOpts));
}
catch (err) {
stepResults.push({
step: name,
status: "failed",
detail: err?.message ?? String(err),
});
}
}
results.push({ harness: adapter.name, steps: stepResults });
}
if (results.length === 0) {
printStatus("No supported AI coding harness detected. Install one (e.g. Claude Code) and re-run `vruum init`.");
if (getContext().json)
printResult({ harnesses: [] });
return;
}
const failed = results.some((r) => r.steps.some((s) => s.status === "failed"));
if (getContext().json) {
printResult({ harnesses: results });
}
else {
for (const r of results) {
printStatus(r.harness);
for (const s of r.steps) {
printStatus(` ${s.step.padEnd(13)} ${s.status.padEnd(9)} ${s.detail}`);
}
}
printStatus("");
printStatus("Restart your harness to load the Vruum MCP, then verify with a real tool call.");
}
if (failed) {
process.exitCode = 1;
throw new ExitSignal(1);
}
});
}
/**
* Claude Code harness adapter for `vruum init` — the reference implementation.
*
* Detect `~/.claude`, then register the Vruum MCP through Claude Code's own CLI
* (`claude mcp add`) rather than hand-editing the large, stateful `~/.claude.json`.
* Idempotent: drop any prior `vruum` entry, then re-add it with a static Bearer
* header (no interactive OAuth) — the same approach as the repo's sync-mcp.mjs.
*
* Import discipline: runtime helpers come from `./core.js`; the
* `HarnessAdapter`/`InitOptions`/`StepResult` *types* come from `../lib/harness.js`
* via `import type` (erased at runtime), so there is no runtime back-edge into
* `harness.ts` (which value-imports this adapter to register it in `ADAPTERS`).
*/
import { join } from "node:path";
import { harnessHome, pathExists, runCommand } from "./core.js";
export const claudeCode = {
name: "Claude Code",
async detect() {
return pathExists(join(harnessHome(), ".claude"));
},
async configureMcp(opts) {
// Register through Claude Code's own CLI rather than hand-editing the large,
// stateful ~/.claude.json. Idempotent: drop any prior `vruum` entry, then
// re-add with the static Bearer header (no interactive OAuth). Same approach
// as the repo's sync-mcp.mjs.
await runCommand("claude", ["mcp", "remove", "-s", "user", "vruum"]).catch(() => { });
await runCommand("claude", [
"mcp",
"add",
"-s",
"user",
"--transport",
"http",
"vruum",
opts.mcpUrl,
"--header",
`Authorization: Bearer ${opts.token}`,
]);
return {
step: "mcp",
status: "done",
detail: "registered vruum MCP (Bearer) via `claude mcp add`",
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "skill linking lands with the portable skill set (VRU-470)",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md/CLAUDE.md emitter lands in VRU-469b",
};
},
};
/**
* Cline `HarnessAdapter` for `vruum init`.
*
* Cline (VS Code extension `saoudrizwan.claude-dev`) stores its MCP servers in
* `cline_mcp_settings.json` under the extension's VS Code globalStorage dir. We
* detect the extension dir, then read-merge-write that JSON to register the
* `vruum` server as a Bearer-authed **streamable-HTTP** endpoint, idempotently.
*
* Why a separate transport shape: Cline's remote MCP config wants
* `{ type: "streamableHttp", url, headers }` (see Cline's remote-server docs and
* PR #6095), not the `{ type: "http" }` shape `buildMcpEntry` produces for
* Claude Code/Cursor/etc. `buildStreamableHttpMcpEntry` below is Cline's shape.
*
* ESM cycle note: this module imports only *types* from `lib/harness.ts`
* (`import type`, erased at runtime) and its runtime helpers from `./core.js`,
* so `harness.ts → cline.adapter.ts` has no runtime back-edge.
*
* Scope: only the stable VS Code **Code** distribution path is resolved.
* VS Code Insiders (`Code - Insiders`), VSCodium (`VSCodium`), Cursor, and the
* Windows `%APPDATA%` override are intentionally NOT resolved here (deferred
* follow-up VRU-497b) — a Cline installed only under those variants is not
* detected by this adapter.
*/
import { promises as fs } from "node:fs";
import { dirname, join } from "node:path";
import { harnessHome, pathExists } from "./core.js";
/**
* Cline's remote MCP entry shape — distinct from `buildMcpEntry`'s `http` shape.
* Cline expects `type: "streamableHttp"` for hosted/remote servers.
*/
export function buildStreamableHttpMcpEntry(url, token) {
return { type: "streamableHttp", url, headers: { Authorization: `Bearer ${token}` } };
}
/** Cline's stable VS Code extension id; its globalStorage dir is named for it. */
const CLINE_EXTENSION_ID = "saoudrizwan.claude-dev";
/**
* Resolve the absolute path to Cline's `cline_mcp_settings.json` for the given
* home dir + platform. Pure (no I/O) so every OS branch is unit-testable on
* Linux CI. `platform` is a `process.platform` value.
*
* Only the stable **Code** distribution is resolved:
* darwin → ~/Library/Application Support/Code/User/globalStorage/<ext>/settings/…
* win32 → ~/AppData/Roaming/Code/User/globalStorage/<ext>/settings/…
* linux/other → ~/.config/Code/User/globalStorage/<ext>/settings/…
* Unknown platforms fall back to the linux layout.
*/
export function resolveClineSettingsPath(home, platform) {
let userDir;
if (platform === "darwin") {
userDir = join(home, "Library", "Application Support", "Code", "User");
}
else if (platform === "win32") {
userDir = join(home, "AppData", "Roaming", "Code", "User");
}
else {
userDir = join(home, ".config", "Code", "User");
}
return join(userDir, "globalStorage", CLINE_EXTENSION_ID, "settings", "cline_mcp_settings.json");
}
/** The extension globalStorage dir whose presence ⇒ Cline is installed. */
function clineExtensionDir(settingsPath) {
// settings/cline_mcp_settings.json → settings → <ext>
return dirname(dirname(settingsPath));
}
function isPlainObject(v) {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** Atomic write: temp file in the same dir + rename, so no partial files. */
async function atomicWrite(path, contents) {
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, contents, "utf8");
await fs.rename(tmp, path);
}
export const clineAdapter = {
name: "Cline",
async detect() {
const settingsPath = resolveClineSettingsPath(harnessHome(), process.platform);
// The extension globalStorage dir's presence ⇒ Cline installed. This is a
// superset of "settings file present" (the file lives inside this dir), so a
// fresh install that hasn't written cline_mcp_settings.json yet is still
// detected — making configureMcp()'s create path reachable via `vruum init`.
return pathExists(clineExtensionDir(settingsPath));
},
async configureMcp(opts) {
const settingsPath = resolveClineSettingsPath(harnessHome(), process.platform);
let raw = null;
try {
raw = await fs.readFile(settingsPath, "utf8");
}
catch {
raw = null; // missing file → treat as a fresh `{}`
}
let root;
if (raw === null || raw.trim() === "") {
root = {};
}
else {
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return {
step: "mcp",
status: "failed",
detail: `cline_mcp_settings.json is not valid JSON — left unchanged at ${settingsPath}`,
};
}
if (!isPlainObject(parsed)) {
return {
step: "mcp",
status: "failed",
detail: `cline_mcp_settings.json root is not a JSON object — left unchanged at ${settingsPath}`,
};
}
root = parsed;
}
const existingServers = root.mcpServers;
if (existingServers !== undefined && !isPlainObject(existingServers)) {
return {
step: "mcp",
status: "failed",
detail: `cline_mcp_settings.json "mcpServers" is not a JSON object — left unchanged at ${settingsPath}`,
};
}
const mcpServers = isPlainObject(existingServers) ? existingServers : {};
mcpServers.vruum = buildStreamableHttpMcpEntry(opts.mcpUrl, opts.token);
root.mcpServers = mcpServers;
await fs.mkdir(dirname(settingsPath), { recursive: true });
await atomicWrite(settingsPath, `${JSON.stringify(root, null, 2)}\n`);
return {
step: "mcp",
status: "done",
detail: `registered vruum MCP (streamableHttp, Bearer) in ${settingsPath}`,
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "skill linking lands with the portable skill set (VRU-470)",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md/CLAUDE.md emitter lands in VRU-469b",
};
},
};
/**
* Codex CLI harness adapter for `vruum init`.
*
* Mirrors the Claude Code reference adapter: detect `~/.codex`, then register
* the Vruum MCP through Codex's own CLI (`codex mcp add`, which writes
* `~/.codex/config.toml`) rather than hand-editing TOML. Idempotent:
* remove-then-add.
*
* Codex wrinkle: unlike Claude (which stores a static Authorization header),
* Codex reads the Bearer from a RUNTIME env var named via
* `--bearer-token-env-var`. We point it at VRUUM_MCP_TOKEN and DOCUMENT the
* one-line export rather than writing it into a shell profile — a profile write
* is unreliable (Codex may launch from a session that never sources the
* profile), unsafe (shell-injection on tokens containing "/$/backtick/newline),
* a security regression (PAT moves from the 0600 creds file into a typically
* 0644 profile), and order-fragile. Documenting matches the install.sh
* precedent (it documents the PATH export, doesn't edit profiles).
*
* Import discipline: runtime helpers come from `./core.js`; types come from
* `../lib/harness.js` via `import type` (erased at runtime → no cycle).
*/
import { join } from "node:path";
import { harnessHome, pathExists, runCommand } from "./core.js";
export const codexCli = {
name: "Codex CLI",
async detect() {
return pathExists(join(harnessHome(), ".codex"));
},
async configureMcp(opts) {
// Register through Codex's own CLI (writes ~/.codex/config.toml) rather than
// hand-editing TOML. Idempotent: drop any prior `vruum` entry, then re-add.
//
// The DETAIL never embeds the token value: printing it would leak the PAT
// into terminal logs, `--json` output, CI logs, shell history, and
// screenshots, and an un-escaped token containing "/$/backtick/newline would
// make the suggested export an unsafe command. We surface a placeholder and
// tell the operator where their real token lives instead.
await runCommand("codex", ["mcp", "remove", "vruum"]).catch(() => { });
await runCommand("codex", [
"mcp",
"add",
"vruum",
"--url",
opts.mcpUrl,
"--bearer-token-env-var",
"VRUUM_MCP_TOKEN",
]);
return {
step: "mcp",
status: "done",
detail: "registered vruum MCP (Bearer via env var) through `codex mcp add`. " +
"Codex reads the token at runtime — before launching Codex, export your " +
"Vruum token (the same PAT from `vruum login`): " +
"export VRUUM_MCP_TOKEN=<your-vruum-token>",
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "skill linking lands with the portable skill set (VRU-470)",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md/CLAUDE.md emitter lands in VRU-469b",
};
},
};
/**
* Leaf helpers shared by `lib/harness.ts` and every per-harness adapter in this
* directory. This module imports ONLY node builtins — nothing adapter-related.
*
* Why a leaf: `harness.ts` registers the adapters (value imports), and an
* adapter needs `harnessHome`/`pathExists`/`isFile` plus the command runner. If
* those lived in `harness.ts`, an adapter's value import would close a runtime
* ESM cycle (`harness.ts → *.adapter.ts → harness.ts`) with a TDZ crash when an
* adapter module loads first (its own unit test). Putting them here breaks the
* back-edge: every side imports runtime helpers from `core`, and adapters pull
* the `HarnessAdapter`/`InitOptions`/`StepResult` *types* from `harness.ts` via
* `import type` (erased at runtime → no cycle). The graph stays a clean DAG.
*
* Consolidates the duplicate leaf helpers the parallel adapter branches each
* grew (`harnesses/core.ts` and `lib/harness-paths.ts`) into one module — the
* union of `harnessHome` + `pathExists` + `isFile` + the command-runner seam.
*/
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { homedir } from "node:os";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** Home dir adapters resolve harness configs under (override in tests). */
export function harnessHome() {
return process.env.VRUUM_HARNESS_HOME || homedir();
}
/** True when `p` exists (file, dir, or otherwise). */
export async function pathExists(p) {
try {
await fs.stat(p);
return true;
}
catch {
return false;
}
}
/** True when `p` exists AND is a regular file (not a directory). */
export async function isFile(p) {
try {
const st = await fs.stat(p);
return st.isFile();
}
catch {
return false;
}
}
const defaultRunner = async (cmd, args) => {
await execFileP(cmd, args);
};
let runner = defaultRunner;
/** Run an external command through the current runner (override in tests). */
export async function runCommand(cmd, args) {
await runner(cmd, args);
}
/** Override the external-command runner (test seam). Returns the previous one. */
export function setCommandRunner(next) {
const prev = runner;
runner = next;
return prev;
}
/**
* Cursor harness adapter for `vruum init`.
*
* Cursor has no `mcp add` CLI (unlike Claude Code); the documented mechanism is
* a JSON config at `~/.cursor/mcp.json` with an `mcpServers` map. This adapter
* registers the Bearer-authed Vruum MCP into that file via read-merge-write,
* preserving every other server and top-level key, and never clobbering a
* user-authored file it can't safely parse.
*
* Cursor uses the canonical `{ type: "http", url, headers }` MCP entry — the
* same shape `lib/harness.ts`'s `buildMcpEntry` produces. To keep the import
* graph a clean DAG (`harness.ts` value-imports this adapter to register it in
* `ADAPTERS`), the builder is inlined here as `buildCursorMcpEntry` rather than
* value-imported back from `harness.ts`. Runtime helpers come from `./core.js`;
* the `HarnessAdapter`/`InitOptions`/`StepResult` types come from
* `../lib/harness.js` via `import type` (erased at runtime → no cycle).
*/
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { harnessHome, pathExists } from "./core.js";
/** Cursor's MCP entry — the canonical Bearer-authed `http` shape. */
export function buildCursorMcpEntry(mcpUrl, token) {
return { type: "http", url: mcpUrl, headers: { Authorization: `Bearer ${token}` } };
}
/** Absolute path to the Cursor global MCP config under the (overridable) home. */
function cursorDir() {
return join(harnessHome(), ".cursor");
}
function mcpConfigPath() {
return join(cursorDir(), "mcp.json");
}
/** A plain `{}` object (not null, not an array). */
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const UNCHANGED = "`~/.cursor/mcp.json` left unchanged — must be valid JSON with an object root";
/**
* Write `data` to `path` securely: serialize as 2-space JSON + trailing newline
* into a sibling temp file with mode 0600, then atomically `rename` over the
* target. The file holds a plaintext Bearer token, so it mirrors the
* credentials module's 0600 posture and avoids truncation on crash. The rename
* stays within the same directory, so it's atomic on the same filesystem.
*/
async function writeJsonSecure(path, data) {
const body = `${JSON.stringify(data, null, 2)}\n`;
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmp, body, { mode: 0o600 });
try {
await fs.rename(tmp, path);
}
catch (err) {
await fs.rm(tmp, { force: true });
throw err;
}
// rename preserves the temp file's mode, but enforce 0600 on the result in
// case it replaced a pre-existing file with looser permissions.
await fs.chmod(path, 0o600).catch(() => { });
}
export const cursorAdapter = {
name: "Cursor",
async detect() {
// Directory check (not a bare existence check): a stray *file* named
// `.cursor` must not pass detection and then fail the write.
try {
const st = await fs.stat(cursorDir());
return st.isDirectory();
}
catch {
return false;
}
},
async configureMcp(opts) {
const path = mcpConfigPath();
let root = {};
if (await pathExists(path)) {
let parsed;
try {
parsed = JSON.parse(await fs.readFile(path, "utf8"));
}
catch {
// Invalid JSON: never clobber user-authored config.
return { step: "mcp", status: "failed", detail: UNCHANGED };
}
if (!isPlainObject(parsed)) {
return { step: "mcp", status: "failed", detail: UNCHANGED };
}
root = parsed;
// An existing `mcpServers` must be a plain object to merge into.
if ("mcpServers" in root && !isPlainObject(root.mcpServers)) {
return { step: "mcp", status: "failed", detail: UNCHANGED };
}
}
// Defensive: detect() gates this, so `.cursor` already exists at runtime —
// but mkdir keeps configureMcp safe when unit-tested directly.
await fs.mkdir(cursorDir(), { recursive: true });
const servers = isPlainObject(root.mcpServers) ? root.mcpServers : {};
// Read-merge-write: overwrite any stale `vruum`, preserve every other
// server and top-level key. Idempotent — a re-run rewrites the same entry.
root.mcpServers = { ...servers, vruum: buildCursorMcpEntry(opts.mcpUrl, opts.token) };
await writeJsonSecure(path, root);
return {
step: "mcp",
status: "done",
detail: "registered vruum MCP (Bearer) in ~/.cursor/mcp.json",
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
// Cursor uses *rules*, not a skills dir — that mapping is VRU-470's call.
detail: "Cursor uses rules, not a skills dir; mapping lands in VRU-470",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md/instructions emitter lands in VRU-469b",
};
},
};
/**
* Gemini CLI harness adapter for `vruum init`.
*
* Unlike the Claude Code reference adapter (which shells out to `claude mcp
* add`), Gemini CLI is configured by raw JSON, so this adapter does a
* read-merge-write of `~/.gemini/settings.json`. The streamable-HTTP MCP entry
* shape Gemini CLI expects is `mcpServers.<name> = { httpUrl, headers }` — note
* `httpUrl`, NOT the `url` field of the canonical `buildMcpEntry` helper. The
* transport is selected by which property is present (`httpUrl` →
* StreamableHTTPClientTransport), so the field name is load-bearing.
*
* Import discipline: runtime helpers come from `./core.js`; the
* `HarnessAdapter`/`InitOptions`/`StepResult` types come from `../lib/harness.js`
* via `import type` (erased at runtime → no cycle into `harness.ts`, which
* value-imports `geminiAdapter` to register it in `ADAPTERS`).
*/
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { harnessHome, pathExists } from "./core.js";
/** Gemini CLI's streamable-HTTP MCP entry. `httpUrl` (not `url`) is required. */
export function buildGeminiMcpEntry(mcpUrl, token) {
return { httpUrl: mcpUrl, headers: { Authorization: `Bearer ${token}` } };
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const geminiAdapter = {
name: "Gemini CLI",
async detect() {
// True only when ~/.gemini is a *directory*. A stray file at that path is
// not a Gemini install and would make a later mkdir fail, so we treat it as
// absent.
try {
const st = await fs.stat(join(harnessHome(), ".gemini"));
return st.isDirectory();
}
catch {
return false;
}
},
async configureMcp(opts) {
const dir = join(harnessHome(), ".gemini");
const settingsPath = join(dir, "settings.json");
// Read existing settings (if any). Fail untouched on unparseable JSON or a
// non-object shape rather than clobbering a user's config.
let settings = {};
if (await pathExists(settingsPath)) {
let raw;
try {
raw = await fs.readFile(settingsPath, "utf8");
}
catch (err) {
return {
step: "mcp",
status: "failed",
detail: `could not read ${settingsPath}: ${err?.message ?? String(err)}`,
};
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return {
step: "mcp",
status: "failed",
detail: `existing ${settingsPath} is not valid JSON — left untouched`,
};
}
if (!isPlainObject(parsed)) {
return {
step: "mcp",
status: "failed",
detail: `existing ${settingsPath} is not a JSON object — left untouched`,
};
}
settings = parsed;
}
// mcpServers must be a plain object to merge into. Anything else (array,
// null, string) is a malformed config we won't silently overwrite.
let mcpServers;
if ("mcpServers" in settings) {
const existing = settings.mcpServers;
if (!isPlainObject(existing)) {
return {
step: "mcp",
status: "failed",
detail: `existing ${settingsPath} has a non-object "mcpServers" — left untouched`,
};
}
mcpServers = existing;
}
else {
mcpServers = {};
}
// Overwrite only the `vruum` entry; preserve every other server and key.
mcpServers.vruum = buildGeminiMcpEntry(opts.mcpUrl, opts.token);
settings.mcpServers = mcpServers;
const serialized = `${JSON.stringify(settings, null, 2)}\n`;
// Atomic write: temp file in the same dir + rename, mode 0600 so the PAT at
// rest is not world/group-readable (POSIX; Windows does not enforce perms).
await fs.mkdir(dir, { recursive: true });
const tmp = join(dir, `.settings.json.${process.pid}.tmp`);
try {
await fs.writeFile(tmp, serialized, { mode: 0o600 });
// Ensure mode even if a pre-existing tmp/umask skewed it.
await fs.chmod(tmp, 0o600);
await fs.rename(tmp, settingsPath);
}
catch (err) {
await fs.rm(tmp, { force: true }).catch(() => { });
return {
step: "mcp",
status: "failed",
detail: `could not write ${settingsPath}: ${err?.message ?? String(err)}`,
};
}
return {
step: "mcp",
status: "done",
detail: `registered vruum MCP (Bearer, httpUrl) in ${settingsPath}`,
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "skill linking lands with the portable skill set (VRU-470)",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md emitter lands in VRU-469b",
};
},
};
/**
* OpenCode harness adapter for `vruum init`.
*
* OpenCode is configured by raw JSON (no install-time CLI), so MCP registration
* is a read-validate-merge-write of OpenCode's `opencode.json`. OpenCode's
* remote MCP schema is `{ type: "remote", url, headers, enabled }` (verified
* against opencode.ai/docs) — deliberately NOT `buildMcpEntry`'s `type:"http"`
* shape, which is for the Cursor/Cline/Windsurf/Gemini family.
*
* Write target: ALWAYS the private global user config
* `<harnessHome>/.config/opencode/opencode.json` (dir `0700` / file `0600`,
* enforced on every write — mirroring the credential perms in `lib/config.ts`).
* The entry carries a live Bearer token, and project-local `opencode.json`
* files are routinely committed to git / shared with collaborators, so we never
* write the token there. A project-local `opencode.json` is still honored as a
* *detection* signal that OpenCode is in use (it's only read, never written), so
* `init` registers the MCP for project-only OpenCode users — but the secret
* lands only in the user's private config. (OpenCode merges the global config
* with any project config, so the global registration is picked up regardless.)
*
* `linkSkills`/`writeInstructions` are `deferred`, mirroring the Claude Code
* reference adapter (OpenCode reads `.agents/skills` natively — placement is
* settled in VRU-470; the AGENTS.md emitter is VRU-469b).
*
* Import discipline: runtime helpers come from `./core.js`; types come from
* `../lib/harness.js` via `import type` (erased at runtime → no cycle).
*/
import { promises as fs } from "node:fs";
import { dirname, join } from "node:path";
import { harnessHome, isFile } from "./core.js";
/** Global user config path, honoring VRUUM_HARNESS_HOME. */
function globalConfigPath() {
return join(harnessHome(), ".config", "opencode", "opencode.json");
}
/** Project-local config path for a given project root. */
function projectConfigPath(projectRoot) {
return join(projectRoot, "opencode.json");
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildRemoteEntry(opts) {
return {
type: "remote",
url: opts.mcpUrl,
headers: { Authorization: `Bearer ${opts.token}` },
enabled: true,
};
}
export const opencode = {
name: "OpenCode",
async detect() {
// OpenCode is "present" if either the global config or a project-local
// opencode.json exists. `isFile` guards against a *directory* named
// opencode.json (which would later throw EISDIR on read).
if (await isFile(globalConfigPath()))
return true;
return isFile(projectConfigPath(process.cwd()));
},
async configureMcp(opts) {
// Always the private global user config — never a project-local
// opencode.json, which is routinely committed/shared and would leak the
// Bearer token. OpenCode merges the global config over any project config,
// so the global registration is honored for project-only users too.
const target = globalConfigPath();
// Read-validate-merge: load any existing config, fail closed on a malformed
// or wrong-shaped document rather than clobbering the user's file.
let config = {};
if (await isFile(target)) {
const raw = await fs.readFile(target, "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
throw new Error(`OpenCode config at ${target} is not valid JSON`);
}
if (!isPlainObject(parsed)) {
throw new Error(`OpenCode config at ${target} is not a JSON object`);
}
if (parsed.mcp !== undefined && !isPlainObject(parsed.mcp)) {
throw new Error(`OpenCode config at ${target} has a non-object "mcp" field`);
}
config = parsed;
}
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
mcp.vruum = buildRemoteEntry(opts);
const next = { ...config, mcp };
// The entry holds a Bearer token, so harden perms on every write (dir 0700
// / file 0600) — not just on creation — mirroring writeCredentials in
// lib/config.ts. mkdir/writeFile honor `mode` only on creation; chmod
// enforces it on a pre-existing file too.
const body = `${JSON.stringify(next, null, 2)}\n`;
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
await fs.writeFile(target, body, { mode: 0o600 });
await fs.chmod(target, 0o600).catch(() => { });
return {
step: "mcp",
status: "done",
detail: `registered vruum MCP (Bearer remote) in ${target}`,
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "OpenCode reads .agents/skills natively; placement lands in VRU-470",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md emitter lands in VRU-469b",
};
},
};
/**
* Windsurf (Codeium) harness adapter for `vruum init`.
*
* Mirrors the Claude Code reference adapter, but Windsurf has no MCP CLI — it's
* configured by raw JSON at `~/.codeium/windsurf/mcp_config.json`. So
* `configureMcp` does a read-merge-write rather than shelling out, and it writes
* Windsurf's own HTTP MCP schema: `mcpServers.<name> = { serverUrl, headers }`.
* Note `serverUrl` (NOT `url`) and a `headers` map carrying the static Bearer
* credential — this differs from the shared `buildMcpEntry` ({ type, url,
* headers }), so Windsurf gets its own `buildWindsurfMcpEntry` and does not
* reuse the shared one (verified against current Windsurf Cascade MCP docs).
*
* Scope: MCP registration only. `linkSkills` / `writeInstructions` are deferred
* (VRU-470 / VRU-469b), matching the reference adapter's posture.
*
* Import discipline: runtime helpers come from `./core.js`; types come from
* `../lib/harness.js` via `import type` (erased at runtime → no cycle).
*/
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { harnessHome, pathExists } from "./core.js";
/**
* Build Windsurf's HTTP MCP entry. Distinct from the shared `buildMcpEntry`:
* Windsurf uses `serverUrl` (not `url`) and carries no `type` field.
*/
export function buildWindsurfMcpEntry(mcpUrl, token) {
return { serverUrl: mcpUrl, headers: { Authorization: `Bearer ${token}` } };
}
/** True for a non-null, non-array object (a JSON "plain object"). */
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Directory holding Windsurf's per-user config. */
function windsurfDir() {
return join(harnessHome(), ".codeium", "windsurf");
}
/** Path to Windsurf's MCP config JSON. */
function windsurfConfigPath() {
return join(windsurfDir(), "mcp_config.json");
}
function failed(detail) {
return { step: "mcp", status: "failed", detail };
}
export const windsurfAdapter = {
name: "Windsurf",
async detect() {
// Directory-only: `~/.codeium/windsurf` must be a directory. A regular file
// at that path is not a Windsurf install.
try {
const stats = await fs.stat(windsurfDir());
return stats.isDirectory();
}
catch {
return false;
}
},
async configureMcp(opts) {
const configPath = windsurfConfigPath();
// Read-merge-write. Refuse to clobber a file we can't safely parse/merge:
// malformed JSON or an unexpected shape returns `failed` and leaves the file
// byte-for-byte untouched (no atomic temp+rename — single-user local CLI).
let config = {};
if (await pathExists(configPath)) {
let raw;
try {
raw = await fs.readFile(configPath, "utf8");
}
catch (err) {
return failed(`could not read ${configPath}: ${err?.message ?? String(err)}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return failed(`refusing to overwrite malformed JSON at ${configPath}`);
}
if (!isPlainObject(parsed)) {
return failed(`refusing to overwrite ${configPath}: root is not a JSON object`);
}
if (parsed.mcpServers !== undefined && !isPlainObject(parsed.mcpServers)) {
return failed(`refusing to overwrite ${configPath}: "mcpServers" is not an object`);
}
config = parsed;
}
const servers = isPlainObject(config.mcpServers) ? config.mcpServers : {};
config.mcpServers = {
...servers,
vruum: buildWindsurfMcpEntry(opts.mcpUrl, opts.token),
};
// The config file carries a long-lived Bearer credential, so it must not be
// world/group-readable on a shared machine. Create it owner-only (0o600) and
// chmod afterwards to also tighten any pre-existing file (writeFile's `mode`
// only applies when the file is created). The directory is created 0o700.
try {
await fs.mkdir(windsurfDir(), { recursive: true, mode: 0o700 });
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(configPath, 0o600);
}
catch (err) {
return failed(`could not write ${configPath}: ${err?.message ?? String(err)}`);
}
return {
step: "mcp",
status: "done",
detail: `registered vruum MCP (Bearer) in ${configPath}`,
};
},
async linkSkills() {
return {
step: "skills",
status: "deferred",
detail: "skill linking lands with the portable skill set (VRU-470)",
};
},
async writeInstructions() {
return {
step: "instructions",
status: "deferred",
detail: "AGENTS.md emitter lands in VRU-469b",
};
},
};
/**
* Harness adapters for `vruum init`.
*
* An adapter wires Vruum into ONE AI coding harness: detect whether it's
* installed, register the Vruum MCP server (Bearer-authed — no interactive
* OAuth), link the portable skill set, and write the agent instruction files.
*
* `HarnessAdapter` is the extension point: adding a harness is adding one file
* under `../harnesses/` and one entry to `ADAPTERS`, with zero change to the
* `init` orchestration. The shipped adapters cover Claude Code, Codex CLI,
* Cursor, Cline, OpenCode, Windsurf, and Gemini CLI.
*
* Module layering (a clean DAG — no runtime ESM cycle): this file owns the
* interface + types + `buildMcpEntry`, and value-imports each adapter to
* register it in `ADAPTERS`. Each adapter pulls the *types* from here via
* `import type` (erased at runtime) and its runtime helpers from the leaf
* `../harnesses/core.js`. The command-runner test seam lives in `core` and is
* re-exported here so the public import surface (`setCommandRunner`,
* `CommandRunner`, all types, `buildMcpEntry`, `ADAPTERS`) stays stable for
* `init.command.ts` and the tests.
*
* Scope note: MCP registration is fully implemented. `linkSkills` /
* `writeInstructions` are interface-complete but their bodies are deferred —
* the published `@vruum/cli` ships no skills (those are the `@vruum/skills`
* package, and the portable SKILL.md placement is settled in VRU-470), and the
* AGENTS.md/CLAUDE.md emitter is VRU-469b. They return a `deferred` step so each
* adapter is a faithful, end-to-end-runnable shape today.
*/
import { claudeCode } from "../harnesses/claude.adapter.js";
import { clineAdapter } from "../harnesses/cline.adapter.js";
import { codexCli } from "../harnesses/codex.adapter.js";
import { cursorAdapter } from "../harnesses/cursor.adapter.js";
import { geminiAdapter } from "../harnesses/gemini.adapter.js";
import { opencode } from "../harnesses/opencode.adapter.js";
import { windsurfAdapter } from "../harnesses/windsurf.adapter.js";
// The command-runner test seam lives in the leaf `core` module (so adapters can
// use it without a runtime edge back into this file). Re-export it here to keep
// the public import surface stable.
export { setCommandRunner } from "../harnesses/core.js";
/**
* Canonical Bearer-authed HTTP MCP entry. Harnesses configured by raw JSON in
* the `{ type: "http", url, headers }` family (e.g. Cursor) write exactly this
* shape; the Claude Code adapter expresses the same thing via `claude mcp add`.
* Exported as the shared contract. Harnesses with a different schema (Windsurf
* `serverUrl`, Gemini `httpUrl`, Cline `streamableHttp`, OpenCode `remote`)
* build their own entry in their adapter file.
*/
export function buildMcpEntry(mcpUrl, token) {
return { type: "http", url: mcpUrl, headers: { Authorization: `Bearer ${token}` } };
}
/** Every harness adapter. Adding a harness = adding one entry here. */
export const ADAPTERS = [
claudeCode,
codexCli,
cursorAdapter,
clineAdapter,
opencode,
windsurfAdapter,
geminiAdapter,
];
+6
-11
import { request } from "../lib/http.js";
import { printResult } from "../lib/output.js";
import { compact, listNotes, logNote, parseIntOption, parseNumberOption, printList } from "../lib/entities.js";
import { compact, handleNoteCommand, parseNumberOption } from "../lib/entities.js";
export function register(program) {

@@ -55,14 +55,9 @@ const account = program

account
.command("note <companyId> [body]")
.description("Add a note to an account, or list its notes when no body is given")
.option("--limit <n>", "max notes to list")
.command("note [companyId] [body]")
.description("Add, list, or delete an account's notes")
.option("--limit <n>", "max notes to list (1-500)")
.option("--delete <noteId>", "delete a note by id (parent-agnostic)")
.action(async (companyId, body, opts) => {
if (body) {
const resp = await logNote(body, { companyId });
printResult(resp);
return;
}
const resp = await listNotes({ companyId }, opts.limit ? parseIntOption(opts.limit, "limit") : undefined);
printList(resp, "notes");
await handleNoteCommand({ companyId }, body, opts);
});
}

@@ -0,1 +1,3 @@

import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";
import { request } from "../lib/http.js";

@@ -5,2 +7,9 @@ import { printError, printResult } from "../lib/output.js";

const BASE = "/api/ads";
/**
* Client-side upload ceiling, mirroring the backend's `_CREATIVE_MAX_BYTES`
* (25 MB). This is a fail-fast UX guard so an oversized file never gets read
* into memory or POSTed — the backend remains the enforcing boundary, so a
* small skew between the two is safe.
*/
const CREATIVE_MAX_BYTES = 25 * 1024 * 1024;
export function register(program) {

@@ -115,2 +124,57 @@ const fail = (msg) => {

});
const creative = ad
.command("creative")
.description("Manage ad creatives (upload a PNG/JPEG/PDF for warming)");
creative
.command("upload <file>")
.description("Upload a local PNG/JPEG/PDF creative, optionally attaching it to a campaign")
.option("--campaign-id <id>", "draft warming campaign to attach the creative to")
.option("--segment-id <id>", "target a segment for a standalone (no-campaign) creative")
.option("--headline <text>", "creative headline copy")
.option("--intro-text <text>", "creative intro / body copy")
.option("--cta-text <text>", "call-to-action button label")
.option("--cta-url <url>", "call-to-action destination URL (cta_destination_url)")
.action(async (file, opts) => {
// Fail closed on all local filesystem errors (missing path, directory,
// unreadable, oversized) BEFORE any request: these are usage errors, not
// server failures, so they exit 2 with no fetch call made. `fail` returns
// `never`, so the `.catch` collapses the error path to an exit.
const info = await stat(file).catch(() => fail(`cannot read file: ${file}`));
if (!info.isFile()) {
fail(`not a regular file: ${file}`);
}
if (info.size > CREATIVE_MAX_BYTES) {
fail(`file exceeds ${CREATIVE_MAX_BYTES / (1024 * 1024)} MB limit: ${file} (${info.size} bytes)`);
}
const raw = await readFile(file).catch(() => fail(`cannot read file: ${file}`));
const form = new FormData();
form.append("file", new Blob([new Uint8Array(raw)]), basename(file));
const campaignId = (opts.campaignId ?? "").trim();
if (campaignId)
form.append("campaign_id", campaignId);
// Optional metadata flags: trim and append only when non-empty, mirroring
// the --campaign-id pattern above. The backend (POST /api/ads/creatives)
// is the enforcing boundary — it validates segment_id as a UUID (400) +
// ownership (404) and reconciles a campaign's segment (409), which the CLI
// surfaces as-is rather than pre-empting. A whitespace-only value is
// treated as "not provided". The flag --cta-url maps to the backend form
// field cta_destination_url (authoritative field name).
const metaFields = [
["segment_id", opts.segmentId],
["headline", opts.headline],
["intro_text", opts.introText],
["cta_text", opts.ctaText],
["cta_destination_url", opts.ctaUrl],
];
for (const [field, value] of metaFields) {
const trimmed = (value ?? "").trim();
if (trimmed)
form.append(field, trimmed);
}
const res = await request(`${BASE}/creatives`, {
method: "POST",
body: form,
});
printResult(res);
});
/** POST a management action to /campaigns/{id}/manage and print the result. */

@@ -117,0 +181,0 @@ async function manage(id, body) {

import { request } from "../lib/http.js";
import { getContext } from "../lib/context.js";
import { printError, printResult } from "../lib/output.js";
import { handleNoteCommand } from "../lib/entities.js";
const BASE = "/api/deals";

@@ -158,2 +159,10 @@ /** Collect a repeatable option into an array (commander reducer). */

});
deal
.command("note [dealId] [body]")
.description("Add, list, or delete a deal's timeline notes (distinct from --notes)")
.option("--limit <n>", "max notes to list (1-500)")
.option("--delete <noteId>", "delete a note by id (parent-agnostic)")
.action(async (dealId, body, opts) => {
await handleNoteCommand({ dealId }, body, opts);
});
registerStakeholders(deal);

@@ -160,0 +169,0 @@ }

@@ -5,2 +5,10 @@ import { request } from "../lib/http.js";

const BASE = "/api/linkedin-marketing/engagement";
/**
* Max engagement ids accepted in a single `bulk-approve` batch. The backend
* `engagement_service.bulk_approve()` loop is sequential and non-transactional,
* so an oversized pasted CSV could partially-approve rows then time out
* mid-loop. Cap conservatively for this manual/pasted action and fail closed
* above it (mirrored by the backend `BulkApproveRequest` Field constraint).
*/
const MAX_BULK_APPROVE_IDS = 100;
export function register(program) {

@@ -66,2 +74,33 @@ const fail = (msg) => {

});
engagement
.command("bulk-approve")
.description("Approve multiple engagement items for sending")
.option("--ids <csv>", `comma-separated engagement ids to approve (max ${MAX_BULK_APPROVE_IDS} per batch)`)
.action(async (opts) => {
const ids = splitCsv(opts.ids ?? "");
if (ids.length === 0) {
fail("no engagement ids — supply at least one via --ids <a,b,c>");
}
if (ids.length > MAX_BULK_APPROVE_IDS) {
fail(`too many engagement ids (${ids.length}) — max ${MAX_BULK_APPROVE_IDS} per batch; split into smaller batches`);
}
const res = await request(`${BASE}/bulk-approve`, {
method: "POST",
body: { engagement_ids: ids },
});
printResult(res);
});
engagement
.command("stats")
.description("Show aggregate engagement queue counts")
.action(async () => {
const res = await request(`${BASE}/stats`);
printResult(res);
});
}
function splitCsv(raw) {
return raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
/**
* Knowledge-base command group: list / show / upsert / reindex / delete.
* Knowledge-base command group: list / show / search / upsert / reindex / delete.
*

@@ -9,5 +9,6 @@ * Maps to the `knowledge` domain REST surface (`/api/knowledge`). All reads

*
* `kb search` is intentionally absent: there is no REST search endpoint
* (semantic search lives only behind the `/mcp` JSON-RPC transport), and a new
* backend endpoint is an explicit Non-goal. It is filed as a follow-up.
* `kb search` (VRU-481) maps to `GET /api/knowledge/documents/search`, a thin
* authenticated vector-search endpoint over the tenant's UPLOADED knowledge
* base (Drive/Notion/Slack connector sources are out of scope). Results are
* chunk-level; each carries a `document_id` usable for a follow-up `kb show`.
*/

@@ -44,2 +45,23 @@ import { readFile } from "node:fs/promises";

kb
.command("search <query...>")
.description("Vector-search the uploaded knowledge base (ranked chunk matches)")
.option("--limit <n>", "Max results (1-50, default 8)")
.action(async (queryParts, opts) => {
// Variadic <query...> is space-joined so multi-word queries need no
// quoting: `kb search foo bar` → q="foo bar".
const q = queryParts.join(" ");
const query = { q };
if (opts.limit !== undefined) {
// Reject a non-integer / out-of-range --limit client-side (exit 2)
// before the fetch — the server bound is [1,50].
const limit = Number(opts.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
usageError("--limit must be an integer between 1 and 50");
}
query.limit = limit;
}
const results = await request(`${KB_BASE}/search`, { query });
printResult(results);
});
kb
.command("upsert")

@@ -46,0 +68,0 @@ .description("Create or update a document by name (text content)")

@@ -10,3 +10,3 @@ /**

* archive → POST /api/people/archive
* note → POST /api/notes/log (notes domain, via entities.logNote)
* note → notes domain add/list/delete (via entities.handleNoteCommand)
* import csv → preview-json (auto-map) → POST /api/people/import/start

@@ -18,3 +18,3 @@ * import sales-nav → GET .../sales-nav/profiles → POST .../sales-nav/start

import { printResult, printStatus } from "../lib/output.js";
import { compact, logNote, parseIntOption, printList } from "../lib/entities.js";
import { compact, handleNoteCommand, parseIntOption, printList } from "../lib/entities.js";
export function register(program) {

@@ -106,7 +106,8 @@ const people = program.command("people").description("Manage people (prospects / contacts)");

people
.command("note <personId> <body>")
.description("Attach a note to a person (visible to the AI writer)")
.action(async (personId, body) => {
const resp = await logNote(body, { personId });
printResult(resp);
.command("note [personId] [body]")
.description("Add, list, or delete a person's notes (visible to the AI writer)")
.option("--limit <n>", "max notes to list (1-500)")
.option("--delete <noteId>", "delete a note by id (parent-agnostic)")
.action(async (personId, body, opts) => {
await handleNoteCommand({ personId }, body, opts);
});

@@ -113,0 +114,0 @@ const importCmd = people.command("import").description("Import people from external sources");

@@ -134,2 +134,38 @@ import { ApiError, request } from "../lib/http.js";

});
const team = settings
.command("team")
.description("Team management: members / invite / join");
team
.command("members")
.description("List the team members for the current company")
.action(async () => {
const result = await request("/api/settings/team/members");
printResult(result);
});
team
.command("invite")
.description("Mint an invite code for the current company")
.action(async () => {
const result = await request("/api/settings/team/invite", { method: "POST" });
printResult(result);
});
team
.command("join")
// Optional positional: missing-code is validated in the action so it exits
// 2 via `usageError` rather than Commander's required-arg path (exit 1),
// keeping usage failures exit-2 consistent with the rest of `settings`.
// Codes are `secrets.token_urlsafe(6)` (base64url, alphabet includes `-`),
// so ~1.5% start with `-`; pass them after `--` (`team join -- -abc`) so
// Commander treats the leading-dash token as a value, not an option.
.argument("[code]", "Invite code to redeem")
.description("Redeem an invite code and join its company")
.action(async (code) => {
if (!code)
usageError("expected an invite code: settings team join <code>");
const result = await request("/api/settings/team/join", {
method: "POST",
body: { code },
});
printResult(result);
});
}

@@ -35,2 +35,14 @@ /**

}
/**
* Parse a notes `--limit`, enforcing the backend's `1..500` contract (see
* `notes.router.get_notes`). Rejecting client-side gives a clean usage error
* instead of a round-trip 400.
*/
export function parseLimitOption(value, name = "limit") {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 500) {
throw new Error(`--${name} must be an integer between 1 and 500`);
}
return n;
}
/** Parse a CLI numeric option (allows decimals), throwing on garbage. */

@@ -83,1 +95,39 @@ export function parseNumberOption(value, name) {

}
/**
* Delete a note by its global id (notes domain). Deletion is keyed on the note
* id alone — it is parent-agnostic — and is tenant-scoped server-side, so a
* foreign/typo'd id reports not-found rather than silently succeeding.
*/
export async function deleteNote(noteId) {
return request("/api/notes/manage", {
method: "POST",
body: { action: "delete", note_id: noteId },
});
}
/**
* Shared dispatcher for `people|account|deal note [parentId] [body]`.
*
* Branch order (first match wins):
* 1. `--delete <noteId>` → delete by id (parent-agnostic; ignores body/limit).
* 2. no parent id → usage error (add/list both need a parent to scope).
* 3. `body` present → add a note to the parent.
* 4. otherwise → list the parent's notes (honoring `--limit`).
*
* Centralizing the branching keeps the three command files identical in
* behavior and the payload shapes in one place next to the backend contract.
*/
export async function handleNoteCommand(parents, body, opts) {
if (opts.delete) {
printResult(await deleteNote(opts.delete));
return;
}
if (!parents.personId && !parents.dealId && !parents.companyId) {
throw new Error("a parent id is required to add or list notes (or pass --delete <noteId>)");
}
if (body) {
printResult(await logNote(body, parents));
return;
}
const resp = await listNotes(parents, opts.limit ? parseLimitOption(opts.limit) : undefined);
printList(resp, "notes");
}

@@ -39,2 +39,3 @@ /**

}
const isMultipart = opts.body instanceof FormData;
const headers = { Accept: "application/json", ...opts.headers };

@@ -45,4 +46,14 @@ if (token)

headers["X-Company-Id"] = ctx.forCompany;
if (opts.body !== undefined)
if (isMultipart) {
// Let the runtime set `Content-Type` with the multipart boundary; a stale or
// boundary-less content-type (even one a caller passed in opts.headers)
// makes FastAPI's multipart parser reject the request. Strip any casing.
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === "content-type")
delete headers[k];
}
}
else if (opts.body !== undefined) {
headers["Content-Type"] = "application/json";
}
let res;

@@ -53,3 +64,7 @@ try {

headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
body: isMultipart
? opts.body
: opts.body !== undefined
? JSON.stringify(opts.body)
: undefined,
});

@@ -56,0 +71,0 @@ }

{
"name": "@vruum/cli",
"version": "0.1.0",
"version": "0.2.0",
"description": "Headless CLI for the Vruum revenue platform.",

@@ -5,0 +5,0 @@ "type": "module",

@@ -102,3 +102,3 @@ # vruum

| `account` | `get` · `set-stage` · `set-state` · `note` |
| `ad` | `list` · `create` · `approve` · `reject` · `pause` · `resume` · `budget` |
| `ad` | `list` · `create` · `approve` · `reject` · `pause` · `resume` · `budget` · `creative upload` |
| `campaign` | `list` · `get` · `create` · `update` · `clone` · `delete` · `members` |

@@ -108,4 +108,4 @@ | `company` | `list` · `get` · `create` · `update` |

| `cta` | `list` · `create` · `update` · `delete` · `set-default` |
| `deal` | `list` · `get` · `create` · `update` · `stage` · `won` · `lost` · `reopen` · `stakeholder` |
| `engagement` | `queue` · `approve` · `edit` · `skip` |
| `deal` | `list` · `get` · `create` · `update` · `stage` · `won` · `lost` · `reopen` · `note` · `stakeholder` |
| `engagement` | `queue` · `approve` · `edit` · `skip` · `bulk-approve` (max 100 ids/batch) · `stats` |
| `kb` | `list` · `show` · `upsert` · `reindex` · `delete` |

@@ -122,2 +122,51 @@ | `message` | `queue` · `approve` · `reject` · `edit` · `regenerate` · `bulk-approve` · `bulk-reject` · `bulk-edit` |

## Notes
`people`, `account`, and `deal` each expose a `note` command over the shared
notes store (the canonical `notes` table / timeline read by the AI writer). All
three share one grammar:
```sh
vruum people note <personId> "Met at SaaStr" # add
vruum account note <companyId> "Renewal at risk" # add
vruum deal note <dealId> "Budget approved" # add
vruum deal note <dealId> # list the deal's notes
vruum deal note <dealId> --limit 20 # list, capped (1-500)
vruum deal note --delete <noteId> # delete by note id (no parent needed)
```
- A bare `note` with no parent id, no body, and no `--delete` is a usage error.
- `--delete <noteId>` takes precedence and is **parent-agnostic** — it deletes by
the note's global id. Deletes are **tenant-scoped server-side**: a foreign or
typo'd id reports not-found rather than silently succeeding.
- `--limit` accepts `1..500` (matching the backend contract).
### `deal note` vs `deal create/update --notes`
These write to **different** places — don't confuse them:
- `deal note <dealId> …` reads/adds/deletes rows in the canonical `notes` table
(the deal's free-text timeline, shared with people & accounts, surfaced to the
AI writer). This is the agent-curatable note store.
- `deal create --notes …` / `deal update --notes …` sets the single
`deals.notes` text field **on the deal row itself** — a one-off summary field,
not a timeline. It is not listable or deletable via `deal note`.
## Soft-delete-only: deals & companies
There is intentionally **no `deal delete` or `company delete`** verb — deals and
companies are **soft-delete-only by policy**, so their history is never lost:
- **Deals** retire by recording an outcome: `deal won` / `deal lost` (and
`deal reopen` to revive). The pipeline row and its full history are preserved
for analytics and postmortems; there is no DELETE route to hard-remove a deal.
- **Companies (accounts)** archive via their account state rather than being
hard-deleted, keeping the account anchor intact for the deals and people that
reference it.
Individual **notes** *can* be hard-deleted (`note --delete`) — the policy above
is about the deal/company records themselves, not their annotations.
## `--json` examples

@@ -124,0 +173,0 @@