#!/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 <url>`, 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(`<!doctype html><title>Vectrum</title><p style="font:16px system-ui;margin:3rem">${message}</p>`);
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 <command> [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 <name> [--private] [--description d]
search <text> [--path glob] [--regex] [--ref r] search the repository's code
issue list [--state s] [--assignee who] | view <n> | create -t title [-b body]
issue comment <n> -b body | close <n> | reopen <n> | assign <n> <who|me>
pr list [--state s] | view <n> | diff <n> | checks <n> [--wait] | create [-H head] [-B base] -t title [-b body] [--draft]
pr comment <n> -b body | review <n> (--approve | --request-changes | --comment) [-b body]
pr reply <n> <comment-id> [-b body] [--resolve] | merge <n> [--squash] [--delete-branch] | close <n> | ready <n>
run list [--workflow f] | view <n> | watch <n> | logs <n> <job> | rerun <n> | cancel <n>
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 <name> [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 <url>`.");
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 <branch>.");
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 <url>` (or set VECTRUM_HOST and VECTRUM_TOKEN).");
}
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));