devlyn-cli 4.2.7 → 4.2.9
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 +1 -1
- package/bin/devlyn.js +121 -34
- package/bin/skill-ownership.js +10 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ That's it. The installer asks:
|
|
|
35
35
|
|
|
36
36
|
In a project, AGENTS.md readers load the skills from `.agents/skills/`; Claude Code loads only `.claude/skills/` and reads AGENTS.md when the project has no CLAUDE.md; a CLAUDE.md the installer creates imports an existing AGENTS.md (`@AGENTS.md`). Global installs skills only: `~/.agents/skills/` (omp, Pi, Grok) and `~/.codex/skills/` (Codex), plus `~/.claude/skills/` for Claude Code. Every target gets the `devlyn-ideate` skill and the `devlyn-engines` utility. In Codex / omp / Pi, invoke them as skills (`$devlyn-ideate`); in Claude Code and Grok they're slash commands (`/devlyn-ideate`). Rerunning refreshes skills and the managed instruction block while preserving project rules outside it. See [Migration from earlier versions](#migration-from-earlier-versions) for legacy migration and merge recovery.
|
|
37
37
|
|
|
38
|
-
Without prompts, `npx devlyn-cli -y` installs AGENTS.md + `.agents/skills/` plus every target this project already has; add `--claude` for Claude Code. With `--global` it installs for every project on this machine, plus `~/.claude/skills/` with `--claude` or when it already has a devlyn install.
|
|
38
|
+
Without prompts, `npx devlyn-cli -y` installs AGENTS.md + `.agents/skills/` plus every target this project already has; add `--claude` for Claude Code. With `--global` it installs for every project on this machine, plus `~/.claude/skills/` with `--claude` or when it already has a devlyn install. After a project install or update, the installer names the changed files to commit, including installed files a `.gitignore` hides, so teammates and `/devlyn-ideate` drains can use them.
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
package/bin/devlyn.js
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
-
const { execSync } = require('child_process');
|
|
6
|
+
const { execSync, spawnSync } = require('child_process');
|
|
7
7
|
|
|
8
8
|
const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
|
|
9
9
|
const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
|
|
10
10
|
const PKG = require('../package.json');
|
|
11
11
|
const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
|
|
12
|
-
const { contentHash, fingerprint, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
|
|
12
|
+
const { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths } = require('./skill-ownership');
|
|
13
13
|
const SKILL_HISTORY = require('./skill-history.json');
|
|
14
14
|
|
|
15
15
|
// The devlyn skill bundle installed into every skill-capable agent's loader
|
|
@@ -346,13 +346,14 @@ function listContents() {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
// Old and retired skills in one skill root; nothing outside it.
|
|
349
|
-
function cleanupDeprecated(skillsDir) {
|
|
349
|
+
function cleanupDeprecated(skillsDir, written) {
|
|
350
350
|
let removed = 0;
|
|
351
351
|
const deprecated = DEPRECATED_DIRS.flatMap((relPath) =>
|
|
352
352
|
legacySkillPaths(skillsDir, path.basename(relPath)).map((fullPath) => [relPath, fullPath]));
|
|
353
353
|
for (const [relPath, fullPath] of deprecated) {
|
|
354
354
|
if (fs.existsSync(fullPath)) {
|
|
355
355
|
fs.rmSync(fullPath, { recursive: true });
|
|
356
|
+
written.push(fullPath);
|
|
356
357
|
log(` ✕ ${relPath}/ (removed)`, 'dim');
|
|
357
358
|
removed++;
|
|
358
359
|
}
|
|
@@ -361,6 +362,7 @@ function cleanupDeprecated(skillsDir) {
|
|
|
361
362
|
const fullPath = path.join(skillsDir, name);
|
|
362
363
|
if (isShippedCopy(fullPath, hashes)) {
|
|
363
364
|
fs.rmSync(fullPath, { recursive: true });
|
|
365
|
+
written.push(fullPath);
|
|
364
366
|
log(` ✕ skills/${name}/ (removed)`, 'dim');
|
|
365
367
|
removed++;
|
|
366
368
|
}
|
|
@@ -372,27 +374,30 @@ function cleanupDeprecated(skillsDir) {
|
|
|
372
374
|
// are in place. An optional skill found under an old spelling is installed under its new name
|
|
373
375
|
// before the old copy goes, so an interrupted migration never loses it. An unedited 0.6.0-0.7.1
|
|
374
376
|
// copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
|
|
375
|
-
function retireRenamedSkills(skillsDir) {
|
|
377
|
+
function retireRenamedSkills(skillsDir, written, installedSkills) {
|
|
376
378
|
const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
|
|
377
379
|
const refreshed = [];
|
|
378
380
|
for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
|
|
379
381
|
const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
|
|
380
382
|
if (found.length > 0 && optional.has(newName)) {
|
|
381
|
-
installOptionalSkillInto(skillsDir, newName);
|
|
383
|
+
installOptionalSkillInto(skillsDir, newName, written, installedSkills);
|
|
382
384
|
refreshed.push(newName);
|
|
383
385
|
} else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
|
|
384
386
|
refreshPreStandardCopy(skillsDir, newName);
|
|
387
|
+
written.push(path.join(skillsDir, newName));
|
|
388
|
+
installedSkills.set(path.join(skillsDir, newName), [path.join(skillsDir, newName, 'SKILL.md')]);
|
|
385
389
|
refreshed.push(newName);
|
|
386
390
|
}
|
|
387
391
|
for (const fullPath of found) {
|
|
388
392
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
393
|
+
written.push(fullPath);
|
|
389
394
|
log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
|
|
390
395
|
}
|
|
391
396
|
}
|
|
392
397
|
return refreshed;
|
|
393
398
|
}
|
|
394
399
|
|
|
395
|
-
function copyRecursive(src, dest, baseDir) {
|
|
400
|
+
function copyRecursive(src, dest, baseDir, files = []) {
|
|
396
401
|
const stats = fs.statSync(src);
|
|
397
402
|
|
|
398
403
|
if (stats.isDirectory()) {
|
|
@@ -401,7 +406,7 @@ function copyRecursive(src, dest, baseDir) {
|
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
for (const item of fs.readdirSync(src)) {
|
|
404
|
-
copyRecursive(path.join(src, item), path.join(dest, item), baseDir);
|
|
409
|
+
copyRecursive(path.join(src, item), path.join(dest, item), baseDir, files);
|
|
405
410
|
}
|
|
406
411
|
} else {
|
|
407
412
|
const destDir = path.dirname(dest);
|
|
@@ -409,8 +414,10 @@ function copyRecursive(src, dest, baseDir) {
|
|
|
409
414
|
fs.mkdirSync(destDir, { recursive: true });
|
|
410
415
|
}
|
|
411
416
|
fs.copyFileSync(src, dest);
|
|
417
|
+
files.push(dest);
|
|
412
418
|
log(` → ${path.relative(baseDir, dest)}`, 'dim');
|
|
413
419
|
}
|
|
420
|
+
return files;
|
|
414
421
|
}
|
|
415
422
|
|
|
416
423
|
function clearInstallMarker(skillsDir) {
|
|
@@ -610,30 +617,34 @@ function singleSelect(message, items, initial) {
|
|
|
610
617
|
});
|
|
611
618
|
}
|
|
612
619
|
|
|
613
|
-
// After
|
|
614
|
-
function installLocalSkill(skillName, roots) {
|
|
620
|
+
// After preflight has cleared every selected addon before any change.
|
|
621
|
+
function installLocalSkill(skillName, roots, written = [], installedSkills = new Map()) {
|
|
615
622
|
if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
|
|
616
623
|
log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
|
|
617
624
|
return false;
|
|
618
625
|
}
|
|
619
626
|
log(`\n🛠️ Installing ${skillName}...`, 'cyan');
|
|
620
627
|
for (const root of roots) {
|
|
621
|
-
installOptionalSkillInto(root, skillName);
|
|
628
|
+
installOptionalSkillInto(root, skillName, written, installedSkills);
|
|
622
629
|
writeInstallMarker(root, [skillName], readInstallManifest(root));
|
|
630
|
+
written.push(path.join(root, DEVLYN_INSTALL_MARKER));
|
|
623
631
|
}
|
|
624
632
|
return true;
|
|
625
633
|
}
|
|
626
634
|
|
|
627
635
|
// One optional skill into one skill-loader directory, replacing any older copy of it. A
|
|
628
636
|
// copy under its name before the 4.0.0 rename goes only once the new one is complete.
|
|
629
|
-
function installOptionalSkillInto(target, skillName) {
|
|
637
|
+
function installOptionalSkillInto(target, skillName, written, installedSkills) {
|
|
630
638
|
const dest = path.join(target, skillName);
|
|
631
639
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
632
|
-
copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target);
|
|
640
|
+
installedSkills.set(dest, copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target));
|
|
633
641
|
assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
|
|
642
|
+
written.push(dest);
|
|
634
643
|
const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
|
|
635
644
|
for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
|
|
645
|
+
if (!fs.existsSync(fullPath)) continue;
|
|
636
646
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
647
|
+
written.push(fullPath);
|
|
637
648
|
}
|
|
638
649
|
}
|
|
639
650
|
|
|
@@ -679,10 +690,7 @@ function installSkillPack(packName) {
|
|
|
679
690
|
}
|
|
680
691
|
}
|
|
681
692
|
|
|
682
|
-
function installAddon(addon
|
|
683
|
-
if (addon.type === 'local') {
|
|
684
|
-
return installLocalSkill(addon.name, roots);
|
|
685
|
-
}
|
|
693
|
+
function installAddon(addon) {
|
|
686
694
|
if (addon.type === 'mcp') {
|
|
687
695
|
return installMcpServer(addon.name, addon.command);
|
|
688
696
|
}
|
|
@@ -691,13 +699,13 @@ function installAddon(addon, roots) {
|
|
|
691
699
|
|
|
692
700
|
// The core skills into one skill root, replacing older copies and retired skills, then the
|
|
693
701
|
// marker that records a complete install.
|
|
694
|
-
function installCoreSkills(skillsDir) {
|
|
702
|
+
function installCoreSkills(skillsDir, written = [], installedSkills = new Map()) {
|
|
695
703
|
const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
|
|
696
704
|
log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
|
|
697
705
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
698
706
|
const previous = readInstallManifest(skillsDir);
|
|
699
707
|
clearInstallMarker(skillsDir);
|
|
700
|
-
const removed = cleanupDeprecated(skillsDir);
|
|
708
|
+
const removed = cleanupDeprecated(skillsDir, written);
|
|
701
709
|
if (removed > 0) {
|
|
702
710
|
log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
|
|
703
711
|
}
|
|
@@ -707,11 +715,13 @@ function installCoreSkills(skillsDir) {
|
|
|
707
715
|
if (!fs.existsSync(src)) continue;
|
|
708
716
|
// Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
|
|
709
717
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
710
|
-
copyRecursive(src, dest, skillsDir);
|
|
718
|
+
installedSkills.set(dest, copyRecursive(src, dest, skillsDir));
|
|
719
|
+
written.push(dest);
|
|
711
720
|
}
|
|
712
721
|
assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
|
|
713
|
-
const refreshed = retireRenamedSkills(skillsDir);
|
|
722
|
+
const refreshed = retireRenamedSkills(skillsDir, written, installedSkills);
|
|
714
723
|
writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
|
|
724
|
+
written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
|
|
715
725
|
}
|
|
716
726
|
|
|
717
727
|
function readProjectGitignore() {
|
|
@@ -763,7 +773,9 @@ function updateProjectGitignore(markerEntry) {
|
|
|
763
773
|
if (!gitignoreContent.equals(original)) {
|
|
764
774
|
fs.writeFileSync(gitignorePath, gitignoreContent);
|
|
765
775
|
log(' → .gitignore (updated project install rules)', 'dim');
|
|
776
|
+
return true;
|
|
766
777
|
}
|
|
778
|
+
return false;
|
|
767
779
|
}
|
|
768
780
|
|
|
769
781
|
// With the Claude target in the same run, an AGENTS.md that links to this project's CLAUDE.md (a
|
|
@@ -783,17 +795,19 @@ function agentsMdIsClaudeMd() {
|
|
|
783
795
|
return target !== null && fs.existsSync(target) && fs.realpathSync.native(target) === fs.realpathSync.native(claude);
|
|
784
796
|
}
|
|
785
797
|
|
|
786
|
-
function installAgentsProject(withClaude) {
|
|
798
|
+
function installAgentsProject(withClaude, installedSkills = new Map()) {
|
|
799
|
+
const written = [];
|
|
787
800
|
if (!agentsMdIsClaudeMd()) {
|
|
788
|
-
updateInstructions('AGENTS.md');
|
|
801
|
+
if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
|
|
789
802
|
} else if (withClaude) {
|
|
790
803
|
log(' → AGENTS.md is CLAUDE.md here, so it gets the devlyn block written there', 'dim');
|
|
791
804
|
} else {
|
|
792
805
|
throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
|
|
793
806
|
+ '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
|
|
794
807
|
}
|
|
795
|
-
installCoreSkills(skillRoots('agents', false)[0]);
|
|
796
|
-
updateProjectGitignore('.agents/skills/.devlyn-install.json');
|
|
808
|
+
installCoreSkills(skillRoots('agents', false)[0], written, installedSkills);
|
|
809
|
+
if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
|
|
810
|
+
return written;
|
|
797
811
|
}
|
|
798
812
|
|
|
799
813
|
// Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
|
|
@@ -817,12 +831,13 @@ function installClaudeInstructions() {
|
|
|
817
831
|
throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
|
|
818
832
|
+ 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
|
|
819
833
|
}
|
|
820
|
-
updateInstructions('CLAUDE.md', initial, !imported);
|
|
834
|
+
const changed = updateInstructions('CLAUDE.md', initial, !imported);
|
|
821
835
|
if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
|
|
836
|
+
return changed;
|
|
822
837
|
}
|
|
823
838
|
|
|
824
839
|
// Project CLAUDE.md and .claude/: skills and settings.
|
|
825
|
-
function installClaudeCore() {
|
|
840
|
+
function installClaudeCore(installedSkills = new Map()) {
|
|
826
841
|
const skillsDir = skillRoots('claude', false)[0];
|
|
827
842
|
const targetDir = path.dirname(skillsDir);
|
|
828
843
|
// Read before any write, so settings this install cannot merge leave the Claude target as it was.
|
|
@@ -845,16 +860,17 @@ function installClaudeCore() {
|
|
|
845
860
|
&& (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
|
|
846
861
|
throw unmergeable('env must be a JSON object');
|
|
847
862
|
}
|
|
848
|
-
installClaudeInstructions();
|
|
863
|
+
const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
|
|
849
864
|
for (const relPath of DEPRECATED_FILES) {
|
|
850
865
|
const fullPath = path.join(targetDir, relPath);
|
|
851
866
|
if (fs.existsSync(fullPath)) {
|
|
852
867
|
fs.unlinkSync(fullPath);
|
|
853
868
|
log(` ✕ ${relPath} (deprecated)`, 'dim');
|
|
869
|
+
written.push(`.claude/${relPath}`);
|
|
854
870
|
}
|
|
855
871
|
}
|
|
856
|
-
installCoreSkills(skillsDir);
|
|
857
|
-
updateProjectGitignore('.claude/skills/.devlyn-install.json');
|
|
872
|
+
installCoreSkills(skillsDir, written, installedSkills);
|
|
873
|
+
if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
|
|
858
874
|
|
|
859
875
|
// One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
|
|
860
876
|
let settingsChanged = false;
|
|
@@ -885,6 +901,7 @@ function installClaudeCore() {
|
|
|
885
901
|
if (settingsChanged) {
|
|
886
902
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
887
903
|
log(' → settings.json', 'dim');
|
|
904
|
+
written.push('.claude/settings.json');
|
|
888
905
|
}
|
|
889
906
|
// Their ownership cannot be established, so the other retired settings stay; name the ones present.
|
|
890
907
|
const retired = [
|
|
@@ -894,6 +911,7 @@ function installClaudeCore() {
|
|
|
894
911
|
if (retired.length > 0) {
|
|
895
912
|
log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
|
|
896
913
|
}
|
|
914
|
+
return written;
|
|
897
915
|
}
|
|
898
916
|
|
|
899
917
|
// Installs the targets in one scope; returns the skill roots written.
|
|
@@ -908,21 +926,90 @@ function install(targets, global, optionalNames = []) {
|
|
|
908
926
|
+ 'would apply to every project. Run from a project folder, or use --global for skills only.');
|
|
909
927
|
}
|
|
910
928
|
const roots = targets.flatMap((target) => skillRoots(target, global));
|
|
929
|
+
const written = [];
|
|
930
|
+
const installedSkills = new Map();
|
|
911
931
|
if (!global) readProjectGitignore();
|
|
912
932
|
preflightSkills(roots, optionalNames,
|
|
913
933
|
!global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
|
|
914
934
|
// AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
|
|
915
935
|
for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
|
|
916
936
|
if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
|
|
917
|
-
else if (target === 'agents') installAgentsProject(targets.includes('claude'));
|
|
918
|
-
else installClaudeCore();
|
|
937
|
+
else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude'), installedSkills));
|
|
938
|
+
else written.push(...installClaudeCore(installedSkills));
|
|
919
939
|
}
|
|
940
|
+
for (const name of optionalNames) installLocalSkill(name, roots, written, installedSkills);
|
|
920
941
|
log(`\n✅ devlyn ${PKG.version} installed`, 'green');
|
|
921
942
|
if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
|
|
943
|
+
else noticeProjectChanges(written, installedSkills);
|
|
922
944
|
noticeGlobalDrift(roots);
|
|
923
945
|
return roots;
|
|
924
946
|
}
|
|
925
947
|
|
|
948
|
+
// Advisory only: a successful install stays successful when Git is unavailable or fails.
|
|
949
|
+
function noticeProjectChanges(paths, installedSkills = new Map()) {
|
|
950
|
+
if (!paths.length && !installedSkills.size) return;
|
|
951
|
+
const cwd = projectDir();
|
|
952
|
+
const relative = (name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
|
|
953
|
+
paths = [...new Set(paths.map(relative))];
|
|
954
|
+
const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
|
|
955
|
+
const git = (args, input) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env, input });
|
|
956
|
+
const repo = git(['rev-parse', '--show-toplevel']);
|
|
957
|
+
if (repo.error || repo.status !== 0) return;
|
|
958
|
+
const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
|
|
959
|
+
'--', ...paths.map((name) => `:(icase,literal)${name}`)]);
|
|
960
|
+
if (status.error || status.status !== 0) return;
|
|
961
|
+
// Porcelain paths are repository-relative, even when installing in a nested project.
|
|
962
|
+
const changed = status.stdout.split('\0').filter(Boolean).map((entry) =>
|
|
963
|
+
path.relative(cwd, path.resolve(repo.stdout.replace(/\r?\n$/, ''), entry.slice(3))).split(path.sep).join('/'));
|
|
964
|
+
// Keep the installed folder/file scope, using Git's spelling on case-insensitive volumes.
|
|
965
|
+
const pending = [...new Set(paths.flatMap((name) => [...new Set(changed
|
|
966
|
+
.map((file) => file.split('/').slice(0, name.split('/').length).join('/')))]
|
|
967
|
+
.filter((file) => {
|
|
968
|
+
if (file === name) return true;
|
|
969
|
+
if (file.toLowerCase() !== name.toLowerCase()) return false;
|
|
970
|
+
// Case folding alone can include independently edited files on case-sensitive volumes.
|
|
971
|
+
// Removed paths have no identity left to compare, so require their exact spelling.
|
|
972
|
+
try {
|
|
973
|
+
const written = fs.lstatSync(path.resolve(cwd, name), { bigint: true, throwIfNoEntry: false });
|
|
974
|
+
const reported = fs.lstatSync(path.resolve(cwd, file), { bigint: true, throwIfNoEntry: false });
|
|
975
|
+
return written && reported && written.dev === reported.dev && written.ino === reported.ino;
|
|
976
|
+
} catch (error) {
|
|
977
|
+
log(`Cannot compare Git path ${file} with installed path ${name}: ${error.message}`, 'yellow');
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
})))];
|
|
981
|
+
// Check only files this run copied, grouped by skill: ignoring a whole skill is deliberate.
|
|
982
|
+
// check-ignore omits tracked files, so a complete committed install stays quiet on reruns.
|
|
983
|
+
const groups = [...installedSkills.values()].map((files) => files.map(relative));
|
|
984
|
+
const files = groups.flat();
|
|
985
|
+
let ignored = [];
|
|
986
|
+
let rule;
|
|
987
|
+
if (files.length) {
|
|
988
|
+
const result = git(['check-ignore', '-z', '--stdin'], files.join('\0') + '\0');
|
|
989
|
+
if (result.error || ![0, 1].includes(result.status)) return;
|
|
990
|
+
const matches = new Set(result.stdout.split('\0').filter(Boolean));
|
|
991
|
+
ignored = [...new Set(groups.flatMap((group) => {
|
|
992
|
+
const hidden = group.filter((file) => matches.has(file));
|
|
993
|
+
return hidden.length === group.length ? [] : hidden;
|
|
994
|
+
}))];
|
|
995
|
+
if (ignored.length) {
|
|
996
|
+
const verbose = git(['check-ignore', '-v', '-z', '--stdin'], ignored[0] + '\0');
|
|
997
|
+
if (verbose.error || verbose.status !== 0) return;
|
|
998
|
+
const [source, line, pattern] = verbose.stdout.split('\0');
|
|
999
|
+
rule = `${source}:${line}: ${pattern}`;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (!pending.length && !ignored.length) return;
|
|
1003
|
+
const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
|
|
1004
|
+
log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
|
|
1005
|
+
if (ignored.length) log(`.gitignore hides ${ignored.length} installed files (${rule}).`, 'yellow');
|
|
1006
|
+
const commands = [];
|
|
1007
|
+
if (pending.length) commands.push(`git add -- ${pending.map(quote).join(' ')}`);
|
|
1008
|
+
if (ignored.length) commands.push(`git add -f -- ${ignored.map(quote).join(' ')}`);
|
|
1009
|
+
commands.push(`git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`);
|
|
1010
|
+
log(` ${commands.join(' && ')}`, 'dim');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
926
1013
|
// A devlyn install in a user root this run does not refresh drifts and can shadow the
|
|
927
1014
|
// project's copy; name it without touching it.
|
|
928
1015
|
function noticeGlobalDrift(installed) {
|
|
@@ -982,11 +1069,11 @@ async function init({ yes, claude, global }) {
|
|
|
982
1069
|
const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
|
|
983
1070
|
// Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
|
|
984
1071
|
const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
|
|
985
|
-
|
|
1072
|
+
install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
|
|
986
1073
|
|
|
987
1074
|
if (selectedAddons.length > 0) {
|
|
988
|
-
for (const addon of selectedAddons) {
|
|
989
|
-
installAddon(addon
|
|
1075
|
+
for (const addon of selectedAddons.filter((addon) => addon.type !== 'local')) {
|
|
1076
|
+
installAddon(addon);
|
|
990
1077
|
}
|
|
991
1078
|
} else {
|
|
992
1079
|
log('💡 No optional addons selected', 'dim');
|
package/bin/skill-ownership.js
CHANGED
|
@@ -52,15 +52,20 @@ function fingerprint(dir) {
|
|
|
52
52
|
return crypto.createHash('sha256').update(JSON.stringify(entries)).digest('hex');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function gitEnvironment() {
|
|
56
|
+
const env = { ...process.env, LC_ALL: 'C' };
|
|
57
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
|
|
58
|
+
'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
|
|
59
|
+
'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM']) delete env[key];
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// Use the index, not status: even files deleted locally need ownership checks.
|
|
56
64
|
// Inspect the repository containing this root, including global dotfiles repos.
|
|
57
65
|
function trackedPaths(root) {
|
|
58
66
|
let cwd = root;
|
|
59
67
|
while (!fs.existsSync(cwd)) cwd = path.dirname(cwd);
|
|
60
|
-
const env =
|
|
61
|
-
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
|
|
62
|
-
'GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS',
|
|
63
|
-
'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM']) delete env[key];
|
|
68
|
+
const env = gitEnvironment();
|
|
64
69
|
const git = (args) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env });
|
|
65
70
|
const repo = git(['rev-parse', '--show-toplevel']);
|
|
66
71
|
if (repo.error?.code === 'ENOENT') return [];
|
|
@@ -76,4 +81,4 @@ function trackedPaths(root) {
|
|
|
76
81
|
return files.stdout.split('\0').filter(Boolean).map((file) => path.resolve(repo.stdout.trim(), file));
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
module.exports = { contentHash, fingerprint, isIgnoredEntry, trackedPaths };
|
|
84
|
+
module.exports = { contentHash, fingerprint, gitEnvironment, isIgnoredEntry, trackedPaths };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devlyn-cli",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.9",
|
|
4
4
|
"description": "Loop & harness engineering for AI coding agents (Claude Code, Codex, and any AGENTS.md-driven agent) — core engineering principles for CLAUDE.md/AGENTS.md and a loop designer that splits one intent into tasks agents drain hands-free",
|
|
5
5
|
"homepage": "https://github.com/fysoul17/devlyn-cli#readme",
|
|
6
6
|
"bin": {
|