Ai-setup writers-pattern
Add a new platform writer module in src/writers/ that generates and writes agent config files for a supported platform. Each writer exports a function that accepts a config interface, creates directories (rules/, skills/, mcp configs), writes files with proper formatting and frontmatter, and returns string[] of written file paths. Use when adding platform support for a new agent, integrating a new code AI tool, or extending caliber to support new targets. Do NOT use for modifying existing writers, refactoring scoring logic, or changing how writers are invoked.
git clone https://github.com/caliber-ai-org/ai-setup
T=$(mktemp -d) && git clone --depth=1 https://github.com/caliber-ai-org/ai-setup "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/writers-pattern" ~/.claude/skills/caliber-ai-org-ai-setup-writers-pattern-10c37c && rm -rf "$T"
.claude/skills/writers-pattern/SKILL.mdPlatform Writer Pattern
Critical
-
Every writer MUST:
- Export a single named function:
write<Platform>Config(config: <PlatformConfig>): string[] - Accept a platform-specific config interface defining what content to write
- Return
of all written file paths (used by manifest and progress display)string[] - Create parent directories with
before writing filesfs.mkdirSync(dir, { recursive: true }) - Wrap skill frontmatter exactly as shown in Step 4 (YAML between
markers)--- - NOT modify files outside the intended platform directories (e.g., Claude writer only touches
,.claude/
,CLAUDE.md
).mcp.json
- Export a single named function:
-
Integration point: Every writer MUST be imported and called in
within thesrc/writers/index.ts
function. Missing this step means the writer will never execute.writeSetup() -
Validation before Step 1: Verify the platform name is unique (
shows nols src/writers/
). If it exists, you are modifying, not adding.<platform>/index.ts
Instructions
Step 1: Define the Platform Config Interface
Verify the platform name is unique (see Critical). Create
src/writers/<platform>/index.ts.
At the top of the file, define a config interface that describes all content the writer accepts. The interface MUST include:
- A main markdown/text file (e.g.,
,claudeMd
,cursorrules
,agentsMd
)instructions - Optional nested arrays:
,rules
,skills
,mcpServers
, etc.instructionFiles - Each rule/skill/file has at minimum:
orname
,filename
, and for skills,contentdescription
Example (matching GitHub Copilot pattern already in codebase):
interface CopilotConfig { instructions: string; // Main content instructionFiles?: Array<{ filename: string; content: string }>; }
Validation: Confirm the interface property names match platform conventions (e.g., Claude uses
claudeMd, Cursor uses cursorrules). Check existing writers: src/writers/claude/index.ts line 9, src/writers/cursor/index.ts line 9, src/writers/codex/index.ts line 5.
Step 2: Implement the Writer Export Function
Export a function named
write<Platform>Config(config: <PlatformConfig>): string[] that:
-
Initialize an empty
array to track all written paths.written: string[] = [] -
For the main config file (e.g.,
for Claude,CLAUDE.md
for Cursor):.cursorrules- Wrap the content with platform-specific blocks: Use helpers from
src/writers/pre-commit-block.ts - Common blocks:
,appendPreCommitBlock(content, platform)
,appendLearningsBlock(content)appendSyncBlock(content) - Examples from actual codebase:
- Claude (
line 19-22):src/writers/claude/index.tsappendSyncBlock(appendLearningsBlock(appendPreCommitBlock(config.claudeMd))) - Cursor (
line 19-22): No sync block; injects rules instead (pre-commit, learnings, sync as separate files)src/writers/cursor/index.ts - Codex (
line 13-16):src/writers/codex/index.tsappendLearningsBlock(appendPreCommitBlock(config.agentsMd, 'codex')) - Copilot (
line 19-22):src/writers/github-copilot/index.tsappendSyncBlock(appendLearningsBlock(appendPreCommitBlock(config.instructions, 'copilot')))
- Claude (
- Write to the exact path (e.g.,
) and push tofs.writeFileSync('CLAUDE.md', wrappedContent)
.written
- Wrap the content with platform-specific blocks: Use helpers from
-
For rules (if applicable): Platform convention determines directory.
- Cursor uses
, Claude uses.cursor/rules/
(see.claude/rules/
lines 123-126 and 135-137)src/writers/index.ts - For each rule: create the directory, write to
, push path to<dir>/<rule.filename>written - Cursor special case (
line 24-27): Cursor injects three system rules:src/writers/cursor/index.tsconst preCommitRule = getCursorPreCommitRule(); const learningsRule = getCursorLearningsRule(); const syncRule = getCursorSyncRule(); const allRules = [...(config.rules || []), preCommitRule, learningsRule, syncRule];
- Cursor uses
-
For skills: Write with YAML frontmatter.
- Directory pattern:
,.claude/skills/<skillName>/SKILL.md
,.cursor/skills/<skillName>/SKILL.md
,.agents/skills/<skillName>/SKILL.md.opencode/skills/<skillName>/SKILL.md - Frontmatter format (exact indentation from
line 40-47):src/writers/claude/index.ts--- name: <skill.name> description: <skill.description> paths: - <optional path pattern 1> - <optional path pattern 2> --- <skill.content> - For Opencode (
line 30): Usesrc/writers/opencode/index.ts
frombuildSkillContent(skill)
instead of manual frontmatter.src/lib/builtin-skills.js - Create directory with
, write skill, push tofs.mkdirSync(skillDir, { recursive: true })written
- Directory pattern:
-
For MCP Servers (if applicable): Write/merge JSON at platform-specific path.
- Claude (
line 54-65):src/writers/claude/index.ts
at root.mcp.json - Cursor (
line 54-69):src/writers/cursor/index.ts.cursor/mcp.json - Pattern: Read existing servers (if file exists, try to parse JSON with try/catch), merge with new servers using spread operator
, write merged object{ ...existingServers, ...config.mcpServers } - Wrap in
and output as pretty-printed JSON:{ mcpServers: mergedServers }JSON.stringify(wrapped, null, 2)
- Claude (
-
For instruction files (GitHub Copilot,
line 26-33): Write tosrc/writers/github-copilot/index.ts
directory..github/instructions/- Create directory, iterate files, write each to
, push paths<dir>/<file.filename>
- Create directory, iterate files, write each to
Return the
written array.
Validation: Confirm all file write operations are synchronous (
fs.writeFileSync, fs.mkdirSync). Ensure every written path is added to the array. No async operations allowed.
Step 3: Add Type Exports (if complex interface)
If the config interface may be reused elsewhere (e.g., in
src/writers/index.ts for the AgentSetup type), export the interface from the module.
Validation: Check
src/writers/index.ts lines 15-19 to see if new agent setup params are needed.
Step 4: Import and Register in src/writers/index.ts
src/writers/index.tsOpen
src/writers/index.ts. At the top (around line 2-6), add:
import { write<Platform>Config } from './<platform>/index.js';
Update the
AgentSetup interface (around line 12-20):
- Add
to the'<platform>'
tuple (line 13:targetAgent
)('claude' | 'cursor' | 'codex' | 'opencode' | 'github-copilot' | '<platform>')[] - Add a new property:
<platform>?: Parameters<typeof write<Platform>Config>[0];
Update
getFilesToWrite() function (starting line 117): Add a new conditional block:
if (setup.targetAgent.includes('<platform>') && setup.<platform>) { files.push('<main-config-file>'); if (setup.<platform>.rules) { for (const r of setup.<platform>.rules) files.push(`<rules-dir>/${r.filename}`); } if (setup.<platform>.skills) { for (const s of setup.<platform>.skills) files.push(`<skills-dir>/${s.name}/SKILL.md`); } // ... repeat for other config types (mcpServers, instructionFiles, etc.) }
Update
writeSetup() function (starting line 22): Add a new conditional block before the return (after line 56):
if (setup.targetAgent.includes('<platform>') && setup.<platform>) { written.push(...write<Platform>Config(setup.<platform>)); }
Validation: Confirm the function call order in
writeSetup() is consistent (line 37-56): claude → cursor → codex → opencode → github-copilot → (new platform). This ensures AGENTS.md is written once if shared (as with Codex/Opencode, see line 50-52).
Step 5: Add Tests
Create
src/writers/__tests__/<platform>.test.ts following the vitest pattern in src/writers/__tests__/codex.test.ts:
- Mock
module:fsvi.mock('fs') - Mock return values in
:beforeEachvi.mocked(fs.existsSync).mockReturnValue(false) - Test that:
- Main config file is written to correct path
- Returned array includes all written paths
- Directories are created before file writes (use
).toHaveBeenCalledWith(path, { recursive: true }) - Skills have correct frontmatter format (check
)vi.mocked(fs.writeFileSync).mock.calls - MCP/instruction files are merged/created correctly
- Pre-commit/learnings/sync blocks are included in main file (expect content
).toContain('caliber:managed:pre-commit')
Run:
npm test -- src/writers/__tests__/<platform>.test.ts
Validation: All tests pass. Confirm mocked file operations match actual file system structure.
Step 6: Update detectSyncedAgents()
in src/commands/refresh.ts
(Optional)
detectSyncedAgents()src/commands/refresh.tsIf the platform writes config files with distinct naming (e.g.,
.newplatform/), update the detection logic around line 68-77 so end-user refresh output correctly identifies the synced platform:
if (joined.includes('.newplatform/') || joined.includes('newplatform-config')) { agents.push('<Platform Name>'); }
Validation: Run
npm run refresh and confirm the summary message lists the new platform.
Examples
Example 1: Add a hypothetical "DevCode" platform writer
User says: "Add support for DevCode, a new agent that reads config from
.devcode/settings.md and .devcode/rules/ directory."
Actions taken:
-
Create
(matching pattern fromsrc/writers/devcode/index.ts
):src/writers/claude/index.tsimport fs from 'fs'; import path from 'path'; import { appendPreCommitBlock, appendLearningsBlock } from '../pre-commit-block.js'; interface DevcodeConfig { settingsMd: string; rules?: Array<{ filename: string; content: string }>; } export function writeDevcodeConfig(config: DevcodeConfig): string[] { const written: string[] = []; fs.writeFileSync( '.devcode/settings.md', appendLearningsBlock(appendPreCommitBlock(config.settingsMd, 'devcode')) ); written.push('.devcode/settings.md'); if (config.rules?.length) { const rulesDir = path.join('.devcode', 'rules'); if (!fs.existsSync(rulesDir)) fs.mkdirSync(rulesDir, { recursive: true }); for (const rule of config.rules) { const rulePath = path.join(rulesDir, rule.filename); fs.writeFileSync(rulePath, rule.content); written.push(rulePath); } } return written; } -
Update
:src/writers/index.ts- Line 2: Add
import { writeDevcodeConfig } from './devcode/index.js'; - Line 13: Change
tuple to includetargetAgent'devcode' - Line 19: Add
devcode?: Parameters<typeof writeDevcodeConfig>[0]; - Line 117+: Add devcode block to
(match Codex pattern lines 144-149)getFilesToWrite() - Line 37+: Add devcode block to
(match Codex pattern lines 45-47)writeSetup() - Line 68+: Update
to check fordetectSyncedAgents().devcode/
- Line 2: Add
-
Create
(matchingsrc/writers/__tests__/devcode.test.ts
):src/writers/__tests__/codex.test.tsimport { describe, it, expect, vi, beforeEach } from 'vitest'; import fs from 'fs'; import path from 'path'; vi.mock('fs'); import { writeDevcodeConfig } from '../devcode/index.js'; describe('writeDevcodeConfig', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(fs.existsSync).mockReturnValue(false); }); it('writes settings.md and rules', () => { const config = { settingsMd: '# DevCode Config', rules: [{ filename: 'style.md', content: 'Style rules' }], }; const written = writeDevcodeConfig(config); expect(written).toContain('.devcode/settings.md'); expect(written).toContain(path.join('.devcode', 'rules', 'style.md')); }); }); -
Run:
npm test && npm run build
Result: Caliber now generates
.devcode/settings.md and rules on caliber refresh and caliber init.
Common Issues
Issue: "TypeError: write<Platform>Config is not a function"
- Fix: Verify the function is exported (not just defined). Check
in the writer file. Missingexport function write<Platform>Config(...)
is a common mistake.export
Issue: "ENOENT: no such file or directory, open '.platform/config.md'"
- Fix: The parent directory was not created. Ensure
is called beforefs.mkdirSync(parentDir, { recursive: true })
. See correct order infs.writeFileSync(filePath, content)
lines 26-27.src/writers/claude/index.ts
Issue: "Skill file has no frontmatter / malformed YAML"
- Fix: Verify frontmatter format is exactly (no extra blank lines):
Use---\nname: <name>\ndescription: <desc>\n---\n<content>
and test with a single skill first. Compare with working code in[...].join('\n')
lines 40-48.src/writers/claude/index.ts
Issue: "MCP servers not merging, file is truncated"
- Fix: Confirm the merge pattern from
line 54-65: read existing JSON (with try/catch), parse safely, merge with spread operatorsrc/writers/claude/index.ts
, then write the merged object. Do NOT overwrite — always merge.{ ...existingServers, ...config.mcpServers }
Issue: "new writer is called but written files are empty array"
- Fix: Verify the writer function returns the
array. Check that every file operation pushes towritten
. Missing awritten
afterwritten.push(filePath)
is the most common error. Seefs.writeFileSync()
lines 17 and 32 for correct pattern.src/writers/codex/index.ts
Issue: "Tests mock fs but actual files are created in .tmp/ or cause permission errors"
- Fix: Ensure
is at the top of the test file before any imports. Allvi.mock('fs')
operations will be mocked and return mock values fromfs
setup. SeebeforeEach
line 5.src/writers/__tests__/codex.test.ts
Issue: "Cursor pre-commit rule not applied, or learnings block missing"
- Fix: Cursor injects system rules during the write, unlike Claude which uses block appenders. Check that
,getCursorPreCommitRule()
, andgetCursorLearningsRule()
are called and concatenated with user rules before iterating (seegetCursorSyncRule()
lines 24-27). Claude and Codex use block-append helpers instead.src/writers/cursor/index.ts