Initial commit — Antigravity plugin v0.1.0

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>
This commit is contained in:
2026-05-19 23:57:41 +02:00
commit e8ad489f36
14 changed files with 869 additions and 0 deletions

344
scripts/agy-companion.mjs Executable file
View File

@@ -0,0 +1,344 @@
#!/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);
}
})();