@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 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, just run `pnpm exec crew update` it auto-detects the package manager (pnpm/npm/yarn/bun) from your lockfile, runs `<pm> update @devshop/crew` to refresh the local install, then re-execs the freshly-installed CLI to apply the new skills. Pass `--no-self-update` to skip the package bump and only re-apply what's already on disk.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@devshop/crew",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Project-agnostic Claude Code skills for spec → implement → qa → review → ship",
5
5
  "bin": {
6
6
  "crew": "scripts/cli.js"
@@ -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 { chooseEditAction } = require('../lib/prompt');
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
- // If installed as a local dep, bump the package via the project's package
15
- // manager first, then re-exec the freshly-installed CLI to do the actual
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
- log.warn(`kept (no longer in package): ${s.name}`);
48
- stats.droppedFromPackage++;
49
- continue;
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
- if (!s.inPkg) continue;
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
- if (s.state === 'missing') {
54
- if (flags.dryRun) log.dryRun('copy', s.name);
55
- else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('copy', s.name); }
56
- stamp(s.name, s.pkgHash);
57
- stats.added++;
58
- } else if (s.state === 'managed-unchanged') {
59
- if (s.diskHash !== s.pkgHash) {
60
- if (flags.dryRun) log.dryRun('replace', s.name);
61
- else { copyFolder(path.join(PKG_SKILLS, s.name), path.join(skillsDir, s.name)); log.action('replace', s.name); }
62
- stamp(s.name, s.pkgHash);
63
- stats.updated++;
64
- } else {
65
- stats.unchanged++;
66
- }
67
- } else if (s.state === 'managed-edited') {
68
- let action;
69
- if (flags.force) action = 'replace';
70
- else if (flags.yes) action = 'backup';
71
- else if (!interactive) {
72
- log.error(`Edited skill detected ('${s.name}') and stdin is not a TTY. Re-run with --yes or --force.`);
73
- refused = true;
74
- continue;
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
- if (flags.dryRun) log.dryRun('replace', s.name);
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 ${s.name}: ${e.message}`);
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
- if (manifestVersionBefore && manifestVersionBefore !== PKG_VERSION) {
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 (stats.added) parts.push(`${stats.added} added`);
120
- if (stats.updated) parts.push(`${stats.updated} updated`);
121
- if (stats.keptEdited) parts.push(`${stats.keptEdited} edited (kept)`);
122
- if (stats.droppedFromPackage) parts.push(`${stats.droppedFromPackage} no longer in package`);
123
- if (stats.unchanged) parts.push(`${stats.unchanged} unchanged`);
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
  };
@@ -1,40 +1,73 @@
1
1
  const readline = require('readline');
2
2
 
3
- function chooseEditAction(skillName) {
4
- return new Promise((resolve) => {
5
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6
- const ask = () => {
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
- rl.question('> ', (answer) => {
12
- const a = (answer || '').trim().toLowerCase();
13
- if (a === '' || a === 'b') { rl.close(); resolve('backup'); }
14
- else if (a === 'k') { rl.close(); resolve('keep'); }
15
- else if (a === 'r') { rl.close(); resolve('replace'); }
16
- else ask();
17
- });
18
- };
19
- ask();
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
- return new Promise((resolve) => {
25
- console.log('');
26
- console.log('The following skills already exist in .claude/skills/ but are not tracked by crew:');
27
- console.log('');
28
- for (const n of skillNames) console.log(` - ${n}`);
29
- console.log('');
30
- console.log('Override and absorb them? Originals will be backed up to .claude/skills/.bak/<utc>/.');
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],