@cuewright/skills 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott Criswell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @cuewright/skills
2
+
3
+ CLI installer for the [Cuewright skills framework](https://github.com/scriswell/cuewright).
4
+
5
+ Install the framework into any project in one command — no manual submodule wiring, no shell loops, no symlink errors on Windows.
6
+
7
+ > **Pre-public note:** While the framework repo is private, `cuewright init` requires GitHub credentials (SSH key or GitHub token). Anyone with read access to `scriswell/cuewright` can use the CLI today.
8
+
9
+ ## Quick start
10
+
11
+ ```bash
12
+ npx @cuewright/skills init
13
+ ```
14
+
15
+ Then follow with `/align-project-skills` in Claude Code to complete setup.
16
+
17
+ ## Commands
18
+
19
+ ### `cuewright init`
20
+
21
+ Installs the Cuewright framework into the current project.
22
+
23
+ ```bash
24
+ npx @cuewright/skills init [options]
25
+ ```
26
+
27
+ **What it does:**
28
+ 1. Clones the framework as a git submodule at `.claude/skills/_framework/`
29
+ 2. Creates skill symlinks at `.claude/skills/<name>/` for each skill in the manifest
30
+ 3. Scaffolds `arch/project-config.md` from the framework template
31
+ 4. Creates `arch/fragments/project/`
32
+
33
+ **Flags:**
34
+
35
+ | Flag | Description |
36
+ |------|-------------|
37
+ | `--tag <version>` | Pin to a specific framework version (default: latest) |
38
+ | `--no-submodule` | Clone as a plain directory instead of a git submodule |
39
+ | `--copy` | Force copy mode — creates file copies instead of symlinks (auto-enabled on Windows) |
40
+ | `--symlink` | Force symlink mode — exits with an error on Windows if symlinks are unavailable |
41
+
42
+ **Existing project adoption:**
43
+ If `.claude/skills/_framework/manifest.json` already exists (manual install), `init` adopts the installation without re-cloning. It syncs any missing symlinks and scaffolds the config if absent.
44
+
45
+ ---
46
+
47
+ ### `cuewright update`
48
+
49
+ Updates the framework to the latest version (or a specified version).
50
+
51
+ ```bash
52
+ npx @cuewright/skills update [options]
53
+ ```
54
+
55
+ **What it does:**
56
+ - Bumps the submodule pointer (or replaces the directory in copy mode)
57
+ - Detects added/removed skills and syncs wiring accordingly
58
+ - Warns if a removed skill has a local override directory
59
+ - Prints the changelog for the updated version range
60
+
61
+ **Flags:**
62
+
63
+ | Flag | Description |
64
+ |------|-------------|
65
+ | `--to <version>` | Target version (default: latest) |
66
+ | `--dry-run` | Show what would change without making changes |
67
+
68
+ Works on projects installed manually — detects existing installations and adopts them.
69
+
70
+ ---
71
+
72
+ ### `cuewright doctor`
73
+
74
+ Verifies the installation is healthy.
75
+
76
+ ```bash
77
+ npx @cuewright/skills doctor
78
+ ```
79
+
80
+ **Example output:**
81
+ ```
82
+ ✓ Framework installed at v0.14.33
83
+ ✓ 25/25 skills wired
84
+ ✓ arch/project-config.md found
85
+ ✓ Target lit-browser found
86
+ ```
87
+
88
+ Exits 0 if all checks pass, 1 if any fail. Each failure includes a fix suggestion.
89
+
90
+ ---
91
+
92
+ ### `cuewright status`
93
+
94
+ Shows a snapshot of the current installation.
95
+
96
+ ```bash
97
+ npx @cuewright/skills status [--json]
98
+ ```
99
+
100
+ **Example output:**
101
+ ```
102
+ Cuewright v0.14.33
103
+ Target: lit-browser
104
+ Skills: 25 total · 2 overridden · 0 from baseline
105
+
106
+ Governance:
107
+ CSS Enforcement
108
+ Accessibility Remediation
109
+ Security Discovery
110
+ ```
111
+
112
+ `--json` outputs machine-readable JSON for scripting.
113
+
114
+ ---
115
+
116
+ ## Windows support
117
+
118
+ On Windows, directory symlinks require Developer Mode or admin privileges. The CLI detects this automatically and uses copy mode instead. Copied `SKILL.md` files include a header marking them as auto-generated so `update` knows which files to refresh.
119
+
120
+ To force copy mode on any platform: `--copy`.
121
+
122
+ ## Manual installation
123
+
124
+ Teams that cannot use Node.js can still install the framework manually. See the [Cuewright adoption guide](https://github.com/scriswell/cuewright/blob/main/docs/adoption-guide.md#manual-installation) for the shell commands.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { createRequire } from 'module';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const require = createRequire(import.meta.url);
10
+ const { version } = require('../package.json');
11
+
12
+ program
13
+ .name('cuewright')
14
+ .description('CLI installer for the Cuewright skills framework')
15
+ .version(version);
16
+
17
+ program
18
+ .command('init')
19
+ .description('Install the Cuewright framework into this project')
20
+ .option('--tag <version>', 'pin to a specific framework version (default: latest)')
21
+ .option('--no-submodule', 'copy framework content instead of adding a git submodule')
22
+ .option('--copy', 'force copy mode (auto-enabled on Windows)')
23
+ .option('--symlink', 'force symlink mode (may fail on Windows without developer mode)')
24
+ .option('--model <models>', 'AI model adapter(s) to wire (default: claude)', 'claude')
25
+ .action(async (options) => {
26
+ const { init } = await import('../src/commands/init.js');
27
+ await init(options);
28
+ });
29
+
30
+ program
31
+ .command('update')
32
+ .description('Update the framework to the latest or a specified version')
33
+ .option('--to <version>', 'target version (default: latest)')
34
+ .option('--dry-run', 'show what would change without making changes')
35
+ .action(async (options) => {
36
+ const { update } = await import('../src/commands/update.js');
37
+ await update(options);
38
+ });
39
+
40
+ program
41
+ .command('doctor')
42
+ .description('Verify the framework installation is healthy')
43
+ .action(async () => {
44
+ const { doctor } = await import('../src/commands/doctor.js');
45
+ await doctor();
46
+ });
47
+
48
+ program
49
+ .command('status')
50
+ .description('Show installed version, skill count, and active target')
51
+ .option('--json', 'output as JSON')
52
+ .action(async (options) => {
53
+ const { status } = await import('../src/commands/status.js');
54
+ await status(options);
55
+ });
56
+
57
+ program
58
+ .command('add-baseline')
59
+ .description('Wire a team baseline as a second submodule')
60
+ .argument('<repo-url>', 'URL of the baseline repository')
61
+ .action(async (repoUrl) => {
62
+ const { addBaseline } = await import('../src/commands/add-baseline.js');
63
+ await addBaseline(repoUrl);
64
+ });
65
+
66
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@cuewright/skills",
3
+ "version": "0.1.0",
4
+ "description": "CLI installer for the Cuewright skills framework",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "bin": {
10
+ "cuewright": "bin/cuewright.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/"
15
+ ],
16
+ "scripts": {
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "dependencies": {
22
+ "chalk": "^5.3.0",
23
+ "commander": "^12.0.0",
24
+ "simple-git": "^3.22.0"
25
+ },
26
+ "devDependencies": {
27
+ "execa": "^9.6.1",
28
+ "vitest": "^1.4.0"
29
+ },
30
+ "keywords": [
31
+ "cuewright",
32
+ "claude",
33
+ "skills",
34
+ "ai",
35
+ "governance"
36
+ ],
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/scriswell/cuewright-cli.git"
41
+ },
42
+ "homepage": "https://github.com/scriswell/cuewright-cli#readme"
43
+ }
@@ -0,0 +1,23 @@
1
+ import { join } from 'path';
2
+
3
+ /**
4
+ * Return the Claude Code skills discovery directory for a project.
5
+ * Claude Code discovers skills at: <projectRoot>/.claude/skills/<name>/SKILL.md
6
+ */
7
+ export function getSkillsDir(projectRoot) {
8
+ return join(projectRoot, '.claude', 'skills');
9
+ }
10
+
11
+ /**
12
+ * Return the framework installation path for a project.
13
+ */
14
+ export function getFrameworkDir(projectRoot) {
15
+ return join(projectRoot, '.claude', 'skills', '_framework');
16
+ }
17
+
18
+ /**
19
+ * Return the submodule path (relative to project root) used when adding the submodule.
20
+ */
21
+ export function getSubmodulePath() {
22
+ return '.claude/skills/_framework';
23
+ }
@@ -0,0 +1,5 @@
1
+ // Issue (Phase 2): add-baseline command
2
+
3
+ export async function addBaseline(repoUrl) {
4
+ throw new Error('add-baseline command not yet implemented');
5
+ }
@@ -0,0 +1,225 @@
1
+ import chalk from 'chalk';
2
+ import { access, readFile, lstat } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { cwd } from 'process';
5
+ import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
+ import { getSkillsDir, getFrameworkDir } from '../adapters/claude.js';
7
+
8
+ export async function doctor() {
9
+ const projectRoot = cwd();
10
+ const frameworkDir = getFrameworkDir(projectRoot);
11
+ const skillsDir = getSkillsDir(projectRoot);
12
+
13
+ let allPassed = true;
14
+ const checks = [];
15
+
16
+ // 1. Framework directory exists
17
+ if (!await exists(frameworkDir)) {
18
+ checks.push(fail('Framework not found at .claude/skills/_framework/', 'Run `cuewright init` to install it.'));
19
+ allPassed = false;
20
+ printChecks(checks);
21
+ process.exit(1);
22
+ }
23
+
24
+ // 2. manifest.json exists and is valid JSON
25
+ let manifest;
26
+ try {
27
+ manifest = await readManifest(frameworkDir);
28
+ checks.push(pass(`Framework installed at v${getVersion(manifest)}`));
29
+ } catch (err) {
30
+ checks.push(fail('manifest.json missing or invalid', `Check .claude/skills/_framework/manifest.json — ${err.message}`));
31
+ allPassed = false;
32
+ printChecks(checks);
33
+ process.exit(1);
34
+ }
35
+
36
+ // 3 & 4. Each skill wired and symlink targets valid
37
+ const skills = listSkills(manifest);
38
+ let wiredCount = 0;
39
+ const brokenLinks = [];
40
+
41
+ for (const name of skills) {
42
+ const skillPath = join(skillsDir, name);
43
+ if (!await exists(skillPath)) {
44
+ brokenLinks.push({ name, reason: 'missing' });
45
+ } else {
46
+ try {
47
+ const stat = await lstat(skillPath);
48
+ if (stat.isSymbolicLink()) {
49
+ // Check the symlink target is valid
50
+ try {
51
+ await access(skillPath);
52
+ wiredCount++;
53
+ } catch {
54
+ brokenLinks.push({ name, reason: 'broken symlink' });
55
+ }
56
+ } else {
57
+ wiredCount++; // real directory override — counts as wired
58
+ }
59
+ } catch {
60
+ brokenLinks.push({ name, reason: 'stat error' });
61
+ }
62
+ }
63
+ }
64
+
65
+ if (brokenLinks.length === 0) {
66
+ checks.push(pass(`${wiredCount}/${skills.length} skills wired`));
67
+ } else {
68
+ allPassed = false;
69
+ checks.push(fail(
70
+ `${wiredCount}/${skills.length} skills wired (${brokenLinks.length} broken)`,
71
+ 'Run `cuewright update` to repair broken symlinks.'
72
+ ));
73
+ for (const { name, reason } of brokenLinks) {
74
+ checks.push(fail(` ${name}: ${reason}`, null));
75
+ }
76
+ }
77
+
78
+ // 5. arch/project-config.md exists
79
+ const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
80
+ if (!await exists(configPath)) {
81
+ allPassed = false;
82
+ checks.push(fail(
83
+ 'arch/project-config.md not found',
84
+ 'Run `cuewright init` to scaffold it, or create it manually from _framework/targets/_template/project-config-template.md.'
85
+ ));
86
+ } else {
87
+ checks.push(pass('arch/project-config.md found'));
88
+
89
+ // 6. Target named in project-config exists
90
+ const targetName = await extractTargetName(configPath);
91
+ if (targetName) {
92
+ const targetDir = join(frameworkDir, 'targets', targetName);
93
+ const projectTargetDir = join(projectRoot, '.claude', 'skills', 'arch', 'targets', targetName);
94
+
95
+ if (await exists(targetDir) || await exists(projectTargetDir)) {
96
+ checks.push(pass(`Target ${targetName} found`));
97
+ } else {
98
+ allPassed = false;
99
+ const availableTargets = await listAvailableTargets(frameworkDir);
100
+ checks.push(fail(
101
+ `Target '${targetName}' not found`,
102
+ `Update arch/project-config.md with a valid target. Available: ${availableTargets.join(', ')}`
103
+ ));
104
+ }
105
+ } else {
106
+ checks.push(warn('Could not read target name from arch/project-config.md'));
107
+ }
108
+ }
109
+
110
+ // 7. Baseline compatibility check (if baseline present)
111
+ const baselineDir = join(projectRoot, '.claude', 'skills', '_baseline');
112
+ if (await exists(baselineDir)) {
113
+ const baselineJsonPath = join(baselineDir, 'baseline.json');
114
+ if (!await exists(baselineJsonPath)) {
115
+ allPassed = false;
116
+ checks.push(fail('_baseline/baseline.json not found', 'The baseline directory is present but missing its manifest.'));
117
+ } else {
118
+ try {
119
+ const baselineJson = JSON.parse(await readFile(baselineJsonPath, 'utf8'));
120
+ const compat = baselineJson.framework_compatibility;
121
+ const current = getVersion(manifest);
122
+ if (compat && !isCompatible(compat, current)) {
123
+ allPassed = false;
124
+ checks.push(fail(
125
+ `Baseline requires framework ${compat}, installed: v${current}`,
126
+ 'Update the framework (`cuewright update`) or update your baseline.'
127
+ ));
128
+ } else {
129
+ checks.push(pass('Baseline compatible'));
130
+ }
131
+ } catch {
132
+ checks.push(warn('Could not parse _baseline/baseline.json'));
133
+ }
134
+ }
135
+ }
136
+
137
+ printChecks(checks);
138
+ process.exit(allPassed ? 0 : 1);
139
+ }
140
+
141
+ function pass(message) {
142
+ return { ok: true, message };
143
+ }
144
+
145
+ function fail(message, fix) {
146
+ return { ok: false, message, fix };
147
+ }
148
+
149
+ function warn(message) {
150
+ return { ok: null, message };
151
+ }
152
+
153
+ function printChecks(checks) {
154
+ for (const check of checks) {
155
+ if (check.ok === true) {
156
+ console.log(chalk.green('✓') + ' ' + check.message);
157
+ } else if (check.ok === false) {
158
+ console.log(chalk.red('✗') + ' ' + check.message);
159
+ if (check.fix) {
160
+ console.log(' ' + chalk.dim(check.fix));
161
+ }
162
+ } else {
163
+ console.log(chalk.yellow('?') + ' ' + check.message);
164
+ }
165
+ }
166
+ }
167
+
168
+ async function exists(path) {
169
+ try {
170
+ await access(path);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Parse the target name from the Stack table in project-config.md.
179
+ * Looks for a line like: | Target | lit-browser |
180
+ */
181
+ async function extractTargetName(configPath) {
182
+ try {
183
+ const content = await readFile(configPath, 'utf8');
184
+ const match = content.match(/^\|\s*Target\s*\|\s*([a-z0-9-]+)\s*\|/m);
185
+ return match ? match[1] : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * List target names from _framework/targets/ (excluding _template).
193
+ */
194
+ async function listAvailableTargets(frameworkDir) {
195
+ const { readdir } = await import('fs/promises');
196
+ try {
197
+ const entries = await readdir(join(frameworkDir, 'targets'));
198
+ return entries.filter(e => !e.startsWith('_'));
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Very simple semver compatibility check.
206
+ * compat is a string like ">=0.14.0" or "0.14.x".
207
+ * Returns true if current satisfies the constraint.
208
+ */
209
+ function isCompatible(compat, current) {
210
+ const gteMatch = compat.match(/^>=(\d+\.\d+\.\d+)/);
211
+ if (gteMatch) {
212
+ return !isOlder(current, gteMatch[1]);
213
+ }
214
+ // Treat as exact prefix match (e.g. "0.14.x")
215
+ const prefix = compat.replace(/\.x$/, '');
216
+ return current.startsWith(prefix);
217
+ }
218
+
219
+ function isOlder(a, b) {
220
+ const [aMaj, aMin, aPat] = a.split('.').map(Number);
221
+ const [bMaj, bMin, bPat] = b.split('.').map(Number);
222
+ if (aMaj !== bMaj) return aMaj < bMaj;
223
+ if (aMin !== bMin) return aMin < bMin;
224
+ return aPat < bPat;
225
+ }
@@ -0,0 +1,147 @@
1
+ import chalk from 'chalk';
2
+ import { access, mkdir, copyFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { cwd } from 'process';
5
+ import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
+ import { syncSymlinks, syncCopies, platformSupportsSymlinks } from '../utils/symlink.js';
7
+ import { addSubmodule } from '../storage/submodule.js';
8
+ import { cloneFramework } from '../storage/copy.js';
9
+ import { getSkillsDir, getFrameworkDir, getSubmodulePath } from '../adapters/claude.js';
10
+
11
+ export async function init(options) {
12
+ const projectRoot = cwd();
13
+ const frameworkDir = getFrameworkDir(projectRoot);
14
+ const skillsDir = getSkillsDir(projectRoot);
15
+
16
+ // Resolve copy vs symlink mode
17
+ if (options.symlink && !platformSupportsSymlinks()) {
18
+ console.error(chalk.red('Error: symlink mode is not supported on this platform.'));
19
+ console.error('Windows requires Developer Mode or admin privileges for directory symlinks.');
20
+ console.error('Enable Developer Mode in Settings → Privacy & Security → Developer Mode,');
21
+ console.error('or run without --symlink to use copy mode instead.');
22
+ process.exit(1);
23
+ }
24
+
25
+ // Detect existing installation
26
+ const alreadyInstalled = await exists(join(frameworkDir, 'manifest.json'));
27
+
28
+ if (alreadyInstalled) {
29
+ await adoptExisting(projectRoot, frameworkDir, skillsDir, options);
30
+ } else {
31
+ await freshInstall(projectRoot, frameworkDir, skillsDir, options);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Existing project adoption path.
37
+ * Framework is already present (manually installed or prior CLI run).
38
+ * Sync any missing symlinks and scaffold config if absent.
39
+ */
40
+ async function adoptExisting(projectRoot, frameworkDir, skillsDir, options) {
41
+ const manifest = await readManifest(frameworkDir);
42
+ const version = getVersion(manifest);
43
+ const skills = listSkills(manifest);
44
+ const useCopies = resolveCopyMode(options);
45
+
46
+ console.log(chalk.cyan(`Found existing framework installation: v${version}`));
47
+ console.log(`Syncing skill ${useCopies ? 'copies' : 'symlinks'}...`);
48
+
49
+ const { wired, skipped, errors } = useCopies
50
+ ? await syncCopies(frameworkDir, skillsDir, skills, version)
51
+ : await syncSymlinks(frameworkDir, skillsDir, skills);
52
+
53
+ if (errors.length) {
54
+ for (const e of errors) console.log(chalk.yellow(` warning: ${e}`));
55
+ }
56
+
57
+ console.log(chalk.green(` ${wired.length} new symlink(s) created`) + (wired.length ? ': ' + wired.join(', ') : ''));
58
+ console.log(` ${skipped.length} skill(s) already wired or overridden`);
59
+
60
+ await scaffoldConfigIfMissing(projectRoot, frameworkDir);
61
+
62
+ console.log(chalk.green('\nDone. Framework adopted.'));
63
+ }
64
+
65
+ /**
66
+ * Fresh install path.
67
+ * Clones or adds the framework, wires symlinks, scaffolds config.
68
+ */
69
+ async function freshInstall(projectRoot, frameworkDir, skillsDir, options) {
70
+ const useSubmodule = options.submodule !== false && !options.copy;
71
+ const useCopies = resolveCopyMode(options);
72
+ const tag = options.tag || null;
73
+
74
+ console.log(chalk.cyan(`Installing Cuewright framework${tag ? ` @ ${tag}` : ' (latest)'}...`));
75
+
76
+ if (useSubmodule) {
77
+ console.log('Adding git submodule...');
78
+ await addSubmodule(projectRoot, getSubmodulePath(), tag);
79
+ } else {
80
+ console.log('Cloning framework (copy mode)...');
81
+ await cloneFramework(frameworkDir, tag);
82
+ }
83
+
84
+ const manifest = await readManifest(frameworkDir);
85
+ const version = getVersion(manifest);
86
+ const skills = listSkills(manifest);
87
+
88
+ console.log(`Framework v${version} installed. Wiring ${skills.length} skills...`);
89
+
90
+ const { wired, skipped, errors } = useCopies
91
+ ? await syncCopies(frameworkDir, skillsDir, skills, version)
92
+ : await syncSymlinks(frameworkDir, skillsDir, skills);
93
+
94
+ if (errors.length) {
95
+ for (const e of errors) console.log(chalk.yellow(` warning: ${e}`));
96
+ }
97
+
98
+ await scaffoldConfigIfMissing(projectRoot, frameworkDir);
99
+
100
+ console.log(chalk.green(`\nDone.`));
101
+ console.log(` Framework: v${version}`);
102
+ console.log(` Skills wired: ${wired.length}`);
103
+ console.log(` Config: .claude/skills/arch/project-config.md`);
104
+ console.log('');
105
+ console.log('Next: edit project-config.md to set your target, then run /align-project-skills');
106
+ }
107
+
108
+ /**
109
+ * Scaffold arch/project-config.md from the framework template if it does not yet exist.
110
+ */
111
+ async function scaffoldConfigIfMissing(projectRoot, frameworkDir) {
112
+ const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
113
+ const configExists = await exists(configPath);
114
+
115
+ if (configExists) {
116
+ console.log(' project-config.md already exists — skipping scaffold');
117
+ return;
118
+ }
119
+
120
+ const templatePath = join(frameworkDir, 'targets', '_template', 'project-config-template.md');
121
+ const fragmentsDir = join(projectRoot, '.claude', 'skills', 'arch', 'fragments', 'project');
122
+
123
+ await mkdir(join(projectRoot, '.claude', 'skills', 'arch'), { recursive: true });
124
+ await mkdir(fragmentsDir, { recursive: true });
125
+ await copyFile(templatePath, configPath);
126
+
127
+ console.log(' Scaffolded arch/project-config.md');
128
+ }
129
+
130
+ async function exists(path) {
131
+ try {
132
+ await access(path);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Determine whether to use file copies instead of symlinks.
141
+ * True when: --copy flag set, OR on Windows and --symlink not forced.
142
+ */
143
+ function resolveCopyMode(options) {
144
+ if (options.copy) return true;
145
+ if (options.symlink) return false;
146
+ return !platformSupportsSymlinks();
147
+ }
@@ -0,0 +1,139 @@
1
+ import chalk from 'chalk';
2
+ import { access, readFile, lstat, readdir } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { cwd } from 'process';
5
+ import { readManifest, listSkills, getVersion } from '../utils/manifest.js';
6
+ import { getSkillsDir, getFrameworkDir } from '../adapters/claude.js';
7
+
8
+ export async function status(options = {}) {
9
+ const projectRoot = cwd();
10
+ const frameworkDir = getFrameworkDir(projectRoot);
11
+ const skillsDir = getSkillsDir(projectRoot);
12
+
13
+ // Framework must be installed
14
+ if (!await exists(join(frameworkDir, 'manifest.json'))) {
15
+ console.error(chalk.red('Framework not installed. Run `cuewright init` first.'));
16
+ process.exit(1);
17
+ }
18
+
19
+ const manifest = await readManifest(frameworkDir);
20
+ const version = getVersion(manifest);
21
+ const skills = listSkills(manifest);
22
+
23
+ // Count overridden and baseline skills
24
+ let overridden = 0;
25
+ let fromBaseline = 0;
26
+ const baselineDir = join(projectRoot, '.claude', 'skills', '_baseline');
27
+ const hasBaseline = await exists(baselineDir);
28
+
29
+ for (const name of skills) {
30
+ const skillPath = join(skillsDir, name);
31
+ try {
32
+ const stat = await lstat(skillPath);
33
+ if (stat.isSymbolicLink()) {
34
+ if (hasBaseline) {
35
+ // Check if symlink points into _baseline
36
+ const { readlink } = await import('fs/promises');
37
+ const target = await readlink(skillPath);
38
+ if (target.includes('_baseline')) fromBaseline++;
39
+ }
40
+ } else if (stat.isDirectory()) {
41
+ overridden++;
42
+ }
43
+ } catch {
44
+ // Skill not wired — not counted
45
+ }
46
+ }
47
+
48
+ // Read project-config for target and governance
49
+ const configPath = join(projectRoot, '.claude', 'skills', 'arch', 'project-config.md');
50
+ const configExists = await exists(configPath);
51
+ let targetName = null;
52
+ let governanceRows = [];
53
+
54
+ if (configExists) {
55
+ const content = await readFile(configPath, 'utf8');
56
+ targetName = extractTargetName(content);
57
+ governanceRows = extractGovernanceTable(content);
58
+ }
59
+
60
+ if (options.json) {
61
+ const output = {
62
+ version,
63
+ target: targetName,
64
+ skills: {
65
+ total: skills.length,
66
+ overridden,
67
+ fromBaseline,
68
+ },
69
+ governance: governanceRows,
70
+ };
71
+ console.log(JSON.stringify(output, null, 2));
72
+ return;
73
+ }
74
+
75
+ // Human-readable output
76
+ console.log(chalk.bold(`Cuewright v${version}`));
77
+ console.log(`Target: ${targetName || chalk.dim('(not configured)')}`);
78
+ console.log(`Skills: ${skills.length} total · ${overridden} overridden · ${fromBaseline} from baseline`);
79
+
80
+ if (governanceRows.length > 0) {
81
+ console.log('');
82
+ console.log(chalk.bold('Governance:'));
83
+ const maxLen = Math.max(...governanceRows.map(r => r.concern.length));
84
+ for (const { concern, stage } of governanceRows) {
85
+ const stageColor = stage === 'Enforcement' ? chalk.green
86
+ : stage === 'Remediation' ? chalk.yellow
87
+ : chalk.dim;
88
+ console.log(` ${concern.padEnd(maxLen + 2)}${stageColor(stage)}`);
89
+ }
90
+ } else if (!configExists) {
91
+ console.log('');
92
+ console.log(chalk.dim('No project-config.md found. Run `cuewright init` to scaffold it.'));
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Extract target name from project-config.md Stack table.
98
+ */
99
+ function extractTargetName(content) {
100
+ const match = content.match(/^\|\s*Target\s*\|\s*([a-z0-9-]+)\s*\|/m);
101
+ return match ? match[1] : null;
102
+ }
103
+
104
+ /**
105
+ * Extract governance stage rows from project-config.md.
106
+ * Looks for a table with columns like | Concern | Stage |
107
+ */
108
+ function extractGovernanceTable(content) {
109
+ const rows = [];
110
+ const lines = content.split('\n');
111
+ let inGovernance = false;
112
+
113
+ for (const line of lines) {
114
+ if (/governance/i.test(line) && line.startsWith('#')) {
115
+ inGovernance = true;
116
+ continue;
117
+ }
118
+ if (inGovernance && line.startsWith('#')) {
119
+ inGovernance = false;
120
+ }
121
+ if (inGovernance) {
122
+ const match = line.match(/^\|\s*([A-Za-z ]+\S)\s*\|\s*(Discovery|Remediation|Enforcement)\s*\|/);
123
+ if (match) {
124
+ rows.push({ concern: match[1].trim(), stage: match[2].trim() });
125
+ }
126
+ }
127
+ }
128
+
129
+ return rows;
130
+ }
131
+
132
+ async function exists(path) {
133
+ try {
134
+ await access(path);
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
@@ -0,0 +1,187 @@
1
+ import chalk from 'chalk';
2
+ import { access, readFile, unlink } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { cwd } from 'process';
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';
8
+ import { cloneFramework } from '../storage/copy.js';
9
+ import { getSkillsDir, getFrameworkDir } from '../adapters/claude.js';
10
+ import simpleGit from 'simple-git';
11
+
12
+ export async function update(options) {
13
+ const projectRoot = cwd();
14
+ const frameworkDir = getFrameworkDir(projectRoot);
15
+ const skillsDir = getSkillsDir(projectRoot);
16
+ const dryRun = options.dryRun || false;
17
+
18
+ // Framework must already be installed
19
+ const manifestPath = join(frameworkDir, 'manifest.json');
20
+ if (!await exists(manifestPath)) {
21
+ console.error(chalk.red('Framework not installed. Run `cuewright init` first.'));
22
+ process.exit(1);
23
+ }
24
+
25
+ const oldManifest = await readManifest(frameworkDir);
26
+ const oldVersion = getVersion(oldManifest);
27
+
28
+ // Detect whether this is a submodule or a plain copy
29
+ const isSubmodule = await exists(join(projectRoot, '.gitmodules'))
30
+ && await isSubmoduleInstall(projectRoot, frameworkDir);
31
+
32
+ // Determine target version
33
+ const targetVersion = options.to || await resolveLatestTag(frameworkDir);
34
+
35
+ if (targetVersion === oldVersion) {
36
+ console.log(chalk.cyan(`Already at v${oldVersion} (latest). Nothing to update.`));
37
+ return;
38
+ }
39
+
40
+ console.log(chalk.cyan(`Updating framework: v${oldVersion} → v${targetVersion}`));
41
+
42
+ if (dryRun) {
43
+ console.log(chalk.yellow('Dry run — no changes will be made.'));
44
+ }
45
+
46
+ if (!dryRun) {
47
+ if (isSubmodule) {
48
+ await updateSubmodule(projectRoot, '.claude/skills/_framework', targetVersion);
49
+ } else {
50
+ await cloneFramework(frameworkDir, targetVersion);
51
+ }
52
+ }
53
+
54
+ const newManifest = await readManifest(frameworkDir);
55
+ const newSkills = new Set(listSkills(newManifest));
56
+ const oldSkills = new Set(listSkills(oldManifest));
57
+
58
+ // Diff skills
59
+ const added = [...newSkills].filter(s => !oldSkills.has(s));
60
+ const removed = [...oldSkills].filter(s => !newSkills.has(s));
61
+
62
+ if (added.length > 0) {
63
+ console.log(`New skills: ${added.join(', ')}`);
64
+ if (!dryRun) {
65
+ const useCopies = !platformSupportsSymlinks();
66
+ if (useCopies) {
67
+ await syncCopies(frameworkDir, skillsDir, added, targetVersion);
68
+ } else {
69
+ await syncSymlinks(frameworkDir, skillsDir, added);
70
+ }
71
+ }
72
+ }
73
+
74
+ if (removed.length > 0) {
75
+ for (const name of removed) {
76
+ const skillPath = join(skillsDir, name);
77
+ 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
83
+ if (!dryRun) {
84
+ await unlink(skillPath);
85
+ }
86
+ console.log(` Removed broken symlink: ${name}`);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Print changelog for the updated range
93
+ const changelogPath = join(frameworkDir, 'CHANGELOG.md');
94
+ if (await exists(changelogPath)) {
95
+ const changelog = await readFile(changelogPath, 'utf8');
96
+ const relevant = extractChangelogRange(changelog, oldVersion, targetVersion);
97
+ if (relevant) {
98
+ console.log('\n' + chalk.bold('What changed:'));
99
+ console.log(relevant);
100
+ }
101
+ }
102
+
103
+ if (!dryRun) {
104
+ console.log(chalk.green(`\nUpdated to v${targetVersion}.`));
105
+ } else {
106
+ console.log(chalk.yellow(`\nDry run complete. Run without --dry-run to apply changes.`));
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve the latest semver tag from the installed framework's git history.
112
+ */
113
+ async function resolveLatestTag(frameworkDir) {
114
+ try {
115
+ const git = simpleGit(frameworkDir);
116
+ return await getLatestTag(git);
117
+ } catch {
118
+ throw new Error('Could not determine latest framework version. Use --to <version> to specify one.');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check whether _framework was installed as a git submodule.
124
+ */
125
+ async function isSubmoduleInstall(projectRoot, frameworkDir) {
126
+ try {
127
+ const gitmodulesPath = join(projectRoot, '.gitmodules');
128
+ const content = await readFile(gitmodulesPath, 'utf8');
129
+ return content.includes('.claude/skills/_framework');
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Extract changelog entries between two versions.
137
+ * Returns the text for versions newer than oldVersion up to and including targetVersion.
138
+ */
139
+ function extractChangelogRange(changelog, oldVersion, targetVersion) {
140
+ const lines = changelog.split('\n');
141
+ const sections = [];
142
+ let inRange = false;
143
+ let currentSection = [];
144
+
145
+ for (const line of lines) {
146
+ const versionMatch = line.match(/^## v(\d+\.\d+\.\d+)/);
147
+ if (versionMatch) {
148
+ const v = versionMatch[1];
149
+ if (currentSection.length > 0 && inRange) {
150
+ sections.push(currentSection.join('\n').trim());
151
+ }
152
+ currentSection = [line];
153
+ inRange = isNewer(v, oldVersion) && !isNewer(v, targetVersion);
154
+ } else if (inRange) {
155
+ currentSection.push(line);
156
+ }
157
+ }
158
+
159
+ if (currentSection.length > 0 && inRange) {
160
+ sections.push(currentSection.join('\n').trim());
161
+ }
162
+
163
+ return sections.join('\n\n');
164
+ }
165
+
166
+ function isNewer(a, b) {
167
+ const [aMaj, aMin, aPat] = a.split('.').map(Number);
168
+ const [bMaj, bMin, bPat] = b.split('.').map(Number);
169
+ if (aMaj !== bMaj) return aMaj > bMaj;
170
+ if (aMin !== bMin) return aMin > bMin;
171
+ return aPat > bPat;
172
+ }
173
+
174
+ async function exists(path) {
175
+ try {
176
+ await access(path);
177
+ return true;
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ async function isRealDirectory(path) {
184
+ const { lstat } = await import('fs/promises');
185
+ const stat = await lstat(path);
186
+ return stat.isDirectory() && !stat.isSymbolicLink();
187
+ }
@@ -0,0 +1,18 @@
1
+ import simpleGit from 'simple-git';
2
+ import { mkdir, rm } from 'fs/promises';
3
+ import { join } from 'path';
4
+
5
+ const FRAMEWORK_REPO = 'https://github.com/scriswell/cuewright';
6
+
7
+ /**
8
+ * Clone the framework as a plain directory (not a submodule).
9
+ * Used when --no-submodule is passed or on platforms where submodules are impractical.
10
+ */
11
+ export async function cloneFramework(targetDir, tag) {
12
+ // Remove if exists (re-install scenario)
13
+ await rm(targetDir, { recursive: true, force: true });
14
+ await mkdir(targetDir, { recursive: true });
15
+
16
+ const git = simpleGit();
17
+ await git.clone(FRAMEWORK_REPO, targetDir, ['--depth', '1', ...(tag ? ['--branch', tag] : [])]);
18
+ }
@@ -0,0 +1,51 @@
1
+ import simpleGit from 'simple-git';
2
+
3
+ const FRAMEWORK_REPO = 'https://github.com/scriswell/cuewright';
4
+
5
+ /**
6
+ * Add the framework as a git submodule.
7
+ * If tag is provided, checks out that tag after adding.
8
+ */
9
+ export async function addSubmodule(projectRoot, submodulePath, tag) {
10
+ const git = simpleGit(projectRoot);
11
+
12
+ await git.submoduleAdd(FRAMEWORK_REPO, submodulePath);
13
+
14
+ if (tag) {
15
+ const subGit = simpleGit(`${projectRoot}/${submodulePath}`);
16
+ await subGit.checkout(tag);
17
+
18
+ // Update the superproject's submodule pointer to this commit
19
+ await git.submoduleUpdate(['--init', submodulePath]);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Update an existing submodule to a specific tag (or latest if no tag given).
25
+ * "Latest" means the most recent semver tag on the remote.
26
+ */
27
+ export async function updateSubmodule(projectRoot, submodulePath, tag) {
28
+ const subGit = simpleGit(`${projectRoot}/${submodulePath}`);
29
+
30
+ await subGit.fetch(['--tags']);
31
+
32
+ if (tag) {
33
+ await subGit.checkout(tag);
34
+ } else {
35
+ // Checkout the latest semver tag
36
+ const latestTag = await getLatestTag(subGit);
37
+ await subGit.checkout(latestTag);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Return the latest semver tag from the repo's tag list.
43
+ */
44
+ export async function getLatestTag(git) {
45
+ const result = await git.tags(['--sort=-version:refname']);
46
+ const semverTags = result.all.filter(t => /^v\d+\.\d+\.\d+/.test(t));
47
+ if (semverTags.length === 0) {
48
+ throw new Error('No semver tags found in framework repo');
49
+ }
50
+ return semverTags[0];
51
+ }
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Read and parse a manifest.json file.
6
+ * Returns the parsed object or throws if the file is missing or invalid JSON.
7
+ */
8
+ export async function readManifest(frameworkDir) {
9
+ const manifestPath = join(frameworkDir, 'manifest.json');
10
+ const raw = await readFile(manifestPath, 'utf8');
11
+ return JSON.parse(raw);
12
+ }
13
+
14
+ /**
15
+ * Return the list of skill names from a parsed manifest.
16
+ */
17
+ export function listSkills(manifest) {
18
+ return Object.keys(manifest.skills);
19
+ }
20
+
21
+ /**
22
+ * Return the framework version string from a parsed manifest.
23
+ */
24
+ export function getVersion(manifest) {
25
+ return manifest.framework_version;
26
+ }
@@ -0,0 +1,135 @@
1
+ import { symlink, mkdir, access, lstat, readFile, writeFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Ensure all skills in the manifest are wired into the skills discovery dir
6
+ * using directory symlinks.
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>.
13
+ *
14
+ * Returns a summary: { wired: string[], skipped: string[], errors: string[] }
15
+ */
16
+ export async function syncSymlinks(frameworkDir, skillsDir, skillNames) {
17
+ const wired = [];
18
+ const skipped = [];
19
+ const errors = [];
20
+
21
+ await mkdir(skillsDir, { recursive: true });
22
+
23
+ for (const name of skillNames) {
24
+ const linkPath = join(skillsDir, name);
25
+ const targetPath = join(frameworkDir, 'skills', name);
26
+
27
+ try {
28
+ const stat = await lstat(linkPath);
29
+ if (stat.isSymbolicLink()) {
30
+ try {
31
+ await access(linkPath);
32
+ skipped.push(name); // valid symlink already present
33
+ } catch {
34
+ // Broken symlink — re-create
35
+ await symlink(targetPath, linkPath, 'dir');
36
+ wired.push(name);
37
+ }
38
+ } else if (stat.isDirectory()) {
39
+ skipped.push(name); // real directory override — never touch
40
+ } else {
41
+ errors.push(`${name}: unexpected file at ${linkPath}`);
42
+ }
43
+ } catch {
44
+ // Path does not exist — create symlink
45
+ await symlink(targetPath, linkPath, 'dir');
46
+ wired.push(name);
47
+ }
48
+ }
49
+
50
+ return { wired, skipped, errors };
51
+ }
52
+
53
+ /**
54
+ * Ensure all skills in the manifest are wired via file copies (Windows / --copy mode).
55
+ *
56
+ * Copies <frameworkDir>/skills/<name>/SKILL.md → <skillsDir>/<name>/SKILL.md,
57
+ * prepending an auto-generated header so consumers know not to edit the file directly.
58
+ *
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.
63
+ *
64
+ * Returns a summary: { wired: string[], skipped: string[], errors: string[] }
65
+ */
66
+ export async function syncCopies(frameworkDir, skillsDir, skillNames, version) {
67
+ const wired = [];
68
+ const skipped = [];
69
+ const errors = [];
70
+
71
+ await mkdir(skillsDir, { recursive: true });
72
+
73
+ for (const name of skillNames) {
74
+ const destDir = join(skillsDir, name);
75
+ const destFile = join(destDir, 'SKILL.md');
76
+ const srcFile = join(frameworkDir, 'skills', name, 'SKILL.md');
77
+
78
+ try {
79
+ const stat = await lstat(destDir);
80
+ if (stat.isDirectory()) {
81
+ // Check if it's an auto-generated copy or a real override
82
+ let existingContent = '';
83
+ try {
84
+ existingContent = await readFile(destFile, 'utf8');
85
+ } catch {
86
+ // No SKILL.md — treat as override directory
87
+ skipped.push(name);
88
+ continue;
89
+ }
90
+
91
+ if (existingContent.startsWith('<!-- AUTO-GENERATED')) {
92
+ skipped.push(name); // already wired as a copy
93
+ } else {
94
+ skipped.push(name); // real override — never touch
95
+ }
96
+ continue;
97
+ }
98
+ } catch {
99
+ // Directory does not exist — proceed to create
100
+ }
101
+
102
+ try {
103
+ const srcContent = await readFile(srcFile, 'utf8');
104
+ const header = buildHeader(name, version);
105
+ await mkdir(destDir, { recursive: true });
106
+ await writeFile(destFile, header + srcContent, 'utf8');
107
+ wired.push(name);
108
+ } catch (err) {
109
+ errors.push(`${name}: ${err.message}`);
110
+ }
111
+ }
112
+
113
+ return { wired, skipped, errors };
114
+ }
115
+
116
+ /**
117
+ * Build the auto-generated header prepended to copied SKILL.md files.
118
+ */
119
+ export function buildHeader(skillName, version) {
120
+ return [
121
+ `<!-- AUTO-GENERATED: Copied from _framework/skills/${skillName}/SKILL.md (v${version}) -->`,
122
+ `<!-- 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
+ '',
125
+ '',
126
+ ].join('\n');
127
+ }
128
+
129
+ /**
130
+ * Detect whether symlinks can be created on the current platform.
131
+ * On Windows, directory symlinks require developer mode or admin privileges.
132
+ */
133
+ export function platformSupportsSymlinks() {
134
+ return process.platform !== 'win32';
135
+ }