baldart 3.6.4 → 3.7.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.7.0] - 2026-05-22
9
+
10
+ BALDART is now AI-tool agnostic. Every framework skill is installed into both Claude Code (`.claude/skills/`) AND OpenAI Codex CLI (`.agents/skills/`) when both are enabled — same source, two symlinks, zero duplication. Codex skill format is structurally identical to Claude's (`SKILL.md` with `name` + `description` frontmatter + optional `scripts/`/`references/`/`assets/`), so the same file works for both runtimes.
11
+
12
+ ### Added
13
+
14
+ - **`src/utils/tool-adapters/`** — new adapter registry (mirrors the existing `routine-adapters/` pattern). Each adapter declares the directory its tool reads skills from, plus capability flags (subagents, slash commands, hooks). Currently: `claude.js`, `codex.js`. Adding Cursor / Aider / Cline later is a one-file addition.
15
+ - **`tools.enabled` in `baldart.config.yml`** — array of enabled tool names. Default: `[claude]`, plus `codex` if `~/.codex/` is detected on the user's machine. Honored by `add`, `update`, `migrate`, and `verifySymlinks`.
16
+ - **`baldart configure` AI-tools section** — prompts per available tool with the autodetected default. Refuses to disable Claude (the framework's primary target).
17
+ - **`AUTODETECTED` summary** now shows `AI tools: claude, codex`.
18
+
19
+ ### Changed
20
+
21
+ - **`SymlinkUtils.mergeSkills({ tools })`** — runs the per-item merge once per enabled tool, resolving each tool's target directory via its adapter. The same source under `.framework/framework/.claude/skills/<skill>/` is linked into every enabled tool's expected location.
22
+ - **`SymlinkUtils.createAllSymlinks({ tools })`** — accepts the tool list and passes it through to `mergeSkills`.
23
+ - **`SymlinkUtils.verifySymlinks()`** — reads `tools.enabled` from `baldart.config.yml` and validates each tool's skills dir separately. Output lines are now tagged `[Claude Code]` / `[OpenAI Codex CLI]`.
24
+ - **Per-item skill merge resilience** — `_mergeSkillsForTool` now detects broken symlinks via `fs.lstatSync` and re-links them silently instead of crashing on EEXIST (same fix-pattern as v3.5.1, now applied per-tool).
25
+
26
+ ### Why it matters
27
+
28
+ Until now BALDART was Claude-Code-only by construction. A consumer using Codex saw nothing — Codex's discovery walks `.agents/skills/`, and the framework had no awareness of that path. v3.7.0 makes the install universal: declare `tools.enabled: [claude, codex]` (or both, autodetected) and Codex picks up the same 24 skills, indexed by its own discovery, with `AGENTS.md` at the root continuing to work for both as it did before. Adding more tools later (Cursor `.cursor/rules/`, Aider `CONVENTIONS.md`, Cline, etc.) only requires writing a new adapter — the rest of the install pipeline is already tool-pluggable.
29
+
30
+ ### Out of scope (Codex-specific limitations)
31
+
32
+ - **Subagents** (Claude's `.claude/agents/<name>.md`) have no Codex equivalent. They remain Claude-only. AGENTS.md sections cover the cross-tool coordination protocol.
33
+ - **Slash commands** (Claude's `.claude/commands/`) — Codex deprecated custom prompts in favor of skills, so `/commandX` patterns stay Claude-only. If you want a workflow callable from Codex, author it as a skill.
34
+ - **Hooks** (Claude's `.claude/hooks/`) — Codex has no equivalent hook system. `framework-edit-gate` remains a Claude-only safety net.
35
+
8
36
  ## [3.6.4] - 2026-05-22
9
37
 
10
38
  Two configure-flow fixes surfaced by a real-world `mayo` install audit.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.6.4
1
+ 3.7.0
@@ -111,3 +111,17 @@ features:
111
111
 
112
112
  # LLM-wiki overlay (paths.wiki_dir + capture/wiki-curator loop).
113
113
  has_wiki_overlay: false
114
+
115
+ # ─── TOOLS ───────────────────────────────────────────────────────────────
116
+ # Which AI CLI tools should the framework target on this machine?
117
+ # Each enabled tool gets its own per-item skill symlinks pointing at the
118
+ # SAME source under `.framework/framework/.claude/skills/<skill>/` (no
119
+ # duplication — Claude reads `.claude/skills/`, Codex reads `.agents/skills/`).
120
+ #
121
+ # Adding "codex" exposes every framework skill to OpenAI Codex CLI as well.
122
+ # The set of supported tools is defined by adapters under
123
+ # `src/utils/tool-adapters/`. Currently: claude, codex.
124
+ tools:
125
+ enabled:
126
+ - claude
127
+ # - codex # uncomment to also install for OpenAI Codex CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.6.4",
3
+ "version": "3.7.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -119,8 +119,13 @@ async function add(repo, options) {
119
119
  UI.newline();
120
120
  // First-time install: 'safe' mode preserves any pre-existing user
121
121
  // customisations without prompting (no destructive backups on fresh
122
- // install paths). Per-skill merge always runs.
123
- await symlinks.createAllSymlinks({ mode: 'safe' });
122
+ // install paths). Per-skill merge runs for every enabled tool — by
123
+ // default `['claude']`, plus `'codex'` if `~/.codex/` is present on
124
+ // the user's machine (autodetected). Run `npx baldart configure` to
125
+ // change the selection.
126
+ const toolAdapters = require('../utils/tool-adapters');
127
+ const enabledTools = toolAdapters.defaultEnabled();
128
+ await symlinks.createAllSymlinks({ mode: 'safe', tools: enabledTools });
124
129
 
125
130
  UI.newline();
126
131
  symlinks.copyCustomizableFiles();
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const yaml = require('js-yaml');
4
4
  const UI = require('../utils/ui');
5
+ const toolAdapters = require('../utils/tool-adapters');
5
6
 
6
7
  const CONFIG_FILE = 'baldart.config.yml';
7
8
  // The subtree pull copies the entire BALDART repo (which itself has a
@@ -298,6 +299,9 @@ function detect(cwd = process.cwd()) {
298
299
  has_prd_workflow: exists('docs/prd'),
299
300
  has_wiki_overlay: exists('docs/wiki'),
300
301
  },
302
+ tools: {
303
+ enabled: toolAdapters.defaultEnabled(cwd)
304
+ },
301
305
  };
302
306
 
303
307
  // Reference walkFirst once so the dead-code linter is happy and the helper
@@ -394,6 +398,32 @@ async function interactivePrompts(merged, detected) {
394
398
  ? segments.split(',').map((s) => s.trim()).filter(Boolean)
395
399
  : [];
396
400
 
401
+ // ---- AI tools (which CLI tools should this install target?) -----------
402
+ UI.section('AI tools (which tools should consume the framework?)');
403
+ merged.tools = merged.tools || {};
404
+ const allTools = toolAdapters.listAdapters();
405
+ const currentEnabled = (merged.tools.enabled && merged.tools.enabled.length)
406
+ ? merged.tools.enabled
407
+ : detected.tools.enabled;
408
+ for (const toolName of allTools) {
409
+ const adapter = toolAdapters.getAdapter(toolName);
410
+ const currentlyOn = currentEnabled.includes(toolName);
411
+ const detectedHint = (toolName !== 'claude' && detected.tools.enabled.includes(toolName))
412
+ ? ' (detected on this machine)' : '';
413
+ const want = await UI.confirm(`Install for ${adapter.label}?${detectedHint}`, currentlyOn);
414
+ if (want && !currentEnabled.includes(toolName)) currentEnabled.push(toolName);
415
+ if (!want) {
416
+ const idx = currentEnabled.indexOf(toolName);
417
+ if (idx >= 0) currentEnabled.splice(idx, 1);
418
+ }
419
+ }
420
+ // Claude is the framework's primary target — refuse to disable it entirely.
421
+ if (!currentEnabled.includes('claude')) {
422
+ UI.warning('Claude Code cannot be disabled (primary target). Re-enabling.');
423
+ currentEnabled.unshift('claude');
424
+ }
425
+ merged.tools.enabled = currentEnabled;
426
+
397
427
  UI.section('Features (explicit yes/no — option A: always ask)');
398
428
  for (const flag of [
399
429
  ['has_design_system', 'Project has a documented design system?'],
@@ -541,6 +571,7 @@ async function configure(opts = {}) {
541
571
 
542
572
  UI.box('AUTODETECTED', [
543
573
  `Brand name: ${detected.identity.brand_name || '—'}`,
574
+ `AI tools: ${detected.tools.enabled.join(', ')}`,
544
575
  `Design system: ${detected.features.has_design_system ? 'yes' : 'no'}`,
545
576
  ` └─ signals: ${dsSignalLabels}`,
546
577
  `UI guidelines: ${detected.paths.ui_guidelines || '— (none found)'}`,
@@ -115,7 +115,19 @@ async function migrate() {
115
115
  // --- Step 2: per-item framework skill merge ----------------------------
116
116
 
117
117
  UI.section('Step 2: merge framework skills (per-item)');
118
- const mergeResult = symlinks.mergeSkills();
118
+ // Read tools from config so migrate honors the user's per-tool selection.
119
+ let enabledTools = ['claude'];
120
+ try {
121
+ const yaml = require('js-yaml');
122
+ const cfgPath = path.join(cwd, 'baldart.config.yml');
123
+ if (fs.existsSync(cfgPath)) {
124
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8'));
125
+ if (Array.isArray(cfg?.tools?.enabled) && cfg.tools.enabled.length) {
126
+ enabledTools = cfg.tools.enabled;
127
+ }
128
+ }
129
+ } catch (_) { /* keep default */ }
130
+ const mergeResult = symlinks.mergeSkills({ tools: enabledTools });
119
131
  UI.info(`Linked ${mergeResult.linked.length} framework skills, kept ${mergeResult.skipped.length} as-is, ${mergeResult.conflicts.length} conflict(s).`);
120
132
 
121
133
  // --- Step 3: restore user skills from .backup --------------------------
@@ -4,6 +4,21 @@ const UI = require('../utils/ui');
4
4
  const State = require('../utils/state');
5
5
  const Hooks = require('../utils/hooks');
6
6
 
7
+ function readEnabledTools(cwd = process.cwd()) {
8
+ try {
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const yaml = require('js-yaml');
12
+ const cfgPath = path.join(cwd, 'baldart.config.yml');
13
+ if (!fs.existsSync(cfgPath)) return ['claude'];
14
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8'));
15
+ if (Array.isArray(cfg?.tools?.enabled) && cfg.tools.enabled.length) {
16
+ return cfg.tools.enabled;
17
+ }
18
+ } catch (_) { /* fall through */ }
19
+ return ['claude'];
20
+ }
21
+
7
22
  // Path prefixes BALDART itself writes during install/update. Anything outside
8
23
  // these patterns is treated as user-owned and never auto-staged.
9
24
  const BALDART_MANAGED_PATTERNS = [
@@ -297,24 +312,26 @@ async function update(options = {}) {
297
312
  UI.newline();
298
313
  UI.section('Verifying Symlinks');
299
314
 
315
+ // Read enabled tools from baldart.config.yml so updates respect the
316
+ // user's per-tool selection (Claude only / Claude + Codex / …).
317
+ const enabledTools = readEnabledTools();
318
+
300
319
  const symlinkValid = symlinks.verifySymlinks();
301
320
  if (!symlinkValid) {
302
321
  UI.info('Some symlinks are missing or out of date. The framework will:');
303
322
  UI.list([
304
323
  'Refuse to overwrite any file/dir you customised (you\'ll be asked first).',
305
324
  'Convert legacy .claude/skills/ bulk symlink (v2.0.x) into the new per-item layout.',
306
- 'Merge framework skills into .claude/skills/ without touching your personal skills.'
325
+ `Merge framework skills into per-tool skill dirs (${enabledTools.join(', ')}) without touching your personal skills.`
307
326
  ]);
308
327
  const recreate = await UI.confirm('Reconcile symlinks now?', true);
309
328
  if (recreate) {
310
- // Use 'prompt' mode so any customised file (non-symlink) gets a
311
- // confirmation prompt before being moved to .backup.
312
- await symlinks.createAllSymlinks({ mode: 'prompt' });
329
+ await symlinks.createAllSymlinks({ mode: 'prompt', tools: enabledTools });
313
330
  }
314
331
  } else {
315
- // Even when bulk symlinks are valid, re-run the per-item skill merge
316
- // to pick up new framework skills shipped in this version.
317
- symlinks.mergeSkills();
332
+ // Re-run the per-item skill merge for every enabled tool so newly-shipped
333
+ // skills are linked in each tool's expected directory.
334
+ symlinks.mergeSkills({ tools: enabledTools });
318
335
  }
319
336
 
320
337
  // Routines wizard (since v2.1.0) — surfaces routines added in the new framework version
@@ -160,93 +160,135 @@ class SymlinkUtils {
160
160
  // -----------------------------------------------------------------------
161
161
 
162
162
  /**
163
- * Merge framework skills into the user's .claude/skills/ directory
164
- * without ever touching user-authored skills.
163
+ * Merge framework skills into the user's per-tool skill directories
164
+ * (.claude/skills/, .agents/skills/, …) without ever touching user-
165
+ * authored skills. The SAME source file under
166
+ * `.framework/framework/.claude/skills/<skill>/` is symlinked into each
167
+ * enabled tool's expected directory — zero duplication, both Claude and
168
+ * Codex read the same content.
165
169
  *
166
- * Returns { linked: [...], skipped: [...], conflicts: [...] }
170
+ * @param {Object} opts
171
+ * @param {string[]} opts.tools - List of enabled tool names (default: ['claude'])
172
+ * @returns {Object} { linked: [...], skipped: [...], conflicts: [...] }
167
173
  */
168
- mergeSkills() {
169
- const result = { linked: [], skipped: [], conflicts: [] };
174
+ mergeSkills(opts = {}) {
175
+ const tools = (opts.tools && opts.tools.length) ? opts.tools : ['claude'];
176
+ const aggregate = { linked: [], skipped: [], conflicts: [] };
170
177
 
171
- const frameworkSkillsDir = path.join(this.cwd, FRAMEWORK_DIR, 'framework', '.claude', 'skills');
178
+ const frameworkSkillsDir = path.join(this.cwd, FRAMEWORK_PAYLOAD, '.claude', 'skills');
172
179
  if (!fs.existsSync(frameworkSkillsDir)) {
173
180
  UI.warning(`No framework skills found at ${path.relative(this.cwd, frameworkSkillsDir)}. Skipping skill merge.`);
174
- return result;
181
+ return aggregate;
182
+ }
183
+
184
+ const frameworkSkills = fs.readdirSync(frameworkSkillsDir).filter(name => {
185
+ if (name.startsWith('.')) return false;
186
+ const full = path.join(frameworkSkillsDir, name);
187
+ return fs.lstatSync(full).isDirectory();
188
+ });
189
+
190
+ for (const tool of tools) {
191
+ const result = this._mergeSkillsForTool(tool, frameworkSkills, frameworkSkillsDir);
192
+ aggregate.linked.push(...result.linked);
193
+ aggregate.skipped.push(...result.skipped);
194
+ aggregate.conflicts.push(...result.conflicts);
195
+ }
196
+
197
+ if (aggregate.conflicts.length > 0) {
198
+ this.ensureDirectory('.baldart');
199
+ const conflictPath = path.join(this.cwd, CONFLICT_LOG);
200
+ let existing = { conflicts: [] };
201
+ if (fs.existsSync(conflictPath)) {
202
+ try { existing = JSON.parse(fs.readFileSync(conflictPath, 'utf8')); }
203
+ catch (_) { /* ignore parse errors, overwrite */ }
204
+ }
205
+ existing.conflicts = aggregate.conflicts;
206
+ existing.last_merge = new Date().toISOString();
207
+ fs.writeFileSync(conflictPath, JSON.stringify(existing, null, 2) + '\n');
208
+ UI.warning(`Recorded ${aggregate.conflicts.length} skill conflict(s) in ${CONFLICT_LOG}`);
209
+ UI.info('Resolve each by renaming your local skill OR confirming the framework version is the one you want, then re-run `npx baldart update`.');
175
210
  }
176
211
 
177
- const userSkillsDir = path.join(this.cwd, '.claude', 'skills');
212
+ return aggregate;
213
+ }
178
214
 
179
- // Handle the v2.0.x legacy case: .claude/skills is a single symlink to
180
- // .framework/.../skills. Convert it back to a real directory so we can
181
- // host per-item symlinks alongside user skills.
215
+ /**
216
+ * Per-tool skill merge internal. Resolves the tool's target directory
217
+ * via the adapter, handles legacy bulk-symlink conversion, then per-item
218
+ * symlinks every framework skill into that directory.
219
+ */
220
+ _mergeSkillsForTool(toolName, frameworkSkills, frameworkSkillsDir) {
221
+ const { getAdapter } = require('./tool-adapters');
222
+ const adapter = getAdapter(toolName, this.cwd);
223
+ const result = { linked: [], skipped: [], conflicts: [] };
224
+
225
+ const skillsRel = adapter.skillsDir(); // e.g. ".claude/skills" or ".agents/skills"
226
+ const userSkillsDir = path.join(this.cwd, skillsRel);
227
+
228
+ // Legacy: if the user's skills dir is itself a bulk symlink, convert
229
+ // back to a real directory so per-item symlinks can coexist with user skills.
182
230
  if (fs.existsSync(userSkillsDir) && fs.lstatSync(userSkillsDir).isSymbolicLink()) {
183
- UI.warning('Detected legacy v2.0.x bulk skills symlink. Converting to per-item layout…');
231
+ UI.warning(`[${adapter.label}] Detected legacy bulk skills symlink at ${skillsRel}. Converting to per-item layout…`);
184
232
  fs.unlinkSync(userSkillsDir);
185
233
  }
186
234
 
187
- this.ensureDirectory('.claude/skills');
235
+ this.ensureDirectory(skillsRel);
188
236
 
189
- const frameworkSkills = fs.readdirSync(frameworkSkillsDir).filter(name => {
190
- // Skip hidden files (.DS_Store, etc.) and any non-skill entries
191
- if (name.startsWith('.')) return false;
192
- const full = path.join(frameworkSkillsDir, name);
193
- return fs.lstatSync(full).isDirectory();
194
- });
237
+ // Compute the relative path from the user's skills dir to the framework
238
+ // source. For ".claude/skills" or ".agents/skills" both are 2 levels deep,
239
+ // so the prefix is "../.." — but compute it generically to support adapters
240
+ // that pick different depths.
241
+ const skillsDirDepth = skillsRel.split(path.sep).filter(Boolean).length;
242
+ const upPath = path.join(...Array(skillsDirDepth).fill('..'));
195
243
 
196
244
  frameworkSkills.forEach(name => {
197
245
  const linkPath = path.join(userSkillsDir, name);
198
- const target = path.join('..', '..', FRAMEWORK_DIR, 'framework', '.claude', 'skills', name);
199
- const targetAbsolute = path.join(this.cwd, FRAMEWORK_DIR, 'framework', '.claude', 'skills', name);
246
+ const target = path.join(upPath, FRAMEWORK_PAYLOAD, '.claude', 'skills', name);
247
+ const targetAbsolute = path.join(this.cwd, FRAMEWORK_PAYLOAD, '.claude', 'skills', name);
248
+
249
+ // Use lstat to detect broken symlinks (fs.existsSync follows links and
250
+ // misclassifies broken ones as "missing", causing EEXIST on symlinkSync).
251
+ let lstat = null;
252
+ try { lstat = fs.lstatSync(linkPath); } catch (_) { /* absent */ }
200
253
 
201
- if (!fs.existsSync(linkPath)) {
254
+ if (!lstat) {
202
255
  fs.symlinkSync(target, linkPath);
203
- UI.success(`Skill linked: .claude/skills/${name}`);
204
- result.linked.push(name);
256
+ UI.success(`[${adapter.label}] Skill linked: ${path.join(skillsRel, name)}`);
257
+ result.linked.push({ tool: toolName, name });
205
258
  return;
206
259
  }
207
260
 
208
- const stat = fs.lstatSync(linkPath);
209
- if (stat.isSymbolicLink()) {
261
+ if (lstat.isSymbolicLink()) {
210
262
  const current = fs.readlinkSync(linkPath);
211
- if (current === target || path.resolve(path.dirname(linkPath), current) === targetAbsolute) {
212
- // Already linked correctly silent OK
213
- result.skipped.push({ name, reason: 'already-linked' });
263
+ const resolved = path.resolve(path.dirname(linkPath), current);
264
+ if (current === target || resolved === targetAbsolute) {
265
+ result.skipped.push({ tool: toolName, name, reason: 'already-linked' });
266
+ return;
267
+ }
268
+ // Broken-or-misaimed symlink → replace silently with the correct one
269
+ if (!fs.existsSync(linkPath)) {
270
+ fs.unlinkSync(linkPath);
271
+ fs.symlinkSync(target, linkPath);
272
+ UI.success(`[${adapter.label}] Skill re-linked (was broken): ${path.join(skillsRel, name)}`);
273
+ result.linked.push({ tool: toolName, name });
214
274
  return;
215
275
  }
216
- // Symlink pointing elsewhere user override, leave alone
217
- UI.info(`Skill kept (user override symlink): .claude/skills/${name} ${current}`);
218
- result.skipped.push({ name, reason: 'user-symlink-override', target: current });
276
+ UI.info(`[${adapter.label}] Skill kept (user override symlink): ${path.join(skillsRel, name)} → ${current}`);
277
+ result.skipped.push({ tool: toolName, name, reason: 'user-symlink-override', target: current });
219
278
  return;
220
279
  }
221
280
 
222
- // Real file or directory under same name NAME COLLISION
223
- UI.warning(`Skill name conflict: .claude/skills/${name} already exists locally. Framework version NOT installed.`);
281
+ UI.warning(`[${adapter.label}] Skill name conflict: ${path.join(skillsRel, name)} already exists locally. Framework version NOT installed.`);
224
282
  result.conflicts.push({
283
+ tool: toolName,
225
284
  name,
226
- local_kind: stat.isDirectory() ? 'directory' : 'file',
227
- local_path: path.join('.claude', 'skills', name),
285
+ local_kind: lstat.isDirectory() ? 'directory' : 'file',
286
+ local_path: path.join(skillsRel, name),
228
287
  framework_path: path.relative(this.cwd, path.join(frameworkSkillsDir, name)),
229
288
  detected_at: new Date().toISOString()
230
289
  });
231
290
  });
232
291
 
233
- // Persist conflicts (only when there's something to record)
234
- if (result.conflicts.length > 0) {
235
- this.ensureDirectory('.baldart');
236
- const conflictPath = path.join(this.cwd, CONFLICT_LOG);
237
- let existing = { conflicts: [] };
238
- if (fs.existsSync(conflictPath)) {
239
- try { existing = JSON.parse(fs.readFileSync(conflictPath, 'utf8')); }
240
- catch (_) { /* ignore parse errors, overwrite */ }
241
- }
242
- // Replace conflicts for this run (most recent wins)
243
- existing.conflicts = result.conflicts;
244
- existing.last_merge = new Date().toISOString();
245
- fs.writeFileSync(conflictPath, JSON.stringify(existing, null, 2) + '\n');
246
- UI.warning(`Recorded ${result.conflicts.length} skill conflict(s) in ${CONFLICT_LOG}`);
247
- UI.info('Resolve each by renaming your local skill OR confirming the framework version is the one you want, then re-run `npx baldart update`.');
248
- }
249
-
250
292
  return result;
251
293
  }
252
294
 
@@ -260,7 +302,7 @@ class SymlinkUtils {
260
302
 
261
303
  UI.newline();
262
304
  UI.section('Merging Framework Skills');
263
- this.mergeSkills();
305
+ this.mergeSkills({ tools: opts.tools });
264
306
 
265
307
  UI.newline();
266
308
  }
@@ -298,31 +340,51 @@ class SymlinkUtils {
298
340
  }
299
341
  });
300
342
 
301
- // .claude/skills/ should be a real directory in v2.1.1+ (or absent).
302
- const skillsDir = path.join(this.cwd, '.claude', 'skills');
303
- if (fs.existsSync(skillsDir)) {
304
- const stat = fs.lstatSync(skillsDir);
305
- if (stat.isSymbolicLink()) {
306
- UI.warning('Legacy v2.0.x layout: .claude/skills is a bulk symlink. Run `npx baldart update` (or `npx baldart migrate`) to convert to the per-item layout.');
307
- allValid = false;
308
- } else {
309
- // Spot-check a couple of well-known framework skills
310
- const sample = ['skill-creator', 'frontend-design', 'bug', 'prd', 'capture'];
311
- let frameworkLinks = 0;
312
- sample.forEach(name => {
313
- const p = path.join(skillsDir, name);
314
- if (fs.existsSync(p) && fs.lstatSync(p).isSymbolicLink()) frameworkLinks++;
315
- });
316
- if (frameworkLinks === 0) {
317
- UI.warning('.claude/skills/ has no framework-linked skills. Run `npx baldart update` to merge them.');
343
+ // Per-tool skill directories. Read enabled tools from baldart.config.yml
344
+ // (fallback to ['claude'] if no config yet).
345
+ const { getAdapter } = require('./tool-adapters');
346
+ let enabledTools = ['claude'];
347
+ try {
348
+ const yaml = require('js-yaml');
349
+ const cfgPath = path.join(this.cwd, 'baldart.config.yml');
350
+ if (fs.existsSync(cfgPath)) {
351
+ const cfg = yaml.load(fs.readFileSync(cfgPath, 'utf8'));
352
+ if (Array.isArray(cfg?.tools?.enabled) && cfg.tools.enabled.length) {
353
+ enabledTools = cfg.tools.enabled;
354
+ }
355
+ }
356
+ } catch (_) { /* keep default */ }
357
+
358
+ for (const toolName of enabledTools) {
359
+ let adapter;
360
+ try { adapter = getAdapter(toolName, this.cwd); }
361
+ catch (err) { UI.warning(err.message); allValid = false; continue; }
362
+
363
+ const skillsRel = adapter.skillsDir();
364
+ const skillsDir = path.join(this.cwd, skillsRel);
365
+ if (fs.existsSync(skillsDir)) {
366
+ const stat = fs.lstatSync(skillsDir);
367
+ if (stat.isSymbolicLink()) {
368
+ UI.warning(`[${adapter.label}] Legacy bulk skills symlink at ${skillsRel}. Run \`npx baldart update\` to convert to per-item.`);
318
369
  allValid = false;
319
370
  } else {
320
- UI.success(`Valid: .claude/skills/ (per-item merge, ${frameworkLinks}/${sample.length} sampled framework skills linked)`);
371
+ const sample = ['skill-creator', 'frontend-design', 'bug', 'prd', 'capture'];
372
+ let frameworkLinks = 0;
373
+ sample.forEach(name => {
374
+ const p = path.join(skillsDir, name);
375
+ if (fs.existsSync(p) && fs.lstatSync(p).isSymbolicLink()) frameworkLinks++;
376
+ });
377
+ if (frameworkLinks === 0) {
378
+ UI.warning(`[${adapter.label}] ${skillsRel}/ has no framework-linked skills. Run \`npx baldart update\`.`);
379
+ allValid = false;
380
+ } else {
381
+ UI.success(`[${adapter.label}] Valid: ${skillsRel}/ (per-item merge, ${frameworkLinks}/${sample.length} sampled framework skills linked)`);
382
+ }
321
383
  }
384
+ } else {
385
+ UI.warning(`[${adapter.label}] Missing: ${skillsRel}/ (the framework would merge skills here)`);
386
+ allValid = false;
322
387
  }
323
- } else {
324
- UI.warning('Missing: .claude/skills/ (the framework would merge skills here)');
325
- allValid = false;
326
388
  }
327
389
 
328
390
  // Project configuration (v3.0.0+)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Claude Code tool adapter.
3
+ *
4
+ * Claude Code consumes the framework via:
5
+ * - `.claude/skills/<skill>/SKILL.md` (skills, per-item symlinks)
6
+ * - `.claude/agents/<agent>.md` (subagents, bulk symlink)
7
+ * - `.claude/commands/<command>.md` (slash commands, bulk symlink)
8
+ * - `.claude/settings.json` (hooks, ide config)
9
+ * - `AGENTS.md` (root coordination protocol)
10
+ *
11
+ * All paths are relative to the consumer repo root.
12
+ */
13
+ class ClaudeAdapter {
14
+ constructor(cwd = process.cwd()) {
15
+ this.cwd = cwd;
16
+ }
17
+
18
+ get name() { return 'claude'; }
19
+ get label() { return 'Claude Code'; }
20
+
21
+ /** Where this tool reads skill bundles from. */
22
+ skillsDir() { return '.claude/skills'; }
23
+
24
+ /** Whether this tool consumes subagent definitions (`.claude/agents/`). */
25
+ supportsSubagents() { return true; }
26
+
27
+ /** Whether this tool consumes Claude-style slash commands. */
28
+ supportsSlashCommands() { return true; }
29
+
30
+ /** Whether this tool consumes the Claude PreToolUse hook system. */
31
+ supportsHooks() { return true; }
32
+
33
+ /**
34
+ * Autodetection signal. We assume Claude is always intended (it's the
35
+ * framework's primary target). The opt-out is via `tools.enabled` in
36
+ * baldart.config.yml.
37
+ */
38
+ static detect(/* cwd */) { return true; }
39
+ }
40
+
41
+ module.exports = ClaudeAdapter;
@@ -0,0 +1,51 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ /**
6
+ * OpenAI Codex CLI tool adapter.
7
+ *
8
+ * Codex skill format is structurally identical to Claude's: a directory
9
+ * with `SKILL.md` (YAML frontmatter: name + description), plus optional
10
+ * `scripts/`, `references/`, `assets/`. The ONLY differences from Claude:
11
+ * - Lives at `.agents/skills/<skill>/` (repo-level) or
12
+ * `~/.agents/skills/<skill>/` (user-level).
13
+ * - May carry an optional `agents/openai.yaml` for Codex-specific
14
+ * metadata (display name, icon, brand color, allow_implicit_invocation,
15
+ * dependencies). Not required for the skill to load.
16
+ *
17
+ * Because the format is the same, the install model is "symlink the same
18
+ * source dir into two places" — Claude reads from `.claude/skills/<x>`,
19
+ * Codex reads from `.agents/skills/<x>`, and both follow the symlink to
20
+ * the single source under `.framework/framework/.claude/skills/<x>/`.
21
+ *
22
+ * Codex does NOT have an equivalent of Claude subagents (`.claude/agents/`)
23
+ * or slash commands (custom prompts are deprecated in favor of skills).
24
+ * AGENTS.md at the repo root IS read by Codex — same convention as Claude.
25
+ */
26
+ class CodexAdapter {
27
+ constructor(cwd = process.cwd()) {
28
+ this.cwd = cwd;
29
+ }
30
+
31
+ get name() { return 'codex'; }
32
+ get label() { return 'OpenAI Codex CLI'; }
33
+
34
+ skillsDir() { return '.agents/skills'; }
35
+
36
+ supportsSubagents() { return false; }
37
+ supportsSlashCommands() { return false; }
38
+ supportsHooks() { return false; }
39
+
40
+ /**
41
+ * Heuristic: Codex is present on the user's machine if `~/.codex/` exists
42
+ * (created automatically by `codex` CLI on first run). Pure best-effort
43
+ * for the `configure` autodetect — the user can always toggle the flag.
44
+ */
45
+ static detect() {
46
+ try { return fs.existsSync(path.join(os.homedir(), '.codex')); }
47
+ catch { return false; }
48
+ }
49
+ }
50
+
51
+ module.exports = CodexAdapter;
@@ -0,0 +1,44 @@
1
+ const ClaudeAdapter = require('./claude');
2
+ const CodexAdapter = require('./codex');
3
+
4
+ /**
5
+ * Tool adapter registry.
6
+ *
7
+ * Adding a new adapter (Cursor, Aider, Cline, …):
8
+ * 1. Create `src/utils/tool-adapters/<name>.js` exporting a class with
9
+ * the same shape as ClaudeAdapter/CodexAdapter.
10
+ * 2. Add it to the REGISTRY below.
11
+ * 3. (Optional) Implement `static detect(cwd)` so `baldart configure`
12
+ * can autodetect when the user already has the tool installed.
13
+ *
14
+ * Adapters do NOT translate skill content — they just declare WHERE the
15
+ * tool reads from. The same source file under `.framework/framework/.claude/skills/`
16
+ * is symlinked into each enabled tool's expected directory.
17
+ */
18
+ const REGISTRY = {
19
+ claude: ClaudeAdapter,
20
+ codex: CodexAdapter
21
+ };
22
+
23
+ function listAdapters() {
24
+ return Object.keys(REGISTRY);
25
+ }
26
+
27
+ function getAdapter(name, cwd) {
28
+ const Cls = REGISTRY[name];
29
+ if (!Cls) throw new Error(`Unknown tool adapter: ${name}. Available: ${listAdapters().join(', ')}`);
30
+ return new Cls(cwd);
31
+ }
32
+
33
+ /**
34
+ * Returns the list of tool names that should be enabled by default for
35
+ * a fresh install. Always includes `claude` (the framework's primary
36
+ * target). Includes `codex` if the user appears to have it installed.
37
+ */
38
+ function defaultEnabled(cwd) {
39
+ const enabled = ['claude'];
40
+ if (CodexAdapter.detect(cwd)) enabled.push('codex');
41
+ return enabled;
42
+ }
43
+
44
+ module.exports = { REGISTRY, listAdapters, getAdapter, defaultEnabled };