@sabaiway/agent-workflow-kit 1.11.0 → 1.13.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 +56 -1
- package/README.md +12 -4
- package/SKILL.md +103 -37
- package/capability.json +5 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +276 -0
- package/tools/family-registry.test.mjs +247 -0
- package/tools/fs-safe.mjs +50 -1
- package/tools/fs-safe.test.mjs +140 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/manifest/validate.mjs +3 -1
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
- package/tools/uninstall.integration.test.mjs +144 -0
- package/tools/uninstall.mjs +420 -0
- package/tools/uninstall.test.mjs +372 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
buildPlan,
|
|
6
|
+
executePlan,
|
|
7
|
+
formatPlan,
|
|
8
|
+
parseArgs,
|
|
9
|
+
SAFE_REMOVE,
|
|
10
|
+
MANAGED_MARKER,
|
|
11
|
+
REPORT_ONLY,
|
|
12
|
+
STOP,
|
|
13
|
+
UNINSTALL_STOP,
|
|
14
|
+
} from './uninstall.mjs';
|
|
15
|
+
import { OK } from './family-registry.mjs';
|
|
16
|
+
|
|
17
|
+
// ── synthetic family rows (the surveyFamily shape) ─────────────────────────────
|
|
18
|
+
const row = (name, kind, over = {}) => ({
|
|
19
|
+
name, kind, installed: true, skillDir: `/skills/${name}`, manifestState: OK, version: '1.0.0', ...over,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const KIT = row('agent-workflow-kit', 'composition-root');
|
|
23
|
+
const MEMORY = row('agent-workflow-memory', 'memory-substrate');
|
|
24
|
+
const ENGINE = row('agent-workflow-engine', 'methodology-engine');
|
|
25
|
+
const CODEX = row('codex-cli-bridge', 'execution-backend');
|
|
26
|
+
const ANTIGRAVITY = row('antigravity-cli-bridge', 'execution-backend');
|
|
27
|
+
|
|
28
|
+
const find = (items, surface, member) => items.find((i) => i.surface === surface && (member ? i.member === member : true));
|
|
29
|
+
|
|
30
|
+
// A path-keyed mock fs. `symlinks` maps a path → its (absolute) link target; `files` maps path →
|
|
31
|
+
// contents; `dirs` is a set of present directories. realpath is identity (no symlinked bindir here).
|
|
32
|
+
const mockFs = ({ symlinks = {}, files = {}, dirs = [], manifests = {} } = {}) => {
|
|
33
|
+
const enoent = (p) => Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' });
|
|
34
|
+
const present = (p) => p in symlinks || p in files || dirs.includes(p);
|
|
35
|
+
return {
|
|
36
|
+
exists: (p) => present(p),
|
|
37
|
+
stat: (p) => ({ isFile: () => p in files, isDirectory: () => dirs.includes(p) }),
|
|
38
|
+
lstat: (p) => {
|
|
39
|
+
if (p in symlinks) return { isSymbolicLink: () => true, isFile: () => false };
|
|
40
|
+
if (present(p)) return { isSymbolicLink: () => false, isFile: () => p in files };
|
|
41
|
+
throw enoent(p);
|
|
42
|
+
},
|
|
43
|
+
readlink: (p) => { if (p in symlinks) return symlinks[p]; throw enoent(p); },
|
|
44
|
+
readFile: (p) => { if (p in files) return files[p]; throw enoent(p); },
|
|
45
|
+
realpath: (p) => p,
|
|
46
|
+
readManifest: (skillDir) => { if (skillDir in manifests) return manifests[skillDir]; throw enoent(skillDir); },
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CODEX_MANIFEST = {
|
|
51
|
+
name: 'codex-cli-bridge', kind: 'execution-backend',
|
|
52
|
+
roles: {
|
|
53
|
+
execute: { cmd: 'codex-exec', source: 'bin/codex-exec.sh' },
|
|
54
|
+
review: { cmd: 'codex-review', source: 'bin/codex-review.sh' },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── buildPlan: SKILL axis ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('buildPlan — skill axis', () => {
|
|
61
|
+
it('plans a proven-managed composition-root for removal, with no shared-global warning', () => {
|
|
62
|
+
const { items } = buildPlan({ family: [KIT] }, mockFs());
|
|
63
|
+
const skill = find(items, 'skill');
|
|
64
|
+
assert.equal(skill.class, SAFE_REMOVE);
|
|
65
|
+
assert.equal(skill.path, '/skills/agent-workflow-kit');
|
|
66
|
+
assert.equal(skill.warn, null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('warns that a shared global (memory/engine/bridge) may be used by other projects', () => {
|
|
70
|
+
const { items } = buildPlan({ family: [MEMORY, ENGINE] }, mockFs());
|
|
71
|
+
assert.match(find(items, 'skill', 'agent-workflow-memory').warn, /GLOBAL skill/);
|
|
72
|
+
assert.match(find(items, 'skill', 'agent-workflow-engine').warn, /GLOBAL skill/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('STOPs (never removes) a present-but-not-ours skill dir', () => {
|
|
76
|
+
const foreign = row('agent-workflow-kit', 'composition-root', { manifestState: 'foreign' });
|
|
77
|
+
const skill = find(buildPlan({ family: [foreign] }, mockFs()).items, 'skill');
|
|
78
|
+
assert.equal(skill.class, STOP);
|
|
79
|
+
assert.match(skill.reason, /not provably ours/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('skips a member that is not installed', () => {
|
|
83
|
+
const { items } = buildPlan({ family: [row('agent-workflow-engine', 'methodology-engine', { installed: false, skillDir: null, manifestState: 'not-installed' })] }, mockFs());
|
|
84
|
+
assert.equal(items.length, 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('limits to a single member when `member` is given', () => {
|
|
88
|
+
const { items } = buildPlan({ family: [KIT, MEMORY, ENGINE], member: 'agent-workflow-memory' }, mockFs());
|
|
89
|
+
assert.deepEqual(items.map((i) => i.member), ['agent-workflow-memory']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── buildPlan: bridge wrappers ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('buildPlan — bridge wrappers', () => {
|
|
96
|
+
const bindir = '/home/u/.local/bin';
|
|
97
|
+
const skillDir = '/skills/codex-cli-bridge';
|
|
98
|
+
|
|
99
|
+
it('reverses a wrapper symlink that points at our source (managed-marker)', () => {
|
|
100
|
+
const fs = mockFs({
|
|
101
|
+
manifests: { [skillDir]: CODEX_MANIFEST },
|
|
102
|
+
symlinks: {
|
|
103
|
+
[join(bindir, 'codex-exec')]: join(skillDir, 'bin/codex-exec.sh'),
|
|
104
|
+
[join(bindir, 'codex-review')]: join(skillDir, 'bin/codex-review.sh'),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const { items } = buildPlan({ family: [CODEX], bindir }, fs);
|
|
108
|
+
const wrappers = items.filter((i) => i.surface === 'wrapper');
|
|
109
|
+
assert.equal(wrappers.length, 2);
|
|
110
|
+
assert.ok(wrappers.every((w) => w.class === MANAGED_MARKER));
|
|
111
|
+
assert.equal(find(wrappers, 'wrapper').expectedSrc, join(skillDir, 'bin/codex-exec.sh'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('STOPs on a foreign wrapper symlink (points elsewhere) — never removed', () => {
|
|
115
|
+
const fs = mockFs({
|
|
116
|
+
manifests: { [skillDir]: CODEX_MANIFEST },
|
|
117
|
+
symlinks: {
|
|
118
|
+
[join(bindir, 'codex-exec')]: '/somewhere/else/codex-exec',
|
|
119
|
+
[join(bindir, 'codex-review')]: join(skillDir, 'bin/codex-review.sh'),
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const wrappers = buildPlan({ family: [CODEX], bindir }, fs).items.filter((i) => i.surface === 'wrapper');
|
|
123
|
+
assert.equal(wrappers.find((w) => w.path.endsWith('codex-exec')).class, STOP);
|
|
124
|
+
assert.equal(wrappers.find((w) => w.path.endsWith('codex-review')).class, MANAGED_MARKER);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('emits no wrapper item when the link is absent', () => {
|
|
128
|
+
const fs = mockFs({ manifests: { [skillDir]: CODEX_MANIFEST } }); // no symlinks present
|
|
129
|
+
const wrappers = buildPlan({ family: [CODEX], bindir }, fs).items.filter((i) => i.surface === 'wrapper');
|
|
130
|
+
assert.equal(wrappers.length, 0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── buildPlan: project deploy axis ─────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe('buildPlan — project deploy axis', () => {
|
|
137
|
+
const dir = '/proj';
|
|
138
|
+
const project = { dir, deployed: true, docsAiPresent: true, hiddenFence: true, stamps: [] };
|
|
139
|
+
|
|
140
|
+
const projectFs = (extra = {}) => mockFs({
|
|
141
|
+
files: {
|
|
142
|
+
[join(dir, '.git/hooks/pre-commit')]: '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\n',
|
|
143
|
+
[join(dir, '.claude/settings.json')]: '{ "includeCoAuthoredBy": false }',
|
|
144
|
+
...extra.files,
|
|
145
|
+
},
|
|
146
|
+
dirs: [join(dir, 'docs/ai'), join(dir, 'docs/plans'), ...(extra.dirs ?? [])],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('plans the hidden fence + marker hook as managed-marker reversals', () => {
|
|
150
|
+
const { items } = buildPlan({ family: [], project, projectDir: dir }, projectFs());
|
|
151
|
+
assert.equal(find(items, 'fence').class, MANAGED_MARKER);
|
|
152
|
+
assert.equal(find(items, 'hook').class, MANAGED_MARKER);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('reports (never removes) an UNMARKED pre-commit hook', () => {
|
|
156
|
+
const fs = mockFs({ files: { [join(dir, '.git/hooks/pre-commit')]: '#!/bin/sh\necho mine\n' } });
|
|
157
|
+
const hook = find(buildPlan({ family: [], project: { ...project, hiddenFence: false }, projectDir: dir }, fs).items, 'hook');
|
|
158
|
+
assert.equal(hook.class, REPORT_ONLY);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('reports the settings.json includeCoAuthoredBy edit (never auto-edits)', () => {
|
|
162
|
+
const settings = buildPlan({ family: [], project, projectDir: dir }, projectFs()).items.find((i) => i.surface === 'settings');
|
|
163
|
+
assert.equal(settings.class, REPORT_ONLY);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('reports docs/ai, AGENTS.md, CLAUDE.md, docs/plans as never-deleted', () => {
|
|
167
|
+
const fs = projectFs({ files: { [join(dir, 'AGENTS.md')]: 'x', [join(dir, 'CLAUDE.md')]: 'x' } });
|
|
168
|
+
const docs = buildPlan({ family: [], project, projectDir: dir }, fs).items.filter((i) => i.surface === 'docs');
|
|
169
|
+
const paths = docs.map((d) => d.path).sort();
|
|
170
|
+
assert.deepEqual(paths, [join(dir, 'AGENTS.md'), join(dir, 'CLAUDE.md'), join(dir, 'docs/ai'), join(dir, 'docs/plans')].sort());
|
|
171
|
+
assert.ok(docs.every((d) => d.class === REPORT_ONLY));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('formatPlan prints rm + git rm guidance for the report-only set', () => {
|
|
175
|
+
const plan = buildPlan({ family: [], project, projectDir: dir }, projectFs());
|
|
176
|
+
const out = formatPlan(plan);
|
|
177
|
+
assert.match(out, /KEEP \(do by hand\)/);
|
|
178
|
+
assert.match(out, /git rm -r --cached/);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── executePlan: guarded mutation ──────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('executePlan — guarded', () => {
|
|
185
|
+
const okClassify = (reg) => ({ installed: true, manifestState: OK, skillDir: `/skills/${reg.name}` });
|
|
186
|
+
|
|
187
|
+
const spyDeps = (over = {}) => {
|
|
188
|
+
const calls = { removeTree: [], unlink: [], unhide: [], rmFile: [] };
|
|
189
|
+
return {
|
|
190
|
+
calls,
|
|
191
|
+
deps: {
|
|
192
|
+
classify: over.classify ?? okClassify,
|
|
193
|
+
removeTree: (p) => { calls.removeTree.push(p); return 'removed'; },
|
|
194
|
+
unlink: (p) => { calls.unlink.push(p); return 'unlinked'; },
|
|
195
|
+
hideFootprint: (opts) => { calls.unhide.push(opts); return { action: 'unhidden' }; },
|
|
196
|
+
rmFile: (p) => { calls.rmFile.push(p); },
|
|
197
|
+
// fs for the wrapper preflight inspect (report 'ours') + the hook marker re-check (present + marked).
|
|
198
|
+
lstat: () => ({ isSymbolicLink: () => true, isFile: () => false }),
|
|
199
|
+
readlink: (p) => p.replace('/home/u/.local/bin/codex-exec', '/skills/codex-cli-bridge/bin/codex-exec.sh'),
|
|
200
|
+
realpath: (p) => p,
|
|
201
|
+
exists: () => true,
|
|
202
|
+
readFile: () => '#!/usr/bin/env bash\n# myproj:install-git-hooks.mjs\nset -e\n',
|
|
203
|
+
...over.deps,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const fullPlan = () => ({
|
|
209
|
+
projectDir: '/proj',
|
|
210
|
+
items: [
|
|
211
|
+
{ surface: 'skill', member: 'agent-workflow-kit', path: '/skills/agent-workflow-kit', class: SAFE_REMOVE },
|
|
212
|
+
{ surface: 'wrapper', member: 'codex-cli-bridge', path: '/home/u/.local/bin/codex-exec', expectedSrc: '/skills/codex-cli-bridge/bin/codex-exec.sh', class: MANAGED_MARKER },
|
|
213
|
+
{ surface: 'fence', path: '/proj/.git/info/exclude', class: MANAGED_MARKER },
|
|
214
|
+
{ surface: 'hook', path: '/proj/.git/hooks/pre-commit', class: MANAGED_MARKER },
|
|
215
|
+
{ surface: 'docs', path: '/proj/docs/ai', class: REPORT_ONLY },
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('--dry-run mutates nothing', () => {
|
|
220
|
+
const { calls, deps } = spyDeps();
|
|
221
|
+
const r = executePlan(fullPlan(), { dryRun: true }, deps);
|
|
222
|
+
assert.equal(r.applied, false);
|
|
223
|
+
assert.deepEqual([calls.removeTree, calls.unlink, calls.unhide, calls.rmFile], [[], [], [], []]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('without --yes mutates nothing (awaiting consent)', () => {
|
|
227
|
+
const { calls, deps } = spyDeps();
|
|
228
|
+
const r = executePlan(fullPlan(), {}, deps);
|
|
229
|
+
assert.equal(r.applied, false);
|
|
230
|
+
assert.equal(calls.removeTree.length, 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('with --yes applies the auto-removable set and never touches report-only', () => {
|
|
234
|
+
const { calls, deps } = spyDeps();
|
|
235
|
+
const r = executePlan(fullPlan(), { yes: true }, deps);
|
|
236
|
+
assert.equal(r.applied, true);
|
|
237
|
+
assert.deepEqual(calls.removeTree, ['/skills/agent-workflow-kit']);
|
|
238
|
+
assert.deepEqual(calls.unlink, ['/home/u/.local/bin/codex-exec']);
|
|
239
|
+
// The fence is unhidden once for real (mutate) after being validated by a dry-run unhide (preflight).
|
|
240
|
+
assert.ok(calls.unhide.some((o) => o.dryRun === true), 'fence validated by a dry-run unhide in preflight');
|
|
241
|
+
assert.ok(calls.unhide.some((o) => !o.dryRun), 'fence unhidden for real in the mutate phase');
|
|
242
|
+
assert.deepEqual(calls.rmFile, ['/proj/.git/hooks/pre-commit']);
|
|
243
|
+
assert.equal(r.unhidden, true);
|
|
244
|
+
assert.equal(r.hookRemoved, true);
|
|
245
|
+
assert.equal(r.reported.length, 1); // the docs item, untouched
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('preflight STOPs (zero mutation) when a skill dir is no longer provably ours', () => {
|
|
249
|
+
const { calls, deps } = spyDeps({ classify: () => ({ installed: true, manifestState: 'foreign', skillDir: '/skills/agent-workflow-kit' }) });
|
|
250
|
+
assert.throws(() => executePlan(fullPlan(), { yes: true }, deps), (err) => err.code === UNINSTALL_STOP);
|
|
251
|
+
assert.deepEqual([calls.removeTree, calls.unlink], [[], []]); // nothing mutated
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('preflight STOPs (zero mutation) when a wrapper turned foreign', () => {
|
|
255
|
+
const { calls, deps } = spyDeps({ deps: { readlink: () => '/somewhere/foreign' } });
|
|
256
|
+
assert.throws(() => executePlan(fullPlan(), { yes: true }, deps), (err) => err.code === UNINSTALL_STOP);
|
|
257
|
+
assert.equal(calls.removeTree.length, 0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('a wrapper that merely VANISHED is benign — teardown proceeds (no abort)', () => {
|
|
261
|
+
const enoent = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
|
|
262
|
+
const { calls, deps } = spyDeps({ deps: { lstat: (p) => (p.endsWith('codex-exec') ? enoent() : { isSymbolicLink: () => true, isFile: () => false }) } });
|
|
263
|
+
const r = executePlan(fullPlan(), { yes: true }, deps);
|
|
264
|
+
assert.equal(r.applied, true);
|
|
265
|
+
assert.deepEqual(calls.removeTree, ['/skills/agent-workflow-kit']); // skill still removed
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('refuses (zero mutation) when the pre-commit hook lost OUR marker since the plan', () => {
|
|
269
|
+
const { calls, deps } = spyDeps({ deps: { readFile: () => '#!/bin/sh\n# the user rewrote this hook\n' } }); // no marker
|
|
270
|
+
assert.throws(() => executePlan(fullPlan(), { yes: true }, deps), (err) => err.code === UNINSTALL_STOP);
|
|
271
|
+
assert.deepEqual([calls.removeTree, calls.rmFile], [[], []]); // nothing mutated
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const planWithStop = () => ({
|
|
275
|
+
projectDir: '/proj',
|
|
276
|
+
items: [
|
|
277
|
+
{ surface: 'skill', member: 'agent-workflow-kit', path: '/skills/agent-workflow-kit', class: SAFE_REMOVE },
|
|
278
|
+
{ surface: 'wrapper', member: 'codex-cli-bridge', path: '/home/u/.local/bin/codex-exec', class: STOP, reason: 'foreign symlink' },
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('a plan-time STOP (a not-ours surface) is reported + LEFT; the teardown still removes what IS ours (per-item, not global-abort)', () => {
|
|
283
|
+
const { calls, deps } = spyDeps();
|
|
284
|
+
const r = executePlan(planWithStop(), { yes: true }, deps);
|
|
285
|
+
assert.equal(r.applied, true);
|
|
286
|
+
assert.deepEqual(calls.removeTree, ['/skills/agent-workflow-kit']); // ours removed
|
|
287
|
+
assert.deepEqual(calls.unlink, []); // the foreign wrapper (STOP) is never touched
|
|
288
|
+
assert.ok(r.reported.some((i) => i.class === STOP), 'the STOP surface is surfaced, not silently dropped');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('--dry-run never mutates, even with a STOP present', () => {
|
|
292
|
+
const { calls, deps } = spyDeps();
|
|
293
|
+
const r = executePlan(planWithStop(), { dryRun: true }, deps);
|
|
294
|
+
assert.equal(r.applied, false);
|
|
295
|
+
assert.deepEqual([calls.removeTree, calls.unlink], [[], []]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('a malformed fence is caught by the preflight dry-run unhide → abort before any mutation (codex #2)', () => {
|
|
299
|
+
const { calls, deps } = spyDeps({ deps: { hideFootprint: (opts) => { if (opts.dryRun) throw new Error('malformed managed block'); return { action: 'unhidden' }; } } });
|
|
300
|
+
assert.throws(() => executePlan(fullPlan(), { yes: true }, deps), (err) => err.code === UNINSTALL_STOP);
|
|
301
|
+
assert.deepEqual([calls.removeTree, calls.unlink, calls.rmFile], [[], [], []]); // fence threw in preflight → nothing mutated
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── formatPlan: report-only guidance (codex #4) ─────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('formatPlan — report-only guidance', () => {
|
|
308
|
+
it('settings.json gets EDIT guidance (remove the key), never `rm`', () => {
|
|
309
|
+
const plan = { projectDir: '/proj', items: [{ surface: 'settings', path: '/proj/.claude/settings.json', class: REPORT_ONLY, reason: 'x' }] };
|
|
310
|
+
const out = formatPlan(plan);
|
|
311
|
+
assert.match(out, /edit .*settings\.json.* remove the "includeCoAuthoredBy"/);
|
|
312
|
+
assert.ok(!/rm -rf .*settings\.json/.test(out), 'settings.json is never rm-ed');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('paths are shell-quoted in the printed rm/git-rm commands', () => {
|
|
316
|
+
const plan = { projectDir: '/p', items: [{ surface: 'docs', path: '/p/docs/ai', class: REPORT_ONLY, reason: 'x' }] };
|
|
317
|
+
const out = formatPlan(plan);
|
|
318
|
+
assert.match(out, /rm -rf '\/p\/docs\/ai'/);
|
|
319
|
+
assert.match(out, /git rm -r --cached '\/p\/docs\/ai'/);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ── buildPlan: an underivable bridge manifest → STOP, not a silent half-removal (codex #3) ──────────
|
|
324
|
+
|
|
325
|
+
describe('buildPlan — underivable bridge', () => {
|
|
326
|
+
it('emits a STOP for the skill (not SAFE_REMOVE) when deriveLinks throws on the bridge manifest', () => {
|
|
327
|
+
const throwingFs = {
|
|
328
|
+
readManifest: () => { throw new Error('corrupt manifest'); },
|
|
329
|
+
};
|
|
330
|
+
const codex = row('codex-cli-bridge', 'execution-backend');
|
|
331
|
+
const items = buildPlan({ family: [codex], bindir: '/home/u/.local/bin' }, throwingFs).items;
|
|
332
|
+
const skill = items.find((i) => i.surface === 'skill');
|
|
333
|
+
assert.equal(skill.class, STOP);
|
|
334
|
+
assert.ok(!items.some((i) => i.surface === 'skill' && i.class === SAFE_REMOVE));
|
|
335
|
+
assert.ok(!items.some((i) => i.surface === 'wrapper'));
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── parseArgs: strict validation (codex #6) ─────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
describe('parseArgs — strict', () => {
|
|
342
|
+
it('accepts a clean whole-family teardown', () => {
|
|
343
|
+
const a = parseArgs(['--dir', '/proj', '--dry-run']);
|
|
344
|
+
assert.equal(a.bad, null);
|
|
345
|
+
assert.equal(a.dir, '/proj');
|
|
346
|
+
assert.equal(a.dryRun, true);
|
|
347
|
+
assert.equal(a.member, undefined);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('accepts a valid <member>', () => {
|
|
351
|
+
assert.equal(parseArgs(['agent-workflow-memory', '--yes']).bad, null);
|
|
352
|
+
assert.equal(parseArgs(['agent-workflow-memory']).member, 'agent-workflow-memory');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('rejects an unknown flag (a typo cannot silently slip past)', () => {
|
|
356
|
+
assert.match(parseArgs(['--yes', '--frce']).bad, /unknown option/);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('rejects an unknown member name', () => {
|
|
360
|
+
assert.match(parseArgs(['memory']).bad, /unknown member "memory"/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('rejects --dir / --bindir without a value', () => {
|
|
364
|
+
assert.match(parseArgs(['--dir']).bad, /--dir requires/);
|
|
365
|
+
assert.match(parseArgs(['--dir', '--yes']).bad, /--dir requires/);
|
|
366
|
+
assert.match(parseArgs(['--bindir']).bad, /--bindir requires/);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('rejects more than one positional', () => {
|
|
370
|
+
assert.match(parseArgs(['agent-workflow-kit', 'agent-workflow-memory']).bad, /at most one/);
|
|
371
|
+
});
|
|
372
|
+
});
|