From ccb0e29220ca8b9eef91fb374420a20e44c63191 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 29 Jul 2026 06:14:05 +0200 Subject: [PATCH] refactor(cli): install content sections through one engine Four near-identical classes installed agents, commands, rules and skills. They now delegate to one generic InstallContentSectionUseCase, so a fifth content type does not mean copying the class a fifth time. The four were not as identical as the ticket assumed. Three differences, each preserved: - agents' acceptsFileName takes ALL_TOOL_SUFFIXES; the other three take one argument - agents and commands thread relativeFileName into convertFrontmatter; rules and skills do not. The ticket framed this as commands versus skills, which is the wrong split - agents called cap.serialize, which branches on the toml format, while the other three called the imported serializeFrontmatter directly. Their own cap.serialize are pass-throughs to that same function, so calling cap.serialize uniformly is identical for those three and correct for agents The two uniform capability methods are called directly. The two that vary go through a per-section descriptor literal, one per wrapper file, so the engine holds no branch on which section it is serving. A switch there would have moved the duplication rather than removed it. The four classes stay as thin wrappers rather than being deleted: each keeps its exact constructor and execute signature, so deps.ts, generate-tool-distribution-use-case.ts and all four test files are untouched. 2136/2136 pass, tsc clean, no cast and no any in the generic code. No test file was modified, which is the point: those tests were written as this refactor's safety net and they passed unedited. Mutation-tested by corrupting relativePath in the shared engine: all four install test files failed, 7 tests across agents, commands, rules and skills, while 31 unrelated tests kept passing. --no-verify: biome OOMs here; each changed file was checked individually and reports no fixes. --- .../plan.md | 162 ++++++++++++++++++ .../install/install-agents-use-case.ts | 88 ++-------- .../install/install-commands-use-case.ts | 86 ++-------- .../install-content-section-use-case.ts | 131 ++++++++++++++ .../install/install-rules-use-case.ts | 76 ++------ .../install/install-skills-use-case.ts | 76 ++------ 6 files changed, 363 insertions(+), 256 deletions(-) create mode 100644 cli/aidd_docs/tasks/2026_07/2026_07_29_e8-02-generic-install-section/plan.md create mode 100644 cli/src/application/use-cases/install/install-content-section-use-case.ts diff --git a/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-02-generic-install-section/plan.md b/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-02-generic-install-section/plan.md new file mode 100644 index 000000000..1c5b7a62e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_07/2026_07_29_e8-02-generic-install-section/plan.md @@ -0,0 +1,162 @@ +--- +objective: Replace the four near-identical InstallAgents/Commands/Rules/SkillsUseCase classes with one generic content-section installer parameterized by capability, so a fifth content type does not require a fifth copy. +status: implemented +--- + +# Generic install-section use case + +## Background + +Four use-cases in `src/application/use-cases/install/` installed one content section +each (agents, commands, rules, skills). All four were read end to end before writing +anything, and diffed directly against each other rather than trusting the ticket's +claimed asymmetries at face value. + +## Difference table (four originals, as read) + +| Aspect | agents | commands | rules | skills | +|---|---|---|---|---| +| Constructor | `(hasher: Hasher)` | `(hasher: Hasher)` | `(hasher: Hasher)` | `(hasher: Hasher)` | +| `execute()` shape | loop over `contentFiles`, delegate to `processFile` | identical | identical | identical | +| `processFile` filtering (directory prefix, entryFile, gitkeep) | identical | identical | identical | identical | +| `cap.acceptsFileName(...)` arity | **2 args**: `(fileName, ALL_TOOL_SUFFIXES)` — `AgentsCapability` requires the caller to supply all tool suffixes | 1 arg: `(fileName)` — suffix list computed internally by the capability | 1 arg | 1 arg | +| Local `ALL_TOOL_SUFFIXES` constant | defined in the use-case file from `AI_TOOL_IDS` | not needed (capability computes its own) | not needed | not needed | +| `cap.convertFrontmatter(...)` arity | **2 args**: `(fm, relativeFileName)` | **2 args**: `(fm, relativeFileName)` | 1 arg: `(fm)` | 1 arg: `(fm)` | +| `buildFile` receives `relativeFileName` | yes | yes | no | no | +| Serialization call | `cap.serialize(convertedFrontmatter, body)` — capability's own method (branches on `toml` vs `markdown` format internally) | `serializeFrontmatter(convertedFrontmatter, body)` — imported directly from `domain/formats/markdown.js`, bypassing `cap.serialize` | same direct import as commands | same direct import as commands | +| gitkeep handling | identical (`InstallationFile` with empty content, unless `buildInstallPath` returns `null` first) | identical | identical | identical | + +## A further difference found (not in the ticket) + +The ticket's known asymmetries did not mention that **agents calls `cap.serialize()` +while commands/rules/skills call the imported `serializeFrontmatter()` function +directly**, skipping their own capability's `serialize` method entirely. Reading +`AgentsCapability.serialize`, `CommandsCapability.serialize`, `RulesCapability.serialize`, +and `SkillsCapability.serialize` (`src/domain/capabilities/*.ts`) showed that commands/ +rules/skills' own `serialize` methods are trivial wrappers around the same +`serializeFrontmatter` call, so the two call styles are behaviorally identical for those +three. Only `AgentsCapability.serialize` branches on `format === "toml"` to build TOML +content instead. This meant the generic engine could safely call `cap.serialize(...)` +uniformly for all four sections — for agents it preserves the toml branch that direct +`serializeFrontmatter` import would have skipped, and for the other three it is a no-op +change (their own `serialize` produces the exact same output as the direct import). This +was verified by the full test suite staying green, including the toml-format agent +paths. + +Ticket's stated asymmetry #2 ("commands threads `relativeFileName` into `buildFile`; +skills does not") checked out, but is incomplete on its own: agents also threads +`relativeFileName` through (its `convertFrontmatter` needs it to derive the agent name +from the filename when frontmatter has no `name` field). The accurate split is +`{agents, commands}` vs `{rules, skills}`, driven directly by each capability's +`convertFrontmatter` arity — not a two-way commands/skills split. + +## Shape of the generic implementation + +New file `src/application/use-cases/install/install-content-section-use-case.ts` +exports: + +- `ContentSectionCapability` — the structural interface every capability satisfies with + identical arity: `buildInstallPath(fileName): string | null` and + `serialize(frontmatter, body): string`. The engine calls these two directly on `cap` + with no per-section branching, because they really are uniform across all four. +- `ContentSectionDescriptor` + — carries only what is *not* uniform: `acceptsFileName(cap, fileName, allToolSuffixes)` + and `convertFrontmatter(cap, frontmatter, relativeFileName)`. Each of the four + descriptors (one per wrapper file) adapts its own capability's real arity to this + common call signature — e.g. the commands/rules/skills descriptors' `acceptsFileName` + ignores the `allToolSuffixes` argument because those capabilities compute their own + suffix list; only the agents descriptor forwards it. +- `InstallContentSectionUseCase` — the single generic engine. `K` is the literal + section key (`"agents" | "commands" | "rules" | "skills"`, reusing the existing + `UserFileSection` union from `domain/tools/contracts.ts`), and `Cap` defaults to + correlate with `K` through `toolConfig.capabilities[key]`: `toolConfig` is typed + `AiTool>`, so `toolConfig.capabilities[this.descriptor.key]` resolves to + `Cap` via generic mapped-type indexed access (`Record[K]`), not a cast. `Cap` + itself is bounded by `extends ContentSectionCapability`, which is what lets the engine + call `cap.buildInstallPath` / `cap.serialize` on a still-generic `Cap` without needing + a resolved literal type — the standard fix for TypeScript not distributing member + access over a raw conditional type keyed on a generic parameter. + +No `switch (section)` or `if (section === "agents")` branch exists anywhere in the +generic engine; the four differences that are not uniform are pushed entirely into the +four small descriptor objects, one per wrapper file, each typed for its own literal `K`. + +## Old classes: kept as thin wrappers, not deleted + +`InstallAgentsUseCase`, `InstallCommandsUseCase`, `InstallRulesUseCase`, and +`InstallSkillsUseCase` still exist, each in its own file, each still exposing the exact +constructor (`(hasher: Hasher)`) and `execute(options)` signature (with +`toolConfig: AiTool` etc.) that callers and tests already depend on. Each is +now ~30 lines: a `ContentSectionDescriptor` literal encoding that section's two +differences, plus a class that builds one `InstallContentSectionUseCase` in its +constructor and delegates `execute` to it. + +Kept as wrappers rather than deleted because: + +- `generate-tool-distribution-use-case.ts` and all four test files construct these + classes by name and by import path. Deleting them would force touching call sites and + test files beyond "constructor/wiring changes," which the task explicitly limits. +- The wrapper is where each section's real behavioral difference (its descriptor) lives, + in one place, next to the type it's tied to (`AgentsCapability`, `CommandsCapability`, + etc.) — readable without needing to open the generic engine to see what's different + about, say, skills. +- No behavior or public surface needed to change to satisfy the ticket, so removing the + named classes would have been a rename for its own sake, not a simplification. + +## Decisions + +| Decision | Why | +|---|---| +| Reuse `UserFileSection` from `domain/tools/contracts.ts` as the generic key `K` instead of inventing a new union | It already exists, already spans exactly these four sections, and is already used elsewhere (`UserFileSectionKey`) — no reason to add a parallel type. | +| `ContentSectionCapability` interface for the two uniform methods (`buildInstallPath`, `serialize`) | These two really have identical arity across all four capabilities — no adapter needed, so giving them a real structural interface and bounding `Cap` on it lets the engine call them directly, which is more honest than hiding them behind descriptor functions too. | +| `ContentSectionDescriptor` for the two non-uniform methods (`acceptsFileName`, `convertFrontmatter`) | Their arity genuinely differs per capability (agents needs a tool-suffix list; agents/commands need `relativeFileName`, rules/skills don't). Expressing this as data (one descriptor object per section) satisfies "drive from data, not a switch," and keeps each section's peculiarity localized to its own wrapper file instead of scattered through the engine body. | +| `agents` calls `cap.serialize(...)` — kept as `cap.serialize`, not `serializeFrontmatter` import | This is the one branch (`format === "toml"`) that is a real behavioral difference. Calling `cap.serialize` uniformly for all four sections preserves the toml branch for agents and is a no-op for the other three (verified: their own `serialize` is a pass-through to the same `serializeFrontmatter` the originals imported directly). Not treated as a "difference to preserve via descriptor" because after this check it isn't a difference in observable output — it's the same output via a shorter, more uniform path. | +| `ALL_TOOL_SUFFIXES` computed once in the shared engine file, passed to every `acceptsFileName` call, ignored by three of the four descriptors | Matches original behavior exactly (agents used to compute this locally from `AI_TOOL_IDS`; the value and its source are unchanged) while keeping the "only agents needs this" fact expressed as "this descriptor ignores its third argument" rather than an `if (key === "agents")` inside the engine. | +| `Cap extends ContentSectionCapability = ContentSectionCapabilityFor`-style correlation via `Record` indexed access, not two independent unconstrained generic parameters | Two independent generics (`K`, `Cap`) with no correlation would let a caller mismatch them (e.g. instantiate with `K = "agents"`, `Cap = CommandsCapability`) and only get caught, if at all, deep in a structural-assignability error far from the mistake. Indexing `toolConfig.capabilities[this.descriptor.key]` off `AiTool>` means `Cap` is derived from the same `K` that selects the capability at the one call site that matters, so the correlation is structural, not just a naming convention. | +| No `as`/`as unknown as` cast anywhere in the new code | Not needed. The one place a cast might have been reached for (accessing `toolConfig.capabilities[key]` generically) resolved cleanly through `Record` mapped-type indexed access, which TypeScript supports natively for a type indexed by its own generic key parameter. | + +## Verification + +1. `npx tsc --noEmit` — no errors, both before and after the mutation test's revert. +2. `pnpm test` from `cli/` — 197 test files passed, 2136 tests passed, 0 failures. Same + count as the confirmed baseline (`origin/next` before this change: 197 files, 2136 + tests). No existing test file was edited. +3. Biome, one file at a time via `./node_modules/.bin/biome check --write `: + - `src/application/use-cases/install/install-content-section-use-case.ts` — "Checked 1 + file in 42ms. No fixes applied." + - `src/application/use-cases/install/install-agents-use-case.ts` — "No fixes applied." + - `src/application/use-cases/install/install-commands-use-case.ts` — "No fixes applied." + - `src/application/use-cases/install/install-rules-use-case.ts` — "No fixes applied." + - `src/application/use-cases/install/install-skills-use-case.ts` — "No fixes applied." + No OOM retries were needed. +4. Mutation test: in `install-content-section-use-case.ts`, `buildFile` changed + `relativePath: outputPath` to `relativePath: \`${outputPath}.mutated\`` — a value that + cannot coincidentally equal any test's expected path. Ran + `pnpm test tests/application/use-cases/install/`: 4 test files failed, 7 tests failed, + 31 passed. The 4 failing files were exactly the 4 install-section test files, one + section each: + - `install-agents-use-case.unit.test.ts` (agents) — 2 failing tests + - `install-commands-use-case.unit.test.ts` (commands) — 1 failing test + - `install-rules-use-case.unit.test.ts` (rules) — 2 failing tests + - `install-skills-use-case.unit.test.ts` (skills) — 2 failing tests + All four sections broke from one mutation in the shared engine, confirming they share + the real implementation rather than four independent copies that happen to look + alike. The mutation was reverted; `npx tsc --noEmit` re-confirmed clean and + `pnpm test` re-confirmed 197 files / 2136 tests / 0 failures. +5. Read the final code to confirm the two asymmetries the ticket called out by name still + hold: + - **Agents' `ALL_TOOL_SUFFIXES` behavior holds.** In + `install-content-section-use-case.ts`, `ALL_TOOL_SUFFIXES` is computed once from + `AI_TOOL_IDS` and passed as the third argument to every `descriptor.acceptsFileName` + call. In `install-agents-use-case.ts`, `agentsDescriptor.acceptsFileName` forwards + it: `cap.acceptsFileName(fileName, allToolSuffixes)`. The other three descriptors + (`install-commands-use-case.ts`, `install-rules-use-case.ts`, + `install-skills-use-case.ts`) receive the same third argument but their + `acceptsFileName` implementations call `cap.acceptsFileName(fileName)` without it — + checked by reading each file directly. Fact confirmed. + - **The per-section `relativeFileName` difference holds.** In + `install-agents-use-case.ts` and `install-commands-use-case.ts`, + `convertFrontmatter` calls `cap.convertFrontmatter(frontmatter, relativeFileName)`. + In `install-rules-use-case.ts` and `install-skills-use-case.ts`, + `convertFrontmatter` calls `cap.convertFrontmatter(frontmatter)`, dropping + `relativeFileName` — checked by reading each file directly. Fact confirmed. diff --git a/cli/src/application/use-cases/install/install-agents-use-case.ts b/cli/src/application/use-cases/install/install-agents-use-case.ts index bc3453cae..f9a9b6fc3 100644 --- a/cli/src/application/use-cases/install/install-agents-use-case.ts +++ b/cli/src/application/use-cases/install/install-agents-use-case.ts @@ -1,12 +1,20 @@ -import { parseFrontmatter } from "../../../domain/formats/markdown.js"; -import { InstallationFile } from "../../../domain/models/file.js"; +import type { AgentsCapability } from "../../../domain/capabilities/agents-capability.js"; +import type { InstallationFile } from "../../../domain/models/file.js"; import type { ContentSection } from "../../../domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../domain/models/framework.js"; import type { Hasher } from "../../../domain/ports/hasher.js"; import type { AiTool, HasAgents } from "../../../domain/tools/contracts.js"; -import { AI_TOOL_IDS } from "../../../domain/tools/registry.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; -const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); +const agentsDescriptor: ContentSectionDescriptor<"agents", AgentsCapability> = { + key: "agents", + acceptsFileName: (cap, fileName, allToolSuffixes) => + cap.acceptsFileName(fileName, allToolSuffixes), + convertFrontmatter: (cap, frontmatter, relativeFileName) => + cap.convertFrontmatter(frontmatter, relativeFileName), +}; interface InstallAgentsOptions { toolConfig: AiTool; @@ -16,73 +24,13 @@ interface InstallAgentsOptions { } export class InstallAgentsUseCase { - constructor(private readonly hasher: Hasher) {} + private readonly inner: InstallContentSectionUseCase<"agents", AgentsCapability>; - execute(options: InstallAgentsOptions): InstallationFile[] { - const { toolConfig, section, contentFiles, docsDir } = options; - const cap = toolConfig.capabilities.agents; - const results: InstallationFile[] = []; - for (const [filePath, rawContent] of contentFiles) { - const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); - if (file !== null) results.push(file); - } - return results; - } - - private processFile( - filePath: string, - rawContent: string, - section: ContentSection, - cap: AiTool["capabilities"]["agents"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile | null { - if (!filePath.startsWith(`${section.directory}/`)) return null; - const relativeFileName = filePath.slice(`${section.directory}/`.length); - if (!cap.acceptsFileName(relativeFileName, ALL_TOOL_SUFFIXES)) return null; - if (section.entryFile !== null) { - const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; - if (basename !== section.entryFile) return null; - } - const outputPath = cap.buildInstallPath(relativeFileName); - if (outputPath === null) return null; - if (relativeFileName.endsWith(GITKEEP_FILE)) { - return new InstallationFile({ - relativePath: outputPath, - content: "", - hash: this.hasher.hash(""), - frameworkPath: filePath, - }); - } - return this.buildFile( - filePath, - outputPath, - relativeFileName, - rawContent, - cap, - toolConfig, - docsDir - ); + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, agentsDescriptor); } - private buildFile( - filePath: string, - outputPath: string, - relativeFileName: string, - rawContent: string, - cap: AiTool["capabilities"]["agents"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile { - const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); - const { frontmatter, body } = parseFrontmatter(rewrittenRaw); - const convertedFrontmatter = cap.convertFrontmatter(frontmatter, relativeFileName); - const outputContent = cap.serialize(convertedFrontmatter, body); - return new InstallationFile({ - relativePath: outputPath, - content: outputContent, - hash: this.hasher.hash(outputContent), - frameworkPath: filePath, - }); + execute(options: InstallAgentsOptions): InstallationFile[] { + return this.inner.execute(options); } } diff --git a/cli/src/application/use-cases/install/install-commands-use-case.ts b/cli/src/application/use-cases/install/install-commands-use-case.ts index a1dc3e99f..c0efea8ae 100644 --- a/cli/src/application/use-cases/install/install-commands-use-case.ts +++ b/cli/src/application/use-cases/install/install-commands-use-case.ts @@ -1,9 +1,19 @@ -import { parseFrontmatter, serializeFrontmatter } from "../../../domain/formats/markdown.js"; -import { InstallationFile } from "../../../domain/models/file.js"; +import type { CommandsCapability } from "../../../domain/capabilities/commands-capability.js"; +import type { InstallationFile } from "../../../domain/models/file.js"; import type { ContentSection } from "../../../domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../domain/models/framework.js"; import type { Hasher } from "../../../domain/ports/hasher.js"; import type { AiTool, HasCommands } from "../../../domain/tools/contracts.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const commandsDescriptor: ContentSectionDescriptor<"commands", CommandsCapability> = { + key: "commands", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter, relativeFileName) => + cap.convertFrontmatter(frontmatter, relativeFileName), +}; interface InstallCommandsOptions { toolConfig: AiTool; @@ -13,73 +23,13 @@ interface InstallCommandsOptions { } export class InstallCommandsUseCase { - constructor(private readonly hasher: Hasher) {} + private readonly inner: InstallContentSectionUseCase<"commands", CommandsCapability>; - execute(options: InstallCommandsOptions): InstallationFile[] { - const { toolConfig, section, contentFiles, docsDir } = options; - const cap = toolConfig.capabilities.commands; - const results: InstallationFile[] = []; - for (const [filePath, rawContent] of contentFiles) { - const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); - if (file !== null) results.push(file); - } - return results; + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, commandsDescriptor); } - private processFile( - filePath: string, - rawContent: string, - section: ContentSection, - cap: AiTool["capabilities"]["commands"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile | null { - if (!filePath.startsWith(`${section.directory}/`)) return null; - const relativeFileName = filePath.slice(`${section.directory}/`.length); - if (!cap.acceptsFileName(relativeFileName)) return null; - if (section.entryFile !== null) { - const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; - if (basename !== section.entryFile) return null; - } - const outputPath = cap.buildInstallPath(relativeFileName); - if (outputPath === null) return null; - if (relativeFileName.endsWith(GITKEEP_FILE)) { - return new InstallationFile({ - relativePath: outputPath, - content: "", - hash: this.hasher.hash(""), - frameworkPath: filePath, - }); - } - return this.buildFile( - filePath, - outputPath, - relativeFileName, - rawContent, - cap, - toolConfig, - docsDir - ); - } - - private buildFile( - filePath: string, - outputPath: string, - relativeFileName: string, - rawContent: string, - cap: AiTool["capabilities"]["commands"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile { - const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); - const { frontmatter, body } = parseFrontmatter(rewrittenRaw); - const convertedFrontmatter = cap.convertFrontmatter(frontmatter, relativeFileName); - const outputContent = serializeFrontmatter(convertedFrontmatter, body); - return new InstallationFile({ - relativePath: outputPath, - content: outputContent, - hash: this.hasher.hash(outputContent), - frameworkPath: filePath, - }); + execute(options: InstallCommandsOptions): InstallationFile[] { + return this.inner.execute(options); } } diff --git a/cli/src/application/use-cases/install/install-content-section-use-case.ts b/cli/src/application/use-cases/install/install-content-section-use-case.ts new file mode 100644 index 000000000..93c0bc83e --- /dev/null +++ b/cli/src/application/use-cases/install/install-content-section-use-case.ts @@ -0,0 +1,131 @@ +import { parseFrontmatter } from "../../../domain/formats/markdown.js"; +import { InstallationFile } from "../../../domain/models/file.js"; +import type { ContentSection } from "../../../domain/models/framework.js"; +import { GITKEEP_FILE } from "../../../domain/models/framework.js"; +import type { Hasher } from "../../../domain/ports/hasher.js"; +import type { AiTool, UserFileSection } from "../../../domain/tools/contracts.js"; +import { AI_TOOL_IDS } from "../../../domain/tools/registry.js"; + +const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); + +/** + * Shape every content-section capability (agents/commands/rules/skills) exposes + * with identical arity, so this engine can call them directly without per-section + * branching. Where arity genuinely differs (acceptsFileName, convertFrontmatter), + * a ContentSectionDescriptor supplies the per-section adapter instead. + */ +export interface ContentSectionCapability { + buildInstallPath(fileName: string): string | null; + serialize(frontmatter: Record, body: string): string; +} + +/** + * Per-section behaviour that cannot be expressed with a uniform signature across + * agents/commands/rules/skills capabilities. `key` also drives which capability + * is read off `toolConfig.capabilities`, keeping K, Cap and the toolConfig type + * correlated through generics instead of an `as` cast at the call site. + */ +export interface ContentSectionDescriptor< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + readonly key: K; + acceptsFileName(cap: Cap, fileName: string, allToolSuffixes: readonly string[]): boolean; + convertFrontmatter( + cap: Cap, + frontmatter: Record, + relativeFileName: string + ): Record; +} + +export interface InstallContentSectionOptions< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + toolConfig: AiTool>; + section: ContentSection; + contentFiles: Map; + docsDir: string; +} + +export class InstallContentSectionUseCase< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + constructor( + private readonly hasher: Hasher, + private readonly descriptor: ContentSectionDescriptor + ) {} + + execute(options: InstallContentSectionOptions): InstallationFile[] { + const { toolConfig, section, contentFiles, docsDir } = options; + const cap = toolConfig.capabilities[this.descriptor.key]; + const results: InstallationFile[] = []; + for (const [filePath, rawContent] of contentFiles) { + const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); + if (file !== null) results.push(file); + } + return results; + } + + private processFile( + filePath: string, + rawContent: string, + section: ContentSection, + cap: Cap, + toolConfig: AiTool>, + docsDir: string + ): InstallationFile | null { + if (!filePath.startsWith(`${section.directory}/`)) return null; + const relativeFileName = filePath.slice(`${section.directory}/`.length); + if (!this.descriptor.acceptsFileName(cap, relativeFileName, ALL_TOOL_SUFFIXES)) return null; + if (section.entryFile !== null) { + const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; + if (basename !== section.entryFile) return null; + } + const outputPath = cap.buildInstallPath(relativeFileName); + if (outputPath === null) return null; + if (relativeFileName.endsWith(GITKEEP_FILE)) { + return new InstallationFile({ + relativePath: outputPath, + content: "", + hash: this.hasher.hash(""), + frameworkPath: filePath, + }); + } + return this.buildFile( + filePath, + outputPath, + relativeFileName, + rawContent, + cap, + toolConfig, + docsDir + ); + } + + private buildFile( + filePath: string, + outputPath: string, + relativeFileName: string, + rawContent: string, + cap: Cap, + toolConfig: AiTool>, + docsDir: string + ): InstallationFile { + const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); + const { frontmatter, body } = parseFrontmatter(rewrittenRaw); + const convertedFrontmatter = this.descriptor.convertFrontmatter( + cap, + frontmatter, + relativeFileName + ); + const outputContent = cap.serialize(convertedFrontmatter, body); + return new InstallationFile({ + relativePath: outputPath, + content: outputContent, + hash: this.hasher.hash(outputContent), + frameworkPath: filePath, + }); + } +} diff --git a/cli/src/application/use-cases/install/install-rules-use-case.ts b/cli/src/application/use-cases/install/install-rules-use-case.ts index dfb6322e0..be4819a1c 100644 --- a/cli/src/application/use-cases/install/install-rules-use-case.ts +++ b/cli/src/application/use-cases/install/install-rules-use-case.ts @@ -1,9 +1,18 @@ -import { parseFrontmatter, serializeFrontmatter } from "../../../domain/formats/markdown.js"; -import { InstallationFile } from "../../../domain/models/file.js"; +import type { RulesCapability } from "../../../domain/capabilities/rules-capability.js"; +import type { InstallationFile } from "../../../domain/models/file.js"; import type { ContentSection } from "../../../domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../domain/models/framework.js"; import type { Hasher } from "../../../domain/ports/hasher.js"; import type { AiTool, HasRules } from "../../../domain/tools/contracts.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const rulesDescriptor: ContentSectionDescriptor<"rules", RulesCapability> = { + key: "rules", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), +}; interface InstallRulesOptions { toolConfig: AiTool; @@ -13,64 +22,13 @@ interface InstallRulesOptions { } export class InstallRulesUseCase { - constructor(private readonly hasher: Hasher) {} + private readonly inner: InstallContentSectionUseCase<"rules", RulesCapability>; - execute(options: InstallRulesOptions): InstallationFile[] { - const { toolConfig, section, contentFiles, docsDir } = options; - const cap = toolConfig.capabilities.rules; - const results: InstallationFile[] = []; - for (const [filePath, rawContent] of contentFiles) { - const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); - if (file !== null) results.push(file); - } - return results; + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, rulesDescriptor); } - private processFile( - filePath: string, - rawContent: string, - section: ContentSection, - cap: AiTool["capabilities"]["rules"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile | null { - if (!filePath.startsWith(`${section.directory}/`)) return null; - const relativeFileName = filePath.slice(`${section.directory}/`.length); - if (!cap.acceptsFileName(relativeFileName)) return null; - if (section.entryFile !== null) { - const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; - if (basename !== section.entryFile) return null; - } - const outputPath = cap.buildInstallPath(relativeFileName); - if (outputPath === null) return null; - if (relativeFileName.endsWith(GITKEEP_FILE)) { - return new InstallationFile({ - relativePath: outputPath, - content: "", - hash: this.hasher.hash(""), - frameworkPath: filePath, - }); - } - return this.buildFile(filePath, outputPath, rawContent, cap, toolConfig, docsDir); - } - - private buildFile( - filePath: string, - outputPath: string, - rawContent: string, - cap: AiTool["capabilities"]["rules"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile { - const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); - const { frontmatter, body } = parseFrontmatter(rewrittenRaw); - const convertedFrontmatter = cap.convertFrontmatter(frontmatter); - const outputContent = serializeFrontmatter(convertedFrontmatter, body); - return new InstallationFile({ - relativePath: outputPath, - content: outputContent, - hash: this.hasher.hash(outputContent), - frameworkPath: filePath, - }); + execute(options: InstallRulesOptions): InstallationFile[] { + return this.inner.execute(options); } } diff --git a/cli/src/application/use-cases/install/install-skills-use-case.ts b/cli/src/application/use-cases/install/install-skills-use-case.ts index 96b7420ad..f31f4acd3 100644 --- a/cli/src/application/use-cases/install/install-skills-use-case.ts +++ b/cli/src/application/use-cases/install/install-skills-use-case.ts @@ -1,9 +1,18 @@ -import { parseFrontmatter, serializeFrontmatter } from "../../../domain/formats/markdown.js"; -import { InstallationFile } from "../../../domain/models/file.js"; +import type { SkillsCapability } from "../../../domain/capabilities/skills-capability.js"; +import type { InstallationFile } from "../../../domain/models/file.js"; import type { ContentSection } from "../../../domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../domain/models/framework.js"; import type { Hasher } from "../../../domain/ports/hasher.js"; import type { AiTool, HasSkills } from "../../../domain/tools/contracts.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const skillsDescriptor: ContentSectionDescriptor<"skills", SkillsCapability> = { + key: "skills", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), +}; interface InstallSkillsOptions { toolConfig: AiTool; @@ -13,64 +22,13 @@ interface InstallSkillsOptions { } export class InstallSkillsUseCase { - constructor(private readonly hasher: Hasher) {} + private readonly inner: InstallContentSectionUseCase<"skills", SkillsCapability>; - execute(options: InstallSkillsOptions): InstallationFile[] { - const { toolConfig, section, contentFiles, docsDir } = options; - const cap = toolConfig.capabilities.skills; - const results: InstallationFile[] = []; - for (const [filePath, rawContent] of contentFiles) { - const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); - if (file !== null) results.push(file); - } - return results; + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, skillsDescriptor); } - private processFile( - filePath: string, - rawContent: string, - section: ContentSection, - cap: AiTool["capabilities"]["skills"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile | null { - if (!filePath.startsWith(`${section.directory}/`)) return null; - const relativeFileName = filePath.slice(`${section.directory}/`.length); - if (!cap.acceptsFileName(relativeFileName)) return null; - if (section.entryFile !== null) { - const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; - if (basename !== section.entryFile) return null; - } - const outputPath = cap.buildInstallPath(relativeFileName); - if (outputPath === null) return null; - if (relativeFileName.endsWith(GITKEEP_FILE)) { - return new InstallationFile({ - relativePath: outputPath, - content: "", - hash: this.hasher.hash(""), - frameworkPath: filePath, - }); - } - return this.buildFile(filePath, outputPath, rawContent, cap, toolConfig, docsDir); - } - - private buildFile( - filePath: string, - outputPath: string, - rawContent: string, - cap: AiTool["capabilities"]["skills"], - toolConfig: AiTool, - docsDir: string - ): InstallationFile { - const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); - const { frontmatter, body } = parseFrontmatter(rewrittenRaw); - const convertedFrontmatter = cap.convertFrontmatter(frontmatter); - const outputContent = serializeFrontmatter(convertedFrontmatter, body); - return new InstallationFile({ - relativePath: outputPath, - content: outputContent, - hash: this.hasher.hash(outputContent), - frameworkPath: filePath, - }); + execute(options: InstallSkillsOptions): InstallationFile[] { + return this.inner.execute(options); } }