@devshop/crew 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # [0.4.0](https://github.com/devshop-software/crew/compare/v0.3.0...v0.4.0) (2026-04-30)
2
+
3
+
4
+ ### Features
5
+
6
+ * migrate to npmjs.com as @devshop/crew ([a2ea3c1](https://github.com/devshop-software/crew/commit/a2ea3c1d5454ead7dd16bde6916a708eca520946))
7
+
8
+ # [0.3.0](https://github.com/devshop-software/crew/compare/v0.2.1...v0.3.0) (2026-04-30)
9
+
10
+
11
+ ### Features
12
+
13
+ * prompt before absorbing foreign-collision skills on init ([e8a045c](https://github.com/devshop-software/crew/commit/e8a045c3339de9065269b9bd7cc8ae5aaa976eca))
14
+
15
+ ## [0.2.1](https://github.com/devshop-software/crew/compare/v0.2.0...v0.2.1) (2026-04-30)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * don't print "Next: ... /adjust" line when init refused ([8175644](https://github.com/devshop-software/crew/commit/81756442a00e39955e0bebd2dd7f6687df2479a7))
21
+
22
+ # [0.2.0](https://github.com/devshop-software/crew/compare/v0.1.0...v0.2.0) (2026-04-30)
23
+
24
+
25
+ ### Features
26
+
27
+ * ship real skill content (12 skills incl. prep) ([b86bd9a](https://github.com/devshop-software/crew/commit/b86bd9abb361f973023a441d2c808917834c4e1b))
28
+
29
+ # Changelog
30
+
31
+ ## 0.1.0 — 2026-04-30
32
+
33
+ Initial release.
34
+
35
+ - `crew init / update / uninstall / list / doctor` against `<cwd>/.claude/skills/` (default) or `~/.claude/skills/` (`--global`).
36
+ - Per-skill SHA-256 manifest at `.claude/skills/.skills-manifest.json`.
37
+ - Conflict matrix resolves `missing` / `managed-unchanged` / `managed-edited` / `foreign` states across all five commands and the `--force` / `--yes` flags.
38
+ - Idempotent `## Workflow Config` append into project `CLAUDE.md`; never touched by `update` or `uninstall`.
39
+ - Refuses project-scope install when cwd lacks any of `package.json`, `.git`, `CLAUDE.md`, `pyproject.toml`, `Cargo.toml`, `go.mod`.
40
+ - Zero runtime dependencies. Node ≥ 20.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devshop-software
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,73 @@
1
+ # crew
2
+
3
+ Project-agnostic Claude Code skills for spec → implement → qa → review → ship.
4
+
5
+ ## Install
6
+
7
+ Public on npmjs.com — no registry config or token needed.
8
+
9
+ ```sh
10
+ npx @devshop/crew@latest init
11
+ ```
12
+
13
+ Or as a project dev dependency:
14
+
15
+ ```sh
16
+ pnpm add -D @devshop/crew
17
+ pnpm exec crew init
18
+ ```
19
+
20
+ This copies the skills into `./.claude/skills/`, writes a manifest, and appends a `## Workflow Config` block to `CLAUDE.md` (creating it if absent).
21
+
22
+ ## Commands
23
+
24
+ ```
25
+ crew init [--global] [--force] [--yes] [--dry-run] [--no-claude-md]
26
+ crew update [--global] [--force] [--yes] [--dry-run]
27
+ crew uninstall [--global] [--dry-run]
28
+ crew list [--global]
29
+ crew doctor [--global]
30
+ ```
31
+
32
+ | Flag | Effect |
33
+ |---|---|
34
+ | `--global` | Target `~/.claude/skills/` (no `CLAUDE.md` handling). |
35
+ | `--force` | Override prompts and refusals (silently absorbs foreign collisions, replaces edits). |
36
+ | `--yes` | Non-interactive (CI-safe). On `init`, refuses foreign collisions; on `update`, defaults edited skills to backup-and-replace. |
37
+ | `--dry-run` | Print actions, write nothing. Exits 1 if errors *would have* occurred. |
38
+ | `--no-claude-md` | On `init` only: skip the `CLAUDE.md` append. |
39
+
40
+ ## How conflicts are resolved
41
+
42
+ The CLI tracks a per-skill SHA-256 in `.claude/skills/.skills-manifest.json`. Each skill folder is in one of four states:
43
+
44
+ - **missing** — not at the target.
45
+ - **managed-unchanged** — present, in manifest, hash matches.
46
+ - **managed-edited** — present, in manifest, you've edited it.
47
+ - **foreign** — present but not in the manifest.
48
+
49
+ `update` will never touch foreign skills. On `init`, when foreign collisions are detected (a folder with the same name as a skill we ship, but not in our manifest), interactive runs prompt with the list and ask `[y/N]` to absorb them; `--force` absorbs silently; `--yes` refuses (CI-safe). Absorbed originals are backed up to `.claude/skills/.bak/<utc>/<skill>/`. This is the bright line that keeps `update` boring.
50
+
51
+ For edited skills:
52
+
53
+ - `update` (default) → prompts: backup-and-replace / keep / replace.
54
+ - `update --yes` → backup-and-replace.
55
+ - `update --force` → replace, no backup.
56
+
57
+ ## Exit codes
58
+
59
+ | Code | Meaning |
60
+ |---|---|
61
+ | 0 | Success. |
62
+ | 1 | Refused due to a conflict you must resolve. |
63
+ | 2 | Invalid usage (or no project markers in cwd). |
64
+ | 3 | I/O error. |
65
+ | 4 | Manifest corrupt. |
66
+
67
+ ## Maintainer publish
68
+
69
+ Releases are automated. Push a conventional-commit message (`feat:`, `fix:`, `BREAKING CHANGE:`) to `main` and `.github/workflows/ci.yml` runs semantic-release: computes the next semver, bumps `package.json`, prepends a `CHANGELOG.md` entry, tags `vX.Y.Z`, publishes to npmjs.com, and creates a GitHub release. The `NPM_TOKEN` repo secret authenticates the npm publish.
70
+
71
+ ## License
72
+
73
+ MIT.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@devshop/crew",
3
+ "version": "0.4.1",
4
+ "description": "Project-agnostic Claude Code skills for spec → implement → qa → review → ship",
5
+ "bin": {
6
+ "crew": "scripts/cli.js"
7
+ },
8
+ "files": [
9
+ "skills/",
10
+ "templates/",
11
+ "scripts/",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "keywords": [
20
+ "claude-code",
21
+ "claude",
22
+ "skills",
23
+ "agentic",
24
+ "workflow",
25
+ "crew"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/devshop-software/crew.git"
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org/",
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "test": "node --test test/*.test.js"
37
+ },
38
+ "license": "MIT",
39
+ "devDependencies": {
40
+ "semantic-release": "^24.2.0"
41
+ }
42
+ }
package/scripts/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ const path = require('path');
3
+
4
+ const COMMANDS = ['init', 'update', 'uninstall', 'list', 'doctor'];
5
+
6
+ function usage() {
7
+ return [
8
+ 'Usage: crew <command> [flags]',
9
+ '',
10
+ 'Commands:',
11
+ ' init Install skills into the current project (or --global).',
12
+ ' update Replace managed skills with newer package versions.',
13
+ ' uninstall Remove managed skills and the manifest.',
14
+ ' list Show installed skills.',
15
+ ' doctor Report drift; never modifies anything.',
16
+ '',
17
+ 'Flags:',
18
+ ' --global Target ~/.claude/skills/ (no CLAUDE.md handling).',
19
+ ' --force Override prompts and refusals.',
20
+ ' --yes Non-interactive (CI-safe defaults).',
21
+ ' --dry-run Print actions, write nothing.',
22
+ ' --no-claude-md On init only: skip CLAUDE.md append.'
23
+ ].join('\n');
24
+ }
25
+
26
+ function parseArgs(argv) {
27
+ const flags = { global: false, force: false, yes: false, dryRun: false, noClaudeMd: false };
28
+ let command = null;
29
+ for (const a of argv) {
30
+ if (a.startsWith('--')) {
31
+ switch (a) {
32
+ case '--global': flags.global = true; break;
33
+ case '--force': flags.force = true; break;
34
+ case '--yes': flags.yes = true; break;
35
+ case '--dry-run': flags.dryRun = true; break;
36
+ case '--no-claude-md': flags.noClaudeMd = true; break;
37
+ case '--help': process.stdout.write(usage() + '\n'); process.exit(0);
38
+ default:
39
+ process.stderr.write(`Unknown flag: ${a}\n\n${usage()}\n`);
40
+ process.exit(2);
41
+ }
42
+ } else if (!command) {
43
+ command = a;
44
+ } else {
45
+ process.stderr.write(`Unexpected argument: ${a}\n\n${usage()}\n`);
46
+ process.exit(2);
47
+ }
48
+ }
49
+ if (!command || !COMMANDS.includes(command)) {
50
+ process.stderr.write(`${usage()}\n`);
51
+ process.exit(2);
52
+ }
53
+ return { command, flags };
54
+ }
55
+
56
+ (async () => {
57
+ const { command, flags } = parseArgs(process.argv.slice(2));
58
+ const cmd = require(path.join(__dirname, 'commands', `${command}.js`));
59
+ try {
60
+ const code = await cmd(flags);
61
+ process.exit(code || 0);
62
+ } catch (e) {
63
+ const log = require('./lib/log');
64
+ log.error(e.message);
65
+ process.exit(e.exitCode || 3);
66
+ }
67
+ })();
@@ -0,0 +1,51 @@
1
+ const path = require('path');
2
+ const { resolveTarget } = require('../lib/paths');
3
+ const { readManifest, diffSkills } = require('../lib/manifest');
4
+ const log = require('../lib/log');
5
+
6
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
7
+ const PKG_VERSION = require(path.join(PKG_ROOT, 'package.json')).version;
8
+ const PKG_SKILLS = path.join(PKG_ROOT, 'skills');
9
+
10
+ module.exports = async function doctor(flags) {
11
+ let target;
12
+ try { target = resolveTarget(flags); }
13
+ catch (e) { log.error(e.message); return e.exitCode || 2; }
14
+
15
+ const { skillsDir } = target;
16
+ let manifest;
17
+ try { manifest = readManifest(skillsDir); }
18
+ catch (e) { log.error(e.message); return 4; }
19
+ if (!manifest) {
20
+ log.plain(`No crew installation at ${skillsDir}. Run \`crew init\` to install.`);
21
+ return 0;
22
+ }
23
+
24
+ const diff = diffSkills(PKG_SKILLS, skillsDir, manifest);
25
+ log.plain(`Package version: ${PKG_VERSION}`);
26
+ log.plain(`Manifest version: ${manifest.package_version}`);
27
+ log.plain(`Scope: ${manifest.scope}`);
28
+ log.plain(`Skills dir: ${skillsDir}`);
29
+ log.plain('');
30
+
31
+ const w = Math.max(4, ...diff.map(d => d.name.length));
32
+ log.plain(`${'name'.padEnd(w)} state mf-ver pkg-ver`);
33
+ log.plain(`${'-'.repeat(w)} ----- ------ -------`);
34
+ for (const d of diff) {
35
+ const mfVer = manifest.skills[d.name]?.version || '-';
36
+ const pkgVer = d.inPkg ? PKG_VERSION : '-';
37
+ log.plain(`${d.name.padEnd(w)} ${d.state.padEnd(18)} ${mfVer.padEnd(8)} ${pkgVer}`);
38
+ }
39
+
40
+ const issues = diff.filter(d =>
41
+ d.state === 'managed-edited' ||
42
+ d.state === 'foreign' ||
43
+ (d.state === 'managed-unchanged' && d.diskHash !== d.pkgHash) ||
44
+ (d.state === 'missing' && d.inPkg)
45
+ );
46
+ if (issues.length > 0) {
47
+ log.plain('');
48
+ log.plain(`${issues.length} skill(s) need attention. Run \`crew update\` (or \`crew init --force\` for foreign).`);
49
+ }
50
+ return 0;
51
+ };
@@ -0,0 +1,131 @@
1
+ const path = require('path');
2
+ const { resolveTarget } = require('../lib/paths');
3
+ const { readManifest, writeManifest, emptyManifest, diffSkills, PACKAGE_NAME, SCHEMA_VERSION } = require('../lib/manifest');
4
+ const { copyFolder, backupFolder, backupRoot } = require('../lib/fsx');
5
+ const { chooseEditAction, confirmAbsorbForeign } = require('../lib/prompt');
6
+ const { ensureWorkflowConfig } = require('../lib/claude-md');
7
+ const log = require('../lib/log');
8
+
9
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
10
+ const PKG_VERSION = require(path.join(PKG_ROOT, 'package.json')).version;
11
+ const PKG_SKILLS = path.join(PKG_ROOT, 'skills');
12
+ const TEMPLATE = path.join(PKG_ROOT, 'templates', 'workflow-config.md');
13
+
14
+ module.exports = async function init(flags) {
15
+ let target;
16
+ try { target = resolveTarget(flags); }
17
+ catch (e) { log.error(e.message); return e.exitCode || 2; }
18
+
19
+ const { skillsDir, claudeMdPath, scope } = target;
20
+ let manifest;
21
+ try { manifest = readManifest(skillsDir); }
22
+ catch (e) { log.error(e.message); return 4; }
23
+ if (!manifest) manifest = emptyManifest(scope, PKG_VERSION);
24
+
25
+ const diff = diffSkills(PKG_SKILLS, skillsDir, manifest);
26
+ let bakBase = null;
27
+ let refused = false;
28
+ let ioError = false;
29
+ const now = new Date().toISOString();
30
+ const stamp = (name, hash) => { manifest.skills[name] = { version: PKG_VERSION, hash, installed_at: now }; };
31
+
32
+ // Foreign-collision handling: decide once for all such skills, up-front.
33
+ // --force: silent absorb. --yes: refuse (CI-safe). Otherwise prompt; default N.
34
+ let absorbForeign = false;
35
+ const foreignCollisions = diff.filter(s => s.state === 'foreign' && s.inPkg);
36
+ const interactive = process.stdin.isTTY || process.env.CREW_FAKE_TTY === '1';
37
+ if (foreignCollisions.length > 0) {
38
+ if (flags.force) {
39
+ absorbForeign = true;
40
+ } else if (!flags.yes && interactive) {
41
+ absorbForeign = await confirmAbsorbForeign(foreignCollisions.map(s => s.name));
42
+ }
43
+ }
44
+
45
+ for (const s of diff) {
46
+ if (!s.inPkg) continue;
47
+ try {
48
+ if (s.state === 'missing') {
49
+ if (flags.dryRun) log.dryRun('copy', s.name);
50
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('copy', s.name); }
51
+ stamp(s.name, s.pkgHash);
52
+ } else if (s.state === 'managed-unchanged') {
53
+ if (flags.force) {
54
+ if (flags.dryRun) log.dryRun('replace', s.name);
55
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
56
+ stamp(s.name, s.pkgHash);
57
+ } else if (flags.dryRun) {
58
+ log.dryRun('skip', s.name);
59
+ }
60
+ } else if (s.state === 'managed-edited') {
61
+ let action;
62
+ if (flags.force) action = 'replace';
63
+ else if (flags.yes) action = 'backup';
64
+ else if (!interactive) {
65
+ log.error(`Edited skill detected ('${s.name}') and stdin is not a TTY. Re-run with --yes or --force.`);
66
+ refused = true;
67
+ continue;
68
+ } else {
69
+ action = await chooseEditAction(s.name);
70
+ }
71
+ if (action === 'keep') {
72
+ if (flags.dryRun) log.dryRun('keep', s.name);
73
+ else log.action('keep', s.name);
74
+ continue;
75
+ }
76
+ if (action === 'backup') {
77
+ bakBase = bakBase || backupRoot(skillsDir);
78
+ if (flags.dryRun) log.dryRun('backup', s.name);
79
+ else { backupFolder(path.join(skillsDir, s.name), bakBase); log.action('backup', s.name); }
80
+ }
81
+ if (flags.dryRun) log.dryRun('replace', s.name);
82
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
83
+ stamp(s.name, s.pkgHash);
84
+ } else if (s.state === 'foreign') {
85
+ if (absorbForeign) {
86
+ bakBase = bakBase || backupRoot(skillsDir);
87
+ if (flags.dryRun) { log.dryRun('backup', s.name); log.dryRun('copy', s.name); }
88
+ else {
89
+ backupFolder(path.join(skillsDir, s.name), bakBase);
90
+ copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name));
91
+ log.action('backup', s.name);
92
+ log.action('copy', s.name);
93
+ }
94
+ stamp(s.name, s.pkgHash);
95
+ } else {
96
+ log.warn(`foreign skill present, refusing: ${s.name} (use --force to absorb)`);
97
+ refused = true;
98
+ }
99
+ }
100
+ } catch (e) {
101
+ log.error(`I/O error on ${s.name}: ${e.message}`);
102
+ ioError = true;
103
+ }
104
+ }
105
+
106
+ if (!flags.dryRun && !ioError) {
107
+ manifest.package = PACKAGE_NAME;
108
+ manifest.package_version = PKG_VERSION;
109
+ manifest.schema_version = SCHEMA_VERSION;
110
+ manifest.scope = scope;
111
+ manifest.updated_at = now;
112
+ if (!manifest.installed_at) manifest.installed_at = now;
113
+ try { writeManifest(skillsDir, manifest); }
114
+ catch (e) { log.error(`Failed writing manifest: ${e.message}`); return 3; }
115
+ }
116
+
117
+ if (scope === 'project' && !flags.noClaudeMd) {
118
+ try {
119
+ const result = ensureWorkflowConfig(claudeMdPath, TEMPLATE, { dryRun: flags.dryRun });
120
+ if (flags.dryRun) log.dryRun(result, 'CLAUDE.md');
121
+ else log.action(result, 'CLAUDE.md');
122
+ } catch (e) { log.error(`CLAUDE.md: ${e.message}`); ioError = true; }
123
+ }
124
+
125
+ if (ioError) return 3;
126
+ if (refused) return 1;
127
+
128
+ log.plain('');
129
+ log.plain('Next: open this project in Claude Code and run /adjust.');
130
+ return 0;
131
+ };
@@ -0,0 +1,33 @@
1
+ const { resolveTarget } = require('../lib/paths');
2
+ const { readManifest } = require('../lib/manifest');
3
+ const log = require('../lib/log');
4
+
5
+ module.exports = async function list(flags) {
6
+ let target;
7
+ try { target = resolveTarget(flags); }
8
+ catch (e) { log.error(e.message); return e.exitCode || 2; }
9
+
10
+ const { skillsDir } = target;
11
+ let manifest;
12
+ try { manifest = readManifest(skillsDir); }
13
+ catch (e) { log.error(e.message); return 4; }
14
+ if (!manifest) {
15
+ log.plain(`No crew installation at ${skillsDir}.`);
16
+ return 0;
17
+ }
18
+
19
+ const names = Object.keys(manifest.skills).sort();
20
+ if (names.length === 0) {
21
+ log.plain(`No skills installed at ${skillsDir}.`);
22
+ return 0;
23
+ }
24
+
25
+ const w = Math.max(4, ...names.map(n => n.length));
26
+ log.plain(`${'name'.padEnd(w)} version installed_at`);
27
+ log.plain(`${'-'.repeat(w)} ------- --------------------`);
28
+ for (const n of names) {
29
+ const e = manifest.skills[n];
30
+ log.plain(`${n.padEnd(w)} ${(e.version || '').padEnd(7)} ${e.installed_at || ''}`);
31
+ }
32
+ return 0;
33
+ };
@@ -0,0 +1,57 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { resolveTarget } = require('../lib/paths');
4
+ const { readManifest, manifestPath } = require('../lib/manifest');
5
+ const { hashSkill } = require('../lib/hash');
6
+ const { removeFolder } = require('../lib/fsx');
7
+ const log = require('../lib/log');
8
+
9
+ module.exports = async function uninstall(flags) {
10
+ let target;
11
+ try { target = resolveTarget(flags); }
12
+ catch (e) { log.error(e.message); return e.exitCode || 2; }
13
+
14
+ const { skillsDir, scope } = target;
15
+ let manifest;
16
+ try { manifest = readManifest(skillsDir); }
17
+ catch (e) { log.error(e.message); return 4; }
18
+ if (!manifest) {
19
+ log.error(`No installation found at ${skillsDir}.`);
20
+ return 1;
21
+ }
22
+
23
+ let ioError = false;
24
+ for (const name of Object.keys(manifest.skills).sort()) {
25
+ const folder = path.join(skillsDir, name);
26
+ try {
27
+ if (!fs.existsSync(folder)) {
28
+ log.info(`missing: ${name}`);
29
+ continue;
30
+ }
31
+ const onDisk = hashSkill(folder);
32
+ if (onDisk === manifest.skills[name].hash) {
33
+ if (flags.dryRun) log.dryRun('remove', name);
34
+ else { removeFolder(folder); log.action('remove', name); }
35
+ } else {
36
+ log.warn(`kept (edited): ${name}`);
37
+ }
38
+ } catch (e) {
39
+ log.error(`I/O error on ${name}: ${e.message}`);
40
+ ioError = true;
41
+ }
42
+ }
43
+
44
+ if (!flags.dryRun && !ioError) {
45
+ try {
46
+ const mp = manifestPath(skillsDir);
47
+ if (fs.existsSync(mp)) fs.unlinkSync(mp);
48
+ } catch (e) { log.error(`Failed removing manifest: ${e.message}`); return 3; }
49
+ }
50
+
51
+ if (scope === 'project') {
52
+ log.plain('');
53
+ log.info(`CLAUDE.md left untouched. Remove the '## Workflow Config' block manually if desired.`);
54
+ }
55
+
56
+ return ioError ? 3 : 0;
57
+ };
@@ -0,0 +1,92 @@
1
+ const path = require('path');
2
+ const { resolveTarget } = require('../lib/paths');
3
+ const { readManifest, writeManifest, diffSkills, PACKAGE_NAME, SCHEMA_VERSION } = require('../lib/manifest');
4
+ const { copyFolder, backupFolder, backupRoot } = require('../lib/fsx');
5
+ const { chooseEditAction } = require('../lib/prompt');
6
+ const log = require('../lib/log');
7
+
8
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
9
+ const PKG_VERSION = require(path.join(PKG_ROOT, 'package.json')).version;
10
+ const PKG_SKILLS = path.join(PKG_ROOT, 'skills');
11
+
12
+ module.exports = async function update(flags) {
13
+ let target;
14
+ try { target = resolveTarget(flags); }
15
+ catch (e) { log.error(e.message); return e.exitCode || 2; }
16
+
17
+ const { skillsDir, scope } = target;
18
+ let manifest;
19
+ try { manifest = readManifest(skillsDir); }
20
+ catch (e) { log.error(e.message); return 4; }
21
+ if (!manifest) {
22
+ log.error(`No installation found at ${skillsDir}. Run \`crew init\` first.`);
23
+ return 1;
24
+ }
25
+
26
+ const diff = diffSkills(PKG_SKILLS, skillsDir, manifest);
27
+ let bakBase = null;
28
+ let refused = false;
29
+ let ioError = false;
30
+ const now = new Date().toISOString();
31
+ const interactive = process.stdin.isTTY || process.env.CREW_FAKE_TTY === '1';
32
+ const stamp = (name, hash) => { manifest.skills[name] = { version: PKG_VERSION, hash, installed_at: now }; };
33
+
34
+ for (const s of diff) {
35
+ if (!s.inPkg) continue;
36
+ try {
37
+ if (s.state === 'missing') {
38
+ if (flags.dryRun) log.dryRun('copy', s.name);
39
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('copy', s.name); }
40
+ stamp(s.name, s.pkgHash);
41
+ } else if (s.state === 'managed-unchanged') {
42
+ if (s.diskHash !== s.pkgHash) {
43
+ if (flags.dryRun) log.dryRun('replace', s.name);
44
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
45
+ stamp(s.name, s.pkgHash);
46
+ }
47
+ } else if (s.state === 'managed-edited') {
48
+ let action;
49
+ if (flags.force) action = 'replace';
50
+ else if (flags.yes) action = 'backup';
51
+ else if (!interactive) {
52
+ log.error(`Edited skill detected ('${s.name}') and stdin is not a TTY. Re-run with --yes or --force.`);
53
+ refused = true;
54
+ continue;
55
+ } else {
56
+ action = await chooseEditAction(s.name);
57
+ }
58
+ if (action === 'keep') {
59
+ if (flags.dryRun) log.dryRun('keep', s.name);
60
+ else log.action('keep', s.name);
61
+ continue;
62
+ }
63
+ if (action === 'backup') {
64
+ bakBase = bakBase || backupRoot(skillsDir);
65
+ if (flags.dryRun) log.dryRun('backup', s.name);
66
+ else { backupFolder(path.join(skillsDir, s.name), bakBase); log.action('backup', s.name); }
67
+ }
68
+ if (flags.dryRun) log.dryRun('replace', s.name);
69
+ else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
70
+ stamp(s.name, s.pkgHash);
71
+ }
72
+ // foreign: leave (don't touch)
73
+ } catch (e) {
74
+ log.error(`I/O error on ${s.name}: ${e.message}`);
75
+ ioError = true;
76
+ }
77
+ }
78
+
79
+ if (!flags.dryRun && !ioError) {
80
+ manifest.package = PACKAGE_NAME;
81
+ manifest.package_version = PKG_VERSION;
82
+ manifest.schema_version = SCHEMA_VERSION;
83
+ manifest.scope = scope;
84
+ manifest.updated_at = now;
85
+ try { writeManifest(skillsDir, manifest); }
86
+ catch (e) { log.error(`Failed writing manifest: ${e.message}`); return 3; }
87
+ }
88
+
89
+ if (ioError) return 3;
90
+ if (refused) return 1;
91
+ return 0;
92
+ };
@@ -0,0 +1,18 @@
1
+ const fs = require('fs');
2
+
3
+ const HEADING_RE = /^##\s+Workflow Config\s*$/m;
4
+
5
+ function ensureWorkflowConfig(claudeMdPath, templatePath, { dryRun = false } = {}) {
6
+ const template = fs.readFileSync(templatePath, 'utf8');
7
+ if (!fs.existsSync(claudeMdPath)) {
8
+ if (!dryRun) fs.writeFileSync(claudeMdPath, '# Project\n\n' + template);
9
+ return 'created';
10
+ }
11
+ const existing = fs.readFileSync(claudeMdPath, 'utf8');
12
+ if (HEADING_RE.test(existing)) return 'present';
13
+ const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
14
+ if (!dryRun) fs.appendFileSync(claudeMdPath, sep + template);
15
+ return 'appended';
16
+ }
17
+
18
+ module.exports = { ensureWorkflowConfig };
@@ -0,0 +1,33 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function copyFolder(src, dst, { dryRun = false } = {}) {
5
+ if (dryRun) return;
6
+ fs.mkdirSync(dst, { recursive: true });
7
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
8
+ const s = path.join(src, e.name);
9
+ const d = path.join(dst, e.name);
10
+ if (e.isDirectory()) copyFolder(s, d);
11
+ else if (e.isFile()) fs.copyFileSync(s, d);
12
+ }
13
+ }
14
+
15
+ function backupFolder(src, bakBase, { dryRun = false } = {}) {
16
+ const dst = path.join(bakBase, path.basename(src));
17
+ if (dryRun) return dst;
18
+ fs.mkdirSync(bakBase, { recursive: true });
19
+ fs.renameSync(src, dst);
20
+ return dst;
21
+ }
22
+
23
+ function removeFolder(p, { dryRun = false } = {}) {
24
+ if (dryRun) return;
25
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
26
+ }
27
+
28
+ function backupRoot(skillsDir) {
29
+ const ts = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
30
+ return path.join(skillsDir, '.bak', ts);
31
+ }
32
+
33
+ module.exports = { copyFolder, backupFolder, removeFolder, backupRoot };