@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.
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env node
2
+ // uninstall.mjs — the guarded family uninstaller behind `/agent-workflow-kit uninstall`.
3
+ //
4
+ // Reverses what `npx … init` and `/agent-workflow-kit setup` placed, SAFELY. It consumes the unified
5
+ // family-registry (the SKILL axis) + surveyProject (the DEPLOY axis) and classifies every surface it
6
+ // could touch into one of four classes, then mutates ONLY after preflighting all of them (AD-011:
7
+ // a conflict on a later item leaves the filesystem untouched). The hard rule: it NEVER deletes
8
+ // user-authored content (docs/ai, the entry-point docs, settings.json) — it only PRINTS the exact
9
+ // commands for the user to run by hand (the AD-014 tracked-file posture, generalized to teardown).
10
+ //
11
+ // safe-remove — kit-placed + provably ours: a family skill dir (valid manifest, name+kind match).
12
+ // managed-marker — recognized by an OWNED marker: a wrapper symlink that points at our source, the
13
+ // hidden-mode managed fence, a pre-commit hook carrying our marker. Reversed
14
+ // surgically (only the owned part), never a blind delete.
15
+ // report-only — user-authored / shared: docs/ai, AGENTS.md, CLAUDE.md, .claude/settings.json.
16
+ // Printed for the user to handle; the tool refuses to delete them.
17
+ // stop — a skill dir that is present but NOT provably ours (foreign/stub/invalid) — left
18
+ // untouched and reported, never removed.
19
+ //
20
+ // Pure planner (buildPlan) + guarded executor (executePlan), both dependency-injectable so the whole
21
+ // module is unit-testable without the real filesystem. Dependency-free, Node >= 18. No side effects on
22
+ // import (the isDirectRun idiom).
23
+
24
+ import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, realpathSync } from 'node:fs';
25
+ import { join, resolve, dirname, basename, isAbsolute } from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+ import os from 'node:os';
28
+ import { surveyFamily, surveyProject, FAMILY_MEMBERS, classifyMember, OK } from './family-registry.mjs';
29
+ import { removeTreeManaged, unlinkManaged, MANAGED_LINK_CONFLICT } from './fs-safe.mjs';
30
+ import { deriveLinks } from './setup-backends.mjs';
31
+ import { hideFootprint, excludePath } from './hide-footprint.mjs';
32
+
33
+ // ── surface classes ────────────────────────────────────────────────────────────
34
+ export const SAFE_REMOVE = 'safe-remove';
35
+ export const MANAGED_MARKER = 'managed-marker';
36
+ export const REPORT_ONLY = 'report-only';
37
+ export const STOP = 'stop';
38
+
39
+ // A typed STOP raised by executePlan's preflight — the same codebase idiom (no classes).
40
+ export const UNINSTALL_STOP = 'UNINSTALL_STOP';
41
+ const stop = (message, fields = {}) =>
42
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'UninstallStop', code: UNINSTALL_STOP, ...fields });
43
+
44
+ const DEFAULT_BINDIR_REL = '.local/bin';
45
+ // The pre-commit hook our installer writes carries `# <project-name>:install-git-hooks.mjs`. The
46
+ // project-name varies, but this suffix is stable — match it to recognize OUR hook without guessing
47
+ // the name (never remove an unmarked / user-authored hook).
48
+ const HOOK_MARKER_SUFFIX = ':install-git-hooks.mjs';
49
+ // User-authored / kit-deployed-but-now-owned-by-the-user surfaces — REPORTED, never deleted.
50
+ const REPORT_PATHS = ['docs/ai', 'AGENTS.md', 'CLAUDE.md', 'docs/plans'];
51
+
52
+ // ── injectable fs ────────────────────────────────────────────────────────────────
53
+ const fsOf = (deps = {}) => ({
54
+ exists: deps.exists ?? existsSync,
55
+ stat: deps.stat ?? statSync,
56
+ lstat: deps.lstat ?? lstatSync,
57
+ readlink: deps.readlink ?? readlinkSync,
58
+ readFile: deps.readFile ?? readFileSync,
59
+ realpath: deps.realpath ?? realpathSync,
60
+ readManifest: deps.readManifest ?? ((skillDir) => JSON.parse(readFileSync(join(skillDir, 'capability.json'), 'utf8'))),
61
+ });
62
+
63
+ const lstatNoFollow = (path, lstat) => {
64
+ try {
65
+ return lstat(path);
66
+ } catch (err) {
67
+ if (err && err.code === 'ENOENT') return null;
68
+ throw err; // EACCES/EIO must not fail open
69
+ }
70
+ };
71
+
72
+ // Classify a wrapper symlink dst WITHOUT mutating (the preflight mirror of fs-safe's unlinkManaged):
73
+ // 'ours' (symlink → our source), 'absent', or 'conflict' (a non-symlink, or a foreign symlink).
74
+ const inspectWrapper = (dst, expectedSrc, fs) => {
75
+ const st = lstatNoFollow(dst, fs.lstat);
76
+ if (st === null) return { state: 'absent' };
77
+ if (!st.isSymbolicLink()) return { state: 'conflict', reason: 'a non-symlink exists there' };
78
+ let target;
79
+ try {
80
+ target = fs.readlink(dst);
81
+ } catch (err) {
82
+ return { state: 'conflict', reason: `unreadable symlink (${err.code ?? 'fs error'})` };
83
+ }
84
+ const resolved = isAbsolute(target) ? target : resolve(dirname(dst), target);
85
+ return resolved === resolve(expectedSrc) ? { state: 'ours' } : { state: 'conflict', reason: `foreign symlink → ${target}` };
86
+ };
87
+
88
+ const bindirOf = (deps) => deps.bindir ?? join(deps.home ?? os.homedir(), DEFAULT_BINDIR_REL);
89
+
90
+ // ── buildPlan (pure) ───────────────────────────────────────────────────────────
91
+ // Classify every surface into the four classes. Takes the already-computed `family` (surveyFamily)
92
+ // and `project` (surveyProject | null) so the classification is testable in isolation; `deps` is used
93
+ // only to read bridge manifests (for the exact wrapper links) + probe the project's hook/settings.
94
+ // `member` (optional) narrows the SKILL axis to a single member name (whole family otherwise).
95
+ export const buildPlan = ({ family, project = null, projectDir = null, member = null, bindir }, deps = {}) => {
96
+ const fs = fsOf(deps);
97
+ const items = [];
98
+ const resolvedBindir = bindir ?? bindirOf(deps);
99
+ // Collapse a symlinked bindir to its real path (the link side did the same) — best-effort.
100
+ const realBindir = (() => {
101
+ try {
102
+ return fs.realpath(resolvedBindir);
103
+ } catch {
104
+ return resolvedBindir;
105
+ }
106
+ })();
107
+
108
+ const members = member ? family.filter((m) => m.name === member) : family;
109
+ const registryOf = (name) => FAMILY_MEMBERS.find((m) => m.name === name);
110
+
111
+ // ── SKILL axis ──
112
+ for (const m of members) {
113
+ if (!m.installed) continue; // nothing on disk for this member
114
+ if (m.manifestState !== OK) {
115
+ items.push({
116
+ surface: 'skill', member: m.name, path: m.skillDir, class: STOP,
117
+ reason: `skill dir is present but not provably ours ("${m.manifestState}") — left untouched`,
118
+ });
119
+ continue;
120
+ }
121
+ const reg = registryOf(m.name);
122
+ // For a BRIDGE, derive its wrapper links FIRST. If that throws (an unreadable / underivable
123
+ // manifest — unexpected for an `ok` member, but possible under a race/corruption), the bridge's
124
+ // wrappers are not classifiable, so we must NOT remove its skill dir either — emit a STOP and move
125
+ // on (a STOP aborts the whole teardown in executePlan; never a silent half-removal — codex #3).
126
+ const links = (() => {
127
+ if (!(reg && reg.wrapperCmds.length)) return [];
128
+ try {
129
+ return deriveLinks(fs.readManifest(m.skillDir), m.skillDir);
130
+ } catch (err) {
131
+ return { error: err.message ?? 'manifest read/derive error' };
132
+ }
133
+ })();
134
+ if (links && links.error) {
135
+ items.push({
136
+ surface: 'skill', member: m.name, path: m.skillDir, class: STOP,
137
+ reason: `could not classify this bridge's wrappers (${links.error}) — leaving the skill dir untouched`,
138
+ });
139
+ continue;
140
+ }
141
+
142
+ const shared = reg && reg.kind !== 'composition-root'; // engine/memory/bridges are shared globals
143
+ items.push({
144
+ surface: 'skill', member: m.name, path: m.skillDir, class: SAFE_REMOVE,
145
+ reason: 'proven-managed family skill (valid manifest, name+kind match)',
146
+ warn: shared ? 'this is a GLOBAL skill — other projects on this machine may use it' : null,
147
+ });
148
+
149
+ // Bridge wrappers: reverse the exact links the setup linker created (deriveLinks).
150
+ for (const { cmd, source } of links) {
151
+ const dst = join(realBindir, cmd);
152
+ const info = inspectWrapper(dst, source, fs);
153
+ if (info.state === 'absent') continue;
154
+ if (info.state === 'ours') {
155
+ items.push({ surface: 'wrapper', member: m.name, path: dst, expectedSrc: source, class: MANAGED_MARKER, reason: `managed wrapper symlink → ${source}` });
156
+ } else {
157
+ items.push({ surface: 'wrapper', member: m.name, path: dst, class: STOP, reason: `wrapper path is not ours (${info.reason}) — left untouched` });
158
+ }
159
+ }
160
+ }
161
+
162
+ // ── DEPLOY axis (only with a project dir) ──
163
+ if (project && projectDir) {
164
+ const dir = resolve(projectDir);
165
+
166
+ if (project.hiddenFence) {
167
+ // Display the git-path-resolved exclude file (worktree/submodule-safe), guarded → conventional path.
168
+ const fencePath = (() => { try { return excludePath(deps, dir); } catch { return join(dir, '.git/info/exclude'); } })();
169
+ items.push({ surface: 'fence', path: fencePath, class: MANAGED_MARKER, reason: 'hidden-mode managed block (removed via the existing unhide path; only the fenced lines)' });
170
+ }
171
+
172
+ const hookPath = join(dir, '.git/hooks/pre-commit');
173
+ const hook = (() => {
174
+ try {
175
+ return fs.exists(hookPath) ? String(fs.readFile(hookPath, 'utf8')) : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ })();
180
+ if (hook != null) {
181
+ if (hook.includes(HOOK_MARKER_SUFFIX)) {
182
+ items.push({ surface: 'hook', path: hookPath, class: MANAGED_MARKER, reason: 'pre-commit hook carrying our marker' });
183
+ } else {
184
+ items.push({ surface: 'hook', path: hookPath, class: REPORT_ONLY, reason: 'a pre-commit hook exists but is NOT ours — left untouched; remove it by hand if you want it gone' });
185
+ }
186
+ }
187
+
188
+ const settingsPath = join(dir, '.claude/settings.json');
189
+ const settings = (() => {
190
+ try {
191
+ return fs.exists(settingsPath) ? String(fs.readFile(settingsPath, 'utf8')) : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ })();
196
+ if (settings != null && settings.includes('includeCoAuthoredBy')) {
197
+ items.push({ surface: 'settings', path: settingsPath, class: REPORT_ONLY, reason: 'we set "includeCoAuthoredBy": false here — review/remove that key by hand (the file may hold your own settings)' });
198
+ }
199
+
200
+ for (const rel of REPORT_PATHS) {
201
+ const p = join(dir, rel);
202
+ if (fs.exists(p)) {
203
+ items.push({ surface: 'docs', path: p, class: REPORT_ONLY, reason: 'user-authored after deploy — the uninstaller never deletes it; remove by hand if you want it gone' });
204
+ }
205
+ }
206
+ }
207
+
208
+ return { items, projectDir: projectDir ? resolve(projectDir) : null };
209
+ };
210
+
211
+ // ── executePlan (guarded: preview → preflight → mutate) ──────────────────────────
212
+ // `opts.yes` applies the auto-removable set (skill dirs + wrappers + fence + hook); without it (and
213
+ // without dryRun) nothing is mutated — the caller previews with --dry-run, asks, then re-runs with
214
+ // --yes (the agent-driven consent model). `opts.dryRun` previews only. Report-only items are NEVER
215
+ // mutated. Before mutating, EVERY surface is preflighted; ANY blocker ⇒ zero mutations (AD-011).
216
+ export const executePlan = (plan, opts = {}, deps = {}) => {
217
+ const fs = fsOf(deps);
218
+ const removeTree = deps.removeTree ?? removeTreeManaged;
219
+ const unlink = deps.unlink ?? unlinkManaged;
220
+ const unhide = deps.hideFootprint ?? hideFootprint;
221
+ const classify = deps.classify ?? classifyMember;
222
+ const rmFile = deps.rmFile ?? ((p) => removeTreeManaged(p, dirname(p), deps)); // marker hook is a regular file
223
+
224
+ const mutable = plan.items.filter((i) => i.class === SAFE_REMOVE || i.class === MANAGED_MARKER);
225
+ // `reported` (returned + summarized) = everything we do NOT mutate: user-authored (report-only) AND
226
+ // not-provably-ours surfaces (STOP). STOP items were detected at plan time and shown in the dry-run;
227
+ // we leave them untouched and proceed with what IS ours (the per-item posture of setup-backends —
228
+ // a stray foreign wrapper never blocks removing the rest). They are NEVER mutated.
229
+ const reported = plan.items.filter((i) => i.class === REPORT_ONLY || i.class === STOP);
230
+ const result = { removed: [], unlinked: [], unhidden: false, hookRemoved: false, reported, applied: false, dryRun: !!opts.dryRun };
231
+
232
+ // Preview / awaiting consent → show the plan (formatPlan), mutate NOTHING, abort NOTHING.
233
+ if (opts.dryRun || !opts.yes) return result;
234
+
235
+ // ── ABOUT TO MUTATE: preflight every MUTABLE surface; if any CHANGED since the plan ⇒ zero mutations
236
+ // (the real AD-011 guarantee — the plan is now stale, so do nothing rather than act on bad data). A
237
+ // surface that merely VANISHED is benign (the mutate is a no-op). Blocker kinds: a skill no longer
238
+ // ours, a wrapper turned foreign, a hook that lost our marker, or a malformed fence (validated by a
239
+ // dry-run unhide — codex #2 — so the fence can't blow up AFTER wrappers/skills were already removed).
240
+ // (Plan-time STOP items are NOT a conflict — they were never ours; they are reported + left, above.)
241
+ const conflicts = [];
242
+ for (const item of mutable) {
243
+ if (item.surface === 'skill') {
244
+ const recheck = classify(FAMILY_MEMBERS.find((m) => m.name === item.member), deps);
245
+ if (!(recheck.installed && recheck.manifestState === OK && recheck.skillDir === item.path)) {
246
+ conflicts.push(`${item.path} is no longer a proven-managed ${item.member} skill`);
247
+ }
248
+ } else if (item.surface === 'wrapper') {
249
+ const info = inspectWrapper(item.path, item.expectedSrc, fs);
250
+ if (info.state === 'conflict') conflicts.push(`${item.path} is not ours (${info.reason})`);
251
+ } else if (item.surface === 'hook') {
252
+ const present = (() => { try { return fs.exists(item.path); } catch { return false; } })();
253
+ if (present) {
254
+ const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return ''; } })();
255
+ if (!content.includes(HOOK_MARKER_SUFFIX)) conflicts.push(`${item.path} no longer carries our marker`);
256
+ }
257
+ } else if (item.surface === 'fence') {
258
+ // Validate the unhide WITHOUT writing — a malformed managed block throws here, before any mutation,
259
+ // so the fence can never blow up AFTER wrappers/skills were already removed (codex #2).
260
+ try {
261
+ unhide({ dir: plan.projectDir, unhide: true, dryRun: true }, deps);
262
+ } catch (err) {
263
+ conflicts.push(`${item.path} — ${err.message ?? 'malformed managed block'}`);
264
+ }
265
+ }
266
+ }
267
+ if (conflicts.length) {
268
+ throw stop(
269
+ `refusing to proceed — ${conflicts.length} surface(s) are not safe to touch (zero changes made):\n - ` +
270
+ `${conflicts.join('\n - ')}\n Resolve these, or narrow the teardown with \`uninstall <member>\`.`,
271
+ { conflicts },
272
+ );
273
+ }
274
+
275
+ // ── MUTATE (wrappers first, then skill dirs, then project surfaces) ──
276
+ for (const item of mutable.filter((i) => i.surface === 'wrapper')) {
277
+ const realBindir = (() => { try { return fs.realpath(dirname(item.path)); } catch { return dirname(item.path); } })();
278
+ const action = unlink(join(realBindir, basename(item.path)), item.expectedSrc, realBindir, deps);
279
+ if (action === 'unlinked') result.unlinked.push(item.path);
280
+ }
281
+ for (const item of mutable.filter((i) => i.surface === 'skill')) {
282
+ const action = removeTree(item.path, dirname(item.path), deps);
283
+ if (action === 'removed') result.removed.push(item.path);
284
+ }
285
+ for (const item of mutable.filter((i) => i.surface === 'fence')) {
286
+ const r = unhide({ dir: plan.projectDir, unhide: true }, deps);
287
+ result.unhidden = r && r.action === 'unhidden';
288
+ }
289
+ for (const item of mutable.filter((i) => i.surface === 'hook')) {
290
+ // Marker-aware even at mutate time (belt-and-suspenders past the preflight): remove the hook ONLY
291
+ // while it still carries our marker, so a user hook can never be deleted even under a TOCTOU race.
292
+ const content = (() => { try { return fs.exists(item.path) ? String(fs.readFile(item.path, 'utf8')) : null; } catch { return null; } })();
293
+ if (content != null && content.includes(HOOK_MARKER_SUFFIX)) {
294
+ rmFile(item.path);
295
+ result.hookRemoved = true;
296
+ }
297
+ }
298
+ result.applied = true;
299
+ return result;
300
+ };
301
+
302
+ // ── report ───────────────────────────────────────────────────────────────────
303
+ const CLASS_LABEL = { [SAFE_REMOVE]: 'remove', [MANAGED_MARKER]: 'reverse', [REPORT_ONLY]: 'KEEP (do by hand)', [STOP]: 'STOP (left untouched)' };
304
+
305
+ // POSIX single-quote a path for the copy-paste commands we PRINT (never run) — so a path with spaces
306
+ // or shell metacharacters can't misbehave when the user pastes it (codex #4).
307
+ const shq = (p) => `'${String(p).replace(/'/g, "'\\''")}'`;
308
+
309
+ // The "do this by hand" line for a report-only surface. settings.json is an EDIT (remove one key), not
310
+ // an `rm` — deleting it would lose the user's own settings (codex #4); everything else is a quoted rm.
311
+ const handGuidance = (item) =>
312
+ item.surface === 'settings'
313
+ ? `edit ${shq(item.path)} → remove the "includeCoAuthoredBy" entry (keep the rest of your settings)`
314
+ : `rm -rf ${shq(item.path)} # if it was committed: git rm -r --cached ${shq(item.path)}`;
315
+
316
+ export const formatPlan = (plan) => {
317
+ const lines = ['agent-workflow uninstall — planned actions (nothing is changed without --yes)', ''];
318
+ if (plan.items.length === 0) return [...lines, ' nothing to do — no installed family members or deployment found here.'].join('\n');
319
+ for (const i of plan.items) {
320
+ lines.push(` [${CLASS_LABEL[i.class]}] ${i.surface}: ${i.path}`);
321
+ lines.push(` ${i.reason}`);
322
+ if (i.warn) lines.push(` ⚠ ${i.warn}`);
323
+ }
324
+ const reportOnly = plan.items.filter((i) => i.class === REPORT_ONLY);
325
+ if (reportOnly.length) {
326
+ lines.push('', 'These are NOT removed (user-authored / shared). To remove them yourself:');
327
+ for (const i of reportOnly) lines.push(` ${handGuidance(i)}`);
328
+ }
329
+ return lines.join('\n');
330
+ };
331
+
332
+ // ── CLI ────────────────────────────────────────────────────────────────────────
333
+ // STRICT parsing (codex #6): an unknown flag, a missing --dir/--bindir value, or an unknown <member>
334
+ // is rejected via `bad` — main() prints it + usage + exits non-zero. A typo can never silently slip
335
+ // through into a `--yes` mutation. Exported for unit testing.
336
+ const FLAGS_NO_VAL = ['--help', '-h', '--dry-run', '--yes'];
337
+ const FLAGS_WITH_VAL = ['--dir', '--bindir'];
338
+ const KNOWN_MEMBERS = new Set(FAMILY_MEMBERS.map((m) => m.name));
339
+
340
+ export const parseArgs = (argv) => {
341
+ const valOf = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
342
+ // The index immediately after a value-flag is that flag's VALUE, not a stray token.
343
+ const valueIdx = new Set(FLAGS_WITH_VAL.flatMap((f) => { const i = argv.indexOf(f); return i >= 0 ? [i + 1] : []; }));
344
+ const stray = argv.filter((a, i) => !FLAGS_NO_VAL.includes(a) && !FLAGS_WITH_VAL.includes(a) && !valueIdx.has(i));
345
+ const unknownFlags = stray.filter((a) => a.startsWith('-'));
346
+ const positionals = stray.filter((a) => !a.startsWith('-'));
347
+ const dir = valOf('--dir');
348
+ const bindir = valOf('--bindir');
349
+ const bad = (() => {
350
+ if (unknownFlags.length) return `unknown option(s): ${unknownFlags.join(', ')}`;
351
+ if (argv.includes('--dir') && (dir === undefined || dir.startsWith('-'))) return '--dir requires a <project> path';
352
+ if (argv.includes('--bindir') && (bindir === undefined || bindir.startsWith('-'))) return '--bindir requires a path';
353
+ if (positionals.length > 1) return `expected at most one <member>, got: ${positionals.join(', ')}`;
354
+ if (positionals.length === 1 && !KNOWN_MEMBERS.has(positionals[0])) {
355
+ return `unknown member "${positionals[0]}" — expected one of: ${[...KNOWN_MEMBERS].join(', ')}`;
356
+ }
357
+ return null;
358
+ })();
359
+ return {
360
+ help: argv.includes('--help') || argv.includes('-h'),
361
+ dryRun: argv.includes('--dry-run'),
362
+ yes: argv.includes('--yes'),
363
+ dir,
364
+ bindir,
365
+ member: positionals[0],
366
+ bad,
367
+ };
368
+ };
369
+
370
+ const HELP = `agent-workflow uninstall — guarded teardown of the installed family.
371
+
372
+ Usage:
373
+ node uninstall.mjs [<member>] [--dir <project>] [--bindir <path>] [--dry-run | --yes]
374
+
375
+ <member> limit the skill axis to one member (default: the whole family)
376
+ --dir also plan the project-deployment surfaces in <project>
377
+ --bindir where the bridge wrappers were linked (default: ~/.local/bin)
378
+ --dry-run print the plan and change NOTHING (run this first)
379
+ --yes apply the auto-removable set (skill dirs + wrappers + fence + marker hook)
380
+ --help this help
381
+
382
+ It NEVER deletes user-authored content (docs/ai, AGENTS.md, settings.json) — those are reported with
383
+ the exact commands for you to run by hand. A skill dir that is not provably ours is left untouched.`;
384
+
385
+ const main = (argv) => {
386
+ const args = parseArgs(argv);
387
+ if (args.help) return console.log(HELP);
388
+ if (args.bad) {
389
+ console.error(`[agent-workflow-kit] ${args.bad}\n`);
390
+ console.log(HELP);
391
+ process.exit(2);
392
+ }
393
+
394
+ const family = surveyFamily();
395
+ const project = args.dir ? surveyProject(args.dir) : null;
396
+ const plan = buildPlan({ family, project, projectDir: args.dir, member: args.member, bindir: args.bindir });
397
+ console.log(formatPlan(plan));
398
+
399
+ if (args.dryRun) return;
400
+ if (!args.yes) {
401
+ console.log('\nThis was a preview. Re-run with --yes to apply the removable set (or --dry-run to preview again).');
402
+ return;
403
+ }
404
+ const result = executePlan(plan, { yes: true });
405
+ console.log(`\n[agent-workflow-kit] done — removed ${result.removed.length} skill dir(s), ${result.unlinked.length} wrapper(s)` +
406
+ `${result.unhidden ? ', unhid the project' : ''}${result.hookRemoved ? ', removed the pre-commit hook' : ''}.`);
407
+ if (result.reported.length) {
408
+ console.log(`${result.reported.length} surface(s) were left untouched — user-authored content (handle by hand, see above) or paths that are not ours.`);
409
+ }
410
+ };
411
+
412
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
413
+ if (isDirectRun) {
414
+ try {
415
+ main(process.argv.slice(2));
416
+ } catch (err) {
417
+ console.error(err.message ?? err);
418
+ process.exit(1);
419
+ }
420
+ }