#!/usr/bin/env node // Vectrum's command line, for people and agents in a shell, like GitHub's gh. One file, no // dependencies, Node.js 18+. It talks to the server's MCP endpoint (/mcp), so it can do what // agents can do there, and signs in with OAuth like any agent (or with an API token). // // curl -fsSLO https://vectrum.example/vectrum.mjs // node vectrum.mjs auth login --host https://vectrum.example // node vectrum.mjs pr list // // Run `node vectrum.mjs help` for the commands. Inside a clone, the repository comes from its // origin remote; elsewhere pass --repo owner/name. --json prints what the server answered as is. import { spawn, execFileSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; const VERSION = "1.0.0"; const CONFIG_DIR = process.env.VECTRUM_CONFIG_DIR ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "vectrum"); const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); // --------------------------------------------------------------------------- // Arguments function parseArgs(argv) { const positional = []; const flags = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--") { positional.push(...argv.slice(i + 1)); break; } if (arg.startsWith("--")) { const [key, inline] = arg.slice(2).split(/=(.*)/s, 2); const next = argv[i + 1]; if (inline !== undefined) flags[key] = inline; else if (next !== undefined && !next.startsWith("--") && !BOOLEAN_FLAGS.has(key)) flags[key] = argv[++i]; else flags[key] = true; } else if (/^-[a-z]$/.test(arg)) { const key = SHORT_FLAGS[arg.slice(1)] ?? arg.slice(1); if (BOOLEAN_FLAGS.has(key)) flags[key] = true; else flags[key] = argv[++i]; } else positional.push(arg); } return { positional, flags }; } const BOOLEAN_FLAGS = new Set(["json", "private", "public", "read-only", "canceled", "draft", "squash", "delete-branch", "approve", "request-changes", "comment", "wait", "follow", "regex", "resolve", "unresolve", "all", "help", "recursive", "web"]); const SHORT_FLAGS = { R: "repo", t: "title", b: "body", m: "message", H: "head", B: "base", h: "help" }; const { positional, flags } = parseArgs(process.argv.slice(2)); function fail(message, code = 1) { process.stderr.write(`vectrum: ${message}\n`); process.exit(code); } // --------------------------------------------------------------------------- // Configuration and credentials function readConfig() { try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch { return { hosts: {} }; } } function writeConfig(config) { mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 }); } const normalizeHost = (host) => { const withScheme = /^https?:\/\//.test(host) ? host : `${/^(localhost|127\.|\[::1\])/.test(host) ? "http" : "https"}://${host}`; return new URL(withScheme).origin; }; /** The origin of a git remote URL (https or ssh), and the repository it names. */ function parseRemote(url) { let m = /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url); if (m) return { host: m[1], owner: m[2], repo: m[3], https: true }; m = /^ssh:\/\/(?:[^@]+@)?([^:/]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url) ?? /^(?:[^@]+@)?([^:/]+):([^/]+)\/([^/]+?)(?:\.git)?$/.exec(url); if (m) return { host: m[1], owner: m[2], repo: m[3], https: false }; return null; } function gitRemote() { try { return execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; } } /** Which server: --host, VECTRUM_HOST, the clone's remote if it's a known host, or the default one. */ function currentHost(config) { if (flags.host) return normalizeHost(flags.host); if (process.env.VECTRUM_HOST) return normalizeHost(process.env.VECTRUM_HOST); const remote = gitRemote(); const parsed = remote && parseRemote(remote); if (parsed) { const known = Object.keys(config.hosts).find((h) => new URL(h).hostname === parsed.host.replace(/:\d+$/, "")); if (known) return known; } if (config.default) return config.default; const hosts = Object.keys(config.hosts); if (hosts.length === 1) return hosts[0]; fail("no server chosen. Run `vectrum auth login --host `, or pass --host."); } /** owner/name from --repo, or from the clone's origin remote. */ function currentRepo() { const given = flags.repo ?? process.env.VECTRUM_REPO; if (given) { const [owner, repo] = String(given).split("/"); if (!owner || !repo) fail("--repo takes owner/name."); return { owner, repo }; } const remote = gitRemote(); const parsed = remote && parseRemote(remote); if (!parsed) fail("not in a clone of a Vectrum repository. Pass --repo owner/name."); return { owner: parsed.owner, repo: parsed.repo.replace(/\.wiki$/, "") }; } const base64url = (buf) => buf.toString("base64url"); function openBrowser(url) { // Not `cmd /c start`: cmd would split the URL at its "&"s. const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open"; const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url]; try { spawn(command, args, { stdio: "ignore", detached: true }).unref(); } catch { // The URL is printed too. } } async function postForm(url, fields) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields) }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error_description ?? body.error ?? `HTTP ${res.status}`); return body; } /** Signs in with OAuth (authorization code + PKCE, a local redirect), like any MCP client. */ async function oauthLogin(host) { const meta = await fetch(`${host}/.well-known/oauth-authorization-server`).then((r) => { if (!r.ok) throw new Error(`${host} doesn't look like a Vectrum server (no OAuth metadata).`); return r.json(); }); const verifier = base64url(randomBytes(32)); const challenge = base64url(createHash("sha256").update(verifier).digest()); const state = base64url(randomBytes(16)); const server = createServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const redirectUri = `http://127.0.0.1:${server.address().port}/callback`; const client = await fetch(meta.registration_endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: `Vectrum CLI (${os.hostname()})`, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }), }).then((r) => r.json()); if (!client.client_id) throw new Error("The server didn't register the command line as an app."); const authorize = new URL(meta.authorization_endpoint); authorize.search = new URLSearchParams({ response_type: "code", client_id: client.client_id, redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: flags["read-only"] ? "read" : "read write", resource: `${host}/mcp`, }).toString(); const code = await new Promise((resolve, reject) => { server.on("request", (req, res) => { const url = new URL(req.url, redirectUri); if (url.pathname !== "/callback") return res.writeHead(404).end(); const done = (message) => res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(`Vectrum

${message}

`); if (url.searchParams.get("state") !== state) return done("Something went wrong (the state didn't match). Try again."), reject(new Error("State mismatch.")); if (url.searchParams.get("error")) return done("Not connected. You can close this tab."), reject(new Error(url.searchParams.get("error_description") ?? "Access denied.")); done("Connected. You can close this tab and go back to the terminal."); resolve(url.searchParams.get("code")); }); process.stderr.write(`Opening your browser to approve the command line. If it doesn't open, visit:\n ${authorize}\n`); openBrowser(authorize.toString()); }).finally(() => server.close()); const tokens = await postForm(meta.token_endpoint, { grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier, resource: `${host}/mcp` }); return { client_id: client.client_id, token_endpoint: meta.token_endpoint, access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + tokens.expires_in * 1000 }; } /** A token for the host: VECTRUM_TOKEN, a stored API token, or an OAuth access token (refreshed when due). */ async function accessToken(config, host) { if (process.env.VECTRUM_TOKEN) return process.env.VECTRUM_TOKEN; const entry = config.hosts[host]; if (!entry) fail(`not signed in to ${host}. Run \`vectrum auth login --host ${host}\`.`); if (entry.token) return entry.token; const oauth = entry.oauth; if (oauth.expires_at - 60_000 > Date.now()) return oauth.access_token; try { const tokens = await postForm(oauth.token_endpoint, { grant_type: "refresh_token", refresh_token: oauth.refresh_token, client_id: oauth.client_id, resource: `${host}/mcp` }); Object.assign(oauth, { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + tokens.expires_in * 1000 }); writeConfig(config); return oauth.access_token; } catch (e) { fail(`the sign-in to ${host} expired (${e.message}). Run \`vectrum auth login\` again.`); } } // --------------------------------------------------------------------------- // MCP let rpcId = 0; async function rpc(host, token, method, params) { const res = await fetch(`${host}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", "MCP-Protocol-Version": "2025-06-18" }, body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params }), }); if (res.status === 401) fail("the server didn't accept the credentials. Run `vectrum auth login` again."); const body = await res.json().catch(() => null); if (!body) fail(`unexpected answer from ${host}/mcp (HTTP ${res.status}).`); if (body.error) fail(body.error.message); return body.result; } async function connect() { const config = readConfig(); const host = currentHost(config); const token = await accessToken(config, host); return { host, /** Calls a tool and returns its answer, parsed from JSON when it is JSON. */ async call(name, args = {}) { const result = await rpc(host, token, "tools/call", { name, arguments: args }); const text = result.content?.map((c) => c.text ?? "").join("\n") ?? ""; if (result.isError) fail(text); try { return JSON.parse(text); } catch { return text; } }, listTools: () => rpc(host, token, "tools/list", {}).then((r) => r.tools), }; } // --------------------------------------------------------------------------- // Output const out = (text) => process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); const isTTY = process.stdout.isTTY && !process.env.NO_COLOR; const color = (code) => (s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s)); const dim = color("2"); const bold = color("1"); const green = color("32"); const red = color("31"); const yellow = color("33"); const magenta = color("35"); function show(value, format) { if (flags.json || !format) return out(typeof value === "string" ? value : JSON.stringify(value, null, 2)); out(format(value)); } const ago = (iso) => { if (!iso) return ""; const s = (Date.now() - Date.parse(iso)) / 1000; if (s < 60) return "just now"; if (s < 3600) return `${Math.round(s / 60)}m ago`; if (s < 86400) return `${Math.round(s / 3600)}h ago`; return `${Math.round(s / 86400)}d ago`; }; const stateColor = (s) => (/success|merged|done|approved|open/.test(s) ? (s === "merged" ? magenta(s) : green(s)) : /fail|closed|cancel|changes/.test(s) ? red(s) : yellow(s)); const formatIssues = (r) => (r.issues.length ? r.issues.map((i) => `${bold(`#${i.number}`)} ${i.title} ${dim(`${i.status} · ${i.priority_name}${i.assignee ? ` · ${i.assignee}` : ""}${i.labels.length ? ` · ${i.labels.join(", ")}` : ""}`)}`).join("\n") : dim("No issues.")); const formatPulls = (r) => r.pull_requests.length ? r.pull_requests.map((p) => `${bold(`#${p.number}`)} ${p.title} ${stateColor(p.draft ? "draft" : p.state)} ${dim(`${p.head} → ${p.base} · ${p.opened_by_agent ? `${p.opened_by_agent} for ` : ""}${p.author} · ${ago(p.updated_at)}`)}`).join("\n") : dim("No pull requests."); const formatRuns = (runs) => (runs.length ? runs.map((r) => `${bold(`#${r.number}`)} ${stateColor(r.status)} ${r.workflow} ${dim(`${r.event} · ${r.ref} · ${r.title} · ${ago(r.created_at)}`)}`).join("\n") : dim("No runs.")); function formatPull(p) { const lines = [ `${bold(p.title)} ${dim(`#${p.number}`)} ${stateColor(p.draft ? "draft" : p.state)}`, dim(`${p.opened_by_agent ? `${p.opened_by_agent} for ` : ""}${p.author} wants to merge ${p.commits.length} commit(s) from ${p.head} into ${p.base} · ${p.url}`), "", p.body || dim("No description."), "", ...(p.state === "open" ? [bold(p.status.mergeable ? "Ready to merge" : "Not ready to merge"), ...p.status.requirements.map((r) => ` ${r.ok ? green("✓") : red("✗")} ${r.requirement}${r.detail ? dim(` (${r.detail})`) : ""}`), ""] : []), `${bold("Checks")} ${stateColor(p.status.checks.state)}`, ...p.status.checks.runs.map((r) => ` ${stateColor(r.status)} ${r.workflow} ${dim(`#${r.number}`)}`), ]; if (p.files.length) lines.push("", bold("Files"), ...p.files.map((f) => ` ${f.status.padEnd(8)} ${f.path} ${green(`+${f.additions}`)} ${red(`-${f.deletions}`)}`)); if (p.reviews.length) lines.push("", bold("Reviews"), ...p.reviews.map((r) => ` ${stateColor(r.state)} ${r.author}${r.outdated ? dim(" (earlier commit)") : ""}${r.body ? `: ${r.body}` : ""}`)); const open = p.review_threads.filter((t) => !t.resolved); if (open.length) { lines.push("", bold(`Unresolved threads (${open.length})`)); for (const t of open) lines.push(` ${dim(`[${t.comment_id}]`)} ${t.path}:${t.line}${t.outdated ? dim(" (outdated)") : ""}`, ...t.comments.map((c) => ` ${bold(c.author)}: ${c.body}`)); } if (p.conversation.length) lines.push("", bold("Conversation"), ...p.conversation.map((c) => ` ${bold(c.author)} ${dim(ago(c.created_at))}\n ${c.body.replace(/\n/g, "\n ")}`)); return lines.join("\n"); } function formatChecks(r) { const lines = [`${bold(r.commit.slice(0, 7))} ${stateColor(r.state)}${r.timed_out ? yellow(" (still running)") : ""}`]; for (const run of r.runs) { lines.push(` ${stateColor(run.status)} ${run.workflow} ${dim(`#${run.number} · ${run.event} · ${run.url}`)}`); for (const f of run.failures ?? []) lines.push(red(` ✗ ${f.job} › ${f.step} (exit ${f.exit_code})`), dim(f.log_tail.split("\n").slice(-15).map((l) => ` ${l}`).join("\n"))); } if (r.note) lines.push(dim(r.note)); return lines.join("\n"); } const formatEvents = (r) => r.events.map((e) => `${dim(e.at)} ${dim(e.repository ?? "")} ${bold(e.event)}${e.action ? `.${e.action}` : ""} ${e.subject} ${dim(e.by ? `by ${e.by}` : "")}`).join("\n") || dim("No events."); // --------------------------------------------------------------------------- // Commands const need = (value, what) => (value === undefined || value === true ? fail(`${what} is required.`) : value); const num = (value, what = "a number") => { const n = Number(need(value, what)); if (!Number.isInteger(n) || n < 1) fail(`${what} must be a number.`); return n; }; /** key=value pairs for `tool`: values that parse as JSON are JSON (numbers, true, arrays), the rest strings. */ function toolArgs(pairs) { const args = flags.args ? JSON.parse(flags.args) : {}; for (const pair of pairs) { const eq = pair.indexOf("="); if (eq < 1) fail(`expected key=value, got ${pair}`); const raw = pair.slice(eq + 1); try { args[pair.slice(0, eq)] = JSON.parse(raw); } catch { args[pair.slice(0, eq)] = raw; } } return args; } async function readBody() { if (flags.body !== undefined && flags.body !== true) return flags.body === "-" ? readStdin() : flags.body; if (flags["body-file"]) return readFileSync(flags["body-file"], "utf8"); return ""; } function readStdin() { return new Promise((resolve) => { let data = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (data += d)); process.stdin.on("end", () => resolve(data)); }); } const HELP = `Vectrum ${VERSION}: work with a Vectrum server from the command line. Usage: vectrum [subcommand] [flags] auth login [--host URL] [--token vec_…] [--read-only] sign in (OAuth in the browser, or an API token) auth status | logout | token show, forget, or print the access token git-credential get git credential helper (see auth login's output) me who you are signed in as tasks your assigned issues and open pull requests (for agents) repo list | view | create [--private] [--description d] search [--path glob] [--regex] [--ref r] search the repository's code issue list [--state s] [--assignee who] | view | create -t title [-b body] issue comment -b body | close | reopen | assign pr list [--state s] | view | diff | checks [--wait] | create [-H head] [-B base] -t title [-b body] [--draft] pr comment -b body | review (--approve | --request-changes | --comment) [-b body] pr reply [-b body] [--resolve] | merge [--squash] [--delete-branch] | close | ready run list [--workflow f] | view | watch | logs | rerun | cancel checks [ref] [--wait] the workflow runs of a commit (the current branch by default) events [--follow] [--since cursor] what happens in your repositories tools every server tool tool [key=value …] [--args JSON] call one directly Flags: --repo owner/name (default: the clone's origin), --host URL, --json (raw answers). Bodies: -b text, -b - (stdin) or --body-file path.`; async function auth(sub) { const config = readConfig(); if (sub === "login") { const remote = gitRemote(); const parsed = remote && parseRemote(remote); const host = flags.host ? normalizeHost(flags.host) : process.env.VECTRUM_HOST ? normalizeHost(process.env.VECTRUM_HOST) : parsed?.https ? normalizeHost(remote.replace(/^(https?:\/\/)(?:[^@/]+@)?([^/]+).*/, "$1$2")) : null; if (!host) fail("pass the server with --host https://…"); const entry = flags.token ? { token: String(flags.token) } : { oauth: await oauthLogin(host).catch((e) => fail(e.message)) }; config.hosts[host] = entry; config.default ??= host; writeConfig(config); const me = await rpc(host, entry.token ?? entry.oauth.access_token, "tools/call", { name: "get_me", arguments: {} }); const who = JSON.parse(me.content[0].text); out(`Signed in to ${host} as ${bold(who.username)} (${who.connection.permission}, ${who.connection.repositories === "all" ? "all repositories" : who.connection.repositories.join(", ")}).`); out(dim(`To push with this sign-in over HTTPS: git config --global credential.${host}.helper "!node ${path.resolve(process.argv[1])} git-credential"`)); return; } if (sub === "logout") { const host = currentHost(config); delete config.hosts[host]; if (config.default === host) delete config.default; writeConfig(config); return out(`Signed out of ${host}.`); } if (sub === "token") return out(await accessToken(config, currentHost(config))); if (sub === "status" || !sub) { const hosts = Object.keys(config.hosts); if (!hosts.length) return out("Not signed in anywhere. Run `vectrum auth login --host `."); for (const host of hosts) { const e = config.hosts[host]; out(`${host}${config.default === host ? " (default)" : ""}: ${e.token ? "API token" : `OAuth, access token ${e.oauth.expires_at > Date.now() ? "valid" : "refreshes on next use"}`}`); } return; } fail(`unknown auth command ${sub}. See vectrum help.`); } /** git's credential helper protocol: hands out the access token as the password for this host. */ async function gitCredential(op) { if (op !== "get") return; const input = await readStdin(); const fields = Object.fromEntries(input.split("\n").filter(Boolean).map((l) => l.split(/=(.*)/s, 2))); const config = readConfig(); const host = Object.keys(config.hosts).find((h) => new URL(h).host === fields.host && new URL(h).protocol === `${fields.protocol}:`); if (!host) return; out(`username=vectrum\npassword=${await accessToken(config, host)}\n`); } async function currentBranch() { try { return execFileSync("git", ["branch", "--show-current"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null; } catch { return null; } } async function main() { const [command, sub, ...rest] = positional; if (!command || command === "help" || flags.help) return out(HELP); if (command === "version" || command === "--version") return out(VERSION); if (command === "auth") return auth(sub); if (command === "git-credential") return gitCredential(sub); const api = await connect(); const here = () => currentRepo(); switch (command) { case "me": return show(await api.call("get_me"), (m) => `${bold(m.username)} ${dim(`(${m.connection.permission} · agent name: ${m.agent})`)}`); case "tasks": return show(await api.call("get_my_tasks"), (t) => [ bold("Assigned issues"), ...(t.assigned_issues.length ? t.assigned_issues.map((i) => ` ${dim(i.repository)} ${bold(`#${i.number}`)} ${i.title} ${dim(`${i.status} · ${i.priority_name}`)}`) : [dim(" none")]), "", bold("Your pull requests"), ...(t.your_pull_requests.length ? t.your_pull_requests.map((p) => ` ${dim(p.repository)} ${bold(`#${p.number}`)} ${p.title} ${p.mergeable ? green("mergeable") : ""}\n${p.waiting_for.map((w) => ` ${yellow("…")} ${w}`).join("\n")}`) : [dim(" none")]), "", dim(t.hint), ].join("\n"), ); case "tools": { const tools = await api.listTools(); return show(tools, (list) => list.map((t) => `${bold(t.name.padEnd(28))} ${dim(t.description.split(". ")[0])}`).join("\n")); } case "tool": return show(await api.call(need(sub, "a tool name"), toolArgs(rest))); case "repo": { if (sub === "list") return show(await api.call("list_repositories", flags.owner ? { owner: flags.owner } : {}), (rs) => rs.map((r) => `${bold(r.full_name)} ${dim(`${r.visibility}${r.description ? ` · ${r.description}` : ""}`)}`).join("\n")); if (sub === "create") return show(await api.call("create_repository", { name: need(rest[0], "a name"), description: flags.description ?? "", visibility: flags.public ? "public" : "private" }), (r) => `Created ${bold(r.full_name)}: ${r.clone_urls.https}`); const r = here(); return show(await api.call("get_repository", r), (x) => [`${bold(x.full_name)} ${dim(x.visibility)}`, x.description ?? "", `default branch ${x.default_branch} · ${x.open_issues} open issues · ${x.open_pull_requests} open pull requests`, dim(x.agent_push.rule), x.url].join("\n")); } case "search": return show(await api.call("search_code", { ...here(), query: need(sub, "text to search for"), ...(flags.path ? { path: flags.path } : {}), ...(flags.ref ? { ref: flags.ref } : {}), regex: !!flags.regex, context_lines: Number(flags.context ?? 0) })); case "issue": return issueCommand(api, sub, rest); case "pr": return prCommand(api, sub, rest); case "run": return runCommand(api, sub, rest); case "checks": { const ref = sub ?? (await currentBranch()) ?? fail("pass a branch, tag or commit."); return show(await api.call("wait_for_checks", { ...here(), ref, timeout_seconds: flags.wait ? Number(flags.timeout ?? 300) : 0 }), formatChecks); } case "events": { // One repository (--repo, or the clone's), or with --all every repository you can reach. const args = { ...(!flags.all && (flags.repo || gitRemote()) ? here() : {}), limit: 50 }; let cursor = flags.since !== undefined ? Number(flags.since) : undefined; for (;;) { const r = await api.call("list_events", { ...args, ...(cursor !== undefined ? { since: cursor, wait_seconds: flags.follow ? 60 : 0 } : {}) }); if (r.events.length || !flags.follow) show(r, formatEvents); cursor = r.cursor; if (!flags.follow) return; } } default: fail(`unknown command ${command}. See vectrum help.`); } } async function issueCommand(api, sub, rest) { const r = currentRepo(); switch (sub) { case "list": case undefined: return show(await api.call("list_issues", { ...r, state: flags.state ?? "open", ...(flags.assignee ? { assignee: flags.assignee } : {}), ...(flags.label ? { label: flags.label } : {}) }), formatIssues); case "view": return show(await api.call("get_issue", { ...r, number: num(rest[0], "the issue number") }), (i) => [`${bold(i.title)} ${dim(`#${i.number}`)} ${stateColor(i.status)}`, dim(`${i.opened_by_agent ? `${i.opened_by_agent} for ` : ""}${i.author} · ${i.priority_name}${i.assignee ? ` · assigned to ${i.assignee}` : ""} · ${i.url}`), "", i.body || dim("No description."), ...i.comment_list.map((c) => `\n${bold(c.agent ? `${c.agent} for ${c.author}` : c.author)} ${dim(ago(c.created_at))}\n${c.body}`)].join("\n"), ); case "create": return show(await api.call("create_issue", { ...r, title: need(flags.title, "--title"), body: await readBody(), ...(flags.assignee ? { assignee: flags.assignee } : {}) }), (x) => `Opened ${bold(`#${x.number}`)}: ${x.url}`); case "comment": return show(await api.call("comment_on_issue", { ...r, number: num(rest[0], "the issue number"), body: need(await readBody(), "--body") }), (x) => x.url); case "close": case "reopen": return show(await api.call("update_issue", { ...r, number: num(rest[0], "the issue number"), status: sub === "close" ? (flags.canceled ? "canceled" : "done") : "todo" }), (x) => `#${x.number} is ${x.status}.`); case "assign": return show(await api.call("update_issue", { ...r, number: num(rest[0], "the issue number"), assignee: rest[1] ?? "me" }), (x) => `#${x.number} is assigned to ${x.assignee ?? "nobody"}.`); default: fail(`unknown issue command ${sub}. See vectrum help.`); } } async function prCommand(api, sub, rest) { const r = currentRepo(); const n = () => num(rest[0], "the pull request number"); switch (sub) { case "list": case undefined: return show(await api.call("list_pull_requests", { ...r, state: flags.state ?? "open" }), formatPulls); case "view": return show(await api.call("get_pull_request", { ...r, number: n() }), formatPull); case "diff": return show(await api.call("get_pull_request_diff", { ...r, number: n() })); case "checks": return show(await api.call("wait_for_checks", { ...r, pull_request: n(), timeout_seconds: flags.wait ? Number(flags.timeout ?? 300) : 0 }), formatChecks); case "create": { const head = flags.head ?? (await currentBranch()) ?? fail("pass --head ."); return show( await api.call("create_pull_request", { ...r, head, ...(flags.base ? { base: flags.base } : {}), title: need(flags.title, "--title"), body: await readBody(), draft: !!flags.draft }), (p) => `Opened ${bold(`#${p.number}`)}: ${p.url}\n${p.status.requirements.filter((x) => !x.ok).map((x) => ` ${yellow("…")} ${x.requirement}`).join("\n")}`, ); } case "comment": return show(await api.call("comment_on_pull_request", { ...r, number: n(), body: need(await readBody(), "--body") }), (x) => x.url); case "review": { const event = flags.approve ? "approve" : flags["request-changes"] ? "request_changes" : "comment"; return show(await api.call("create_review", { ...r, number: n(), event, body: await readBody() }), (x) => `Review submitted (${x.state}): ${x.url}`); } case "reply": return show( await api.call("reply_to_review_comment", { ...r, number: n(), comment_id: need(rest[1], "the comment id"), body: await readBody(), ...(flags.resolve ? { resolve: true } : flags.unresolve ? { resolve: false } : {}) }), (x) => `Replied${x.resolved ? " and resolved" : ""}: ${x.url}`, ); case "merge": return show(await api.call("merge_pull_request", { ...r, number: n(), method: flags.squash ? "squash" : "merge", delete_branch: !!flags["delete-branch"] }), (p) => `Merged ${bold(`#${p.number}`)} as ${p.merge_commit.slice(0, 7)}.`); case "close": case "reopen": return show(await api.call("update_pull_request", { ...r, number: n(), state: sub === "close" ? "closed" : "open" }), (p) => `#${p.number} is ${p.state}.`); case "ready": return show(await api.call("update_pull_request", { ...r, number: n(), draft: false }), (p) => `#${p.number} is ready for review.`); default: fail(`unknown pr command ${sub}. See vectrum help.`); } } async function runCommand(api, sub, rest) { const r = currentRepo(); const n = () => num(rest[0], "the run number"); switch (sub) { case "list": case undefined: return show(await api.call("list_workflow_runs", { ...r, ...(flags.workflow ? { workflow: flags.workflow } : {}), limit: Number(flags.limit ?? 20) }), formatRuns); case "view": return show(await api.call("get_workflow_run", { ...r, number: n() }), (x) => [`${bold(`${x.workflow} #${x.number}`)} ${stateColor(x.status)} ${dim(`${x.event} · ${x.ref} · ${x.url}`)}`, ...x.jobs.map((j) => ` ${stateColor(j.status)} ${j.key}${j.steps.filter((s) => s.status === "failure").map((s) => red(`\n ✗ ${s.name}`)).join("")}`)].join("\n")); case "watch": return show(await api.call("wait_for_workflow_run", { ...r, number: n(), timeout_seconds: Number(flags.timeout ?? 300) }), (x) => formatChecks({ commit: x.sha, state: x.status, timed_out: x.timed_out, runs: [x] })); case "logs": return show(await api.call("get_job_logs", { ...r, number: n(), job: need(rest[1], "the job"), tail_lines: Number(flags.lines ?? 200) })); case "rerun": return show(await api.call("rerun_workflow_run", { ...r, number: n() }), (x) => `Started run #${x.number}: ${x.url}`); case "cancel": return show(await api.call("cancel_workflow_run", { ...r, number: n() })); default: fail(`unknown run command ${sub}. See vectrum help.`); } } if (!existsSync(CONFIG_DIR) && positional[0] !== "auth" && !process.env.VECTRUM_TOKEN && positional[0] && positional[0] !== "help") { // Nothing configured yet: say how to start rather than failing on a missing host. if (!flags.host && !process.env.VECTRUM_HOST) fail("not signed in. Run `vectrum auth login --host ` (or set VECTRUM_HOST and VECTRUM_TOKEN)."); } main().catch((e) => fail(e instanceof Error ? e.message : String(e)));