@devshop/crew 0.7.0 → 0.8.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 +14 -0
- package/README.md +10 -1
- package/package.json +1 -1
- package/scripts/commands/update.js +121 -73
- package/scripts/lib/prompt.js +62 -29
- package/scripts/lib/self-update.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.8.1](https://github.com/devshop-software/crew/compare/v0.8.0...v0.8.1) (2026-04-30)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* don't rewrite users' version range on self-update ([a85a3c3](https://github.com/devshop-software/crew/commit/a85a3c38485f9613bb5a3f679dff08d472601568))
|
|
7
|
+
|
|
8
|
+
# [0.8.0](https://github.com/devshop-software/crew/compare/v0.7.0...v0.8.0) (2026-04-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* redesigned crew update — plan, confirm, optional backup, auto-remove ([4859ca0](https://github.com/devshop-software/crew/commit/4859ca0f7a424ab32b14fa8bf3ef5ed83c26a6a1))
|
|
14
|
+
|
|
1
15
|
# [0.7.0](https://github.com/devshop-software/crew/compare/v0.6.0...v0.7.0) (2026-04-30)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,16 @@ pnpm add -D @devshop/crew
|
|
|
17
17
|
pnpm exec crew init
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
To pull newer skill content later,
|
|
20
|
+
To pull newer skill content later, run `pnpm exec crew update`. The flow:
|
|
21
|
+
|
|
22
|
+
1. Auto-detects the package manager (pnpm / npm / yarn / bun) from your lockfile and runs `<pm> update @devshop/crew` to bump the package within the range you've pinned in `package.json`. To always pull the absolute newest (including across major versions), set the range to `"latest"`; for "any 0.x" use `"0.x"`.
|
|
23
|
+
2. Re-execs the freshly-installed CLI.
|
|
24
|
+
3. Computes the diff and prints a plan: which skills will be added, updated, replaced (had local edits), or removed (no longer in the package).
|
|
25
|
+
4. Prompts `Apply these changes? [Y/n]`. Default Y. Press `n` to abort with no writes.
|
|
26
|
+
5. Prompts `Back up current versions to .bak/<utc>/? [y/N]`. Default N. Press `y` to keep a snapshot of the pre-change state.
|
|
27
|
+
6. Applies the changes — including removing skills the package no longer ships.
|
|
28
|
+
|
|
29
|
+
`--yes` makes it non-interactive and CI-safe (auto-applies, defaults backup to Y to protect against accidentally clobbering edits in CI). `--force` is destructive: auto-applies and skips backup. `--dry-run` shows the plan without writing. `--no-self-update` skips step 1 (only re-applies what's already on disk from the existing local install).
|
|
21
30
|
|
|
22
31
|
This copies the skills into `./.claude/skills/`, writes a manifest, and appends a `## Workflow Config` block to `CLAUDE.md` (creating it if absent).
|
|
23
32
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
2
3
|
const { resolveTarget } = require('../lib/paths');
|
|
3
4
|
const { readManifest, writeManifest, diffSkills, PACKAGE_NAME, SCHEMA_VERSION } = require('../lib/manifest');
|
|
4
|
-
const { copyFolder, backupFolder, backupRoot } = require('../lib/fsx');
|
|
5
|
-
const {
|
|
5
|
+
const { copyFolder, backupFolder, removeFolder, backupRoot } = require('../lib/fsx');
|
|
6
|
+
const { Prompter } = require('../lib/prompt');
|
|
6
7
|
const { runSelfUpdate } = require('../lib/self-update');
|
|
7
8
|
const log = require('../lib/log');
|
|
8
9
|
|
|
@@ -11,9 +12,8 @@ const PKG_VERSION = require(path.join(PKG_ROOT, 'package.json')).version;
|
|
|
11
12
|
const PKG_SKILLS = path.join(PKG_ROOT, 'skills');
|
|
12
13
|
|
|
13
14
|
module.exports = async function update(flags) {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// skill update. Skipped for global/dlx invocations (no project lockfile).
|
|
15
|
+
// Self-update via the project's PM (pnpm/npm/yarn/bun) with --latest, then
|
|
16
|
+
// re-exec the freshly-installed CLI to do the real work.
|
|
17
17
|
const self = runSelfUpdate(flags, log);
|
|
18
18
|
if (self.error) { log.error(self.message); return self.exitCode; }
|
|
19
19
|
if (self.reExec) return self.exitCode;
|
|
@@ -33,67 +33,124 @@ module.exports = async function update(flags) {
|
|
|
33
33
|
|
|
34
34
|
const manifestVersionBefore = manifest.package_version;
|
|
35
35
|
const diff = diffSkills(PKG_SKILLS, skillsDir, manifest);
|
|
36
|
-
let bakBase = null;
|
|
37
|
-
let refused = false;
|
|
38
|
-
let ioError = false;
|
|
39
|
-
const now = new Date().toISOString();
|
|
40
|
-
const interactive = process.stdin.isTTY || process.env.CREW_FAKE_TTY === '1';
|
|
41
|
-
const stamp = (name, hash) => { manifest.skills[name] = { version: PKG_VERSION, hash, installed_at: now }; };
|
|
42
|
-
const stats = { added: 0, updated: 0, keptEdited: 0, droppedFromPackage: 0, unchanged: 0 };
|
|
43
36
|
|
|
37
|
+
// Categorize per-skill changes the user needs to consent to.
|
|
38
|
+
// - add: missing on disk, present in package
|
|
39
|
+
// - update: on disk + matches manifest, but package hash differs
|
|
40
|
+
// - replace: on disk, edited (manifest hash != disk hash) — about to clobber edits
|
|
41
|
+
// - remove: in manifest + on disk, package no longer ships it
|
|
42
|
+
// managed-unchanged with matching hash → no-op (excluded)
|
|
43
|
+
// foreign with inPkg → leave per spec (excluded)
|
|
44
|
+
// foreign without inPkg → user's own folder, ignore (excluded)
|
|
45
|
+
const changes = [];
|
|
44
46
|
for (const s of diff) {
|
|
45
|
-
// Skill in manifest+disk but no longer in the package — keep it but tell the user.
|
|
46
47
|
if (!s.inPkg && s.inDisk && s.mfHash) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
changes.push({ name: s.name, kind: 'remove' });
|
|
49
|
+
} else if (s.inPkg && s.state === 'missing') {
|
|
50
|
+
changes.push({ name: s.name, kind: 'add', pkgHash: s.pkgHash });
|
|
51
|
+
} else if (s.inPkg && s.state === 'managed-unchanged' && s.diskHash !== s.pkgHash) {
|
|
52
|
+
changes.push({ name: s.name, kind: 'update', pkgHash: s.pkgHash });
|
|
53
|
+
} else if (s.inPkg && s.state === 'managed-edited') {
|
|
54
|
+
changes.push({ name: s.name, kind: 'replace', pkgHash: s.pkgHash, edited: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Header
|
|
59
|
+
if (manifestVersionBefore && manifestVersionBefore !== PKG_VERSION) {
|
|
60
|
+
log.plain(`${PACKAGE_NAME}: ${manifestVersionBefore} → ${PKG_VERSION}`);
|
|
61
|
+
} else {
|
|
62
|
+
log.plain(`${PACKAGE_NAME}: ${PKG_VERSION}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (changes.length === 0) {
|
|
66
|
+
log.plain('all up to date.');
|
|
67
|
+
if (!flags.dryRun && manifestVersionBefore !== PKG_VERSION) {
|
|
68
|
+
manifest.package_version = PKG_VERSION;
|
|
69
|
+
manifest.updated_at = new Date().toISOString();
|
|
70
|
+
try { writeManifest(skillsDir, manifest); }
|
|
71
|
+
catch (e) { log.error(`Failed writing manifest: ${e.message}`); return 3; }
|
|
72
|
+
}
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Show plan
|
|
77
|
+
log.plain('');
|
|
78
|
+
log.plain('The following skills will change:');
|
|
79
|
+
const labelFor = (c) => {
|
|
80
|
+
if (c.kind === 'add') return 'add';
|
|
81
|
+
if (c.kind === 'update') return 'update';
|
|
82
|
+
if (c.kind === 'replace') return c.edited ? 'replace (you have local edits)' : 'replace';
|
|
83
|
+
if (c.kind === 'remove') return 'remove (no longer in package)';
|
|
84
|
+
return c.kind;
|
|
85
|
+
};
|
|
86
|
+
const w = Math.max(...changes.map(c => c.name.length));
|
|
87
|
+
for (const c of changes) log.plain(` - ${c.name.padEnd(w)} ${labelFor(c)}`);
|
|
88
|
+
log.plain('');
|
|
89
|
+
|
|
90
|
+
const interactive = process.stdin.isTTY || process.env.CREW_FAKE_TTY === '1';
|
|
91
|
+
|
|
92
|
+
// Single Prompter shared across the apply + backup prompts so we don't
|
|
93
|
+
// create/close a readline between them (which can drop buffered stdin).
|
|
94
|
+
const prompter = (interactive && !flags.force && !flags.yes) ? new Prompter() : null;
|
|
95
|
+
let apply;
|
|
96
|
+
let doBackup;
|
|
97
|
+
try {
|
|
98
|
+
if (flags.force || flags.yes) {
|
|
99
|
+
apply = true;
|
|
100
|
+
} else if (!interactive) {
|
|
101
|
+
log.error('Cannot prompt: stdin is not a TTY. Re-run with --yes or --force.');
|
|
102
|
+
return 1;
|
|
103
|
+
} else {
|
|
104
|
+
apply = await prompter.confirm('Apply these changes?', true);
|
|
105
|
+
}
|
|
106
|
+
if (!apply) {
|
|
107
|
+
log.plain('Aborted.');
|
|
108
|
+
return 1;
|
|
50
109
|
}
|
|
51
|
-
|
|
110
|
+
// Backup prompt — interactive default N (per the new flow).
|
|
111
|
+
// --yes is CI-safe → backup edits defensively. --force is destructive → no backup.
|
|
112
|
+
if (flags.force) doBackup = false;
|
|
113
|
+
else if (flags.yes) doBackup = true;
|
|
114
|
+
else doBackup = await prompter.confirm('Back up current versions to .bak/<utc>/?', false);
|
|
115
|
+
} finally {
|
|
116
|
+
if (prompter) prompter.close();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Apply
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const stamp = (name, hash) => { manifest.skills[name] = { version: PKG_VERSION, hash, installed_at: now }; };
|
|
122
|
+
let bakBase = null;
|
|
123
|
+
let ioError = false;
|
|
124
|
+
|
|
125
|
+
for (const c of changes) {
|
|
52
126
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
} else {
|
|
76
|
-
action = await chooseEditAction(s.name);
|
|
77
|
-
}
|
|
78
|
-
if (action === 'keep') {
|
|
79
|
-
if (flags.dryRun) log.dryRun('keep', s.name);
|
|
80
|
-
else log.action('keep', s.name);
|
|
81
|
-
stats.keptEdited++;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (action === 'backup') {
|
|
85
|
-
bakBase = bakBase || backupRoot(skillsDir);
|
|
86
|
-
if (flags.dryRun) log.dryRun('backup', s.name);
|
|
87
|
-
else { backupFolder(path.join(skillsDir, s.name), bakBase); log.action('backup', s.name); }
|
|
127
|
+
const live = path.join(skillsDir, c.name);
|
|
128
|
+
const src = path.join(PKG_SKILLS, c.name);
|
|
129
|
+
|
|
130
|
+
if (doBackup && (c.kind === 'update' || c.kind === 'replace' || c.kind === 'remove')) {
|
|
131
|
+
bakBase = bakBase || backupRoot(skillsDir);
|
|
132
|
+
if (flags.dryRun) log.dryRun('backup', c.name);
|
|
133
|
+
else { backupFolder(live, bakBase); log.action('backup', c.name); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (c.kind === 'add') {
|
|
137
|
+
if (flags.dryRun) log.dryRun('add', c.name);
|
|
138
|
+
else { copyFolder(src, live); log.action('add', c.name); }
|
|
139
|
+
stamp(c.name, c.pkgHash);
|
|
140
|
+
} else if (c.kind === 'update' || c.kind === 'replace') {
|
|
141
|
+
if (!doBackup && fs.existsSync(live) && !flags.dryRun) removeFolder(live);
|
|
142
|
+
if (flags.dryRun) log.dryRun('replace', c.name);
|
|
143
|
+
else { copyFolder(src, live); log.action('replace', c.name); }
|
|
144
|
+
stamp(c.name, c.pkgHash);
|
|
145
|
+
} else if (c.kind === 'remove') {
|
|
146
|
+
if (!doBackup) {
|
|
147
|
+
if (flags.dryRun) log.dryRun('remove', c.name);
|
|
148
|
+
else { removeFolder(live); log.action('remove', c.name); }
|
|
88
149
|
}
|
|
89
|
-
|
|
90
|
-
else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
|
|
91
|
-
stamp(s.name, s.pkgHash);
|
|
92
|
-
stats.updated++;
|
|
150
|
+
delete manifest.skills[c.name];
|
|
93
151
|
}
|
|
94
|
-
// foreign: leave (don't touch)
|
|
95
152
|
} catch (e) {
|
|
96
|
-
log.error(`I/O error on ${
|
|
153
|
+
log.error(`I/O error on ${c.name}: ${e.message}`);
|
|
97
154
|
ioError = true;
|
|
98
155
|
}
|
|
99
156
|
}
|
|
@@ -110,23 +167,14 @@ module.exports = async function update(flags) {
|
|
|
110
167
|
|
|
111
168
|
// Summary
|
|
112
169
|
log.plain('');
|
|
113
|
-
|
|
114
|
-
log.plain(`${PACKAGE_NAME}: ${manifestVersionBefore} → ${PKG_VERSION}`);
|
|
115
|
-
} else {
|
|
116
|
-
log.plain(`${PACKAGE_NAME}: ${PKG_VERSION}`);
|
|
117
|
-
}
|
|
170
|
+
const counts = changes.reduce((a, c) => (a[c.kind] = (a[c.kind] || 0) + 1, a), {});
|
|
118
171
|
const parts = [];
|
|
119
|
-
if (
|
|
120
|
-
if (
|
|
121
|
-
if (
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
log.plain(parts.length ? parts.join(', ') + '.' : 'nothing to do.');
|
|
125
|
-
if (stats.droppedFromPackage) {
|
|
126
|
-
log.plain(`Run \`crew uninstall && crew init\` to drop skills the package no longer ships.`);
|
|
127
|
-
}
|
|
172
|
+
if (counts.add) parts.push(`${counts.add} added`);
|
|
173
|
+
if (counts.update) parts.push(`${counts.update} updated`);
|
|
174
|
+
if (counts.replace) parts.push(`${counts.replace} replaced`);
|
|
175
|
+
if (counts.remove) parts.push(`${counts.remove} removed`);
|
|
176
|
+
log.plain(parts.join(', ') + '.');
|
|
128
177
|
|
|
129
178
|
if (ioError) return 3;
|
|
130
|
-
if (refused) return 1;
|
|
131
179
|
return 0;
|
|
132
180
|
};
|
package/scripts/lib/prompt.js
CHANGED
|
@@ -1,40 +1,73 @@
|
|
|
1
1
|
const readline = require('readline');
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
// One-shot confirm — creates and closes its own readline. OK for a single
|
|
4
|
+
// question; if you need several in a row use Prompter so the interface (and
|
|
5
|
+
// stdin's internal buffer) stays alive across them.
|
|
6
|
+
function confirm(question, defaultYes = false) {
|
|
7
|
+
const p = new Prompter();
|
|
8
|
+
return p.confirm(question, defaultYes).finally(() => p.close());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class Prompter {
|
|
12
|
+
constructor() {
|
|
13
|
+
this._rl = readline.createInterface({ input: process.stdin });
|
|
14
|
+
this._lines = [];
|
|
15
|
+
this._waiters = [];
|
|
16
|
+
this._closed = false;
|
|
17
|
+
this._rl.on('line', (line) => {
|
|
18
|
+
if (this._waiters.length) this._waiters.shift()(line);
|
|
19
|
+
else this._lines.push(line);
|
|
20
|
+
});
|
|
21
|
+
this._rl.on('close', () => {
|
|
22
|
+
this._closed = true;
|
|
23
|
+
while (this._waiters.length) this._waiters.shift()(null);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
_question(prompt) {
|
|
27
|
+
process.stdout.write(prompt);
|
|
28
|
+
if (this._lines.length) return Promise.resolve(this._lines.shift());
|
|
29
|
+
if (this._closed) return Promise.resolve(null);
|
|
30
|
+
return new Promise((resolve) => this._waiters.push(resolve));
|
|
31
|
+
}
|
|
32
|
+
async confirm(question, defaultYes = false) {
|
|
33
|
+
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
34
|
+
const answer = await this._question(`${question} ${hint} > `);
|
|
35
|
+
if (answer === null) return defaultYes;
|
|
36
|
+
const a = answer.trim().toLowerCase();
|
|
37
|
+
if (a === '') return defaultYes;
|
|
38
|
+
return a === 'y' || a === 'yes';
|
|
39
|
+
}
|
|
40
|
+
async chooseEditAction(skillName) {
|
|
41
|
+
while (true) {
|
|
7
42
|
console.log(`The skill '${skillName}' has local edits.`);
|
|
8
43
|
console.log(' (b) backup edits and replace [default]');
|
|
9
44
|
console.log(' (k) keep edits, skip update');
|
|
10
45
|
console.log(' (r) replace, discard edits');
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
const answer = await this._question('> ');
|
|
47
|
+
if (answer === null) return 'backup';
|
|
48
|
+
const a = (answer || '').trim().toLowerCase();
|
|
49
|
+
if (a === '' || a === 'b') return 'backup';
|
|
50
|
+
if (a === 'k') return 'keep';
|
|
51
|
+
if (a === 'r') return 'replace';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
close() { this._rl.close(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Back-compat shims for init.js
|
|
58
|
+
function chooseEditAction(skillName) {
|
|
59
|
+
const p = new Prompter();
|
|
60
|
+
return p.chooseEditAction(skillName).finally(() => p.close());
|
|
21
61
|
}
|
|
22
62
|
|
|
23
63
|
function confirmAbsorbForeign(skillNames) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
-
rl.question('[y/N] > ', (answer) => {
|
|
33
|
-
rl.close();
|
|
34
|
-
const a = (answer || '').trim().toLowerCase();
|
|
35
|
-
resolve(a === 'y' || a === 'yes');
|
|
36
|
-
});
|
|
37
|
-
});
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log('The following skills already exist in .claude/skills/ but are not tracked by crew:');
|
|
66
|
+
console.log('');
|
|
67
|
+
for (const n of skillNames) console.log(` - ${n}`);
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log('Override and absorb them? Originals will be backed up to .claude/skills/.bak/<utc>/.');
|
|
70
|
+
return confirm('[y/N]', false);
|
|
38
71
|
}
|
|
39
72
|
|
|
40
|
-
module.exports = { chooseEditAction, confirmAbsorbForeign };
|
|
73
|
+
module.exports = { confirm, chooseEditAction, confirmAbsorbForeign, Prompter };
|
|
@@ -38,6 +38,11 @@ function isLocalDep(projectRoot) {
|
|
|
38
38
|
} catch { return false; }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Range-respecting update so we don't rewrite the user's package.json
|
|
42
|
+
// version specifier. To always pull the absolute latest across majors,
|
|
43
|
+
// users can pin "@devshop/crew": "latest" in their package.json (a dist-tag,
|
|
44
|
+
// not a semver range — never gets rewritten); for "all 0.x but never 1.x"
|
|
45
|
+
// use "0.x". Any caret/tilde range will be honored as written.
|
|
41
46
|
const UPDATE_ARGS = {
|
|
42
47
|
pnpm: ['update', PACKAGE_NAME],
|
|
43
48
|
npm: ['update', PACKAGE_NAME],
|