@elalfner/relevo 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.es.md +2 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/agent-guard.mjs +94 -0
- package/scripts/cli.mjs +48 -12
- package/scripts/doctor.mjs +10 -0
- package/scripts/init.mjs +1 -0
- package/scripts/lib/adapters/antigravity.mjs +99 -0
- package/scripts/lib/adapters/index.mjs +6 -0
- package/scripts/lib/adapters/install.mjs +127 -0
- package/scripts/lib/adapters/source.mjs +65 -0
- package/scripts/lib/adapters/tools.mjs +52 -0
- package/scripts/lib/agent-guard.mjs +188 -0
- package/scripts/lib/agent-workflow.mjs +46 -0
- package/scripts/lib/cli.mjs +7 -3
- package/scripts/lib/config.mjs +18 -7
- package/scripts/lib/doctor.mjs +65 -4
- package/scripts/lib/go-local.mjs +31 -4
- package/scripts/lib/init-tools.mjs +97 -0
- package/scripts/lib/init.mjs +29 -2
- package/scripts/lib/protected-paths.mjs +10 -4
- package/scripts/lib/run-usage.mjs +1 -1
- package/scripts/lib/select.mjs +58 -0
- package/skills/init/SKILL.md +3 -2
- package/skills/review/SKILL.md +10 -0
- package/stacks/supabase.md +63 -0
- package/templates/agents/antigravity-step.yml +70 -0
- package/templates/deploy-supabase.yml +75 -0
- package/templates/workflows/relevo-digest.yml +1 -1
- package/templates/workflows/relevo-go.yml +5 -1
- package/templates/workflows/relevo-queue.yml +3 -1
- package/templates/workflows/relevo-review.yml +1 -1
- package/templates/workflows/relevo-sla.yml +1 -1
package/README.es.md
CHANGED
|
@@ -33,6 +33,8 @@ npm install -g @elalfner/relevo
|
|
|
33
33
|
relevo install
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
Para usar Relevo desde Antigravity (el IDE, la App o la CLI), ejecuta `relevo init`: pregunta qué herramientas usa tu equipo e instala un plugin generado para cada una. Los detalles están en [Instalación](docs/install.es.md#otras-herramientas-antigravity).
|
|
37
|
+
|
|
36
38
|
Reinicia Claude Code en tu proyecto y ejecuta `/relevo:doctor` para ver qué falta todavía. Qué necesitas y los primeros pasos están en [Instalación](docs/install.es.md).
|
|
37
39
|
|
|
38
40
|
## Cómo funciona
|
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ npm install -g @elalfner/relevo
|
|
|
33
33
|
relevo install
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
To use Relevo from Antigravity (the IDE, the App or the CLI), run `relevo init`: it asks which tools your team uses and installs a plugin rendered for each. Details in [Install](docs/install.md#other-tools-antigravity).
|
|
37
|
+
|
|
36
38
|
Restart Claude Code in your project and run `/relevo:doctor` to see what is still missing. What you need and the first steps are in [Install](docs/install.md).
|
|
37
39
|
|
|
38
40
|
## How it works
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The run guard and the settings of an Antigravity run. Usage:
|
|
3
|
+
// node scripts/agent-guard.mjs settings --out <file> [--platform <name>] [--config relevo.yml] [--plugin-root <dir>]
|
|
4
|
+
// writes the user-level settings of `agy` for the run: the key provider, the denied web and the allowed commands
|
|
5
|
+
// node scripts/agent-guard.mjs run --prompt <text> --timeout-minutes <n> --summary <file> [--config relevo.yml]
|
|
6
|
+
// [--max-turns <n>] [--max-tokens <n>] [--github-output <file>] [--agy <path>]
|
|
7
|
+
// starts `agy -p` under the limits and writes the summary of the run (the same shape that run-usage.mjs reads).
|
|
8
|
+
// The limits are limits.run_max_turns and limits.run_max_tokens of relevo.yml; a flag can only make one lower.
|
|
9
|
+
// exit codes of `run`: 0 success, 1 the agent failed, 10 a tool was refused, 11 turn limit, 12 token limit, 13 time limit,
|
|
10
|
+
// 14 no result; 2 bad usage. The agent's output is never printed: the log has one line per step, and the final answer.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { EXIT, agySettings, runGuard } from './lib/agent-guard.mjs';
|
|
16
|
+
import { loadConfig } from './lib/config.mjs';
|
|
17
|
+
import { resolveProfile } from './lib/stack-profile.mjs';
|
|
18
|
+
|
|
19
|
+
const [mode, ...rest] = process.argv.slice(2);
|
|
20
|
+
const arg = (name) => {
|
|
21
|
+
const i = rest.indexOf(`--${name}`);
|
|
22
|
+
return i === -1 ? undefined : rest[i + 1];
|
|
23
|
+
};
|
|
24
|
+
const usage = (message) => {
|
|
25
|
+
console.error(`${message}\nusage: agent-guard.mjs settings --out <file> [--platform <name>] | run --prompt <text> --max-turns <n> --max-tokens <n> --timeout-minutes <n> --summary <file>`);
|
|
26
|
+
process.exit(EXIT.usage);
|
|
27
|
+
};
|
|
28
|
+
const positive = (name, fallback = null) => {
|
|
29
|
+
const given = arg(name);
|
|
30
|
+
const v = given === undefined && fallback !== null ? fallback : Number(given);
|
|
31
|
+
if (!Number.isFinite(v) || v <= 0) usage(`--${name} must be a number greater than zero${fallback === null ? '' : ' (or set it in relevo.yml)'}`);
|
|
32
|
+
return v;
|
|
33
|
+
};
|
|
34
|
+
const lower = (flag, fromConfig) => (arg(flag) === undefined || fromConfig === null ? positive(flag, fromConfig) : Math.min(positive(flag), fromConfig));
|
|
35
|
+
|
|
36
|
+
if (mode === 'settings') {
|
|
37
|
+
const out = arg('out');
|
|
38
|
+
if (!out) usage('settings needs --out');
|
|
39
|
+
const pluginRoot = resolve(arg('plugin-root') ?? resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
40
|
+
let profile = null;
|
|
41
|
+
const configPath = resolve(arg('config') ?? 'relevo.yml');
|
|
42
|
+
if (existsSync(configPath)) {
|
|
43
|
+
const loaded = loadConfig(readFileSync(configPath, 'utf8'));
|
|
44
|
+
if (!loaded.ok) usage(`relevo.yml is invalid:\n${loaded.errors.map((e) => ` ${e.message}`).join('\n')}`);
|
|
45
|
+
const names = Object.keys(loaded.config.platforms);
|
|
46
|
+
const name = arg('platform') ?? (names.length === 1 ? names[0] : undefined);
|
|
47
|
+
if (!name || !loaded.config.platforms[name]) usage(`the platform must be one of: ${names.join(', ')}`);
|
|
48
|
+
const base = dirname(configPath);
|
|
49
|
+
const read = (p) => {
|
|
50
|
+
const file = p.startsWith('/') ? p : resolve(base, p);
|
|
51
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : null;
|
|
52
|
+
};
|
|
53
|
+
const resolved = resolveProfile({ platform: loaded.config.platforms[name], pluginRoot, read });
|
|
54
|
+
if (!resolved.ok) usage(resolved.errors.join('\n'));
|
|
55
|
+
profile = resolved.profile;
|
|
56
|
+
}
|
|
57
|
+
mkdirSync(dirname(resolve(out)), { recursive: true });
|
|
58
|
+
writeFileSync(resolve(out), `${JSON.stringify(agySettings({ profile }), null, 2)}\n`);
|
|
59
|
+
console.log(`settings written to ${out}`);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (mode === 'run') {
|
|
64
|
+
const prompt = arg('prompt');
|
|
65
|
+
const summaryFile = arg('summary');
|
|
66
|
+
if (!prompt || !summaryFile) usage('run needs --prompt and --summary');
|
|
67
|
+
let fromConfig = { turns: null, tokens: null };
|
|
68
|
+
const configPath = resolve(arg('config') ?? 'relevo.yml');
|
|
69
|
+
if (arg('config') !== undefined || existsSync(configPath)) {
|
|
70
|
+
if (!existsSync(configPath)) usage(`relevo.yml not found at ${configPath}`);
|
|
71
|
+
const loaded = loadConfig(readFileSync(configPath, 'utf8'));
|
|
72
|
+
if (!loaded.ok) usage(`relevo.yml is invalid:\n${loaded.errors.map((e) => ` ${e.message}`).join('\n')}`);
|
|
73
|
+
fromConfig = { turns: loaded.config.limits.run_max_turns ?? null, tokens: loaded.config.limits.run_max_tokens ?? null };
|
|
74
|
+
}
|
|
75
|
+
const limits = { turns: lower('max-turns', fromConfig.turns), tokens: lower('max-tokens', fromConfig.tokens), minutes: positive('timeout-minutes') };
|
|
76
|
+
const agy = arg('agy') ?? 'agy';
|
|
77
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--print-timeout', `${Math.ceil(limits.minutes) + 1}m`];
|
|
78
|
+
const start = (command, a) => {
|
|
79
|
+
const child = spawn(command, a, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
80
|
+
child.stdout.setEncoding('utf8');
|
|
81
|
+
child.stderr.setEncoding('utf8');
|
|
82
|
+
child.on('error', (err) => child.stderr.emit('data', `${err.message}\n`));
|
|
83
|
+
return { pid: child.pid, stdout: child.stdout, stderr: child.stderr, kill: (signal) => process.kill(-child.pid, signal), on: (ev, cb) => child.on(ev, cb) };
|
|
84
|
+
};
|
|
85
|
+
const { code, summary, response } = await runGuard({ command: agy, args, limits, spawn: start, log: (line) => console.error(line) });
|
|
86
|
+
mkdirSync(dirname(resolve(summaryFile)), { recursive: true });
|
|
87
|
+
writeFileSync(resolve(summaryFile), `${JSON.stringify(summary, null, 2)}\n`);
|
|
88
|
+
if (arg('github-output')) appendFileSync(arg('github-output'), `execution_file=${resolve(summaryFile)}\n`);
|
|
89
|
+
if (response) console.log(response);
|
|
90
|
+
console.error(`relevo guard: ${summary.num_turns} turns, ${summary.usage.input_tokens + summary.usage.output_tokens} tokens, ${summary.duration_seconds}s, exit ${code}`);
|
|
91
|
+
process.exit(code);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
usage(`unknown mode "${mode ?? ''}"`);
|
package/scripts/cli.mjs
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// relevo install | update | doctor | version. See scripts/lib/cli.mjs. `install`
|
|
3
|
-
// terminal, with the project preselected.
|
|
2
|
+
// relevo init | install | update | doctor | version. See scripts/lib/cli.mjs and scripts/lib/init-tools.mjs. `install`
|
|
3
|
+
// and `init` without --scope ask where, in a terminal, with the project preselected; `init` also asks for the tools.
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { USAGE, execute, isInsideGitRepo, parseArgs, resolveScope, scopeChoices } from './lib/cli.mjs';
|
|
8
|
-
import {
|
|
9
|
+
import { TOOLS, initScopeChoices, parseInitArgs, repoRoot, resolveInitScope, runInit, runUpdate } from './lib/init-tools.mjs';
|
|
10
|
+
import { select, selectMany } from './lib/select.mjs';
|
|
9
11
|
|
|
10
12
|
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
11
|
-
const
|
|
13
|
+
const argv = process.argv.slice(2);
|
|
14
|
+
const parsed = argv[0] === 'init' ? parseInitArgs(argv.slice(1)) : parseArgs(argv);
|
|
12
15
|
if (parsed.error) {
|
|
13
16
|
process.stderr.write(`${parsed.error}\n${USAGE}\n`);
|
|
14
17
|
process.exit(2);
|
|
@@ -24,11 +27,48 @@ const run = (cmd, args) => {
|
|
|
24
27
|
};
|
|
25
28
|
const has = (cmd) => spawnSync(cmd, ['--version'], { encoding: 'utf8' }).error === undefined;
|
|
26
29
|
const version = JSON.parse(readFileSync(here('../package.json'), 'utf8')).version;
|
|
30
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
31
|
+
const deps = { run, has, version, doctorScript: here('./doctor.mjs'), cwd: process.cwd(), packageRoot: here('..'), home: homedir(), root: repoRoot(run) };
|
|
32
|
+
const cancel = () => {
|
|
33
|
+
process.stdout.write('Cancelled: nothing was installed.\n');
|
|
34
|
+
process.exit(130);
|
|
35
|
+
};
|
|
36
|
+
const finish = ({ code, lines }) => {
|
|
37
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
38
|
+
process.exit(code);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (parsed.command === 'init') {
|
|
42
|
+
const inRepo = isInsideGitRepo(run);
|
|
43
|
+
let { tools, scope } = parsed;
|
|
44
|
+
if (!tools) {
|
|
45
|
+
if (!interactive) {
|
|
46
|
+
process.stderr.write(`init needs --tools without a terminal: ${TOOLS.map((t) => t.value).join(', ')}\n`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
tools = await selectMany({ title: 'Which tools does your team use?', choices: TOOLS, initial: ['claude'], input: process.stdin, output: process.stdout });
|
|
50
|
+
if (tools === null) cancel();
|
|
51
|
+
if (tools.length === 0) {
|
|
52
|
+
process.stdout.write('No tool chosen: nothing was installed.\n');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const resolved = resolveInitScope({ given: scope, interactive: interactive && !parsed.yes, inRepo });
|
|
57
|
+
if (resolved.ask) {
|
|
58
|
+
const choices = initScopeChoices({ cwd: process.cwd(), inRepo });
|
|
59
|
+
scope = await select({ title: 'Where should Relevo be installed?', choices, defaultIndex: Math.max(0, choices.findIndex((c) => c.value === resolved.preferred)), input: process.stdin, output: process.stdout });
|
|
60
|
+
if (scope === null) cancel();
|
|
61
|
+
} else {
|
|
62
|
+
scope = resolved.scope;
|
|
63
|
+
if (resolved.reason) process.stdout.write(`relevo: ${resolved.reason}.\n`);
|
|
64
|
+
}
|
|
65
|
+
finish(runInit({ tools, scope, force: Boolean(parsed.force) }, deps));
|
|
66
|
+
}
|
|
27
67
|
|
|
28
68
|
let { scope } = parsed;
|
|
29
69
|
if (parsed.command === 'install' && has('claude')) {
|
|
30
70
|
const inRepo = isInsideGitRepo(run);
|
|
31
|
-
const resolved = resolveScope({ given: scope, interactive
|
|
71
|
+
const resolved = resolveScope({ given: scope, interactive, inRepo });
|
|
32
72
|
if (resolved.ask) {
|
|
33
73
|
const choices = scopeChoices({ cwd: process.cwd(), inRepo });
|
|
34
74
|
scope = await select({
|
|
@@ -38,16 +78,12 @@ if (parsed.command === 'install' && has('claude')) {
|
|
|
38
78
|
input: process.stdin,
|
|
39
79
|
output: process.stdout
|
|
40
80
|
});
|
|
41
|
-
if (scope === null)
|
|
42
|
-
process.stdout.write('Cancelled: nothing was installed.\n');
|
|
43
|
-
process.exit(130);
|
|
44
|
-
}
|
|
81
|
+
if (scope === null) cancel();
|
|
45
82
|
} else {
|
|
46
83
|
scope = resolved.scope;
|
|
47
84
|
if (resolved.reason) process.stdout.write(`relevo: ${resolved.reason}.\n`);
|
|
48
85
|
}
|
|
49
86
|
}
|
|
50
87
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
process.exit(code);
|
|
88
|
+
if (parsed.command === 'update') finish(runUpdate({ scope, force: Boolean(parsed.force) }, deps));
|
|
89
|
+
finish(execute({ command: parsed.command, scope }, deps));
|
package/scripts/doctor.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
|
+
import { adapterFacts } from './lib/adapters/install.mjs';
|
|
9
10
|
import { gatherFacts, readOnlyGh, renderReport, runChecks } from './lib/doctor.mjs';
|
|
10
11
|
|
|
11
12
|
const arg = (name) => {
|
|
@@ -31,7 +32,16 @@ const readFolder = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e
|
|
|
31
32
|
return e.isFile() ? [{ path: e.name, content: readFileSync(path) }] : [];
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
const repoTop = (() => {
|
|
36
|
+
try {
|
|
37
|
+
return tool('git', ['rev-parse', '--show-toplevel']).trim() || null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
|
|
34
43
|
const facts = gatherFacts({
|
|
44
|
+
adapters: () => adapterFacts({ root: repoTop, home: homedir() }),
|
|
35
45
|
tool,
|
|
36
46
|
gh: readOnlyGh(gh),
|
|
37
47
|
exists: existsSync,
|
package/scripts/init.mjs
CHANGED
|
@@ -59,6 +59,7 @@ try {
|
|
|
59
59
|
|
|
60
60
|
const templates = {
|
|
61
61
|
config: readFileSync(`${root}/templates/relevo.yml`, 'utf8'),
|
|
62
|
+
agentBlocks: answers.agentBuild && answers.agentBuild !== 'claude' ? readFileSync(`${root}/templates/agents/${answers.agentBuild}-step.yml`, 'utf8') : null,
|
|
62
63
|
workflows: Object.fromEntries(WORKFLOW_FILES.map((f) => [f, readFileSync(`${root}/templates/workflows/${f}`, 'utf8')])),
|
|
63
64
|
};
|
|
64
65
|
const keepPaths = ['openspec/specs/.gitkeep', 'openspec/changes/archive/.gitkeep'];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Renders Relevo as a native Antigravity plugin: plugin.json, the skills, the agents and a copy of what the scripts
|
|
2
|
+
// need. Pure: it returns the text files and what to copy, and the caller writes them. What is changed, and why, was
|
|
3
|
+
// seen on Antigravity CLI 1.4.4 (docs/pilot-findings.md): the script path variable is not substituted, an agent needs
|
|
4
|
+
// its own tool list, `relevo:dev` is not a valid agent name, and a subagent call does not block.
|
|
5
|
+
import { antigravityToolsOf } from './tools.mjs';
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_NAME = 'relevo';
|
|
8
|
+
export const COPIED_DIRS = ['scripts', 'stacks', 'templates'];
|
|
9
|
+
export const COPIED_FILES = ['LICENSE', 'NOTICE'];
|
|
10
|
+
|
|
11
|
+
// The skill `go` tells the agent to wait for each subagent. In Antigravity the call returns at once and the result
|
|
12
|
+
// arrives later as a message, so the wait has to be said in other words. The heading is looked for exactly: if the
|
|
13
|
+
// source changes it, the render fails instead of keeping a sentence that is wrong here.
|
|
14
|
+
const FOREGROUND_HEADING = '## Subagents run in the foreground';
|
|
15
|
+
const FOREGROUND_FOR_ANTIGRAVITY = `${FOREGROUND_HEADING}
|
|
16
|
+
|
|
17
|
+
Call \`relevo-dev\` and \`relevo-qa\` with \`invoke_subagent\`, one at a time. In Antigravity, subagents run in the background and the report arrives later as a message. After calling \`invoke_subagent\`, do NOT poll with \`manage_subagents\` or loop in any way; simply output that you are waiting for the subagent's report and end your turn without further tool calls. Antigravity will automatically reactivate you when the subagent finishes. Once reactivated with the subagent report, verify it, terminate the subagent using \`manage_subagents\` (\`Action: "kill"\`, \`ConversationIds: ["<subagent_id>"]\`) to free context, and proceed to the next step. If you cannot get a report or the subagent fails, take the failure path.
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const WHERE_RUNS_HEADING = '## Where it runs';
|
|
21
|
+
const WHERE_RUNS_FOR_ANTIGRAVITY = (pluginDir) => `${WHERE_RUNS_HEADING}
|
|
22
|
+
|
|
23
|
+
In the cloud the workflow starts this skill with the decision already made. A person can also start it on their machine: \`node ${pluginDir}/scripts/go-local.mjs <issue>\` runs it in a separate git worktree, with the same limits and tool lists as the workflow, and removes the worktree when it ends cleanly. Started by hand without that script, it needs a clean working tree (the preflight below stops otherwise).
|
|
24
|
+
|
|
25
|
+
**When \`$ARGUMENTS\` has \`--local\`**, do not build in this session: run \`node ${pluginDir}/scripts/go-local.mjs <issue>\`, show what it prints, and stop. The build happens in the process that script starts, in its own worktree and with the closed tool lists, which a plain session does not have.
|
|
26
|
+
|
|
27
|
+
**In an interactive Antigravity chat session** (started by hand without \`--decision\` and without \`--local\`), do not switch branches or build directly in this workspace root (multiple chat tabs share this workspace and will collide). Instead, isolate the build in an ephemeral worktree:
|
|
28
|
+
1. Call \`invoke_subagent\` with \`TypeName: "self"\`, \`Role: "relevo-builder"\`, and \`Workspace: 'share'\`, passing the build instructions for the issue.
|
|
29
|
+
2. Call \`schedule\` with \`DurationSeconds: 1200\` and \`TimerCondition: "<builder_conversation_id>"\` as a watchdog timer.
|
|
30
|
+
3. Yield your turn without further tool calls. If the builder completes, the timer cancels early and you are reactivated with its report. If the timer fires, the builder timed out.
|
|
31
|
+
4. Call \`manage_subagents\` with \`Action: "kill"\` and \`ConversationIds: ["<builder_conversation_id>"]\` to destroy the ephemeral worktree and clean up session state.
|
|
32
|
+
5. If the builder succeeded, report the outcome here; if it timed out or failed, take the failure path. The repository root remains clean on its base branch.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const TOOL_WORDS = { Read: 'view_file', Write: 'write_to_file', Edit: 'replace_file_content' };
|
|
36
|
+
|
|
37
|
+
const q = (s) => JSON.stringify(String(s));
|
|
38
|
+
|
|
39
|
+
/** The path of the plugin is written into a shell command, so it must not need quoting. */
|
|
40
|
+
function checkPluginDir(dir) {
|
|
41
|
+
if (!dir || !/^\//.test(dir) && !/^[A-Za-z]:[\\/]/.test(dir)) throw new Error(`the plugin folder must be an absolute path, got "${dir}"`);
|
|
42
|
+
if (/[\s"'`$&;|<>()\\]/.test(dir.replace(/^[A-Za-z]:\\/, ''))) {
|
|
43
|
+
throw new Error(`the plugin folder "${dir}" has a space or a shell character, which the commands in the skills cannot carry; choose another folder`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text of a skill, an agent or a reference, as Antigravity needs it. */
|
|
48
|
+
export function adaptText(text, { pluginDir }) {
|
|
49
|
+
let out = String(text).split('${CLAUDE_PLUGIN_ROOT}').join(pluginDir);
|
|
50
|
+
out = out.replace(/relevo:(dev|qa)(?![\w-])/g, 'relevo-$1');
|
|
51
|
+
for (const [from, to] of Object.entries(TOOL_WORDS)) out = out.split(`\`${from}\``).join(`\`${to}\``);
|
|
52
|
+
if (out.includes(FOREGROUND_HEADING)) {
|
|
53
|
+
const re = new RegExp(`${FOREGROUND_HEADING}\\n\\n[^\\n]*\\n`);
|
|
54
|
+
if (!re.test(out)) throw new Error(`"${FOREGROUND_HEADING}" is no longer followed by one paragraph: update the Antigravity renderer`);
|
|
55
|
+
out = out.replace(re, FOREGROUND_FOR_ANTIGRAVITY);
|
|
56
|
+
}
|
|
57
|
+
if (out.includes(WHERE_RUNS_HEADING)) {
|
|
58
|
+
const re = new RegExp(`${WHERE_RUNS_HEADING}\\n\\n[\\s\\S]*?(?=\\n\\n## OpenSpec commands)`);
|
|
59
|
+
if (!re.test(out)) throw new Error(`"${WHERE_RUNS_HEADING}" block could not be matched: update the Antigravity renderer`);
|
|
60
|
+
out = out.replace(re, WHERE_RUNS_FOR_ANTIGRAVITY(pluginDir).trimEnd());
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function skillFile(skill, ctx) {
|
|
66
|
+
const { meta } = skill;
|
|
67
|
+
const lines = ['---', `name: ${meta.name ?? skill.name}`, `description: ${q(meta.description)}`];
|
|
68
|
+
if (meta['argument-hint'] !== undefined) lines.push(`argument-hint: ${q(meta['argument-hint'])}`);
|
|
69
|
+
if (String(meta['disable-model-invocation']) === 'true') lines.push('disable-model-invocation: true');
|
|
70
|
+
lines.push('---', '');
|
|
71
|
+
return { path: `skills/${skill.name}/SKILL.md`, content: `${lines.join('\n')}\n${adaptText(skill.body, ctx).replace(/^\n+/, '')}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function agentFile(agent, ctx) {
|
|
75
|
+
const name = `${PLUGIN_NAME}-${agent.name}`;
|
|
76
|
+
const tools = antigravityToolsOf(agent.meta);
|
|
77
|
+
const head = ['---', `name: ${name}`, `description: ${q(agent.meta.description)}`, `tools: [${tools.join(', ')}]`, '---', ''];
|
|
78
|
+
return { path: `agents/${name}.md`, content: `${head.join('\n')}\n${adaptText(agent.body, ctx).replace(/^\n+/, '')}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* source: the result of readSource. ctx.pluginDir: the absolute folder where the plugin will live.
|
|
83
|
+
* Returns { files: [{ path, content }], copy: { dirs, files } } with paths relative to the plugin folder; `copy`
|
|
84
|
+
* says what to take from the package unchanged.
|
|
85
|
+
*/
|
|
86
|
+
export function renderAntigravity(source, ctx) {
|
|
87
|
+
checkPluginDir(ctx.pluginDir);
|
|
88
|
+
const m = source.manifest;
|
|
89
|
+
const manifest = { name: PLUGIN_NAME, displayName: m.displayName ?? 'Relevo', version: source.version, description: m.description, author: m.author };
|
|
90
|
+
const files = [{ path: 'plugin.json', content: `${JSON.stringify(manifest, null, 2)}\n` }];
|
|
91
|
+
for (const skill of source.skills) {
|
|
92
|
+
files.push(skillFile(skill, ctx));
|
|
93
|
+
for (const f of skill.files) {
|
|
94
|
+
files.push({ path: `skills/${skill.name}/${f.path}`, content: f.path.endsWith('.md') ? adaptText(f.content, ctx) : f.content });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const agent of source.agents) files.push(agentFile(agent, ctx));
|
|
98
|
+
return { files, copy: { dirs: COPIED_DIRS, files: COPIED_FILES } };
|
|
99
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Puts a rendered plugin where a tool reads it, and keeps a record of what was put there so that an update can tell a
|
|
2
|
+
// file that is out of date from one that a person edited. For the project the plugin is written into
|
|
3
|
+
// `.agents/plugins/relevo/`; for the machine it is staged and installed with `agy plugin install`, which copies it to
|
|
4
|
+
// `~/.gemini/config/plugins/relevo/` (the path the text of the skills already carries, see antigravity.mjs).
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { PLUGIN_NAME, renderAntigravity } from './antigravity.mjs';
|
|
10
|
+
import { readSource } from './source.mjs';
|
|
11
|
+
|
|
12
|
+
export const RECORD = '.relevo-adapter.json';
|
|
13
|
+
|
|
14
|
+
export const pluginDirFor = ({ scope, root, home }) =>
|
|
15
|
+
scope === 'project' ? join(root, '.agents', 'plugins', PLUGIN_NAME) : join(home, '.gemini', 'config', 'plugins', PLUGIN_NAME);
|
|
16
|
+
|
|
17
|
+
const sha = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
18
|
+
|
|
19
|
+
/** { 'relative/path': sha256 } of every file under dir, except the record itself. */
|
|
20
|
+
export function hashTree(dir, base = dir) {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
const full = join(dir, e.name);
|
|
24
|
+
if (e.isDirectory()) Object.assign(out, hashTree(full, base));
|
|
25
|
+
else if (e.name !== RECORD && e.name !== '.DS_Store') out[full.slice(base.length + 1).split('\\').join('/')] = sha(readFileSync(full));
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readRecord(dir) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(join(dir, RECORD), 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The recorded files that are missing or whose content changed since they were written. */
|
|
39
|
+
export function editedFiles(dir) {
|
|
40
|
+
const record = readRecord(dir);
|
|
41
|
+
if (!record) return [];
|
|
42
|
+
const now = existsSync(dir) ? hashTree(dir) : {};
|
|
43
|
+
return Object.entries(record.files).filter(([path, hash]) => now[path] !== hash).map(([path]) => path);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeTree(rendered, { packageRoot, dest }) {
|
|
47
|
+
for (const f of rendered.files) {
|
|
48
|
+
mkdirSync(dirname(join(dest, f.path)), { recursive: true });
|
|
49
|
+
writeFileSync(join(dest, f.path), f.content);
|
|
50
|
+
}
|
|
51
|
+
for (const d of rendered.copy.dirs) cpSync(join(packageRoot, d), join(dest, d), { recursive: true });
|
|
52
|
+
for (const f of rendered.copy.files) if (existsSync(join(packageRoot, f))) cpSync(join(packageRoot, f), join(dest, f));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const where = { project: (root) => `.agents/plugins/relevo of ${root}, so commit it to share it`, machine: () => '~/.gemini/config/plugins/relevo, for the IDE, the App and the CLI' };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Renders and installs the Antigravity plugin for the scope ('project' | 'machine'). `update` only changes the
|
|
59
|
+
* sentence it prints. A file edited by hand stops it unless `force`. Returns { code, lines }; nothing is changed
|
|
60
|
+
* when it stops before writing.
|
|
61
|
+
*/
|
|
62
|
+
export function installAntigravity({ packageRoot, scope, root, home, run, has, force = false, update = false }) {
|
|
63
|
+
if (scope === 'machine' && !has('agy')) {
|
|
64
|
+
return { code: 1, lines: ['Antigravity CLI is required for the machine scope and "agy" was not found on the path.', 'Install it from https://antigravity.google/docs/getting-started?tab=cli and run this command again; nothing was changed.'] };
|
|
65
|
+
}
|
|
66
|
+
if (scope === 'project' && !root) return { code: 1, lines: ['The project scope needs a git repository and this folder is not inside one; nothing was changed.', 'Use --scope machine, or run the command inside the repository.'] };
|
|
67
|
+
const dest = pluginDirFor({ scope, root, home });
|
|
68
|
+
const edited = editedFiles(dest);
|
|
69
|
+
if (edited.length && !force) {
|
|
70
|
+
return { code: 1, lines: [`These files of ${dest} were edited by hand and would be overwritten:`, ...edited.map((f) => ` ${f}`), 'Nothing was changed. Run the command again with --force to overwrite them.'] };
|
|
71
|
+
}
|
|
72
|
+
let source;
|
|
73
|
+
try {
|
|
74
|
+
source = readSource(packageRoot);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return { code: 1, lines: [`Could not read the Relevo package at ${packageRoot}: ${err.message}`] };
|
|
77
|
+
}
|
|
78
|
+
let rendered;
|
|
79
|
+
try {
|
|
80
|
+
rendered = renderAntigravity(source, { pluginDir: dest });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return { code: 1, lines: [`Could not render the plugin: ${err.message}`, 'Nothing was changed.'] };
|
|
83
|
+
}
|
|
84
|
+
const lines = [];
|
|
85
|
+
const stageRoot = scope === 'machine' ? mkdtempSync(join(tmpdir(), 'relevo-stage-')) : null;
|
|
86
|
+
const target = scope === 'machine' ? join(stageRoot, PLUGIN_NAME) : dest;
|
|
87
|
+
try {
|
|
88
|
+
if (scope === 'project') rmSync(dest, { recursive: true, force: true });
|
|
89
|
+
writeTree(rendered, { packageRoot, dest: target });
|
|
90
|
+
writeFileSync(join(target, RECORD), `${JSON.stringify({ tool: 'antigravity', scope, package: source.version, files: hashTree(target) }, null, 2)}\n`);
|
|
91
|
+
if (scope === 'machine') {
|
|
92
|
+
if (existsSync(dest)) {
|
|
93
|
+
const r = run('agy', ['plugin', 'uninstall', PLUGIN_NAME]);
|
|
94
|
+
if (r.code !== 0) return { code: r.code || 1, lines: [`"agy plugin uninstall ${PLUGIN_NAME}" failed, so the old plugin was kept:`, r.output.trimEnd()] };
|
|
95
|
+
}
|
|
96
|
+
const r = run('agy', ['plugin', 'install', target]);
|
|
97
|
+
if (r.output.trim()) lines.push(r.output.trimEnd());
|
|
98
|
+
if (r.code !== 0) return { code: r.code || 1, lines: [...lines, `"agy plugin install" failed. Fix the cause and run the command again: it is safe to repeat.`] };
|
|
99
|
+
if (!existsSync(join(dest, 'plugin.json'))) {
|
|
100
|
+
return { code: 1, lines: [...lines, `agy installed the plugin, but not in ${dest}, where the commands of the skills point. Nothing else was done; report this with the version of agy.`] };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
if (stageRoot) rmSync(stageRoot, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
lines.push(`Antigravity: ${update ? 'updated' : 'installed'} Relevo ${source.version} in ${where[scope](root)}.`, 'Open Antigravity and type /relevo:doctor.');
|
|
107
|
+
return { code: 0, lines };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What doctor needs about each rendered plugin: { scope, dir, package, edited, missing }. */
|
|
111
|
+
export function adapterFacts({ root, home }) {
|
|
112
|
+
return findRecords({ root, home }).map(({ scope, dir, record }) => {
|
|
113
|
+
const now = hashTree(dir);
|
|
114
|
+
const missing = Object.keys(record.files).filter((p) => !(p in now));
|
|
115
|
+
const edited = Object.entries(record.files).filter(([p, h]) => p in now && now[p] !== h).map(([p]) => p);
|
|
116
|
+
return { scope, dir, package: record.package, edited, missing };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The rendered plugins that exist for this repository and this machine: [{ scope, dir, record }]. */
|
|
121
|
+
export function findRecords({ root, home }) {
|
|
122
|
+
return ['project', 'machine']
|
|
123
|
+
.filter((scope) => scope === 'machine' || root)
|
|
124
|
+
.map((scope) => ({ scope, dir: pluginDirFor({ scope, root, home }) }))
|
|
125
|
+
.map((x) => ({ ...x, record: readRecord(x.dir) }))
|
|
126
|
+
.filter((x) => x.record?.tool === 'antigravity');
|
|
127
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Reads what Relevo is made of, in Claude Code's format, so that a renderer can write it for another tool. Nothing is
|
|
2
|
+
// written here and nothing is interpreted beyond the frontmatter: a renderer decides what to change.
|
|
3
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parse } from '../yaml-lite.mjs';
|
|
6
|
+
|
|
7
|
+
/** { head, body }: the frontmatter text (no fences) and what follows it; head is null when there is none. */
|
|
8
|
+
export function splitFrontmatter(text) {
|
|
9
|
+
const m = String(text).replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
10
|
+
return m ? { head: m[1], body: m[2] } : { head: null, body: String(text) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const walk = (dir, base = dir) =>
|
|
14
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
15
|
+
const full = join(dir, e.name);
|
|
16
|
+
if (e.isDirectory()) return walk(full, base);
|
|
17
|
+
return [full.slice(base.length + 1).split('\\').join('/')];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const isDir = (p) => {
|
|
21
|
+
try {
|
|
22
|
+
return statSync(p).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* { version, manifest, skills, agents } read from a Relevo checkout or an installed package.
|
|
30
|
+
* skill: { name, meta, body, files: [{ path, content }] } (files are everything beside SKILL.md, text only)
|
|
31
|
+
* agent: { name, meta, body }
|
|
32
|
+
*/
|
|
33
|
+
export function readSource(root) {
|
|
34
|
+
const manifest = JSON.parse(readFileSync(join(root, '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
35
|
+
const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
|
|
36
|
+
|
|
37
|
+
const skillsDir = join(root, 'skills');
|
|
38
|
+
const skills = readdirSync(skillsDir, { withFileTypes: true })
|
|
39
|
+
.filter((e) => e.isDirectory())
|
|
40
|
+
.map((e) => e.name)
|
|
41
|
+
.sort()
|
|
42
|
+
.map((name) => {
|
|
43
|
+
const dir = join(skillsDir, name);
|
|
44
|
+
const { head, body } = splitFrontmatter(readFileSync(join(dir, 'SKILL.md'), 'utf8'));
|
|
45
|
+
if (head === null) throw new Error(`skills/${name}/SKILL.md has no frontmatter`);
|
|
46
|
+
const files = walk(dir)
|
|
47
|
+
.filter((f) => f !== 'SKILL.md')
|
|
48
|
+
.map((path) => ({ path, content: readFileSync(join(dir, path), 'utf8') }));
|
|
49
|
+
return { name, meta: parse(head), body, files };
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const agentsDir = join(root, 'agents');
|
|
53
|
+
const agents = !isDir(agentsDir)
|
|
54
|
+
? []
|
|
55
|
+
: readdirSync(agentsDir)
|
|
56
|
+
.filter((f) => f.endsWith('.md'))
|
|
57
|
+
.sort()
|
|
58
|
+
.map((f) => {
|
|
59
|
+
const { head, body } = splitFrontmatter(readFileSync(join(agentsDir, f), 'utf8'));
|
|
60
|
+
if (head === null) throw new Error(`agents/${f} has no frontmatter`);
|
|
61
|
+
return { name: f.slice(0, -3), meta: parse(head), body };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return { version, manifest, skills, agents };
|
|
65
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The tools of an agent, said in the words of the tool that will run it. Claude Code's subagents have every tool
|
|
2
|
+
// unless told otherwise (`disallowedTools`); Antigravity's have none beyond a small base unless a list is given, so
|
|
3
|
+
// the list is built here. A Claude tool with no entry stops the render: a wrong name can hang a subagent.
|
|
4
|
+
|
|
5
|
+
/** What a subagent of Claude Code can use when nothing restricts it, as far as Relevo's agents rely on it. */
|
|
6
|
+
export const CLAUDE_AGENT_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Claude tool -> the Antigravity tools that do the same. Names are the ones the CLI reports in the `init` event of
|
|
10
|
+
* `agy -p --output-format stream-json` (checked by tests/adapter-antigravity.test.mjs when RELEVO_AGY_CHECK=1).
|
|
11
|
+
* An empty list means the tool has no counterpart and is left out on purpose.
|
|
12
|
+
*/
|
|
13
|
+
export const ANTIGRAVITY_TOOLS = {
|
|
14
|
+
Read: ['view_file', 'list_dir'],
|
|
15
|
+
Write: ['write_to_file'],
|
|
16
|
+
Edit: ['replace_file_content', 'multi_replace_file_content'],
|
|
17
|
+
NotebookEdit: [],
|
|
18
|
+
Bash: ['run_command'],
|
|
19
|
+
Grep: ['grep_search'],
|
|
20
|
+
Glob: ['find_by_name']
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class UnmappedToolError extends Error {
|
|
24
|
+
constructor(tool) {
|
|
25
|
+
super(`the Claude tool "${tool}" has no entry in the Antigravity tool table (scripts/lib/adapters/tools.mjs)`);
|
|
26
|
+
this.tool = tool;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const names = (v) =>
|
|
31
|
+
(Array.isArray(v) ? v : String(v ?? '').split(','))
|
|
32
|
+
.map((s) => String(s).trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
/** The Claude tools an agent ends up with, from its `tools` (a list) or from everything minus `disallowedTools`. */
|
|
36
|
+
export function claudeToolsOf(meta) {
|
|
37
|
+
if (meta.tools !== undefined) return names(meta.tools);
|
|
38
|
+
const denied = new Set(names(meta.disallowedTools));
|
|
39
|
+
return CLAUDE_AGENT_TOOLS.filter((t) => !denied.has(t));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The Antigravity tool names for an agent, without repeats, in a stable order. */
|
|
43
|
+
export function antigravityToolsOf(meta) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const tool of claudeToolsOf(meta)) {
|
|
46
|
+
if (!(tool in ANTIGRAVITY_TOOLS)) throw new UnmappedToolError(tool);
|
|
47
|
+
for (const t of ANTIGRAVITY_TOOLS[tool]) if (!out.includes(t)) out.push(t);
|
|
48
|
+
}
|
|
49
|
+
const denied = names(meta.disallowedTools);
|
|
50
|
+
for (const tool of denied) if (!(tool in ANTIGRAVITY_TOOLS)) throw new UnmappedToolError(tool);
|
|
51
|
+
return out;
|
|
52
|
+
}
|