devlyn-cli 4.2.0 → 4.2.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.
Files changed (132) hide show
  1. package/README.md +4 -2
  2. package/bin/devlyn.js +170 -143
  3. package/bin/skill-history.json +140 -0
  4. package/bin/skill-ownership.js +67 -0
  5. package/node_modules/@inquirer/ansi/LICENSE +22 -0
  6. package/node_modules/@inquirer/ansi/README.md +121 -0
  7. package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
  8. package/node_modules/@inquirer/ansi/dist/index.js +21 -0
  9. package/node_modules/@inquirer/ansi/package.json +79 -0
  10. package/node_modules/@inquirer/checkbox/LICENSE +22 -0
  11. package/node_modules/@inquirer/checkbox/README.md +265 -0
  12. package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
  13. package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
  14. package/node_modules/@inquirer/checkbox/package.json +93 -0
  15. package/node_modules/@inquirer/core/LICENSE +22 -0
  16. package/node_modules/@inquirer/core/README.md +424 -0
  17. package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
  18. package/node_modules/@inquirer/core/dist/index.js +12 -0
  19. package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
  20. package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
  21. package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
  22. package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
  23. package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
  24. package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
  25. package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
  26. package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
  27. package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
  28. package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
  29. package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
  30. package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
  31. package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
  32. package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
  33. package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
  34. package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
  35. package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
  36. package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
  37. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
  38. package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
  39. package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
  40. package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
  41. package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
  42. package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
  43. package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
  44. package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
  45. package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
  46. package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
  47. package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
  48. package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
  49. package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
  50. package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
  51. package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
  52. package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
  53. package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
  54. package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
  55. package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
  56. package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
  57. package/node_modules/@inquirer/core/package.json +98 -0
  58. package/node_modules/@inquirer/figures/LICENSE +22 -0
  59. package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
  60. package/node_modules/@inquirer/figures/dist/index.js +315 -0
  61. package/node_modules/@inquirer/figures/package.json +81 -0
  62. package/node_modules/@inquirer/select/LICENSE +22 -0
  63. package/node_modules/@inquirer/select/README.md +258 -0
  64. package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
  65. package/node_modules/@inquirer/select/dist/index.js +191 -0
  66. package/node_modules/@inquirer/select/package.json +93 -0
  67. package/node_modules/@inquirer/type/LICENSE +22 -0
  68. package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
  69. package/node_modules/@inquirer/type/dist/index.js +2 -0
  70. package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
  71. package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
  72. package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
  73. package/node_modules/@inquirer/type/dist/utils.js +16 -0
  74. package/node_modules/@inquirer/type/package.json +88 -0
  75. package/node_modules/cli-width/LICENSE +13 -0
  76. package/node_modules/cli-width/README.md +71 -0
  77. package/node_modules/cli-width/index.d.ts +13 -0
  78. package/node_modules/cli-width/index.js +49 -0
  79. package/node_modules/cli-width/package.json +40 -0
  80. package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
  81. package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
  82. package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
  83. package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
  84. package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
  85. package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
  86. package/node_modules/fast-string-truncated-width/license +21 -0
  87. package/node_modules/fast-string-truncated-width/package.json +35 -0
  88. package/node_modules/fast-string-truncated-width/readme.md +59 -0
  89. package/node_modules/fast-string-width/dist/index.d.ts +4 -0
  90. package/node_modules/fast-string-width/dist/index.js +14 -0
  91. package/node_modules/fast-string-width/license +21 -0
  92. package/node_modules/fast-string-width/package.json +34 -0
  93. package/node_modules/fast-string-width/readme.md +42 -0
  94. package/node_modules/fast-wrap-ansi/LICENSE +23 -0
  95. package/node_modules/fast-wrap-ansi/README.md +26 -0
  96. package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
  97. package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
  98. package/node_modules/fast-wrap-ansi/package.json +51 -0
  99. package/node_modules/mute-stream/LICENSE +15 -0
  100. package/node_modules/mute-stream/README.md +68 -0
  101. package/node_modules/mute-stream/lib/index.js +142 -0
  102. package/node_modules/mute-stream/package.json +54 -0
  103. package/node_modules/signal-exit/LICENSE.txt +16 -0
  104. package/node_modules/signal-exit/README.md +74 -0
  105. package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  106. package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  107. package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  108. package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  109. package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  110. package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  111. package/node_modules/signal-exit/dist/cjs/index.js +279 -0
  112. package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  113. package/node_modules/signal-exit/dist/cjs/package.json +3 -0
  114. package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  115. package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  116. package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  117. package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  118. package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  119. package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  120. package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  121. package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  122. package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  123. package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  124. package/node_modules/signal-exit/dist/mjs/index.js +275 -0
  125. package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  126. package/node_modules/signal-exit/dist/mjs/package.json +3 -0
  127. package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  128. package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  129. package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  130. package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  131. package/node_modules/signal-exit/package.json +106 -0
  132. package/package.json +16 -2
package/README.md CHANGED
@@ -215,10 +215,12 @@ Selected during install. Run `npx devlyn-cli` again to add more.
215
215
 
216
216
  ---
217
217
 
218
+ Installer updates preserve user work: before writing any selected target, devlyn refuses to replace or remove git-tracked skills, edited skills, or directories with extra files (including hidden files). `-y` does not bypass this check. Commit your work or move conflicting folders to a backup and migrate them manually; tracked folders must be moved out of the install path before rerunning. Unchanged installer copies and pristine untracked 4.1.0 installs upgrade automatically. Older copies without verifiable content history require manual migration.
219
+
218
220
  ## Requirements
219
221
 
220
- - **Node.js 18+** and npm
221
- - **Python 3.11+** available as `python3`, and Git for the harness
222
+ - **Node.js 20 (20.17+), 22 (22.13+) or 23.5+** and npm
223
+ - **Python 3.11+** available as `python3` for the harness, and **Git** for safe installation and the harness
222
224
  - **An agent CLI** installed and configured: Codex, omp, Pi or Grok (AGENTS.md), or [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLAUDE.md)
223
225
  - **For `auto`/`pr` delivery**, an `origin` remote naming one GitHub repository and an authenticated [`gh`](https://cli.github.com/)
224
226
 
package/bin/devlyn.js CHANGED
@@ -4,13 +4,14 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
- const readline = require('readline');
8
7
  const { execSync } = require('child_process');
9
8
 
10
9
  const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
11
10
  const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
12
11
  const PKG = require('../package.json');
13
12
  const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
13
+ const { contentHash, fingerprint, isOsMetadata, trackedPaths } = require('./skill-ownership');
14
+ const SKILL_HISTORY = require('./skill-history.json');
14
15
 
15
16
  // The devlyn skill bundle installed into every skill-capable agent's loader
16
17
  // directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
@@ -82,13 +83,13 @@ const RETIRED_SKILL_MD_SHA256 = {
82
83
  ]),
83
84
  };
84
85
 
85
- // Whether `dir` is a real folder whose only file (dotfiles such as .DS_Store aside) is a SKILL.md
86
+ // Whether `dir` is a real folder whose only file, besides OS metadata, is a SKILL.md
86
87
  // with an LF-normalized SHA-256 in `hashes`: an unedited copy devlyn-cli shipped, not something
87
88
  // the user added to or wrote.
88
89
  function isShippedCopy(dir, hashes) {
89
90
  const skill = path.join(dir, 'SKILL.md');
90
91
  return fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() === true
91
- && fs.readdirSync(dir).filter((name) => !name.startsWith('.')).length === 1
92
+ && fs.readdirSync(dir).filter((name) => !isOsMetadata(path.join(dir, name))).length === 1
92
93
  && fs.lstatSync(skill, { throwIfNoEntry: false })?.isFile() === true
93
94
  && hashes.has(crypto.createHash('sha256')
94
95
  .update(fs.readFileSync(skill, 'utf8').replace(/\r\n/g, '\n')).digest('hex'));
@@ -121,7 +122,8 @@ function hasDevlynClaude(global) {
121
122
  || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
122
123
  }
123
124
 
124
- // Commands removed in previous versions; the project Claude install deletes them from .claude/.
125
+ // Commands removed in previous versions; the project Claude install deletes them from .claude/
126
+ // when they hold the bytes a version shipped (SKILL_HISTORY.commands).
125
127
  const DEPRECATED_FILES = [
126
128
  'commands/devlyn.handoff.md', // removed in v0.2.0
127
129
  'commands/devlyn.clean.md', // migrated to skills in v0.6.0
@@ -146,7 +148,7 @@ const DEPRECATED_FILES = [
146
148
  // Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
147
149
  // iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
148
150
  // to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
149
- // force-remove stale legacy skill dirs from downstream `~/.claude/skills/`
151
+ // remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
150
152
  // even though the source dirs no longer exist (the installer replaces only
151
153
  // the skills it ships — without this list, deleted-from-source skills persist
152
154
  // in user installs forever).
@@ -374,15 +376,22 @@ function cleanupDeprecated(skillsDir) {
374
376
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
375
377
  function retireRenamedSkills(skillsDir) {
376
378
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
379
+ const refreshed = [];
377
380
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
378
381
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
379
- if (found.length > 0 && optional.has(newName)) installOptionalSkillInto(skillsDir, newName);
380
- else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) refreshPreStandardCopy(skillsDir, newName);
382
+ if (found.length > 0 && optional.has(newName)) {
383
+ installOptionalSkillInto(skillsDir, newName);
384
+ refreshed.push(newName);
385
+ } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
386
+ refreshPreStandardCopy(skillsDir, newName);
387
+ refreshed.push(newName);
388
+ }
381
389
  for (const fullPath of found) {
382
390
  fs.rmSync(fullPath, { recursive: true, force: true });
383
391
  log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
384
392
  }
385
393
  }
394
+ return refreshed;
386
395
  }
387
396
 
388
397
  function copyRecursive(src, dest, baseDir) {
@@ -410,6 +419,101 @@ function clearInstallMarker(skillsDir) {
410
419
  fs.rmSync(path.join(skillsDir, DEVLYN_INSTALL_MARKER), { force: true });
411
420
  }
412
421
 
422
+ function readInstallManifest(root) {
423
+ const markerPath = path.join(root, DEVLYN_INSTALL_MARKER);
424
+ const stat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
425
+ if (!stat) return {};
426
+ if (!stat.isFile()) throw new Error(`not a regular install marker: ${markerPath}`);
427
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
428
+ return marker?.package === PKG.name && marker.schemaVersion === 1 ? marker.skills || {} : {};
429
+ }
430
+
431
+ // Enumerate every directory this install may replace, migrate or remove, before
432
+ // touching any target. Optional addons must be selected before this preflight too.
433
+ function plannedSkills(root, optionalNames = []) {
434
+ const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
435
+ for (const relative of DEPRECATED_DIRS) {
436
+ for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
437
+ }
438
+ for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
439
+ const oldPaths = legacySkillPaths(root, oldName);
440
+ oldPaths.forEach((full) => names.add(path.basename(full)));
441
+ if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
442
+ || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
443
+ }
444
+ for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
445
+ if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
446
+ }
447
+ return names;
448
+ }
449
+
450
+ // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
451
+ function preflightSkills(roots, optionalNames = [], commandDir = null) {
452
+ const conflicts = [];
453
+ try {
454
+ // Do not follow a commands link into another installation either.
455
+ const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
456
+ if (commands && !commands.isDirectory()) throw new Error('not a real directory');
457
+ const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
458
+ for (const relPath of commandDir ? DEPRECATED_FILES : []) {
459
+ const fullPath = path.join(commandDir, relPath);
460
+ const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
461
+ if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
462
+ const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
463
+ conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
464
+ + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
465
+ }
466
+ } catch (error) {
467
+ conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
468
+ }
469
+ for (const root of roots) {
470
+ try {
471
+ // Do not follow an agent-directory or skill-root link into another installation.
472
+ for (const dir of [path.dirname(root), root]) {
473
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
474
+ if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
475
+ }
476
+ const tracked = trackedPaths(root);
477
+ const manifest = readInstallManifest(root);
478
+ for (const name of plannedSkills(root, optionalNames)) {
479
+ const dest = path.join(root, name);
480
+ const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
481
+ || file.toLowerCase().startsWith(dest.toLowerCase() + path.sep));
482
+ try {
483
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) {
484
+ if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
485
+ continue;
486
+ }
487
+ const actual = fingerprint(dest);
488
+ const logicalName = name.replace(/[:\uF03A]/g, '-');
489
+ const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
490
+ ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
491
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
492
+ const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
493
+ const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
494
+ if (!current && !historical && !singleFile && manifest[name] !== actual) {
495
+ throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
496
+ : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
497
+ ? 'fingerprint mismatch with known shipped copies'
498
+ : 'unknown installer ownership (no fingerprint history for this copy)');
499
+ }
500
+ } catch (error) {
501
+ conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
502
+ }
503
+ }
504
+ } catch (error) {
505
+ conflicts.push(`${root}: ${error.message}`);
506
+ }
507
+ }
508
+ if (conflicts.length) {
509
+ throw new InstructionError('Installation refused; nothing was changed:\n'
510
+ + conflicts.map((conflict) => ` - ${conflict}`).join('\n')
511
+ + '\nMove these out of the install path, or back them up and remove them, then rerun.'
512
+ + '\nFor tracked paths, also remove them from the Git index.'
513
+ + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
514
+ }
515
+ }
516
+
413
517
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
414
518
  function complete(src, dest) {
415
519
  if (!fs.existsSync(src) || !fs.existsSync(dest)) return false;
@@ -429,13 +533,14 @@ function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
429
533
  }
430
534
  }
431
535
 
432
- function writeInstallMarker(skillsDir) {
536
+ function writeInstallMarker(skillsDir, names, previous) {
433
537
  const markerPath = path.join(skillsDir, DEVLYN_INSTALL_MARKER);
434
538
  const tempPath = `${markerPath}.${process.pid}.tmp`;
435
539
  const marker = {
436
540
  schemaVersion: 1,
437
541
  package: PKG.name,
438
542
  version: PKG.version,
543
+ skills: { ...previous, ...Object.fromEntries(names.map((name) => [name, fingerprint(path.join(skillsDir, name))])) },
439
544
  };
440
545
  try {
441
546
  fs.writeFileSync(tempPath, JSON.stringify(marker, null, 2) + '\n', {
@@ -449,141 +554,65 @@ function writeInstallMarker(skillsDir) {
449
554
  }
450
555
  }
451
556
 
452
- function multiSelect(items, preselectedIndices = []) {
453
- return new Promise((resolve) => {
454
- const selected = new Set(preselectedIndices.filter((i) => i >= 0 && i < items.length));
455
- let cursor = 0;
456
- let firstRender = true;
457
-
458
- const render = () => {
459
- // Move cursor up to redraw (skip on first render)
460
- const totalLines = items.length * 2 + 2; // 2 lines per item + header + blank
461
- if (!firstRender) {
462
- process.stdout.write(`\x1b[${totalLines}A\x1b[0J`); // Move up and clear to end of screen
463
- }
464
- firstRender = false;
465
-
466
- console.log(`${COLORS.dim}(↑↓ navigate, space select, enter confirm)${COLORS.reset}\n`);
467
-
468
- items.forEach((item, i) => {
469
- const checkbox = selected.has(i) ? `${COLORS.green}◉${COLORS.reset}` : `${COLORS.dim}○${COLORS.reset}`;
470
- const pointer = i === cursor ? `${COLORS.cyan}❯${COLORS.reset}` : ' ';
471
- const name = i === cursor ? `${COLORS.cyan}${item.name}${COLORS.reset}` : item.name;
472
- const tagLabel = item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack';
473
- const tagColor = item.type === 'mcp' ? COLORS.green : item.type === 'local' ? COLORS.magenta : COLORS.cyan;
474
- const tag = `${tagColor}${tagLabel}${COLORS.reset}`;
475
- console.log(`${pointer} ${checkbox} ${name}${item.type ? ` ${COLORS.dim}[${tag}${COLORS.dim}]${COLORS.reset}` : ''}`);
476
- console.log(` ${COLORS.dim}${item.desc}${COLORS.reset}`);
477
- });
478
- };
479
-
480
- render();
481
-
482
- process.stdin.setRawMode(true);
483
- process.stdin.resume();
484
- process.stdin.setEncoding('utf8');
485
-
486
- const onKeypress = (key) => {
487
- // Ctrl+C
488
- if (key === '\u0003') {
489
- process.stdin.setRawMode(false);
490
- process.stdin.removeListener('data', onKeypress);
491
- process.exit();
492
- }
493
-
494
- // Enter
495
- if (key === '\r' || key === '\n') {
496
- process.stdin.setRawMode(false);
497
- process.stdin.removeListener('data', onKeypress);
498
- process.stdin.pause();
499
- console.log('');
500
- resolve([...selected].map((i) => items[i]));
501
- return;
502
- }
503
-
504
- // Space - toggle selection
505
- if (key === ' ') {
506
- if (selected.has(cursor)) {
507
- selected.delete(cursor);
508
- } else {
509
- selected.add(cursor);
510
- }
511
- render();
512
- return;
513
- }
514
-
515
- // Arrow up or k
516
- if (key === '\x1b[A' || key === 'k') {
517
- cursor = cursor > 0 ? cursor - 1 : items.length - 1;
518
- render();
519
- return;
520
- }
521
-
522
- // Arrow down or j
523
- if (key === '\x1b[B' || key === 'j') {
524
- cursor = cursor < items.length - 1 ? cursor + 1 : 0;
525
- render();
526
- return;
527
- }
528
-
529
- // 'a' - select all
530
- if (key === 'a') {
531
- if (selected.size === items.length) {
532
- selected.clear();
533
- } else {
534
- items.forEach((_, i) => selected.add(i));
535
- }
536
- render();
537
- return;
538
- }
539
- };
540
-
541
- process.stdin.on('data', onKeypress);
557
+ // One Inquirer menu, imported only when shown (Inquirer is ESM). The page gets the terminal
558
+ // rows left after everything else the menu draws, so a redraw never scrolls it into
559
+ // duplicates. Ctrl+C cancels the install.
560
+ async function ask(module, help, config) {
561
+ const { default: prompt } = await import(module);
562
+ const { stdin: input, stdout: output } = process;
563
+ const columns = output.columns || 80;
564
+ // Most rows a text wraps to: an ASCII character takes one cell, any other at most two.
565
+ const rows = (text = '') => Math.ceil([...text].reduce((cells, c) => cells + (c.codePointAt(0) < 0x80 ? 1 : 2), 0) / columns);
566
+ // Message, blank line, the active choice's description, key help, and the line Inquirer
567
+ // adds when the help exactly fills the width.
568
+ const reserved = rows(`? ${config.message}`) + 1 + Math.max(...config.choices.map((choice) => rows(choice.description))) + rows(help) + 1;
569
+ try {
570
+ return await prompt({
571
+ ...config,
572
+ pageSize: Math.max(1, (output.rows || 24) - reserved),
573
+ theme: { keybindings: ['vim'], style: { keysHelpTip: () => help } },
574
+ }, { input, output });
575
+ } catch (error) {
576
+ if (error.name !== 'ExitPromptError') throw error;
577
+ console.log('Installation cancelled.');
578
+ process.exit(0);
579
+ }
580
+ }
581
+
582
+ // Checkbox menu (space toggles, a toggles all); resolves to the chosen items.
583
+ function multiSelect(message, items, preselectedIndices = []) {
584
+ return ask('@inquirer/checkbox', '↑↓ navigate · space select · a all · enter confirm', {
585
+ message,
586
+ choices: items.map((item, index) => ({
587
+ name: item.type ? `${item.name} [${item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack'}]` : item.name,
588
+ short: item.name,
589
+ description: item.desc,
590
+ value: item,
591
+ checked: preselectedIndices.includes(index),
592
+ })),
542
593
  });
543
594
  }
544
595
 
545
- // One of `items` (↑↓ move, Enter confirms); resolves to the chosen index.
546
- function singleSelect(items, initial) {
547
- return new Promise((resolve) => {
548
- let cursor = initial;
549
- let drawn = false;
550
- const render = () => {
551
- if (drawn) process.stdout.write(`\x1b[${items.length + 2}A\x1b[0J`);
552
- drawn = true;
553
- console.log(`${COLORS.dim}(↑↓ navigate, enter confirm)${COLORS.reset}\n`);
554
- items.forEach((item, i) => console.log(i === cursor ? `${COLORS.cyan}❯ ${item}${COLORS.reset}` : ` ${item}`));
555
- };
556
- render();
557
- process.stdin.setRawMode(true);
558
- process.stdin.resume();
559
- process.stdin.setEncoding('utf8');
560
- const onKeypress = (key) => {
561
- if (key === '\u0003') {
562
- process.stdin.setRawMode(false);
563
- process.exit();
564
- }
565
- if (key === '\r' || key === '\n') {
566
- process.stdin.setRawMode(false);
567
- process.stdin.removeListener('data', onKeypress);
568
- process.stdin.pause();
569
- console.log('');
570
- resolve(cursor);
571
- } else if (['\x1b[A', 'k', '\x1b[B', 'j'].includes(key)) {
572
- cursor = (cursor + (key === '\x1b[A' || key === 'k' ? items.length - 1 : 1)) % items.length;
573
- render();
574
- }
575
- };
576
- process.stdin.on('data', onKeypress);
596
+ // One of `items`; resolves to the chosen index.
597
+ function singleSelect(message, items, initial) {
598
+ return ask('@inquirer/select', '↑↓ navigate · enter confirm', {
599
+ message,
600
+ choices: items.map((name, index) => ({ name, value: index })),
601
+ default: initial,
577
602
  });
578
603
  }
579
604
 
605
+ // After install(), whose preflight already cleared every selected addon before any change.
580
606
  function installLocalSkill(skillName, roots) {
581
607
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
582
608
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
583
609
  return false;
584
610
  }
585
611
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
586
- for (const root of roots) installOptionalSkillInto(root, skillName);
612
+ for (const root of roots) {
613
+ installOptionalSkillInto(root, skillName);
614
+ writeInstallMarker(root, [skillName], readInstallManifest(root));
615
+ }
587
616
  return true;
588
617
  }
589
618
 
@@ -653,6 +682,7 @@ function installCoreSkills(skillsDir) {
653
682
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
654
683
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
655
684
  fs.mkdirSync(skillsDir, { recursive: true });
685
+ const previous = readInstallManifest(skillsDir);
656
686
  clearInstallMarker(skillsDir);
657
687
  const removed = cleanupDeprecated(skillsDir);
658
688
  if (removed > 0) {
@@ -667,8 +697,8 @@ function installCoreSkills(skillsDir) {
667
697
  copyRecursive(src, dest, skillsDir);
668
698
  }
669
699
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
670
- retireRenamedSkills(skillsDir);
671
- writeInstallMarker(skillsDir);
700
+ const refreshed = retireRenamedSkills(skillsDir);
701
+ writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
672
702
  }
673
703
 
674
704
  // Keep installer-managed pipeline state and install metadata out of git.
@@ -820,7 +850,7 @@ function installClaudeCore() {
820
850
  }
821
851
 
822
852
  // Installs the targets in one scope; returns the skill roots written.
823
- function install(targets, global) {
853
+ function install(targets, global, optionalNames = []) {
824
854
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
825
855
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
826
856
  // another spelling of the path is caught too.
@@ -831,6 +861,8 @@ function install(targets, global) {
831
861
  + 'would apply to every project. Run from a project folder, or use --global for skills only.');
832
862
  }
833
863
  const roots = targets.flatMap((target) => skillRoots(target, global));
864
+ preflightSkills(roots, optionalNames,
865
+ !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
834
866
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
835
867
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
836
868
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
@@ -885,13 +917,12 @@ async function init({ yes, claude, global }) {
885
917
  return;
886
918
  }
887
919
 
888
- log('\n🎯 What to install:\n', 'blue');
889
920
  const targetOptions = [
890
921
  { key: 'agents', name: 'AGENTS.md — Codex · omp · Pi · Grok', desc: 'AGENTS.md + .agents/skills' },
891
922
  { key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills, settings)' },
892
923
  ];
893
924
  const preselected = claude || hasDevlynClaude(false) || (global && hasDevlynClaude(true)) ? [0, 1] : [0];
894
- const targets = (await multiSelect(targetOptions, preselected)).map((option) => option.key);
925
+ const targets = (await multiSelect('What to install', targetOptions, preselected)).map((option) => option.key);
895
926
 
896
927
  if (targets.length === 0) {
897
928
  log('\n💡 Nothing selected — nothing installed.', 'yellow');
@@ -899,14 +930,10 @@ async function init({ yes, claude, global }) {
899
930
  return;
900
931
  }
901
932
 
902
- log('📍 Where:\n', 'blue');
903
- const scope = await singleSelect(['This project', 'Global — every project on this machine'], global ? 1 : 0);
904
- const roots = install(targets, scope === 1);
905
-
933
+ const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
906
934
  // Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
907
- log('\n📚 Optional skills & packs:\n', 'blue');
908
-
909
- const selectedAddons = await multiSelect(OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
935
+ const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
936
+ const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
910
937
 
911
938
  if (selectedAddons.length > 0) {
912
939
  for (const addon of selectedAddons) {
@@ -0,0 +1,140 @@
1
+ {
2
+ "source": "devlyn-cli@4.1.0 (npm tarball sha1 398ff07d3d689b56cc22e18ac50260bc6e4e9e85); devlyn-cli@4.2.0 (npm tarball sha1 c93c2fb1cf5630b18d2eba0085182c14fddbe2d5); whole-tree fingerprints from bin/skill-ownership.js; deprecated commands: commands/ and optional-commands/*/ files of devlyn-cli 0.0.1-0.5.8 (every published version that shipped them), LF-normalized SHA-256",
3
+ "skills": {
4
+ "_shared": [
5
+ "eb3327d5e6940bd5e0459fa28c9ab323d9c58ccfdeb9426c19cd027cc4e5c4ed",
6
+ "8c80926ab03b2477e52264495b35c2116f65fc51b02e8c56e441b5705872392a"
7
+ ],
8
+ "devlyn-design-ui": [
9
+ "91080266232fb62b6f1d45932184f065589e12eb66f02fb6442527c6dfcd81b6"
10
+ ],
11
+ "devlyn-engines": [
12
+ "3451d37f47e6e707cff92baa12d04b43aee94301456b8e280c09332e598a1c2c",
13
+ "89f0efaa2efdfddc887b494138247980d97856f6cabe6a8dcc585b3a28e5fa88"
14
+ ],
15
+ "devlyn-ideate": [
16
+ "8ff5148cc65b4608c35b29afd6fef20d42d8da93a094144d98cfc63e74089b6e",
17
+ "5824f07e455fe1c3942bf595a12e935258d4c0a851ca1bf741db915603d1c4e0"
18
+ ],
19
+ "devlyn-queue": [
20
+ "4c7d988dcd154c7bf658d2d24cd7868955df9d854ed87eb784742d6d4bb229e0"
21
+ ],
22
+ "devlyn-resolve": [
23
+ "2abd303851f085da07f6b55ee1724842ef7f8e8ad6b68c6339df5f1eaa8c69de"
24
+ ],
25
+ "asset-creator": [
26
+ "e295f57eca534cbd4e27e236999f4f5412ebdc6944ac864761e922dea213f904"
27
+ ],
28
+ "better-auth-setup": [
29
+ "e97c39c5d669ffc1f49d57fd3a016e852727e57c3abe2f9be1ebffab9fcb19ec"
30
+ ],
31
+ "cloudflare-nextjs-setup": [
32
+ "f147a28b9f6f62feb21df89bdf08869820f4871ede7a8ba4a242246509ee6e35"
33
+ ],
34
+ "code-health-standards": [
35
+ "2056617b324bb7e1e3b85a6536bfe33151e31ef421656cade74258fb4e922c1b"
36
+ ],
37
+ "code-review-standards": [
38
+ "3fe0742825aff05fa6fda49d3e3e0fbf5457307af944eb90f2771cbf611edd69"
39
+ ],
40
+ "devlyn-pencil-pull": [
41
+ "65af00f88b0837ffd377ebf363da73c766ae0ae8e20716fe18f80edfa91bd6da"
42
+ ],
43
+ "devlyn-pencil-push": [
44
+ "76955458ced72f4b0bdaf71482be6097c4c86876934b6e6a2ac7d19f850662ad"
45
+ ],
46
+ "devlyn-reap": [
47
+ "77e029ab06c37fb7135a9bd4395ff84b84ab2e44cf79c5827798777e39472351"
48
+ ],
49
+ "dokkit": [
50
+ "42045db4e2f70f68d0ed90a9c7c5c2660a6d5183ea641dfa9f2f1f0d0938e3fb"
51
+ ],
52
+ "generate-skill": [
53
+ "16a78b867539c0bc11a44f3ecfa1762ba7fcba8841fca8558abe818fc961ff68"
54
+ ],
55
+ "polar-billing-setup": [
56
+ "8b6dc842903e7343ac7b1e3a1c55911a9987552196ffb27a11a86093537fe976"
57
+ ],
58
+ "prompt-engineering": [
59
+ "d7a0718ab0fd7e0a3c0d8ef8bc4e0349edfc843648343f08f9776e1ea047573c"
60
+ ],
61
+ "pyx-scan": [
62
+ "60f02fa049616e94fff4318c96dc9f417b3def7ec75a3bd0846f2bcbd99b0b69"
63
+ ],
64
+ "root-cause-analysis": [
65
+ "eeb20fe09edd37b07360c3440b68bdb488abee4917d4e808e64e3889c6addf5a"
66
+ ],
67
+ "ui-implementation-standards": [
68
+ "0fbaad2b7d77462916d9364b97b921354ffbf945a68e9e0d422b3a88d36d2569"
69
+ ]
70
+ },
71
+ "commands": {
72
+ "commands/devlyn.handoff.md": [
73
+ "91d143c6305e95e4af6992eda0d127e94a394c2e91a2d409b4d41ca03c23f470"
74
+ ],
75
+ "commands/devlyn.clean.md": [
76
+ "918c6ae867c57a4d9748237adae0df8c78a1447c7c028a0af988b59a5fbb0d9a"
77
+ ],
78
+ "commands/devlyn.design-system.md": [
79
+ "f81166b5ba08d9ee1f5cff33c911ea387e5cb50141a9712a96fc4434c60a673f"
80
+ ],
81
+ "commands/devlyn.design-ui.md": [
82
+ "0496a6b2dd8c6bb0474b71ef9a319a6b95bb76972d0f07a01c33f8860fe83dfb",
83
+ "4e2aafcc0de6ce7f3022a353aaa9fb4330e76a50a91ab970b9b9a0f192f9f1f2"
84
+ ],
85
+ "commands/devlyn.discover-product.md": [
86
+ "a609401d29cd2bdad7a48c7c8f096b442fe03a17c6b2c3151936c47b4a16e6c6",
87
+ "1e66aa35bf300ec6167e1b888f43a92ebd9e60d501397ff0334c9a7809d433f5"
88
+ ],
89
+ "commands/devlyn.evaluate.md": [
90
+ "8f17ccc639d54adab24c49dcf471376bab97511675d00554056a4c1f67a14115"
91
+ ],
92
+ "commands/devlyn.feature-spec.md": [
93
+ "d442ed7ae8d14a255c37f1b120b758f69794be88e2970a8a0c0e51519344b6c7"
94
+ ],
95
+ "commands/devlyn.implement-ui.md": [
96
+ "a8d0813781b2f7a3df5a4751c92755bf7c84c53428cf63cea34c965939b8c478"
97
+ ],
98
+ "commands/devlyn.product-spec.md": [
99
+ "7a92d2df2586b44e2c4d9182eeb3d61f7adc2a13bed4d2cd26abcfe086585895"
100
+ ],
101
+ "commands/devlyn.recommend-features.md": [
102
+ "2b92523017e1da10c299dafb19d84736de5084b5b46693de7fd2d6df51dc56b4"
103
+ ],
104
+ "commands/devlyn.resolve.md": [
105
+ "48c0c3ee553f87f1f0733190effc7220c01c82837a5300ef68c9ffdf30c5d3a1",
106
+ "24595766e1a81a1fcacb78261b03c3665bc686bcac8b1805d1c4db379584879f",
107
+ "eaad5354cfc20ed378ffd2a2e1ca6bade1310bd2a47ab93e5942e9599c3ba9a8"
108
+ ],
109
+ "commands/devlyn.review.md": [
110
+ "55888e08bf31ead1d359f218277cfa7efc3c6cb0f96fadc665d5a2406ff115d3",
111
+ "22f532f587f6d2fbf2662e38d5073b85a030141573f86d92ad40b9d292836fb7",
112
+ "7f9ce1db4ffd3cdaf9fd949e4b07de04e0931c91cd846d4ab220ff6114a45b17",
113
+ "3a511fc678e599ff4b4c37fd9191399a599e1ba9ea9c40fc4afcbe29cb188d2b"
114
+ ],
115
+ "commands/devlyn.team-design-ui.md": [
116
+ "02485911588e9f52afb5a5082fd7804ee7a41bad44323960a2afe1a16a49d628"
117
+ ],
118
+ "commands/devlyn.team-resolve.md": [
119
+ "8f11fa0c3203c0dd5bf09b5243dcff5968a36275682c54b3a012262fc545859a",
120
+ "09fc163d34dfe8906dd8d22501a028bfbbad639499acab01e5e60f06fc520382",
121
+ "ccfc5083813ee50d407109616ccbc2509aa7526316cebbf7a271537abdfff3b7",
122
+ "72f66be3c4c27a46e76d56d13a0d381880c054a03243188f98da2faf6dc77295",
123
+ "54c52c3f27998998ed55cc648d071bbb10444761b819c59ed118b795596a4aee"
124
+ ],
125
+ "commands/devlyn.team-review.md": [
126
+ "58b36cfe2749968d697d486a104a1b139e4312730b0ac2933550634f6ece48db",
127
+ "7e5c2d93a23d51af7939dae29c70095dbd8a4a681cc06d4d578f2de29df346a3",
128
+ "c8e145955a303141af4082dae174e3b3f1681e4c9698e25ae337eadd31fb883b"
129
+ ],
130
+ "commands/devlyn.update-docs.md": [
131
+ "86945afccf24762a88d26eef3703fbf6c5a90a3343fd3de72a9cb26b6f62d3b5"
132
+ ],
133
+ "commands/devlyn.pencil-pull.md": [
134
+ "dfdd3d19ca676558bfa2bb3ce398181b0a081ab88d1feaf69c54c706a9671631"
135
+ ],
136
+ "commands/devlyn.pencil-push.md": [
137
+ "5f2e8b29609cbb2af72941579823bec598265ebf29cd3a375d6aa47d95b74354"
138
+ ]
139
+ }
140
+ }