@sabaiway/agent-workflow-kit 1.6.0 → 1.8.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/CHANGELOG.md +61 -0
- package/README.md +14 -7
- package/SKILL.md +39 -2
- package/bin/install.mjs +135 -56
- package/bin/install.test.mjs +138 -5
- package/bridges/antigravity-cli-bridge/SKILL.md +178 -0
- package/bridges/antigravity-cli-bridge/bin/agy.sh +133 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -0
- package/bridges/antigravity-cli-bridge/capability.json +22 -0
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +108 -0
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +93 -0
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +51 -0
- package/bridges/antigravity-cli-bridge/setup/README.md +65 -0
- package/bridges/codex-cli-bridge/SKILL.md +148 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +84 -0
- package/bridges/codex-cli-bridge/capability.json +22 -0
- package/bridges/codex-cli-bridge/references/driving-codex.md +97 -0
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +105 -0
- package/bridges/codex-cli-bridge/setup/README.md +78 -0
- package/capability.json +1 -1
- package/package.json +3 -2
- package/tools/detect-backends.mjs +36 -0
- package/tools/detect-backends.test.mjs +102 -0
- package/tools/fs-safe.mjs +129 -0
- package/tools/fs-safe.test.mjs +200 -0
- package/tools/setup-backends.mjs +468 -0
- package/tools/setup-backends.test.mjs +500 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// setup-backends.mjs — link-only auto-setup for the execution-backend bridges (Plan B / AD-011).
|
|
3
|
+
// The kit owns ONLY the two deterministic, secret-free steps: (1) PLACE/REFRESH the bundled bridge
|
|
4
|
+
// skill into its canonical dir, and (2) LINK its POSIX wrappers onto PATH (~/.local/bin) via managed
|
|
5
|
+
// symlinks. Binary install + the interactive subscription login stay GUIDED (printed, never run) —
|
|
6
|
+
// guideFor() supplies the exact commands. The read-only detector is the reader; this is the writer.
|
|
7
|
+
//
|
|
8
|
+
// Safety posture (AD-011): drive off the decoupled axes (a per-bindir wrapper check + an independent
|
|
9
|
+
// skill-dir inspection), never the detector's collapsed readiness. REFRESH only a dir we provably own
|
|
10
|
+
// (valid manifest, name+kind match) or one that is absent/empty; STOP on anything else (a marker fs
|
|
11
|
+
// error, a stub/foreign/invalid/unsupported manifest, a non-empty unknown dir, or a symlinked dir).
|
|
12
|
+
// Clobber-risk lives only on the wrapper symlinks — replace only a symlink already pointing at our
|
|
13
|
+
// source; STOP on a foreign symlink or a non-symlink. PREFLIGHT every dst, THEN mutate (a conflict on
|
|
14
|
+
// the 2nd wrapper ⇒ zero mutations; no rollback — symlinks/chmod converge on re-run). Windows: the
|
|
15
|
+
// wrappers are POSIX .sh — report unsupported / use WSL and mutate nothing. Every fs primitive is
|
|
16
|
+
// injectable (deps.*) so the whole module is unit-testable without touching the real filesystem.
|
|
17
|
+
//
|
|
18
|
+
// Dependency-free, Node >= 18.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync, lstatSync, statSync, readdirSync, readlinkSync, symlinkSync, mkdirSync, copyFileSync,
|
|
22
|
+
chmodSync, readFileSync, realpathSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { join, resolve, relative, dirname, isAbsolute } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import { KNOWN_BACKENDS, detectBackend, resolveDir, guideFor } from './detect-backends.mjs';
|
|
28
|
+
import { copyTreeRefresh, linkManaged } from './fs-safe.mjs';
|
|
29
|
+
import { validateManifest, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
// bridges/ ships beside tools/ in both the repo and the installed kit, so this resolves in both.
|
|
33
|
+
const DEFAULT_BUNDLE_ROOT = resolve(__dirname, '..', 'bridges');
|
|
34
|
+
const DEFAULT_BINDIR_REL = '.local/bin';
|
|
35
|
+
const EXPECTED_KIND = 'execution-backend';
|
|
36
|
+
// A wrapper cmd becomes a PATH filename — keep it to a plain basename, no separators/traversal/control.
|
|
37
|
+
const CMD_ALLOWED = /^[A-Za-z0-9._-]+$/;
|
|
38
|
+
|
|
39
|
+
// Token → registry name. `codex`/`agy`/`antigravity` are the CLI-facing aliases.
|
|
40
|
+
const ALIASES = { codex: 'codex-cli-bridge', agy: 'antigravity-cli-bridge', antigravity: 'antigravity-cli-bridge' };
|
|
41
|
+
const resolveBackendName = (token) => ALIASES[token] ?? token;
|
|
42
|
+
const registryEntry = (name) => KNOWN_BACKENDS.find((b) => b.name === resolveBackendName(name));
|
|
43
|
+
|
|
44
|
+
// A typed STOP — a deliberate refusal we surface (distinct from a native fs error, though both exit
|
|
45
|
+
// non-zero). `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
|
|
46
|
+
export const SETUP_STOP = 'SETUP_STOP';
|
|
47
|
+
const stop = (message, fields = {}) =>
|
|
48
|
+
Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'SetupStop', code: SETUP_STOP, ...fields });
|
|
49
|
+
|
|
50
|
+
// ── injectable fs ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const fsDeps = (deps = {}) => ({
|
|
53
|
+
lstat: deps.lstat ?? lstatSync,
|
|
54
|
+
exists: deps.exists ?? existsSync,
|
|
55
|
+
stat: deps.stat ?? statSync,
|
|
56
|
+
readdir: deps.readdir ?? readdirSync,
|
|
57
|
+
readlink: deps.readlink ?? readlinkSync,
|
|
58
|
+
symlink: deps.symlink ?? symlinkSync,
|
|
59
|
+
mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
|
|
60
|
+
copyFile: deps.copyFile ?? copyFileSync,
|
|
61
|
+
chmod: deps.chmod ?? chmodSync,
|
|
62
|
+
readFile: deps.readFile ?? readFileSync,
|
|
63
|
+
realpath: deps.realpath ?? realpathSync,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
|
|
67
|
+
const lstatNoFollow = (path, lstat) => {
|
|
68
|
+
try {
|
|
69
|
+
return lstat(path);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err && err.code === 'ENOENT') return null;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// A path must be a real REGULAR file (never a symlink). Gates both the bundle source (during planning
|
|
77
|
+
// — what we will copy then link) and the placed skill source (at link time, a TOCTOU re-check).
|
|
78
|
+
const assertRegularFile = (absPath, label, fs) => {
|
|
79
|
+
const st = lstatNoFollow(absPath, fs.lstat);
|
|
80
|
+
if (st === null) throw stop(`${label} is missing: ${absPath}`);
|
|
81
|
+
if (st.isSymbolicLink()) throw stop(`${label} is a symlink — refusing: ${absPath}`);
|
|
82
|
+
if (!st.isFile()) throw stop(`${label} is not a regular file: ${absPath}`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Wrapped file probe: present (regular file) | missing (absent / not a file) | unknown (other fs err).
|
|
86
|
+
const probeMarker = (file, fs) => {
|
|
87
|
+
try {
|
|
88
|
+
if (!fs.exists(file)) return 'missing';
|
|
89
|
+
return fs.stat(file).isFile() ? 'present' : 'missing';
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return err && err.code === 'ENOENT' ? 'missing' : 'unknown';
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── path resolution ──────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const skillDirOf = (entry, deps) =>
|
|
98
|
+
resolveDir({ env: entry.installed.env, default: entry.installed.default }, deps.getenv ?? process.env, deps.home ?? os.homedir());
|
|
99
|
+
const bundleDirOf = (name, deps) => join(deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT, name);
|
|
100
|
+
const bindirOf = (deps) => deps.bindir ?? join(deps.home ?? os.homedir(), DEFAULT_BINDIR_REL);
|
|
101
|
+
|
|
102
|
+
// Is `bindir` already a PATH member? Normalised per platform (delimiter + win32 case-fold). Read-only —
|
|
103
|
+
// a "no" only yields a printed "add to PATH" hint; we never edit a shell rc file.
|
|
104
|
+
export const bindirOnPath = (bindir, env = process.env, platform = process.platform) => {
|
|
105
|
+
const isWin = platform === 'win32';
|
|
106
|
+
const raw = (isWin ? env.PATH ?? env.Path : env.PATH) ?? '';
|
|
107
|
+
const norm = (p) => (isWin ? resolve(p).toLowerCase() : resolve(p));
|
|
108
|
+
const target = norm(bindir);
|
|
109
|
+
return raw.split(isWin ? ';' : ':').filter(Boolean).some((d) => norm(d) === target);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// ── skill-dir inspection (read-only) ──────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const provenManaged = (report, entry) => {
|
|
115
|
+
if (report.result === UNSUPPORTED) return { ok: false, state: 'unsupported-schema' };
|
|
116
|
+
if (report.result === INVALID) return { ok: false, state: 'invalid-manifest' };
|
|
117
|
+
if (report.available === false) return { ok: false, state: 'stub' };
|
|
118
|
+
if (report.kind !== EXPECTED_KIND || report.name !== entry.name) return { ok: false, state: 'foreign' };
|
|
119
|
+
return { ok: true, state: 'ok' };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Decide what placeSkill WOULD do, without writing. Returns { action:'place'|'refresh', skillDir,
|
|
123
|
+
// bundleDir, reason } or throws a typed STOP. `place` = absent/empty dir; `refresh` = proven-managed.
|
|
124
|
+
const inspectSkillDir = (entry, deps) => {
|
|
125
|
+
const fs = fsDeps(deps);
|
|
126
|
+
const validate = deps.validate ?? validateManifest;
|
|
127
|
+
const skillDir = skillDirOf(entry, deps);
|
|
128
|
+
const bundleDir = bundleDirOf(entry.name, deps);
|
|
129
|
+
|
|
130
|
+
// A published tarball always ships the bundle; its absence means a corrupt/partial kit, fail loud.
|
|
131
|
+
if (!fs.exists(join(bundleDir, entry.installed.file))) {
|
|
132
|
+
throw Object.assign(
|
|
133
|
+
new Error(`[agent-workflow-kit] bundled bridge missing: ${bundleDir} (corrupt kit install?)`),
|
|
134
|
+
{ code: 'MISSING_BUNDLE' },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Defense-in-depth: never place/link from a bundle we can't validate as THIS backend. The mirror
|
|
138
|
+
// drift-guard pins the bundle at build time; this catches a corrupted / tampered install at runtime
|
|
139
|
+
// (a valid-JSON manifest with the wrong name/kind would otherwise place a foreign bridge). Always
|
|
140
|
+
// the REAL validator — the bundle is our own shipped artifact; `deps.validate` mocks only the
|
|
141
|
+
// user-owned skill dir checked below.
|
|
142
|
+
const bundleProvenance = provenManaged(validateManifest(bundleDir), entry);
|
|
143
|
+
if (!bundleProvenance.ok) {
|
|
144
|
+
throw Object.assign(
|
|
145
|
+
new Error(`[agent-workflow-kit] bundled bridge manifest is "${bundleProvenance.state}" at ${bundleDir} (corrupt kit install?)`),
|
|
146
|
+
{ code: 'CORRUPT_BUNDLE' },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const dirStat = lstatNoFollow(skillDir, fs.lstat);
|
|
151
|
+
if (dirStat === null) return { action: 'place', skillDir, bundleDir, reason: 'skill dir absent' };
|
|
152
|
+
if (dirStat.isSymbolicLink()) throw stop(`skill dir is a symlink — refusing to write through it: ${skillDir}`, { skillDir });
|
|
153
|
+
if (!dirStat.isDirectory()) throw stop(`skill path exists but is not a directory: ${skillDir}`, { skillDir });
|
|
154
|
+
|
|
155
|
+
const markerState = probeMarker(join(skillDir, entry.installed.file), fs);
|
|
156
|
+
if (markerState === 'unknown') throw stop(`cannot determine bridge skill state — fs error on ${join(skillDir, entry.installed.file)}`, { skillDir });
|
|
157
|
+
if (markerState === 'missing') {
|
|
158
|
+
let entries;
|
|
159
|
+
try {
|
|
160
|
+
entries = fs.readdir(skillDir);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw stop(`cannot read skill dir (${err.code ?? 'fs error'}): ${skillDir}`, { skillDir });
|
|
163
|
+
}
|
|
164
|
+
if (entries.length === 0) return { action: 'place', skillDir, bundleDir, reason: 'skill dir empty' };
|
|
165
|
+
throw stop(`skill dir is non-empty but has no ${entry.installed.file} — refusing to overwrite unknown files: ${skillDir}`, { skillDir });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const provenance = provenManaged(validate(skillDir), entry);
|
|
169
|
+
if (!provenance.ok) throw stop(`skill dir manifest is "${provenance.state}" — refusing to overwrite a dir we don't own: ${skillDir}`, { skillDir, state: provenance.state });
|
|
170
|
+
return { action: 'refresh', skillDir, bundleDir, reason: 'proven-managed bridge skill' };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// ── wrapper link derivation (string-only) ─────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
// Manifest roles → the deduped { cmd, sourceRel, source } link set. Validates UNTRUSTED manifest
|
|
176
|
+
// strings: cmd allowlist; a cmd mapped to two different sources is a STOP; a source must stay inside
|
|
177
|
+
// the skill dir. Pure string work — no fs (so the dry-run plan is correct before the skill is placed).
|
|
178
|
+
export const deriveLinks = (manifest, skillDir) => {
|
|
179
|
+
const roles = manifest && typeof manifest.roles === 'object' && !Array.isArray(manifest.roles) ? manifest.roles : {};
|
|
180
|
+
const byCmd = new Map();
|
|
181
|
+
for (const role of Object.values(roles)) {
|
|
182
|
+
const cmd = role && typeof role.cmd === 'string' ? role.cmd : null;
|
|
183
|
+
const sourceRel = role && typeof role.source === 'string' ? role.source : null;
|
|
184
|
+
if (!cmd || !CMD_ALLOWED.test(cmd)) throw stop(`invalid wrapper cmd ${JSON.stringify(cmd)} (must match ${CMD_ALLOWED})`);
|
|
185
|
+
if (cmd === '.' || cmd === '..') throw stop(`invalid wrapper cmd ${JSON.stringify(cmd)} (reserved path name)`);
|
|
186
|
+
if (!sourceRel) throw stop(`wrapper "${cmd}" has no source in the manifest`);
|
|
187
|
+
if (byCmd.has(cmd) && byCmd.get(cmd) !== sourceRel) {
|
|
188
|
+
throw stop(`wrapper "${cmd}" maps to two sources: "${byCmd.get(cmd)}" and "${sourceRel}"`);
|
|
189
|
+
}
|
|
190
|
+
byCmd.set(cmd, sourceRel);
|
|
191
|
+
}
|
|
192
|
+
if (byCmd.size === 0) throw stop('manifest declares no wrapper roles');
|
|
193
|
+
return [...byCmd.entries()].map(([cmd, sourceRel]) => {
|
|
194
|
+
const source = resolve(skillDir, sourceRel);
|
|
195
|
+
const rel = relative(skillDir, source);
|
|
196
|
+
if (rel.startsWith('..') || isAbsolute(rel)) throw stop(`wrapper "${cmd}" source escapes the skill dir: "${sourceRel}"`);
|
|
197
|
+
return { cmd, sourceRel, source };
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Classify a wrapper dst per-bindir (NOT PATH-wide): absent | ours (symlink → our source) | conflict.
|
|
202
|
+
const inspectDst = (dst, source, fs) => {
|
|
203
|
+
const st = lstatNoFollow(dst, fs.lstat);
|
|
204
|
+
if (st === null) return { state: 'absent' };
|
|
205
|
+
if (!st.isSymbolicLink()) return { state: 'conflict', reason: 'a non-symlink already exists there' };
|
|
206
|
+
let target;
|
|
207
|
+
try {
|
|
208
|
+
target = fs.readlink(dst);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return { state: 'conflict', reason: `unreadable symlink (${err.code ?? 'fs error'})` };
|
|
211
|
+
}
|
|
212
|
+
const resolved = isAbsolute(target) ? target : resolve(dirname(dst), target);
|
|
213
|
+
return resolved === resolve(source) ? { state: 'ours' } : { state: 'conflict', reason: `foreign symlink → ${target}` };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── mutating primitives ───────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
// Place/refresh the bundled bridge skill. Re-inspects before writing (never trusts a stale plan).
|
|
219
|
+
export const placeSkill = (name, deps = {}) => {
|
|
220
|
+
const entry = registryEntry(name);
|
|
221
|
+
if (!entry) throw stop(`unknown backend: ${name}`);
|
|
222
|
+
const plan = inspectSkillDir(entry, deps);
|
|
223
|
+
copyTreeRefresh(plan.bundleDir, plan.skillDir, plan.skillDir, fsDeps(deps));
|
|
224
|
+
return plan;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Link the wrappers onto `bindir`. PREFLIGHT all (source is a regular non-symlink file inside the
|
|
228
|
+
// skill dir; every dst is absent/ours) THEN mutate, so a conflict on a later wrapper leaves the
|
|
229
|
+
// filesystem untouched. win32 → no mutation (POSIX-only wrappers). Returns the per-wrapper results.
|
|
230
|
+
export const linkWrappers = (skillDir, manifest, opts = {}) => {
|
|
231
|
+
const platform = opts.platform ?? process.platform;
|
|
232
|
+
if (platform === 'win32') return { platform, skipped: true, links: [] };
|
|
233
|
+
const fs = fsDeps(opts);
|
|
234
|
+
const bindir = opts.bindir ?? bindirOf(opts);
|
|
235
|
+
const derived = deriveLinks(manifest, skillDir);
|
|
236
|
+
|
|
237
|
+
for (const { cmd, source } of derived) {
|
|
238
|
+
assertRegularFile(source, `wrapper "${cmd}" source (was the skill placed?)`, fs);
|
|
239
|
+
}
|
|
240
|
+
for (const { cmd, source } of derived) {
|
|
241
|
+
const info = inspectDst(join(bindir, cmd), source, fs);
|
|
242
|
+
if (info.state === 'conflict') throw stop(`wrapper "${cmd}" target conflict at ${join(bindir, cmd)} — ${info.reason}`, { dst: join(bindir, cmd) });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
fs.mkdir(bindir);
|
|
246
|
+
// Collapse a symlinked bindir (a common dotfiles setup, e.g. ~/.local/bin → ~/dotfiles/bin) to its
|
|
247
|
+
// real path, so the traversal guard inside linkManaged doesn't refuse the user's own PATH dir. cmd is
|
|
248
|
+
// an allowlisted basename, so dst can never escape this dir.
|
|
249
|
+
const realBindir = fs.realpath(bindir);
|
|
250
|
+
const links = [];
|
|
251
|
+
for (const { cmd, source } of derived) {
|
|
252
|
+
fs.chmod(source, 0o755);
|
|
253
|
+
const action = linkManaged(source, join(realBindir, cmd), realBindir, fs); // 'linked' | 'noop'
|
|
254
|
+
links.push({ cmd, source, dst: join(bindir, cmd), action });
|
|
255
|
+
}
|
|
256
|
+
return { platform, skipped: false, links };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// ── planning (read-only) ──────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const readBundledManifest = (bundleDir, deps) => {
|
|
262
|
+
const read = deps.readFile ?? readFileSync;
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = read(join(bundleDir, 'capability.json'), 'utf8');
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw Object.assign(
|
|
268
|
+
new Error(`[agent-workflow-kit] cannot read bundled manifest: ${join(bundleDir, 'capability.json')} (${err.code ?? 'fs error'})`),
|
|
269
|
+
{ code: 'MISSING_BUNDLE' },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(raw);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
throw stop(`bundled manifest is not valid JSON: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const errOutcome = (entry, skillDir, bindir, platform, err, place = null) => ({
|
|
280
|
+
name: entry.name,
|
|
281
|
+
skillDir,
|
|
282
|
+
bindir,
|
|
283
|
+
platform,
|
|
284
|
+
place,
|
|
285
|
+
links: [],
|
|
286
|
+
guides: [],
|
|
287
|
+
bindirHint: null,
|
|
288
|
+
outcome: err.code === SETUP_STOP ? 'stop' : 'error',
|
|
289
|
+
reason: err.message,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Build the full per-backend plan WITHOUT mutating anything (this is exactly what --dry-run prints).
|
|
293
|
+
// outcome ∈ unsupported(win32) | error | stop | ok. Only the deterministic skill+wrapper steps are
|
|
294
|
+
// "owned"; the cli/login axes surface as guides (informational — they never fail the command).
|
|
295
|
+
export const planFor = (backend, deps = {}) => {
|
|
296
|
+
const entry = registryEntry(backend);
|
|
297
|
+
if (!entry) throw stop(`unknown backend: ${backend}`);
|
|
298
|
+
const platform = deps.platform ?? process.platform;
|
|
299
|
+
const skillDir = skillDirOf(entry, deps);
|
|
300
|
+
const bindir = bindirOf(deps);
|
|
301
|
+
|
|
302
|
+
if (platform === 'win32') {
|
|
303
|
+
return {
|
|
304
|
+
name: entry.name, skillDir, bindir, platform, place: null, links: [], guides: [], bindirHint: null,
|
|
305
|
+
outcome: 'unsupported',
|
|
306
|
+
reason: 'POSIX .sh wrappers — run setup under WSL (Claude Code reads the kit natively on Windows)',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let place;
|
|
311
|
+
try {
|
|
312
|
+
place = inspectSkillDir(entry, deps);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return errOutcome(entry, skillDir, bindir, platform, err);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let links;
|
|
318
|
+
try {
|
|
319
|
+
const fs = fsDeps(deps);
|
|
320
|
+
const derived = deriveLinks(readBundledManifest(place.bundleDir, deps), skillDir);
|
|
321
|
+
// Preflight the BUNDLE sources (what we will copy → link). After place, the skill source IS the
|
|
322
|
+
// bundle source, so checking it here makes a dry-run faithfully predict linkWrappers instead of
|
|
323
|
+
// reporting "ok" and then throwing at apply time.
|
|
324
|
+
for (const { cmd, sourceRel } of derived) {
|
|
325
|
+
assertRegularFile(join(place.bundleDir, sourceRel), `bundled wrapper "${cmd}" source`, fs);
|
|
326
|
+
}
|
|
327
|
+
links = derived.map(({ cmd, sourceRel, source }) => {
|
|
328
|
+
const dst = join(bindir, cmd);
|
|
329
|
+
const info = inspectDst(dst, source, fs);
|
|
330
|
+
return { cmd, sourceRel, source, dst, dstState: info.state, dstReason: info.reason ?? null };
|
|
331
|
+
});
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return errOutcome(entry, skillDir, bindir, platform, err, place);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const conflicts = links.filter((l) => l.dstState === 'conflict');
|
|
337
|
+
if (conflicts.length > 0) {
|
|
338
|
+
return {
|
|
339
|
+
name: entry.name, skillDir, bindir, platform, place, links, guides: [], bindirHint: null,
|
|
340
|
+
outcome: 'stop',
|
|
341
|
+
reason: conflicts.map((c) => `wrapper "${c.cmd}": ${c.dstReason}`).join('; '),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const status = (deps.detect ?? detectBackend)(entry, deps);
|
|
346
|
+
const guides = guideFor(status).filter((g) => g.need !== 'skill'); // the place plan owns the skill axis
|
|
347
|
+
const onPath = bindirOnPath(bindir, deps.getenv ?? process.env, platform);
|
|
348
|
+
const bindirHint = onPath ? null : `add ${bindir} to PATH to use the wrappers: export PATH="${bindir}:$PATH" (persist in ~/.bashrc / ~/.zshrc)`;
|
|
349
|
+
|
|
350
|
+
return { name: entry.name, skillDir, bindir, platform, place, links, guides, bindirHint, outcome: 'ok' };
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Perform an `ok` plan's deterministic steps. Re-derives + re-checks inside the primitives (no trust
|
|
354
|
+
// in the plan across the read→write gap). Throws on a mid-flight conflict / fs error (honest partial
|
|
355
|
+
// failure — placeSkill/chmod/symlink all converge on a re-run, so there is nothing to roll back).
|
|
356
|
+
const applyBackend = (plan, deps) => {
|
|
357
|
+
if (plan.place.action === 'place' || plan.place.action === 'refresh') placeSkill(plan.name, deps);
|
|
358
|
+
const manifest = readBundledManifest(plan.place.bundleDir, deps);
|
|
359
|
+
linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// ── formatting ─────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
const formatBackend = (plan, applied) => {
|
|
365
|
+
const lines = [` ${plan.name} → ${plan.outcome}`];
|
|
366
|
+
if (plan.outcome === 'unsupported') {
|
|
367
|
+
lines.push(` ↳ ${plan.reason}`);
|
|
368
|
+
return lines.join('\n');
|
|
369
|
+
}
|
|
370
|
+
if (plan.outcome === 'stop' || plan.outcome === 'error') {
|
|
371
|
+
lines.push(` ✗ ${plan.reason}`);
|
|
372
|
+
return lines.join('\n');
|
|
373
|
+
}
|
|
374
|
+
const placeVerb = applied ? { place: 'placed', refresh: 'refreshed' } : { place: 'will place', refresh: 'will refresh' };
|
|
375
|
+
lines.push(` • skill: ${placeVerb[plan.place.action]} → ${plan.skillDir}`);
|
|
376
|
+
for (const l of plan.links) {
|
|
377
|
+
const verb = l.dstState === 'ours' ? 'already linked' : applied ? 'linked' : 'will link';
|
|
378
|
+
lines.push(` • wrapper ${l.cmd}: ${verb} → ${l.dst}`);
|
|
379
|
+
}
|
|
380
|
+
if (plan.bindirHint) lines.push(` ↳ ${plan.bindirHint}`);
|
|
381
|
+
for (const g of plan.guides) lines.push(` ↳ ${g.need}: ${g.hint}`);
|
|
382
|
+
return lines.join('\n');
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
const USAGE = `usage: setup-backends [<backend>] [--bindir <path>] [--dry-run] [--help]
|
|
388
|
+
|
|
389
|
+
<backend> codex | agy | antigravity | codex-cli-bridge | antigravity-cli-bridge (default: all)
|
|
390
|
+
--bindir where to link the wrappers (default: ~/.local/bin)
|
|
391
|
+
--dry-run print the plan; change nothing
|
|
392
|
+
--help, -h this help
|
|
393
|
+
|
|
394
|
+
Places the bundled bridge skill + links its wrappers (idempotent; refuses to clobber a non-symlink).
|
|
395
|
+
Binary install + the interactive subscription login stay manual — the printed guidance has the exact
|
|
396
|
+
commands. The detector ("/agent-workflow-kit backends") stays read-only; this is the only writer.`;
|
|
397
|
+
|
|
398
|
+
const parseArgs = (argv) => {
|
|
399
|
+
const out = { dryRun: false, help: false, bindir: undefined, backend: undefined, bad: null };
|
|
400
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
401
|
+
const a = argv[i];
|
|
402
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
403
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
404
|
+
else if (a === '--bindir') {
|
|
405
|
+
// Do NOT greedily swallow a following flag (e.g. `--bindir --dry-run` must not become
|
|
406
|
+
// bindir="--dry-run" and silently mutate); a missing or flag-like value is a usage error.
|
|
407
|
+
const next = argv[i + 1];
|
|
408
|
+
if (next === undefined || next.startsWith('-')) out.bad = '--bindir needs a path argument';
|
|
409
|
+
else {
|
|
410
|
+
out.bindir = next;
|
|
411
|
+
i += 1;
|
|
412
|
+
}
|
|
413
|
+
} else if (a.startsWith('-')) out.bad = `unknown flag: ${a}`;
|
|
414
|
+
else if (out.backend === undefined) out.backend = a;
|
|
415
|
+
else out.bad = `unexpected argument: ${a}`;
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// Returns the process exit code (0 ok/guided/unsupported, non-zero on STOP/error/bad-args). Pure of
|
|
421
|
+
// process.exit so tests can assert the code; isDirectRun is what actually exits.
|
|
422
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
423
|
+
const log = deps.log ?? console.log;
|
|
424
|
+
const errlog = deps.errlog ?? console.error;
|
|
425
|
+
const args = parseArgs(argv);
|
|
426
|
+
|
|
427
|
+
if (args.help) {
|
|
428
|
+
log(USAGE);
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
if (args.bad) {
|
|
432
|
+
errlog(args.bad);
|
|
433
|
+
errlog(USAGE);
|
|
434
|
+
return 2;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let targets;
|
|
438
|
+
if (args.backend === undefined) targets = KNOWN_BACKENDS.map((b) => b.name);
|
|
439
|
+
else {
|
|
440
|
+
const entry = registryEntry(args.backend);
|
|
441
|
+
if (!entry) {
|
|
442
|
+
errlog(`unknown backend: ${args.backend}`);
|
|
443
|
+
errlog(USAGE);
|
|
444
|
+
return 2;
|
|
445
|
+
}
|
|
446
|
+
targets = [entry.name];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const runDeps = { ...deps, bindir: args.bindir ?? deps.bindir };
|
|
450
|
+
log(args.dryRun ? 'agent-workflow backend setup — DRY RUN (no changes)' : 'agent-workflow backend setup (link-only)');
|
|
451
|
+
let worst = 0;
|
|
452
|
+
for (const name of targets) {
|
|
453
|
+
let plan = planFor(name, runDeps);
|
|
454
|
+
if (!args.dryRun && plan.outcome === 'ok') {
|
|
455
|
+
try {
|
|
456
|
+
applyBackend(plan, runDeps);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
plan = { ...plan, outcome: err.code === SETUP_STOP ? 'stop' : 'error', reason: err.message };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
log(formatBackend(plan, !args.dryRun));
|
|
462
|
+
if (plan.outcome === 'stop' || plan.outcome === 'error') worst = 1;
|
|
463
|
+
}
|
|
464
|
+
return worst;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
468
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|