@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 +1 -1
- package/src/commands/doctor.js +34 -2
- package/src/commands/init.js +15 -1
- package/src/commands/update.js +61 -19
- package/src/storage/copy.js +2 -1
- package/src/storage/submodule.js +47 -7
- package/src/utils/project-config.js +25 -0
- package/src/utils/symlink.js +152 -16
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -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)) {
|
package/src/commands/init.js
CHANGED
|
@@ -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);
|
package/src/commands/update.js
CHANGED
|
@@ -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,
|
|
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 = (
|
|
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)
|
|
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
|
-
|
|
67
|
-
await syncCopies(frameworkDir, skillsDir, added, targetVersion)
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
79
|
-
if (
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/storage/copy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/storage/submodule.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/utils/symlink.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
87
|
-
|
|
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
|
-
|
|
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
|
+
}
|