devlyn-cli 4.2.7 → 4.2.8

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 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 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,20 +374,22 @@ 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) {
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);
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));
385
388
  refreshed.push(newName);
386
389
  }
387
390
  for (const fullPath of found) {
388
391
  fs.rmSync(fullPath, { recursive: true, force: true });
392
+ written.push(fullPath);
389
393
  log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
390
394
  }
391
395
  }
@@ -610,30 +614,34 @@ function singleSelect(message, items, initial) {
610
614
  });
611
615
  }
612
616
 
613
- // After install(), whose preflight already cleared every selected addon before any change.
614
- function installLocalSkill(skillName, roots) {
617
+ // After preflight has cleared every selected addon before any change.
618
+ function installLocalSkill(skillName, roots, written = []) {
615
619
  if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
616
620
  log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
617
621
  return false;
618
622
  }
619
623
  log(`\n🛠️ Installing ${skillName}...`, 'cyan');
620
624
  for (const root of roots) {
621
- installOptionalSkillInto(root, skillName);
625
+ installOptionalSkillInto(root, skillName, written);
622
626
  writeInstallMarker(root, [skillName], readInstallManifest(root));
627
+ written.push(path.join(root, DEVLYN_INSTALL_MARKER));
623
628
  }
624
629
  return true;
625
630
  }
626
631
 
627
632
  // One optional skill into one skill-loader directory, replacing any older copy of it. A
628
633
  // copy under its name before the 4.0.0 rename goes only once the new one is complete.
629
- function installOptionalSkillInto(target, skillName) {
634
+ function installOptionalSkillInto(target, skillName, written) {
630
635
  const dest = path.join(target, skillName);
631
636
  fs.rmSync(dest, { recursive: true, force: true });
632
637
  copyRecursive(path.join(OPTIONAL_SKILLS_SOURCE, skillName), dest, target);
633
638
  assertCompleteSkillInstall(OPTIONAL_SKILLS_SOURCE, target, [skillName]);
639
+ written.push(dest);
634
640
  const oldName = Object.keys(RENAMED_SKILLS).find((name) => RENAMED_SKILLS[name] === skillName);
635
641
  for (const fullPath of oldName ? legacySkillPaths(target, oldName) : []) {
642
+ if (!fs.existsSync(fullPath)) continue;
636
643
  fs.rmSync(fullPath, { recursive: true, force: true });
644
+ written.push(fullPath);
637
645
  }
638
646
  }
639
647
 
@@ -679,10 +687,7 @@ function installSkillPack(packName) {
679
687
  }
680
688
  }
681
689
 
682
- function installAddon(addon, roots) {
683
- if (addon.type === 'local') {
684
- return installLocalSkill(addon.name, roots);
685
- }
690
+ function installAddon(addon) {
686
691
  if (addon.type === 'mcp') {
687
692
  return installMcpServer(addon.name, addon.command);
688
693
  }
@@ -691,13 +696,13 @@ function installAddon(addon, roots) {
691
696
 
692
697
  // The core skills into one skill root, replacing older copies and retired skills, then the
693
698
  // marker that records a complete install.
694
- function installCoreSkills(skillsDir) {
699
+ function installCoreSkills(skillsDir, written = []) {
695
700
  const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
696
701
  log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
697
702
  fs.mkdirSync(skillsDir, { recursive: true });
698
703
  const previous = readInstallManifest(skillsDir);
699
704
  clearInstallMarker(skillsDir);
700
- const removed = cleanupDeprecated(skillsDir);
705
+ const removed = cleanupDeprecated(skillsDir, written);
701
706
  if (removed > 0) {
702
707
  log(`\n🧹 Cleaned up ${removed} deprecated file${removed > 1 ? 's' : ''}`, 'yellow');
703
708
  }
@@ -708,10 +713,12 @@ function installCoreSkills(skillsDir) {
708
713
  // Full replace: copyRecursive is an overlay, so stale files would otherwise persist.
709
714
  fs.rmSync(dest, { recursive: true, force: true });
710
715
  copyRecursive(src, dest, skillsDir);
716
+ written.push(dest);
711
717
  }
712
718
  assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
713
- const refreshed = retireRenamedSkills(skillsDir);
719
+ const refreshed = retireRenamedSkills(skillsDir, written);
714
720
  writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
721
+ written.push(path.join(skillsDir, DEVLYN_INSTALL_MARKER));
715
722
  }
716
723
 
717
724
  function readProjectGitignore() {
@@ -763,7 +770,9 @@ function updateProjectGitignore(markerEntry) {
763
770
  if (!gitignoreContent.equals(original)) {
764
771
  fs.writeFileSync(gitignorePath, gitignoreContent);
765
772
  log(' → .gitignore (updated project install rules)', 'dim');
773
+ return true;
766
774
  }
775
+ return false;
767
776
  }
768
777
 
769
778
  // With the Claude target in the same run, an AGENTS.md that links to this project's CLAUDE.md (a
@@ -784,16 +793,18 @@ function agentsMdIsClaudeMd() {
784
793
  }
785
794
 
786
795
  function installAgentsProject(withClaude) {
796
+ const written = [];
787
797
  if (!agentsMdIsClaudeMd()) {
788
- updateInstructions('AGENTS.md');
798
+ if (updateInstructions('AGENTS.md')) written.push('AGENTS.md');
789
799
  } else if (withClaude) {
790
800
  log(' → AGENTS.md is CLAUDE.md here, so it gets the devlyn block written there', 'dim');
791
801
  } else {
792
802
  throw new InstructionError('AGENTS.md is CLAUDE.md under another name here. Choose CLAUDE.md as well '
793
803
  + '(npx devlyn-cli -y --claude) so the devlyn block is written once, into CLAUDE.md.');
794
804
  }
795
- installCoreSkills(skillRoots('agents', false)[0]);
796
- updateProjectGitignore('.agents/skills/.devlyn-install.json');
805
+ installCoreSkills(skillRoots('agents', false)[0], written);
806
+ if (updateProjectGitignore('.agents/skills/.devlyn-install.json')) written.push('.gitignore');
807
+ return written;
797
808
  }
798
809
 
799
810
  // Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
@@ -817,8 +828,9 @@ function installClaudeInstructions() {
817
828
  throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
818
829
  + 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
819
830
  }
820
- updateInstructions('CLAUDE.md', initial, !imported);
831
+ const changed = updateInstructions('CLAUDE.md', initial, !imported);
821
832
  if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
833
+ return changed;
822
834
  }
823
835
 
824
836
  // Project CLAUDE.md and .claude/: skills and settings.
@@ -845,16 +857,17 @@ function installClaudeCore() {
845
857
  && (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
846
858
  throw unmergeable('env must be a JSON object');
847
859
  }
848
- installClaudeInstructions();
860
+ const written = installClaudeInstructions() ? ['CLAUDE.md'] : [];
849
861
  for (const relPath of DEPRECATED_FILES) {
850
862
  const fullPath = path.join(targetDir, relPath);
851
863
  if (fs.existsSync(fullPath)) {
852
864
  fs.unlinkSync(fullPath);
853
865
  log(` ✕ ${relPath} (deprecated)`, 'dim');
866
+ written.push(`.claude/${relPath}`);
854
867
  }
855
868
  }
856
- installCoreSkills(skillsDir);
857
- updateProjectGitignore('.claude/skills/.devlyn-install.json');
869
+ installCoreSkills(skillsDir, written);
870
+ if (updateProjectGitignore('.claude/skills/.devlyn-install.json')) written.push('.gitignore');
858
871
 
859
872
  // One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
860
873
  let settingsChanged = false;
@@ -885,6 +898,7 @@ function installClaudeCore() {
885
898
  if (settingsChanged) {
886
899
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
887
900
  log(' → settings.json', 'dim');
901
+ written.push('.claude/settings.json');
888
902
  }
889
903
  // Their ownership cannot be established, so the other retired settings stay; name the ones present.
890
904
  const retired = [
@@ -894,6 +908,7 @@ function installClaudeCore() {
894
908
  if (retired.length > 0) {
895
909
  log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
896
910
  }
911
+ return written;
897
912
  }
898
913
 
899
914
  // Installs the targets in one scope; returns the skill roots written.
@@ -908,21 +923,62 @@ function install(targets, global, optionalNames = []) {
908
923
  + 'would apply to every project. Run from a project folder, or use --global for skills only.');
909
924
  }
910
925
  const roots = targets.flatMap((target) => skillRoots(target, global));
926
+ const written = [];
911
927
  if (!global) readProjectGitignore();
912
928
  preflightSkills(roots, optionalNames,
913
929
  !global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
914
930
  // AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
915
931
  for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
916
932
  if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
917
- else if (target === 'agents') installAgentsProject(targets.includes('claude'));
918
- else installClaudeCore();
933
+ else if (target === 'agents') written.push(...installAgentsProject(targets.includes('claude')));
934
+ else written.push(...installClaudeCore());
919
935
  }
936
+ for (const name of optionalNames) installLocalSkill(name, roots, written);
920
937
  log(`\n✅ devlyn ${PKG.version} installed`, 'green');
921
938
  if (global) log('The principles block installs per project: run npx devlyn-cli in the project without --global.');
939
+ else noticeProjectChanges(written);
922
940
  noticeGlobalDrift(roots);
923
941
  return roots;
924
942
  }
925
943
 
944
+ // Advisory only: a successful install stays successful when Git is unavailable or fails.
945
+ function noticeProjectChanges(paths) {
946
+ if (!paths.length) return;
947
+ const cwd = projectDir();
948
+ paths = [...new Set(paths.map((name) => path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/')))];
949
+ const env = { ...gitEnvironment(), GIT_OPTIONAL_LOCKS: '0' };
950
+ const git = (args) => spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env });
951
+ const repo = git(['rev-parse', '--show-toplevel']);
952
+ if (repo.error || repo.status !== 0) return;
953
+ const status = git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames',
954
+ '--', ...paths.map((name) => `:(icase,literal)${name}`)]);
955
+ if (status.error || status.status !== 0) return;
956
+ // Porcelain paths are repository-relative, even when installing in a nested project.
957
+ const changed = status.stdout.split('\0').filter(Boolean).map((entry) =>
958
+ path.relative(cwd, path.resolve(repo.stdout.replace(/\r?\n$/, ''), entry.slice(3))).split(path.sep).join('/'));
959
+ // Keep the installed folder/file scope, using Git's spelling on case-insensitive volumes.
960
+ const pending = [...new Set(paths.flatMap((name) => [...new Set(changed
961
+ .map((file) => file.split('/').slice(0, name.split('/').length).join('/')))]
962
+ .filter((file) => {
963
+ if (file === name) return true;
964
+ if (file.toLowerCase() !== name.toLowerCase()) return false;
965
+ // Case folding alone can include independently edited files on case-sensitive volumes.
966
+ // Removed paths have no identity left to compare, so require their exact spelling.
967
+ try {
968
+ const written = fs.lstatSync(path.resolve(cwd, name), { bigint: true, throwIfNoEntry: false });
969
+ const reported = fs.lstatSync(path.resolve(cwd, file), { bigint: true, throwIfNoEntry: false });
970
+ return written && reported && written.dev === reported.dev && written.ino === reported.ino;
971
+ } catch (error) {
972
+ log(`Cannot compare Git path ${file} with installed path ${name}: ${error.message}`, 'yellow');
973
+ return false;
974
+ }
975
+ })))];
976
+ if (!pending.length) return;
977
+ const quote = (name) => /^[a-zA-Z0-9_./-]+$/.test(name) ? name : `'${name.replace(/'/g, "'\\''")}'`;
978
+ log('Commit these changes for teammates and /devlyn-ideate drains to use them:', 'yellow');
979
+ log(` git add -- ${pending.map(quote).join(' ')} && git commit -m "chore(devlyn): update devlyn-cli to ${PKG.version}"`, 'dim');
980
+ }
981
+
926
982
  // A devlyn install in a user root this run does not refresh drifts and can shadow the
927
983
  // project's copy; name it without touching it.
928
984
  function noticeGlobalDrift(installed) {
@@ -982,11 +1038,11 @@ async function init({ yes, claude, global }) {
982
1038
  const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
983
1039
  // Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
984
1040
  const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
985
- const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
1041
+ install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
986
1042
 
987
1043
  if (selectedAddons.length > 0) {
988
- for (const addon of selectedAddons) {
989
- installAddon(addon, roots);
1044
+ for (const addon of selectedAddons.filter((addon) => addon.type !== 'local')) {
1045
+ installAddon(addon);
990
1046
  }
991
1047
  } else {
992
1048
  log('💡 No optional addons selected', 'dim');
@@ -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 = { ...process.env, LC_ALL: 'C' };
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.7",
3
+ "version": "4.2.8",
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": {