| 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, | ||
| ]; |
| 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); | ||
| }); | ||
| } |
+50
-0
@@ -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"); | ||
| } |
+17
-2
@@ -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 @@ } |
+1
-1
| { | ||
| "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", |
+52
-3
@@ -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 @@ |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
162883
49.92%39
34.48%3715
48.3%193
34.03%21
61.54%2
100%