@cuewright/skills 0.2.0 → 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.2.0",
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() {
@@ -75,32 +76,29 @@ export async function doctor() {
75
76
  }
76
77
  }
77
78
 
78
- // 4b. Check for legacy unprefixed skill directories (old install, needs migration)
79
- const legacySkillNames = skills.map(s => s.replace(/^cw-/, ''));
80
- const legacyFound = [];
81
- for (const name of legacySkillNames) {
82
- const legacyPath = join(skillsDir, name);
83
- try {
84
- const stat = await lstat(legacyPath);
85
- if (stat.isDirectory()) {
86
- legacyFound.push(name);
87
- }
88
- } catch {
89
- // Not present — good
90
- }
91
- }
79
+ // 4b. Inventory skills directory detect legacy and orphaned entries
80
+ const { legacy: legacyFound, orphaned: orphanedFound } = await inventorySkillsDir(skillsDir, skills);
92
81
 
93
82
  if (legacyFound.length > 0) {
94
83
  allPassed = false;
95
84
  checks.push(fail(
96
85
  `${legacyFound.length} legacy skill director${legacyFound.length === 1 ? 'y' : 'ies'} found (pre-v0.15.0 install)`,
97
- 'Run `cuewright update` to migrate to cw- prefix and move reference files to arch/references/.'
86
+ 'Run `cuewright update` to clean up automatically.'
98
87
  ));
99
88
  for (const name of legacyFound) {
100
89
  checks.push(fail(` .claude/skills/${name}/`, null));
101
90
  }
102
91
  }
103
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
+
104
102
  // 5. arch/project-config.md exists
105
103
  const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
106
104
  if (!await exists(configPath)) {
@@ -1,9 +1,9 @@
1
1
  import chalk from 'chalk';
2
- import { access, readFile, unlink, readdir, rename, mkdir } 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';
6
+ import { syncSymlinks, syncCopies, platformSupportsSymlinks, cleanupLegacyDirectories } from '../utils/symlink.js';
7
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';
@@ -43,7 +43,8 @@ export async function update(options) {
43
43
  const targetVersion = (options.to || await resolveLatestTag(frameworkDir, isSubmodule)).replace(/^v/, '');
44
44
 
45
45
  if (targetVersion === oldVersion) {
46
- 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);
47
48
  return;
48
49
  }
49
50
 
@@ -74,11 +75,10 @@ export async function update(options) {
74
75
  console.log(`New skills: ${added.join(', ')}`);
75
76
  if (!dryRun) {
76
77
  const useCopies = !platformSupportsSymlinks();
77
- if (useCopies) {
78
- await syncCopies(frameworkDir, skillsDir, added, targetVersion);
79
- } else {
80
- await syncSymlinks(frameworkDir, skillsDir, added);
81
- }
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}`));
82
82
  }
83
83
  }
84
84
 
@@ -87,29 +87,22 @@ export async function update(options) {
87
87
  const skillPath = join(skillsDir, name);
88
88
  if (await exists(skillPath)) {
89
89
  const isRealDir = await isRealDirectory(skillPath);
90
- if (isRealDir) {
91
- // Check if this is a cw- rename migration (old unprefixed name → cw-name)
92
- const isMigrationRename = newSkills.has(`cw-${name}`);
93
- if (isMigrationRename) {
94
- if (!dryRun) {
95
- await migrateSkillDirectory(skillPath, name, skillsDir, projectRoot);
96
- } else {
97
- console.log(chalk.yellow(` migrate: ${name}/ → arch/references/${name}/ (reference files) + remove SKILL.md`));
98
- }
99
- } else {
100
- console.log(chalk.yellow(` warning: '${name}' removed from framework. Local directory at .claude/skills/${name}/ left in place.`));
101
- }
102
- } else {
90
+ if (!isRealDir) {
103
91
  // Symlink to now-missing target — clean it up
104
92
  if (!dryRun) {
105
93
  await unlink(skillPath);
106
94
  }
107
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.`));
108
98
  }
109
99
  }
110
100
  }
111
101
  }
112
102
 
103
+ // Always run legacy cleanup — catches dirs that survived the diff-based migration
104
+ await runCleanup(skillsDir, [...newSkills], projectRoot, dryRun);
105
+
113
106
  // Scaffold arch/references/ and arch/templates/ if missing
114
107
  if (!dryRun) {
115
108
  const archDir = join(projectRoot, '.claude', 'skills', 'arch');
@@ -143,48 +136,12 @@ export async function update(options) {
143
136
  }
144
137
  }
145
138
 
146
- /**
147
- * Migrate an old skill directory during the cw- rename:
148
- * - Move any non-SKILL.md files to arch/references/<name>/
149
- * - Remove the SKILL.md (it was a framework copy, overrides are gone)
150
- * - Remove the now-empty directory
151
- */
152
- async function migrateSkillDirectory(skillPath, name, skillsDir, projectRoot) {
153
- const archReferencesDir = join(projectRoot, '.claude', 'skills', 'arch', 'references', name);
154
-
155
- let entries;
156
- try {
157
- entries = await readdir(skillPath);
158
- } catch {
159
- return;
160
- }
161
-
162
- const referenceFiles = entries.filter(e => e !== 'SKILL.md');
163
-
164
- if (referenceFiles.length > 0) {
165
- await mkdir(archReferencesDir, { recursive: true });
166
- for (const file of referenceFiles) {
167
- const src = join(skillPath, file);
168
- const dest = join(archReferencesDir, file);
169
- await rename(src, dest);
170
- }
171
- console.log(` Moved ${referenceFiles.length} reference file(s) to arch/references/${name}/`);
172
- }
173
-
174
- // Remove SKILL.md and the directory
175
- try {
176
- await unlink(join(skillPath, 'SKILL.md'));
177
- } catch {
178
- // Already gone or never existed
179
- }
180
-
181
- try {
182
- const { rmdir } = await import('fs/promises');
183
- await rmdir(skillPath);
184
- console.log(` Removed legacy skill directory: ${name}/`);
185
- } catch {
186
- console.log(chalk.yellow(` warning: Could not remove .claude/skills/${name}/ — remove it manually.`));
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(', ')}`));
187
143
  }
144
+ for (const e of errors) console.log(chalk.yellow(` warning: ${e}`));
188
145
  }
189
146
 
190
147
  /**
@@ -1,4 +1,4 @@
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
  /**
@@ -129,3 +129,143 @@ export function buildHeader(skillName, version) {
129
129
  export function platformSupportsSymlinks() {
130
130
  return process.platform !== 'win32';
131
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
+ }