devlyn-cli 4.2.0 → 4.2.2

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 +184 -147
  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,110 @@ 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
+ if (marker?.package !== PKG.name || marker.schemaVersion !== 1) {
429
+ throw new Error('not a devlyn-cli install marker');
430
+ }
431
+ return marker.skills || {};
432
+ }
433
+
434
+ // Enumerate every directory this install may replace, migrate or remove, before
435
+ // touching any target. Optional addons must be selected before this preflight too.
436
+ function plannedSkills(root, optionalNames = []) {
437
+ const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
438
+ for (const relative of DEPRECATED_DIRS) {
439
+ for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
440
+ }
441
+ for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
442
+ const oldPaths = legacySkillPaths(root, oldName);
443
+ oldPaths.forEach((full) => names.add(path.basename(full)));
444
+ if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
445
+ || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
446
+ }
447
+ for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
448
+ if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
449
+ }
450
+ return names;
451
+ }
452
+
453
+ // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
454
+ function preflightSkills(roots, optionalNames = [], commandDir = null) {
455
+ const conflicts = [];
456
+ try {
457
+ // Do not follow a commands link into another installation either.
458
+ const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
459
+ if (commands && !commands.isDirectory()) throw new Error('not a real directory');
460
+ const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
461
+ for (const relPath of commandDir ? DEPRECATED_FILES : []) {
462
+ const fullPath = path.join(commandDir, relPath);
463
+ const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
464
+ if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
465
+ const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
466
+ conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
467
+ + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
468
+ }
469
+ } catch (error) {
470
+ conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
471
+ }
472
+ for (const root of roots) {
473
+ try {
474
+ // Do not follow an agent-directory or skill-root link into another installation.
475
+ for (const dir of [path.dirname(root), root]) {
476
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
477
+ if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
478
+ }
479
+ const tracked = trackedPaths(root);
480
+ let manifest;
481
+ try {
482
+ manifest = readInstallManifest(root);
483
+ } catch (error) {
484
+ conflicts.push(`${path.join(root, DEVLYN_INSTALL_MARKER)}: ${error.message}`);
485
+ continue;
486
+ }
487
+ for (const name of plannedSkills(root, optionalNames)) {
488
+ const dest = path.join(root, name);
489
+ const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
490
+ || file.toLowerCase().startsWith(dest.toLowerCase() + path.sep));
491
+ try {
492
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) {
493
+ if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
494
+ continue;
495
+ }
496
+ const actual = fingerprint(dest);
497
+ const logicalName = name.replace(/[:\uF03A]/g, '-');
498
+ const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
499
+ ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
500
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
501
+ const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
502
+ const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
503
+ if (!current && !historical && !singleFile && manifest[name] !== actual) {
504
+ throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
505
+ : fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
506
+ ? 'fingerprint mismatch with known shipped copies'
507
+ : 'unknown installer ownership (no fingerprint history for this copy)');
508
+ }
509
+ } catch (error) {
510
+ conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
511
+ }
512
+ }
513
+ } catch (error) {
514
+ conflicts.push(`${root}: ${error.message}`);
515
+ }
516
+ }
517
+ if (conflicts.length) {
518
+ throw new InstructionError('Installation refused; nothing was changed:\n'
519
+ + conflicts.map((conflict) => ` - ${conflict}`).join('\n')
520
+ + '\nMove these out of the install path, or back them up and remove them, then rerun.'
521
+ + '\nFor tracked paths, also remove them from the Git index.'
522
+ + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
523
+ }
524
+ }
525
+
413
526
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
414
527
  function complete(src, dest) {
415
528
  if (!fs.existsSync(src) || !fs.existsSync(dest)) return false;
@@ -429,161 +542,87 @@ function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
429
542
  }
430
543
  }
431
544
 
432
- function writeInstallMarker(skillsDir) {
545
+ function writeInstallMarker(skillsDir, names, previous) {
433
546
  const markerPath = path.join(skillsDir, DEVLYN_INSTALL_MARKER);
434
547
  const tempPath = `${markerPath}.${process.pid}.tmp`;
435
548
  const marker = {
436
549
  schemaVersion: 1,
437
550
  package: PKG.name,
438
551
  version: PKG.version,
552
+ skills: { ...previous, ...Object.fromEntries(names.map((name) => [name, fingerprint(path.join(skillsDir, name))])) },
439
553
  };
554
+ const fd = fs.openSync(tempPath, 'wx', 0o600);
440
555
  try {
441
- fs.writeFileSync(tempPath, JSON.stringify(marker, null, 2) + '\n', {
442
- encoding: 'utf8',
443
- flag: 'wx',
444
- mode: 0o600,
445
- });
556
+ try {
557
+ fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + '\n', 'utf8');
558
+ } finally {
559
+ fs.closeSync(fd);
560
+ }
446
561
  fs.renameSync(tempPath, markerPath);
447
562
  } finally {
448
563
  fs.rmSync(tempPath, { force: true });
449
564
  }
450
565
  }
451
566
 
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
- };
567
+ // One Inquirer menu, imported only when shown (Inquirer is ESM). The page gets the terminal
568
+ // rows left after everything else the menu draws, so a redraw never scrolls it into
569
+ // duplicates. Ctrl+C cancels the install.
570
+ async function ask(module, help, config) {
571
+ const { default: prompt } = await import(module);
572
+ const { stdin: input, stdout: output } = process;
573
+ const columns = output.columns || 80;
574
+ // Most rows a text wraps to: an ASCII character takes one cell, any other at most two.
575
+ const rows = (text = '') => Math.ceil([...text].reduce((cells, c) => cells + (c.codePointAt(0) < 0x80 ? 1 : 2), 0) / columns);
576
+ // Message, blank line, the active choice's description, key help, and the line Inquirer
577
+ // adds when the help exactly fills the width.
578
+ const reserved = rows(`? ${config.message}`) + 1 + Math.max(...config.choices.map((choice) => rows(choice.description))) + rows(help) + 1;
579
+ try {
580
+ return await prompt({
581
+ ...config,
582
+ pageSize: Math.max(1, (output.rows || 24) - reserved),
583
+ theme: { keybindings: ['vim'], style: { keysHelpTip: () => help } },
584
+ }, { input, output });
585
+ } catch (error) {
586
+ if (error.name !== 'ExitPromptError') throw error;
587
+ console.log('Installation cancelled.');
588
+ process.exit(0);
589
+ }
590
+ }
540
591
 
541
- process.stdin.on('data', onKeypress);
592
+ // Checkbox menu (space toggles, a toggles all); resolves to the chosen items.
593
+ function multiSelect(message, items, preselectedIndices = []) {
594
+ return ask('@inquirer/checkbox', '↑↓ navigate · space select · a all · enter confirm', {
595
+ message,
596
+ choices: items.map((item, index) => ({
597
+ name: item.type ? `${item.name} [${item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack'}]` : item.name,
598
+ short: item.name,
599
+ description: item.desc,
600
+ value: item,
601
+ checked: preselectedIndices.includes(index),
602
+ })),
542
603
  });
543
604
  }
544
605
 
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);
606
+ // One of `items`; resolves to the chosen index.
607
+ function singleSelect(message, items, initial) {
608
+ return ask('@inquirer/select', '↑↓ navigate · enter confirm', {
609
+ message,
610
+ choices: items.map((name, index) => ({ name, value: index })),
611
+ default: initial,
577
612
  });
578
613
  }
579
614
 
615
+ // After install(), whose preflight already cleared every selected addon before any change.
580
616
  function installLocalSkill(skillName, roots) {
581
617
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
582
618
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
583
619
  return false;
584
620
  }
585
621
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
586
- for (const root of roots) installOptionalSkillInto(root, skillName);
622
+ for (const root of roots) {
623
+ installOptionalSkillInto(root, skillName);
624
+ writeInstallMarker(root, [skillName], readInstallManifest(root));
625
+ }
587
626
  return true;
588
627
  }
589
628
 
@@ -653,6 +692,7 @@ function installCoreSkills(skillsDir) {
653
692
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
654
693
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
655
694
  fs.mkdirSync(skillsDir, { recursive: true });
695
+ const previous = readInstallManifest(skillsDir);
656
696
  clearInstallMarker(skillsDir);
657
697
  const removed = cleanupDeprecated(skillsDir);
658
698
  if (removed > 0) {
@@ -667,8 +707,8 @@ function installCoreSkills(skillsDir) {
667
707
  copyRecursive(src, dest, skillsDir);
668
708
  }
669
709
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
670
- retireRenamedSkills(skillsDir);
671
- writeInstallMarker(skillsDir);
710
+ const refreshed = retireRenamedSkills(skillsDir);
711
+ writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
672
712
  }
673
713
 
674
714
  // Keep installer-managed pipeline state and install metadata out of git.
@@ -820,7 +860,7 @@ function installClaudeCore() {
820
860
  }
821
861
 
822
862
  // Installs the targets in one scope; returns the skill roots written.
823
- function install(targets, global) {
863
+ function install(targets, global, optionalNames = []) {
824
864
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
825
865
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
826
866
  // another spelling of the path is caught too.
@@ -831,6 +871,8 @@ function install(targets, global) {
831
871
  + 'would apply to every project. Run from a project folder, or use --global for skills only.');
832
872
  }
833
873
  const roots = targets.flatMap((target) => skillRoots(target, global));
874
+ preflightSkills(roots, optionalNames,
875
+ !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
834
876
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
835
877
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
836
878
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
@@ -885,13 +927,12 @@ async function init({ yes, claude, global }) {
885
927
  return;
886
928
  }
887
929
 
888
- log('\n🎯 What to install:\n', 'blue');
889
930
  const targetOptions = [
890
931
  { key: 'agents', name: 'AGENTS.md — Codex · omp · Pi · Grok', desc: 'AGENTS.md + .agents/skills' },
891
932
  { key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills, settings)' },
892
933
  ];
893
934
  const preselected = claude || hasDevlynClaude(false) || (global && hasDevlynClaude(true)) ? [0, 1] : [0];
894
- const targets = (await multiSelect(targetOptions, preselected)).map((option) => option.key);
935
+ const targets = (await multiSelect('What to install', targetOptions, preselected)).map((option) => option.key);
895
936
 
896
937
  if (targets.length === 0) {
897
938
  log('\n💡 Nothing selected — nothing installed.', 'yellow');
@@ -899,14 +940,10 @@ async function init({ yes, claude, global }) {
899
940
  return;
900
941
  }
901
942
 
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
-
943
+ const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
906
944
  // 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')));
945
+ const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
946
+ const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
910
947
 
911
948
  if (selectedAddons.length > 0) {
912
949
  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
+ }