devlyn-cli 4.2.19 → 4.2.21

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/bin/devlyn.js CHANGED
@@ -12,6 +12,7 @@ const PKG = require('../package.json');
12
12
  const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
13
13
  const { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
14
14
  const SKILL_HISTORY = require('./skill-history.json');
15
+ const CLEANUP_HISTORY = require('./cleanup-history.json');
15
16
 
16
17
  // The devlyn skill bundle installed into every skill-capable agent's loader
17
18
  // directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
@@ -39,7 +40,13 @@ const RETIRED_PERMISSIONS = ['Write(.devlyn/**)', 'Edit(.devlyn/**)', 'Bash(git
39
40
 
40
41
  // Every spelling a colon name can have on disk: its own and npm's U+F03A extraction alias.
41
42
  function legacySkillPaths(root, name) {
42
- return [...new Set([name, name.replace(/:/g, '\uF03A')])].map((spelling) => path.join(root, spelling));
43
+ const spellings = new Set([name, name.replace(/:/g, '\uF03A')]);
44
+ if (fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) {
45
+ for (const entry of fs.readdirSync(root)) {
46
+ if ([...spellings].some((spelling) => spelling.toLowerCase() === entry.toLowerCase())) spellings.add(entry);
47
+ }
48
+ }
49
+ return [...spellings].map((spelling) => path.join(root, spelling));
43
50
  }
44
51
 
45
52
  // SKILL.md of the pencil skills 0.6.0-0.7.1 installed under today's names, without the
@@ -53,36 +60,8 @@ const PRE_STANDARD_SKILL_MD_SHA256 = new Set([
53
60
  // Skills devlyn-cli installed by default and later deleted or made opt-in, by SHA-256 of their
54
61
  // only file, SKILL.md (LF endings): an opted-in copy or a user's own folder of the same name is
55
62
  // never removed.
56
- const RETIRED_SKILL_MD_SHA256 = {
57
- // 0.2.0-1.15.0; deleted in iter-0034, it still routes agents to retired commands.
58
- 'workflow-routing': new Set([
59
- '7fec2bb20d808a873a975d64a6223e404bc5b328c800760bb77257ae6b2f467a',
60
- '8bfdaeff2ce48d5a79b5653de3bbe0d56a61a7fd750ed3ad11149fec5a502ecf',
61
- ]),
62
- // The standards skills, 0.1.0-3.2.1; 3.3.0 (0221) made them optional addons.
63
- 'code-health-standards': new Set([
64
- 'df2838904c938aa0306ed333cc460cfc5c2cf0ffa20bb5ade57067c6e0acf9be',
65
- '962a82b5ebeafac515f81f5c68494c67dbf31c3e3e453f23c4452d4725e21355',
66
- ]),
67
- 'code-review-standards': new Set([
68
- '9c906f2b2a88c2d0aaeec9714405945da5177de6ec433f1acf520c6c4385b875',
69
- '3bc8587a9143487328be2470dec8df17a19cb14f94e6ec916ed58de660d0e1c4',
70
- '56abe5736f505c53ca9ce14087a9891cc8661be783abaec7b5b924e3f339cc2a',
71
- '586dde5014f9ac2f9a13d43fa1c8c257a3ae998a3fc1b867b23b3b2e44f77eca',
72
- ]),
73
- 'root-cause-analysis': new Set([
74
- 'e200ddd04196435af3162a9e13595f8eb5ac68b06465026e0155e453b1bd1c93',
75
- '1793dcb0f72621c78cc20c905fe5dd7a0af3115e904bf737a6a983a7b9d2c2b6',
76
- 'aab22d1bddc66c6a798f08cdf7be9b45fbe80ccfc928b068ef5d9e2ad951bdda',
77
- '0eac104bfcde8bdf14d4c284be6f3f4cb243219a679bf569433c2465df84c6c3',
78
- 'e66d15a9585c07e819acfd89c1f6d030517130f6d8f8924bda3120307949fb2e',
79
- ]),
80
- 'ui-implementation-standards': new Set([
81
- 'e2473e35474e78f2b581e225cb33a1e711bb79519a02e9780fc9606e582b878f',
82
- '1ff64816454171b3f879e9e5cf3c15e3d9c5f2ffc81b73262155f6bc93a1b054',
83
- '13c709c0e2b01ae94997c271043a685200e969192d2056534bd787c5682874e8',
84
- ]),
85
- };
63
+ const RETIRED_SKILL_MD_SHA256 = Object.fromEntries(
64
+ Object.entries(CLEANUP_HISTORY.skills).map(([name, hashes]) => [name, new Set(hashes)]));
86
65
 
87
66
  // Whether `dir` is a real folder whose only file, besides generated metadata, is a SKILL.md
88
67
  // with an LF-normalized SHA-256 in `hashes`: an unedited copy devlyn-cli shipped, not something
@@ -92,7 +71,7 @@ function isShippedCopy(dir, hashes) {
92
71
  return fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() === true
93
72
  && fs.readdirSync(dir).filter((name) => !isIgnoredEntry(path.join(dir, name))).length === 1
94
73
  && fs.lstatSync(skill, { throwIfNoEntry: false })?.isFile() === true
95
- && hashes.has(contentHash(skill));
74
+ && hashes.has(crypto.createHash('sha256').update(fs.readFileSync(skill).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex'));
96
75
  }
97
76
 
98
77
  // Skill roots of each target. In a project, `.agents/skills` serves Codex, omp, Pi and Grok,
@@ -116,7 +95,7 @@ function hasDevlynInstall(root) {
116
95
  return !!stat(path.join(root, DEVLYN_INSTALL_LOCK))
117
96
  || names.some((name) => [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
118
97
  path.join(root, name.replace(/:/g, '-'))].some((full) => stat(full)?.isDirectory()))
119
- || planDeprecatedSkills(root, {}).removals.size > 0
98
+ || hasHistoricalRetiredSkill(root)
120
99
  || (!!stat(path.join(root, DEVLYN_INSTALL_MARKER)) && stat(path.join(root, '_shared'))?.isDirectory());
121
100
  } catch (error) {
122
101
  if (error.code === 'ENOTDIR') return false;
@@ -138,11 +117,9 @@ function hasDevlynClaude(global) {
138
117
  const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
139
118
  const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
140
119
  const root = path.join(claudeDir, 'skills');
141
- const deprecated = new Map([...deprecatedSkillPaths(root)].map((full) => [path.basename(full).toLowerCase(), full]));
142
- const { removals } = planDeprecatedSkills(root, {});
120
+ const deprecated = new Set([...deprecatedSkillPaths(root)].map((full) => path.basename(full).toLowerCase()));
143
121
  return names(root).some((name) => {
144
- const canonical = deprecated.get(name.toLowerCase());
145
- if (canonical) return removals.has(canonical);
122
+ if (deprecated.has(name.toLowerCase())) return historicalSkill(path.join(root, name));
146
123
  return name === DEVLYN_INSTALL_MARKER
147
124
  || (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-')));
148
125
  })
@@ -150,9 +127,10 @@ function hasDevlynClaude(global) {
150
127
  || holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
151
128
  }
152
129
 
153
- // Commands removed in previous versions; the project Claude install deletes them from .claude/
154
- // when they hold the bytes a version shipped (SKILL_HISTORY.commands).
130
+ // Exact Devlyn command names removed by previous versions, in either scope.
155
131
  const DEPRECATED_FILES = [
132
+ 'commands/devlyn.ui.md',
133
+ 'commands/devlyn.team-ui.md',
156
134
  'commands/devlyn.handoff.md', // removed in v0.2.0
157
135
  'commands/devlyn.clean.md', // migrated to skills in v0.6.0
158
136
  'commands/devlyn.design-system.md',
@@ -176,7 +154,7 @@ const DEPRECATED_FILES = [
176
154
  // Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
177
155
  // iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
178
156
  // to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
179
- // remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
157
+ // remove these exact retired skill names from downstream skill roots
180
158
  // even though the source dirs no longer exist (the installer replaces only
181
159
  // the skills it ships — without this list, deleted-from-source skills persist
182
160
  // in user installs forever).
@@ -375,43 +353,23 @@ function listContents() {
375
353
  log('');
376
354
  }
377
355
 
378
- // Old and retired skills in one skill root; nothing outside it.
379
- function cleanupDeprecated(skillsDir, written, removals) {
380
- let removed = 0;
381
- for (const fullPath of removals) {
382
- if (path.dirname(fullPath) === skillsDir) {
383
- fs.rmSync(fullPath, { recursive: true });
384
- written.push(fullPath);
385
- log(` ✕ skills/${path.basename(fullPath)}/ (removed)`, 'dim');
386
- removed++;
387
- }
388
- }
389
- return removed;
390
- }
391
-
392
356
  // Remove every old spelling of the renamed skills in `skillsDir`, after the new core skills
393
357
  // are in place. An optional skill found under an old spelling is installed under its new name
394
358
  // before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
395
359
  // copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
396
- function retireRenamedSkills(skillsDir, written, installedSkills) {
360
+ function refreshRenamedSkills(skillsDir, installedSkills) {
397
361
  const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
398
362
  const refreshed = [];
399
363
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
400
364
  const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
401
- if (found.length > 0 && optional.has(newName)) {
402
- installOptionalSkillInto(skillsDir, newName, written, installedSkills);
365
+ if (found.length > 0 && optional.has(newName) && canRefreshRenamed(skillsDir, newName)) {
366
+ installOptionalSkillInto(skillsDir, newName, installedSkills);
403
367
  refreshed.push(newName);
404
368
  } else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
405
369
  refreshPreStandardCopy(skillsDir, newName);
406
- written.push(path.join(skillsDir, newName));
407
370
  installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
408
371
  refreshed.push(newName);
409
372
  }
410
- for (const fullPath of found) {
411
- fs.rmSync(fullPath, { recursive: true, force: true });
412
- written.push(fullPath);
413
- log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
414
- }
415
373
  }
416
374
  return refreshed;
417
375
  }
@@ -455,27 +413,34 @@ function readInstallManifest(root) {
455
413
  return marker.skills || {};
456
414
  }
457
415
 
416
+ // A retired optional name must not make a foreign copy under its new name an install conflict.
417
+ function canRefreshRenamed(root, name) {
418
+ const dest = path.join(root, name);
419
+ if (!fs.lstatSync(dest, { throwIfNoEntry: false })) return true;
420
+ try { verifySkill(dest, name, readInstallManifest(root)); return true; }
421
+ catch { return false; } // No ownership proof: preserve the new-name entry without installing over it.
422
+ }
423
+
458
424
  // Enumerate every directory this install may replace, migrate or remove, before
459
425
  // touching any target. Optional addons must be selected before this preflight too.
460
426
  function plannedSkills(root, optionalNames = []) {
461
427
  const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
462
428
  for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
463
429
  const oldPaths = legacySkillPaths(root, oldName);
464
- oldPaths.forEach((full) => names.add(path.basename(full)));
465
- if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
430
+ if ((oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false })) && canRefreshRenamed(root, newName))
466
431
  || isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
467
432
  }
468
433
  return names;
469
434
  }
470
435
 
471
- function verifySkill(dest, name, manifest, removalOnly = false) {
436
+ function verifySkill(dest, name, manifest) {
472
437
  const actual = fingerprint(dest);
473
438
  const logicalName = name.replace(/[:\uF03A]/g, '-');
474
439
  const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
475
440
  ? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
476
- const current = !removalOnly && fs.existsSync(source) && fingerprint(source) === actual;
441
+ const current = fs.existsSync(source) && fingerprint(source) === actual;
477
442
  const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
478
- const hashes = RETIRED_SKILL_MD_SHA256[name] || (removalOnly ? new Set() : PRE_STANDARD_SKILL_MD_SHA256);
443
+ const hashes = RETIRED_SKILL_MD_SHA256[name.toLowerCase()] || PRE_STANDARD_SKILL_MD_SHA256;
479
444
  const singleFile = isShippedCopy(dest, hashes);
480
445
  if (!current && !historical && !singleFile && manifest[name] !== actual) {
481
446
  throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
@@ -485,55 +450,31 @@ function verifySkill(dest, name, manifest, removalOnly = false) {
485
450
  }
486
451
  }
487
452
 
488
- // Removal-only paths never block an install: unverifiable copies stay with their owner.
453
+ // Discovery keeps its historical ownership boundary; cleanup below uses exact retired names.
489
454
  function deprecatedSkillPaths(root) {
490
- return new Set(DEPRECATED_DIRS.flatMap((relative) => legacySkillPaths(root, path.basename(relative))));
455
+ return new Set(DEPRECATED_DIRS.flatMap((relative) => {
456
+ const name = path.basename(relative);
457
+ return [...legacySkillPaths(root, name.replace(/^devlyn-/, 'devlyn:')),
458
+ path.join(root, name.replace(/:/g, '-'))];
459
+ }));
491
460
  }
492
461
 
493
- function planDeprecatedSkills(root, manifest) {
494
- const removals = new Set();
495
- const retained = new Set();
496
- for (const dest of deprecatedSkillPaths(root)) {
497
- try {
498
- if (!fs.lstatSync(dest, { throwIfNoEntry: false })) continue;
499
- verifySkill(dest, path.basename(dest), manifest, true);
500
- removals.add(dest);
501
- } catch (error) {
502
- retained.add(dest);
503
- }
462
+ function historicalSkill(dest) {
463
+ try {
464
+ return SKILL_HISTORY.skills[path.basename(dest).toLowerCase().replace(/[:\uF03A]/g, '-')]?.includes(fingerprint(dest)) === true;
465
+ } catch {
466
+ // A link, unreadable or partial tree is not evidence of an install to refresh.
467
+ return false;
504
468
  }
505
- return { removals, retained };
506
469
  }
507
470
 
508
- function planSkillRemovals(root, manifest) {
509
- const plan = planDeprecatedSkills(root, manifest);
510
- for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
511
- const dest = path.join(root, name);
512
- if (isShippedCopy(dest, hashes)) plan.removals.add(dest);
513
- }
514
- return plan;
471
+ function hasHistoricalRetiredSkill(root) {
472
+ return [...deprecatedSkillPaths(root)].some(historicalSkill);
515
473
  }
516
474
 
517
- // `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
518
- function preflightSkills(roots, optionalNames = [], commandDir = null) {
475
+ // Validate only paths this run installs; removal-only names never refuse an install.
476
+ function preflightSkills(roots, optionalNames = []) {
519
477
  const conflicts = [];
520
- const plan = { removals: new Set(), retained: new Set() };
521
- try {
522
- // Do not follow a commands link into another installation either.
523
- const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
524
- if (commands && !commands.isDirectory()) throw new Error('not a real directory');
525
- const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
526
- for (const relPath of commandDir ? DEPRECATED_FILES : []) {
527
- const fullPath = path.join(commandDir, relPath);
528
- const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
529
- if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
530
- const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
531
- conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
532
- + (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
533
- }
534
- } catch (error) {
535
- conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
536
- }
537
478
  for (const root of roots) {
538
479
  try {
539
480
  // Do not follow an agent-directory or skill-root link into another installation.
@@ -549,9 +490,6 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
549
490
  conflicts.push(`${path.join(root, DEVLYN_INSTALL_MARKER)}: ${error.message}`);
550
491
  continue;
551
492
  }
552
- const deprecated = planSkillRemovals(root, manifest);
553
- for (const dest of deprecated.removals) plan.removals.add(dest);
554
- for (const dest of deprecated.retained) plan.retained.add(dest);
555
493
  for (const name of plannedSkills(root, optionalNames)) {
556
494
  const dest = path.join(root, name);
557
495
  const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
@@ -577,7 +515,7 @@ function preflightSkills(roots, optionalNames = [], commandDir = null) {
577
515
  + '\nFor tracked paths, also remove them from the Git index.'
578
516
  + '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
579
517
  }
580
- return plan;
518
+ return {};
581
519
  }
582
520
 
583
521
  function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
@@ -670,34 +608,35 @@ function singleSelect(message, items, initial) {
670
608
  }
671
609
 
672
610
  // After preflight has cleared every selected addon before any change.
673
- function installLocalSkill(skillName, roots, written = [], installedSkills = new Map()) {
611
+ function installLocalSkill(skillName, roots, installedSkills = new Map()) {
674
612
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
675
613
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
676
614
  return false;
677
615
  }
678
616
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
679
617
  for (const root of roots) {
680
- installOptionalSkillInto(root, skillName, written, installedSkills);
618
+ installOptionalSkillInto(root, skillName, installedSkills);
681
619
  writeInstallMarker(root, [skillName], readInstallManifest(root));
682
- written.push(path.join(root, DEVLYN_INSTALL_MARKER));
683
620
  }
684
621
  return true;
685
622
  }
686
623
 
687
624
  // One optional skill into one skill-loader directory, replacing any older copy of it. A
688
625
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
689
- function installOptionalSkillInto(target, skillName, written, installedSkills) {
626
+ function installOptionalSkillInto(target, skillName, installedSkills) {
690
627
  const dest = path.join(target, skillName);
691
- fs.rmSync(dest, { recursive: true, force: true });
692
- installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
693
- assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
694
- written.push(dest);
695
- const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
696
- for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
697
- if (!fs.existsSync(fullPath)) continue;
698
- fs.rmSync(fullPath, { recursive: true, force: true });
699
- written.push(fullPath);
628
+ const stage = fs.mkdtempSync(path.join(target, '.devlyn-skill-'));
629
+ const stagedSkill = path.join(stage, skillName);
630
+ try {
631
+ const files = copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), stagedSkill, stage);
632
+ assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, stage, [skillName]);
633
+ fs.rmSync(dest, { recursive: true, force: true });
634
+ fs.renameSync(stagedSkill, dest);
635
+ installedSkills.set(dest, files.map((file) => path.join(dest, path.relative(stagedSkill, file))));
636
+ } finally {
637
+ fs.rmSync(stage, { recursive: true, force: true });
700
638
  }
639
+
701
640
  }
702
641
 
703
642
  // A 0.6.0-0.7.1 copy is one SKILL.md, the file that identifies it. A failed swap leaves
@@ -751,17 +690,12 @@ function installAddon(addon) {
751
690
 
752
691
  // The core skills into one skill root, replacing older copies and retired skills, then the
753
692
  // marker that records a complete install.
754
- function installCoreSkills(skillsDir, written = [], installedSkills = new Map(), plan) {
693
+ function installCoreSkills(skillsDir, installedSkills = new Map()) {
755
694
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
756
695
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
757
696
  fs.mkdirSync(skillsDir, { recursive: true });
758
697
  const previous = readInstallManifest(skillsDir);
759
- plan ??= planSkillRemovals(skillsDir, previous);
760
698
  clearInstallMarker(skillsDir);
761
- const removed = cleanupDeprecated(skillsDir, written, plan.removals);
762
- if (removed > 0) {
763
- log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
764
- }
765
699
  for (const skillName of DEVLYN_CORE_SKILLS) {
766
700
  const src = path.join(sourceSkillsDir, skillName);
767
701
  const dest = path.join(skillsDir, skillName);
@@ -769,12 +703,10 @@ function installCoreSkills(skillsDir, written = [], installedSkills = new Map(),
769
703
  // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
770
704
  fs.rmSync(dest, { recursive: true, force: true });
771
705
  installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
772
- written.push(dest);
773
706
  }
774
707
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
775
- const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
708
+ const refreshed = refreshRenamedSkills(skillsDir, installedSkills);
776
709
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
777
- written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
778
710
  }
779
711
 
780
712
  function readProjectGitignore() {
@@ -826,9 +758,7 @@ function updateProjectGitignore(markerEntry) {
826
758
  if (!gitignoreContent.equals(original)) {
827
759
  fs.writeFileSync(gitignorePath, gitignoreContent);
828
760
  log(' → .gitignore (updated project install rules)', 'dim');
829
- return true;
830
761
  }
831
- return false;
832
762
  }
833
763
 
834
764
  // With the Claude target in the same run, an AGENTS.md that links to this project's CLAUDE.md (a
@@ -848,19 +778,17 @@ function agentsMdIsClaudeMd() {
848
778
  return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
849
779
  }
850
780
 
851
- function installAgentsProject(withClaude, installedSkills = new Map(), plan) {
852
- const written = [];
781
+ function installAgentsProject(withClaude, installedSkills = new Map()) {
853
782
  if (!agentsMdIsClaudeMd()) {
854
- if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
783
+ updateInstructions('AGENTS.md');
855
784
  } else if (withClaude) {
856
785
  log(' → AGENTS.md is CLAUDE.md here, so it gets the devlyn block written there', 'dim');
857
786
  } else {
858
787
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
859
788
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
860
789
  }
861
- installCoreSkills(skillRoots('agents', false)[0], written, installedSkills, plan);
862
- if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
863
- return written;
790
+ installCoreSkills(skillRoots('agents', false)[0], installedSkills);
791
+ updateProjectGitignore('.agents/skills/.devlyn-install.json');
864
792
  }
865
793
 
866
794
  // Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
@@ -884,19 +812,20 @@ function installClaudeInstructions() {
884
812
  throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
885
813
  + 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
886
814
  }
887
- const changed = updateInstructions('CLAUDE.md', initial, !imported);
815
+ updateInstructions('CLAUDE.md', initial, !imported);
888
816
  if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
889
- return changed;
890
817
  }
891
818
 
892
819
  // Project CLAUDE.md and .claude/: skills and settings.
893
- function installClaudeCore(installedSkills = new Map(), plan) {
820
+ function installClaudeCore(installedSkills = new Map()) {
894
821
  const skillsDir = skillRoots('claude', false)[0];
895
822
  const targetDir = path.dirname(skillsDir);
896
823
  // Read before any write, so settings this install cannot merge leave the Claude target as it was.
897
824
  const settingsPath = path.join(targetDir, 'settings.json');
898
825
  const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
899
826
  + 'CLAUDE.md and .claude/ are unchanged; fix the file and rerun the same install command.');
827
+ const settingsStat = fs.lstatSync(settingsPath, { throwIfNoEntry: false });
828
+ if (settingsStat && !settingsStat.isFile()) throw unmergeable('not a regular file');
900
829
  let settings = {};
901
830
  if (fs.existsSync(settingsPath)) {
902
831
  try {
@@ -913,17 +842,9 @@ function installClaudeCore(installedSkills = new Map(), plan) {
913
842
  && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
914
843
  throw unmergeable('env must be a JSON object');
915
844
  }
916
- const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
917
- for (const relPath of DEPRECATED_FILES) {
918
- const fullPath = path.join(targetDir, relPath);
919
- if (fs.existsSync(fullPath)) {
920
- fs.unlinkSync(fullPath);
921
- log(` ✕ ${relPath} (deprecated)`, 'dim');
922
- written.push(`.claude/${relPath}`);
923
- }
924
- }
925
- installCoreSkills(skillsDir, written, installedSkills, plan);
926
- if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
845
+ installClaudeInstructions();
846
+ installCoreSkills(skillsDir, installedSkills);
847
+ updateProjectGitignore('.claude/skills/.devlyn-install.json');
927
848
 
928
849
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
929
850
  let settingsChanged = false;
@@ -931,30 +852,18 @@ function installClaudeCore(installedSkills = new Map(), plan) {
931
852
  settings.env = {};
932
853
  settingsChanged = true;
933
854
  }
934
- if (!settings.env.ENABLE_PROMPT_CACHING_1H) {
855
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'ENABLE_PROMPT_CACHING_1H')) {
935
856
  settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
936
857
  settingsChanged = true;
937
858
  }
938
- const bashMaxTimeoutMs = Number.parseInt(settings.env.BASH_MAX_TIMEOUT_MS, 10);
939
- if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
859
+ if (!Object.prototype.hasOwnProperty.call(settings.env, 'BASH_MAX_TIMEOUT_MS')) {
940
860
  settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
941
861
  settingsChanged = true;
942
862
  }
943
- // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
944
- const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
945
- const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
946
- for (let i = stop.length - 1; i >= 0; i--) {
947
- const hooks = stop[i]?.hooks;
948
- if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
949
- stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
950
- if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
951
- log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
952
- settingsChanged = true;
953
- }
863
+ settingsChanged = cleanRetiredSettings(settings) || settingsChanged;
954
864
  if (settingsChanged) {
955
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
865
+ replaceFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
956
866
  log(' → settings.json', 'dim');
957
- written.push('.claude/settings.json');
958
867
  }
959
868
  // Their ownership cannot be established, so the other retired settings stay; name the ones present.
960
869
  const retired = [
@@ -964,12 +873,40 @@ function installClaudeCore(installedSkills = new Map(), plan) {
964
873
  if (retired.length > 0) {
965
874
  log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
966
875
  }
967
- return written;
876
+ }
877
+
878
+ // Replace this directory entry, never a hardlink's other names.
879
+ function replaceFile(file, text) {
880
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
881
+ try {
882
+ const mode = fs.lstatSync(file, { throwIfNoEntry: false })?.mode & 0o777;
883
+ fs.writeFileSync(temp, text, { flag: 'wx', mode: mode || 0o600 });
884
+ fs.renameSync(temp, file);
885
+ } finally {
886
+ fs.rmSync(temp, { force: true });
887
+ }
888
+ }
889
+
890
+ function cleanRetiredSettings(settings) {
891
+ let changed = false;
892
+ // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
893
+ const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
894
+ const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
895
+ for (let i = stop.length - 1; i >= 0; i--) {
896
+ const hooks = stop[i]?.hooks;
897
+ if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
898
+ stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
899
+ if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
900
+ log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
901
+ changed = true;
902
+ }
903
+ return changed;
968
904
  }
969
905
 
970
906
  // Shared user roots need one writer from ownership checks through the last addon write.
971
907
  // Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
972
- function withUserSkillLocks(roots, action) {
908
+ const heldSkillLocks = new Set();
909
+ function withUserSkillLocks(roots, action, { existingOnly = false } = {}) {
973
910
  const locks = [];
974
911
  const createdDirs = [];
975
912
  try {
@@ -980,18 +917,22 @@ function withUserSkillLocks(roots, action) {
980
917
  const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
981
918
  if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
982
919
  }
983
- const missing = [];
984
- for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
985
- missing.push(dir);
986
- if (path.dirname(dir) === dir) break;
920
+ if (existingOnly && !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
921
+ if (!existingOnly) {
922
+ const missing = [];
923
+ for (let dir = root; !fs.existsSync(dir); dir = path.dirname(dir)) {
924
+ missing.push(dir);
925
+ if (path.dirname(dir) === dir) break;
926
+ }
927
+ fs.mkdirSync(root, { recursive: true });
928
+ createdDirs.push(...missing);
987
929
  }
988
- fs.mkdirSync(root, { recursive: true });
989
- createdDirs.push(...missing);
990
930
  const token = crypto.randomBytes(16).toString('hex');
991
931
  const fd = fs.openSync(lock, 'wx', 0o600);
992
932
  try {
993
933
  fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }) + '\n');
994
934
  locks.push({ lock, token });
935
+ heldSkillLocks.add(root);
995
936
  } catch (error) {
996
937
  // A failed initial write may not leave parseable JSON; verify the open file's identity.
997
938
  const opened = fs.fstatSync(fd);
@@ -1019,6 +960,7 @@ function withUserSkillLocks(roots, action) {
1019
960
  } finally {
1020
961
  const errors = [];
1021
962
  for (const { lock, token } of locks.reverse()) {
963
+ heldSkillLocks.delete(path.dirname(lock));
1022
964
  try {
1023
965
  if (JSON.parse(fs.readFileSync(lock, 'utf8')).token === token) fs.unlinkSync(lock);
1024
966
  } catch (error) {
@@ -1037,8 +979,8 @@ function withUserSkillLocks(roots, action) {
1037
979
  }
1038
980
  }
1039
981
 
1040
- // Installs the targets in one scope; returns the skill roots written.
1041
- function install(targets, global, optionalNames = [], plan) {
982
+ // Installs the targets in one scope; returns the installed skill roots.
983
+ function install(targets, global, optionalNames = [], plan, afterInstall = () => {}) {
1042
984
  // In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
1043
985
  // .claude/settings.json would apply to every project. Same folder by identity, so a link or
1044
986
  // another spelling of the path is caught too.
@@ -1050,104 +992,176 @@ function install(targets, global, optionalNames = [], plan) {
1050
992
  }
1051
993
  const roots = targets.flatMap((target) => skillRoots(target, global));
1052
994
  return withUserSkillLocks(global ? roots : [], () => {
1053
- const written = [];
1054
995
  const installedSkills = new Map();
1055
996
  if (!global) readProjectGitignore();
1056
- plan ??= preflightSkills(roots, optionalNames,
1057
- !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
997
+ plan ??= preflightSkills(roots, optionalNames);
1058
998
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
1059
999
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
1060
- if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, [], installedSkills, plan));
1061
- else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills, plan));
1062
- else written.push(...installClaudeCore(installedSkills, plan));
1000
+ if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root, installedSkills));
1001
+ else if (target === 'agents') installAgentsProject(targets.includes('claude'), installedSkills);
1002
+ else installClaudeCore(installedSkills);
1063
1003
  }
1064
- for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
1004
+ for (const name of optionalNames) installLocalSkill(name, roots, installedSkills);
1005
+ afterInstall();
1065
1006
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1066
1007
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
1067
1008
  noticeGlobalDrift(roots);
1068
- noticeRetainedSkills(plan.retained);
1069
- if (!global) noticeProjectChanges(written, installedSkills);
1009
+ cleanupScope(global ? os.homedir() : projectDir(), global,
1010
+ global ? [] : targets.map((target) => target === 'agents' ? 'AGENTS.md' : 'CLAUDE.md'));
1011
+ if (!global) noticeIgnoredInstallFiles(installedSkills);
1070
1012
  return roots;
1071
1013
  });
1072
1014
  }
1073
1015
 
1074
- function noticeRetainedSkills(retained) {
1075
- if (!retained.size) return;
1076
- log('Left in place (not verified as unchanged devlyn-cli copies):\n'
1077
- + [...retained].map((dest) => ` ${dest}`).join('\n')
1078
- + '\nThese retired skills may still be visible to agents and may call removed helpers. '
1079
- + 'Move them out of the skills folder, or delete them, if you no longer use them.', 'yellow');
1016
+ // Cleanup visits existing paths in the command's scope; it never creates an install.
1017
+ function cleanupScope(base, global, activeInstructions = []) {
1018
+ const warned = new Set();
1019
+ let found = false;
1020
+ const warning = (file, error) => {
1021
+ if (!warned.has(file)) log(`Cannot clean up ${file}: ${error.message}`, 'yellow');
1022
+ warned.add(file);
1023
+ };
1024
+ const attempt = (file, action) => {
1025
+ try { return action(); } catch (error) { warning(file, error); return false; }
1026
+ };
1027
+ const realParents = (file) => {
1028
+ let dir = base;
1029
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1030
+ for (const part of path.relative(base, path.dirname(file)).split(path.sep).filter(Boolean)) {
1031
+ if (part === '..') return false;
1032
+ dir = path.join(dir, part);
1033
+ if (!fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory()) return false;
1034
+ }
1035
+ return true;
1036
+ };
1037
+ const remove = (file) => attempt(file, () => {
1038
+ if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })) return false;
1039
+ found = true;
1040
+ fs.rmSync(file, { recursive: true }); // lstat/realParents never follows a link; rm removes the link itself.
1041
+ log(` ✕ ${path.relative(base, file)} (removed)`, 'dim');
1042
+ return true;
1043
+ });
1044
+ const hash = (file) => crypto.createHash('sha256')
1045
+ .update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
1046
+ for (const dir of global ? ['.agents', '.codex', '.claude', '.grok'] : ['.agents', '.claude']) {
1047
+ const root = path.join(base, dir, 'skills');
1048
+ attempt(root, () => {
1049
+ if (!realParents(root) || !fs.lstatSync(root, { throwIfNoEntry: false })?.isDirectory()) return;
1050
+ const retiredRoot = dir === '.grok';
1051
+ const clean = () => {
1052
+ const marker = path.join(root, DEVLYN_INSTALL_MARKER);
1053
+ let manifest;
1054
+ if (fs.lstatSync(marker, { throwIfNoEntry: false })?.isFile()) {
1055
+ try {
1056
+ const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
1057
+ if (data && data.package === PKG.name && data.schemaVersion === 1
1058
+ && (data.skills === undefined || (data.skills && typeof data.skills === 'object' && !Array.isArray(data.skills)))) manifest = data.skills || {};
1059
+ } catch (error) {
1060
+ if (!(error instanceof SyntaxError)) throw error;
1061
+ // A malformed/foreign marker is not ownership evidence and stays in place.
1062
+ }
1063
+ }
1064
+ const exact = new Set([...deprecatedSkillPaths(root),
1065
+ ...Object.keys(RENAMED_SKILLS).flatMap((name) => legacySkillPaths(root, name))]
1066
+ .map((file) => path.basename(file).toLowerCase()));
1067
+ if (retiredRoot) for (const name of [...DEVLYN_CORE_SKILLS, ...Object.values(RENAMED_SKILLS)]
1068
+ .filter((name) => name.startsWith('devlyn'))) exact.add(name);
1069
+ const removed = new Set();
1070
+ for (const name of fs.readdirSync(root)) {
1071
+ const file = path.join(root, name);
1072
+ attempt(file, () => {
1073
+ const listed = manifest && Object.prototype.hasOwnProperty.call(manifest, name);
1074
+ let owned = exact.has(name.toLowerCase()) || (retiredRoot && listed && /^devlyn[-:\uF03A]/i.test(name));
1075
+ if (!owned && RETIRED_SKILL_MD_SHA256[name.toLowerCase()]) owned = isShippedCopy(file, RETIRED_SKILL_MD_SHA256[name.toLowerCase()]);
1076
+ if (!owned && retiredRoot && name.toLowerCase() === '_shared') owned = historicalSkill(file);
1077
+ if (!owned && retiredRoot && listed && fs.lstatSync(file).isDirectory()) {
1078
+ try { owned = fingerprint(file) === manifest[name]; }
1079
+ catch { /* Links or special files cannot prove a generic tree is an unedited copy. */ }
1080
+ }
1081
+ if (owned && remove(file)) removed.add(name);
1082
+ });
1083
+ }
1084
+ if (retiredRoot && manifest) {
1085
+ if (![...warned].some((file) => path.dirname(file) === root)) remove(marker);
1086
+ } else if (manifest && [...removed].some((name) => Object.prototype.hasOwnProperty.call(manifest, name))) {
1087
+ // Keep current markers accurate after cleaning a previously listed retired entry.
1088
+ attempt(marker, () => {
1089
+ const data = JSON.parse(fs.readFileSync(marker, 'utf8'));
1090
+ for (const name of removed) delete data.skills[name];
1091
+ replaceFile(marker, JSON.stringify(data, null, 2) + '\n');
1092
+ });
1093
+ }
1094
+ };
1095
+ if (heldSkillLocks.has(root)) clean();
1096
+ else withUserSkillLocks([root], clean, { existingOnly: true });
1097
+ });
1098
+ }
1099
+ const commands = path.join(base, '.claude', 'commands');
1100
+ attempt(commands, () => {
1101
+ if (!realParents(commands) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return;
1102
+ const names = new Set(DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()));
1103
+ for (const name of fs.readdirSync(commands)) if (names.has(name.toLowerCase())) remove(path.join(commands, name));
1104
+ });
1105
+ for (const [name, hashes] of global ? [] : Object.entries(CLEANUP_HISTORY.files)) {
1106
+ const file = path.join(base, name);
1107
+ attempt(file, () => {
1108
+ if (realParents(file) && fs.lstatSync(file, { throwIfNoEntry: false })?.isFile() && hashes.includes(hash(file))) remove(file);
1109
+ });
1110
+ }
1111
+ const settingsFile = path.join(base, '.claude', 'settings.json');
1112
+ attempt(settingsFile, () => {
1113
+ if (!realParents(settingsFile) || !fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) return;
1114
+ let settings;
1115
+ try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^\uFEFF/, '')); }
1116
+ catch (error) { if (error instanceof SyntaxError) return; throw error; }
1117
+ if (settings && typeof settings === 'object' && cleanRetiredSettings(settings)) {
1118
+ found = true;
1119
+ replaceFile(settingsFile, JSON.stringify(settings, null, 2) + '\n');
1120
+ log(' ✕ .claude/settings.json retired entries (removed)', 'dim');
1121
+ }
1122
+ });
1123
+ const instructions = global ? [] : ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.windsurfrules', '.github/copilot-instructions.md'];
1124
+ for (const name of instructions.filter((name) => !activeInstructions.includes(name))) {
1125
+ const file = path.join(base, name);
1126
+ attempt(file, () => {
1127
+ if (!realParents(file) || !fs.lstatSync(file, { throwIfNoEntry: false })?.isFile()) return;
1128
+ const evaluatorOnly = ['CLAUDE.md', 'AGENTS.md'].includes(name);
1129
+ if (!updateInstructions(name, '', false, false, base, evaluatorOnly)) return;
1130
+ found = true;
1131
+ updateInstructions(name, '', false, true, base, evaluatorOnly);
1132
+ });
1133
+ }
1134
+ return found;
1080
1135
  }
1081
1136
 
1082
- // Advisory only: a successful install stays successful when Git is unavailable or fails.
1083
- function noticeProjectChanges(paths, installedSkills = new Map()) {
1084
- if (!paths.length && !installedSkills.size) return;
1137
+ // Advisory only: check files this run copied, grouped by skill; a wholly ignored skill
1138
+ // folder is deliberate. check-ignore omits tracked files, and Git failures stay silent.
1139
+ function noticeIgnoredInstallFiles(installedSkills) {
1085
1140
  const cwd = projectDir();
1086
- const relative = (name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
1087
- paths = [...new Set(paths.map(relative))];
1141
+ const groups = [...installedSkills.values()].map((files) =>
1142
+ files.map((file) => path.relative(cwd, file).split(path.sep).join('/')));
1143
+ const files = groups.flat();
1144
+ if (!files.length) return;
1088
1145
  const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
1089
1146
  const git = (args, input) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, input });
1090
- const repo = git(['rev-parse', '--show-toplevel']);
1091
- if (repo.error || repo.status !== 0) return;
1092
- const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
1093
- '--', ...paths.map((name) => `:(icase,literal)${name}`)]);
1094
- if (status.error || status.status !== 0) return;
1095
- // Porcelain paths are repository-relative, even when installing in a nested project.
1096
- const changed = status.stdout.split('\0').filter(Boolean).map((entry) =>
1097
- path.relative(cwd, path.resolve(repo.stdout.replace(/\r?\n$/, ''), entry.slice(3))).split(path.sep).join('/'));
1098
- // Keep the installed folder/file scope, using Git's spelling on case-insensitive volumes.
1099
- const pending = [...new Set(paths.flatMap((name) => [...new Set(changed
1100
- .map((file) => file.split('/').slice(0, name.split('/').length).join('/')))]
1101
- .filter((file) => {
1102
- if (file === name) return true;
1103
- if (file.toLowerCase() !== name.toLowerCase()) return false;
1104
- // Case folding alone can include independently edited files on case-sensitive volumes.
1105
- // Removed paths have no identity left to compare, so require their exact spelling.
1106
- try {
1107
- const written = fs.lstatSync(path.resolve(cwd, name), { bigint: true, throwIfNoEntry: false });
1108
- const reported = fs.lstatSync(path.resolve(cwd, file), { bigint: true, throwIfNoEntry: false });
1109
- return written && reported && written.dev === reported.dev && written.ino === reported.ino;
1110
- } catch (error) {
1111
- log(`Cannot compare Git path ${file} with installed path ${name}: ${error.message}`, 'yellow');
1112
- return false;
1113
- }
1114
- })))];
1115
- // Check only files this run copied, grouped by skill: ignoring a whole skill is deliberate.
1116
- // check-ignore omits tracked files, so a complete committed install stays quiet on reruns.
1117
- const groups = [...installedSkills.values()].map((files) => files.map(relative));
1118
- const files = groups.flat();
1119
- let ignored = [];
1120
- let rule;
1121
- if (files.length) {
1122
- const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
1123
- if (result.error || ![0, 1].includes(result.status)) return;
1124
- const matches = new Set(result.stdout.split('\0').filter(Boolean));
1125
- ignored = [...new Set(groups.flatMap((group) => {
1126
- const hidden = group.filter((file) => matches.has(file));
1127
- return hidden.length === group.length ? [] : hidden;
1128
- }))];
1129
- if (ignored.length) {
1130
- const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
1131
- if (verbose.error || verbose.status !== 0) return;
1132
- const [source, line, pattern] = verbose.stdout.split('\0');
1133
- rule = `${source}:${line}: ${pattern}`;
1134
- }
1135
- }
1136
- if (!pending.length && !ignored.length) return;
1137
- const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
1138
- log('Optional — commit (and push) these changes if teammates or /devlyn-ideate task worktrees should use them:', 'yellow');
1139
- if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
1140
- const commands = [];
1141
- if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
1142
- if (ignored.length) commands.push(`git add -f -- ${ignored.map(quote).join(' ')}`);
1143
- commands.push(`git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`);
1144
- log(` ${commands.join(' && ')}`, 'dim');
1147
+ const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
1148
+ if (result.error || ![0, 1].includes(result.status)) return;
1149
+ const matches = new Set(result.stdout.split('\0').filter(Boolean));
1150
+ const ignored = [...new Set(groups.flatMap((group) => {
1151
+ const hidden = group.filter((file) => matches.has(file));
1152
+ return hidden.length === group.length ? [] : hidden;
1153
+ }))];
1154
+ if (!ignored.length) return;
1155
+ const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
1156
+ if (verbose.error || verbose.status !== 0) return;
1157
+ const [source, line, pattern] = verbose.stdout.split('\0');
1158
+ log(`Git ignores ${ignored.length} installed file${ignored.length === 1 ? '' : 's'} (example rule: ${source}:${line}: ${pattern}).`, 'dim');
1145
1159
  }
1146
1160
 
1147
1161
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
1148
1162
  // project's copy; name it without touching it.
1149
1163
  function noticeGlobalDrift(installed) {
1150
- for (const dir of ['.agents', '.codex', '.claude', '.grok']) {
1164
+ for (const dir of ['.agents', '.codex', '.claude']) {
1151
1165
  const root = path.join(os.homedir(), dir, 'skills');
1152
1166
  const marker = path.join(root, DEVLYN_INSTALL_MARKER);
1153
1167
  if (installed.includes(root) || !fs.existsSync(marker)) continue;
@@ -1157,16 +1171,14 @@ function noticeGlobalDrift(installed) {
1157
1171
  } catch (error) {
1158
1172
  version = `(unreadable marker: ${error.message})`;
1159
1173
  }
1160
- if (dir !== '.grok' && version === PKG.version) continue;
1161
- // 4.0's Grok root; Grok now reads ~/.agents/skills, so --global never refreshes it.
1162
- const advice = dir === '.grok' ? 'delete it' : 'refresh it with --global, or delete it';
1174
+ if (version === PKG.version) continue;
1175
+ const advice = 'refresh it with --global, or delete it';
1163
1176
  log(`Global devlyn ${version} in ${root.replace(os.homedir(), '~')} — ${advice}.`, 'yellow');
1164
1177
  }
1165
1178
  }
1166
1179
 
1167
1180
  // Skip project setup in the home folder, where instructions would affect every project.
1168
- // Lock user roots, preflight all roots, check versions, then write; user roots go first
1169
- // so the project's commit guidance remains the last output.
1181
+ // Lock user roots, preflight all roots, check versions, then write.
1170
1182
  function update() {
1171
1183
  showLogo();
1172
1184
  log('─'.repeat(44), 'dim');
@@ -1203,12 +1215,15 @@ function update() {
1203
1215
  const projectRoots = targets.flatMap((target) => skillRoots(target, false));
1204
1216
  const roots = [...userRoots, ...projectRoots];
1205
1217
  if (!roots.length) {
1218
+ const userCleanup = cleanupScope(os.homedir(), true);
1219
+ const projectCleanup = (home?.dev !== here.dev || home?.ino !== here.ino) && cleanupScope(projectDir(), false);
1220
+ if (userCleanup || projectCleanup) return;
1206
1221
  throw new InstructionError(['No devlyn install here to update. Run npx devlyn-cli to install.', ...linkNotices].join('\n'));
1207
1222
  }
1208
1223
 
1209
1224
  return withUserSkillLocks(userRoots, () => {
1210
1225
  if (targets.length) readProjectGitignore();
1211
- const plan = preflightSkills(roots, [], targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
1226
+ const plan = preflightSkills(roots);
1212
1227
  // Ownership preflight has already refused unreadable or invalid markers.
1213
1228
  const current = PKG.version.split('.').map(BigInt);
1214
1229
  const newer = roots.flatMap((root) => {
@@ -1231,12 +1246,15 @@ function update() {
1231
1246
  + newer.join('\n') + '\nRun npx devlyn-cli@latest update.');
1232
1247
  }
1233
1248
 
1234
- userRoots.forEach((root) => installCoreSkills(root, [], new Map(), plan));
1235
- if (targets.length) install(targets, false, [], plan);
1249
+ userRoots.forEach((root) => installCoreSkills(root));
1250
+ if (targets.length) install(targets, false, [], plan, () => cleanupScope(os.homedir(), true));
1236
1251
  else {
1252
+ cleanupScope(os.homedir(), true);
1237
1253
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
1238
1254
  noticeGlobalDrift(userRoots);
1239
- noticeRetainedSkills(plan.retained);
1255
+ }
1256
+ if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
1257
+ cleanupScope(projectDir(), false);
1240
1258
  }
1241
1259
  });
1242
1260
  }