@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 +21 -0
- package/README.md +128 -0
- package/bin/cuewright.js +66 -0
- package/package.json +43 -0
- package/src/adapters/claude.js +23 -0
- package/src/commands/add-baseline.js +5 -0
- package/src/commands/doctor.js +225 -0
- package/src/commands/init.js +147 -0
- package/src/commands/status.js +139 -0
- package/src/commands/update.js +187 -0
- package/src/storage/copy.js +18 -0
- package/src/storage/submodule.js +51 -0
- package/src/utils/manifest.js +26 -0
- package/src/utils/symlink.js +135 -0
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
|
package/bin/cuewright.js
ADDED
|
@@ -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,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
|
+
}
|