devlyn-cli 4.1.0 → 4.2.1
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/AGENTS.md +10 -94
- package/CLAUDE.md +7 -160
- package/LICENSE +21 -0
- package/README.md +68 -111
- package/bin/devlyn.js +265 -259
- package/bin/instruction-templates.json +43 -2
- package/bin/instructions.js +29 -8
- package/bin/skill-history.json +140 -0
- package/bin/skill-ownership.js +67 -0
- package/config/skills/_shared/adapters/claude.md +8 -85
- package/config/skills/_shared/adapters/codex.md +14 -34
- package/config/skills/_shared/adapters/omp.md +1 -11
- package/config/skills/_shared/codex-monitored.sh +4 -54
- package/config/skills/_shared/engine-doctor.sh +18 -58
- package/config/skills/_shared/engine-preflight.md +15 -47
- package/config/skills/_shared/expected-contract.py +242 -0
- package/config/skills/_shared/expected.schema.json +10 -95
- package/config/skills/_shared/invocation-receipt.py +26 -757
- package/config/skills/_shared/platform-support.py +37 -28
- package/config/skills/_shared/role-config.py +100 -421
- package/config/skills/_shared/runtime-principles.md +17 -93
- package/config/skills/_shared/task-complete.py +490 -285
- package/config/skills/_shared/task-completion.md +235 -0
- package/config/skills/devlyn-engines/SKILL.md +9 -11
- package/config/skills/devlyn-ideate/SKILL.md +57 -107
- package/config/skills/devlyn-ideate/references/elicitation.md +18 -99
- package/config/skills/devlyn-ideate/references/loop.md +110 -0
- package/config/skills/devlyn-ideate/references/package-format.md +128 -0
- package/config/skills/devlyn-ideate/scripts/acceptance.py +795 -0
- package/config/skills/devlyn-ideate/scripts/queue.py +1780 -0
- package/node_modules/@inquirer/ansi/LICENSE +22 -0
- package/node_modules/@inquirer/ansi/README.md +121 -0
- package/node_modules/@inquirer/ansi/dist/index.d.ts +14 -0
- package/node_modules/@inquirer/ansi/dist/index.js +21 -0
- package/node_modules/@inquirer/ansi/package.json +79 -0
- package/node_modules/@inquirer/checkbox/LICENSE +22 -0
- package/node_modules/@inquirer/checkbox/README.md +265 -0
- package/node_modules/@inquirer/checkbox/dist/index.d.ts +67 -0
- package/node_modules/@inquirer/checkbox/dist/index.js +210 -0
- package/node_modules/@inquirer/checkbox/package.json +93 -0
- package/node_modules/@inquirer/core/LICENSE +22 -0
- package/node_modules/@inquirer/core/README.md +424 -0
- package/node_modules/@inquirer/core/dist/index.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/index.js +12 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.d.ts +10 -0
- package/node_modules/@inquirer/core/dist/lib/Separator.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p1.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p2.js +3 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.d.ts +9 -0
- package/node_modules/@inquirer/core/dist/lib/__probe/p3.js +5 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.d.ts +11 -0
- package/node_modules/@inquirer/core/dist/lib/create-prompt.js +185 -0
- package/node_modules/@inquirer/core/dist/lib/errors.d.ts +20 -0
- package/node_modules/@inquirer/core/dist/lib/errors.js +21 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.d.ts +17 -0
- package/node_modules/@inquirer/core/dist/lib/hook-engine.js +109 -0
- package/node_modules/@inquirer/core/dist/lib/key.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/key.js +34 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/make-theme.js +33 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.d.ts +15 -0
- package/node_modules/@inquirer/core/dist/lib/pagination/use-pagination.js +121 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.d.ts +14 -0
- package/node_modules/@inquirer/core/dist/lib/screen-manager.js +84 -0
- package/node_modules/@inquirer/core/dist/lib/theme.d.ts +166 -0
- package/node_modules/@inquirer/core/dist/lib/theme.js +29 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.d.ts +2 -0
- package/node_modules/@inquirer/core/dist/lib/use-effect.js +11 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.d.ts +3 -0
- package/node_modules/@inquirer/core/dist/lib/use-keypress.js +20 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.d.ts +1 -0
- package/node_modules/@inquirer/core/dist/lib/use-memo.js +14 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.d.ts +5 -0
- package/node_modules/@inquirer/core/dist/lib/use-prefix.js +35 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.d.ts +6 -0
- package/node_modules/@inquirer/core/dist/lib/use-ref.js +4 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.d.ts +7 -0
- package/node_modules/@inquirer/core/dist/lib/use-state.js +28 -0
- package/node_modules/@inquirer/core/dist/lib/utils.d.ts +13 -0
- package/node_modules/@inquirer/core/dist/lib/utils.js +25 -0
- package/node_modules/@inquirer/core/package.json +98 -0
- package/node_modules/@inquirer/figures/LICENSE +22 -0
- package/node_modules/@inquirer/figures/dist/index.d.ts +275 -0
- package/node_modules/@inquirer/figures/dist/index.js +315 -0
- package/node_modules/@inquirer/figures/package.json +81 -0
- package/node_modules/@inquirer/select/LICENSE +22 -0
- package/node_modules/@inquirer/select/README.md +258 -0
- package/node_modules/@inquirer/select/dist/index.d.ts +42 -0
- package/node_modules/@inquirer/select/dist/index.js +191 -0
- package/node_modules/@inquirer/select/package.json +93 -0
- package/node_modules/@inquirer/type/LICENSE +22 -0
- package/node_modules/@inquirer/type/dist/index.d.ts +2 -0
- package/node_modules/@inquirer/type/dist/index.js +2 -0
- package/node_modules/@inquirer/type/dist/inquirer.d.ts +35 -0
- package/node_modules/@inquirer/type/dist/inquirer.js +1 -0
- package/node_modules/@inquirer/type/dist/utils.d.ts +45 -0
- package/node_modules/@inquirer/type/dist/utils.js +16 -0
- package/node_modules/@inquirer/type/package.json +88 -0
- package/node_modules/cli-width/LICENSE +13 -0
- package/node_modules/cli-width/README.md +71 -0
- package/node_modules/cli-width/index.d.ts +13 -0
- package/node_modules/cli-width/index.js +49 -0
- package/node_modules/cli-width/package.json +40 -0
- package/node_modules/fast-string-truncated-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/index.js +111 -0
- package/node_modules/fast-string-truncated-width/dist/types.d.ts +19 -0
- package/node_modules/fast-string-truncated-width/dist/types.js +2 -0
- package/node_modules/fast-string-truncated-width/dist/utils.d.ts +4 -0
- package/node_modules/fast-string-truncated-width/dist/utils.js +20 -0
- package/node_modules/fast-string-truncated-width/license +21 -0
- package/node_modules/fast-string-truncated-width/package.json +35 -0
- package/node_modules/fast-string-truncated-width/readme.md +59 -0
- package/node_modules/fast-string-width/dist/index.d.ts +4 -0
- package/node_modules/fast-string-width/dist/index.js +14 -0
- package/node_modules/fast-string-width/license +21 -0
- package/node_modules/fast-string-width/package.json +34 -0
- package/node_modules/fast-string-width/readme.md +42 -0
- package/node_modules/fast-wrap-ansi/LICENSE +23 -0
- package/node_modules/fast-wrap-ansi/README.md +26 -0
- package/node_modules/fast-wrap-ansi/lib/main.d.ts +6 -0
- package/node_modules/fast-wrap-ansi/lib/main.js +218 -0
- package/node_modules/fast-wrap-ansi/package.json +51 -0
- package/node_modules/mute-stream/LICENSE +15 -0
- package/node_modules/mute-stream/README.md +68 -0
- package/node_modules/mute-stream/lib/index.js +142 -0
- package/node_modules/mute-stream/package.json +54 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +74 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/browser.js +10 -0
- package/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/index.js +279 -0
- package/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/cjs/package.json +3 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/cjs/signals.js +42 -0
- package/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
- package/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/browser.js +4 -0
- package/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
- package/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/index.js +275 -0
- package/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
- package/node_modules/signal-exit/dist/mjs/package.json +3 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
- package/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
- package/node_modules/signal-exit/dist/mjs/signals.js +39 -0
- package/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
- package/node_modules/signal-exit/package.json +106 -0
- package/package.json +17 -4
- package/config/commit-conventions.md +0 -28
- package/config/skills/_shared/adapters/README.md +0 -74
- package/config/skills/_shared/adapters/grok.md +0 -100
- package/config/skills/_shared/archive_run.py +0 -969
- package/config/skills/_shared/codex-config.md +0 -81
- package/config/skills/_shared/collect-codex-findings.py +0 -505
- package/config/skills/_shared/finish-gate.py +0 -521
- package/config/skills/_shared/grok-anchor-guard.py +0 -78
- package/config/skills/_shared/judge-output-parser.py +0 -200
- package/config/skills/_shared/judge-role-evidence.py +0 -320
- package/config/skills/_shared/phase-prompt-render.py +0 -183
- package/config/skills/_shared/process-evidence.py +0 -926
- package/config/skills/_shared/resolve-bootstrap.py +0 -1410
- package/config/skills/_shared/resolve-stop-hook.py +0 -359
- package/config/skills/_shared/run-bounded.py +0 -58
- package/config/skills/_shared/spec-verify-check.py +0 -5083
- package/config/skills/_shared/state-phase-write.py +0 -5989
- package/config/skills/_shared/terminal-claim-check.py +0 -777
- package/config/skills/_shared/verify-merge-findings.py +0 -3528
- package/config/skills/devlyn-design-ui/SKILL.md +0 -650
- package/config/skills/devlyn-ideate/references/from-spec-mode.md +0 -61
- package/config/skills/devlyn-ideate/references/project-mode.md +0 -80
- package/config/skills/devlyn-ideate/references/spec-template.md +0 -121
- package/config/skills/devlyn-ideate/references/templates/decision.md +0 -48
- package/config/skills/devlyn-ideate/references/templates/roadmap.md +0 -50
- package/config/skills/devlyn-ideate/references/templates/vision.md +0 -44
- package/config/skills/devlyn-queue/SKILL.md +0 -66
- package/config/skills/devlyn-queue/scripts/append.py +0 -94
- package/config/skills/devlyn-resolve/SKILL.md +0 -379
- package/config/skills/devlyn-resolve/references/free-form-mode.md +0 -71
- package/config/skills/devlyn-resolve/references/outer-loop.md +0 -23
- package/config/skills/devlyn-resolve/references/phases/build-gate.md +0 -75
- package/config/skills/devlyn-resolve/references/phases/cleanup.md +0 -33
- package/config/skills/devlyn-resolve/references/phases/implement.md +0 -44
- package/config/skills/devlyn-resolve/references/phases/plan.md +0 -39
- package/config/skills/devlyn-resolve/references/phases/probe-derive.md +0 -261
- package/config/skills/devlyn-resolve/references/phases/surface-close.md +0 -34
- package/config/skills/devlyn-resolve/references/phases/verify.md +0 -310
- package/config/skills/devlyn-resolve/references/state-schema.md +0 -152
- package/config/skills/devlyn-resolve/references/task-completion.md +0 -160
- package/config/templates/prompt-templates.md +0 -71
- package/config/templates/template-feature.spec.md +0 -255
- package/config/templates/template-product-spec.md +0 -680
package/bin/devlyn.js
CHANGED
|
@@ -4,34 +4,37 @@ const crypto = require('crypto');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
-
const readline = require('readline');
|
|
8
7
|
const { execSync } = require('child_process');
|
|
9
8
|
|
|
10
9
|
const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
|
|
11
10
|
const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
|
|
12
11
|
const PKG = require('../package.json');
|
|
13
|
-
const { updateInstructions, InstructionError, holdsDevlynDefaults } = require('./instructions');
|
|
12
|
+
const { updateInstructions, InstructionError, holdsDevlynDefaults, importsAgentsMd } = require('./instructions');
|
|
13
|
+
const { contentHash, fingerprint, isOsMetadata, trackedPaths } = require('./skill-ownership');
|
|
14
|
+
const SKILL_HISTORY = require('./skill-history.json');
|
|
14
15
|
|
|
15
16
|
// The devlyn skill bundle installed into every skill-capable agent's loader
|
|
16
17
|
// directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
|
|
17
18
|
// skill here installs it everywhere.
|
|
18
|
-
const DEVLYN_CORE_SKILLS = ['devlyn-
|
|
19
|
+
const DEVLYN_CORE_SKILLS = ['devlyn-ideate', 'devlyn-engines', '_shared'];
|
|
19
20
|
// 4.0.0 renamed the colon-named skills to the Agent Skills standard (`[a-z0-9-]`, name ==
|
|
20
21
|
// folder): Git for Windows cannot check out a folder with ':' in its name. Installs from 3.x
|
|
21
22
|
// and earlier keep the old folder under either spelling (':' or npm's U+F03A extraction
|
|
22
23
|
// alias). Both are removed, and an optional skill that was installed is installed again
|
|
23
24
|
// under its new name in the same place.
|
|
24
25
|
const RENAMED_SKILLS = {
|
|
25
|
-
'devlyn:resolve': 'devlyn-resolve',
|
|
26
26
|
'devlyn:ideate': 'devlyn-ideate',
|
|
27
|
-
'devlyn:design-ui': 'devlyn-design-ui',
|
|
28
27
|
'devlyn:engines': 'devlyn-engines',
|
|
29
|
-
'devlyn:queue': 'devlyn-queue',
|
|
30
28
|
'devlyn:pencil-pull': 'devlyn-pencil-pull',
|
|
31
29
|
'devlyn:pencil-push': 'devlyn-pencil-push',
|
|
32
30
|
'devlyn:reap': 'devlyn-reap',
|
|
33
31
|
};
|
|
34
32
|
const DEVLYN_INSTALL_MARKER = '.devlyn-install.json';
|
|
33
|
+
// Project settings earlier releases added for the retired pipeline.
|
|
34
|
+
const RETIRED_STOP_HOOK = 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/_shared/resolve-stop-hook.py"';
|
|
35
|
+
const RETIRED_ENV = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'];
|
|
36
|
+
const RETIRED_PERMISSIONS = ['Write(.devlyn/**)', 'Edit(.devlyn/**)', 'Bash(git add *)', 'Bash(git commit *)',
|
|
37
|
+
'Bash(git diff *)', 'Bash(git status *)', 'Bash(git log *)'];
|
|
35
38
|
|
|
36
39
|
// Every spelling a colon name can have on disk: its own and npm's U+F03A extraction alias.
|
|
37
40
|
function legacySkillPaths(root, name) {
|
|
@@ -80,13 +83,13 @@ const RETIRED_SKILL_MD_SHA256 = {
|
|
|
80
83
|
]),
|
|
81
84
|
};
|
|
82
85
|
|
|
83
|
-
// Whether `dir` is a real folder whose only file
|
|
86
|
+
// Whether `dir` is a real folder whose only file, besides OS metadata, is a SKILL.md
|
|
84
87
|
// with an LF-normalized SHA-256 in `hashes`: an unedited copy devlyn-cli shipped, not something
|
|
85
88
|
// the user added to or wrote.
|
|
86
89
|
function isShippedCopy(dir, hashes) {
|
|
87
90
|
const skill = path.join(dir, 'SKILL.md');
|
|
88
91
|
return fs.lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() === true
|
|
89
|
-
&& fs.readdirSync(dir).filter((name) => !
|
|
92
|
+
&& fs.readdirSync(dir).filter((name) => !isOsMetadata(path.join(dir, name))).length === 1
|
|
90
93
|
&& fs.lstatSync(skill, { throwIfNoEntry: false })?.isFile() === true
|
|
91
94
|
&& hashes.has(crypto.createHash('sha256')
|
|
92
95
|
.update(fs.readFileSync(skill, 'utf8').replace(/\r\n/g, '\n')).digest('hex'));
|
|
@@ -102,24 +105,25 @@ function skillRoots(target, global) {
|
|
|
102
105
|
|
|
103
106
|
// A devlyn Claude install in this scope. Globally only the marker a `--global --claude` run
|
|
104
107
|
// writes counts; in a project also a devlyn skill (0.6.0 and later), command (0.2-0.5) or
|
|
105
|
-
// CLAUDE.md with devlyn defaults, which a team may commit
|
|
106
|
-
// devlyn skill is not one: 4.0.1 put those into
|
|
107
|
-
// CLAUDE.md link (often to AGENTS.md) is not the
|
|
108
|
-
// refuses links.
|
|
108
|
+
// CLAUDE.md with devlyn defaults, held or imported from AGENTS.md, which a team may commit
|
|
109
|
+
// while ignoring .claude/. An optional devlyn skill is not one: 4.0.1 put those into
|
|
110
|
+
// .claude/skills without the Claude target. A CLAUDE.md link (often to AGENTS.md) is not the
|
|
111
|
+
// Claude target's file: updateInstructions refuses links.
|
|
109
112
|
function hasDevlynClaude(global) {
|
|
110
113
|
const claudeDir = path.dirname(skillRoots('claude', global)[0]);
|
|
111
114
|
if (global) return fs.existsSync(path.join(claudeDir, 'skills', DEVLYN_INSTALL_MARKER));
|
|
112
115
|
const names = (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir) : []);
|
|
113
116
|
const optional = new Set(OPTIONAL_ADDONS.map((addon) => addon.name));
|
|
114
117
|
const instructions = path.join(path.dirname(claudeDir), 'CLAUDE.md');
|
|
118
|
+
const claude = fs.lstatSync(instructions, { throwIfNoEntry: false })?.isFile() ? fs.readFileSync(instructions, 'utf8') : '';
|
|
115
119
|
return names(path.join(claudeDir, 'skills')).some((name) => name === DEVLYN_INSTALL_MARKER
|
|
116
120
|
|| (name.startsWith('devlyn') && !optional.has(name.replace(/[:\uF03A]/g, '-'))))
|
|
117
121
|
|| names(path.join(claudeDir, 'commands')).some((name) => name.startsWith('devlyn.'))
|
|
118
|
-
|| (
|
|
119
|
-
&& holdsDevlynDefaults('CLAUDE.md', fs.readFileSync(instructions, 'utf8')));
|
|
122
|
+
|| holdsDevlynDefaults('CLAUDE.md', claude) || importsAgentsDefaults(claude);
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
// Commands removed in previous versions; the project Claude install deletes them from .claude
|
|
125
|
+
// Commands removed in previous versions; the project Claude install deletes them from .claude/
|
|
126
|
+
// when they hold the bytes a version shipped (SKILL_HISTORY.commands).
|
|
123
127
|
const DEPRECATED_FILES = [
|
|
124
128
|
'commands/devlyn.handoff.md', // removed in v0.2.0
|
|
125
129
|
'commands/devlyn.clean.md', // migrated to skills in v0.6.0
|
|
@@ -144,7 +148,7 @@ const DEPRECATED_FILES = [
|
|
|
144
148
|
// Skill directories renamed from devlyn-* to devlyn:* in v0.7.x, plus
|
|
145
149
|
// iter-0034 Phase 4 cutover (2026-05-03): 15 user skills deleted and 3 moved
|
|
146
150
|
// to optional-skills/. Listed here so post-cutover `npx devlyn-cli` upgrades
|
|
147
|
-
//
|
|
151
|
+
// remove verified, unchanged legacy skill dirs from downstream `~/.claude/skills/`
|
|
148
152
|
// even though the source dirs no longer exist (the installer replaces only
|
|
149
153
|
// the skills it ships — without this list, deleted-from-source skills persist
|
|
150
154
|
// in user installs forever).
|
|
@@ -183,6 +187,15 @@ const DEPRECATED_DIRS = [
|
|
|
183
187
|
// so users who previously opted in get their stale copies purged on upgrade.
|
|
184
188
|
'skills/devlyn:team-design-ui',
|
|
185
189
|
'skills/devlyn:design-system',
|
|
190
|
+
// Retired after 4.1.0 (design-ui deleted; the intent queue moved into
|
|
191
|
+
// devlyn-ideate; resolve retired). All were core skills, so their 3.x and
|
|
192
|
+
// 4.x folders go.
|
|
193
|
+
'skills/devlyn-design-ui',
|
|
194
|
+
'skills/devlyn:design-ui',
|
|
195
|
+
'skills/devlyn-queue',
|
|
196
|
+
'skills/devlyn:queue',
|
|
197
|
+
'skills/devlyn-resolve',
|
|
198
|
+
'skills/devlyn:resolve',
|
|
186
199
|
];
|
|
187
200
|
|
|
188
201
|
function projectDir() {
|
|
@@ -257,10 +270,7 @@ const OPTIONAL_ADDONS = [
|
|
|
257
270
|
{ name: 'anthropics/skills', desc: 'Official Anthropic skill-creator with eval framework and description optimizer', type: 'external' },
|
|
258
271
|
{ name: 'Leonxlnx/taste-skill', desc: 'Premium frontend design skills — modern layouts, animations, and visual refinement', type: 'external' },
|
|
259
272
|
// MCP servers (installed via claude mcp add)
|
|
260
|
-
|
|
261
|
-
// Install the CLI separately per https://platform.openai.com/docs/codex — the
|
|
262
|
-
// pair/risk-probe routes fail closed when Codex is required but unavailable.
|
|
263
|
-
{ name: 'playwright', desc: 'Playwright MCP for browser testing — powers /devlyn-resolve BUILD_GATE browser tier', type: 'mcp', command: 'npx -y @playwright/mcp@latest' },
|
|
273
|
+
{ name: 'playwright', desc: 'Playwright MCP for browser testing', type: 'mcp', command: 'npx -y @playwright/mcp@latest' },
|
|
264
274
|
];
|
|
265
275
|
|
|
266
276
|
function log(msg, color = 'reset') {
|
|
@@ -306,23 +316,8 @@ function listContents() {
|
|
|
306
316
|
showLogo();
|
|
307
317
|
log('─'.repeat(44), 'dim');
|
|
308
318
|
|
|
309
|
-
const templatesDir = path.join(CONFIG_SOURCE, 'templates');
|
|
310
319
|
const skillsDir = path.join(CONFIG_SOURCE, 'skills');
|
|
311
320
|
|
|
312
|
-
// List templates
|
|
313
|
-
if (fs.existsSync(templatesDir)) {
|
|
314
|
-
const templates = fs.readdirSync(templatesDir).filter((f) => f.endsWith('.md'));
|
|
315
|
-
if (templates.length > 0) {
|
|
316
|
-
log('\n📄 Templates:', 'blue');
|
|
317
|
-
templates.forEach((file) => {
|
|
318
|
-
const name = file.replace('.md', '');
|
|
319
|
-
const desc = getDescription(path.join(templatesDir, file));
|
|
320
|
-
log(` ${COLORS.green}${name}${COLORS.reset}`);
|
|
321
|
-
if (desc) log(` ${COLORS.dim}${desc}${COLORS.reset}`);
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
321
|
// List skills
|
|
327
322
|
if (fs.existsSync(skillsDir)) {
|
|
328
323
|
const skills = fs.readdirSync(skillsDir).filter((d) => {
|
|
@@ -381,15 +376,22 @@ function cleanupDeprecated(skillsDir) {
|
|
|
381
376
|
// copy is refreshed in place. Any other folder under a 4.0 name is the user's own; `-y` leaves it.
|
|
382
377
|
function retireRenamedSkills(skillsDir) {
|
|
383
378
|
const optional = new Set(OPTIONAL_ADDONS.filter((addon) => addon.type === 'local').map((addon) => addon.name));
|
|
379
|
+
const refreshed = [];
|
|
384
380
|
for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
|
|
385
381
|
const found = legacySkillPaths(skillsDir, oldName).filter((fullPath) => fs.existsSync(fullPath));
|
|
386
|
-
if (found.length > 0 && optional.has(newName))
|
|
387
|
-
|
|
382
|
+
if (found.length > 0 && optional.has(newName)) {
|
|
383
|
+
installOptionalSkillInto(skillsDir, newName);
|
|
384
|
+
refreshed.push(newName);
|
|
385
|
+
} else if (isShippedCopy(path.join(skillsDir, newName), PRE_STANDARD_SKILL_MD_SHA256)) {
|
|
386
|
+
refreshPreStandardCopy(skillsDir, newName);
|
|
387
|
+
refreshed.push(newName);
|
|
388
|
+
}
|
|
388
389
|
for (const fullPath of found) {
|
|
389
390
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
390
391
|
log(` ✕ ${path.basename(fullPath)}/ (renamed to ${newName})`, 'dim');
|
|
391
392
|
}
|
|
392
393
|
}
|
|
394
|
+
return refreshed;
|
|
393
395
|
}
|
|
394
396
|
|
|
395
397
|
function copyRecursive(src, dest, baseDir) {
|
|
@@ -417,6 +419,101 @@ function clearInstallMarker(skillsDir) {
|
|
|
417
419
|
fs.rmSync(path.join(skillsDir, DEVLYN_INSTALL_MARKER), { force: true });
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
function readInstallManifest(root) {
|
|
423
|
+
const markerPath = path.join(root, DEVLYN_INSTALL_MARKER);
|
|
424
|
+
const stat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
|
|
425
|
+
if (!stat) return {};
|
|
426
|
+
if (!stat.isFile()) throw new Error(`not a regular install marker: ${markerPath}`);
|
|
427
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
428
|
+
return marker?.package === PKG.name && marker.schemaVersion === 1 ? marker.skills || {} : {};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Enumerate every directory this install may replace, migrate or remove, before
|
|
432
|
+
// touching any target. Optional addons must be selected before this preflight too.
|
|
433
|
+
function plannedSkills(root, optionalNames = []) {
|
|
434
|
+
const names = new Set([...DEVLYN_CORE_SKILLS, ...optionalNames]);
|
|
435
|
+
for (const relative of DEPRECATED_DIRS) {
|
|
436
|
+
for (const full of legacySkillPaths(root, path.basename(relative))) names.add(path.basename(full));
|
|
437
|
+
}
|
|
438
|
+
for (const [oldName, newName] of Object.entries(RENAMED_SKILLS)) {
|
|
439
|
+
const oldPaths = legacySkillPaths(root, oldName);
|
|
440
|
+
oldPaths.forEach((full) => names.add(path.basename(full)));
|
|
441
|
+
if (oldPaths.some((full) => fs.lstatSync(full, { throwIfNoEntry: false }))
|
|
442
|
+
|| isShippedCopy(path.join(root, newName), PRE_STANDARD_SKILL_MD_SHA256)) names.add(newName);
|
|
443
|
+
}
|
|
444
|
+
for (const [name, hashes] of Object.entries(RETIRED_SKILL_MD_SHA256)) {
|
|
445
|
+
if (isShippedCopy(path.join(root, name), hashes)) names.add(name);
|
|
446
|
+
}
|
|
447
|
+
return names;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// `commandDir`: the project .claude/ whose DEPRECATED_FILES this install deletes, or null.
|
|
451
|
+
function preflightSkills(roots, optionalNames = [], commandDir = null) {
|
|
452
|
+
const conflicts = [];
|
|
453
|
+
try {
|
|
454
|
+
// Do not follow a commands link into another installation either.
|
|
455
|
+
const commands = commandDir && fs.lstatSync(path.join(commandDir, 'commands'), { throwIfNoEntry: false });
|
|
456
|
+
if (commands && !commands.isDirectory()) throw new Error('not a real directory');
|
|
457
|
+
const tracked = commandDir ? trackedPaths(path.join(commandDir, 'commands')) : [];
|
|
458
|
+
for (const relPath of commandDir ? DEPRECATED_FILES : []) {
|
|
459
|
+
const fullPath = path.join(commandDir, relPath);
|
|
460
|
+
const stat = fs.lstatSync(fullPath, { throwIfNoEntry: false });
|
|
461
|
+
if (!stat || (stat.isFile() && SKILL_HISTORY.commands[relPath].includes(contentHash(fullPath)))) continue;
|
|
462
|
+
const isTracked = tracked.some((file) => file.toLowerCase() === fullPath.toLowerCase());
|
|
463
|
+
conflicts.push(`${fullPath}: ${isTracked ? 'git-tracked; ' : ''}`
|
|
464
|
+
+ (stat.isFile() ? 'fingerprint mismatch with known shipped copies' : 'link or special file'));
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
conflicts.push(`${path.join(commandDir, 'commands')}: ${error.message}`);
|
|
468
|
+
}
|
|
469
|
+
for (const root of roots) {
|
|
470
|
+
try {
|
|
471
|
+
// Do not follow an agent-directory or skill-root link into another installation.
|
|
472
|
+
for (const dir of [path.dirname(root), root]) {
|
|
473
|
+
const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
474
|
+
if (stat && !stat.isDirectory()) throw new Error(`not a real directory: ${dir}`);
|
|
475
|
+
}
|
|
476
|
+
const tracked = trackedPaths(root);
|
|
477
|
+
const manifest = readInstallManifest(root);
|
|
478
|
+
for (const name of plannedSkills(root, optionalNames)) {
|
|
479
|
+
const dest = path.join(root, name);
|
|
480
|
+
const isTracked = tracked.some((file) => file.toLowerCase() === dest.toLowerCase()
|
|
481
|
+
|| file.toLowerCase().startsWith(dest.toLowerCase() + path.sep));
|
|
482
|
+
try {
|
|
483
|
+
if (!fs.lstatSync(dest, { throwIfNoEntry: false })) {
|
|
484
|
+
if (isTracked) throw new Error('missing contents; cannot verify installer ownership');
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const actual = fingerprint(dest);
|
|
488
|
+
const logicalName = name.replace(/[:\uF03A]/g, '-');
|
|
489
|
+
const source = path.join(DEVLYN_CORE_SKILLS.includes(logicalName)
|
|
490
|
+
? path.join(CONFIG_SOURCE, 'skills') : OPTIONAL_SKILLS_SOURCE, logicalName);
|
|
491
|
+
const current = fs.existsSync(source) && fingerprint(source) === actual;
|
|
492
|
+
const historical = SKILL_HISTORY.skills[logicalName]?.includes(actual);
|
|
493
|
+
const singleFile = isShippedCopy(dest, RETIRED_SKILL_MD_SHA256[name] || PRE_STANDARD_SKILL_MD_SHA256);
|
|
494
|
+
if (!current && !historical && !singleFile && manifest[name] !== actual) {
|
|
495
|
+
throw new Error(manifest[name] ? 'modified contents (install manifest fingerprint mismatch)'
|
|
496
|
+
: fs.existsSync(source) || SKILL_HISTORY.skills[logicalName]?.length
|
|
497
|
+
? 'fingerprint mismatch with known shipped copies'
|
|
498
|
+
: 'unknown installer ownership (no fingerprint history for this copy)');
|
|
499
|
+
}
|
|
500
|
+
} catch (error) {
|
|
501
|
+
conflicts.push(`${dest}: ${isTracked ? 'git-tracked; ' : ''}${error.message}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch (error) {
|
|
505
|
+
conflicts.push(`${root}: ${error.message}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (conflicts.length) {
|
|
509
|
+
throw new InstructionError('Installation refused; nothing was changed:\n'
|
|
510
|
+
+ conflicts.map((conflict) => ` - ${conflict}`).join('\n')
|
|
511
|
+
+ '\nMove these out of the install path, or back them up and remove them, then rerun.'
|
|
512
|
+
+ '\nFor tracked paths, also remove them from the Git index.'
|
|
513
|
+
+ '\nRerun the same command after resolving every conflict; -y does not bypass this check.');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
420
517
|
function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
|
|
421
518
|
function complete(src, dest) {
|
|
422
519
|
if (!fs.existsSync(src) || !fs.existsSync(dest)) return false;
|
|
@@ -436,13 +533,14 @@ function assertCompleteSkillInstall(sourceSkillsDir, skillsDir, skillNames) {
|
|
|
436
533
|
}
|
|
437
534
|
}
|
|
438
535
|
|
|
439
|
-
function writeInstallMarker(skillsDir) {
|
|
536
|
+
function writeInstallMarker(skillsDir, names, previous) {
|
|
440
537
|
const markerPath = path.join(skillsDir, DEVLYN_INSTALL_MARKER);
|
|
441
538
|
const tempPath = `${markerPath}.${process.pid}.tmp`;
|
|
442
539
|
const marker = {
|
|
443
540
|
schemaVersion: 1,
|
|
444
541
|
package: PKG.name,
|
|
445
542
|
version: PKG.version,
|
|
543
|
+
skills: { ...previous, ...Object.fromEntries(names.map((name) => [name, fingerprint(path.join(skillsDir, name))])) },
|
|
446
544
|
};
|
|
447
545
|
try {
|
|
448
546
|
fs.writeFileSync(tempPath, JSON.stringify(marker, null, 2) + '\n', {
|
|
@@ -456,141 +554,65 @@ function writeInstallMarker(skillsDir) {
|
|
|
456
554
|
}
|
|
457
555
|
}
|
|
458
556
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (key === '\u0003') {
|
|
496
|
-
process.stdin.setRawMode(false);
|
|
497
|
-
process.stdin.removeListener('data', onKeypress);
|
|
498
|
-
process.exit();
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Enter
|
|
502
|
-
if (key === '\r' || key === '\n') {
|
|
503
|
-
process.stdin.setRawMode(false);
|
|
504
|
-
process.stdin.removeListener('data', onKeypress);
|
|
505
|
-
process.stdin.pause();
|
|
506
|
-
console.log('');
|
|
507
|
-
resolve([...selected].map((i) => items[i]));
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Space - toggle selection
|
|
512
|
-
if (key === ' ') {
|
|
513
|
-
if (selected.has(cursor)) {
|
|
514
|
-
selected.delete(cursor);
|
|
515
|
-
} else {
|
|
516
|
-
selected.add(cursor);
|
|
517
|
-
}
|
|
518
|
-
render();
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Arrow up or k
|
|
523
|
-
if (key === '\x1b[A' || key === 'k') {
|
|
524
|
-
cursor = cursor > 0 ? cursor - 1 : items.length - 1;
|
|
525
|
-
render();
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Arrow down or j
|
|
530
|
-
if (key === '\x1b[B' || key === 'j') {
|
|
531
|
-
cursor = cursor < items.length - 1 ? cursor + 1 : 0;
|
|
532
|
-
render();
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// 'a' - select all
|
|
537
|
-
if (key === 'a') {
|
|
538
|
-
if (selected.size === items.length) {
|
|
539
|
-
selected.clear();
|
|
540
|
-
} else {
|
|
541
|
-
items.forEach((_, i) => selected.add(i));
|
|
542
|
-
}
|
|
543
|
-
render();
|
|
544
|
-
return;
|
|
545
|
-
}
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
process.stdin.on('data', onKeypress);
|
|
557
|
+
// One Inquirer menu, imported only when shown (Inquirer is ESM). The page gets the terminal
|
|
558
|
+
// rows left after everything else the menu draws, so a redraw never scrolls it into
|
|
559
|
+
// duplicates. Ctrl+C cancels the install.
|
|
560
|
+
async function ask(module, help, config) {
|
|
561
|
+
const { default: prompt } = await import(module);
|
|
562
|
+
const { stdin: input, stdout: output } = process;
|
|
563
|
+
const columns = output.columns || 80;
|
|
564
|
+
// Most rows a text wraps to: an ASCII character takes one cell, any other at most two.
|
|
565
|
+
const rows = (text = '') => Math.ceil([...text].reduce((cells, c) => cells + (c.codePointAt(0) < 0x80 ? 1 : 2), 0) / columns);
|
|
566
|
+
// Message, blank line, the active choice's description, key help, and the line Inquirer
|
|
567
|
+
// adds when the help exactly fills the width.
|
|
568
|
+
const reserved = rows(`? ${config.message}`) + 1 + Math.max(...config.choices.map((choice) => rows(choice.description))) + rows(help) + 1;
|
|
569
|
+
try {
|
|
570
|
+
return await prompt({
|
|
571
|
+
...config,
|
|
572
|
+
pageSize: Math.max(1, (output.rows || 24) - reserved),
|
|
573
|
+
theme: { keybindings: ['vim'], style: { keysHelpTip: () => help } },
|
|
574
|
+
}, { input, output });
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (error.name !== 'ExitPromptError') throw error;
|
|
577
|
+
console.log('Installation cancelled.');
|
|
578
|
+
process.exit(0);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Checkbox menu (space toggles, a toggles all); resolves to the chosen items.
|
|
583
|
+
function multiSelect(message, items, preselectedIndices = []) {
|
|
584
|
+
return ask('@inquirer/checkbox', '↑↓ navigate · space select · a all · enter confirm', {
|
|
585
|
+
message,
|
|
586
|
+
choices: items.map((item, index) => ({
|
|
587
|
+
name: item.type ? `${item.name} [${item.type === 'mcp' ? 'mcp' : item.type === 'local' ? 'skill' : 'pack'}]` : item.name,
|
|
588
|
+
short: item.name,
|
|
589
|
+
description: item.desc,
|
|
590
|
+
value: item,
|
|
591
|
+
checked: preselectedIndices.includes(index),
|
|
592
|
+
})),
|
|
549
593
|
});
|
|
550
594
|
}
|
|
551
595
|
|
|
552
|
-
// One of `items
|
|
553
|
-
function singleSelect(items, initial) {
|
|
554
|
-
return
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (drawn) process.stdout.write(`\x1b[${items.length + 2}A\x1b[0J`);
|
|
559
|
-
drawn = true;
|
|
560
|
-
console.log(`${COLORS.dim}(↑↓ navigate, enter confirm)${COLORS.reset}\n`);
|
|
561
|
-
items.forEach((item, i) => console.log(i === cursor ? `${COLORS.cyan}❯ ${item}${COLORS.reset}` : ` ${item}`));
|
|
562
|
-
};
|
|
563
|
-
render();
|
|
564
|
-
process.stdin.setRawMode(true);
|
|
565
|
-
process.stdin.resume();
|
|
566
|
-
process.stdin.setEncoding('utf8');
|
|
567
|
-
const onKeypress = (key) => {
|
|
568
|
-
if (key === '\u0003') {
|
|
569
|
-
process.stdin.setRawMode(false);
|
|
570
|
-
process.exit();
|
|
571
|
-
}
|
|
572
|
-
if (key === '\r' || key === '\n') {
|
|
573
|
-
process.stdin.setRawMode(false);
|
|
574
|
-
process.stdin.removeListener('data', onKeypress);
|
|
575
|
-
process.stdin.pause();
|
|
576
|
-
console.log('');
|
|
577
|
-
resolve(cursor);
|
|
578
|
-
} else if (['\x1b[A', 'k', '\x1b[B', 'j'].includes(key)) {
|
|
579
|
-
cursor = (cursor + (key === '\x1b[A' || key === 'k' ? items.length - 1 : 1)) % items.length;
|
|
580
|
-
render();
|
|
581
|
-
}
|
|
582
|
-
};
|
|
583
|
-
process.stdin.on('data', onKeypress);
|
|
596
|
+
// One of `items`; resolves to the chosen index.
|
|
597
|
+
function singleSelect(message, items, initial) {
|
|
598
|
+
return ask('@inquirer/select', '↑↓ navigate · enter confirm', {
|
|
599
|
+
message,
|
|
600
|
+
choices: items.map((name, index) => ({ name, value: index })),
|
|
601
|
+
default: initial,
|
|
584
602
|
});
|
|
585
603
|
}
|
|
586
604
|
|
|
605
|
+
// After install(), whose preflight already cleared every selected addon before any change.
|
|
587
606
|
function installLocalSkill(skillName, roots) {
|
|
588
607
|
if (!fs.existsSync(path.join(OPTIONAL_SKILLS_SOURCE, skillName))) {
|
|
589
608
|
log(` ⚠️ Skill "${skillName}" not found`, 'yellow');
|
|
590
609
|
return false;
|
|
591
610
|
}
|
|
592
611
|
log(`\n🛠️ Installing ${skillName}...`, 'cyan');
|
|
593
|
-
for (const root of roots)
|
|
612
|
+
for (const root of roots) {
|
|
613
|
+
installOptionalSkillInto(root, skillName);
|
|
614
|
+
writeInstallMarker(root, [skillName], readInstallManifest(root));
|
|
615
|
+
}
|
|
594
616
|
return true;
|
|
595
617
|
}
|
|
596
618
|
|
|
@@ -660,6 +682,7 @@ function installCoreSkills(skillsDir) {
|
|
|
660
682
|
const sourceSkillsDir = path.join(CONFIG_SOURCE, 'skills');
|
|
661
683
|
log(`\n📁 Installing devlyn skills to ${skillsDir.replace(os.homedir(), '~')}`, 'green');
|
|
662
684
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
685
|
+
const previous = readInstallManifest(skillsDir);
|
|
663
686
|
clearInstallMarker(skillsDir);
|
|
664
687
|
const removed = cleanupDeprecated(skillsDir);
|
|
665
688
|
if (removed > 0) {
|
|
@@ -674,8 +697,8 @@ function installCoreSkills(skillsDir) {
|
|
|
674
697
|
copyRecursive(src, dest, skillsDir);
|
|
675
698
|
}
|
|
676
699
|
assertCompleteSkillInstall(sourceSkillsDir, skillsDir, DEVLYN_CORE_SKILLS);
|
|
677
|
-
retireRenamedSkills(skillsDir);
|
|
678
|
-
writeInstallMarker(skillsDir);
|
|
700
|
+
const refreshed = retireRenamedSkills(skillsDir);
|
|
701
|
+
writeInstallMarker(skillsDir, [...DEVLYN_CORE_SKILLS, ...refreshed], previous);
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
// Keep installer-managed pipeline state and install metadata out of git.
|
|
@@ -726,14 +749,56 @@ function installAgentsProject(withClaude) {
|
|
|
726
749
|
ignoreInGit(['.devlyn/', '.agents/skills/.devlyn-install.json']);
|
|
727
750
|
}
|
|
728
751
|
|
|
729
|
-
//
|
|
752
|
+
// Whether Claude Code reads the devlyn defaults of AGENTS.md through an `@AGENTS.md` import in this CLAUDE.md text.
|
|
753
|
+
// An AGENTS.md that is CLAUDE.md under another name holds CLAUDE.md's own defaults.
|
|
754
|
+
function importsAgentsDefaults(claude) {
|
|
755
|
+
const agents = path.join(projectDir(), 'AGENTS.md');
|
|
756
|
+
return importsAgentsMd(claude) && !agentsMdIsClaudeMd() && fs.statSync(agents, { throwIfNoEntry: false })?.isFile() === true
|
|
757
|
+
&& holdsDevlynDefaults('AGENTS.md', fs.readFileSync(agents, 'utf8'));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Claude Code reads AGENTS.md only where no CLAUDE.md exists, or through a CLAUDE.md that imports it. So a new
|
|
761
|
+
// CLAUDE.md imports an existing AGENTS.md, and a CLAUDE.md importing an AGENTS.md that holds devlyn defaults holds no
|
|
762
|
+
// copy of its own, not even one it got while AGENTS.md lacked the block. Only the AGENTS.md target updates those defaults,
|
|
763
|
+
// together with .agents/skills, so the Claude target stops while they need an update.
|
|
764
|
+
function installClaudeInstructions() {
|
|
765
|
+
const file = path.join(projectDir(), 'CLAUDE.md');
|
|
766
|
+
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
767
|
+
const initial = !stat && fs.statSync(path.join(projectDir(), 'AGENTS.md'), { throwIfNoEntry: false })?.isFile() ? '@AGENTS.md\n' : '';
|
|
768
|
+
const imported = importsAgentsDefaults(stat?.isFile() ? fs.readFileSync(file, 'utf8') : initial);
|
|
769
|
+
if (imported && updateInstructions('AGENTS.md', '', true, false)) {
|
|
770
|
+
throw new InstructionError('Claude Code reads AGENTS.md here, and its devlyn defaults need an update. Codex, omp, Pi and Grok '
|
|
771
|
+
+ 'read them too, with .agents/skills, so choose AGENTS.md as well (npx devlyn-cli -y --claude) to update both together.');
|
|
772
|
+
}
|
|
773
|
+
updateInstructions('CLAUDE.md', initial, !imported);
|
|
774
|
+
if (imported) log(' → CLAUDE.md imports AGENTS.md, which holds the devlyn block', 'dim');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Project CLAUDE.md and .claude/: skills and settings.
|
|
730
778
|
function installClaudeCore() {
|
|
731
|
-
updateInstructions('CLAUDE.md');
|
|
732
779
|
const skillsDir = skillRoots('claude', false)[0];
|
|
733
780
|
const targetDir = path.dirname(skillsDir);
|
|
734
|
-
|
|
735
|
-
|
|
781
|
+
// Read before any write, so settings this install cannot merge leave the Claude target as it was.
|
|
782
|
+
const settingsPath = path.join(targetDir, 'settings.json');
|
|
783
|
+
const unmergeable = (reason) => new InstructionError(`Cannot merge .claude/settings.json: ${reason}. `
|
|
784
|
+
+ 'CLAUDE.md and .claude/ are unchanged; fix the file and rerun the same install command.');
|
|
785
|
+
let settings = {};
|
|
786
|
+
if (fs.existsSync(settingsPath)) {
|
|
787
|
+
try {
|
|
788
|
+
// Claude Code accepts a leading byte order mark.
|
|
789
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
790
|
+
} catch (error) {
|
|
791
|
+
throw unmergeable(error.message);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
795
|
+
throw unmergeable('root must be a JSON object');
|
|
796
|
+
}
|
|
797
|
+
if (Object.prototype.hasOwnProperty.call(settings, 'env')
|
|
798
|
+
&& (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env))) {
|
|
799
|
+
throw unmergeable('env must be a JSON object');
|
|
736
800
|
}
|
|
801
|
+
installClaudeInstructions();
|
|
737
802
|
for (const relPath of DEPRECATED_FILES) {
|
|
738
803
|
const fullPath = path.join(targetDir, relPath);
|
|
739
804
|
if (fs.existsSync(fullPath)) {
|
|
@@ -744,108 +809,51 @@ function installClaudeCore() {
|
|
|
744
809
|
installCoreSkills(skillsDir);
|
|
745
810
|
ignoreInGit(['.devlyn/', '.claude/skills/.devlyn-install.json']);
|
|
746
811
|
|
|
747
|
-
//
|
|
748
|
-
const settingsPath = path.join(targetDir, 'settings.json');
|
|
749
|
-
let settings = {};
|
|
750
|
-
if (fs.existsSync(settingsPath)) {
|
|
751
|
-
try {
|
|
752
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
753
|
-
} catch (error) {
|
|
754
|
-
throw new Error(`Cannot merge .claude/settings.json: ${error.message}`);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
758
|
-
throw new Error('Cannot merge .claude/settings.json: root must be a JSON object');
|
|
759
|
-
}
|
|
760
|
-
const hasOwnSetting = (key) => Object.prototype.hasOwnProperty.call(settings, key);
|
|
812
|
+
// One-hour foreground Bash calls and one-hour prompt caching serve a drain host that waits in the foreground.
|
|
761
813
|
let settingsChanged = false;
|
|
762
|
-
if (!
|
|
814
|
+
if (!Object.prototype.hasOwnProperty.call(settings, 'env')) {
|
|
763
815
|
settings.env = {};
|
|
764
816
|
settingsChanged = true;
|
|
765
817
|
}
|
|
766
|
-
if (!settings.env || typeof settings.env !== 'object' || Array.isArray(settings.env)) {
|
|
767
|
-
throw new Error('Cannot merge .claude/settings.json: env must be a JSON object');
|
|
768
|
-
}
|
|
769
|
-
// Auto-allow pipeline state directory and common git commands so resolve doesn't prompt
|
|
770
|
-
if (!hasOwnSetting('permissions')) {
|
|
771
|
-
settings.permissions = {};
|
|
772
|
-
settingsChanged = true;
|
|
773
|
-
}
|
|
774
|
-
if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
|
|
775
|
-
throw new Error('Cannot merge .claude/settings.json: permissions must be a JSON object');
|
|
776
|
-
}
|
|
777
|
-
if (!Object.prototype.hasOwnProperty.call(settings.permissions, 'allow')) {
|
|
778
|
-
settings.permissions.allow = [];
|
|
779
|
-
settingsChanged = true;
|
|
780
|
-
}
|
|
781
|
-
if (!Array.isArray(settings.permissions.allow)) {
|
|
782
|
-
throw new Error('Cannot merge .claude/settings.json: permissions.allow must be an array');
|
|
783
|
-
}
|
|
784
|
-
const pipelinePermissions = [
|
|
785
|
-
'Write(.devlyn/**)',
|
|
786
|
-
'Edit(.devlyn/**)',
|
|
787
|
-
'Bash(git add *)',
|
|
788
|
-
'Bash(git commit *)',
|
|
789
|
-
'Bash(git diff *)',
|
|
790
|
-
'Bash(git status *)',
|
|
791
|
-
'Bash(git log *)',
|
|
792
|
-
];
|
|
793
|
-
for (const perm of pipelinePermissions) {
|
|
794
|
-
if (!settings.permissions.allow.includes(perm)) {
|
|
795
|
-
settings.permissions.allow.push(perm);
|
|
796
|
-
settingsChanged = true;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
818
|
if (!settings.env.ENABLE_PROMPT_CACHING_1H) {
|
|
800
819
|
settings.env.ENABLE_PROMPT_CACHING_1H = 'true';
|
|
801
820
|
settingsChanged = true;
|
|
802
821
|
}
|
|
803
|
-
if (!settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) {
|
|
804
|
-
settings.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
|
805
|
-
settingsChanged = true;
|
|
806
|
-
}
|
|
807
822
|
const bashMaxTimeoutMs = Number.parseInt(settings.env.BASH_MAX_TIMEOUT_MS, 10);
|
|
808
823
|
if (!Number.isFinite(bashMaxTimeoutMs) || bashMaxTimeoutMs < 3600000) {
|
|
809
824
|
settings.env.BASH_MAX_TIMEOUT_MS = '3600000';
|
|
810
825
|
settingsChanged = true;
|
|
811
826
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
settings.
|
|
821
|
-
settingsChanged = true;
|
|
822
|
-
}
|
|
823
|
-
if (!Array.isArray(settings.hooks.Stop)) {
|
|
824
|
-
throw new Error('Cannot merge .claude/settings.json: hooks.Stop must be an array');
|
|
825
|
-
}
|
|
826
|
-
const stopHookCommand = 'python3 "$CLAUDE_PROJECT_DIR/.claude/skills/_shared/resolve-stop-hook.py"';
|
|
827
|
-
const stopHookInstalled = settings.hooks.Stop.some((entry) => (
|
|
828
|
-
entry && Array.isArray(entry.hooks) && entry.hooks.some((hook) => (
|
|
829
|
-
hook && hook.type === 'command' && hook.command === stopHookCommand
|
|
830
|
-
))
|
|
831
|
-
));
|
|
832
|
-
if (!stopHookInstalled) {
|
|
833
|
-
settings.hooks.Stop.push({
|
|
834
|
-
hooks: [{ type: 'command', command: stopHookCommand, timeout: 30 }],
|
|
835
|
-
});
|
|
827
|
+
// Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
|
|
828
|
+
const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
|
|
829
|
+
const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
|
|
830
|
+
for (let i = stop.length - 1; i >= 0; i--) {
|
|
831
|
+
const hooks = stop[i]?.hooks;
|
|
832
|
+
if (!Array.isArray(hooks) || !hooks.some(retiredHook)) continue;
|
|
833
|
+
stop[i].hooks = hooks.filter((hook) => !retiredHook(hook));
|
|
834
|
+
if (stop[i].hooks.length === 0 && Object.keys(stop[i]).length === 1) stop.splice(i, 1);
|
|
835
|
+
log(' ✕ settings.json Stop hook of the retired pipeline (removed)', 'dim');
|
|
836
836
|
settingsChanged = true;
|
|
837
837
|
}
|
|
838
838
|
if (settingsChanged) {
|
|
839
839
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
840
|
-
log(' → settings.json
|
|
840
|
+
log(' → settings.json', 'dim');
|
|
841
|
+
}
|
|
842
|
+
// Their ownership cannot be established, so the other retired settings stay; name the ones present.
|
|
843
|
+
const retired = [
|
|
844
|
+
...RETIRED_ENV.filter((key) => Object.prototype.hasOwnProperty.call(settings.env, key)),
|
|
845
|
+
...RETIRED_PERMISSIONS.filter((rule) => Array.isArray(settings.permissions?.allow) && settings.permissions.allow.includes(rule)),
|
|
846
|
+
];
|
|
847
|
+
if (retired.length > 0) {
|
|
848
|
+
log(` Retired devlyn settings may remain in .claude/settings.json: ${retired.join(', ')} — delete those you did not add yourself.`, 'yellow');
|
|
841
849
|
}
|
|
842
850
|
}
|
|
843
851
|
|
|
844
852
|
// Installs the targets in one scope; returns the skill roots written.
|
|
845
|
-
function install(targets, global) {
|
|
853
|
+
function install(targets, global, optionalNames = []) {
|
|
846
854
|
// In the home folder CLAUDE.md, AGENTS.md (agents read a parent folder's too) and
|
|
847
|
-
// .claude/settings.json
|
|
848
|
-
//
|
|
855
|
+
// .claude/settings.json would apply to every project. Same folder by identity, so a link or
|
|
856
|
+
// another spelling of the path is caught too.
|
|
849
857
|
const home = fs.statSync(os.homedir(), { bigint: true, throwIfNoEntry: false });
|
|
850
858
|
const here = fs.statSync(projectDir(), { bigint: true });
|
|
851
859
|
if (!global && home?.dev === here.dev && home?.ino === here.ino) {
|
|
@@ -853,7 +861,10 @@ function install(targets, global) {
|
|
|
853
861
|
+ 'would apply to every project. Run from a project folder, or use --global for skills only.');
|
|
854
862
|
}
|
|
855
863
|
const roots = targets.flatMap((target) => skillRoots(target, global));
|
|
856
|
-
|
|
864
|
+
preflightSkills(roots, optionalNames,
|
|
865
|
+
!global && targets.includes('claude') ? path.dirname(skillRoots('claude', false)[0]) : null);
|
|
866
|
+
// AGENTS.md first, whatever order the menu gave: a new CLAUDE.md holds the block only when AGENTS.md does not.
|
|
867
|
+
for (const target of ['agents', 'claude'].filter((name) => targets.includes(name))) {
|
|
857
868
|
if (global) skillRoots(target, true).forEach((root) => installCoreSkills(root));
|
|
858
869
|
else if (target === 'agents') installAgentsProject(targets.includes('claude'));
|
|
859
870
|
else installClaudeCore();
|
|
@@ -906,13 +917,12 @@ async function init({ yes, claude, global }) {
|
|
|
906
917
|
return;
|
|
907
918
|
}
|
|
908
919
|
|
|
909
|
-
log('\n🎯 What to install:\n', 'blue');
|
|
910
920
|
const targetOptions = [
|
|
911
921
|
{ key: 'agents', name: 'AGENTS.md — Codex · omp · Pi · Grok', desc: 'AGENTS.md + .agents/skills' },
|
|
912
|
-
{ key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills,
|
|
922
|
+
{ key: 'claude', name: 'CLAUDE.md — Claude Code', desc: 'CLAUDE.md + .claude/ (skills, settings)' },
|
|
913
923
|
];
|
|
914
924
|
const preselected = claude || hasDevlynClaude(false) || (global && hasDevlynClaude(true)) ? [0, 1] : [0];
|
|
915
|
-
const targets = (await multiSelect(targetOptions, preselected)).map((option) => option.key);
|
|
925
|
+
const targets = (await multiSelect('What to install', targetOptions, preselected)).map((option) => option.key);
|
|
916
926
|
|
|
917
927
|
if (targets.length === 0) {
|
|
918
928
|
log('\n💡 Nothing selected — nothing installed.', 'yellow');
|
|
@@ -920,14 +930,10 @@ async function init({ yes, claude, global }) {
|
|
|
920
930
|
return;
|
|
921
931
|
}
|
|
922
932
|
|
|
923
|
-
|
|
924
|
-
const scope = await singleSelect(['This project', 'Global — every project on this machine'], global ? 1 : 0);
|
|
925
|
-
const roots = install(targets, scope === 1);
|
|
926
|
-
|
|
933
|
+
const scope = await singleSelect('Where', ['This project', 'Global — every project on this machine'], global ? 1 : 0);
|
|
927
934
|
// Ask about optional addons (local skills + external packs; MCP servers belong to Claude Code)
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const selectedAddons = await multiSelect(OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
|
|
935
|
+
const selectedAddons = await multiSelect('Optional skills & packs', OPTIONAL_ADDONS.filter((addon) => addon.type !== 'mcp' || targets.includes('claude')));
|
|
936
|
+
const roots = install(targets, scope === 1, selectedAddons.filter((addon) => addon.type === 'local').map((addon) => addon.name));
|
|
931
937
|
|
|
932
938
|
if (selectedAddons.length > 0) {
|
|
933
939
|
for (const addon of selectedAddons) {
|
|
@@ -951,7 +957,7 @@ function showHelp() {
|
|
|
951
957
|
log(' npx devlyn-cli -y Without prompts: AGENTS.md + .agents/skills, plus CLAUDE.md + .claude/ if this project has them');
|
|
952
958
|
log(' npx devlyn-cli -y --claude Also install CLAUDE.md + .claude/ for Claude Code');
|
|
953
959
|
log(' npx devlyn-cli -y --global Skills only, for every project on this machine (~/.agents/skills, ~/.codex/skills; ~/.claude/skills with --claude, or when it already has a devlyn install)');
|
|
954
|
-
log(' npx devlyn-cli list List available skills
|
|
960
|
+
log(' npx devlyn-cli list List available skills');
|
|
955
961
|
log(' npx devlyn-cli --help Show this help\n');
|
|
956
962
|
log('Optional skills (select during install):', 'green');
|
|
957
963
|
OPTIONAL_ADDONS.filter((a) => a.type === 'local').forEach((skill) => {
|