@sabaiway/agent-workflow-kit 1.5.2 → 1.7.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 +67 -0
- package/README.md +12 -5
- package/SKILL.md +48 -20
- package/bin/install.mjs +33 -50
- package/bin/install.test.mjs +30 -1
- 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/migrations/README.md +1 -1
- package/package.json +3 -2
- package/references/templates/AGENTS.md +2 -1
- package/tools/delegation.mjs +4 -4
- package/tools/delegation.test.mjs +4 -3
- 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/inject-methodology.mjs +131 -23
- package/tools/inject-methodology.test.mjs +232 -1
- package/tools/setup-backends.mjs +468 -0
- package/tools/setup-backends.test.mjs +500 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, readlinkSync, readFileSync, existsSync, lstatSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
bindirOnPath,
|
|
10
|
+
deriveLinks,
|
|
11
|
+
placeSkill,
|
|
12
|
+
linkWrappers,
|
|
13
|
+
planFor,
|
|
14
|
+
main,
|
|
15
|
+
SETUP_STOP,
|
|
16
|
+
} from './setup-backends.mjs';
|
|
17
|
+
|
|
18
|
+
const eacces = () => Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
19
|
+
|
|
20
|
+
// ── fixtures ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const CODEX_ROLES = {
|
|
23
|
+
execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' },
|
|
24
|
+
review: { cmd: 'codex-review', source: 'bin/codex-review.sh' },
|
|
25
|
+
};
|
|
26
|
+
const manifestOf = (name, roles) => ({
|
|
27
|
+
family: 'agent-workflow', schema: 1, name, kind: 'execution-backend', version: '1.0.0',
|
|
28
|
+
provides: Object.keys(roles), roles,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// A bundle SKILL.md the REAL validator accepts (it now validates the bundle): metadata.version must
|
|
32
|
+
// match the manifest version (1.0.0). The frontmatter `name` is decorative — validateManifest reads
|
|
33
|
+
// the name from capability.json.
|
|
34
|
+
const skillMd = (name) => `---\nname: ${name}\nmetadata:\n version: '1.0.0'\n---\n# ${name} (bundled)\n`;
|
|
35
|
+
|
|
36
|
+
// Write a fake bundle at <root>/<name>/ with SKILL.md + capability.json + the wrapper scripts.
|
|
37
|
+
const writeBundle = (root, name = 'codex-cli-bridge', roles = CODEX_ROLES, extra = {}) => {
|
|
38
|
+
const dir = join(root, name);
|
|
39
|
+
mkdirSync(join(dir, 'bin'), { recursive: true });
|
|
40
|
+
writeFileSync(join(dir, 'SKILL.md'), extra.skill ?? skillMd(name));
|
|
41
|
+
writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifestOf(name, roles), null, 2));
|
|
42
|
+
for (const r of Object.values(roles)) writeFileSync(join(dir, r.source), '#!/bin/sh\necho hi\n');
|
|
43
|
+
if (extra.newFile) writeFileSync(join(dir, extra.newFile), 'bundled-only\n');
|
|
44
|
+
return dir;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const okValidate = (name = 'codex-cli-bridge') => () => ({ result: 'valid', name, kind: 'execution-backend', available: true });
|
|
48
|
+
const fakeDetect = (over = {}) => () => ({
|
|
49
|
+
name: 'codex-cli-bridge',
|
|
50
|
+
manifestState: 'ok',
|
|
51
|
+
cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
|
|
52
|
+
credentials: { state: 'present', path: '/c/auth.json' },
|
|
53
|
+
wrappers: [], readiness: 'ready', setupHint: { url: 'u' }, skillDir: '/s',
|
|
54
|
+
...over,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let tmp;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
tmp = mkdtempSync(join(tmpdir(), 'awf-setup-'));
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Build a deps object that points the codex backend at controllable skill/bundle/bindir dirs.
|
|
66
|
+
const baseDeps = (over = {}) => {
|
|
67
|
+
const bundleRoot = over.bundleRoot ?? join(tmp, 'bundles');
|
|
68
|
+
const skillDir = over.skillDir ?? join(tmp, 'skill');
|
|
69
|
+
return {
|
|
70
|
+
platform: 'linux',
|
|
71
|
+
home: join(tmp, 'home'),
|
|
72
|
+
getenv: { CODEX_CLI_BRIDGE_DIR: skillDir, PATH: '', ...(over.getenv ?? {}) },
|
|
73
|
+
bundleRoot,
|
|
74
|
+
bindir: over.bindir ?? join(tmp, 'bin'),
|
|
75
|
+
validate: over.validate,
|
|
76
|
+
detect: over.detect ?? fakeDetect(),
|
|
77
|
+
...over.rest,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ── bindirOnPath ────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('bindirOnPath', () => {
|
|
84
|
+
it('true when the dir is a PATH member (posix, normalised)', () => {
|
|
85
|
+
assert.equal(bindirOnPath('/home/u/.local/bin', { PATH: '/usr/bin:/home/u/.local/bin/' }, 'linux'), true);
|
|
86
|
+
});
|
|
87
|
+
it('false when absent', () => {
|
|
88
|
+
assert.equal(bindirOnPath('/home/u/.local/bin', { PATH: '/usr/bin:/bin' }, 'linux'), false);
|
|
89
|
+
});
|
|
90
|
+
it('win32 is case-insensitive and uses ; + Path', () => {
|
|
91
|
+
assert.equal(bindirOnPath('C:\\Tools\\Bin', { Path: 'c:\\tools\\bin;C:\\Windows' }, 'win32'), true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── deriveLinks (pure manifest → links, untrusted-string validation) ──────────
|
|
96
|
+
|
|
97
|
+
describe('deriveLinks', () => {
|
|
98
|
+
const SK = '/skills/codex-cli-bridge';
|
|
99
|
+
|
|
100
|
+
it('dedupes a cmd shared by two roles with the SAME source (agy-run case)', () => {
|
|
101
|
+
const roles = { review: { cmd: 'agy-run', source: 'bin/agy.sh' }, probe: { cmd: 'agy-run', source: 'bin/agy.sh' } };
|
|
102
|
+
const links = deriveLinks(manifestOf('antigravity-cli-bridge', roles), SK);
|
|
103
|
+
assert.equal(links.length, 1);
|
|
104
|
+
assert.equal(links[0].cmd, 'agy-run');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('two distinct cmds → two links', () => {
|
|
108
|
+
assert.equal(deriveLinks(manifestOf('codex-cli-bridge', CODEX_ROLES), SK).length, 2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const bad of ['../evil', 'a/b', 'ab', '', 'a b']) {
|
|
112
|
+
it(`rejects cmd ${JSON.stringify(bad)} (allowlist)`, () => {
|
|
113
|
+
const roles = { execute: { cmd: bad, source: 'bin/x.sh' } };
|
|
114
|
+
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), (e) => e.code === SETUP_STOP);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('STOPs when one cmd maps to two different sources', () => {
|
|
119
|
+
const roles = { execute: { cmd: 'dup', source: 'bin/a.sh' }, review: { cmd: 'dup', source: 'bin/b.sh' } };
|
|
120
|
+
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /two sources/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('STOPs when a source escapes the skill dir', () => {
|
|
124
|
+
const roles = { execute: { cmd: 'codex-exec', source: '../escape.sh' } };
|
|
125
|
+
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /escapes/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('STOPs when there are no wrapper roles', () => {
|
|
129
|
+
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', {}), SK), /no wrapper roles/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const reserved of ['.', '..']) {
|
|
133
|
+
it(`rejects the reserved cmd ${JSON.stringify(reserved)} (would resolve to bindir / its parent)`, () => {
|
|
134
|
+
const roles = { execute: { cmd: reserved, source: 'bin/x.sh' } };
|
|
135
|
+
assert.throws(() => deriveLinks(manifestOf('codex-cli-bridge', roles), SK), /reserved path name/);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── planFor: action selection by axes (read-only, no mutation) ────────────────
|
|
141
|
+
|
|
142
|
+
describe('planFor — action selection', () => {
|
|
143
|
+
it('not-installed (absent skill dir) → place + link, no fs mutation', () => {
|
|
144
|
+
writeBundle(join(tmp, 'bundles'));
|
|
145
|
+
const skillDir = join(tmp, 'skill');
|
|
146
|
+
const plan = planFor('codex', baseDeps({ skillDir }));
|
|
147
|
+
assert.equal(plan.outcome, 'ok');
|
|
148
|
+
assert.equal(plan.place.action, 'place');
|
|
149
|
+
assert.deepEqual(plan.links.map((l) => l.dstState).sort(), ['absent', 'absent']);
|
|
150
|
+
assert.equal(existsSync(skillDir), false, 'planFor must not create the skill dir');
|
|
151
|
+
assert.equal(existsSync(plan.bindir), false, 'planFor must not create the bindir');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('ok + proven-managed, one wrapper linked → refresh + relink the missing one', () => {
|
|
155
|
+
writeBundle(join(tmp, 'bundles'));
|
|
156
|
+
const skillDir = join(tmp, 'skill');
|
|
157
|
+
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
158
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
159
|
+
writeFileSync(join(skillDir, 'capability.json'), '{}');
|
|
160
|
+
const bindir = join(tmp, 'bin');
|
|
161
|
+
mkdirSync(bindir, { recursive: true });
|
|
162
|
+
symlinkSync(join(skillDir, 'bin', 'codex-exec.sh'), join(bindir, 'codex-exec')); // ours
|
|
163
|
+
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate() }));
|
|
164
|
+
assert.equal(plan.place.action, 'refresh');
|
|
165
|
+
const byCmd = Object.fromEntries(plan.links.map((l) => [l.cmd, l.dstState]));
|
|
166
|
+
assert.equal(byCmd['codex-exec'], 'ours');
|
|
167
|
+
assert.equal(byCmd['codex-review'], 'absent');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('ok + all wrappers linked + cli missing → outcome ok with a cli guide', () => {
|
|
171
|
+
writeBundle(join(tmp, 'bundles'));
|
|
172
|
+
const skillDir = join(tmp, 'skill');
|
|
173
|
+
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
174
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
175
|
+
const bindir = join(tmp, 'bin');
|
|
176
|
+
mkdirSync(bindir, { recursive: true });
|
|
177
|
+
symlinkSync(join(skillDir, 'bin', 'codex-exec.sh'), join(bindir, 'codex-exec'));
|
|
178
|
+
symlinkSync(join(skillDir, 'bin', 'codex-review.sh'), join(bindir, 'codex-review'));
|
|
179
|
+
const detect = fakeDetect({ cli: { bin: 'codex', state: 'missing', path: null } });
|
|
180
|
+
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate(), detect }));
|
|
181
|
+
assert.equal(plan.outcome, 'ok');
|
|
182
|
+
assert.deepEqual(plan.links.map((l) => l.dstState), ['ours', 'ours']);
|
|
183
|
+
assert.ok(plan.guides.some((g) => g.need === 'cli'));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
for (const state of ['stub', 'foreign', 'invalid-manifest', 'unsupported-schema']) {
|
|
187
|
+
it(`${state} manifest → STOP, no mutation`, () => {
|
|
188
|
+
writeBundle(join(tmp, 'bundles'));
|
|
189
|
+
const skillDir = join(tmp, 'skill');
|
|
190
|
+
mkdirSync(skillDir, { recursive: true });
|
|
191
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# present\n');
|
|
192
|
+
const validate = () => ({
|
|
193
|
+
stub: { result: 'valid', name: 'codex-cli-bridge', kind: 'execution-backend', available: false },
|
|
194
|
+
foreign: { result: 'valid', name: 'other', kind: 'execution-backend', available: true },
|
|
195
|
+
'invalid-manifest': { result: 'invalid', errors: ['x'] },
|
|
196
|
+
'unsupported-schema': { result: 'unsupported', errors: ['x'] },
|
|
197
|
+
}[state]);
|
|
198
|
+
const plan = planFor('codex', baseDeps({ skillDir, validate }));
|
|
199
|
+
assert.equal(plan.outcome, 'stop');
|
|
200
|
+
assert.match(plan.reason, new RegExp(state));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
it('marker unknown (EACCES on SKILL.md) → STOP, no write', () => {
|
|
205
|
+
writeBundle(join(tmp, 'bundles')); // a real, valid bundle (now validated); the skill dir is mocked
|
|
206
|
+
const skillDir = join(tmp, 'skill');
|
|
207
|
+
const deps = baseDeps({
|
|
208
|
+
skillDir,
|
|
209
|
+
rest: {
|
|
210
|
+
exists: () => true, // bundle marker + skill marker both "exist"
|
|
211
|
+
lstat: () => ({ isSymbolicLink: () => false, isDirectory: () => true, isFile: () => true }),
|
|
212
|
+
stat: () => { throw eacces(); }, // probing the marker fails non-ENOENT → unknown
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const plan = planFor('codex', deps);
|
|
216
|
+
assert.equal(plan.outcome, 'stop');
|
|
217
|
+
assert.match(plan.reason, /cannot determine bridge skill state/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('a foreign same-named wrapper elsewhere on PATH does NOT count as linked (per-bindir)', () => {
|
|
221
|
+
writeBundle(join(tmp, 'bundles'));
|
|
222
|
+
const skillDir = join(tmp, 'skill');
|
|
223
|
+
mkdirSync(skillDir, { recursive: true });
|
|
224
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# installed\n');
|
|
225
|
+
const elsewhere = join(tmp, 'elsewhere');
|
|
226
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
227
|
+
writeFileSync(join(elsewhere, 'codex-exec'), 'foreign'); // same name, different dir on PATH
|
|
228
|
+
const bindir = join(tmp, 'bin');
|
|
229
|
+
const plan = planFor('codex', baseDeps({ skillDir, bindir, validate: okValidate(), getenv: { CODEX_CLI_BRIDGE_DIR: skillDir, PATH: elsewhere } }));
|
|
230
|
+
const exec = plan.links.find((l) => l.cmd === 'codex-exec');
|
|
231
|
+
assert.equal(exec.dstState, 'absent'); // judged at bindir/codex-exec, not PATH-wide
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── placeSkill (mutating) ─────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe('placeSkill', () => {
|
|
238
|
+
it('refreshes a proven-managed dir (overwrites + adds bundled files)', () => {
|
|
239
|
+
writeBundle(join(tmp, 'bundles'), 'codex-cli-bridge', CODEX_ROLES, { newFile: 'NEW.txt' });
|
|
240
|
+
const skillDir = join(tmp, 'skill');
|
|
241
|
+
mkdirSync(skillDir, { recursive: true });
|
|
242
|
+
writeFileSync(join(skillDir, 'SKILL.md'), 'OLD\n');
|
|
243
|
+
writeFileSync(join(skillDir, 'capability.json'), '{}');
|
|
244
|
+
placeSkill('codex', baseDeps({ skillDir, validate: okValidate() }));
|
|
245
|
+
assert.equal(readFileSync(join(skillDir, 'SKILL.md'), 'utf8'), skillMd('codex-cli-bridge')); // overwritten
|
|
246
|
+
assert.ok(existsSync(join(skillDir, 'NEW.txt'))); // bundled-only file delivered
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('refuses a foreign bundle (valid JSON, wrong name) — corrupt kit, no place', () => {
|
|
250
|
+
const bundleRoot = join(tmp, 'bundles');
|
|
251
|
+
writeBundle(bundleRoot, 'codex-cli-bridge'); // create the expected slot…
|
|
252
|
+
const dir = join(bundleRoot, 'codex-cli-bridge'); // …then claim a foreign name in it
|
|
253
|
+
writeFileSync(join(dir, 'capability.json'), JSON.stringify(manifestOf('not-codex', CODEX_ROLES), null, 2));
|
|
254
|
+
writeFileSync(join(dir, 'SKILL.md'), skillMd('not-codex'));
|
|
255
|
+
const skillDir = join(tmp, 'skill');
|
|
256
|
+
assert.throws(() => placeSkill('codex', baseDeps({ skillDir, bundleRoot })), /bundled bridge manifest/);
|
|
257
|
+
assert.equal(existsSync(join(skillDir, 'SKILL.md')), false); // never placed the foreign bridge
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('refuses a non-empty foreign dir (no SKILL.md marker)', () => {
|
|
261
|
+
writeBundle(join(tmp, 'bundles'));
|
|
262
|
+
const skillDir = join(tmp, 'skill');
|
|
263
|
+
mkdirSync(skillDir, { recursive: true });
|
|
264
|
+
writeFileSync(join(skillDir, 'random.txt'), 'someone elses files\n');
|
|
265
|
+
assert.throws(() => placeSkill('codex', baseDeps({ skillDir })), (e) => e.code === SETUP_STOP);
|
|
266
|
+
assert.equal(existsSync(join(skillDir, 'SKILL.md')), false); // untouched
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('refuses to write through a symlinked skill dir', () => {
|
|
270
|
+
writeBundle(join(tmp, 'bundles'));
|
|
271
|
+
const real = join(tmp, 'real');
|
|
272
|
+
mkdirSync(real, { recursive: true });
|
|
273
|
+
const skillDir = join(tmp, 'skill-link');
|
|
274
|
+
symlinkSync(real, skillDir);
|
|
275
|
+
assert.throws(() => placeSkill('codex', baseDeps({ skillDir })), /symlink/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('fails loud when the bundle is missing (corrupt kit)', () => {
|
|
279
|
+
const skillDir = join(tmp, 'skill');
|
|
280
|
+
assert.throws(() => placeSkill('codex', baseDeps({ skillDir, bundleRoot: join(tmp, 'no-bundles') })), /bundled bridge missing/);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── linkWrappers (mutating, preflight-then-mutate) ────────────────────────────
|
|
285
|
+
|
|
286
|
+
const placedSkill = (roles = CODEX_ROLES) => {
|
|
287
|
+
const skillDir = join(tmp, 'skill');
|
|
288
|
+
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
289
|
+
for (const r of Object.values(roles)) writeFileSync(join(skillDir, r.source), '#!/bin/sh\n');
|
|
290
|
+
return skillDir;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
describe('linkWrappers', () => {
|
|
294
|
+
it('creates an absent bindir and links each wrapper (mkdir -p)', () => {
|
|
295
|
+
const skillDir = placedSkill();
|
|
296
|
+
const bindir = join(tmp, 'newbin', 'nested');
|
|
297
|
+
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
298
|
+
assert.deepEqual(res.links.map((l) => l.action).sort(), ['linked', 'linked']);
|
|
299
|
+
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('links into a SYMLINKED bindir (common dotfiles setup) instead of refusing it', () => {
|
|
303
|
+
const skillDir = placedSkill();
|
|
304
|
+
const realBin = join(tmp, 'real-bin');
|
|
305
|
+
mkdirSync(realBin, { recursive: true });
|
|
306
|
+
const bindir = join(tmp, 'link-bin');
|
|
307
|
+
symlinkSync(realBin, bindir); // ~/.local/bin → elsewhere
|
|
308
|
+
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
309
|
+
assert.deepEqual(res.links.map((l) => l.action).sort(), ['linked', 'linked']);
|
|
310
|
+
// the wrapper lands in the real dir, reachable through the symlinked bindir, pointing at our source
|
|
311
|
+
assert.equal(readlinkSync(join(realBin, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
312
|
+
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('is idempotent — a re-run is all noop', () => {
|
|
316
|
+
const skillDir = placedSkill();
|
|
317
|
+
const bindir = join(tmp, 'bin');
|
|
318
|
+
linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
319
|
+
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir });
|
|
320
|
+
assert.deepEqual(res.links.map((l) => l.action), ['noop', 'noop']);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('STOPs on a non-symlink dest (and does not clobber it)', () => {
|
|
324
|
+
const skillDir = placedSkill();
|
|
325
|
+
const bindir = join(tmp, 'bin');
|
|
326
|
+
mkdirSync(bindir, { recursive: true });
|
|
327
|
+
writeFileSync(join(bindir, 'codex-exec'), 'real file');
|
|
328
|
+
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), (e) => e.code === SETUP_STOP);
|
|
329
|
+
assert.equal(readFileSync(join(bindir, 'codex-exec'), 'utf8'), 'real file');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('STOPs on a foreign symlink dest', () => {
|
|
333
|
+
const skillDir = placedSkill();
|
|
334
|
+
const bindir = join(tmp, 'bin');
|
|
335
|
+
mkdirSync(bindir, { recursive: true });
|
|
336
|
+
symlinkSync(join(tmp, 'somewhere-else'), join(bindir, 'codex-exec'));
|
|
337
|
+
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), /foreign symlink/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('preflight conflict on the 2nd wrapper → ZERO mutations (1st not linked)', () => {
|
|
341
|
+
const skillDir = placedSkill();
|
|
342
|
+
const bindir = join(tmp, 'bin');
|
|
343
|
+
mkdirSync(bindir, { recursive: true });
|
|
344
|
+
symlinkSync(join(tmp, 'elsewhere'), join(bindir, 'codex-review')); // 2nd wrapper conflicts
|
|
345
|
+
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir }), (e) => e.code === SETUP_STOP);
|
|
346
|
+
assert.equal(existsSync(join(bindir, 'codex-exec')), false, '1st wrapper must not be linked');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('STOPs when a source is a symlink (never links through a symlinked source)', () => {
|
|
350
|
+
const skillDir = join(tmp, 'skill');
|
|
351
|
+
mkdirSync(join(skillDir, 'bin'), { recursive: true });
|
|
352
|
+
writeFileSync(join(skillDir, 'bin', 'real.sh'), '#!/bin/sh\n');
|
|
353
|
+
symlinkSync(join(skillDir, 'bin', 'real.sh'), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
354
|
+
const roles = { execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' } };
|
|
355
|
+
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', roles), { bindir: join(tmp, 'bin') }), /symlink/i);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('STOPs when a source is missing (skill not placed)', () => {
|
|
359
|
+
const skillDir = join(tmp, 'skill'); // no bin/*.sh
|
|
360
|
+
mkdirSync(skillDir, { recursive: true });
|
|
361
|
+
assert.throws(() => linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir: join(tmp, 'bin') }), /is missing/);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('win32 → no mutation (POSIX-only wrappers)', () => {
|
|
365
|
+
const skillDir = placedSkill();
|
|
366
|
+
const bindir = join(tmp, 'bin');
|
|
367
|
+
const res = linkWrappers(skillDir, manifestOf('codex-cli-bridge', CODEX_ROLES), { bindir, platform: 'win32' });
|
|
368
|
+
assert.equal(res.skipped, true);
|
|
369
|
+
assert.equal(existsSync(bindir), false);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ── dry-run plan correctness before the skill is placed ───────────────────────
|
|
374
|
+
|
|
375
|
+
describe('planFor --dry-run shape (skill not placed yet)', () => {
|
|
376
|
+
it('derives links from the bundled manifest with dst/source set, mutates nothing', () => {
|
|
377
|
+
writeBundle(join(tmp, 'bundles'));
|
|
378
|
+
const skillDir = join(tmp, 'skill');
|
|
379
|
+
const bindir = join(tmp, 'bin');
|
|
380
|
+
const plan = planFor('codex', baseDeps({ skillDir, bindir }));
|
|
381
|
+
assert.equal(plan.place.action, 'place');
|
|
382
|
+
const exec = plan.links.find((l) => l.cmd === 'codex-exec');
|
|
383
|
+
assert.equal(exec.dst, join(bindir, 'codex-exec'));
|
|
384
|
+
assert.equal(exec.source, join(skillDir, 'bin', 'codex-exec.sh'));
|
|
385
|
+
assert.equal(existsSync(skillDir), false);
|
|
386
|
+
assert.equal(existsSync(bindir), false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('a dry-run STOPs if a bundle wrapper source is a symlink (validates, but not linkable) — faithful plan', () => {
|
|
390
|
+
const bundleDir = writeBundle(join(tmp, 'bundles')); // valid bundle…
|
|
391
|
+
// …then replace a wrapper source with a symlink (validateManifest follows it → still "valid",
|
|
392
|
+
// but linkWrappers would refuse it, so the dry-run must predict the STOP, not report ok).
|
|
393
|
+
const target = join(bundleDir, 'bin', 'real-exec.sh');
|
|
394
|
+
writeFileSync(target, '#!/bin/sh\n');
|
|
395
|
+
const src = join(bundleDir, 'bin', 'codex-exec.sh');
|
|
396
|
+
rmSync(src);
|
|
397
|
+
symlinkSync(target, src);
|
|
398
|
+
const plan = planFor('codex', baseDeps({ skillDir: join(tmp, 'skill') }));
|
|
399
|
+
assert.equal(plan.outcome, 'stop');
|
|
400
|
+
assert.match(plan.reason, /symlink/i);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// ── main(): CLI contract + exit matrix ────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
const capturedMain = (argv, deps = {}) => {
|
|
407
|
+
const out = [];
|
|
408
|
+
const code = main(argv, { ...deps, log: (s) => out.push(s), errlog: (s) => out.push(s) });
|
|
409
|
+
return { code, text: out.join('\n') };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
describe('main — CLI contract + exit matrix', () => {
|
|
413
|
+
it('--help → 0 + usage', () => {
|
|
414
|
+
const { code, text } = capturedMain(['--help']);
|
|
415
|
+
assert.equal(code, 0);
|
|
416
|
+
assert.match(text, /usage: setup-backends/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('unknown flag → 2 + usage', () => {
|
|
420
|
+
assert.equal(capturedMain(['--bogus']).code, 2);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('unknown backend → 2 + usage', () => {
|
|
424
|
+
const { code, text } = capturedMain(['nope']);
|
|
425
|
+
assert.equal(code, 2);
|
|
426
|
+
assert.match(text, /unknown backend: nope/);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('--bindir with a flag-like or missing value → 2 (never swallows --dry-run into a mutating run)', () => {
|
|
430
|
+
assert.equal(capturedMain(['--bindir', '--dry-run']).code, 2); // would have mutated into "./--dry-run"
|
|
431
|
+
assert.equal(capturedMain(['codex', '--bindir']).code, 2); // trailing --bindir
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('dry-run on an ok plan with guides → 0 (guides never fail the command)', () => {
|
|
435
|
+
writeBundle(join(tmp, 'bundles'));
|
|
436
|
+
const skillDir = join(tmp, 'skill');
|
|
437
|
+
const detect = fakeDetect({ cli: { bin: 'codex', state: 'missing', path: null } });
|
|
438
|
+
const { code, text } = capturedMain(['codex', '--dry-run'], baseDeps({ skillDir, detect }));
|
|
439
|
+
assert.equal(code, 0);
|
|
440
|
+
assert.match(text, /DRY RUN/);
|
|
441
|
+
assert.match(text, /cli:/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('STOP (foreign skill dir) → non-zero, no mutation', () => {
|
|
445
|
+
writeBundle(join(tmp, 'bundles'));
|
|
446
|
+
const skillDir = join(tmp, 'skill');
|
|
447
|
+
mkdirSync(skillDir, { recursive: true });
|
|
448
|
+
writeFileSync(join(skillDir, 'SKILL.md'), '# present\n');
|
|
449
|
+
const bindir = join(tmp, 'bin');
|
|
450
|
+
const validate = () => ({ result: 'valid', name: 'other', kind: 'execution-backend', available: true });
|
|
451
|
+
const { code } = capturedMain(['codex'], baseDeps({ skillDir, bindir, validate }));
|
|
452
|
+
assert.notEqual(code, 0);
|
|
453
|
+
assert.equal(existsSync(bindir), false); // refused before any write
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('missing bundle → non-zero with the bundle path in the message', () => {
|
|
457
|
+
const { code, text } = capturedMain(['codex'], baseDeps({ skillDir: join(tmp, 'skill'), bundleRoot: join(tmp, 'gone') }));
|
|
458
|
+
assert.notEqual(code, 0);
|
|
459
|
+
assert.match(text, /bundled bridge missing/);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('native fs error preserves the underlying reason (EIO)', () => {
|
|
463
|
+
writeBundle(join(tmp, 'bundles')); // real valid bundle; the skill-dir lstat is what throws EIO
|
|
464
|
+
const deps = baseDeps({
|
|
465
|
+
skillDir: join(tmp, 'skill'),
|
|
466
|
+
rest: {
|
|
467
|
+
exists: () => true,
|
|
468
|
+
lstat: () => { throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' }); },
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
const { code, text } = capturedMain(['codex'], deps);
|
|
472
|
+
assert.notEqual(code, 0);
|
|
473
|
+
assert.match(text, /EIO/);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('win32 → 0, prints unsupported / WSL, mutates nothing', () => {
|
|
477
|
+
writeBundle(join(tmp, 'bundles'));
|
|
478
|
+
const bindir = join(tmp, 'bin');
|
|
479
|
+
const { code, text } = capturedMain(['codex'], baseDeps({ skillDir: join(tmp, 'skill'), bindir, rest: { platform: 'win32' } }));
|
|
480
|
+
assert.equal(code, 0);
|
|
481
|
+
assert.match(text, /unsupported|WSL/i);
|
|
482
|
+
assert.equal(existsSync(bindir), false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('real run: places the skill + links both wrappers, idempotent on re-run', () => {
|
|
486
|
+
writeBundle(join(tmp, 'bundles'));
|
|
487
|
+
const skillDir = join(tmp, 'skill');
|
|
488
|
+
const bindir = join(tmp, 'bin');
|
|
489
|
+
const deps = baseDeps({ skillDir, bindir });
|
|
490
|
+
const first = capturedMain(['codex'], deps);
|
|
491
|
+
assert.equal(first.code, 0);
|
|
492
|
+
assert.ok(existsSync(join(skillDir, 'SKILL.md')), 'skill placed');
|
|
493
|
+
assert.equal(readlinkSync(join(bindir, 'codex-exec')), join(skillDir, 'bin', 'codex-exec.sh'));
|
|
494
|
+
assert.equal(readlinkSync(join(bindir, 'codex-review')), join(skillDir, 'bin', 'codex-review.sh'));
|
|
495
|
+
// re-run: proven-managed refresh + all links already ours → still 0, no breakage
|
|
496
|
+
const second = capturedMain(['codex'], { ...deps, validate: okValidate() });
|
|
497
|
+
assert.equal(second.code, 0);
|
|
498
|
+
assert.match(second.text, /already linked/);
|
|
499
|
+
});
|
|
500
|
+
});
|