devflow-kit 3.10.0 → 3.11.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 +31 -0
- package/dist/cli/commands/backup-session.js +131 -0
- package/dist/cli/commands/compliance.js +16 -3
- package/dist/cli/commands/curation.js +12 -4
- package/dist/cli/commands/init.js +36 -55
- package/dist/cli/commands/install-report.js +95 -0
- package/dist/cli/commands/legacy-sweep.js +140 -45
- package/dist/cli/commands/rules.js +50 -13
- package/dist/cli/commands/uninstall.js +62 -24
- package/dist/core/compliance.js +14 -0
- package/dist/core/fs-atomic.js +5 -1
- package/dist/core/orphan-sweep.js +20 -2
- package/dist/core/path-set.js +15 -0
- package/dist/core/plugins.js +3 -2
- package/dist/core/reference-sweep.js +42 -25
- package/dist/core/retired-shipped-names.js +278 -0
- package/dist/targets/claude-code/compliance-install.js +38 -13
- package/dist/targets/claude-code/compliance-off-files.js +5 -1
- package/dist/targets/claude-code/curation-install.js +75 -30
- package/dist/targets/claude-code/folder-swap.js +142 -0
- package/dist/targets/claude-code/install-backup.js +780 -0
- package/dist/targets/claude-code/installer.js +383 -192
- package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
- package/dist/targets/claude-code/linked-folder.js +5 -11
- package/dist/targets/claude-code/reference-converge.js +15 -2
- package/dist/targets/claude-code/rules-folder.js +34 -0
- package/dist/targets/claude-code/shipped-set.js +160 -0
- package/dist/targets/claude-code/workflow-folder-install.js +32 -12
- package/package.json +1 -1
|
@@ -1,64 +1,159 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
1
2
|
import { promises as fs } from 'fs';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import { LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES } from '../../core/plugins.js';
|
|
4
|
-
import {
|
|
5
|
+
import { RETIRED_SHIPPED } from '../../core/retired-shipped-names.js';
|
|
6
|
+
import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
|
|
7
|
+
import { BARE_LEGACY_SKILL_HASHES } from '../../targets/claude-code/legacy-skill-hashes.js';
|
|
8
|
+
import { directoryState, isUnrealFolder } from '../../targets/claude-code/linked-folder.js';
|
|
9
|
+
/** The prefix of a skill folder in devflow's own namespace. A folder without it is bare. */
|
|
10
|
+
const NAMESPACE_PREFIX = 'devflow:';
|
|
5
11
|
/**
|
|
6
|
-
* Whether the sweep may remove files from `dir`: the install did not refuse it,
|
|
7
|
-
*
|
|
12
|
+
* Whether the sweep may remove files from `dir`: the install did not refuse it, nothing in it stays
|
|
13
|
+
* because a move failed or the folder was too large to scan, and `lstat` shows a real folder or
|
|
14
|
+
* nothing. `readdir` and `rm` of a path below a link act on the folder it points to.
|
|
8
15
|
*/
|
|
9
|
-
async function mayRemoveFrom(report, dir) {
|
|
10
|
-
return !report.refusedFolders.includes(dir) && !(await isUnrealFolder(dir));
|
|
16
|
+
async function mayRemoveFrom(report, backups, dir) {
|
|
17
|
+
return !report.refusedFolders.includes(dir) && !backups.isKept(dir) && !(await isUnrealFolder(dir));
|
|
18
|
+
}
|
|
19
|
+
/** Each name once, in the order given. */
|
|
20
|
+
const uniqueNames = (...lists) => [...new Set(lists.flat())];
|
|
21
|
+
/**
|
|
22
|
+
* Remove `<folder>/<name>.md` for each name, by exact name. A name that is shipped today is never
|
|
23
|
+
* removed, and a file that stays is left for the session's record.
|
|
24
|
+
*/
|
|
25
|
+
async function removeRetiredMarkdown(folder, names, backups) {
|
|
26
|
+
const removed = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const file = path.join(folder, `${name}.md`);
|
|
29
|
+
if (backups.classifyAt(file, 'file') === 'shipped')
|
|
30
|
+
continue;
|
|
31
|
+
if ((await backups.removeExact(file)).status === 'removed')
|
|
32
|
+
removed.push(file);
|
|
33
|
+
}
|
|
34
|
+
return removed;
|
|
11
35
|
}
|
|
12
36
|
/**
|
|
13
37
|
* Remove the files that earlier releases installed into `commands/devflow` and `rules/devflow` and
|
|
14
|
-
* that no release installs now: the
|
|
15
|
-
* Runs on every install shape, since the full install's pre-clean covers only a full
|
|
38
|
+
* that no release installs now: the retired command and rule names and the legacy lists, each by its
|
|
39
|
+
* exact name. Runs on every install shape, since the full install's pre-clean covers only a full
|
|
40
|
+
* install. A file whose name only looks like a retired one (a `*-teams.md` of the user's) is not
|
|
41
|
+
* matched here: the install's pre-pass moves it aside and names it.
|
|
16
42
|
*
|
|
17
|
-
* A folder the install refused (`report.refusedFolders`)
|
|
18
|
-
* sweep reads and removes nothing there, and the folder
|
|
43
|
+
* A folder the install refused (`report.refusedFolders`), that the session could not scan, or that
|
|
44
|
+
* is a link now is skipped whole: the sweep reads and removes nothing there, and the folder a link
|
|
45
|
+
* points to is left as it is. Every removal goes through `backups`, so each removed file is named
|
|
46
|
+
* once, on the retired line of its record. Never throws.
|
|
19
47
|
*
|
|
20
|
-
* @param opts.legacyCommands - Legacy command names;
|
|
21
|
-
* @param opts.legacyRules - Legacy rule names;
|
|
48
|
+
* @param opts.legacyCommands - Legacy command names; added to the retired commands.
|
|
49
|
+
* @param opts.legacyRules - Legacy rule names; added to the retired rules.
|
|
22
50
|
*/
|
|
23
51
|
export async function sweepLegacyDevflowFiles(opts) {
|
|
24
|
-
const { claudeDir, report } = opts;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
52
|
+
const { claudeDir, report, backups } = opts;
|
|
53
|
+
const commands = uniqueNames(RETIRED_SHIPPED.commands, opts.legacyCommands ?? LEGACY_COMMAND_NAMES);
|
|
54
|
+
const rules = uniqueNames(RETIRED_SHIPPED.rules, opts.legacyRules ?? LEGACY_RULE_NAMES);
|
|
55
|
+
const removed = [];
|
|
28
56
|
const commandsDir = path.join(claudeDir, 'commands', 'devflow');
|
|
29
|
-
if (await mayRemoveFrom(report, commandsDir)) {
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
await fs.rm(path.join(commandsDir, `${legacy}.md`));
|
|
33
|
-
result.staleCommandsRemoved++;
|
|
34
|
-
}
|
|
35
|
-
catch { /* absent — expected for most entries */ }
|
|
36
|
-
}
|
|
37
|
-
// Orphaned *-teams.md workflow command variants left by the Agent Teams refactor. None is ever
|
|
38
|
-
// re-installed, so a blanket sweep is safe on any install type.
|
|
39
|
-
try {
|
|
40
|
-
for (const entry of await fs.readdir(commandsDir)) {
|
|
41
|
-
if (!entry.endsWith('-teams.md'))
|
|
42
|
-
continue;
|
|
43
|
-
try {
|
|
44
|
-
await fs.rm(path.join(commandsDir, entry));
|
|
45
|
-
result.staleCommandsRemoved++;
|
|
46
|
-
}
|
|
47
|
-
catch { /* already gone */ }
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
catch { /* commands folder absent — nothing to sweep */ }
|
|
57
|
+
if (await mayRemoveFrom(report, backups, commandsDir)) {
|
|
58
|
+
removed.push(...await removeRetiredMarkdown(commandsDir, commands, backups));
|
|
51
59
|
}
|
|
52
60
|
const rulesDir = path.join(claudeDir, 'rules', 'devflow');
|
|
53
|
-
if (await mayRemoveFrom(report, rulesDir)) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
if (await mayRemoveFrom(report, backups, rulesDir)) {
|
|
62
|
+
removed.push(...await removeRetiredMarkdown(rulesDir, rules, backups));
|
|
63
|
+
}
|
|
64
|
+
return { removed };
|
|
65
|
+
}
|
|
66
|
+
/** The most bytes of a `SKILL.md` the check reads. Every released copy is far smaller, so a larger file is not one. */
|
|
67
|
+
const MAX_SKILL_FILE_BYTES = 1024 * 1024;
|
|
68
|
+
/**
|
|
69
|
+
* Who made a bare legacy skill folder, judged from its `SKILL.md` alone:
|
|
70
|
+
* - `devflow`: the file is a regular file whose SHA-256 is one of `released`;
|
|
71
|
+
* - `other`: the file reads and is not one of them, or is larger than any release copy;
|
|
72
|
+
* - `unknown`: the check cannot decide. There is no `SKILL.md`, it is not a regular file, the
|
|
73
|
+
* folder or the file is a link, or a read fails.
|
|
74
|
+
* An `absent` folder needs no answer. Reads one file at most. Never throws.
|
|
75
|
+
*/
|
|
76
|
+
async function judgeBareSkillFolder(folder, released) {
|
|
77
|
+
const state = await directoryState(folder);
|
|
78
|
+
if (state === 'absent')
|
|
79
|
+
return 'absent';
|
|
80
|
+
if (state !== 'real')
|
|
81
|
+
return 'unknown';
|
|
82
|
+
try {
|
|
83
|
+
const skillFile = path.join(folder, 'SKILL.md');
|
|
84
|
+
const stat = await fs.lstat(skillFile);
|
|
85
|
+
if (!stat.isFile())
|
|
86
|
+
return 'unknown';
|
|
87
|
+
if (stat.size > MAX_SKILL_FILE_BYTES)
|
|
88
|
+
return 'other';
|
|
89
|
+
const digest = createHash('sha256').update(await fs.readFile(skillFile)).digest('hex');
|
|
90
|
+
return released.includes(digest) ? 'devflow' : 'other';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return 'unknown';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* {@link judgeBareSkillFolder} for the bare legacy skill `name`, against the released copies of that
|
|
98
|
+
* name. A name with no entry in `released` was never installed bare by a release, so a folder of
|
|
99
|
+
* that name is `other`. The one check that `devflow init` and `devflow uninstall` share.
|
|
100
|
+
*
|
|
101
|
+
* @param folder - The folder of the skill, `<skills folder>/<name>`.
|
|
102
|
+
* @param released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
|
|
103
|
+
*/
|
|
104
|
+
export function judgeBareLegacySkill(folder, name, released = BARE_LEGACY_SKILL_HASHES) {
|
|
105
|
+
return judgeBareSkillFolder(folder, Object.hasOwn(released, name) ? released[name] : []);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Deal with the skill folders that earlier installs left under the legacy names: every name of the
|
|
109
|
+
* legacy skill list, bare and `devflow:`-prefixed.
|
|
110
|
+
*
|
|
111
|
+
* A `devflow:`-prefixed folder that no release listed moves whole, with one rename and no walk, and
|
|
112
|
+
* is named; it is never deleted. A bare folder lives in the skills folder that Claude Code and other
|
|
113
|
+
* tools share, so its name proves nothing (`python`, `plan` and `review` are names of a user's own
|
|
114
|
+
* skills too). It moves only when its `SKILL.md` is a copy that a release of devflow installed
|
|
115
|
+
* (`released`). A bare folder whose `SKILL.md` is any other file stays where it is, and nothing is
|
|
116
|
+
* said. A bare folder that the check cannot judge stays, is never deleted, and is returned in
|
|
117
|
+
* `kept`, so the caller names it. A name with a listing in `retiredFiles` (the files a release
|
|
118
|
+
* shipped in that folder) goes by the listing instead: the listed files are removed, the rest of
|
|
119
|
+
* the folder moves, and a plain `rmdir` removes the folder only when it is empty. A move that fails
|
|
120
|
+
* leaves the folder and its files where they are, and the session records it. A name that is shipped
|
|
121
|
+
* today, or that an earlier step could not move, is skipped, and so is every name when the skills
|
|
122
|
+
* folder is a link or not a folder: nothing is moved through a link. Nothing here removes a folder
|
|
123
|
+
* with recursion. Never throws.
|
|
124
|
+
*
|
|
125
|
+
* @param opts.legacySkills - Folder names to deal with; defaults to the legacy skill list.
|
|
126
|
+
* @param opts.retiredFiles - Released file listings by folder name; defaults to the retired registry.
|
|
127
|
+
* @param opts.released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
|
|
128
|
+
*/
|
|
129
|
+
export async function sweepLegacySkillFolders(opts) {
|
|
130
|
+
const { backups } = opts;
|
|
131
|
+
const retiredFiles = opts.retiredFiles ?? RETIRED_SHIPPED.skillFiles;
|
|
132
|
+
const released = opts.released ?? BARE_LEGACY_SKILL_HASHES;
|
|
133
|
+
const kept = [];
|
|
134
|
+
const skillsDir = path.join(opts.claudeDir, 'skills');
|
|
135
|
+
if ((await directoryState(skillsDir)) !== 'real')
|
|
136
|
+
return { kept };
|
|
137
|
+
for (const name of opts.legacySkills ?? LEGACY_SKILL_NAMES) {
|
|
138
|
+
const folder = path.join(skillsDir, name);
|
|
139
|
+
if (backups.isKept(folder) || backups.classifyAt(folder, 'directory') === 'shipped')
|
|
140
|
+
continue;
|
|
141
|
+
const listed = Object.hasOwn(retiredFiles, name) && retiredFiles[name].length > 0;
|
|
142
|
+
if (listed && (await directoryState(folder)) === 'real') {
|
|
143
|
+
await backups.prepass([folder]);
|
|
144
|
+
await backups.removeListed(folder);
|
|
145
|
+
}
|
|
146
|
+
else if (name.startsWith(NAMESPACE_PREFIX)) {
|
|
147
|
+
await backups.moveWhole(folder);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
const verdict = await judgeBareLegacySkill(folder, name, released);
|
|
151
|
+
if (verdict === 'devflow')
|
|
152
|
+
await backups.moveWhole(folder);
|
|
153
|
+
else if (verdict === 'unknown')
|
|
154
|
+
kept.push(folder);
|
|
60
155
|
}
|
|
61
156
|
}
|
|
62
|
-
return
|
|
157
|
+
return { kept };
|
|
63
158
|
}
|
|
64
159
|
//# sourceMappingURL=legacy-sweep.js.map
|
|
@@ -10,7 +10,9 @@ import { rulesDir } from '../../core/assets.js';
|
|
|
10
10
|
import { installAllRules, validateRuleShadow } from '../../targets/claude-code/installer.js';
|
|
11
11
|
import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
|
|
12
12
|
import { convergeBehaviorFromManifest } from '../../targets/claude-code/behavior-install.js';
|
|
13
|
-
import { newLinkReport, refuseUnrealFolder, refusedFolderMessage
|
|
13
|
+
import { newLinkReport, refuseUnrealFolder, refusedFolderMessage } from '../../targets/claude-code/linked-folder.js';
|
|
14
|
+
import { removeRulesFolder, rulesFolderRefusedMessage } from '../../targets/claude-code/rules-folder.js';
|
|
15
|
+
import { openBackupSessionOrFail, runWithBackupReport } from './backup-session.js';
|
|
14
16
|
import { formatLinksSummary } from './install-report.js';
|
|
15
17
|
/**
|
|
16
18
|
* Check whether a rule has a user shadow file at ~/.devflow/rules/{name}.md.
|
|
@@ -205,20 +207,29 @@ async function handleRuleUnshadow(name, allRules, devflowDir) {
|
|
|
205
207
|
*
|
|
206
208
|
* Warn-not-throw on per-item compliance convergence failure, so a convergence
|
|
207
209
|
* failure never undoes the rules install.
|
|
210
|
+
*
|
|
211
|
+
* The record of `backups` prints once, as the enable returns or throws.
|
|
208
212
|
*/
|
|
209
|
-
export
|
|
213
|
+
export function runRulesEnable(claudeDir, devflowDir, backups) {
|
|
214
|
+
return runWithBackupReport(backups, printBackups => enableRules(claudeDir, devflowDir, backups, printBackups));
|
|
215
|
+
}
|
|
216
|
+
async function enableRules(claudeDir, devflowDir, backups, printBackups) {
|
|
210
217
|
const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
|
|
211
218
|
const manifest = await readManifest(devflowDir);
|
|
212
219
|
if (!manifest) {
|
|
213
220
|
p.log.error('No manifest found. Run devflow init first.');
|
|
221
|
+
printBackups();
|
|
214
222
|
process.exit(1);
|
|
215
223
|
}
|
|
216
224
|
const installedPlugins = DEVFLOW_PLUGINS.filter(pl => manifest.plugins.includes(pl.name));
|
|
217
225
|
const rulesMap = buildRulesMap(installedPlugins);
|
|
218
|
-
//
|
|
219
|
-
//
|
|
226
|
+
// Clear stale rules from previously uninstalled plugins before re-installing: the files devflow did
|
|
227
|
+
// not ship move to the backup folder, and the rest of the folder is removed by the session's list.
|
|
228
|
+
// A rules folder that is a link is unlinked and replaced by a real folder, never emptied through,
|
|
229
|
+
// and the link is reported. Something that is not a folder stays and is refused below.
|
|
220
230
|
const links = newLinkReport();
|
|
221
|
-
|
|
231
|
+
if ((await removeRulesFolder(rulesTarget, backups)) === 'unlinked')
|
|
232
|
+
links.replacedLinks.push(rulesTarget);
|
|
222
233
|
// A link that could not be removed is still there, and `mkdir` and every rule write would follow it.
|
|
223
234
|
// Prove the folder first; a refused folder gets no rule, no manifest write and one refusal line.
|
|
224
235
|
const rulesFolderRefused = await refuseUnrealFolder(links, rulesTarget);
|
|
@@ -268,6 +279,7 @@ export async function runRulesEnable(claudeDir, devflowDir) {
|
|
|
268
279
|
manifest,
|
|
269
280
|
warn: warnConverge,
|
|
270
281
|
rulesEnabledOverride: !installFailed,
|
|
282
|
+
backups,
|
|
271
283
|
});
|
|
272
284
|
}
|
|
273
285
|
catch (err) {
|
|
@@ -325,17 +337,42 @@ export const rulesCommand = new Command('rules')
|
|
|
325
337
|
}
|
|
326
338
|
// No positional action — fall through to flag-based handling
|
|
327
339
|
if (options.enable) {
|
|
328
|
-
await
|
|
340
|
+
const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
|
|
341
|
+
if (backups === null)
|
|
342
|
+
return;
|
|
343
|
+
await runRulesEnable(claudeDir, devflowDir, backups);
|
|
329
344
|
}
|
|
330
345
|
else if (options.disable) {
|
|
331
|
-
await
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
await
|
|
346
|
+
const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
|
|
347
|
+
if (backups === null)
|
|
348
|
+
return;
|
|
349
|
+
const removal = await runWithBackupReport(backups, async () => {
|
|
350
|
+
const outcome = await removeRulesFolder(rulesTarget, backups);
|
|
351
|
+
const manifest = await readManifest(devflowDir);
|
|
352
|
+
if (manifest) {
|
|
353
|
+
manifest.features.rules = false;
|
|
354
|
+
manifest.updatedAt = new Date().toISOString();
|
|
355
|
+
await writeManifest(devflowDir, manifest);
|
|
356
|
+
}
|
|
357
|
+
return outcome;
|
|
358
|
+
});
|
|
359
|
+
switch (removal) {
|
|
360
|
+
case 'kept':
|
|
361
|
+
p.log.warn(`Rules disabled — ${rulesTarget} stays, because it still holds files that devflow did not remove`);
|
|
362
|
+
break;
|
|
363
|
+
case 'refused':
|
|
364
|
+
p.log.warn(`Rules disabled. ${rulesFolderRefusedMessage(rulesTarget)}`);
|
|
365
|
+
break;
|
|
366
|
+
case 'absent':
|
|
367
|
+
case 'removed':
|
|
368
|
+
case 'unlinked':
|
|
369
|
+
p.log.success('Rules disabled — removed rules directory');
|
|
370
|
+
break;
|
|
371
|
+
default: {
|
|
372
|
+
const unreachable = removal;
|
|
373
|
+
void unreachable;
|
|
374
|
+
}
|
|
337
375
|
}
|
|
338
|
-
p.log.success('Rules disabled — removed rules directory');
|
|
339
376
|
}
|
|
340
377
|
else if (options.status) {
|
|
341
378
|
let installedFiles = [];
|
|
@@ -37,8 +37,10 @@ import { probeTrackedArea } from '../../core/tracked-areas.js';
|
|
|
37
37
|
import { restampInstalledCommands } from '../../targets/claude-code/language-stamp.js';
|
|
38
38
|
import { installRenderedCharter, removeHandoffSentinel } from '../../targets/claude-code/charter-render.js';
|
|
39
39
|
import { installCommandsAndAgents } from '../../targets/claude-code/installer.js';
|
|
40
|
+
import { BACKUP_ROOT_NAME, escapeName } from '../../targets/claude-code/install-backup.js';
|
|
40
41
|
import { isUnrealFolder, newLinkReport, refuseUnrealFolder, refusedFolderStatement } from '../../targets/claude-code/linked-folder.js';
|
|
41
42
|
import { formatReplacedLinksSummary } from './install-report.js';
|
|
43
|
+
import { judgeBareLegacySkill } from './legacy-sweep.js';
|
|
42
44
|
/**
|
|
43
45
|
* Where a retired repo-local install lives: `<gitRoot>/.claude` and `<gitRoot>/.devflow`.
|
|
44
46
|
*
|
|
@@ -654,6 +656,9 @@ export function userContentPaths(devflowDir) {
|
|
|
654
656
|
{ relPath: 'skills', isDir: true, label: `skill shadows (${path.join(devflowDir, 'skills')})` },
|
|
655
657
|
// Rule shadow overrides (~/.devflow/rules/{name}.md)
|
|
656
658
|
{ relPath: 'rules', isDir: true, label: `rule shadows (${path.join(devflowDir, 'rules')})` },
|
|
659
|
+
// The files an install moved out of the devflow folders because devflow did not ship them.
|
|
660
|
+
// They are the user's own files, so only a confirmed full wipe removes them.
|
|
661
|
+
{ relPath: BACKUP_ROOT_NAME, isDir: true, label: `install backups (${path.join(devflowDir, BACKUP_ROOT_NAME)})` },
|
|
657
662
|
// preference-profile.md — user-curated decision-preference profile
|
|
658
663
|
{ relPath: 'preference-profile.md', label: 'preference-profile.md' },
|
|
659
664
|
// tracker/ — the inferred, hand-editable issue-tracker conventions, one file
|
|
@@ -921,7 +926,7 @@ export function installArtifactPaths(devflowDir) {
|
|
|
921
926
|
* This function runs on the decline, cancel, non-interactive AND --keep-docs paths,
|
|
922
927
|
* so an entry here is deleted even when the user answers "no" to the full wipe.
|
|
923
928
|
* User-authored state (everything in `userContentPaths`: skill/rule shadows,
|
|
924
|
-
* preference-profile.md, the tracker conventions with the legacy tracker.md and
|
|
929
|
+
* the install backups, preference-profile.md, the tracker conventions with the legacy tracker.md and
|
|
925
930
|
* its backups, the global tuning file under both names, hud.json) is removed only by
|
|
926
931
|
* the confirmed full-dir rm.
|
|
927
932
|
* agent-models.json is an INSTALL ARTIFACT (stale per-agent overrides silently
|
|
@@ -1012,6 +1017,35 @@ export async function isDevFlowInstalled(claudeDir) {
|
|
|
1012
1017
|
catch { /* skills dir absent or unreadable */ }
|
|
1013
1018
|
return false;
|
|
1014
1019
|
}
|
|
1020
|
+
/** Why a bare legacy skill folder stays: its `SKILL.md` is a file that no release of devflow installed. */
|
|
1021
|
+
const KEPT_NOT_MADE = 'devflow did not make this folder';
|
|
1022
|
+
/** Why a bare legacy skill folder stays: there is no regular `SKILL.md`, the folder or file is a link, or a read failed. */
|
|
1023
|
+
const KEPT_UNDECIDED = 'devflow could not tell if it made this folder';
|
|
1024
|
+
/**
|
|
1025
|
+
* Sort the bare legacy skill folders that exist in `skillsDir` into those a full uninstall removes
|
|
1026
|
+
* and those it keeps. The skills folder is shared with Claude Code and other tools, so a bare name
|
|
1027
|
+
* (`python`, `plan`) does not prove that devflow made the folder. It is removed only when the check
|
|
1028
|
+
* `devflow init` uses finds a `SKILL.md` that a release installed (`judgeBareLegacySkill`). The dry
|
|
1029
|
+
* run and the removal both take this one list. A `devflow:` name is in the namespace of devflow, and
|
|
1030
|
+
* the prefixed pass removes it. Reads one file per folder at most. Never throws.
|
|
1031
|
+
*/
|
|
1032
|
+
async function planBareLegacySkills(skillsDir) {
|
|
1033
|
+
const remove = [];
|
|
1034
|
+
const keep = [];
|
|
1035
|
+
for (const name of new Set(LEGACY_SKILL_NAMES)) {
|
|
1036
|
+
if (name.startsWith(SKILL_NAMESPACE))
|
|
1037
|
+
continue;
|
|
1038
|
+
const folder = path.join(skillsDir, name);
|
|
1039
|
+
const verdict = await judgeBareLegacySkill(folder, name);
|
|
1040
|
+
if (verdict === 'devflow')
|
|
1041
|
+
remove.push(folder);
|
|
1042
|
+
else if (verdict === 'other')
|
|
1043
|
+
keep.push({ folder, reason: KEPT_NOT_MADE });
|
|
1044
|
+
else if (verdict === 'unknown')
|
|
1045
|
+
keep.push({ folder, reason: KEPT_UNDECIDED });
|
|
1046
|
+
}
|
|
1047
|
+
return { remove, keep };
|
|
1048
|
+
}
|
|
1015
1049
|
// ---------------------------------------------------------------------------
|
|
1016
1050
|
// Phase runners — extracted from the action function for readability and
|
|
1017
1051
|
// independent testability. Each covers one logical stage of the uninstall.
|
|
@@ -1029,9 +1063,10 @@ export async function isDevFlowInstalled(claudeDir) {
|
|
|
1029
1063
|
* and the scripts directory inside devflowDir.
|
|
1030
1064
|
* 2. ALL skill removal candidates (existence-filtered):
|
|
1031
1065
|
* - Prefixed (devflow:name): live registry ∪ LEGACY_SKILL_NAMES.
|
|
1032
|
-
* - Bare (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY
|
|
1033
|
-
* ~/.claude/skills/ is
|
|
1034
|
-
* are by construction
|
|
1066
|
+
* - Bare (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY, and only a
|
|
1067
|
+
* folder that devflow made (planBareLegacySkills). ~/.claude/skills/ is
|
|
1068
|
+
* shared; bare dirs for live-registry skill names are by construction
|
|
1069
|
+
* foreign to Devflow. A bare folder that stays is listed as `(kept: <reason>)`.
|
|
1035
1070
|
* 3. manifest.json (removed separately in removeDevFlowInstallArtifacts).
|
|
1036
1071
|
* 4. Every artifact path from installArtifactPaths(devflowDir) — the single
|
|
1037
1072
|
* source of truth shared with the real removal loop.
|
|
@@ -1057,8 +1092,8 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir, folderWorkflo
|
|
|
1057
1092
|
// 2. Skills: enumerate ALL removal candidates that exist on disk.
|
|
1058
1093
|
// Mirrors removeAllDevFlow's split-pass approach:
|
|
1059
1094
|
// Prefixed: live registry ∪ LEGACY_SKILL_NAMES ∪ FEATURE_OWNED_SKILLS
|
|
1060
|
-
// Bare: LEGACY_SKILL_NAMES only
|
|
1061
|
-
// dirs are by construction foreign to Devflow.
|
|
1095
|
+
// Bare: LEGACY_SKILL_NAMES only, and only a folder that devflow made — shared
|
|
1096
|
+
// skills/ dir; live-registry bare dirs are by construction foreign to Devflow.
|
|
1062
1097
|
// FEATURE_OWNED_SKILLS (compliance) is included so full uninstall removes
|
|
1063
1098
|
// devflow:compliance even though it left the plugin registry (step 1.5).
|
|
1064
1099
|
// The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) join through getAllSkillNames.
|
|
@@ -1072,14 +1107,10 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir, folderWorkflo
|
|
|
1072
1107
|
}
|
|
1073
1108
|
catch { /* absent */ }
|
|
1074
1109
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
extras.push(barePath);
|
|
1080
|
-
}
|
|
1081
|
-
catch { /* absent */ }
|
|
1082
|
-
}
|
|
1110
|
+
const bareSkills = await planBareLegacySkills(skillsDir);
|
|
1111
|
+
extras.push(...bareSkills.remove);
|
|
1112
|
+
for (const { folder, reason } of bareSkills.keep)
|
|
1113
|
+
extras.push(`${escapeName(folder)} (kept: ${reason})`);
|
|
1083
1114
|
// 3. manifest.json — removed separately by removeDevFlowInstallArtifacts
|
|
1084
1115
|
// (NOT in installArtifactPaths). List it here so the preview is complete.
|
|
1085
1116
|
const manifestPath = path.join(devflowDir, 'manifest.json');
|
|
@@ -1422,9 +1453,11 @@ export async function runSelectivePhaseForScope(opts) {
|
|
|
1422
1453
|
* @param opts.isTTY - Whether the session is interactive. Injected rather than
|
|
1423
1454
|
* read from process.stdin so the prompt gate is an explicit input (matching
|
|
1424
1455
|
* resolveDevflowDirCleanup) instead of an ambient global a test cannot control.
|
|
1456
|
+
* @param opts.confirm - The prompt for the full-dir wipe (default `p.confirm`).
|
|
1425
1457
|
*/
|
|
1426
1458
|
export async function runFullPhaseForScope(opts) {
|
|
1427
1459
|
const { scope, claudeDir, devflowDir, devflowScriptsDir, verbose, keepDocs, isTTY } = opts;
|
|
1460
|
+
const confirm = opts.confirm ?? p.confirm;
|
|
1428
1461
|
// D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first.
|
|
1429
1462
|
const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir });
|
|
1430
1463
|
// Revert GPT agent frontmatter before removing agents — ensures no orphaned
|
|
@@ -1482,7 +1515,7 @@ export async function runFullPhaseForScope(opts) {
|
|
|
1482
1515
|
for (const item of userContent) {
|
|
1483
1516
|
p.log.info(` ${item}`);
|
|
1484
1517
|
}
|
|
1485
|
-
const confirmFullCleanup = await
|
|
1518
|
+
const confirmFullCleanup = await confirm({
|
|
1486
1519
|
message: `Remove entire ${devflowDir}? (includes the items listed above, plus logs and install metadata)`,
|
|
1487
1520
|
initialValue: false,
|
|
1488
1521
|
});
|
|
@@ -2005,10 +2038,11 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
|
|
|
2005
2038
|
// prefixSkillName() is idempotent for already-prefixed legacy entries.
|
|
2006
2039
|
// FEATURE_OWNED_SKILLS (compliance) is included so full uninstall removes
|
|
2007
2040
|
// devflow:compliance even though it left the plugin registry (step 1.5).
|
|
2008
|
-
// Bare pass (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY
|
|
2009
|
-
//
|
|
2010
|
-
//
|
|
2011
|
-
//
|
|
2041
|
+
// Bare pass (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY, and only a folder
|
|
2042
|
+
// whose SKILL.md is a copy that a release of devflow installed (planBareLegacySkills).
|
|
2043
|
+
// Any other bare folder stays and is named. A bare dir whose name matches a
|
|
2044
|
+
// live-registry skill is by construction foreign to Devflow (the devflow: namespace
|
|
2045
|
+
// shipped in dcecda3, 2026-03-30). A bare 'compliance/' dir is also foreign (not in LEGACY_SKILL_NAMES).
|
|
2012
2046
|
// The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) are in getAllSkillNames, so the folder is removed.
|
|
2013
2047
|
const prefixedSkillNames = new Set([...getAllSkillNames(folderWorkflows), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
|
|
2014
2048
|
const skillsDir = path.join(claudeDir, 'skills');
|
|
@@ -2025,17 +2059,21 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
|
|
|
2025
2059
|
}
|
|
2026
2060
|
catch { /* Skill doesn't exist */ }
|
|
2027
2061
|
}
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2062
|
+
// Bare variant: frozen legacy list only, never the live registry, and only a folder that devflow made.
|
|
2063
|
+
const bareSkills = await planBareLegacySkills(skillsDir);
|
|
2064
|
+
for (const barePath of bareSkills.remove) {
|
|
2031
2065
|
try {
|
|
2032
|
-
await fs.stat(barePath);
|
|
2033
2066
|
if (!(await mayChange(barePath)))
|
|
2034
2067
|
continue;
|
|
2035
2068
|
await fs.rm(barePath, { recursive: true, force: true });
|
|
2036
2069
|
skillsRemoved++;
|
|
2037
2070
|
}
|
|
2038
|
-
catch {
|
|
2071
|
+
catch (error) {
|
|
2072
|
+
p.log.warn(`Could not remove ${escapeName(barePath)}: ${error}`);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
for (const { folder, reason } of bareSkills.keep) {
|
|
2076
|
+
p.log.info(`Kept ${escapeName(folder)}: ${reason}`);
|
|
2039
2077
|
}
|
|
2040
2078
|
if (skillsRemoved > 0 && verbose) {
|
|
2041
2079
|
p.log.success(`Removed ${skillsRemoved} Devflow skill directories`);
|
package/dist/core/compliance.js
CHANGED
|
@@ -31,6 +31,20 @@ export const COMPLIANCE_RULE_PLACEHOLDER = '${DEVFLOW_COMPLIANCE_FRAMEWORKS}';
|
|
|
31
31
|
* definition. Adding a third always-present ref requires only one change here.
|
|
32
32
|
*/
|
|
33
33
|
export const ALWAYS_PRESENT_REFS = ['detection.md', 'sources.md'];
|
|
34
|
+
/**
|
|
35
|
+
* Every file the installed compliance skill folder holds, as POSIX paths below it: `SKILL.md`, the
|
|
36
|
+
* always-present references, and one `references/{id}.md` per registry framework. The install
|
|
37
|
+
* writes all of them on every machine whatever the selection (D-COMPLIANCE-INSTALL-ALWAYS), so
|
|
38
|
+
* this is also the set a scan treats as shipped. The skill source holds more (the framework
|
|
39
|
+
* fragments), which the install does not copy.
|
|
40
|
+
*/
|
|
41
|
+
export function complianceSkillFiles() {
|
|
42
|
+
return [
|
|
43
|
+
'SKILL.md',
|
|
44
|
+
...ALWAYS_PRESENT_REFS.map(ref => `references/${ref}`),
|
|
45
|
+
...COMPLIANCE_FRAMEWORKS.map(fw => `references/${fw.id}.md`),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
34
48
|
// ---------------------------------------------------------------------------
|
|
35
49
|
// Internal helpers
|
|
36
50
|
// ---------------------------------------------------------------------------
|
package/dist/core/fs-atomic.js
CHANGED
|
@@ -10,6 +10,10 @@ import { promises as fs } from 'fs';
|
|
|
10
10
|
* intentionally remains a separate implementation — same semantics, different module
|
|
11
11
|
* system. Any change to the retry logic here MUST be mirrored there.
|
|
12
12
|
*/
|
|
13
|
+
/** The sibling `filePath` is written to before the rename: `<file>.tmp.<pid>`. */
|
|
14
|
+
export function atomicTempPath(filePath) {
|
|
15
|
+
return `${filePath}.tmp.${process.pid}`;
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Atomically write `filePath` by writing to a sibling `.tmp` then renaming.
|
|
15
19
|
*
|
|
@@ -38,7 +42,7 @@ export async function writeFileAtomicExclusive(filePath, data, createMode) {
|
|
|
38
42
|
// (e.g., two Claude Code sessions) never collide on the same .tmp path.
|
|
39
43
|
// mirrors proxy-log.ts rotation at src/core/proxy-log.ts which PID-scopes
|
|
40
44
|
// for the same reason.
|
|
41
|
-
const tmp =
|
|
45
|
+
const tmp = atomicTempPath(filePath);
|
|
42
46
|
const options = { encoding: 'utf-8', flag: 'wx', ...(createMode === undefined ? {} : { mode: createMode }) };
|
|
43
47
|
try {
|
|
44
48
|
await fs.writeFile(tmp, data, options);
|
|
@@ -59,6 +59,10 @@ export function mdEntryName(entry) {
|
|
|
59
59
|
* that `dir` is a real folder before the call, because the sweep reads and
|
|
60
60
|
* removes inside whatever `dir` names.
|
|
61
61
|
*
|
|
62
|
+
* @param removal - How an admitted orphan is removed ({@link SweepRemoval}). Defaults to
|
|
63
|
+
* `recursive`. With `unlink`, an entry that has become a folder since the check stays and is
|
|
64
|
+
* recorded in `failed`; an entry that has vanished since the check counts as removed.
|
|
65
|
+
*
|
|
62
66
|
* @returns A {@link SweepResult} reporting how many entries were scanned, which
|
|
63
67
|
* registry names were removed, and which removals failed.
|
|
64
68
|
* `scanned` is the count matched by the predicate (not the removed count) —
|
|
@@ -70,7 +74,7 @@ export function mdEntryName(entry) {
|
|
|
70
74
|
* individual removal is recorded in `failed` without aborting the sweep.
|
|
71
75
|
* Never writes, only removes — no delete-then-write window.
|
|
72
76
|
*/
|
|
73
|
-
export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName, mayRemove) {
|
|
77
|
+
export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName, mayRemove, removal = 'recursive') {
|
|
74
78
|
const removed = [];
|
|
75
79
|
const failed = [];
|
|
76
80
|
let scanned = 0;
|
|
@@ -86,7 +90,7 @@ export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName,
|
|
|
86
90
|
try {
|
|
87
91
|
if (mayRemove !== undefined && !(await mayRemove(entryPath)))
|
|
88
92
|
continue;
|
|
89
|
-
await
|
|
93
|
+
await removeEntry(entryPath, removal);
|
|
90
94
|
removed.push(registryName);
|
|
91
95
|
}
|
|
92
96
|
catch (err) {
|
|
@@ -98,4 +102,18 @@ export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName,
|
|
|
98
102
|
catch { /* directory absent or unreadable — not an error */ }
|
|
99
103
|
return { scanned, removed, failed };
|
|
100
104
|
}
|
|
105
|
+
/** Remove one orphan as `removal` says. An absent entry is already removed, as `fs.rm` with `force` treats it. */
|
|
106
|
+
async function removeEntry(entryPath, removal) {
|
|
107
|
+
if (removal === 'recursive') {
|
|
108
|
+
await fs.rm(entryPath, { recursive: true, force: true });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await fs.unlink(entryPath);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err.code !== 'ENOENT')
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
101
119
|
//# sourceMappingURL=orphan-sweep.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Build a {@link PathSet} from file paths: the folder prefixes are computed once. */
|
|
2
|
+
export function createPathSet(files) {
|
|
3
|
+
const all = new Set(files);
|
|
4
|
+
const folders = new Set();
|
|
5
|
+
for (const file of all) {
|
|
6
|
+
for (let cut = file.indexOf('/'); cut !== -1; cut = file.indexOf('/', cut + 1)) {
|
|
7
|
+
folders.add(file.slice(0, cut));
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
files: all,
|
|
12
|
+
holds: (folder) => all.has(folder) || folders.has(folder),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=path-set.js.map
|
package/dist/core/plugins.js
CHANGED
|
@@ -640,11 +640,12 @@ export function parsePluginSelection(input, validPlugins) {
|
|
|
640
640
|
/**
|
|
641
641
|
* Deprecated command names from old installations.
|
|
642
642
|
* Used during init to clean up stale command files on upgrade.
|
|
643
|
+
*
|
|
644
|
+
* Each name is a command file that a release installed. The cleanup removes such a file with no
|
|
645
|
+
* backup, so a name that no release shipped does not belong here.
|
|
643
646
|
*/
|
|
644
647
|
export const LEGACY_COMMAND_NAMES = [
|
|
645
|
-
'review',
|
|
646
648
|
'specify',
|
|
647
|
-
'specify-teams',
|
|
648
649
|
];
|
|
649
650
|
/** A command entry of a plugin without its leading `/`. */
|
|
650
651
|
function commandName(cmd) {
|