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/README.md +5 -5
- package/bin/cleanup-history.json +97 -0
- package/bin/devlyn.js +285 -267
- package/bin/instructions.js +44 -24
- package/bin/skill-history.json +1 -70
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
||
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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 =
|
|
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] ||
|
|
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
|
-
//
|
|
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) =>
|
|
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
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|
509
|
-
|
|
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
|
-
//
|
|
518
|
-
function preflightSkills(roots, optionalNames = []
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
626
|
+
function installOptionalSkillInto(target, skillName, installedSkills) {
|
|
690
627
|
const dest = path.join(target, skillName);
|
|
691
|
-
fs.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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,
|
|
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 =
|
|
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()
|
|
852
|
-
const written = [];
|
|
781
|
+
function installAgentsProject(withClaude, installedSkills = new Map()) {
|
|
853
782
|
if (!agentsMdIsClaudeMd()) {
|
|
854
|
-
|
|
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],
|
|
862
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
missing
|
|
986
|
-
|
|
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
|
|
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,
|
|
1061
|
-
else if (target === 'agents')
|
|
1062
|
-
else
|
|
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,
|
|
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
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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:
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
|
1087
|
-
|
|
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
|
|
1091
|
-
if (
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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'
|
|
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 (
|
|
1161
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1255
|
+
}
|
|
1256
|
+
if (!targets.length && (home?.dev !== here.dev || home?.ino !== here.ino)) {
|
|
1257
|
+
cleanupScope(projectDir(), false);
|
|
1240
1258
|
}
|
|
1241
1259
|
});
|
|
1242
1260
|
}
|