@cuewright/skills 0.1.1 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuewright/skills",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "CLI installer for the Cuewright skills framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { access, readFile, lstat } from 'fs/promises';
2
+ import { access, readFile, lstat } from 'fs/promises'; // lstat used in checks 3-4
3
3
  import { join } from 'path';
4
4
  import { cwd } from 'process';
5
5
  import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
+ import { inventorySkillsDir } from '../utils/symlink.js';
6
7
  import { getSkillsDir, getFrameworkDir } from '../adapters/claude.js';
7
8
 
8
9
  export async function doctor() {
@@ -63,7 +64,7 @@ export async function doctor() {
63
64
  }
64
65
 
65
66
  if (brokenLinks.length === 0) {
66
- checks.push(pass(`${wiredCount}/${skills.length} skills wired`));
67
+ checks.push(pass(`${wiredCount}/${skills.length} skills wired (cw- prefix)`));
67
68
  } else {
68
69
  allPassed = false;
69
70
  checks.push(fail(
@@ -75,6 +76,29 @@ export async function doctor() {
75
76
  }
76
77
  }
77
78
 
79
+ // 4b. Inventory skills directory — detect legacy and orphaned entries
80
+ const { legacy: legacyFound, orphaned: orphanedFound } = await inventorySkillsDir(skillsDir, skills);
81
+
82
+ if (legacyFound.length > 0) {
83
+ allPassed = false;
84
+ checks.push(fail(
85
+ `${legacyFound.length} legacy skill director${legacyFound.length === 1 ? 'y' : 'ies'} found (pre-v0.15.0 install)`,
86
+ 'Run `cuewright update` to clean up automatically.'
87
+ ));
88
+ for (const name of legacyFound) {
89
+ checks.push(fail(` .claude/skills/${name}/`, null));
90
+ }
91
+ }
92
+
93
+ if (orphanedFound.length > 0) {
94
+ checks.push(warn(
95
+ `${orphanedFound.length} unknown director${orphanedFound.length === 1 ? 'y' : 'ies'} in .claude/skills/ (third-party or project-local — no action needed)`
96
+ ));
97
+ for (const name of orphanedFound) {
98
+ checks.push(warn(` .claude/skills/${name}/`));
99
+ }
100
+ }
101
+
78
102
  // 5. arch/project-config.md exists
79
103
  const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
80
104
  if (!await exists(configPath)) {
@@ -107,6 +131,14 @@ export async function doctor() {
107
131
  }
108
132
  }
109
133
 
134
+ // 6b. arch/references/ exists
135
+ const referencesDir = join(projectRoot, '.claude', 'skills', 'arch', 'references');
136
+ if (!await exists(referencesDir)) {
137
+ checks.push(warn('arch/references/ not found — skill reference files have no home. Run `cuewright update` to scaffold it.'));
138
+ } else {
139
+ checks.push(pass('arch/references/ found'));
140
+ }
141
+
110
142
  // 7. Baseline compatibility check (if baseline present)
111
143
  const baselineDir = join(projectRoot, '.claude', 'skills', '_baseline');
112
144
  if (await exists(baselineDir)) {
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { cwd } from 'process';
5
5
  import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
6
  import { syncSymlinks, syncCopies, platformSupportsSymlinks } from '../utils/symlink.js';
7
- import { addSubmodule } from '../storage/submodule.js';
7
+ import { addSubmodule, configureSparseCheckout } from '../storage/submodule.js';
8
8
  import { cloneFramework } from '../storage/copy.js';
9
9
  import { getSkillsDir, getFrameworkDir, getSubmodulePath } from '../adapters/claude.js';
10
10
 
@@ -76,6 +76,7 @@ async function freshInstall(projectRoot, frameworkDir, skillsDir, options) {
76
76
  if (useSubmodule) {
77
77
  console.log('Adding git submodule...');
78
78
  await addSubmodule(projectRoot, getSubmodulePath(), tag);
79
+ await configureSparseCheckout(frameworkDir);
79
80
  } else {
80
81
  console.log('Cloning framework (copy mode)...');
81
82
  await cloneFramework(frameworkDir, tag);
@@ -96,6 +97,7 @@ async function freshInstall(projectRoot, frameworkDir, skillsDir, options) {
96
97
  }
97
98
 
98
99
  await scaffoldConfigIfMissing(projectRoot, frameworkDir);
100
+ await scaffoldArchDirectories(projectRoot);
99
101
 
100
102
  console.log(chalk.green(`\nDone.`));
101
103
  console.log(` Framework: v${version}`);
@@ -127,6 +129,18 @@ async function scaffoldConfigIfMissing(projectRoot, frameworkDir) {
127
129
  console.log(' Scaffolded arch/project-config.md');
128
130
  }
129
131
 
132
+ /**
133
+ * Scaffold arch/references/ and arch/templates/ subdirectories if they don't exist.
134
+ * These are the project customization roots under the cw- prefix model.
135
+ */
136
+ async function scaffoldArchDirectories(projectRoot) {
137
+ const archDir = join(projectRoot, '.claude', 'skills', 'arch');
138
+ await mkdir(join(archDir, 'references'), { recursive: true });
139
+ await mkdir(join(archDir, 'templates', 'page'), { recursive: true });
140
+ await mkdir(join(archDir, 'templates', 'component'), { recursive: true });
141
+ await mkdir(join(archDir, 'templates', 'guide'), { recursive: true });
142
+ }
143
+
130
144
  async function exists(path) {
131
145
  try {
132
146
  await access(path);
@@ -1,12 +1,13 @@
1
1
  import chalk from 'chalk';
2
- import { access, readFile, unlink } from 'fs/promises';
2
+ import { access, readFile, unlink, mkdir } from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { cwd } from 'process';
5
5
  import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
- import { syncSymlinks, syncCopies, platformSupportsSymlinks, buildHeader } from '../utils/symlink.js';
7
- import { updateSubmodule, getLatestTag } from '../storage/submodule.js';
6
+ import { syncSymlinks, syncCopies, platformSupportsSymlinks, cleanupLegacyDirectories } from '../utils/symlink.js';
7
+ import { updateSubmodule, fetchTags, getLatestTag, getLatestTagFromRemote, configureSparseCheckout } from '../storage/submodule.js';
8
8
  import { cloneFramework } from '../storage/copy.js';
9
9
  import { getSkillsDir, getFrameworkDir } from '../adapters/claude.js';
10
+ import { updateProjectConfigVersion } from '../utils/project-config.js';
10
11
  import simpleGit from 'simple-git';
11
12
 
12
13
  export async function update(options) {
@@ -29,11 +30,21 @@ export async function update(options) {
29
30
  const isSubmodule = await exists(join(projectRoot, '.gitmodules'))
30
31
  && await isSubmoduleInstall(projectRoot, frameworkDir);
31
32
 
33
+ // Fetch remote tags before resolving latest — ensures local tag list is current
34
+ if (isSubmodule && !options.to) {
35
+ try {
36
+ await fetchTags(frameworkDir);
37
+ } catch {
38
+ // Non-fatal — fallback to remote query below
39
+ }
40
+ }
41
+
32
42
  // Determine target version — always bare (no leading v)
33
- const targetVersion = ((options.to || await resolveLatestTag(frameworkDir))).replace(/^v/, '');
43
+ const targetVersion = (options.to || await resolveLatestTag(frameworkDir, isSubmodule)).replace(/^v/, '');
34
44
 
35
45
  if (targetVersion === oldVersion) {
36
- console.log(chalk.cyan(`Already at v${oldVersion} (latest). Nothing to update.`));
46
+ console.log(chalk.cyan(`Already at v${oldVersion} (latest).`));
47
+ await runCleanup(skillsDir, listSkills(oldManifest), projectRoot, dryRun);
37
48
  return;
38
49
  }
39
50
 
@@ -46,6 +57,7 @@ export async function update(options) {
46
57
  if (!dryRun) {
47
58
  if (isSubmodule) {
48
59
  await updateSubmodule(projectRoot, '.claude/skills/_framework', targetVersion);
60
+ await configureSparseCheckout(frameworkDir);
49
61
  } else {
50
62
  await cloneFramework(frameworkDir, targetVersion);
51
63
  }
@@ -63,11 +75,10 @@ export async function update(options) {
63
75
  console.log(`New skills: ${added.join(', ')}`);
64
76
  if (!dryRun) {
65
77
  const useCopies = !platformSupportsSymlinks();
66
- if (useCopies) {
67
- await syncCopies(frameworkDir, skillsDir, added, targetVersion);
68
- } else {
69
- await syncSymlinks(frameworkDir, skillsDir, added);
70
- }
78
+ const { errors: syncErrors } = useCopies
79
+ ? await syncCopies(frameworkDir, skillsDir, added, targetVersion)
80
+ : await syncSymlinks(frameworkDir, skillsDir, added);
81
+ for (const e of syncErrors) console.log(chalk.yellow(` warning: ${e}`));
71
82
  }
72
83
  }
73
84
 
@@ -75,20 +86,32 @@ export async function update(options) {
75
86
  for (const name of removed) {
76
87
  const skillPath = join(skillsDir, name);
77
88
  if (await exists(skillPath)) {
78
- const isOverride = await isRealDirectory(skillPath);
79
- if (isOverride) {
80
- console.log(chalk.yellow(` warning: skill '${name}' was removed from the framework but you have a local override at .claude/skills/${name}/. It has been left in place.`));
81
- } else {
82
- // It's a symlink to a now-missing target — clean it up
89
+ const isRealDir = await isRealDirectory(skillPath);
90
+ if (!isRealDir) {
91
+ // Symlink to now-missing target clean it up
83
92
  if (!dryRun) {
84
93
  await unlink(skillPath);
85
94
  }
86
- console.log(` Removed broken symlink: ${name}`);
95
+ console.log(` Removed stale symlink: ${name}`);
96
+ } else {
97
+ console.log(chalk.yellow(` warning: '${name}' removed from framework. Local directory at .claude/skills/${name}/ left in place.`));
87
98
  }
88
99
  }
89
100
  }
90
101
  }
91
102
 
103
+ // Always run legacy cleanup — catches dirs that survived the diff-based migration
104
+ await runCleanup(skillsDir, [...newSkills], projectRoot, dryRun);
105
+
106
+ // Scaffold arch/references/ and arch/templates/ if missing
107
+ if (!dryRun) {
108
+ const archDir = join(projectRoot, '.claude', 'skills', 'arch');
109
+ await mkdir(join(archDir, 'references'), { recursive: true });
110
+ await mkdir(join(archDir, 'templates', 'page'), { recursive: true });
111
+ await mkdir(join(archDir, 'templates', 'component'), { recursive: true });
112
+ await mkdir(join(archDir, 'templates', 'guide'), { recursive: true });
113
+ }
114
+
92
115
  // Print changelog for the updated range
93
116
  const changelogPath = join(frameworkDir, 'CHANGELOG.md');
94
117
  if (await exists(changelogPath)) {
@@ -100,6 +123,12 @@ export async function update(options) {
100
123
  }
101
124
  }
102
125
 
126
+ // Update version in project-config.md
127
+ if (!dryRun) {
128
+ const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
129
+ await updateProjectConfigVersion(configPath, targetVersion);
130
+ }
131
+
103
132
  if (!dryRun) {
104
133
  console.log(chalk.green(`\nUpdated to v${targetVersion}.`));
105
134
  } else {
@@ -107,15 +136,28 @@ export async function update(options) {
107
136
  }
108
137
  }
109
138
 
139
+ async function runCleanup(skillsDir, manifestSkillNames, projectRoot, dryRun) {
140
+ const { cleaned, errors } = await cleanupLegacyDirectories(skillsDir, manifestSkillNames, projectRoot, { dryRun });
141
+ if (cleaned.length > 0) {
142
+ console.log(chalk.green(` Cleaned up ${cleaned.length} legacy director${cleaned.length === 1 ? 'y' : 'ies'}: ${cleaned.join(', ')}`));
143
+ }
144
+ for (const e of errors) console.log(chalk.yellow(` warning: ${e}`));
145
+ }
146
+
110
147
  /**
111
- * Resolve the latest semver tag from the installed framework's git history.
148
+ * Resolve the latest semver tag from the installed framework.
112
149
  */
113
- async function resolveLatestTag(frameworkDir) {
150
+ async function resolveLatestTag(frameworkDir, isSubmodule) {
114
151
  try {
115
152
  const git = simpleGit(frameworkDir);
116
153
  return await getLatestTag(git);
117
154
  } catch {
118
- throw new Error('Could not determine latest framework version. Use --to <version> to specify one.');
155
+ // Shallow clone or no local tags query remote directly
156
+ try {
157
+ return await getLatestTagFromRemote();
158
+ } catch {
159
+ throw new Error('Could not determine latest framework version. Use --to <version> to specify one.');
160
+ }
119
161
  }
120
162
  }
121
163
 
@@ -14,5 +14,6 @@ export async function cloneFramework(targetDir, tag) {
14
14
  await mkdir(targetDir, { recursive: true });
15
15
 
16
16
  const git = simpleGit();
17
- await git.clone(FRAMEWORK_REPO, targetDir, ['--depth', '1', ...(tag ? ['--branch', tag] : [])]);
17
+ const gitTag = tag ? (tag.startsWith('v') ? tag : `v${tag}`) : undefined;
18
+ await git.clone(FRAMEWORK_REPO, targetDir, ['--depth', '1', ...(gitTag ? ['--branch', gitTag] : [])]);
18
19
  }
@@ -5,7 +5,7 @@ const FRAMEWORK_REPO = 'https://github.com/scriswell/cuewright';
5
5
  /**
6
6
  * Add the framework as a git submodule.
7
7
  * If tag is provided, checks out that tag after adding.
8
- * Tag may be bare (0.14.33) or prefixed (v0.14.33) — normalized internally.
8
+ * Tag may be bare (0.15.0) or prefixed (v0.15.0) — normalized internally.
9
9
  */
10
10
  export async function addSubmodule(projectRoot, submodulePath, tag) {
11
11
  const git = simpleGit(projectRoot);
@@ -23,20 +23,27 @@ export async function addSubmodule(projectRoot, submodulePath, tag) {
23
23
 
24
24
  /**
25
25
  * Update an existing submodule to a specific tag (or latest if no tag given).
26
- * "Latest" means the most recent semver tag on the remote.
26
+ * Caller is responsible for fetching tags before calling this.
27
27
  */
28
28
  export async function updateSubmodule(projectRoot, submodulePath, tag) {
29
29
  const subGit = simpleGit(`${projectRoot}/${submodulePath}`);
30
-
31
- await subGit.fetch(['--tags']);
32
-
33
30
  const gitTag = tag ? toGitTag(tag) : toGitTag(await getLatestTag(subGit));
34
31
  await subGit.checkout(gitTag);
35
32
  }
36
33
 
34
+ /**
35
+ * Fetch all tags from the remote into the submodule's local repo.
36
+ * Call this before resolveLatestTag to ensure local tags are current.
37
+ */
38
+ export async function fetchTags(frameworkDir) {
39
+ const git = simpleGit(frameworkDir);
40
+ await git.fetch(['--tags']);
41
+ }
42
+
37
43
  /**
38
44
  * Return the latest semver tag as a bare version string (no leading v).
39
- * e.g. "0.14.33" not "v0.14.33"
45
+ * e.g. "0.15.0" not "v0.15.0"
46
+ * Uses local tags — call fetchTags() first if stale.
40
47
  */
41
48
  export async function getLatestTag(git) {
42
49
  const result = await git.tags(['--sort=-version:refname']);
@@ -47,9 +54,42 @@ export async function getLatestTag(git) {
47
54
  return semverTags[0].replace(/^v/, '');
48
55
  }
49
56
 
57
+ /**
58
+ * Resolve the latest semver tag directly from the remote.
59
+ * Fallback for copy-mode installs where local tags are unavailable.
60
+ */
61
+ export async function getLatestTagFromRemote() {
62
+ const git = simpleGit();
63
+ const result = await git.listRemote(['--tags', '--sort=-version:refname', FRAMEWORK_REPO]);
64
+ const tags = result.split('\n')
65
+ .map(line => line.match(/refs\/tags\/(v\d+\.\d+\.\d+)$/))
66
+ .filter(Boolean)
67
+ .map(m => m[1]);
68
+ if (tags.length === 0) throw new Error('No semver tags found on remote');
69
+ return tags[0].replace(/^v/, '');
70
+ }
71
+
72
+ /**
73
+ * Configure sparse checkout on a submodule to include only the directories
74
+ * that consuming projects need: skills/, targets/, core/, plus key root files.
75
+ * Excludes docs/, .github/, CHANGELOG.md, etc.
76
+ */
77
+ export async function configureSparseCheckout(submoduleAbsPath) {
78
+ const git = simpleGit(submoduleAbsPath);
79
+ await git.raw(['sparse-checkout', 'init', '--no-cone']);
80
+ await git.raw(['sparse-checkout', 'set',
81
+ 'skills/',
82
+ 'targets/',
83
+ 'core/',
84
+ 'manifest.json',
85
+ 'LICENSE',
86
+ 'README.md',
87
+ ]);
88
+ }
89
+
50
90
  /**
51
91
  * Normalize a version string to a git tag with v prefix.
52
- * "0.14.33" → "v0.14.33", "v0.14.33" → "v0.14.33"
92
+ * "0.15.0" → "v0.15.0", "v0.15.0" → "v0.15.0"
53
93
  */
54
94
  function toGitTag(version) {
55
95
  return version.startsWith('v') ? version : `v${version}`;
@@ -0,0 +1,25 @@
1
+ import { readFile, writeFile, access } from 'fs/promises';
2
+
3
+ /**
4
+ * Update the Framework Version line in arch/project-config.md.
5
+ * Looks for a markdown table row like: | Framework Version | v0.8.0 |
6
+ * Updates the version value in place. No-ops if the file doesn't exist
7
+ * or doesn't contain a recognizable version line.
8
+ */
9
+ export async function updateProjectConfigVersion(configPath, newVersion) {
10
+ try {
11
+ await access(configPath);
12
+ } catch {
13
+ return; // No project-config — nothing to update
14
+ }
15
+
16
+ const content = await readFile(configPath, 'utf8');
17
+ const updated = content.replace(
18
+ /^(\|\s*(?:Framework\s+Version|framework_version)\s*\|\s*)v?\d+\.\d+\.\d+(\s*\|)/im,
19
+ `$1v${newVersion}$2`
20
+ );
21
+
22
+ if (updated !== content) {
23
+ await writeFile(configPath, updated, 'utf8');
24
+ }
25
+ }
@@ -1,15 +1,13 @@
1
- import { symlink, mkdir, access, lstat, readFile, writeFile } from 'fs/promises';
1
+ import { symlink, mkdir, access, lstat, readFile, writeFile, readdir, unlink, rename } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
 
4
4
  /**
5
5
  * Ensure all skills in the manifest are wired into the skills discovery dir
6
6
  * using directory symlinks.
7
7
  *
8
- * For each skill name:
9
- * - If a real directory already exists at <skillsDir>/<name>: skip (override preserved).
10
- * - If a valid symlink already exists: skip.
11
- * - If a broken symlink exists: re-create it.
12
- * - Otherwise: create a directory symlink pointing to <frameworkDir>/skills/<name>.
8
+ * Framework skills use the cw- prefix and are always symlinks — no local
9
+ * directory overrides. If a real directory is found at a cw-* path, a warning
10
+ * is emitted and the entry is skipped.
13
11
  *
14
12
  * Returns a summary: { wired: string[], skipped: string[], errors: string[] }
15
13
  */
@@ -36,7 +34,8 @@ export async function syncSymlinks(frameworkDir, skillsDir, skillNames) {
36
34
  wired.push(name);
37
35
  }
38
36
  } else if (stat.isDirectory()) {
39
- skipped.push(name); // real directory overridenever touch
37
+ // Real directory at a cw-* path should not exist
38
+ errors.push(`${name}: real directory found — framework skills cannot be overridden locally. Remove .claude/skills/${name}/ to restore the framework version.`);
40
39
  } else {
41
40
  errors.push(`${name}: unexpected file at ${linkPath}`);
42
41
  }
@@ -56,10 +55,9 @@ export async function syncSymlinks(frameworkDir, skillsDir, skillNames) {
56
55
  * Copies <frameworkDir>/skills/<name>/SKILL.md → <skillsDir>/<name>/SKILL.md,
57
56
  * prepending an auto-generated header so consumers know not to edit the file directly.
58
57
  *
59
- * For each skill name:
60
- * - If a real directory exists at <skillsDir>/<name> WITHOUT the auto-gen header: skip (override).
61
- * - If a copy with the header already exists: skip (already wired).
62
- * - Otherwise: create <skillsDir>/<name>/ and write SKILL.md with header.
58
+ * Framework skills (cw-* prefix) are always auto-generated copies. If a copy
59
+ * already exists with the auto-generated header, it is refreshed. If a real
60
+ * override without the header is found, an error is emitted.
63
61
  *
64
62
  * Returns a summary: { wired: string[], skipped: string[], errors: string[] }
65
63
  */
@@ -78,20 +76,19 @@ export async function syncCopies(frameworkDir, skillsDir, skillNames, version) {
78
76
  try {
79
77
  const stat = await lstat(destDir);
80
78
  if (stat.isDirectory()) {
81
- // Check if it's an auto-generated copy or a real override
82
79
  let existingContent = '';
83
80
  try {
84
81
  existingContent = await readFile(destFile, 'utf8');
85
82
  } catch {
86
- // No SKILL.md treat as override directory
87
- skipped.push(name);
83
+ // No SKILL.md in a cw-* dir — unexpected
84
+ errors.push(`${name}: real directory without SKILL.md — framework skills cannot be overridden locally.`);
88
85
  continue;
89
86
  }
90
87
 
91
88
  if (existingContent.startsWith('<!-- AUTO-GENERATED')) {
92
89
  skipped.push(name); // already wired as a copy
93
90
  } else {
94
- skipped.push(name); // real overridenever touch
91
+ errors.push(`${name}: real directory with custom SKILL.md found framework skills cannot be overridden locally. Remove .claude/skills/${name}/ to restore the framework version.`);
95
92
  }
96
93
  continue;
97
94
  }
@@ -120,7 +117,6 @@ export function buildHeader(skillName, version) {
120
117
  return [
121
118
  `<!-- AUTO-GENERATED: Copied from _framework/skills/${skillName}/SKILL.md (v${version}) -->`,
122
119
  `<!-- Do not edit directly. Run \`npx @cuewright/skills update\` to refresh. -->`,
123
- `<!-- To override: delete this file and create your own SKILL.md in this directory. -->`,
124
120
  '',
125
121
  '',
126
122
  ].join('\n');
@@ -133,3 +129,143 @@ export function buildHeader(skillName, version) {
133
129
  export function platformSupportsSymlinks() {
134
130
  return process.platform !== 'win32';
135
131
  }
132
+
133
+ /**
134
+ * Remove old unprefixed skill directories that correspond to cw-* framework skills.
135
+ *
136
+ * This runs independently of the manifest diff, so it catches directories that
137
+ * survived the initial migration (e.g., rmdir failed due to non-empty content)
138
+ * as well as directories from even older installs that were never in a manifest.
139
+ *
140
+ * For each cw-* skill in manifestSkillNames, checks for a real directory or
141
+ * symlink at skillsDir/<unprefixed-name>/ and cleans it up:
142
+ * - Symlink: unlinked (was an old framework pointer)
143
+ * - Real directory: reference files moved to arch/references/<name>/, SKILL.md
144
+ * removed, directory rmdir'd
145
+ *
146
+ * Returns { cleaned: string[], errors: string[] }
147
+ */
148
+ export async function cleanupLegacyDirectories(skillsDir, manifestSkillNames, projectRoot, { dryRun = false } = {}) {
149
+ const cleaned = [];
150
+ const errors = [];
151
+ const { join } = await import('path');
152
+ const { rmdir } = await import('fs/promises');
153
+
154
+ for (const cwName of manifestSkillNames) {
155
+ if (!cwName.startsWith('cw-')) continue;
156
+ const unprefixed = cwName.slice(3);
157
+ const legacyPath = join(skillsDir, unprefixed);
158
+
159
+ let stat;
160
+ try {
161
+ stat = await lstat(legacyPath);
162
+ } catch {
163
+ continue; // Does not exist — nothing to do
164
+ }
165
+
166
+ if (stat.isSymbolicLink()) {
167
+ if (!dryRun) {
168
+ try {
169
+ await unlink(legacyPath);
170
+ } catch (err) {
171
+ errors.push(`${unprefixed}: could not remove symlink — ${err.message}`);
172
+ continue;
173
+ }
174
+ }
175
+ cleaned.push(unprefixed);
176
+ continue;
177
+ }
178
+
179
+ if (!stat.isDirectory()) continue;
180
+
181
+ // Real directory — migrate reference files, then remove
182
+ let entries;
183
+ try {
184
+ entries = await readdir(legacyPath);
185
+ } catch (err) {
186
+ errors.push(`${unprefixed}: could not read directory — ${err.message}`);
187
+ continue;
188
+ }
189
+
190
+ const referenceFiles = entries.filter(e => e !== 'SKILL.md');
191
+
192
+ if (referenceFiles.length > 0 && !dryRun) {
193
+ const refDir = join(projectRoot, '.claude', 'skills', 'arch', 'references', unprefixed);
194
+ try {
195
+ await mkdir(refDir, { recursive: true });
196
+ for (const file of referenceFiles) {
197
+ await rename(join(legacyPath, file), join(refDir, file));
198
+ }
199
+ } catch (err) {
200
+ errors.push(`${unprefixed}: could not migrate reference files — ${err.message}`);
201
+ continue;
202
+ }
203
+ }
204
+
205
+ if (!dryRun) {
206
+ try {
207
+ await unlink(join(legacyPath, 'SKILL.md'));
208
+ } catch {
209
+ // Already gone or never existed — fine
210
+ }
211
+
212
+ try {
213
+ await rmdir(legacyPath);
214
+ } catch {
215
+ errors.push(`${unprefixed}: directory not empty after migration — remove .claude/skills/${unprefixed}/ manually`);
216
+ continue;
217
+ }
218
+ }
219
+
220
+ cleaned.push(unprefixed);
221
+ }
222
+
223
+ return { cleaned, errors };
224
+ }
225
+
226
+ /**
227
+ * Scan skillsDir and categorize every entry.
228
+ *
229
+ * Returns:
230
+ * {
231
+ * legacy: string[], // unprefixed dirs matching a cw-* manifest skill
232
+ * orphaned: string[], // dirs that don't match any manifest skill or reserved name
233
+ * }
234
+ *
235
+ * Reserved names (skipped): _framework, _baseline, arch, worktrees
236
+ * Framework skills (cw-*) are not included in the return — they're already
237
+ * checked by the main doctor health checks.
238
+ */
239
+ export async function inventorySkillsDir(skillsDir, manifestSkillNames) {
240
+ const { join } = await import('path');
241
+ const legacy = [];
242
+ const orphaned = [];
243
+
244
+ const RESERVED = new Set(['_framework', '_baseline', 'arch', 'worktrees']);
245
+ const unprefixedNames = new Set(
246
+ manifestSkillNames.filter(n => n.startsWith('cw-')).map(n => n.slice(3))
247
+ );
248
+ const cwNames = new Set(manifestSkillNames);
249
+
250
+ let entries;
251
+ try {
252
+ entries = await readdir(skillsDir, { withFileTypes: true });
253
+ } catch {
254
+ return { legacy, orphaned };
255
+ }
256
+
257
+ for (const entry of entries) {
258
+ const name = entry.name;
259
+ if (RESERVED.has(name)) continue;
260
+ if (cwNames.has(name)) continue; // framework skill — handled by main checks
261
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
262
+
263
+ if (unprefixedNames.has(name)) {
264
+ legacy.push(name);
265
+ } else {
266
+ orphaned.push(name);
267
+ }
268
+ }
269
+
270
+ return { legacy, orphaned };
271
+ }