baldart 3.7.0 → 3.8.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 +69 -0
- package/VERSION +1 -1
- package/framework/.claude/hooks/framework-edit-gate.js +38 -0
- package/framework/agents/project-context.md +59 -0
- package/framework/templates/overlays/agents/coder.example.md +43 -0
- package/framework/templates/overlays/commands/codexreview.example.md +17 -0
- package/package.json +1 -1
- package/src/commands/doctor.js +11 -5
- package/src/commands/migrate.js +3 -0
- package/src/commands/update.js +38 -6
- package/src/utils/overlay-merger.js +206 -0
- package/src/utils/symlinks.js +245 -15
- package/src/utils/tool-adapters/claude.js +6 -0
- package/src/utils/tool-adapters/codex.js +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,75 @@ 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.8.0] - 2026-05-22
|
|
9
|
+
|
|
10
|
+
`.claude/agents/` and `.claude/commands/` move from bulk symlink to **per-item merge** with an **overlay system** (compile-time merge of base + overlay). Closes the long-standing gap where specializing a framework agent forced you to either fork the whole file (and lose upstream updates) or contaminate `.framework/`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`src/utils/overlay-merger.js`** — new pure-function module. `mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion })` returns the generated file content + warnings. Section markers: `## [OVERRIDE]`, `## [APPEND]`, `## [PREPEND]`; un-marked H2 sections in the overlay are appended at the end. Base frontmatter (`name`, `description`, `tools`, `model`) is preserved verbatim. Output carries a leading `<!-- baldart-generated: kind=… name=… base_version=… overlay_sha=… -->` marker so future updates know it's regeneration-safe.
|
|
15
|
+
- **`SymlinkUtils.mergeAgents(opts)` / `mergeCommands(opts)`** — per-item merge with overlay awareness:
|
|
16
|
+
- No overlay → install as symlink (same model as skills).
|
|
17
|
+
- Overlay present → write a real file with the merged content + marker.
|
|
18
|
+
- Existing generated file → detected by marker → safely regenerated.
|
|
19
|
+
- User-authored fork (no marker) → conflict logged, file left untouched, user told to migrate to overlay.
|
|
20
|
+
- Broken symlink → silently re-linked (same fix-pattern as v3.5.1).
|
|
21
|
+
- **`tool-adapters/claude.js`** declares `agentsDir()` and `commandsDir()`. Codex returns `null` (no equivalent), so the dispatch skips it cleanly.
|
|
22
|
+
- **`framework/.claude/hooks/framework-edit-gate.js`** extended: blocks `Edit`/`Write` of any file whose first line is `<!-- baldart-generated:` unless the new content also starts with the same marker. Forces the user to edit the overlay instead.
|
|
23
|
+
- **Example overlays**: `framework/templates/overlays/agents/coder.example.md` and `framework/templates/overlays/commands/codexreview.example.md` showing all three section markers in use.
|
|
24
|
+
- **`framework/agents/project-context.md` § 5.bis** — new section documenting the agent/command overlay protocol, layout, frontmatter, section markers, generation rules, drift handling, and hard rules.
|
|
25
|
+
- **`baldart doctor` Overlays line** now counts skill overlays + agent overlays + command overlays.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`SymlinkUtils.createBulkSymlinks()`** — no longer creates bulk symlinks for `.claude/agents/` and `.claude/commands/`. If a legacy bulk symlink is detected (v3.7.x and earlier layout), it's unlinked and replaced with a real directory, populated per-item by `mergeAgents`/`mergeCommands` immediately after.
|
|
30
|
+
- **`SymlinkUtils.createAllSymlinks({ tools })`** — calls `mergeSkills` + `mergeAgents` + `mergeCommands` in sequence.
|
|
31
|
+
- **`SymlinkUtils.verifySymlinks()`** — `AGENTS.md` and `agents/` stay bulk symlinks; `.claude/agents/` and `.claude/commands/` are now validated as real dirs containing at least one framework-managed file (symlink or generated). Per-tool, only for tools whose adapter declares a non-null `agentsDir()` / `commandsDir()`.
|
|
32
|
+
- **`src/commands/update.js`** — post-update reconcile now runs the new agent + command merges in addition to skills.
|
|
33
|
+
- **`src/commands/migrate.js`** — additionally converts legacy `.claude/agents` and `.claude/commands` bulk symlinks to per-item.
|
|
34
|
+
|
|
35
|
+
### Migration (pre-3.8.0 → 3.8.0)
|
|
36
|
+
|
|
37
|
+
On the next `npx baldart update`, an old consumer sees:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
⚠ Detected legacy bulk symlink at .claude/agents. Converting to per-item layout (v3.8.0+).
|
|
41
|
+
⚠ Detected legacy bulk symlink at .claude/commands. Converting to per-item layout (v3.8.0+).
|
|
42
|
+
✓ [Claude Code] agent linked: .claude/agents/coder.md
|
|
43
|
+
…
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Zero data loss (the bulk symlink was just one pointer; the real content stays in `.framework/`). After the convert, the user can create `.baldart/overlays/agents/<name>.md` to specialize any framework agent without forking.
|
|
47
|
+
|
|
48
|
+
### Verification (proven in sandbox)
|
|
49
|
+
|
|
50
|
+
1. **Legacy → per-item migration**: bulk `.claude/agents` symlink → real dir + per-item symlinks ✓
|
|
51
|
+
2. **Overlay create → regenerate**: real generated file with marker + overlay content visible ✓
|
|
52
|
+
3. **Overlay remove → revert to symlink**: generated file deleted, symlink re-created ✓
|
|
53
|
+
4. **User fork without marker → conflict logged**: framework version NOT overwritten, user file preserved, guidance printed ✓
|
|
54
|
+
5. **Heading mismatch in overlay**: warning emitted, content appended as new section (no silent loss) ✓
|
|
55
|
+
6. **Marker round-trip**: SHA-256-truncated overlay hash + framework version recoverable from generated file header ✓
|
|
56
|
+
|
|
57
|
+
### Out of scope (explicitly deferred)
|
|
58
|
+
|
|
59
|
+
- **Codex agent/command equivalents**: Codex doesn't have subagents or slash commands (its custom prompts are deprecated). The overlay system is Claude-only by mechanism; the cross-tool flat skill model (v3.7.0) continues to handle Codex.
|
|
60
|
+
- **Skill overlay refactor**: skills today use runtime concatenation (SKILL.md body reads overlay), which works. Migrating skills to compile-time merge is opt-in for a future release.
|
|
61
|
+
- **Drift-section diff during update**: structural support is in place via `base_agent_version` in overlay frontmatter and `base_version` in generated marker, but the actual section-level diff against the previous-version base content is a future enhancement (the framework just emits a one-line "overlay vs vX → vY" warning today).
|
|
62
|
+
|
|
63
|
+
## [3.7.1] - 2026-05-22
|
|
64
|
+
|
|
65
|
+
`baldart update` now actively migrates pre-v3.7.0 configs to the new multi-tool layout instead of silently leaving them on `[claude]` only.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **`src/commands/update.js` legacy-config migration** — after the subtree pull, if `baldart.config.yml` exists but `tools.enabled` is missing/empty, the CLI now:
|
|
70
|
+
1. Detects the legacy state.
|
|
71
|
+
2. Surfaces the new capability and shows the autodetected tool list (Claude always, plus Codex if `~/.codex/` exists).
|
|
72
|
+
3. Asks: *"Backfill `tools.enabled: [claude, codex]` into baldart.config.yml?"*.
|
|
73
|
+
4. On accept: writes the array into the config AND re-runs `mergeSkills` for the new tools so `.agents/skills/` is populated immediately — not deferred to the next update.
|
|
74
|
+
|
|
75
|
+
Without this, a pre-v3.7.0 install upgrading to v3.7.x would never get the Codex skill dir populated, because the per-tool dispatch falls back to `['claude']` when `tools.enabled` is absent.
|
|
76
|
+
|
|
8
77
|
## [3.7.0] - 2026-05-22
|
|
9
78
|
|
|
10
79
|
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.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.8.0
|
|
@@ -158,6 +158,44 @@ function main() {
|
|
|
158
158
|
const filePath = input.tool_input && input.tool_input.file_path;
|
|
159
159
|
if (!filePath || typeof filePath !== 'string') safeExit();
|
|
160
160
|
|
|
161
|
+
// v3.8.0+: also block direct edits to BALDART-generated files (agents /
|
|
162
|
+
// commands generated from base + overlay). These live OUTSIDE .framework/
|
|
163
|
+
// but carry the baldart-generated marker comment as their first line.
|
|
164
|
+
// The user should edit the overlay, not the generated file, otherwise the
|
|
165
|
+
// next `baldart update` will overwrite their changes.
|
|
166
|
+
try {
|
|
167
|
+
const fs = require('fs');
|
|
168
|
+
if (fs.existsSync(filePath)) {
|
|
169
|
+
const head = fs.readFileSync(filePath, 'utf8').slice(0, 200);
|
|
170
|
+
if (head.startsWith('<!-- baldart-generated:')) {
|
|
171
|
+
// Allow ONLY if the edit explicitly retains the marker on line 1 —
|
|
172
|
+
// catches accidental overwrites while letting BALDART itself regenerate.
|
|
173
|
+
const newContent = extractNewContent(toolName, input.tool_input) || '';
|
|
174
|
+
if (!newContent.startsWith('<!-- baldart-generated:')) {
|
|
175
|
+
const reason =
|
|
176
|
+
`Cannot edit BALDART-generated file (${filePath}).\n` +
|
|
177
|
+
`This file is auto-generated by \`npx baldart update\` from a base ` +
|
|
178
|
+
`framework file plus an overlay. Manual edits will be overwritten.\n\n` +
|
|
179
|
+
`To customise: edit the corresponding overlay under .baldart/overlays/` +
|
|
180
|
+
`(or create it if absent), then re-run \`npx baldart update\`.`;
|
|
181
|
+
try {
|
|
182
|
+
process.stdout.write(JSON.stringify({
|
|
183
|
+
hookSpecificOutput: {
|
|
184
|
+
hookEventName: 'PreToolUse',
|
|
185
|
+
permissionDecision: 'deny',
|
|
186
|
+
permissionDecisionReason: reason
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
} catch (_) {
|
|
190
|
+
process.stderr.write(reason + '\n');
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (_) { /* fail-safe: do not block on hook internal errors */ }
|
|
198
|
+
|
|
161
199
|
const realPath = resolveReal(filePath);
|
|
162
200
|
if (!realPath) safeExit();
|
|
163
201
|
|
|
@@ -98,6 +98,65 @@ If `base_skill_version` in the overlay is older than the installed framework ver
|
|
|
98
98
|
|
|
99
99
|
This is non-blocking by design — silent overrides are worse than noisy ones.
|
|
100
100
|
|
|
101
|
+
## 5.bis) Overlays for agents and commands (v3.8.0+)
|
|
102
|
+
|
|
103
|
+
Skill overlays use **runtime concatenation** — the SKILL.md body instructs Claude to read `.baldart/overlays/<skill>.md`. Agent and command files do not have that affordance (Claude Code reads a subagent file as a single unit), so BALDART uses a different model: **compile-time merge**.
|
|
104
|
+
|
|
105
|
+
### Layout
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
.baldart/overlays/
|
|
109
|
+
├── ui-design.md (skill overlay — runtime, unchanged)
|
|
110
|
+
├── agents/
|
|
111
|
+
│ └── coder.md (agent overlay — compile-time merge)
|
|
112
|
+
└── commands/
|
|
113
|
+
└── codexreview.md (command overlay — compile-time merge)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Frontmatter (mandatory for agent / command overlays)
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
---
|
|
120
|
+
base_agent: coder # or base_command: codexreview
|
|
121
|
+
base_agent_version: 3.8.0 # framework version when the overlay was authored
|
|
122
|
+
mode: extend # extend | replace
|
|
123
|
+
---
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Section markers in the overlay body
|
|
127
|
+
|
|
128
|
+
- `## [OVERRIDE] <heading>` — replace the matching `## <heading>` of the base verbatim.
|
|
129
|
+
- `## [APPEND] <heading>` — add content after the base section.
|
|
130
|
+
- `## [PREPEND] <heading>` — add content before the base section.
|
|
131
|
+
- `## <heading>` (no marker) — append as a new H2 at the end of the merged file.
|
|
132
|
+
|
|
133
|
+
If the overlay targets a heading that no longer exists in the base (e.g. it was renamed upstream), BALDART emits a warning at update time and falls back to appending the overlay block as a new section, so content is never silently dropped.
|
|
134
|
+
|
|
135
|
+
### Generation
|
|
136
|
+
|
|
137
|
+
At `baldart add` / `baldart update`, for each framework agent/command file `<x>.md`:
|
|
138
|
+
|
|
139
|
+
- No overlay → BALDART installs a **per-item symlink** in `.claude/agents/<x>.md` (or `.claude/commands/<x>.md`) pointing at the framework source.
|
|
140
|
+
- Overlay present → BALDART writes a **generated file** at `.claude/agents/<x>.md` containing base + overlay merged, with a leading marker:
|
|
141
|
+
|
|
142
|
+
```html
|
|
143
|
+
<!-- baldart-generated: kind=agent name=coder base_version=3.8.0 overlay_sha=ab12cd34
|
|
144
|
+
DO NOT EDIT MANUALLY. Edit .baldart/overlays/agents/coder.md instead;
|
|
145
|
+
this file is regenerated by `npx baldart update`. -->
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The base's frontmatter (`name`, `description`, `tools`, `model`) is preserved verbatim — the overlay's frontmatter is metadata for BALDART only and is NOT propagated to the generated file.
|
|
149
|
+
|
|
150
|
+
### Drift detection
|
|
151
|
+
|
|
152
|
+
When `base_agent_version` in the overlay is older than the installed framework version, BALDART compares the base sections the overlay overrides between the two versions. If a targeted section has changed upstream, `baldart update` emits a one-line warning per overlay and suggests bumping `base_agent_version` after reviewing the new base content.
|
|
153
|
+
|
|
154
|
+
### Hard rules
|
|
155
|
+
|
|
156
|
+
- Never edit `.claude/agents/<x>.md` or `.claude/commands/<x>.md` if it carries the `baldart-generated` marker — your edits will be overwritten on the next update. Edit the overlay instead. The `framework-edit-gate` hook blocks direct edits to generated files.
|
|
157
|
+
- Overlay files (`.baldart/overlays/**`) live in the consumer repo only. `baldart push` operates on `.framework/...` exclusively, so overlays are structurally excluded from upstream contributions.
|
|
158
|
+
- A user-authored agent/command with NO framework counterpart (e.g. `.claude/agents/mayo-billing-expert.md`) is left alone forever. Per-item merge only touches names that exist in the framework.
|
|
159
|
+
|
|
101
160
|
## 6) Autodetection (used by `baldart configure`)
|
|
102
161
|
|
|
103
162
|
`npx baldart configure` populates `baldart.config.yml` by probing the filesystem. The complete probe table (source of truth: `src/commands/configure.js` `detect()`):
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
base_agent: coder
|
|
3
|
+
base_agent_version: 3.8.0
|
|
4
|
+
mode: extend
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
> **How to use this example**
|
|
8
|
+
>
|
|
9
|
+
> Copy this file to `.baldart/overlays/agents/coder.md` (drop the `.example`)
|
|
10
|
+
> and adapt it to your project. The framework will regenerate
|
|
11
|
+
> `.claude/agents/coder.md` from base + overlay on every `npx baldart update`.
|
|
12
|
+
>
|
|
13
|
+
> Section markers:
|
|
14
|
+
> - `## [OVERRIDE] <heading>` — replace the entire matching H2 from the base.
|
|
15
|
+
> - `## [APPEND] <heading>` — add content after the base section.
|
|
16
|
+
> - `## [PREPEND] <heading>` — add content before the base section.
|
|
17
|
+
> - `## <heading>` with no marker — appended at the end as a new custom section.
|
|
18
|
+
>
|
|
19
|
+
> The base's frontmatter (`name`, `description`, `tools`, `model`) is preserved
|
|
20
|
+
> automatically — do NOT redeclare it in the overlay.
|
|
21
|
+
|
|
22
|
+
## [APPEND] Quality bar
|
|
23
|
+
|
|
24
|
+
Project-specific addition that lives in your repo only:
|
|
25
|
+
|
|
26
|
+
- Before closing any task, run `npm run test:e2e -- --grep <feature>`.
|
|
27
|
+
- Attach the Playwright screenshot to the commit body.
|
|
28
|
+
- Never push to `develop` without the QA label on the PR.
|
|
29
|
+
|
|
30
|
+
## [OVERRIDE] When to escalate
|
|
31
|
+
|
|
32
|
+
In this project, escalate to the human owner BEFORE making any change that
|
|
33
|
+
touches `prisma/schema.prisma`, `next.config.ts`, or anything under
|
|
34
|
+
`src/lib/billing/`. These paths are policy-protected.
|
|
35
|
+
|
|
36
|
+
## Project-only conventions
|
|
37
|
+
|
|
38
|
+
Custom rules with no equivalent in the base — appended as a new H2:
|
|
39
|
+
|
|
40
|
+
- All API calls go through `src/lib/api-client.ts`. Never call `fetch()` directly.
|
|
41
|
+
- Logging always via `src/lib/observability/logger.ts` (no `console.log` in
|
|
42
|
+
production code paths).
|
|
43
|
+
- Feature flags live in `src/lib/flags/` and are read via `useFlag(name)`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
base_command: codexreview
|
|
3
|
+
base_command_version: 3.8.0
|
|
4
|
+
mode: extend
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
> Copy to `.baldart/overlays/commands/codexreview.md` and adapt freely.
|
|
8
|
+
> Same merge model as agent overlays: `[OVERRIDE]` / `[APPEND]` / `[PREPEND]`
|
|
9
|
+
> with H2 heading matching, plus untagged H2 sections appended at the end.
|
|
10
|
+
|
|
11
|
+
## [APPEND] Project-specific gates
|
|
12
|
+
|
|
13
|
+
Before approving a PR in this repo:
|
|
14
|
+
|
|
15
|
+
1. Verify GitHub status check `mayo-build` is green.
|
|
16
|
+
2. Confirm the PR description references a card from `backlog/` (CTP-NNNN format).
|
|
17
|
+
3. If the PR touches `src/lib/billing/` request a second reviewer.
|
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -78,11 +78,17 @@ function configSchemaDrift(config, template) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
function countOverlays(cwd) {
|
|
81
|
-
const
|
|
82
|
-
if (!fileExists(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
const root = path.join(cwd, '.baldart', 'overlays');
|
|
82
|
+
if (!fileExists(root)) return 0;
|
|
83
|
+
// Skill overlays at root, agent overlays under agents/, command under commands/.
|
|
84
|
+
const counts = (sub) => {
|
|
85
|
+
const d = sub ? path.join(root, sub) : root;
|
|
86
|
+
if (!fileExists(d)) return 0;
|
|
87
|
+
try {
|
|
88
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.md') && !f.endsWith('.example.md')).length;
|
|
89
|
+
} catch (_) { return 0; }
|
|
90
|
+
};
|
|
91
|
+
return counts('') + counts('agents') + counts('commands');
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
function overlayDrift(cwd, frameworkVersion) {
|
package/src/commands/migrate.js
CHANGED
|
@@ -128,6 +128,9 @@ async function migrate() {
|
|
|
128
128
|
}
|
|
129
129
|
} catch (_) { /* keep default */ }
|
|
130
130
|
const mergeResult = symlinks.mergeSkills({ tools: enabledTools });
|
|
131
|
+
// v3.8.0+: also per-item merge agents and commands (was bulk symlink before).
|
|
132
|
+
symlinks.mergeAgents({ tools: enabledTools });
|
|
133
|
+
symlinks.mergeCommands({ tools: enabledTools });
|
|
131
134
|
UI.info(`Linked ${mergeResult.linked.length} framework skills, kept ${mergeResult.skipped.length} as-is, ${mergeResult.conflicts.length} conflict(s).`);
|
|
132
135
|
|
|
133
136
|
// --- Step 3: restore user skills from .backup --------------------------
|
package/src/commands/update.js
CHANGED
|
@@ -329,9 +329,11 @@ async function update(options = {}) {
|
|
|
329
329
|
await symlinks.createAllSymlinks({ mode: 'prompt', tools: enabledTools });
|
|
330
330
|
}
|
|
331
331
|
} else {
|
|
332
|
-
// Re-run the per-item
|
|
333
|
-
// skills are linked in each tool's expected directory.
|
|
332
|
+
// Re-run the per-item merges for every enabled tool so newly-shipped
|
|
333
|
+
// skills/agents/commands are linked in each tool's expected directory.
|
|
334
334
|
symlinks.mergeSkills({ tools: enabledTools });
|
|
335
|
+
symlinks.mergeAgents({ tools: enabledTools });
|
|
336
|
+
symlinks.mergeCommands({ tools: enabledTools });
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
// Routines wizard (since v2.1.0) — surfaces routines added in the new framework version
|
|
@@ -370,17 +372,47 @@ async function update(options = {}) {
|
|
|
370
372
|
await configureCmd();
|
|
371
373
|
}
|
|
372
374
|
} else {
|
|
373
|
-
//
|
|
375
|
+
// Schema migration: backfill `tools.enabled` for configs created
|
|
376
|
+
// before v3.7.0 (the multi-tool release). Without this, a legacy
|
|
377
|
+
// install that has Codex on the machine would never get .agents/skills/
|
|
378
|
+
// populated, because update would keep defaulting to ['claude'].
|
|
374
379
|
const yaml = require('js-yaml');
|
|
380
|
+
let needsRewrite = false;
|
|
381
|
+
let cur = {};
|
|
382
|
+
try { cur = yaml.load(fs.readFileSync(configPath, 'utf8')) || {}; } catch (_) { cur = null; }
|
|
383
|
+
|
|
384
|
+
if (cur && (!cur.tools || !Array.isArray(cur.tools.enabled) || cur.tools.enabled.length === 0)) {
|
|
385
|
+
const toolAdapters = require('../utils/tool-adapters');
|
|
386
|
+
const suggested = toolAdapters.defaultEnabled();
|
|
387
|
+
UI.newline();
|
|
388
|
+
UI.warning('Pre-v3.7.0 config detected — `tools.enabled` is missing.');
|
|
389
|
+
UI.info(`From v3.7.0, BALDART can install framework skills for multiple AI tools (Claude Code, OpenAI Codex CLI, …) from the same source.`);
|
|
390
|
+
UI.info(`Autodetected on this machine: ${suggested.join(', ')}`);
|
|
391
|
+
const ok = await UI.confirm(`Backfill \`tools.enabled: [${suggested.join(', ')}]\` into baldart.config.yml?`, true);
|
|
392
|
+
if (ok) {
|
|
393
|
+
cur.tools = cur.tools || {};
|
|
394
|
+
cur.tools.enabled = suggested;
|
|
395
|
+
fs.writeFileSync(configPath, yaml.dump(cur, { lineWidth: 100, noRefs: true }));
|
|
396
|
+
UI.success('Backfilled tools.enabled. Re-running the symlink reconcile to install per-tool skill dirs…');
|
|
397
|
+
// Re-run merge so the just-backfilled tools get their skill dirs populated now.
|
|
398
|
+
try { symlinks.mergeSkills({ tools: suggested }); }
|
|
399
|
+
catch (err) { UI.warning(`Skill merge for new tools failed: ${err.message}`); }
|
|
400
|
+
needsRewrite = true;
|
|
401
|
+
} else {
|
|
402
|
+
UI.info('Skipped. You can add it manually under `tools:` in baldart.config.yml or rerun `npx baldart configure`.');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Detect schema drift on other keys (features.*, paths.*).
|
|
375
407
|
const templatePath = path.join('.framework', 'framework', 'templates', 'baldart.config.template.yml');
|
|
376
408
|
if (fs.existsSync(templatePath)) {
|
|
377
409
|
try {
|
|
378
410
|
const tpl = yaml.load(fs.readFileSync(templatePath, 'utf8')) || {};
|
|
379
|
-
const
|
|
411
|
+
const cur2 = needsRewrite ? cur : (yaml.load(fs.readFileSync(configPath, 'utf8')) || {});
|
|
380
412
|
const missingFeatures = Object.keys(tpl.features || {})
|
|
381
|
-
.filter((k) => !(k in (
|
|
413
|
+
.filter((k) => !(k in (cur2.features || {})));
|
|
382
414
|
const missingPaths = Object.keys(tpl.paths || {})
|
|
383
|
-
.filter((k) => !(k in (
|
|
415
|
+
.filter((k) => !(k in (cur2.paths || {})));
|
|
384
416
|
if (missingFeatures.length || missingPaths.length) {
|
|
385
417
|
UI.newline();
|
|
386
418
|
UI.warning(
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const yaml = require('js-yaml');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Overlay merge engine for agents and commands.
|
|
6
|
+
*
|
|
7
|
+
* Reads a base markdown file (from the framework) plus an overlay file
|
|
8
|
+
* (from `.baldart/overlays/<kind>/<name>.md`) and produces the merged
|
|
9
|
+
* content that BALDART writes into `.claude/<kind>s/<name>.md` at
|
|
10
|
+
* `add`/`update` time.
|
|
11
|
+
*
|
|
12
|
+
* Section model
|
|
13
|
+
* -------------
|
|
14
|
+
* - The base is split into H2 sections (`^## <heading>` … up to next H2/EOF).
|
|
15
|
+
* - The overlay body uses three section markers:
|
|
16
|
+
* `## [OVERRIDE] <heading>` → replace base section <heading> verbatim
|
|
17
|
+
* `## [APPEND] <heading>` → append overlay block after base section
|
|
18
|
+
* `## [PREPEND] <heading>` → prepend overlay block before base section
|
|
19
|
+
* Sections in the overlay without a marker are appended at the end of the
|
|
20
|
+
* merged file as new H2 sections (custom additions).
|
|
21
|
+
*
|
|
22
|
+
* - The base file's frontmatter (YAML between leading `---` fences) is
|
|
23
|
+
* preserved untouched in the output — Claude Code uses `name`, `description`,
|
|
24
|
+
* `tools`, `model` from it. Overlay frontmatter is metadata FOR BALDART only
|
|
25
|
+
* (`base_agent`, `base_agent_version`, `mode`) and is NOT propagated.
|
|
26
|
+
*
|
|
27
|
+
* - The output gets a BALDART marker comment as the first line (above the
|
|
28
|
+
* frontmatter) so `update` can detect that the file is regeneration-safe.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const MARKER_PREFIX = '<!-- baldart-generated:';
|
|
32
|
+
|
|
33
|
+
function shortSha(content) {
|
|
34
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildMarker({ kind, name, baseVersion, overlaySha }) {
|
|
38
|
+
return `${MARKER_PREFIX} kind=${kind} name=${name} base_version=${baseVersion} overlay_sha=${overlaySha}
|
|
39
|
+
DO NOT EDIT MANUALLY. Edit .baldart/overlays/${kind}s/${name}.md instead;
|
|
40
|
+
this file is regenerated by \`npx baldart update\`. -->`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the marker fields encoded in the first line if present, or null.
|
|
45
|
+
* Used by `update` to know "is this file ours (safe to regenerate) or did the
|
|
46
|
+
* user edit it manually?"
|
|
47
|
+
*/
|
|
48
|
+
function readMarker(content) {
|
|
49
|
+
const firstLine = content.split('\n').slice(0, 3).join('\n');
|
|
50
|
+
if (!firstLine.includes(MARKER_PREFIX)) return null;
|
|
51
|
+
const m = firstLine.match(/kind=(\S+) name=(\S+) base_version=(\S+) overlay_sha=(\S+)/);
|
|
52
|
+
if (!m) return null;
|
|
53
|
+
return { kind: m[1], name: m[2], baseVersion: m[3], overlaySha: m[4] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split markdown text into:
|
|
58
|
+
* - frontmatter: the raw text between leading `---` fences (or '')
|
|
59
|
+
* - preamble: any text after frontmatter before the first H2
|
|
60
|
+
* - sections: ordered list of { heading, marker, body }
|
|
61
|
+
* marker is null | 'OVERRIDE' | 'APPEND' | 'PREPEND'
|
|
62
|
+
*/
|
|
63
|
+
function parseMarkdown(text) {
|
|
64
|
+
let frontmatter = '';
|
|
65
|
+
let rest = text;
|
|
66
|
+
|
|
67
|
+
// Strip leading BOM / whitespace
|
|
68
|
+
rest = rest.replace(/^/, '');
|
|
69
|
+
|
|
70
|
+
const fmMatch = rest.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
71
|
+
if (fmMatch) {
|
|
72
|
+
frontmatter = fmMatch[1];
|
|
73
|
+
rest = rest.slice(fmMatch[0].length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Split on H2 boundaries. Keep delimiters by using a lookahead.
|
|
77
|
+
const parts = rest.split(/(?=^## )/m);
|
|
78
|
+
const preamble = (parts.length && !parts[0].startsWith('## ')) ? parts.shift() : '';
|
|
79
|
+
|
|
80
|
+
const sections = parts.map((block) => {
|
|
81
|
+
// Heading line
|
|
82
|
+
const eol = block.indexOf('\n');
|
|
83
|
+
const headingLine = eol >= 0 ? block.slice(0, eol) : block;
|
|
84
|
+
const body = eol >= 0 ? block.slice(eol + 1) : '';
|
|
85
|
+
|
|
86
|
+
// Detect marker — `## [OVERRIDE] Foo` etc. Marker keyword is upper-case
|
|
87
|
+
// between square brackets, then a single space, then the heading text.
|
|
88
|
+
const m = headingLine.match(/^##\s+\[(OVERRIDE|APPEND|PREPEND)\]\s+(.+?)\s*$/);
|
|
89
|
+
if (m) {
|
|
90
|
+
return { marker: m[1], heading: m[2].trim(), body: body.replace(/\n+$/, '') + '\n' };
|
|
91
|
+
}
|
|
92
|
+
const plain = headingLine.match(/^##\s+(.+?)\s*$/);
|
|
93
|
+
return { marker: null, heading: plain ? plain[1].trim() : '', body: body.replace(/\n+$/, '') + '\n' };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { frontmatter, preamble, sections };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rebuildSections(sections) {
|
|
100
|
+
return sections.map((s) => `## ${s.heading}\n${s.body}`).join('\n').replace(/\n+$/, '\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Merge base + overlay → generated content + warnings.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} args
|
|
107
|
+
* @param {string} args.kind - 'agent' | 'command'
|
|
108
|
+
* @param {string} args.name - identifier (filename without extension)
|
|
109
|
+
* @param {string} args.baseContent - raw markdown of the framework base
|
|
110
|
+
* @param {string} args.overlayContent - raw markdown of the overlay
|
|
111
|
+
* @param {string} args.frameworkVersion - the installed framework VERSION
|
|
112
|
+
* @returns {{ generated: string, warnings: string[], baseVersionTargeted: string, overlayMode: string }}
|
|
113
|
+
*/
|
|
114
|
+
function mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion }) {
|
|
115
|
+
const warnings = [];
|
|
116
|
+
|
|
117
|
+
// Parse overlay frontmatter for metadata.
|
|
118
|
+
const overlayParsed = parseMarkdown(overlayContent);
|
|
119
|
+
let overlayMeta = {};
|
|
120
|
+
if (overlayParsed.frontmatter) {
|
|
121
|
+
try { overlayMeta = yaml.load(overlayParsed.frontmatter) || {}; }
|
|
122
|
+
catch (err) {
|
|
123
|
+
warnings.push(`overlay frontmatter parse error (${err.message}) — treating overlay as plain markdown without metadata`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const baseVersionTargeted = String(overlayMeta[`base_${kind}_version`] || '').trim() || 'unknown';
|
|
127
|
+
const overlayMode = (overlayMeta.mode || 'extend').trim();
|
|
128
|
+
|
|
129
|
+
// Parse the base. Keep its frontmatter verbatim — that's what Claude reads.
|
|
130
|
+
const baseParsed = parseMarkdown(baseContent);
|
|
131
|
+
|
|
132
|
+
// Build a heading → section map for the base. If headings repeat we keep
|
|
133
|
+
// the first occurrence (Claude Code does the same for repeated `name:`).
|
|
134
|
+
const baseSectionByHeading = new Map();
|
|
135
|
+
baseParsed.sections.forEach((s, idx) => {
|
|
136
|
+
if (s.heading && !baseSectionByHeading.has(s.heading)) {
|
|
137
|
+
baseSectionByHeading.set(s.heading, { ...s, _idx: idx });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Apply overlay sections.
|
|
142
|
+
const newSections = baseParsed.sections.map((s) => ({ ...s }));
|
|
143
|
+
const trailing = []; // overlay sections without marker → custom adds at the end
|
|
144
|
+
|
|
145
|
+
for (const ov of overlayParsed.sections) {
|
|
146
|
+
if (!ov.marker) {
|
|
147
|
+
trailing.push(ov);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const base = baseSectionByHeading.get(ov.heading);
|
|
151
|
+
if (!base) {
|
|
152
|
+
warnings.push(`overlay section "${ov.marker} ${ov.heading}" has no matching H2 in base ${kind} "${name}" — appending as new section instead`);
|
|
153
|
+
trailing.push({ marker: null, heading: ov.heading, body: ov.body });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const target = newSections[base._idx];
|
|
157
|
+
if (ov.marker === 'OVERRIDE') {
|
|
158
|
+
target.body = ov.body;
|
|
159
|
+
} else if (ov.marker === 'APPEND') {
|
|
160
|
+
target.body = `${target.body.replace(/\n+$/, '')}\n\n${ov.body}`;
|
|
161
|
+
} else if (ov.marker === 'PREPEND') {
|
|
162
|
+
target.body = `${ov.body.replace(/\n+$/, '')}\n\n${target.body}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Compose the merged body.
|
|
167
|
+
const sectionsOut = rebuildSections(newSections);
|
|
168
|
+
const trailingOut = trailing.length
|
|
169
|
+
? '\n' + trailing.map((s) => `## ${s.heading}\n${s.body}`).join('\n').replace(/\n+$/, '\n')
|
|
170
|
+
: '';
|
|
171
|
+
|
|
172
|
+
const overlaySha = shortSha(overlayContent);
|
|
173
|
+
const marker = buildMarker({
|
|
174
|
+
kind,
|
|
175
|
+
name,
|
|
176
|
+
baseVersion: frameworkVersion || baseVersionTargeted,
|
|
177
|
+
overlaySha
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const fmBlock = baseParsed.frontmatter
|
|
181
|
+
? `---\n${baseParsed.frontmatter}\n---\n`
|
|
182
|
+
: '';
|
|
183
|
+
|
|
184
|
+
const generated =
|
|
185
|
+
`${marker}\n` +
|
|
186
|
+
fmBlock +
|
|
187
|
+
(baseParsed.preamble || '') +
|
|
188
|
+
sectionsOut +
|
|
189
|
+
trailingOut;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
generated,
|
|
193
|
+
warnings,
|
|
194
|
+
baseVersionTargeted,
|
|
195
|
+
overlayMode,
|
|
196
|
+
overlaySha
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
mergeOverlay,
|
|
202
|
+
readMarker,
|
|
203
|
+
parseMarkdown,
|
|
204
|
+
shortSha,
|
|
205
|
+
MARKER_PREFIX
|
|
206
|
+
};
|
package/src/utils/symlinks.js
CHANGED
|
@@ -126,6 +126,9 @@ class SymlinkUtils {
|
|
|
126
126
|
async createBulkSymlinks(opts = {}) {
|
|
127
127
|
const mode = opts.mode || 'prompt';
|
|
128
128
|
|
|
129
|
+
// AGENTS.md and the cross-tool `agents/` reference dir remain BULK
|
|
130
|
+
// symlinks — they're single canonical artifacts of the framework that
|
|
131
|
+
// do not need per-item user-overlay behaviour.
|
|
129
132
|
await this.createSymlink(
|
|
130
133
|
path.join(FRAMEWORK_PAYLOAD, 'AGENTS.md'),
|
|
131
134
|
'AGENTS.md',
|
|
@@ -140,19 +143,29 @@ class SymlinkUtils {
|
|
|
140
143
|
|
|
141
144
|
this.ensureDirectory('.claude');
|
|
142
145
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
);
|
|
146
|
+
// NB: `.claude/agents/` and `.claude/commands/` are NO LONGER bulk
|
|
147
|
+
// symlinks since v3.8.0 — they're real directories with per-item
|
|
148
|
+
// symlinks (or overlay-generated real files). Populated by
|
|
149
|
+
// mergeAgents() and mergeCommands() which createAllSymlinks() invokes
|
|
150
|
+
// right after this method.
|
|
151
|
+
this._convertLegacyBulkSymlinkToDir(path.join('.claude', 'agents'));
|
|
152
|
+
this._convertLegacyBulkSymlinkToDir(path.join('.claude', 'commands'));
|
|
153
|
+
}
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
/**
|
|
156
|
+
* If `rel` exists and is a symlink (the v3.7.x and earlier bulk-symlink
|
|
157
|
+
* layout for .claude/agents and .claude/commands), unlink it so that the
|
|
158
|
+
* per-item merge can populate a real directory in its place. Legacy migration.
|
|
159
|
+
*/
|
|
160
|
+
_convertLegacyBulkSymlinkToDir(rel) {
|
|
161
|
+
const full = path.join(this.cwd, rel);
|
|
162
|
+
let lstat = null;
|
|
163
|
+
try { lstat = fs.lstatSync(full); } catch (_) { /* absent */ }
|
|
164
|
+
if (lstat && lstat.isSymbolicLink()) {
|
|
165
|
+
UI.warning(`Detected legacy bulk symlink at ${rel}. Converting to per-item layout (v3.8.0+).`);
|
|
166
|
+
fs.unlinkSync(full);
|
|
167
|
+
}
|
|
168
|
+
this.ensureDirectory(rel);
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
// -----------------------------------------------------------------------
|
|
@@ -305,14 +318,198 @@ class SymlinkUtils {
|
|
|
305
318
|
this.mergeSkills({ tools: opts.tools });
|
|
306
319
|
|
|
307
320
|
UI.newline();
|
|
321
|
+
UI.section('Merging Framework Agents');
|
|
322
|
+
this.mergeAgents({ tools: opts.tools });
|
|
323
|
+
|
|
324
|
+
UI.newline();
|
|
325
|
+
UI.section('Merging Framework Commands');
|
|
326
|
+
this.mergeCommands({ tools: opts.tools });
|
|
327
|
+
|
|
328
|
+
UI.newline();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -----------------------------------------------------------------------
|
|
332
|
+
// Per-item merge for agents and commands (v3.8.0+)
|
|
333
|
+
//
|
|
334
|
+
// Same model as skills (per-item, user files coexist, broken-symlink-safe)
|
|
335
|
+
// PLUS overlay support: if `.baldart/overlays/<kind>s/<name>.md` exists,
|
|
336
|
+
// BALDART generates a real file `.claude/<kind>s/<name>.md` = base + overlay
|
|
337
|
+
// merged, with a marker header so future updates know it's regeneration-safe.
|
|
338
|
+
// -----------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
mergeAgents(opts = {}) { return this._mergeBulkDir('agent', opts); }
|
|
341
|
+
mergeCommands(opts = {}) { return this._mergeBulkDir('command', opts); }
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Generic per-item merge for kinds whose framework source is a directory
|
|
345
|
+
* of single .md files (agents, commands). For each enabled tool whose
|
|
346
|
+
* adapter declares a target directory (claude.agentsDir(), etc.), this
|
|
347
|
+
* either symlinks each base file or — when an overlay exists — writes a
|
|
348
|
+
* generated real file with the overlay applied.
|
|
349
|
+
*/
|
|
350
|
+
_mergeBulkDir(kind, opts = {}) {
|
|
351
|
+
const tools = (opts.tools && opts.tools.length) ? opts.tools : ['claude'];
|
|
352
|
+
const aggregate = { linked: [], generated: [], skipped: [], conflicts: [], warnings: [] };
|
|
353
|
+
const { getAdapter } = require('./tool-adapters');
|
|
354
|
+
|
|
355
|
+
const fwSourceDir = path.join(this.cwd, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`);
|
|
356
|
+
if (!fs.existsSync(fwSourceDir)) {
|
|
357
|
+
UI.warning(`No framework ${kind}s found at ${path.relative(this.cwd, fwSourceDir)}. Skipping ${kind} merge.`);
|
|
358
|
+
return aggregate;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const fwItems = fs.readdirSync(fwSourceDir)
|
|
362
|
+
.filter((n) => n.endsWith('.md') && !n.startsWith('.') && n !== 'REGISTRY.md');
|
|
363
|
+
|
|
364
|
+
const frameworkVersion = this._readFrameworkVersion();
|
|
365
|
+
|
|
366
|
+
for (const tool of tools) {
|
|
367
|
+
const adapter = getAdapter(tool, this.cwd);
|
|
368
|
+
const dirGetter = kind === 'agent' ? adapter.agentsDir : adapter.commandsDir;
|
|
369
|
+
const targetRel = dirGetter ? dirGetter.call(adapter) : null;
|
|
370
|
+
if (!targetRel) continue; // tool doesn't support this kind (e.g. Codex)
|
|
371
|
+
|
|
372
|
+
this.ensureDirectory(targetRel);
|
|
373
|
+
|
|
374
|
+
for (const filename of fwItems) {
|
|
375
|
+
const baseName = filename.replace(/\.md$/, '');
|
|
376
|
+
const linkPath = path.join(this.cwd, targetRel, filename);
|
|
377
|
+
const fwAbsolute = path.join(fwSourceDir, filename);
|
|
378
|
+
|
|
379
|
+
// Compute the relative-from-link path used by the symlink so it's
|
|
380
|
+
// portable if the project gets moved.
|
|
381
|
+
const linkDepth = targetRel.split(path.sep).filter(Boolean).length;
|
|
382
|
+
const upPath = path.join(...Array(linkDepth).fill('..'));
|
|
383
|
+
const target = path.join(upPath, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`, filename);
|
|
384
|
+
|
|
385
|
+
const overlayRel = path.join('.baldart', 'overlays', `${kind}s`, filename);
|
|
386
|
+
const overlayAbs = path.join(this.cwd, overlayRel);
|
|
387
|
+
const hasOverlay = fs.existsSync(overlayAbs);
|
|
388
|
+
|
|
389
|
+
// Inspect what currently lives at the link path (if anything).
|
|
390
|
+
let lstat = null;
|
|
391
|
+
try { lstat = fs.lstatSync(linkPath); } catch (_) { /* absent */ }
|
|
392
|
+
|
|
393
|
+
if (hasOverlay) {
|
|
394
|
+
// Generate a real file from base + overlay.
|
|
395
|
+
this._generateOverlayedFile({
|
|
396
|
+
kind, name: baseName, fwAbsolute, overlayAbs, linkPath, lstat,
|
|
397
|
+
frameworkVersion, aggregate, adapter, targetRel
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
// Plain symlink, with broken-symlink recovery (same fix-pattern as v3.5.1).
|
|
401
|
+
this._installPerItemSymlink({
|
|
402
|
+
linkPath, target, fwAbsolute, lstat, baseName, filename,
|
|
403
|
+
aggregate, adapter, targetRel, kind
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return aggregate;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_installPerItemSymlink({ linkPath, target, fwAbsolute, lstat, baseName, filename, aggregate, adapter, targetRel, kind }) {
|
|
413
|
+
if (!lstat) {
|
|
414
|
+
fs.symlinkSync(target, linkPath);
|
|
415
|
+
UI.success(`[${adapter.label}] ${kind} linked: ${path.join(targetRel, filename)}`);
|
|
416
|
+
aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (lstat.isSymbolicLink()) {
|
|
420
|
+
const current = fs.readlinkSync(linkPath);
|
|
421
|
+
const resolved = path.resolve(path.dirname(linkPath), current);
|
|
422
|
+
if (current === target || resolved === fwAbsolute) {
|
|
423
|
+
aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'already-linked' });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Broken or pointing elsewhere — replace if broken, else respect user override
|
|
427
|
+
if (!fs.existsSync(linkPath)) {
|
|
428
|
+
fs.unlinkSync(linkPath);
|
|
429
|
+
fs.symlinkSync(target, linkPath);
|
|
430
|
+
UI.success(`[${adapter.label}] ${kind} re-linked (was broken): ${path.join(targetRel, filename)}`);
|
|
431
|
+
aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
UI.info(`[${adapter.label}] ${kind} kept (user override symlink): ${path.join(targetRel, filename)} → ${current}`);
|
|
435
|
+
aggregate.skipped.push({ tool: adapter.name, kind, name: baseName, reason: 'user-symlink-override' });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Real file. Could be:
|
|
439
|
+
// (a) A baldart-generated file from a previous run where overlay USED to
|
|
440
|
+
// exist but now doesn't — safe to replace with symlink.
|
|
441
|
+
// (b) A user-authored fork — leave alone.
|
|
442
|
+
const { readMarker } = require('./overlay-merger');
|
|
443
|
+
const content = fs.readFileSync(linkPath, 'utf8');
|
|
444
|
+
const marker = readMarker(content);
|
|
445
|
+
if (marker && marker.kind === kind && marker.name === baseName) {
|
|
446
|
+
// Overlay was removed → revert to symlink.
|
|
447
|
+
UI.info(`[${adapter.label}] ${kind} overlay removed — replacing generated file with symlink: ${path.join(targetRel, filename)}`);
|
|
448
|
+
fs.unlinkSync(linkPath);
|
|
449
|
+
fs.symlinkSync(target, linkPath);
|
|
450
|
+
aggregate.linked.push({ tool: adapter.name, kind, name: baseName });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
UI.warning(`[${adapter.label}] ${kind} name conflict (user file at ${path.join(targetRel, filename)}). Framework version NOT installed. Move user content to .baldart/overlays/${kind}s/${filename} to specialize without losing upstream updates.`);
|
|
454
|
+
aggregate.conflicts.push({
|
|
455
|
+
tool: adapter.name, kind, name: baseName,
|
|
456
|
+
local_path: path.join(targetRel, filename),
|
|
457
|
+
framework_path: path.relative(this.cwd, fwAbsolute),
|
|
458
|
+
detected_at: new Date().toISOString()
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
_generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
|
|
463
|
+
const { mergeOverlay, readMarker } = require('./overlay-merger');
|
|
464
|
+
const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
|
|
465
|
+
const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
|
|
466
|
+
const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
|
|
467
|
+
|
|
468
|
+
if (merged.warnings && merged.warnings.length) {
|
|
469
|
+
merged.warnings.forEach((w) => {
|
|
470
|
+
UI.warning(`[${adapter.label}] ${kind} ${name}: ${w}`);
|
|
471
|
+
aggregate.warnings.push({ tool: adapter.name, kind, name, warning: w });
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// If a previously generated file is on disk, check ownership via marker
|
|
476
|
+
// before overwriting. If it lacks the marker (= user edited manually after
|
|
477
|
+
// a previous generation, or a pre-3.8 fork), refuse to overwrite.
|
|
478
|
+
if (lstat && !lstat.isSymbolicLink()) {
|
|
479
|
+
const existing = fs.readFileSync(linkPath, 'utf8');
|
|
480
|
+
const m = readMarker(existing);
|
|
481
|
+
if (!m) {
|
|
482
|
+
UI.warning(`[${adapter.label}] ${kind} ${name}: existing file at ${path.join(targetRel, name)}.md has no baldart-generated marker — NOT overwriting (user fork?). Move it aside (or merge it into the overlay) and re-run update.`);
|
|
483
|
+
aggregate.conflicts.push({
|
|
484
|
+
tool: adapter.name, kind, name,
|
|
485
|
+
local_path: path.join(targetRel, `${name}.md`),
|
|
486
|
+
reason: 'manual-edit-no-marker',
|
|
487
|
+
detected_at: new Date().toISOString()
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Replace any existing symlink/file with the freshly generated content.
|
|
494
|
+
if (lstat) {
|
|
495
|
+
try { fs.unlinkSync(linkPath); }
|
|
496
|
+
catch (_) { try { fs.rmSync(linkPath, { recursive: true, force: true }); } catch (_) { /* noop */ } }
|
|
497
|
+
}
|
|
498
|
+
fs.writeFileSync(linkPath, merged.generated);
|
|
499
|
+
UI.success(`[${adapter.label}] ${kind} generated (overlay applied): ${path.join(targetRel, name)}.md`);
|
|
500
|
+
aggregate.generated.push({ tool: adapter.name, kind, name, overlaySha: merged.overlaySha });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_readFrameworkVersion() {
|
|
504
|
+
const vPath = path.join(this.cwd, FRAMEWORK_DIR, 'VERSION');
|
|
505
|
+
try { return fs.readFileSync(vPath, 'utf8').trim(); }
|
|
506
|
+
catch { return 'unknown'; }
|
|
308
507
|
}
|
|
309
508
|
|
|
310
509
|
verifySymlinks() {
|
|
311
510
|
const bulkSymlinks = [
|
|
312
511
|
'AGENTS.md',
|
|
313
|
-
'agents'
|
|
314
|
-
'.claude/agents',
|
|
315
|
-
'.claude/commands'
|
|
512
|
+
'agents'
|
|
316
513
|
];
|
|
317
514
|
|
|
318
515
|
let allValid = true;
|
|
@@ -385,6 +582,39 @@ class SymlinkUtils {
|
|
|
385
582
|
UI.warning(`[${adapter.label}] Missing: ${skillsRel}/ (the framework would merge skills here)`);
|
|
386
583
|
allValid = false;
|
|
387
584
|
}
|
|
585
|
+
|
|
586
|
+
// v3.8.0+: agents and commands also use per-item merge with overlay support.
|
|
587
|
+
for (const [kindGetter, kindLabel] of [['agentsDir', 'agents'], ['commandsDir', 'commands']]) {
|
|
588
|
+
if (typeof adapter[kindGetter] !== 'function') continue;
|
|
589
|
+
const rel = adapter[kindGetter]();
|
|
590
|
+
if (!rel) continue;
|
|
591
|
+
const full = path.join(this.cwd, rel);
|
|
592
|
+
let s = null;
|
|
593
|
+
try { s = fs.lstatSync(full); } catch (_) { /* absent */ }
|
|
594
|
+
if (!s) {
|
|
595
|
+
UI.warning(`[${adapter.label}] Missing: ${rel}/ (framework ${kindLabel} would be merged here)`);
|
|
596
|
+
allValid = false;
|
|
597
|
+
} else if (s.isSymbolicLink()) {
|
|
598
|
+
UI.warning(`[${adapter.label}] Legacy bulk symlink at ${rel}. Run \`npx baldart update\` to convert to per-item layout.`);
|
|
599
|
+
allValid = false;
|
|
600
|
+
} else {
|
|
601
|
+
const entries = fs.readdirSync(full);
|
|
602
|
+
const frameworkOwned = entries.filter((n) => {
|
|
603
|
+
const p = path.join(full, n);
|
|
604
|
+
try {
|
|
605
|
+
if (fs.lstatSync(p).isSymbolicLink()) return true;
|
|
606
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
607
|
+
return content.startsWith('<!-- baldart-generated:');
|
|
608
|
+
} catch { return false; }
|
|
609
|
+
});
|
|
610
|
+
if (frameworkOwned.length === 0) {
|
|
611
|
+
UI.warning(`[${adapter.label}] ${rel}/ has no framework-managed ${kindLabel}. Run \`npx baldart update\`.`);
|
|
612
|
+
allValid = false;
|
|
613
|
+
} else {
|
|
614
|
+
UI.success(`[${adapter.label}] Valid: ${rel}/ (${frameworkOwned.length} framework-managed ${kindLabel})`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
388
618
|
}
|
|
389
619
|
|
|
390
620
|
// Project configuration (v3.0.0+)
|
|
@@ -21,6 +21,12 @@ class ClaudeAdapter {
|
|
|
21
21
|
/** Where this tool reads skill bundles from. */
|
|
22
22
|
skillsDir() { return '.claude/skills'; }
|
|
23
23
|
|
|
24
|
+
/** Where this tool reads subagent definitions from (null if unsupported). */
|
|
25
|
+
agentsDir() { return '.claude/agents'; }
|
|
26
|
+
|
|
27
|
+
/** Where this tool reads slash commands from (null if unsupported). */
|
|
28
|
+
commandsDir() { return '.claude/commands'; }
|
|
29
|
+
|
|
24
30
|
/** Whether this tool consumes subagent definitions (`.claude/agents/`). */
|
|
25
31
|
supportsSubagents() { return true; }
|
|
26
32
|
|
|
@@ -33,6 +33,13 @@ class CodexAdapter {
|
|
|
33
33
|
|
|
34
34
|
skillsDir() { return '.agents/skills'; }
|
|
35
35
|
|
|
36
|
+
// Codex has no equivalent of Claude subagents or slash commands (custom
|
|
37
|
+
// prompts are deprecated in favor of skills). Returning null disables the
|
|
38
|
+
// per-item merge for those kinds — adapters that DO support them return
|
|
39
|
+
// the target directory string.
|
|
40
|
+
agentsDir() { return null; }
|
|
41
|
+
commandsDir() { return null; }
|
|
42
|
+
|
|
36
43
|
supportsSubagents() { return false; }
|
|
37
44
|
supportsSlashCommands() { return false; }
|
|
38
45
|
supportsHooks() { return false; }
|