Plugin Claude Code miroir du plugin codex officiel d'OpenAI, mais pour Google Antigravity (agy CLI). Headless via: agy --print --dangerously-skip-permissions --print-timeout 10m Includes: - 1 forwarder agent (antigravity-rescue) - 5 slash commands (setup, rescue, status, result, cancel) - 3 internal skills (cli-runtime, result-handling, gemini-prompting) - agy-companion.mjs runtime (task / setup / status / result / cancel) - marketplace.json for `/plugin marketplace add` Tested: setup OK, foreground task OK, background workflow OK (except OAuth refresh which requires interactive TTY). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
12 KiB
JavaScript
Executable File
345 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Antigravity (agy) companion — minimal runtime for Claude Code plugin.
|
|
// Mirrors the codex-companion pattern but limited to `task` and `setup`.
|
|
// Headless execution: `agy --print --dangerously-skip-permissions --print-timeout <T>`.
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min foreground default
|
|
const DEFAULT_BG_TIMEOUT_MS = 30 * 60 * 1000; // 30 min background default
|
|
const STATE_DIR = path.join(os.homedir(), ".cache", "antigravity-plugin");
|
|
const JOBS_DIR = path.join(STATE_DIR, "jobs");
|
|
const LAST_THREAD_FILE = path.join(STATE_DIR, "last-thread.json");
|
|
|
|
function ensureDir(p) { try { fs.mkdirSync(p, { recursive: true }); } catch {} }
|
|
|
|
// ---- which agy binary ----------------------------------------------------
|
|
function findAgyBinary() {
|
|
const candidates = [
|
|
process.env.AGY_BINARY,
|
|
path.join(os.homedir(), ".local", "bin", "agy"),
|
|
"/usr/local/bin/agy",
|
|
"/opt/homebrew/bin/agy"
|
|
].filter(Boolean);
|
|
for (const c of candidates) {
|
|
try { fs.accessSync(c, fs.constants.X_OK); return c; } catch {}
|
|
}
|
|
// PATH lookup
|
|
const res = spawnSync("which", ["agy"], { encoding: "utf8" });
|
|
if (res.status === 0) return res.stdout.trim();
|
|
return null;
|
|
}
|
|
|
|
function getAgyVersion(bin) {
|
|
try {
|
|
const res = spawnSync(bin, ["--help"], { encoding: "utf8", timeout: 5000 });
|
|
if (res.status === 0) return "available";
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
// ---- CLI args -------------------------------------------------------------
|
|
const BOOL_FLAGS = new Set([
|
|
"background", "sandbox", "resume", "resume-last", "fresh", "json", "wait"
|
|
]);
|
|
const VALUE_FLAGS = new Set([
|
|
"add-dir", "timeout", "model", "effort"
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith("--")) {
|
|
const eq = a.indexOf("=");
|
|
const key = eq > -1 ? a.slice(2, eq) : a.slice(2);
|
|
if (eq > -1) { out[key] = a.slice(eq + 1); }
|
|
else if (BOOL_FLAGS.has(key)) { out[key] = true; }
|
|
else if (VALUE_FLAGS.has(key) && argv[i + 1] !== undefined) { out[key] = argv[++i]; }
|
|
else { out[key] = true; }
|
|
} else {
|
|
out._.push(a);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- setup command -------------------------------------------------------
|
|
function setup(args) {
|
|
const bin = findAgyBinary();
|
|
const installed = !!bin;
|
|
const result = {
|
|
cli: "agy",
|
|
installed,
|
|
binary_path: bin || null,
|
|
headless_mode: installed ? "agy --print --dangerously-skip-permissions" : null,
|
|
install_command: installed ? null : "curl -fsSL https://antigravity.google/cli/install.sh | bash",
|
|
notes: installed
|
|
? "Antigravity CLI is installed. Use `/antigravity:rescue` to delegate tasks."
|
|
: "Antigravity CLI not found. Install via the official one-liner, then re-run /antigravity:setup."
|
|
};
|
|
if (args.json) {
|
|
console.log(JSON.stringify(result, null, 2));
|
|
} else {
|
|
console.log("Antigravity CLI status\n----------------------");
|
|
console.log(`Installed : ${result.installed ? "✓" : "✗"}`);
|
|
if (result.binary_path) console.log(`Binary path : ${result.binary_path}`);
|
|
if (result.headless_mode) console.log(`Headless cmd : ${result.headless_mode}`);
|
|
if (result.install_command) console.log(`Install command : ${result.install_command}`);
|
|
console.log(`\n${result.notes}`);
|
|
}
|
|
process.exit(installed ? 0 : 1);
|
|
}
|
|
|
|
// ---- task command --------------------------------------------------------
|
|
function buildPromptText(args) {
|
|
return (args._.join(" ") || "").trim();
|
|
}
|
|
|
|
function runTaskForeground(prompt, opts) {
|
|
const bin = opts.bin;
|
|
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
const timeoutStr = Math.floor(timeoutMs / 1000) + "s";
|
|
|
|
const cliArgs = [
|
|
"--print",
|
|
"--dangerously-skip-permissions",
|
|
"--print-timeout", timeoutStr
|
|
];
|
|
if (opts.continueLast) cliArgs.push("--continue");
|
|
if (opts.addDir) cliArgs.push("--add-dir", opts.addDir);
|
|
if (opts.sandbox) cliArgs.push("--sandbox");
|
|
cliArgs.push(prompt);
|
|
|
|
// Use spawn to stream output to stdout in real-time
|
|
const child = spawn(bin, cliArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
let stdoutBuf = "";
|
|
let stderrBuf = "";
|
|
child.stdout.on("data", (d) => {
|
|
const s = d.toString();
|
|
stdoutBuf += s;
|
|
process.stdout.write(s);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
const s = d.toString();
|
|
stderrBuf += s;
|
|
process.stderr.write(s);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
try { child.kill("SIGTERM"); } catch {}
|
|
process.stderr.write("\n[antigravity] timeout reached (" + timeoutStr + ")\n");
|
|
resolve({ code: 124, stdout: stdoutBuf, stderr: stderrBuf, timedOut: true });
|
|
}, timeoutMs);
|
|
child.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
resolve({ code, stdout: stdoutBuf, stderr: stderrBuf, timedOut: false });
|
|
});
|
|
});
|
|
}
|
|
|
|
function runTaskBackground(prompt, opts) {
|
|
ensureDir(JOBS_DIR);
|
|
const jobId = "agy-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 7);
|
|
const jobFile = path.join(JOBS_DIR, jobId + ".json");
|
|
const logFile = path.join(JOBS_DIR, jobId + ".log");
|
|
|
|
const bin = opts.bin;
|
|
const timeoutMs = opts.timeoutMs || DEFAULT_BG_TIMEOUT_MS;
|
|
const timeoutStr = Math.floor(timeoutMs / 1000) + "s";
|
|
|
|
const cliArgs = [
|
|
"--print",
|
|
"--dangerously-skip-permissions",
|
|
"--print-timeout", timeoutStr
|
|
];
|
|
if (opts.continueLast) cliArgs.push("--continue");
|
|
if (opts.addDir) cliArgs.push("--add-dir", opts.addDir);
|
|
if (opts.sandbox) cliArgs.push("--sandbox");
|
|
cliArgs.push(prompt);
|
|
|
|
const logFd = fs.openSync(logFile, "a");
|
|
const child = spawn(bin, cliArgs, { detached: true, stdio: ["ignore", logFd, logFd] });
|
|
|
|
const meta = {
|
|
jobId,
|
|
pid: child.pid,
|
|
startedAt: new Date().toISOString(),
|
|
cmd: bin + " " + cliArgs.join(" "),
|
|
prompt,
|
|
logFile,
|
|
timeoutMs
|
|
};
|
|
fs.writeFileSync(jobFile, JSON.stringify(meta, null, 2));
|
|
child.unref();
|
|
fs.closeSync(logFd);
|
|
|
|
console.log(`[antigravity] Job started in background.`);
|
|
console.log(` job id : ${jobId}`);
|
|
console.log(` pid : ${child.pid}`);
|
|
console.log(` log file : ${logFile}`);
|
|
console.log(` status : node "${process.argv[1]}" status ${jobId}`);
|
|
console.log(` result : node "${process.argv[1]}" result ${jobId}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
async function task(args) {
|
|
const bin = findAgyBinary();
|
|
if (!bin) {
|
|
console.error("[antigravity] agy not found in PATH. Run /antigravity:setup first.");
|
|
process.exit(2);
|
|
}
|
|
const prompt = buildPromptText(args);
|
|
if (!prompt) {
|
|
console.error("[antigravity] empty task prompt.");
|
|
process.exit(2);
|
|
}
|
|
|
|
const opts = {
|
|
bin,
|
|
continueLast: !!args["resume-last"] || !!args["resume"],
|
|
addDir: args["add-dir"] || null,
|
|
sandbox: !!args.sandbox,
|
|
timeoutMs: args.timeout ? parseTimeout(args.timeout) : null
|
|
};
|
|
|
|
// Save last-thread marker so future --resume can be smart
|
|
ensureDir(STATE_DIR);
|
|
fs.writeFileSync(LAST_THREAD_FILE, JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
prompt: prompt.slice(0, 200),
|
|
bin
|
|
}, null, 2));
|
|
|
|
if (args.background) {
|
|
runTaskBackground(prompt, opts);
|
|
return;
|
|
}
|
|
const r = await runTaskForeground(prompt, opts);
|
|
process.exit(r.code || 0);
|
|
}
|
|
|
|
function parseTimeout(v) {
|
|
if (typeof v === "number") return v;
|
|
const m = String(v).match(/^(\d+)([sm]?)$/i);
|
|
if (!m) return DEFAULT_TIMEOUT_MS;
|
|
const n = parseInt(m[1], 10);
|
|
const unit = (m[2] || "s").toLowerCase();
|
|
return unit === "m" ? n * 60_000 : n * 1000;
|
|
}
|
|
|
|
// ---- status / result -----------------------------------------------------
|
|
function status(args) {
|
|
ensureDir(JOBS_DIR);
|
|
const jobs = fs.readdirSync(JOBS_DIR).filter(f => f.endsWith(".json")).map(f => {
|
|
try { return JSON.parse(fs.readFileSync(path.join(JOBS_DIR, f), "utf8")); } catch { return null; }
|
|
}).filter(Boolean);
|
|
const wanted = args._[0];
|
|
const target = wanted ? jobs.find(j => j.jobId === wanted) : jobs.sort((a,b) => (b.startedAt||'').localeCompare(a.startedAt||''))[0];
|
|
if (!target) { console.log("[antigravity] no background jobs found."); process.exit(0); }
|
|
const alive = isPidAlive(target.pid);
|
|
const out = {
|
|
jobId: target.jobId,
|
|
pid: target.pid,
|
|
startedAt: target.startedAt,
|
|
alive,
|
|
logFile: target.logFile,
|
|
promptPreview: (target.prompt || "").slice(0, 200)
|
|
};
|
|
if (args.json) console.log(JSON.stringify(out, null, 2));
|
|
else {
|
|
console.log(`Job ${target.jobId}`);
|
|
console.log(` pid : ${target.pid} (${alive ? "running" : "exited"})`);
|
|
console.log(` started : ${target.startedAt}`);
|
|
console.log(` log : ${target.logFile}`);
|
|
console.log(` prompt : ${out.promptPreview}`);
|
|
}
|
|
}
|
|
|
|
function isPidAlive(pid) {
|
|
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
}
|
|
|
|
function result(args) {
|
|
ensureDir(JOBS_DIR);
|
|
const wanted = args._[0];
|
|
let job;
|
|
if (wanted) {
|
|
const f = path.join(JOBS_DIR, wanted + ".json");
|
|
try { job = JSON.parse(fs.readFileSync(f, "utf8")); } catch {}
|
|
} else {
|
|
const list = fs.readdirSync(JOBS_DIR).filter(f => f.endsWith(".json")).map(f => {
|
|
try { return JSON.parse(fs.readFileSync(path.join(JOBS_DIR, f), "utf8")); } catch { return null; }
|
|
}).filter(Boolean).sort((a,b) => (b.startedAt||'').localeCompare(a.startedAt||''));
|
|
job = list[0];
|
|
}
|
|
if (!job) { console.error("[antigravity] no job found."); process.exit(1); }
|
|
try {
|
|
const log = fs.readFileSync(job.logFile, "utf8");
|
|
process.stdout.write(log);
|
|
process.exit(0);
|
|
} catch (e) {
|
|
console.error("[antigravity] log file not readable: " + (e?.message || e));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function cancel(args) {
|
|
ensureDir(JOBS_DIR);
|
|
const wanted = args._[0];
|
|
if (!wanted) { console.error("[antigravity] cancel requires a job id."); process.exit(2); }
|
|
const f = path.join(JOBS_DIR, wanted + ".json");
|
|
let job;
|
|
try { job = JSON.parse(fs.readFileSync(f, "utf8")); } catch { console.error("[antigravity] job not found."); process.exit(1); }
|
|
try { process.kill(job.pid, "SIGTERM"); console.log("[antigravity] sent SIGTERM to " + job.pid); }
|
|
catch (e) { console.error("[antigravity] could not signal pid " + job.pid + ": " + (e?.message||e)); process.exit(1); }
|
|
}
|
|
|
|
function taskResumeCandidate(args) {
|
|
let available = false, thread = null;
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(LAST_THREAD_FILE, "utf8"));
|
|
if (data && data.ts) {
|
|
const ageMs = Date.now() - Date.parse(data.ts);
|
|
if (ageMs < 24 * 3600 * 1000) { available = true; thread = data; }
|
|
}
|
|
} catch {}
|
|
const out = { available, thread };
|
|
if (args.json) console.log(JSON.stringify(out, null, 2));
|
|
else console.log(available ? "Resumable thread available." : "No resumable thread.");
|
|
}
|
|
|
|
// ---- entry ---------------------------------------------------------------
|
|
function printUsage() {
|
|
console.log(`Antigravity companion — usage:
|
|
setup [--json] check agy availability
|
|
task [--background] [--resume] [--add-dir <p>] run a task (foreground default)
|
|
[--sandbox] [--timeout 10m] <prompt>
|
|
status [<jobId>] [--json] show background job status
|
|
result [<jobId>] print background job log
|
|
cancel <jobId> SIGTERM a background job
|
|
task-resume-candidate [--json] check resumable thread
|
|
`);
|
|
}
|
|
|
|
(async function main() {
|
|
const argv = process.argv.slice(2);
|
|
const sub = argv.shift();
|
|
const args = parseArgs(argv);
|
|
switch (sub) {
|
|
case "setup": return setup(args);
|
|
case "task": return task(args);
|
|
case "status": return status(args);
|
|
case "result": return result(args);
|
|
case "cancel": return cancel(args);
|
|
case "task-resume-candidate": return taskResumeCandidate(args);
|
|
case "-h": case "--help": case undefined: return printUsage();
|
|
default: console.error("[antigravity] unknown subcommand: " + sub); printUsage(); process.exit(2);
|
|
}
|
|
})();
|