diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 33d7cc7..61d1791 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -60,3 +60,17 @@ {"id":"ade-5.1","title":"Explore","description":"Understand the problem, analyze existing patterns, and design your approach. Consider the scope and impact of the change. **STEP 1: Analyze Requirements** - If exists: Use it to understand the required changes - Otherwise: Document requirements in your task management system **STEP 2: Review Design Approach** - If exists: Respect the design approach documented in - Otherwise: Design your approach based on the problem analysis **STEP 3: Document Decisions** - Document your analysis and design decisions - Create tasks to guide implementation - Focus on analysis and design only - do not write any code yet","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T11:33:13.459272+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T11:33:13.459272+01:00","dependencies":[{"issue_id":"ade-5.1","depends_on_id":"ade-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} {"id":"ade-5.2","title":"Implement","description":"Write clean, focused code for the minor enhancement, test your changes, and prepare for commit. **STEP 1: Review Design and Requirements** - If exists: Follow your design from - Otherwise: Elaborate design options and present them to the user - If exists: Ensure the relevant requirements from are met - Otherwise: Ensure existing requirements are met based on your task context **STEP 2: Implement Changes** - Write clean, focused code for the minor enhancement - Test your changes to ensure they work correctly and don't break existing functionality **STEP 3: Prepare for Finalization** - Update task progress as needed - Prepare documentation and commit when ready","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T11:33:13.597501+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T11:33:13.597501+01:00","dependencies":[{"issue_id":"ade-5.2","depends_on_id":"ade-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-5.2","depends_on_id":"ade-5.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} {"id":"ade-5.3","title":"Finalize","description":"Ensure code quality and documentation accuracy through systematic cleanup and review. **STEP 1: Code Cleanup** Systematically clean up development artifacts: - **Remove Debug Output**: Search for and remove all temporary debug output statements used during development. Look for language-specific debug output methods (console logging, print statements, debug output functions). Remove any debugging statements that were added for development purposes. - **Review TODO/FIXME Comments**: - Address each TODO/FIXME comment by either implementing the solution or documenting why it's deferred - Remove completed TODOs - Convert remaining TODOs to proper issue tracking if needed - **Remove Debugging Code Blocks**: - Remove temporary debugging code, test code blocks, and commented-out code - Clean up any experimental code that's no longer needed - Ensure proper error handling replaces temporary debug logging **STEP 2: Documentation Review** Review and update documentation to reflect final implementation: - **Update Long-Term Memory Documents**: Based on what was actually implemented: - If exists: Update if requirements changed during development - If exists: Update if design details were refined or changed - **Compare Against Implementation**: Review documentation against actual implemented functionality - **Update Changed Sections**: Only modify documentation sections that have functional changes - **Remove Development Progress**: Remove references to development iterations, progress notes, and temporary decisions - **Focus on Final State**: Ensure documentation describes the final implemented state, not the development process - **Ask User to Review Document Updates** **STEP 3: Final Validation** - Run existing tests to ensure cleanup didn't break functionality - Verify documentation accuracy with a final review - Ensure minor enhancement is ready for delivery - Update task progress and mark completed work as you finalize the minor enhancement","status":"open","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T11:33:13.733496+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T11:33:13.733496+01:00","dependencies":[{"issue_id":"ade-5.3","depends_on_id":"ade-5","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-5.3","depends_on_id":"ade-5.2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6","title":"ade: epcc (development-plan.md)","description":"Responsible vibe engineering session using epcc workflow for ade","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T16:48:54.346525+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T20:46:05.46088+01:00","closed_at":"2026-03-19T20:46:05.46088+01:00","close_reason":"Closed"} +{"id":"ade-6.1","title":"Explore","description":"Research the codebase to understand existing patterns and gather context about the problem space. - If uncertain about conventions or rules, ask the user about them - Read relevant files and documentation - If exists: Understand and document requirements there - Otherwise: Document requirements in your task management system Focus on understanding without writing code yet. Document your findings and create tasks as needed.","status":"closed","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T16:48:54.522007+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T20:46:05.255704+01:00","closed_at":"2026-03-19T20:46:05.255704+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.1","depends_on_id":"ade-6","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.2","title":"Plan","description":"Create a detailed implementation strategy based on your exploration: - If exists: Base your strategy on requirements from it - Otherwise: Use existing task context Break down the work into specific, actionable tasks. Consider edge cases, dependencies, and potential challenges. - If architectural changes needed and exists: Document in - Otherwise: Create tasks to track architectural decisions - If exists: Adhere to the design in it - Otherwise: Elaborate design options and present them to the user Document the planning work thoroughly and create implementation tasks as part of the code phase as needed.","status":"closed","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T16:48:54.661272+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T20:46:05.353452+01:00","closed_at":"2026-03-19T20:46:05.353452+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.2","depends_on_id":"ade-6","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.2","depends_on_id":"ade-6.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3","title":"Code","description":"Follow your plan to build the solution: - If exists: Follow the design from it - Otherwise: Elaborate design options and present them to the user - If exists: Build according to the architecture from it - Otherwise: Elaborate architectural options and present them to the user - If exists: Ensure requirements from it are met - Otherwise: Ensure existing requirements are met based on your task context Write clean, well-structured code with proper error handling. Prevent regression by building, linting, and executing existing tests. Stay flexible and adapt the plan as you learn more during implementation. Update task progress and create new tasks as needed.","status":"closed","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T16:48:54.813402+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T19:29:11.362541+01:00","closed_at":"2026-03-19T19:29:11.362541+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3","depends_on_id":"ade-6","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3","depends_on_id":"ade-6.2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.1","title":"Add AdeExtensions interface and AdeExtensionsSchema (Zod) to packages/core/src/types.ts","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.210558+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.234448+01:00","closed_at":"2026-03-19T17:48:15.234448+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.1","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.2","title":"Add mergeExtensions() to packages/core/src/catalog/index.ts","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.382003+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.336955+01:00","closed_at":"2026-03-19T17:48:15.336955+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.2","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.2","depends_on_id":"ade-6.3.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.3","title":"Export AdeExtensions, AdeExtensionsSchema, mergeExtensions from packages/core/src/index.ts","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.523449+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.438591+01:00","closed_at":"2026-03-19T17:48:15.438591+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.3","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.3","depends_on_id":"ade-6.3.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.3","depends_on_id":"ade-6.3.2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.4","title":"Add jiti as CLI dependency and zod as core dependency","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.663512+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.53763+01:00","closed_at":"2026-03-19T17:48:15.53763+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.4","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.5","title":"Create packages/cli/src/extensions.ts with loadExtensions(projectRoot)","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.81068+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.632037+01:00","closed_at":"2026-03-19T17:48:15.632037+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.5","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.5","depends_on_id":"ade-6.3.4","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.5","depends_on_id":"ade-6.3.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.6","title":"Wire extension loading into packages/cli/src/index.ts (setup + install commands)","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:05.953907+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:19.642615+01:00","closed_at":"2026-03-19T17:48:19.642615+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.6","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.6","depends_on_id":"ade-6.3.5","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.6","depends_on_id":"ade-6.3.7","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.7","title":"Expose buildHarnessWriters(extensions) in packages/harnesses/src/index.ts","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:06.106652+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:15.854204+01:00","closed_at":"2026-03-19T17:48:15.854204+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.7","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.3.8","title":"Create ade.extensions.mjs SAP example in repo root","status":"closed","priority":2,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T17:10:06.230083+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T17:48:19.753233+01:00","closed_at":"2026-03-19T17:48:19.753233+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.3.8","depends_on_id":"ade-6.3","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.3.8","depends_on_id":"ade-6.3.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.4","title":"Commit","description":"Ensure code quality and documentation accuracy through systematic cleanup and review. **STEP 1: Code Cleanup** Systematically clean up development artifacts: 1. **Remove Debug Output**: Search for and remove all temporary debug output statements used during development. Look for language-specific debug output methods (console logging, print statements, debug output functions). Remove any debugging statements that were added for development purposes. 2. **Review TODO/FIXME Comments**: - Address each TODO/FIXME comment by either implementing the solution or documenting why it's deferred - Remove completed TODOs - Convert remaining TODOs to proper issue tracking if needed 3. **Remove Debugging Code Blocks**: - Remove temporary debugging code, test code blocks, and commented-out code - Clean up any experimental code that's no longer needed - Ensure proper error handling replaces temporary debug logging **STEP 2: Documentation Review** Review and update documentation to reflect final implementation: 1. **Update Long-Term Memory Documents**: Based on what was actually implemented: - If exists: Update it if requirements changed during development - If exists: Update it if architectural impacts were identified - If exists: Update it if design details were refined or changed - Otherwise: Document any changes in the plan file 2. **Compare Against Implementation**: Review documentation against actual implemented functionality 3. **Update Changed Sections**: Only modify documentation sections that have functional changes 4. **Remove Development Progress**: Remove references to development iterations, progress notes, and temporary decisions 5. **Focus on Final State**: Ensure documentation describes the final implemented state, not the development process 6. **Ask User to Review Document Updates** **STEP 3: Final Validation** - Run existing tests to ensure cleanup didn't break functionality - Verify documentation accuracy with a final review - Ensure code is ready for production/delivery Update task progress and mark completed work as you finalize the feature.","status":"closed","priority":3,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T16:48:54.960844+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T19:37:20.65686+01:00","closed_at":"2026-03-19T19:29:11.477044+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.4","depends_on_id":"ade-6","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"ade-6.4","depends_on_id":"ade-6.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]} +{"id":"ade-6.4.1","title":"e2e integration test: extension option produces skills + knowledge in setup output","status":"closed","priority":1,"issue_type":"task","owner":"github@beimir.net","created_at":"2026-03-19T19:30:47.327614+01:00","created_by":"Oliver Jägle","updated_at":"2026-03-19T19:32:06.768961+01:00","closed_at":"2026-03-19T19:32:06.768961+01:00","close_reason":"Closed","dependencies":[{"issue_id":"ade-6.4.1","depends_on_id":"ade-6.4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"}]} diff --git a/.beads/last-touched b/.beads/last-touched index d00dfbc..3aece51 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -ade-5.3 +ade-6.4.1 diff --git a/.vibe/beads-state-ade-main-iazal7.json b/.vibe/beads-state-ade-main-iazal7.json new file mode 100644 index 0000000..b1ab5fc --- /dev/null +++ b/.vibe/beads-state-ade-main-iazal7.json @@ -0,0 +1,29 @@ +{ + "conversationId": "ade-main-iazal7", + "projectPath": "/Users/oliverjaegle/projects/privat/codemcp/ade", + "epicId": "ade-6", + "phaseTasks": [ + { + "phaseId": "explore", + "phaseName": "Explore", + "taskId": "ade-6.1" + }, + { + "phaseId": "plan", + "phaseName": "Plan", + "taskId": "ade-6.2" + }, + { + "phaseId": "code", + "phaseName": "Code", + "taskId": "ade-6.3" + }, + { + "phaseId": "commit", + "phaseName": "Commit", + "taskId": "ade-6.4" + } + ], + "createdAt": "2026-03-19T15:48:55.317Z", + "updatedAt": "2026-03-19T15:48:55.317Z" +} \ No newline at end of file diff --git a/.vibe/development-plan-extensibility.md b/.vibe/development-plan-extensibility.md new file mode 100644 index 0000000..c06951a --- /dev/null +++ b/.vibe/development-plan-extensibility.md @@ -0,0 +1,169 @@ +# Development Plan: ade (main branch) + +*Generated on 2026-03-19 by Vibe Feature MCP* +*Workflow: [epcc](https://mrsimpson.github.io/responsible-vibe-mcp/workflows/epcc)* + +## Goal + +Make the ADE project extensible so that forks/downstream consumers can add new facets, options, and harness writers without modifying the upstream source files. The goal is upstream-compatibility — consumers should be able to pull upstream changes without merge conflicts caused by modifications to core catalog or registry files. + +## Explore + +### Tasks +- [x] Explore codebase structure: packages/core, packages/harnesses, packages/cli +- [x] Understand how catalog, registry, and writers are wired together +- [x] Identify all the places consumers currently need to modify to extend + +### Findings + +**Current extension points require modifying upstream files:** +1. **Adding a new facet/option** → must edit `packages/core/src/catalog/index.ts` (`getDefaultCatalog()`) and add a new file in `catalog/facets/` +2. **Adding a new provision writer** → must edit `packages/core/src/registry.ts` (`createDefaultRegistry()`) +3. **Adding a new harness writer** → must edit `packages/harnesses/src/index.ts` (`allHarnessWriters` array + `getHarnessWriter` + `getHarnessIds`) +4. **Wiring it all into the CLI** → `packages/cli/src/index.ts` calls `getDefaultCatalog()` and relies on `allHarnessWriters` from harnesses + +**Existing infrastructure that's already extensible:** +- `WriterRegistry` + `registerProvisionWriter/registerAgentWriter` already exist as a general-purpose registry +- `Catalog` and `Facet` types are public and composable +- `createRegistry()` + `createDefaultRegistry()` already allow building custom registries +- `resolve()` accepts a `Catalog` and `WriterRegistry` — so it's already injection-ready +- `runSetup(projectRoot, catalog)` already takes a `catalog` parameter + +**Key insight:** The CLI's `index.ts` is the main wiring point. It hardcodes `getDefaultCatalog()` and `allHarnessWriters`. The CLI needs a plugin/extension loading mechanism so forks can inject their extensions without modifying the upstream entry point. + +## Plan + + +### Phase Entrance Criteria: +- [x] The codebase has been thoroughly explored +- [x] Current extension points and blockers are identified +- [x] It's clear what's in scope and what's out of scope + +### Tasks + +*Tasks managed via `bd` CLI* + +## Code + + +### Phase Entrance Criteria: +- [ ] Design is documented and reviewed +- [ ] Scope is agreed upon: what changes are needed and where +- [ ] No open design questions remain + +### Tasks + +*Tasks managed via `bd` CLI* + +## Commit + + +### Phase Entrance Criteria: +- [ ] All code changes are implemented and tested +- [ ] Existing tests pass +- [ ] The extension mechanism works end-to-end (catalog + writers + harnesses injectable) + +### Tasks +- [ ] Squash WIP commits: `git reset --soft `. Then, create a conventional commit. In the message, first summarize the intentions and key decisions from the development plan. Then, add a brief summary of the key changes and their side effects and dependencies + +*Tasks managed via `bd` CLI* + +## Key Decisions + +### KD-1: Extension model — `ade.extensions.mjs` with declarative contributions + +A consumer creates `ade.extensions.mjs` (or `.js`) in their project (or in a forked CLI's src dir). +The CLI loads it via dynamic `import()` at startup and merges contributions before resolving. + +The extension file exports a default object conforming to `AdeExtensions`: + +```ts +interface AdeExtensions { + // Contribute new options into existing facets (e.g. add "SAP" to "architecture") + facetContributions?: Record + // Register entirely new facets + facets?: Facet[] + // Register new provision writers (e.g. a "sap-config" writer) + provisionWriters?: ProvisionWriterDef[] + // Register new harness writers (e.g. a custom IDE) + harnessWriters?: HarnessWriter[] +} +``` + +**Why this model:** +- `facetContributions` (keyed by facet id) is the right primitive for the SAP use case: + SAP is a new *option* inside the existing `architecture` facet, not a new facet. +- `facets` covers the case where a consumer wants to add a completely new facet (e.g. "cloud-provider"). +- `provisionWriters` and `harnessWriters` cover the writer extension cases. +- The consumer never needs to modify upstream files. +- Dynamic `import()` means no build step required for `.mjs` extensions. + +### KD-2: Loading location + +The extension file is resolved relative to the **project root** passed to the CLI. +Search order: `ade.extensions.mjs` → `ade.extensions.js` (first match wins). +This works for both the "consumer uses npx @codemcp/ade setup" case and the "fork scenario". + +### KD-3: Type exports + +`AdeExtensions`, `HarnessWriter` (re-exported from harnesses), and all catalog/writer types +are already exported from `@codemcp/ade-core`. We need to add `AdeExtensions` to the exports. +No breaking changes to existing APIs. + +### KD-4: SAP example placement + +The SAP example (`sap` option on the `architecture` facet) will be implemented as a concrete +`ade.extensions.mjs` example file **in the repo root** (not bundled into the catalog). +This doubles as the integration test and documentation for the extension mechanism. + +### KD-5: Type safety for extension loading → documented in `docs/adrs/0002-extension-file-type-safety.md` + +Runtime Zod validation (always) + `jiti` for `.ts` extension files + `AdeExtensions` type export for JSDoc consumers. + +## Notes + +**SAP Architecture option shape** (what the consumer writes): +```js +// ade.extensions.mjs +import { } from '@codemcp/ade-core' // types only if needed + +const sapOption = { + id: 'sap', + label: 'SAP', + description: 'SAP development with ABAP, CAP (Cloud Application Programming model), and BTP', + recipe: [ + { + writer: 'skills', + config: { + skills: [ + { name: 'sap-architecture', description: '...', body: '...' }, + { name: 'sap-design', description: '...', body: '...' }, + ] + } + } + ], + docsets: [ + { id: 'cap-docs', label: 'SAP CAP', origin: 'https://github.com/SAP/cloud-sdk.git', description: '...' } + ] +} + +export default { + facetContributions: { + architecture: [sapOption] + } +} +``` + +The CLI then calls `mergeExtensions(catalog, registry, extensions)` before running setup/install. + +**Files to create/modify:** +1. `packages/core/src/types.ts` — add `AdeExtensions` type +2. `packages/core/src/catalog/index.ts` — add `mergeExtensions(catalog, extensions)` function +3. `packages/core/src/index.ts` — export `AdeExtensions` and `mergeExtensions` +4. `packages/cli/src/extensions.ts` — new file: `loadExtensions(projectRoot)` using dynamic import +5. `packages/cli/src/index.ts` — load extensions and pass merged catalog/registry to setup/install +6. `packages/harnesses/src/index.ts` — expose `mergeHarnessWriters` or accept additional writers +7. `ade.extensions.mjs` — example file in repo root with the SAP architecture option + +--- +*This plan is maintained by the LLM and uses beads CLI for task management. Tool responses provide guidance on which bd commands to use for task management.* diff --git a/ade.extensions.mjs b/ade.extensions.mjs new file mode 100644 index 0000000..ebcde12 --- /dev/null +++ b/ade.extensions.mjs @@ -0,0 +1,66 @@ +/** + * ade.extensions.mjs — SAP BTP / ABAP architecture extension example + * + * Place this file in your project root to extend the default ADE catalog + * without modifying any upstream source files. + * + * This file serves as both documentation and an integration example. + * It is by no means functional! It's about providing options with dependent + * skills and documentation sources. + * TypeScript consumers can use ade.extensions.ts with full IDE type-checking. + * + * @type {import('@codemcp/ade-core').AdeExtensions} + */ +export default { + facetContributions: { + architecture: [ + { + id: "sap-abap", + label: "SAP BTP / ABAP", + description: + "SAP Business Technology Platform with ABAP Cloud development", + recipe: [ + { + writer: "skills", + config: { + skills: [ + { + name: "sap-abap-architecture", + source: "your-org/ade-sap/skills/sap-abap-architecture" + }, + { + name: "sap-abap-code", + source: "your-org/ade-sap/skills/sap-abap-code" + }, + { + name: "sap-abap-testing", + source: "your-org/ade-sap/skills/sap-abap-testing" + } + ] + } + }, + { + writer: "knowledge", + config: { + sources: [ + { + name: "sap-abap-docs", + origin: "https://your-serialized-version-of-abap-docs.git", + description: "Official SAP ABAP Cloud development guide" + } + ] + } + } + ], + docsets: [ + { + id: "sap-btp-docs", + label: "SAP BTP", + origin: "https://your-serialized-version-of-btp-docs.git", + description: "SAP Business Technology Platform documentation" + } + ] + } + ] + } +}; diff --git a/docs/adrs/0001-tui-framework-selection.md b/docs/adr/0001-tui-framework-selection.md similarity index 100% rename from docs/adrs/0001-tui-framework-selection.md rename to docs/adr/0001-tui-framework-selection.md diff --git a/docs/adr/0002-extension-file-type-safety.md b/docs/adr/0002-extension-file-type-safety.md new file mode 100644 index 0000000..3e869fa --- /dev/null +++ b/docs/adr/0002-extension-file-type-safety.md @@ -0,0 +1,97 @@ +# ADR 0002: Type Safety Strategy for Extension File Loading + +## Status + +Proposed + +## Context + +ADE is being made extensible: consumers who fork the repository or use it as an upstream +can place an `ade.extensions.mjs` (or `.js`, `.ts`) file in their project root to contribute +new catalog options, facets, provision writers, and harness writers without modifying +upstream files. + +The CLI loads this file at runtime via dynamic `import()`. This creates a type safety gap: + +- `import()` returns `Promise`, so the loaded module has no compile-time shape + guarantee on the CLI side. +- Consumers writing `.mjs`/`.js` extension files have no TypeScript compiler checking their + exported object against the `AdeExtensions` interface. +- Shape errors (wrong property name, wrong recipe format, missing required field) would + surface as confusing runtime failures deep inside `resolve()` or a writer, not at the + point of authoring the extension. + +Three levels of type safety are available: + +**Level 1 — JSDoc `@type` annotation (authoring-time, opt-in)** +The consumer annotates their JS extension file with +`/** @type {import('@codemcp/ade-core').AdeExtensions} */`. +This provides IDE autocompletion and type hints in editors with JSDoc awareness (VS Code). +No build step. No CLI-side guarantee — the annotation is advisory only and silently ignored +if the consumer doesn't use it. + +**Level 2 — Runtime Zod validation (load-time, mandatory)** +The CLI validates the loaded module against a Zod schema derived from `AdeExtensions` +immediately after import. Rejects invalid extensions with a structured, actionable error +message before any catalog or registry mutation happens. +No authoring-time feedback, but errors surface at `ade setup` time rather than +mid-resolution. + +**Level 3 — TypeScript extension files via `jiti` (authoring-time, opt-in)** +The CLI's extension loader also accepts `ade.extensions.ts`. Loading it requires an +in-process TypeScript runner. `jiti` (by the Nuxt/unjs team) is the established solution: +it strips types at load time using `oxc-transform` (zero native binaries, fast, ESM-native). +Consumers writing `.ts` extension files get full TypeScript compiler checking and IDE +support. `jiti` is already used by Vite, Nuxt, and most of the unjs ecosystem. + +These levels are not mutually exclusive. The fork scenario (consumer edits source TypeScript +directly in a forked CLI) already has full compile-time safety through the TypeScript +compiler — no additional mechanism needed there. + +## Decision + +We will implement **Level 2 (Zod runtime validation) combined with Level 3 (`.ts` support +via `jiti`)**, and export the `AdeExtensions` type from `@codemcp/ade-core` to enable +Level 1 as a zero-cost baseline for JS consumers. + +Concretely: + +1. `AdeExtensions` is defined as a TypeScript interface in `@codemcp/ade-core` and exported + from its public index. + +2. A corresponding `AdeExtensionsSchema` Zod schema is defined alongside the interface. + The CLI's `loadExtensions(projectRoot)` function validates every loaded extension object + against this schema and throws a structured error if validation fails. + +3. The extension loader searches for files in this order: + `ade.extensions.ts` → `ade.extensions.mjs` → `ade.extensions.js` + The first match is loaded. `.ts` files are loaded via `jiti`; `.mjs`/`.js` files via + native `import()`. + +4. `jiti` is added as a production dependency of `@codemcp/ade-cli`. + +## Consequences + +**Easier:** + +- Consumers writing `ade.extensions.ts` get full IDE type checking and compile-time errors + — the same authoring experience as editing the upstream source directly. +- All consumers (JS and TS) get clear, structured error messages at `ade setup` time if + their extension file is malformed, rather than cryptic failures during resolution. +- The `AdeExtensions` type and JSDoc `@type` path give JS consumers a zero-friction + upgrade path toward type safety without requiring a build step. +- The fork scenario (editing CLI TypeScript source directly) retains full compile-time + safety with no additional tooling. + +**More difficult / trade-offs:** + +- Adding `jiti` as a dependency introduces a transitive dependency surface (~400 kB + unpacked, no native binaries). This is a deliberate trade-off accepted in exchange for + `.ts` extension support. +- Zod must be kept as a runtime dependency of `@codemcp/ade-core` (it already is, or will + be added). The `AdeExtensionsSchema` must be kept in sync with the `AdeExtensions` + interface — a dual-maintenance surface. A build-time `zod-to-ts` or `ts-to-zod` step + could eliminate this in the future if drift becomes a problem. +- Dynamic loading of arbitrary user files (via `import()` or `jiti`) means the CLI cannot + be fully type-checked end-to-end at its own build time. The Zod boundary is the explicit + trust boundary between upstream-typed code and user-supplied code. diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md new file mode 100644 index 0000000..867286f --- /dev/null +++ b/docs/guide/extensions.md @@ -0,0 +1,187 @@ +# Extending ADE + +ADE is designed to be forked and extended without modifying upstream source +files. Drop an `ade.extensions.mjs` (or `.ts` / `.js`) into your project root +and ADE picks it up automatically on every `ade setup` or `ade install` run. + +## How it works + +``` +your-project/ + ade.extensions.mjs ← ADE reads this from process.cwd() + config.yaml + config.lock.yaml +``` + +`npx @codemcp/ade setup` resolves `projectRoot` to `process.cwd()` — the +directory you run the command from — so the extensions file is always loaded +from your project, never from the installed CLI package. + +## The extension file + +Export a default object conforming to `AdeExtensions`: + +```js +// ade.extensions.mjs +/** @type {import('@codemcp/ade-core').AdeExtensions} */ +export default { + // Add options to an existing facet + facetContributions: { + architecture: [ + /* Option[] */ + ] + }, + + // Add entirely new facets + facets: [ + /* Facet[] */ + ], + + // Add custom provision writers + provisionWriters: [ + /* ProvisionWriterDef[] */ + ], + + // Add custom harness writers + harnessWriters: [ + /* HarnessWriter[] */ + ] +}; +``` + +All fields are optional — include only what you need. + +For TypeScript with full IDE type-checking, name the file +`ade.extensions.ts` instead (requires `jiti`, which is bundled with the CLI). + +## Adding an architecture option + +The most common case: contributing a new option to the built-in `architecture` +facet so it appears in the setup wizard alongside TanStack, Node.js, etc. + +```js +// ade.extensions.mjs +/** @type {import('@codemcp/ade-core').AdeExtensions} */ +export default { + facetContributions: { + architecture: [ + { + id: "sap-abap", + label: "SAP BTP / ABAP", + description: "SAP BTP ABAP Cloud development", + recipe: [ + { + writer: "skills", + config: { + skills: [ + // Inline skill — body is written to .ade/skills//SKILL.md + { + name: "sap-abap-code", + description: "SAP ABAP coding guidelines", + body: "# SAP ABAP Code\n\nUse ABAP Cloud APIs only. ..." + }, + // External skill — fetched from a skills server at install time + { + name: "sap-abap-architecture", + source: "your-org/ade-sap/skills/sap-abap-architecture" + } + ] + } + }, + { + writer: "knowledge", + config: { + name: "sap-abap-docs", + origin: "https://help.sap.com/docs/abap-cloud", + description: "SAP ABAP Cloud documentation" + } + } + ], + docsets: [ + { + id: "sap-abap-cloud-docs", + label: "SAP ABAP Cloud", + origin: "https://help.sap.com/docs/abap-cloud", + description: "SAP ABAP Cloud development documentation" + } + ] + } + ] + } +}; +``` + +After running `ade setup` and selecting `SAP BTP / ABAP`: + +- Inline skills are staged to `.ade/skills//SKILL.md` and installed + to `.agentskills/skills//` for agent consumption +- Knowledge sources appear in `config.lock.yaml` under + `logical_config.knowledge_sources` and can be initialised with + `npx @codemcp/knowledge init` +- Docsets appear in the setup wizard's documentation sources step + +## Adding a new facet + +```js +export default { + facets: [ + { + id: "deployment", + label: "Deployment", + description: "Target deployment platform", + required: false, + options: [ + { + id: "cf", + label: "Cloud Foundry", + description: "SAP BTP Cloud Foundry environment", + recipe: [{ writer: "skills", config: { skills: [...] } }] + } + ] + } + ] +}; +``` + +New facets are appended after the built-in facets in the wizard. To express +that a facet depends on another (for conditional option filtering), set +`dependsOn: ["architecture"]` and implement `available()` on individual options. + +## Adding a harness writer + +```js +export default { + harnessWriters: [ + { + id: "my-internal-ide", + label: "Internal IDE", + description: "Our internal coding assistant", + async install(logicalConfig, projectRoot) { + // write whatever config files your IDE expects + } + } + ] +}; +``` + +The harness appears in the setup wizard's "which agents should receive config?" +multi-select, after the built-in harnesses. + +## Validation + +The extensions file is validated with Zod when loaded. If the shape is wrong +you get a descriptive error at setup time, not a silent no-op: + +``` +Error: Invalid ade.extensions file at /your-project/ade.extensions.mjs: + facetContributions: Expected object, received string +``` + +## What stays upstream-compatible + +| Upstream file | Status | +| ------------------------------------ | ------------------------------------------------ | +| `packages/core/src/catalog/index.ts` | ✅ Never touch — use `facetContributions` | +| `packages/harnesses/src/index.ts` | ✅ Never touch — use `harnessWriters` | +| `packages/cli/src/index.ts` | ✅ Never touch — extensions loaded automatically | +| `ade.extensions.mjs` (your file) | 🔧 Yours to own | diff --git a/package.json b/package.json index 4108ead..37c8c36 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "typescript": "catalog:", "vitepress": "1.6.2", "vitepress-plugin-mermaid": "2.0.17", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "catalog:" }, "packageManager": "pnpm@10.32.1", "pnpm": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 61f23f8..a7c1869 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,7 +28,9 @@ "@clack/prompts": "^1.1.0", "@codemcp/ade-core": "workspace:*", "@codemcp/ade-harnesses": "workspace:*", - "yaml": "^2.8.2" + "jiti": "2.6.1", + "yaml": "^2.8.2", + "zod": "catalog:" }, "devDependencies": { "@codemcp/knowledge": "2.1.0", diff --git a/packages/cli/src/commands/extensions.integration.spec.ts b/packages/cli/src/commands/extensions.integration.spec.ts new file mode 100644 index 0000000..0c22422 --- /dev/null +++ b/packages/cli/src/commands/extensions.integration.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock only the TUI — everything else (catalog, registry, resolver, config I/O, writers) is real +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + note: vi.fn(), + select: vi.fn(), + multiselect: vi.fn(), + confirm: vi.fn().mockResolvedValue(false), // decline skill install prompt + isCancel: vi.fn().mockReturnValue(false), + cancel: vi.fn(), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + spinner: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() }) +})); + +import * as clack from "@clack/prompts"; +import { runSetup } from "./setup.js"; +import { + readLockFile, + getDefaultCatalog, + mergeExtensions +} from "@codemcp/ade-core"; +import type { AdeExtensions } from "@codemcp/ade-core"; + +describe("extension e2e — option contributes skills and knowledge to setup output", () => { + let dir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(clack.confirm).mockResolvedValue(false); // don't install skills + dir = await mkdtemp(join(tmpdir(), "ade-ext-e2e-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it( + "extension-contributed architecture option writes inline skill and knowledge source", + { timeout: 60_000 }, + async () => { + // Build an extension with a SAP option that has an inline skill + knowledge + const extensions: AdeExtensions = { + facetContributions: { + architecture: [ + { + id: "sap-abap", + label: "SAP BTP / ABAP", + description: "SAP BTP ABAP Cloud development", + recipe: [ + { + writer: "skills", + config: { + skills: [ + { + name: "sap-abap-code", + description: "SAP ABAP coding guidelines", + body: "# SAP ABAP Code\nUse ABAP Cloud APIs only." + } + ] + } + }, + { + writer: "knowledge", + config: { + name: "sap-abap-docs", + origin: "https://help.sap.com/docs/abap-cloud", + description: "SAP ABAP Cloud documentation" + } + } + ] + } + ] + } + }; + + const catalog = mergeExtensions(getDefaultCatalog(), extensions); + + // Facet order from sortFacets: process → architecture → practices → backpressure → autonomy + vi.mocked(clack.select) + .mockResolvedValueOnce("native-agents-md") // process + .mockResolvedValueOnce("sap-abap"); // architecture — the extended option + vi.mocked(clack.multiselect) + .mockResolvedValueOnce([]) // practices: none + // backpressure: sap-abap has no matching options so skipped + .mockResolvedValueOnce([]); // harnesses + + await runSetup(dir, catalog); + + // ── Skill should be staged to .ade/skills/sap-abap-code/SKILL.md ──── + const skillMd = await readFile( + join(dir, ".ade", "skills", "sap-abap-code", "SKILL.md"), + "utf-8" + ); + expect(skillMd).toContain("name: sap-abap-code"); + expect(skillMd).toContain("SAP ABAP Code"); + expect(skillMd).toContain("ABAP Cloud APIs only"); + + // ── Knowledge source should appear in the lock file ────────────────── + const lock = await readLockFile(dir); + expect(lock).not.toBeNull(); + const knowledgeSources = lock!.logical_config.knowledge_sources; + expect(knowledgeSources).toHaveLength(1); + expect(knowledgeSources[0].name).toBe("sap-abap-docs"); + expect(knowledgeSources[0].origin).toBe( + "https://help.sap.com/docs/abap-cloud" + ); + expect(knowledgeSources[0].description).toBe( + "SAP ABAP Cloud documentation" + ); + + // ── config.yaml should record the extension option as the choice ────── + const { readUserConfig } = await import("@codemcp/ade-core"); + const config = await readUserConfig(dir); + expect(config!.choices.architecture).toBe("sap-abap"); + } + ); +}); diff --git a/packages/cli/src/commands/install.spec.ts b/packages/cli/src/commands/install.spec.ts index 4c6f25e..f281003 100644 --- a/packages/cli/src/commands/install.spec.ts +++ b/packages/cli/src/commands/install.spec.ts @@ -27,9 +27,29 @@ vi.mock("@codemcp/ade-core", async (importOriginal) => { }; }); -const mockInstall = vi.fn().mockResolvedValue(undefined); +const mockInstall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); vi.mock("@codemcp/ade-harnesses", () => ({ + allHarnessWriters: [ + { + id: "universal", + label: "Universal", + description: "Universal", + install: mockInstall + }, + { + id: "claude-code", + label: "Claude Code", + description: "Claude Code", + install: mockInstall + }, + { + id: "cursor", + label: "Cursor", + description: "Cursor", + install: mockInstall + } + ], getHarnessWriter: vi.fn().mockImplementation((id: string) => { if (id === "universal" || id === "claude-code" || id === "cursor") { return { id, install: mockInstall }; diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 346d084..a455ded 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -1,6 +1,8 @@ import * as clack from "@clack/prompts"; import { readLockFile } from "@codemcp/ade-core"; import { + type HarnessWriter, + allHarnessWriters, getHarnessWriter, getHarnessIds, installSkills, @@ -9,7 +11,8 @@ import { export async function runInstall( projectRoot: string, - harnessIds?: string[] + harnessIds?: string[], + harnessWriters: HarnessWriter[] = allHarnessWriters ): Promise { clack.intro("ade install"); @@ -24,11 +27,12 @@ export async function runInstall( // 3. default: universal const ids = harnessIds ?? lockFile.harnesses ?? ["universal"]; - const validIds = getHarnessIds(); + const validIds = [...getHarnessIds(), ...harnessWriters.map((w) => w.id)]; + const uniqueValidIds = [...new Set(validIds)]; for (const id of ids) { - if (!validIds.includes(id)) { + if (!uniqueValidIds.includes(id)) { throw new Error( - `Unknown harness "${id}". Available: ${validIds.join(", ")}` + `Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}` ); } } @@ -36,7 +40,8 @@ export async function runInstall( const logicalConfig = lockFile.logical_config; for (const id of ids) { - const writer = getHarnessWriter(id); + const writer = + harnessWriters.find((w) => w.id === id) ?? getHarnessWriter(id); if (writer) { await writer.install(logicalConfig, projectRoot); } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 8944bca..89b70d8 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -16,6 +16,7 @@ import { getVisibleOptions } from "@codemcp/ade-core"; import { + type HarnessWriter, allHarnessWriters, getHarnessWriter, installSkills, @@ -24,7 +25,8 @@ import { export async function runSetup( projectRoot: string, - catalog: Catalog + catalog: Catalog, + harnessWriters: HarnessWriter[] = allHarnessWriters ): Promise { let lineIndex = 0; const LOGO_LINES = [ @@ -138,14 +140,14 @@ export async function runSetup( // Harness selection — multi-select from all available harnesses const existingHarnesses = existingConfig?.harnesses; - const harnessOptions = allHarnessWriters.map((w) => ({ + const harnessOptions = harnessWriters.map((w) => ({ value: w.id, label: w.label, hint: w.description })); const validExistingHarnesses = existingHarnesses?.filter((h) => - allHarnessWriters.some((w) => w.id === h) + harnessWriters.some((w) => w.id === h) ); const selectedHarnesses = await clack.multiselect({ @@ -188,7 +190,9 @@ export async function runSetup( // Install to all selected harnesses for (const harnessId of harnesses) { - const writer = getHarnessWriter(harnessId); + const writer = + harnessWriters.find((w) => w.id === harnessId) ?? + getHarnessWriter(harnessId); if (writer) { await writer.install(logicalConfig, projectRoot); } diff --git a/packages/cli/src/extensions.spec.ts b/packages/cli/src/extensions.spec.ts new file mode 100644 index 0000000..f6edcf1 --- /dev/null +++ b/packages/cli/src/extensions.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { loadExtensions } from "./extensions.js"; +import { tmpdir } from "node:os"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; + +describe("loadExtensions", () => { + it("returns an empty object when no extensions file exists", async () => { + const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-")); + try { + const result = await loadExtensions(dir); + expect(result).toEqual({}); + } finally { + await rm(dir, { recursive: true }); + } + }); + + it("loads and validates a valid .mjs extensions file", async () => { + const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-")); + try { + await writeFile( + join(dir, "ade.extensions.mjs"), + `export default { + facetContributions: { + architecture: [ + { + id: "sap", + label: "SAP BTP / ABAP", + description: "SAP BTP ABAP development", + recipe: [{ writer: "skills", config: { skills: [] } }] + } + ] + } + };` + ); + const result = await loadExtensions(dir); + expect(result.facetContributions?.architecture).toHaveLength(1); + expect(result.facetContributions?.architecture?.[0].id).toBe("sap"); + } finally { + await rm(dir, { recursive: true }); + } + }); + + it("throws a descriptive error when the extensions file exports an invalid shape", async () => { + const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-")); + try { + await writeFile( + join(dir, "ade.extensions.mjs"), + `export default { facetContributions: "not-an-object" };` + ); + await expect(loadExtensions(dir)).rejects.toThrow(/invalid/i); + } finally { + await rm(dir, { recursive: true }); + } + }); + + it("loads a .js file when only .js exists", async () => { + const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-")); + try { + await writeFile( + join(dir, "ade.extensions.js"), + `export default { + facets: [ + { + id: "js-only-facet", + label: "JS Only", + description: "From .js fallback", + required: false, + options: [] + } + ] + };` + ); + const result = await loadExtensions(dir); + expect(result.facets).toHaveLength(1); + expect(result.facets?.[0].id).toBe("js-only-facet"); + } finally { + await rm(dir, { recursive: true }); + } + }); + + it("prefers .mjs over .js when both exist", async () => { + const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-")); + try { + await writeFile( + join(dir, "ade.extensions.mjs"), + `export default { facets: [{ id: "from-mjs", label: "MJS", description: "MJS wins", required: false, options: [] }] };` + ); + await writeFile( + join(dir, "ade.extensions.js"), + `export default { facets: [{ id: "from-js", label: "JS", description: "JS loses", required: false, options: [] }] };` + ); + const result = await loadExtensions(dir); + expect(result.facets?.[0].id).toBe("from-mjs"); + } finally { + await rm(dir, { recursive: true }); + } + }); + + it("loads from an absolute path — simulating npx run from a different cwd", async () => { + // This is the published-package scenario: + // The CLI binary lives in ~/.npm/_npx/... but projectRoot is the user's cwd. + // loadExtensions(projectRoot) must look in projectRoot, not in the CLI package dir. + const userProjectDir = await mkdtemp(join(tmpdir(), "ade-user-project-")); + const cliPackageDir = await mkdtemp(join(tmpdir(), "ade-cli-package-")); + try { + // Simulate: user project has ade.extensions.mjs + await writeFile( + join(userProjectDir, "ade.extensions.mjs"), + `export default { + facets: [{ id: "user-project-facet", label: "User", description: "From user project", required: false, options: [] }] + };` + ); + // CLI package dir has no extension file (it shouldn't be used) + + // loadExtensions is called with the user's project dir as projectRoot + const result = await loadExtensions(userProjectDir); + expect(result.facets?.[0].id).toBe("user-project-facet"); + + // CLI package dir produces empty extensions — it is never consulted + const cliResult = await loadExtensions(cliPackageDir); + expect(cliResult).toEqual({}); + } finally { + await rm(userProjectDir, { recursive: true }); + await rm(cliPackageDir, { recursive: true }); + } + }); +}); diff --git a/packages/cli/src/extensions.ts b/packages/cli/src/extensions.ts new file mode 100644 index 0000000..b811f83 --- /dev/null +++ b/packages/cli/src/extensions.ts @@ -0,0 +1,71 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { AdeExtensionsSchema, type AdeExtensions } from "@codemcp/ade-core"; + +const SEARCH_ORDER = [ + "ade.extensions.ts", + "ade.extensions.mjs", + "ade.extensions.js" +] as const; + +/** + * Loads and validates the project's `ade.extensions` file (if any). + * + * Search order: ade.extensions.ts → ade.extensions.mjs → ade.extensions.js + * + * - `.ts` files are loaded via `jiti` for TypeScript support. + * - `.mjs` / `.js` files are loaded via native dynamic `import()`. + * - Returns `{}` when no extensions file exists. + * - Throws with a descriptive message when the file exports an invalid shape. + */ +export async function loadExtensions( + projectRoot: string +): Promise { + for (const filename of SEARCH_ORDER) { + const filePath = join(projectRoot, filename); + + if (!(await fileExists(filePath))) continue; + + // eslint-disable-next-line no-await-in-loop + const mod = await loadModule(filePath, filename); + const raw = mod?.default ?? mod; + + const result = AdeExtensionsSchema.safeParse(raw); + if (!result.success) { + throw new Error( + `Invalid ade.extensions file at ${filePath}:\n${result.error.message}` + ); + } + + return result.data; + } + + return {}; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function loadModule( + filePath: string, + filename: string +): Promise> { + if (filename.endsWith(".ts")) { + // Use jiti for TypeScript support + const { createJiti } = await import("jiti"); + const jiti = createJiti(import.meta.url); + return jiti.import(filePath) as Promise>; + } + + // Native ESM for .mjs / .js + return import(pathToFileURL(filePath).href) as Promise< + Record + >; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f343a2e..e8dd410 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,18 +3,23 @@ import { version } from "./version.js"; import { runSetup } from "./commands/setup.js"; import { runInstall } from "./commands/install.js"; -import { getDefaultCatalog } from "@codemcp/ade-core"; -import { getHarnessIds } from "@codemcp/ade-harnesses"; +import { getDefaultCatalog, mergeExtensions } from "@codemcp/ade-core"; +import { getHarnessIds, buildHarnessWriters } from "@codemcp/ade-harnesses"; +import { loadExtensions } from "./extensions.js"; const args = process.argv.slice(2); const command = args[0]; if (command === "setup") { const projectRoot = args[1] ?? process.cwd(); - const catalog = getDefaultCatalog(); - await runSetup(projectRoot, catalog); + const extensions = await loadExtensions(projectRoot); + const catalog = mergeExtensions(getDefaultCatalog(), extensions); + const harnessWriters = buildHarnessWriters(extensions); + await runSetup(projectRoot, catalog, harnessWriters); } else if (command === "install") { const projectRoot = args[1] ?? process.cwd(); + const extensions = await loadExtensions(projectRoot); + const harnessWriters = buildHarnessWriters(extensions); let harnessIds: string[] | undefined; @@ -26,7 +31,7 @@ if (command === "setup") { } } - await runInstall(projectRoot, harnessIds); + await runInstall(projectRoot, harnessIds, harnessWriters); } else if (command === "--version" || command === "-v") { console.log(version); } else { diff --git a/packages/core/package.json b/packages/core/package.json index dc22157..469b804 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,7 +28,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "yaml": "^2.8.2" + "yaml": "^2.8.2", + "zod": "catalog:" }, "devDependencies": { "oxlint": "catalog:", diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index 1141e3e..a003fd1 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -1,4 +1,4 @@ -import type { Catalog, Facet, Option } from "../types.js"; +import type { Catalog, Facet, Option, AdeExtensions } from "../types.js"; import { processFacet } from "./facets/process.js"; import { architectureFacet } from "./facets/architecture.js"; import { practicesFacet } from "./facets/practices.js"; @@ -91,3 +91,40 @@ export function getVisibleOptions( return option.available(deps); }); } + +/** + * Merges extension contributions into a catalog, returning a new catalog + * without mutating the original. + * + * - `extensions.facetContributions`: appends new options to existing facets + * (silently ignores contributions for unknown facet ids) + * - `extensions.facets`: appends entirely new facets + */ +export function mergeExtensions( + catalog: Catalog, + extensions: AdeExtensions +): Catalog { + // Deep-clone the facets array (shallow-clone each facet with a new options array) + let facets: Facet[] = catalog.facets.map((f) => ({ + ...f, + options: [...f.options] + })); + + // Append contributed options to existing facets + for (const [facetId, newOptions] of Object.entries( + extensions.facetContributions ?? {} + )) { + const facet = facets.find((f) => f.id === facetId); + if (facet) { + facet.options = [...facet.options, ...newOptions]; + } + // Unknown facet ids are silently ignored + } + + // Append entirely new facets + if (extensions.facets && extensions.facets.length > 0) { + facets = [...facets, ...extensions.facets]; + } + + return { facets }; +} diff --git a/packages/core/src/extensions.spec.ts b/packages/core/src/extensions.spec.ts new file mode 100644 index 0000000..2838559 --- /dev/null +++ b/packages/core/src/extensions.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "vitest"; +import type { AdeExtensions } from "./types.js"; +import { AdeExtensionsSchema } from "./types.js"; +import { mergeExtensions } from "./catalog/index.js"; +import { getDefaultCatalog, getFacet, getOption } from "./catalog/index.js"; + +// ─── AdeExtensionsSchema (Zod validation) ────────────────────────────────── + +describe("AdeExtensionsSchema", () => { + it("accepts an empty object (all fields optional)", () => { + const result = AdeExtensionsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("accepts a valid facetContributions map", () => { + const ext: AdeExtensions = { + facetContributions: { + architecture: [ + { + id: "sap", + label: "SAP", + description: "SAP BTP ABAP development", + recipe: [{ writer: "skills", config: { skills: [] } }] + } + ] + } + }; + const result = AdeExtensionsSchema.safeParse(ext); + expect(result.success).toBe(true); + }); + + it("accepts a valid facets array (new facets)", () => { + const ext: AdeExtensions = { + facets: [ + { + id: "custom-facet", + label: "Custom", + description: "A custom facet", + required: false, + options: [] + } + ] + }; + const result = AdeExtensionsSchema.safeParse(ext); + expect(result.success).toBe(true); + }); + + it("accepts harnessWriters", () => { + const ext: AdeExtensions = { + harnessWriters: [ + { + id: "my-harness", + label: "My Harness", + description: "Custom harness", + install: async () => {} + } + ] + }; + const result = AdeExtensionsSchema.safeParse(ext); + expect(result.success).toBe(true); + }); + + it("rejects an invalid facetContributions value (wrong type)", () => { + const result = AdeExtensionsSchema.safeParse({ + facetContributions: "not-an-object" + }); + expect(result.success).toBe(false); + }); + + it("rejects a facetContributions option missing required fields", () => { + const result = AdeExtensionsSchema.safeParse({ + facetContributions: { + architecture: [ + { id: "sap" } // missing label, description, recipe + ] + } + }); + expect(result.success).toBe(false); + }); +}); + +// ─── mergeExtensions ──────────────────────────────────────────────────────── + +describe("mergeExtensions", () => { + it("returns the original catalog unchanged when extensions is empty", () => { + const original = getDefaultCatalog(); + const merged = mergeExtensions(original, {}); + expect(merged.facets).toHaveLength(original.facets.length); + expect(merged.facets.map((f) => f.id)).toEqual( + original.facets.map((f) => f.id) + ); + }); + + it("adds new options to an existing facet via facetContributions", () => { + const catalog = getDefaultCatalog(); + const sapOption = { + id: "sap", + label: "SAP BTP / ABAP", + description: "SAP BTP ABAP development", + recipe: [{ writer: "skills" as const, config: { skills: [] } }] + }; + + const merged = mergeExtensions(catalog, { + facetContributions: { architecture: [sapOption] } + }); + + const arch = getFacet(merged, "architecture")!; + expect(arch).toBeDefined(); + const sap = getOption(arch, "sap"); + expect(sap).toBeDefined(); + expect(sap!.label).toBe("SAP BTP / ABAP"); + }); + + it("does not mutate the original catalog", () => { + const catalog = getDefaultCatalog(); + const originalArchOptionCount = getFacet(catalog, "architecture")!.options + .length; + + mergeExtensions(catalog, { + facetContributions: { + architecture: [ + { + id: "sap", + label: "SAP", + description: "SAP", + recipe: [{ writer: "skills" as const, config: { skills: [] } }] + } + ] + } + }); + + expect(getFacet(catalog, "architecture")!.options).toHaveLength( + originalArchOptionCount + ); + }); + + it("appends entirely new facets from extensions.facets", () => { + const catalog = getDefaultCatalog(); + const newFacet = { + id: "sap-specific", + label: "SAP Specific", + description: "SAP-specific choices", + required: false, + options: [] + }; + + const merged = mergeExtensions(catalog, { facets: [newFacet] }); + expect(merged.facets.map((f) => f.id)).toContain("sap-specific"); + expect(merged.facets).toHaveLength(catalog.facets.length + 1); + }); + + it("ignores facetContributions for unknown facet ids (no crash)", () => { + const catalog = getDefaultCatalog(); + const merged = mergeExtensions(catalog, { + facetContributions: { + "totally-unknown-facet": [ + { + id: "x", + label: "X", + description: "X", + recipe: [{ writer: "skills" as const, config: { skills: [] } }] + } + ] + } + }); + // Should not throw; catalog unchanged + expect(merged.facets).toHaveLength(catalog.facets.length); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42251d1..99d7ab1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,8 +45,10 @@ export { getFacet, getOption, sortFacets, - getVisibleOptions + getVisibleOptions, + mergeExtensions } from "./catalog/index.js"; +export { type AdeExtensions, AdeExtensionsSchema } from "./types.js"; export { skillsWriter } from "./writers/skills.js"; export { knowledgeWriter } from "./writers/knowledge.js"; export { permissionPolicyWriter } from "./writers/permission-policy.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a59261d..f1154fe 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + // --- Catalog types --- export interface Catalog { @@ -157,3 +159,72 @@ export interface WriterRegistry { provisions: Map; agents: Map; } + +// --- Extension types --- + +/** + * Runtime validation helpers for extension file loading. + * + * We use z.custom() for Option, Facet, HarnessWriter and ProvisionWriterDef + * because their TypeScript interfaces contain function types that Zod cannot + * faithfully represent without losing the concrete signature. z.custom + * gives us the correct TS type while still letting us write a runtime check. + */ +const OptionSchema = z.custom