diff --git a/.github/release-notes-template.md b/.github/release-notes-template.md index 8d27503..358ed2d 100644 --- a/.github/release-notes-template.md +++ b/.github/release-notes-template.md @@ -2,7 +2,7 @@ ## Highlights -- Standardized release packaging across all supported surfaces. +- Uniformed template generation - See attached assets below for install-ready downloads. ## Release Assets diff --git a/bindery-core/src/templates.ts b/bindery-core/src/templates.ts index 56d2aef..28fdd27 100644 --- a/bindery-core/src/templates.ts +++ b/bindery-core/src/templates.ts @@ -64,11 +64,11 @@ const TEMPLATES: Record = { // Bump per-file version inside the matching module's `meta` when content // changes significantly so users with outdated content are prompted. -export const FILE_VERSION_INFO: Record = +export const FILE_VERSION_INFO: Record = Object.fromEntries( Object.values(TEMPLATES).map(t => [ t.meta.file, - { version: t.meta.version, label: t.meta.label, zip: t.meta.zip }, + { version: t.meta.version, label: t.meta.label }, ]), ); diff --git a/bindery-core/src/templates/agentRender.ts b/bindery-core/src/templates/agentRender.ts new file mode 100644 index 0000000..416a1ef --- /dev/null +++ b/bindery-core/src/templates/agentRender.ts @@ -0,0 +1,23 @@ +import type { TemplateContext, AgentTemplate } from './context'; +import { pushCapabilitiesSource, pushProjectSection, pushSessionStart, pushMemorySystem, pushRepoLayout, pushWritingRules } from './building-blocks'; + +export type TopLevelTarget = 'claude' | 'copilot' | 'cursor' | 'agents'; + +export function renderAgentTemplate(ctx: TemplateContext, agent: AgentTemplate): string { + const lines: string[] = [ + `# ${agent.name} — ${ctx.title}`, + '', + ]; + pushProjectSection(lines, ctx); + lines.push(''); + pushSessionStart(lines, ctx, agent); + lines.push(''); + pushMemorySystem(lines, agent); + lines.push(''); + pushRepoLayout(lines, ctx); + lines.push(''); + pushWritingRules(lines, ctx); + lines.push(''); + pushCapabilitiesSource(lines); + return lines.filter(l => l !== '\n').join('\n') + '\n'; +}; \ No newline at end of file diff --git a/bindery-core/src/templates/agents.ts b/bindery-core/src/templates/agents.ts index fc46595..f93d5ff 100644 --- a/bindery-core/src/templates/agents.ts +++ b/bindery-core/src/templates/agents.ts @@ -1,59 +1,12 @@ -import { audienceNote, languageSection, type TemplateContext, type TemplateMeta } from './context'; +import { type TemplateContext, type TemplateMeta } from './context'; +import { renderAgentTemplate } from './agentRender'; export const meta: TemplateMeta = { file: 'AGENTS.md', - version: 11, - label: 'agents instructions', - zip: null, + version: 13, + label: 'agent instructions', }; export function render(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, preferencesFile, arcGranularity, memoriesFolder } = ctx; - const lines: string[] = [`# Agent Instructions — ${title}`, '']; - lines.push('## Project overview'); - if (genre) { lines.push(`${genre} novel.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push( - languageSection(ctx), - '', - '## Start of session', - `1. Read \`${sessionFile}\` for current focus and handoff notes (via \`session_focus_get\`), and \`${preferencesFile}\` for the author's durable working preferences.`, - `2. Read \`${memoriesFolder}/global.md\` for cross-chapter context.`, - `3. If working on a specific chapter, read \`${memoriesFolder}/chXX.md\` if it exists.`, - '4. Check `.claude/skills/` for shared slash workflows before improvising a bespoke process.', - '', - '## Story files', - `- Chapter files are \`.md\` files in \`${storyFolder}/\`, organized in act subfolders.`, - `- Arc files live in \`${arcFolder}/\`; default planning granularity is \`${arcGranularity}\`. Treat these as story architecture.`, - `- Character profiles live in \`${charactersFolder}/\`; use one profile per character plus the index.`, - '- HTML comments `` are writer notes — treat as context only, not prose.', - '- Quotation marks and em-dashes are managed by the Bindery extension. Do not normalize them.', - '', - '## Shared skill workflows', - '- Shared workflows live in `.claude/skills/` and can be used by agents beyond Claude when the runtime supports workspace skills.', - '- Prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` when the user is asking for one of those structured tasks.', - '- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, `chapter_status_*` tools for progress, and `session_focus_*` tools for current working state. PREFERENCES.md is user-owned — propose changes rather than writing it.', - '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage with `inbox_process` (propose destinations) and `inbox_resolve` (clear routed items) — do not dump it into memory.', - '', - '## Writing guidelines', - '- Do not rewrite paragraphs unless explicitly asked. Suggest edits only.', - ); - if (ctx.audience) { - lines.push(`- Audience is ${ctx.audience}. Keep vocabulary clear and themes age-appropriate.`); - } - lines.push( - '', - '## Key reference files', - '| File | Contains |', - '|---|---|', - `| \`${arcFolder}/\` | Story arc files for overall and per-act structure and beats |`, - `| \`${charactersFolder}/\` | Character index and one profile per character |`, - `| \`${notesFolder}/\` | Story notes, like world rules, scene ideas, inbox, and research |`, - `| \`${sessionFile}\` | Ephemeral working state: current focus, next actions, open questions, handoff (via \`session_focus_*\`) |`, - `| \`${preferencesFile}\` | Durable working preferences; user-owned, never tool-written |`, - `| \`${memoriesFolder}/global.md\` | Cross-session decisions |`, - ); - return lines.join('\n') + '\n'; + return renderAgentTemplate(ctx, { hasSkills: true, requiresSkillUpload: false, name: 'Agent Instructions' }); } diff --git a/bindery-core/src/templates/bindery-readme.ts b/bindery-core/src/templates/bindery-readme.ts index 410ff08..97bd245 100644 --- a/bindery-core/src/templates/bindery-readme.ts +++ b/bindery-core/src/templates/bindery-readme.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.bindery/README.md', version: 10, label: 'bindery capabilities', - zip: null, }; export function render(ctx: TemplateContext): string { @@ -27,7 +26,7 @@ may fetch it for context; otherwise, treat this file as the complete answer. Bindery is a markdown book authoring toolkit with three surfaces: 1. **VS Code / Obsidian extension** — commands, format-on-save, export to DOCX/EPUB/PDF. -2. **MCP server** — programmatic tools for AI assistants (Claude, Copilot, Codex, Cursor). +2. **MCP server** — programmatic tools for AI assistants (Claude, Copilot, ChatGPT, Cursor). 3. **Skill workflows** — opinionated slash-commands like \`/review\`, \`/translate\`, \`/memory\`. All three operate on the same workspace state: \`.bindery/settings.json\`, @@ -121,7 +120,7 @@ tagged **(writes)** modify files or git state. ### Tool Workflow Shortcuts - Use \`init_workspace\` first for a new book. It creates the settings, translation file, generated capability README, and the default authoring scaffold. -- Use \`setup_ai_files\` after init or when \`health\` reports outdated AI files. It refreshes CLAUDE.md, Copilot instructions, Cursor rules, AGENTS.md, Claude skills, and this capability README. +- Use \`setup_ai_files\` after init or when \`health\` reports outdated AI files. It refreshes CLAUDE.md, Copilot instructions, Cursor rules, AGENTS.md, generated skills, and this capability README. - Use \`arc_*\` for story architecture under \`${arcFolder}/\`. - Use \`character_*\` for cast profiles under \`${charactersFolder}/\`. - Use \`note_*\` for canonical story notes under \`${notesFolder}/\`. @@ -156,7 +155,7 @@ work-in-progress and continue on another machine. ## Skill workflows (Claude / Copilot Chat / etc.) -Generated under \`.claude/skills//SKILL.md\`. Trigger them with a slash +Generated under \`.claude/skills//SKILL.md\` and/or \`.agents/skills//SKILL.md\` depending on selected targets. Trigger them with a slash command or by paraphrasing the description. | Skill | Trigger phrases | diff --git a/bindery-core/src/templates/building-blocks.ts b/bindery-core/src/templates/building-blocks.ts new file mode 100644 index 0000000..551465b --- /dev/null +++ b/bindery-core/src/templates/building-blocks.ts @@ -0,0 +1,97 @@ +import { type TemplateContext, type AgentTemplate } from './context'; + +/** + * Shared project-intent section used by top-level AI instruction templates. + */ +export function pushProjectSection(lines: string[], ctx: TemplateContext): void { + lines.push('## Project'); + if (ctx.genre) {lines.push(`Genre: ${ctx.genre}`); } + if (ctx.description) { lines.push(ctx.description); } + if (ctx.audience) { lines.push(`Target audience: ${ctx.audience}.`); } + if (ctx.author) { lines.push(`Author: ${ctx.author}.`); } + if (ctx.hasMultiLang) { lines.push(`Languages: ${ctx.langList}`); } +} + +/** + * Canonical capabilities pointer shared by all top-level AI instruction files. + */ +export function pushCapabilitiesSource(lines: string[]): void { + lines.push( + '## Capabilities source', + '- Use `.bindery/README.md` if you require the canonical source for Bindery capabilities and workflows.', + ); +} + +/** + * Shared session-start instructions used by top-level AI instruction templates. + */ +export function pushSessionStart(lines: string[], ctx: TemplateContext, agent: AgentTemplate): void { + lines.push( + '## Start of session', + '1. Run `health` from the Bindery MCP and check `ai_versions_outdated`.', + '2. If `ai_versions_outdated` has entries, run `setup_ai_files`.' + (agent.requiresSkillUpload ? ' If `skill_files.reupload_required` has entries, ask the user to re-upload those SKILL.md files to use them.' : ''), + ); + if (agent.hasSkills) { + lines.push( + '3. Use /read-in at the start of a session to load context and get your bearings.', + `4. If the skill is not available, read at least ${ctx.sessionFile} (if present, use \`session_focus_get\`) for current focus and handoff context, and ${ctx.preferencesFile} for the author's durable working preferences.`, + ); + } else { + lines.push( + `3. Read ${ctx.sessionFile} (if present) for current focus and handoff context, and ${ctx.preferencesFile} for the author's durable working preferences.`, + `4. If ${ctx.sessionFile} mentions a chapter, \`get_chapter(chapterNumber)\` to read that chapter and \`memory_list\` to check for any chapter-specific memory files.`, + ); + } +} + +/** + * Shared memory-system instructions used by top-level AI instruction templates. + */ +export function pushMemorySystem(lines: string[], agent: AgentTemplate): void { + lines.push('## Memory system'); + + if (agent.hasSkills) { + lines.push( + `1. When concluding a discussion, or after you give a meaningful, preservation-worthy response: use /memory to store it.`, + `2. Also when the user asks or otherwise indicates the end of a session: use /memory to save decisions.`, + ); + } else { + lines.push( + `1. When concluding a discussion, or after you give a meaningful, preservation-worthy response: use \`memory_append\` to store it.`, + `2. Also when the user asks or otherwise indicates the end of a session: use \`memory_append\` to save decisions.`, + `3. If a memory file grows too large, use \`memory_compact\` to condense it.`, + ); + } +} + +export function pushRepoLayout(lines: string[], ctx: TemplateContext): void { + lines.push( + '## Repository layout', + '```', + `${ctx.arcFolder}/ ← story architecture (${ctx.arcGranularity}-level arc planning by default)`, + ' index.md ← arc map', + ' Overall.md ← whole-book arc', + ' Acts/ ← act-level arc files', + `${ctx.notesFolder}/ ← story notes`, + `${ctx.charactersFolder}/ ← character index and one profile per character`, + `${ctx.sessionFile} ← ephemeral working state (current focus / next actions / open questions / handoff) via session_focus_*`, + `${ctx.preferencesFile} ← durable working preferences ("do it like this for me"); user-owned, never tool-written`, + `${ctx.storyFolder}/`, + ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`), + '```', + ); +} + +export function pushWritingRules(lines: string[], ctx: TemplateContext): void { + lines.push( + '## Writing rules', + `- If these rules conflict with the author's preferences in ${ctx.preferencesFile}, follow the preferences.`, + '- Prefer usage of the Bindery MCP tools over direct file access.', + '- Never rewrite paragraphs unless explicitly asked. Suggest edits only.', + '- HTML comments `` in chapter files are writer notes. Treat as context, not prose.', + '- Quotation marks and dashes in chapter files are managed by the Bindery extension. Do not flag these as formatting errors.', + ); + if (ctx.audience) { + lines.push(`- Content is aimed at ${ctx.audience}. Keep language accessible and themes age-appropriate.`); + } +} \ No newline at end of file diff --git a/bindery-core/src/templates/claude.ts b/bindery-core/src/templates/claude.ts index 66ee2b3..78d03d0 100644 --- a/bindery-core/src/templates/claude.ts +++ b/bindery-core/src/templates/claude.ts @@ -1,121 +1,12 @@ -import { audienceNote, languageSection, type TemplateContext, type TemplateMeta } from './context'; +import { type TemplateContext, type TemplateMeta } from './context'; +import { renderAgentTemplate } from './agentRender'; export const meta: TemplateMeta = { file: 'CLAUDE.md', - version: 17, + version: 18, label: 'project instructions', - zip: null, }; export function render(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, preferencesFile, arcGranularity } = ctx; - const lines: string[] = [ - `# Claude — ${title}`, - '', - '## Project', - ]; - if (genre) { lines.push(`Genre: ${genre}.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push( - languageSection(ctx), - '## Start of session', - '1. Use /read-in at the start of a session to load context and get your bearings.', - '2. Run `health` from the Bindery MCP and check `ai_versions_outdated`.', - '3. If `ai_versions_outdated` has entries, run `setup_ai_files`; if `skill_files.reupload_required` has entries, ask the user to re-upload those SKILL.md files in Claude Desktop Skills (no zip needed).', - `4. If the skill or MCP server is not available, read at least ${sessionFile} (if present) for current focus and handoff context, and ${preferencesFile} for the author's durable working preferences.`, - '', - '## Memory system', - '1. When concluding a discussion, or after you give a meaningful, preservation-worthy response: use /memory to store it.', - '2. Also when the user asks or otherwise indicates the end of a session: use /memory to save decisions.', - '', - '## Repo layout', - '```', - `${arcFolder}/ ← story architecture (${arcGranularity}-level arc planning by default)`, - ` index.md ← arc map`, - ` Overall.md ← whole-book arc`, - ` Acts/ ← act-level arc files`, - `${notesFolder}/ ← story notes`, - `${charactersFolder}/ ← character index and one profile per character`, - `${sessionFile} ← ephemeral working state (current focus / next actions / open questions / handoff) via session_focus_*`, - `${preferencesFile} ← durable working preferences ("do it like this for me"); user-owned, never tool-written`, - `${storyFolder}/`, - ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters (one .md per chapter)`), - '```', - '', - '## Writing rules', - '- Never rewrite paragraphs unless explicitly asked. Suggest edits only.', - '- HTML comments `` in chapter files are writer notes. Treat as context, not prose.', - '- Quotation marks and dashes in chapter files are managed by the Bindery extension. Do not flag these as formatting errors.', - ); - if (ctx.audience) { - lines.push(`- Content is aimed at ${ctx.audience}. Keep language accessible and themes age-appropriate.`); - } - lines.push( - '', - '## Available skills', - 'Use these slash commands to trigger structured workflows:', - '| Command | Purpose |', - '|---|---|', - '| `/review` | Review a chapter for language, arc consistency, and age-appropriateness |', - '| `/brainstorm` | Guided story ideation when the author is stuck |', - '| `/plan-beats` | Create, expand, or refine chapter and scene beatmaps |', - '| `/character-setup` | Create or refine structured character profiles |', - '| `/memory` | Update memory files and compact if needed |', - '| `/translate` | Assist with chapter translation |', - '| `/translation-review` | Review a hand-crafted translation against the source |', - '| `/status` | Book progress snapshot |', - '| `/continuity` | Check a chapter for consistency errors |', - '| `/read-aloud` | Test how a passage reads when spoken |', - '| `/read-in` | Load context and get your bearings at the start of a session |', - '| `/proof-read` | Read the book as multiple proofreaders and present the findings |', - '', - '## MCP server (bindery-mcp)', - '', - 'All tools require a `book` argument. Use `list_books` to discover available names.', - 'Prefer these tools over Read/Bash when they apply.', - '', - '| Tool | What it does |', - '|---|---|', - '| `list_books` | List all configured book names |', - '| `identify_book` | Match a working directory to a book name |', - '| `health` | Server status: settings, index, embedding backend |', - '| `init_workspace` | Create or update `.bindery/settings.json`, `translations.json`, `.bindery/README.md`, and the opinionated Arc / Notes / Characters / SESSION / PREFERENCES / memory / status scaffold |', - '| `setup_ai_files` | Regenerate AI instruction files and return a change manifest |', - '| `index_build` | Build or rebuild the full-text search index |', - '| `index_status` | Show index chunk count and build time |', - '| `get_text` | Read any file by relative path, with optional line range |', - '| `get_chapter` | Full chapter content by number and language |', - '| `get_book_until` | Fetch chapters from 1..N (or start..N) in one call, concatenated in reading order |', - '| `get_overview` | Chapter structure — acts, chapters, titles |', - '| `get_notes` | Notes/ files, filterable by category or name |', - '| `note_list` / `note_get` | List or read canonical story notes under Notes/ |', - '| `note_create` / `note_append` | Create or append canonical story notes under Notes/ |', - '| `character_list` / `character_get` | List or read structured character profiles |', - '| `character_create` / `character_update` | Create or update structured character profiles and the cast index |', - '| `arc_list` / `arc_get` | List or read structured arc files |', - '| `arc_create` / `arc_update` | Create or update structured arc files and the arc index |', - '| `search` | BM25 full-text search with ranked snippets, optional semantic ranking |', - '| `format` | Apply typography formatting to a file or folder |', - '| `get_review_text` | Structured git diff with review-marker regions and optional auto-staging that consumes markers |', - '| `update_workspace` | Fetch and pull the current branch, with branch/default-branch reporting |', - '| `git_snapshot` | Commit changes in bindery workspace, with optional push |', - '| `get_translation` | List glossary entries for a language, or look up a specific term (forgiving) |', - '| `add_translation` | Add or update a cross-language glossary entry (agent reference, not auto-applied) |', - '| `get_dialect` | List dialect substitution rules, or look up a specific word |', - '| `add_dialect` | Add or update a dialect substitution rule (auto-applied at export, e.g. US→UK) |', - '| `add_language` | Add a language to settings.json and scaffold its story folder with stubs |', - '| `settings_update` | Merge a partial patch into settings.json without replacing unrelated keys |', - '| `memory_list` | List `.bindery/memories/` files with line counts |', - '| `memory_append` | Append a dated session entry to a file in `.bindery/memories/` |', - '| `memory_compact` | Overwrite a file in `.bindery/memories/` with a summary (backs up original to `.bindery/memories/archive/`) |', - '| `chapter_status_get` | Read the chapter progress tracker — entries grouped by status |', - '| `chapter_status_update` | Upsert chapter progress entries (send only changed chapters) |', - `| \`session_focus_get\` | Read current working state from ${sessionFile} (optionally one section) |`, - `| \`session_focus_update\` | Update neutral ${sessionFile} sections (Current Focus, Next Actions, Open Questions, Handoff Notes); leaves ${preferencesFile} untouched |`, - `| \`inbox_process\` | Enumerate ${notesFolder}/Inbox.md items with stable numbers and propose destinations (read-only) |`, - `| \`inbox_resolve\` | Remove already-routed inbox items by number after confirmation |`, - ); - return lines.filter(l => l !== '\n').join('\n') + '\n'; + return renderAgentTemplate(ctx, { hasSkills: true, requiresSkillUpload: true, name: 'Claude' }); } diff --git a/bindery-core/src/templates/context.ts b/bindery-core/src/templates/context.ts index c2b69cd..5bcba60 100644 --- a/bindery-core/src/templates/context.ts +++ b/bindery-core/src/templates/context.ts @@ -32,15 +32,10 @@ export interface TemplateMeta { version: number; /** Short, human-readable label used by health reporting. */ label: string; - /** Legacy companion zip path or null. */ - zip: string | null; } -export function audienceNote(ctx: TemplateContext): string { - return ctx.audience ? `Target audience: ${ctx.audience}.` : ''; -} - -export function languageSection(ctx: TemplateContext): string { - if (!ctx.hasMultiLang) { return ''; } - return `\nLanguages: ${ctx.langList}.\n`; -} +export interface AgentTemplate { + hasSkills: boolean; + requiresSkillUpload: boolean; + name: string; +} \ No newline at end of file diff --git a/bindery-core/src/templates/copilot.ts b/bindery-core/src/templates/copilot.ts index 5abb364..0464b88 100644 --- a/bindery-core/src/templates/copilot.ts +++ b/bindery-core/src/templates/copilot.ts @@ -1,48 +1,12 @@ -import { audienceNote, languageSection, type TemplateContext, type TemplateMeta } from './context'; +import { type TemplateContext, type TemplateMeta } from './context'; +import { renderAgentTemplate } from './agentRender'; export const meta: TemplateMeta = { file: '.github/copilot-instructions.md', - version: 11, + version: 12, label: 'copilot instructions', - zip: null, }; export function render(ctx: TemplateContext): string { - const { title, author, description, genre, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, preferencesFile, arcGranularity } = ctx; - const lines: string[] = [`# GitHub Copilot — ${title}`, '']; - if (genre || description || ctx.audience) { - lines.push('## Project'); - if (genre) { lines.push(`${genre} novel.`); } - if (description) { lines.push(description); } - if (ctx.audience){ lines.push(audienceNote(ctx)); } - if (author) { lines.push(`Author: ${author}.`); } - lines.push(languageSection(ctx), ''); - } - lines.push( - '## Repo layout', - '```', - `${arcFolder}/ ← story architecture (${arcGranularity}-level arc planning by default)`, - ` index.md / Overall.md / Acts/`, - `${notesFolder}/ ← story notes`, - `${charactersFolder}/ ← character index and one profile per character`, - `${sessionFile} ← ephemeral working state (current focus / handoff) via session_focus_*`, - `${preferencesFile} ← durable working preferences; user-owned, never tool-written`, - `${storyFolder}/`, - ...ctx.languages.map(l => ` ${l.folderName}/ ← ${l.code} chapters`), - '```', - '', - '## Shared skill workflows', - '- Workspace skill files live in `.claude/skills/` and may also be picked up by agents beyond Claude.', - '- Prefer those shared slash workflows when available: `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, `/character-setup`.', - '- Treat arc files as story architecture, not generic notes. Use `arc_*` tools for structure, `character_*` tools for durable cast facts, `note_*` tools for story notes, `memory_*` tools for cross-session decisions, and `session_focus_*` tools for current working state. Durable preferences are user-owned in PREFERENCES.md — propose changes rather than writing it.', - '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage it with `inbox_process` (propose destinations) and `inbox_resolve` (clear routed items) — do not dump it into memory.', - '', - '## Writing guidelines', - '- HTML comments `` in chapter files are writer notes — treat as context only.', - '- Quotation marks and dashes are managed by the Bindery VS Code extension. Do not normalize them.', - ); - if (ctx.audience) { - lines.push(`- Content targets ${ctx.audience}. Keep vocabulary accessible and themes appropriate.`); - } - return lines.join('\n') + '\n'; + return renderAgentTemplate(ctx, { hasSkills: true, requiresSkillUpload: false, name: 'GitHub Copilot' }); } diff --git a/bindery-core/src/templates/cursor.ts b/bindery-core/src/templates/cursor.ts index a9b5451..531a342 100644 --- a/bindery-core/src/templates/cursor.ts +++ b/bindery-core/src/templates/cursor.ts @@ -1,42 +1,12 @@ import type { TemplateContext, TemplateMeta } from './context'; +import { renderAgentTemplate } from './agentRender'; export const meta: TemplateMeta = { file: '.cursor/rules', - version: 11, + version: 12, label: 'cursor rules', - zip: null, }; export function render(ctx: TemplateContext): string { - const { title, storyFolder, notesFolder, arcFolder, charactersFolder, sessionFile, preferencesFile, arcGranularity, memoriesFolder } = ctx; - const lines: string[] = [ - `# Cursor rules — ${title}`, - '', - `Story folder: \`${storyFolder}/\``, - `Notes folder: \`${notesFolder}/\``, - `Arc folder: \`${arcFolder}/\` (index.md, Overall.md, Acts/; default granularity: ${arcGranularity})`, - `Characters folder: \`${charactersFolder}/\``, - `Session file: \`${sessionFile}\` (ephemeral working state)`, - `Preferences file: \`${preferencesFile}\` (durable, user-owned)`, - '', - '## Context files to read', - `- \`${sessionFile}\` — ephemeral working state (current focus, next actions, open questions, handoff) via \`session_focus_*\``, - `- \`${preferencesFile}\` — durable working preferences ("do it like this for me"); user-owned, never tool-written`, - `- \`${memoriesFolder}/global.md\` — cross-chapter decisions (read at start of session)`, - `- \`${arcFolder}/\` — story architecture, structure, pacing, and beats`, - `- \`${charactersFolder}/\` — character index and one profile per character`, - `- \`${notesFolder}/\` — story notes, like world rules, scene ideas, inbox, and research`, - '- Shared workflows live in `.claude/skills/`; if your runtime exposes them, prefer `/read-in`, `/review`, `/translation-review`, `/translate`, `/memory`, `/continuity`, `/status`, `/read-aloud`, `/proof-read`, `/plan-beats`, and `/character-setup` for those tasks.', - '- Use `arc_*` tools for story structure, `character_*` tools for cast profiles, `note_*` tools for story notes, `memory_*` tools for session decisions, `chapter_status_*` tools for progress, and `session_focus_*` tools for current working state.', - '- Send rough, unsorted, or pasted material to `Notes/Inbox.md`, then triage with `inbox_process` and `inbox_resolve` — do not dump it into memory.', - '', - '## Rules', - '- HTML comments `` in chapter files are writer notes. Treat as context, not story content.', - '- Do not normalize quotation marks or dashes — these are managed by the Bindery extension.', - '- Do not rewrite prose unless explicitly asked. Suggest edits only.', - ]; - if (ctx.audience) { - lines.push(`- Target audience is ${ctx.audience}. Flag content that is too complex or inappropriate.`); - } - return lines.join('\n') + '\n'; + return renderAgentTemplate(ctx, { hasSkills: false, requiresSkillUpload: false, name: 'Cursor' }); } diff --git a/bindery-core/src/templates/skills/brainstorm.ts b/bindery-core/src/templates/skills/brainstorm.ts index 30ad47d..95e575a 100644 --- a/bindery-core/src/templates/skills/brainstorm.ts +++ b/bindery-core/src/templates/skills/brainstorm.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/brainstorm/SKILL.md', version: 12, label: 'brainstorm skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/character-setup.ts b/bindery-core/src/templates/skills/character-setup.ts index 3659998..84e77a2 100644 --- a/bindery-core/src/templates/skills/character-setup.ts +++ b/bindery-core/src/templates/skills/character-setup.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/character-setup/SKILL.md', version: 1, label: 'character-setup skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/continuity.ts b/bindery-core/src/templates/skills/continuity.ts index e2684d2..fdc62f2 100644 --- a/bindery-core/src/templates/skills/continuity.ts +++ b/bindery-core/src/templates/skills/continuity.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/continuity/SKILL.md', version: 14, label: 'continuity skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/memory.ts b/bindery-core/src/templates/skills/memory.ts index c6ddf3a..7d00696 100644 --- a/bindery-core/src/templates/skills/memory.ts +++ b/bindery-core/src/templates/skills/memory.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/memory/SKILL.md', version: 14, label: 'memory skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/plan-beats.ts b/bindery-core/src/templates/skills/plan-beats.ts index 8eb71b0..ec4555a 100644 --- a/bindery-core/src/templates/skills/plan-beats.ts +++ b/bindery-core/src/templates/skills/plan-beats.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/plan-beats/SKILL.md', version: 3, label: 'plan-beats skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/proof-read.ts b/bindery-core/src/templates/skills/proof-read.ts index b9ddb1c..de70233 100644 --- a/bindery-core/src/templates/skills/proof-read.ts +++ b/bindery-core/src/templates/skills/proof-read.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/proof-read/SKILL.md', version: 6, label: 'proof-read skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/read-aloud.ts b/bindery-core/src/templates/skills/read-aloud.ts index 837e640..d571952 100644 --- a/bindery-core/src/templates/skills/read-aloud.ts +++ b/bindery-core/src/templates/skills/read-aloud.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/read-aloud/SKILL.md', version: 11, label: 'read-aloud skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/read-in.ts b/bindery-core/src/templates/skills/read-in.ts index 8fc77a8..d9c43bb 100644 --- a/bindery-core/src/templates/skills/read-in.ts +++ b/bindery-core/src/templates/skills/read-in.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/read-in/SKILL.md', version: 16, label: 'read-in skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/review.ts b/bindery-core/src/templates/skills/review.ts index 41f2574..ff03114 100644 --- a/bindery-core/src/templates/skills/review.ts +++ b/bindery-core/src/templates/skills/review.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/review/SKILL.md', version: 14, label: 'review skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/status.ts b/bindery-core/src/templates/skills/status.ts index c3b98cb..380ae13 100644 --- a/bindery-core/src/templates/skills/status.ts +++ b/bindery-core/src/templates/skills/status.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/status/SKILL.md', version: 14, label: 'status skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/translate.ts b/bindery-core/src/templates/skills/translate.ts index fbe8e37..62ad0b4 100644 --- a/bindery-core/src/templates/skills/translate.ts +++ b/bindery-core/src/templates/skills/translate.ts @@ -4,7 +4,6 @@ export const meta: TemplateMeta = { file: '.claude/skills/translate/SKILL.md', version: 10, label: 'translate skill', - zip: null, }; const CONTENT = [ diff --git a/bindery-core/src/templates/skills/translation-review.ts b/bindery-core/src/templates/skills/translation-review.ts index 167b113..6766b20 100644 --- a/bindery-core/src/templates/skills/translation-review.ts +++ b/bindery-core/src/templates/skills/translation-review.ts @@ -2,9 +2,8 @@ import type { TemplateContext, TemplateMeta } from '../context'; export const meta: TemplateMeta = { file: '.claude/skills/translation-review/SKILL.md', - version: 4, + version: 5, label: 'translation-review skill', - zip: null, }; const CONTENT = [ @@ -73,9 +72,9 @@ const CONTENT = [ "", "## Output format", "", - "| Category | Before (target) | After (target) | Reason |", - "|---|---|---|---|", - "| Glossary | Keep context short; bold only the changed words | Suggested wording | Term differs from stored glossary |", + "| Category | Location | Before (target) | After (target) | Reason |", + "|---|---|---|---|---|", + "| Glossary | Line 5 | Keep context short; bold only the changed words | Suggested wording | Term differs from stored glossary |", "", "**Category values:** Fidelity · Naturalness · Glossary · Register · Comment", "", diff --git a/bindery-core/test/templates.test.ts b/bindery-core/test/templates.test.ts index a643b64..e86bb78 100644 --- a/bindery-core/test/templates.test.ts +++ b/bindery-core/test/templates.test.ts @@ -64,10 +64,9 @@ describe('renderTemplate — claude', () => { expect(result).toContain('## Project'); expect(result).toContain('## Start of session'); expect(result).toContain('## Memory system'); - expect(result).toContain('## Repo layout'); + expect(result).toContain('## Repository layout'); expect(result).toContain('## Writing rules'); - expect(result).toContain('## Available skills'); - expect(result).toContain('## MCP server (bindery-mcp)'); + expect(result).toContain('## Capabilities source'); }); it('includes genre, description, audience, and author', () => { @@ -113,8 +112,12 @@ describe('renderTemplate — copilot', () => { it('contains required sections', () => { const result = renderTemplate('copilot', makeCtx()); expect(result).toContain('# GitHub Copilot — Test Book'); - expect(result).toContain('## Repo layout'); - expect(result).toContain('## Writing guidelines'); + expect(result).toContain('## Project'); + expect(result).toContain('## Start of session'); + expect(result).toContain('## Memory system'); + expect(result).toContain('## Repository layout'); + expect(result).toContain('## Writing rules'); + expect(result).toContain('## Capabilities source'); }); it('includes genre and description when set', () => { @@ -123,9 +126,12 @@ describe('renderTemplate — copilot', () => { expect(result).toContain('A tale of adventure.'); }); - it('omits project section entirely when all optional fields are empty', () => { + it('keeps project section even when optional project fields are empty', () => { const result = renderTemplate('copilot', makeMinimalCtx()); - expect(result).not.toContain('## Project'); + expect(result).toContain('## Project'); + expect(result).not.toContain('Genre:'); + expect(result).not.toContain('Author:'); + expect(result).not.toContain('Target audience:'); }); it('includes audience writing guideline when audience is set', () => { @@ -133,13 +139,10 @@ describe('renderTemplate — copilot', () => { expect(result).toContain('middle-grade'); }); - it('mentions shared workspace skills from the .claude folder', () => { + it('uses read-in startup guidance and no explicit skill catalog', () => { const result = renderTemplate('copilot', makeCtx()); - expect(result).toContain('.claude/skills/'); - expect(result).toContain('/translation-review'); expect(result).toContain('/read-in'); - expect(result).toContain('/plan-beats'); - expect(result).toContain('/character-setup'); + expect(result).not.toContain('.claude/skills/'); }); it('ends with a newline', () => { @@ -150,14 +153,19 @@ describe('renderTemplate — copilot', () => { describe('renderTemplate — cursor', () => { it('contains required sections', () => { const result = renderTemplate('cursor', makeCtx()); - expect(result).toContain('# Cursor rules — Test Book'); - expect(result).toContain('## Context files to read'); - expect(result).toContain('## Rules'); + expect(result).toContain('# Cursor — Test Book'); + expect(result).toContain('## Project'); + expect(result).toContain('## Start of session'); + expect(result).toContain('## Memory system'); + expect(result).toContain('## Repository layout'); + expect(result).toContain('## Writing rules'); + expect(result).toContain('## Capabilities source'); }); - it('references memory and arc folder paths', () => { + it('references arc folder and memory tools', () => { const result = renderTemplate('cursor', makeCtx()); - expect(result).toContain('.bindery/memories'); + expect(result).toContain('memory_append'); + expect(result).toContain('memory_compact'); expect(result).toContain('Arc/'); }); @@ -166,13 +174,11 @@ describe('renderTemplate — cursor', () => { expect(result).toContain('middle-grade'); }); - it('mentions shared workspace skills from the .claude folder', () => { + it('uses non-skill startup guidance for runtimes without skill support', () => { const result = renderTemplate('cursor', makeCtx()); - expect(result).toContain('.claude/skills/'); - expect(result).toContain('/translation-review'); - expect(result).toContain('/proof-read'); - expect(result).toContain('/plan-beats'); - expect(result).toContain('/character-setup'); + expect(result).toContain('Read SESSION.md'); + expect(result).toContain('get_chapter(chapterNumber)'); + expect(result).not.toContain('/read-in'); }); it('ends with a newline', () => { @@ -184,11 +190,12 @@ describe('renderTemplate — agents', () => { it('contains required sections', () => { const result = renderTemplate('agents', makeCtx()); expect(result).toContain('# Agent Instructions — Test Book'); - expect(result).toContain('## Project overview'); + expect(result).toContain('## Project'); expect(result).toContain('## Start of session'); - expect(result).toContain('## Story files'); - expect(result).toContain('## Writing guidelines'); - expect(result).toContain('## Key reference files'); + expect(result).toContain('## Memory system'); + expect(result).toContain('## Repository layout'); + expect(result).toContain('## Writing rules'); + expect(result).toContain('## Capabilities source'); }); it('includes genre, description, audience, and author', () => { @@ -199,19 +206,21 @@ describe('renderTemplate — agents', () => { expect(result).toContain('Jane Doe'); }); - it('references memories and arc folders', () => { + it('references repository structure and arc folders', () => { const result = renderTemplate('agents', makeCtx()); - expect(result).toContain('.bindery/memories'); + expect(result).toContain('Repository layout'); expect(result).toContain('Arc/'); }); - it('mentions shared workspace skills from the .claude folder', () => { + it('uses read-in startup guidance and no explicit shared-skill inventory section', () => { const result = renderTemplate('agents', makeCtx()); - expect(result).toContain('.claude/skills/'); - expect(result).toContain('/translation-review'); - expect(result).toContain('/review'); - expect(result).toContain('/plan-beats'); - expect(result).toContain('/character-setup'); + expect(result).toContain('/read-in'); + expect(result).not.toContain('## Shared skill workflows'); + }); + + it('uses skill-first startup guidance with read-in', () => { + const result = renderTemplate('agents', makeCtx()); + expect(result).toContain('Use /read-in at the start of a session'); }); it('ends with a newline', () => { diff --git a/mcp-ts/src/aisetup.ts b/mcp-ts/src/aisetup.ts index fffb55a..c4228dd 100644 --- a/mcp-ts/src/aisetup.ts +++ b/mcp-ts/src/aisetup.ts @@ -2,7 +2,8 @@ * AI instruction file generation for Bindery (MCP server). * * Generates CLAUDE.md, .github/copilot-instructions.md, .cursor/rules, - * AGENTS.md, and .claude/skills//SKILL.md from the book's + * AGENTS.md, and skills under .claude/skills//SKILL.md and + * .agents/skills//SKILL.md from the book's * .bindery/settings.json. * * Template content is maintained in bindery-core/src/templates/*.ts @@ -58,25 +59,21 @@ export interface AiSetupOptions { export interface AiSetupResult { regenerated: string[]; skipped: string[]; - skillZipManifest: { - rebuilt: string[]; - created: string[]; - skipped: string[]; - failed: string[]; - }; versionStamp: AiVersionFile; } interface AiVersionEntry { version: number; label: string; - zip: string | null; } export interface AiVersionFile { versions: Record; } +const SKILL_ROOTS = ['.claude', '.agents'] as const; +const SKILL_PATH_RE = /^\.(claude|agents)\/skills\/([^/]+)\/SKILL\.md$/; + // ─── Settings types ─────────────────────────────────────────────────────────── interface Settings extends Omit { @@ -142,7 +139,6 @@ export function setupAiFiles(options: AiSetupOptions): AiSetupResult { const result: AiSetupResult = { regenerated: [], skipped: [], - skillZipManifest: { rebuilt: [], created: [], skipped: [], failed: [] }, versionStamp: versionFile, }; @@ -169,6 +165,10 @@ export function setupAiFiles(options: AiSetupOptions): AiSetupResult { break; case 'agents': writeFile(root, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, versionFile, result); + for (const skill of skills) { + const skillMd = path.join('.agents', 'skills', skill, 'SKILL.md'); + writeFile(root, skillMd, renderTemplate(skill, ctx), overwrite, versionFile, result); + } break; } } @@ -200,7 +200,10 @@ export function readAiVersionFile(root: string): AiVersionFile { export function expectedAiVersionEntries(): Record { const out: Record = {}; for (const [file, info] of Object.entries(FILE_VERSION_INFO)) { - out[file] = { version: info.version, label: info.label, zip: info.zip }; + const entry = { version: info.version, label: info.label }; + for (const variant of versionPathVariants(file)) { + out[variant] = entry; + } } return out; } @@ -222,7 +225,6 @@ export function writeBinderyCapabilitiesReadme(root: string): void { const versionFile = readAiVersionFile(root); const result: AiSetupResult = { regenerated: [], skipped: [], - skillZipManifest: { rebuilt: [], created: [], skipped: [], failed: [] }, versionStamp: versionFile, }; writeFile(root, path.join('.bindery', 'README.md'), renderTemplate('bindery-readme', ctx), true, versionFile, result); @@ -241,7 +243,7 @@ function writeFile( ): void { const full = path.join(root, relPath); const key = toKey(relPath); - const expected = FILE_VERSION_INFO[key]; + const expected = expectedVersionInfoForKey(key); const existingVersion = versionFile.versions[key]?.version ?? 0; const isUpToDate = expected ? existingVersion >= expected.version : true; @@ -258,15 +260,29 @@ function writeFile( } function stampVersionEntry(versionFile: AiVersionFile, relPath: string): void { - const expected = FILE_VERSION_INFO[relPath]; + const expected = expectedVersionInfoForKey(relPath); if (!expected) { return; } versionFile.versions[relPath] = { version: expected.version, - label: expected.label, - zip: expected.zip, + label: expected.label }; } +function expectedVersionInfoForKey(key: string): { version: number; label: string } | undefined { + for (const variant of versionPathVariants(key)) { + const expected = FILE_VERSION_INFO[variant]; + if (expected) { return expected; } + } + return undefined; +} + +function versionPathVariants(relPath: string): string[] { + const skillMatch = SKILL_PATH_RE.exec(relPath); + if (!skillMatch) { return [relPath]; } + const skillName = skillMatch[2]; + return SKILL_ROOTS.map(root => `${root}/skills/${skillName}/SKILL.md`); +} + function stampAiVersionFile(root: string, versionFile: AiVersionFile): void { const dir = path.join(root, '.bindery'); fs.mkdirSync(dir, { recursive: true }); diff --git a/mcp-ts/src/tools.ts b/mcp-ts/src/tools.ts index 072fa7b..59adb71 100644 --- a/mcp-ts/src/tools.ts +++ b/mcp-ts/src/tools.ts @@ -446,7 +446,7 @@ function getSemanticStatus(root: string): SemanticStatus { return { status, staleReasons: stale.isStale ? stale.reasons : [] }; } -type OutdatedEntry = { file: string; label: string; zip: string | null; expected: number; found: number }; +type OutdatedEntry = { file: string; label: string; expected: number; found: number }; function getOutdatedAiFiles(root: string, enabledTargets: Set): OutdatedEntry[] { const installed = readAiVersionFile(root); @@ -457,7 +457,7 @@ function getOutdatedAiFiles(root: string, enabledTargets: Set): Outdated if (!fs.existsSync(path.join(root, file))) { continue; } const found = installed.versions[file]?.version ?? 0; if (found < exp.version) { - outdated.push({ file, label: exp.label, zip: exp.zip, expected: exp.version, found }); + outdated.push({ file, label: exp.label, expected: exp.version, found }); } } return outdated; @@ -2626,13 +2626,6 @@ export function toolSetupAiFiles(root: string, args: SetupAiFilesArgs): string { const response = { regenerated_files: result.regenerated, skipped_files: result.skipped, - skill_zips: { - created: result.skillZipManifest.created, - rebuilt: result.skillZipManifest.rebuilt, - skipped: result.skillZipManifest.skipped, - failed: result.skillZipManifest.failed, - reupload_required: [], - }, skill_files: { reupload_required: skillFilesToReupload, }, diff --git a/mcp-ts/test/aisetup.test.ts b/mcp-ts/test/aisetup.test.ts index 2add7ba..f75e9f1 100644 --- a/mcp-ts/test/aisetup.test.ts +++ b/mcp-ts/test/aisetup.test.ts @@ -21,6 +21,11 @@ async function loadAiSetup() { return import('../src/aisetup'); } +async function loadTemplates() { + vi.resetModules(); + return import('../src/templates'); +} + afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); @@ -52,29 +57,6 @@ describe('setupAiFiles skill generation', () => { expect(fs.existsSync(path.join(root, '.claude', 'skills', 'translation-review.zip'))).toBe(false); }); - it('returns an empty skill zip manifest (zips are no longer generated)', async () => { - const { setupAiFiles } = await loadAiSetup(); - - const root = makeRoot(); - write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ - bookTitle: 'Test Book', - storyFolder: 'Story', - languages: [{ code: 'EN', folderName: 'EN' }], - }, null, 2) + '\n'); - - const result = setupAiFiles({ - root, - targets: ['claude'], - skills: ['read-aloud'], - overwrite: true, - }); - - expect(result.skillZipManifest.created).toEqual([]); - expect(result.skillZipManifest.rebuilt).toEqual([]); - expect(result.skillZipManifest.skipped).toEqual([]); - expect(result.skillZipManifest.failed).toEqual([]); - }); - it('does not overwrite legacy zip files that already exist', async () => { const { setupAiFiles } = await loadAiSetup(); @@ -87,15 +69,86 @@ describe('setupAiFiles skill generation', () => { write(path.join(root, '.claude', 'skills', 'read-aloud.zip'), 'legacy zip bytes'); - const result = setupAiFiles({ + setupAiFiles({ root, targets: ['claude'], skills: ['read-aloud'], overwrite: true, }); - - expect(result.skillZipManifest.failed).toEqual([]); - expect(result.skillZipManifest.created).toEqual([]); + expect(fs.readFileSync(path.join(root, '.claude', 'skills', 'read-aloud.zip'), 'utf-8')).toBe('legacy zip bytes'); }); + + it('generates requested skill markdown files for agents target under .agents/skills', async () => { + const { setupAiFiles } = await loadAiSetup(); + + const root = makeRoot(); + write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ + bookTitle: 'Test Book', + storyFolder: 'Story', + languages: [{ code: 'EN', folderName: 'EN' }], + }, null, 2) + '\n'); + + const result = setupAiFiles({ + root, + targets: ['agents'], + skills: ['read-in'], + overwrite: true, + }); + + expect(result.regenerated).toContain('.agents/skills/read-in/SKILL.md'); + expect(fs.existsSync(path.join(root, '.agents', 'skills', 'read-in', 'SKILL.md'))).toBe(true); + }); + + it('includes .agents skill files in expected AI version entries', async () => { + const { expectedAiVersionEntries } = await loadAiSetup(); + const { FILE_VERSION_INFO } = await loadTemplates(); + + const expected = expectedAiVersionEntries(); + const claudeKey = '.claude/skills/read-in/SKILL.md'; + const agentsKey = '.agents/skills/read-in/SKILL.md'; + + expect(expected[agentsKey]).toEqual(expected[claudeKey]); + expect(expected[agentsKey]).toEqual({ + version: FILE_VERSION_INFO[claudeKey].version, + label: FILE_VERSION_INFO[claudeKey].label, + }); + }); + + it('regenerates .agents skill file when stamped version is stale and overwrite is false', async () => { + const { setupAiFiles, readAiVersionFile } = await loadAiSetup(); + + const root = makeRoot(); + write(path.join(root, '.bindery', 'settings.json'), JSON.stringify({ + bookTitle: 'Test Book', + storyFolder: 'Story', + languages: [{ code: 'EN', folderName: 'EN' }], + }, null, 2) + '\n'); + + setupAiFiles({ + root, + targets: ['agents'], + skills: ['read-in'], + overwrite: true, + }); + + const versionPath = path.join(root, '.bindery', 'ai-version.json'); + const raw = JSON.parse(fs.readFileSync(versionPath, 'utf-8')) as { + versions: Record; + }; + raw.versions['.agents/skills/read-in/SKILL.md'].version = 0; + fs.writeFileSync(versionPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8'); + + const second = setupAiFiles({ + root, + targets: ['agents'], + skills: ['read-in'], + overwrite: false, + }); + + expect(second.regenerated).toContain('.agents/skills/read-in/SKILL.md'); + + const stamped = readAiVersionFile(root); + expect(stamped.versions['.agents/skills/read-in/SKILL.md'].version).toBeGreaterThan(0); + }); }); diff --git a/mcp-ts/test/tools.test.ts b/mcp-ts/test/tools.test.ts index b95cfd0..12fd442 100644 --- a/mcp-ts/test/tools.test.ts +++ b/mcp-ts/test/tools.test.ts @@ -342,18 +342,12 @@ describe('mcp tools', () => { const parsed = JSON.parse(raw) as { regenerated_files?: string[]; skipped_files?: string[]; - skill_zips?: { created?: string[]; rebuilt?: string[]; skipped?: string[]; failed?: string[]; reupload_required?: string[] }; skill_files?: { reupload_required?: string[] }; - ai_versions?: { versions?: Record }; + ai_versions?: { versions?: Record }; }; expect(Array.isArray(parsed.regenerated_files)).toBe(true); expect(Array.isArray(parsed.skipped_files)).toBe(true); - expect(Array.isArray(parsed.skill_zips?.created)).toBe(true); - expect(Array.isArray(parsed.skill_zips?.rebuilt)).toBe(true); - expect(Array.isArray(parsed.skill_zips?.skipped)).toBe(true); - expect(Array.isArray(parsed.skill_zips?.failed)).toBe(true); - expect(Array.isArray(parsed.skill_zips?.reupload_required)).toBe(true); expect(Array.isArray(parsed.skill_files?.reupload_required)).toBe(true); expect(parsed.skill_files?.reupload_required).toContain('.claude/skills/review/SKILL.md'); expect(parsed.ai_versions?.versions?.['.claude/skills/review/SKILL.md']?.label).toBe('review skill'); @@ -369,16 +363,11 @@ describe('mcp tools', () => { const raw = toolSetupAiFiles(root, { targets: ['claude'], skills: ['read-aloud'], overwrite: true }); const parsed = JSON.parse(raw) as { - skill_zips?: { created?: string[]; rebuilt?: string[]; failed?: string[]; reupload_required?: string[] }; skill_files?: { reupload_required?: string[] }; }; const zipPath = path.join(root, '.claude', 'skills', 'read-aloud.zip'); expect(fs.existsSync(zipPath)).toBe(false); - expect(parsed.skill_zips?.created).toEqual([]); - expect(parsed.skill_zips?.rebuilt).toEqual([]); - expect(parsed.skill_zips?.failed).toEqual([]); - expect(parsed.skill_zips?.reupload_required).toEqual([]); expect(parsed.skill_files?.reupload_required).toEqual(['.claude/skills/read-aloud/SKILL.md']); }); @@ -401,7 +390,7 @@ describe('mcp tools', () => { // Downgrade the review skill version to simulate outdated const versionPath = path.join(root, '.bindery', 'ai-version.json'); const versionFile = JSON.parse(fs.readFileSync(versionPath, 'utf-8')) as { - versions: Record; + versions: Record; }; versionFile.versions['.claude/skills/review/SKILL.md'].version = 0; fs.writeFileSync(versionPath, JSON.stringify(versionFile, null, 2) + '\n', 'utf-8'); @@ -436,7 +425,7 @@ describe('mcp tools', () => { // Downgrade the review skill version to simulate outdated const versionPath = path.join(root, '.bindery', 'ai-version.json'); const versionFile = JSON.parse(fs.readFileSync(versionPath, 'utf-8')) as { - versions: Record; + versions: Record; }; versionFile.versions['.claude/skills/review/SKILL.md'].version = 0; fs.writeFileSync(versionPath, JSON.stringify(versionFile, null, 2) + '\n', 'utf-8'); @@ -523,7 +512,7 @@ describe('mcp tools', () => { const versionPath = path.join(root, '.bindery', 'ai-version.json'); const versionFile = JSON.parse(fs.readFileSync(versionPath, 'utf-8')) as { - versions: Record; + versions: Record; }; versionFile.versions['.claude/skills/review/SKILL.md'].version = 0; fs.writeFileSync(versionPath, JSON.stringify(versionFile, null, 2) + '\n', 'utf-8'); @@ -531,7 +520,7 @@ describe('mcp tools', () => { const healthRaw = toolHealth(root); const health = JSON.parse(healthRaw) as { ai_version_outdated?: boolean; - ai_versions_outdated?: Array<{ file: string; label: string; zip: string | null }>; + ai_versions_outdated?: Array<{ file: string; label: string; }>; }; expect(health.ai_version_outdated).toBe(true); diff --git a/obsidian-plugin/src/ai-setup.ts b/obsidian-plugin/src/ai-setup.ts index 6f58f43..b64d386 100644 --- a/obsidian-plugin/src/ai-setup.ts +++ b/obsidian-plugin/src/ai-setup.ts @@ -2,7 +2,7 @@ * Obsidian-specific AI setup wrapper. * * Generates AI instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) - * and Claude skill templates from the book's .bindery/settings.json. + * and skill templates from the book's .bindery/settings.json. * * For Obsidian, this is a simpler adaptation since we don't have the VS Code LM API, * so we just generate the files to the filesystem directly. @@ -89,6 +89,10 @@ export function setupAiFiles( break; case 'agents': writeFile(bookRoot, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, result); + for (const skill of skills) { + const skillDir = path.join('.agents', 'skills', skill); + writeFile(bookRoot, path.join(skillDir, 'SKILL.md'), renderTemplate(skill, ctx), overwrite, result); + } break; } } diff --git a/obsidian-plugin/test/ai-setup.test.ts b/obsidian-plugin/test/ai-setup.test.ts index 0b2d095..b2cc236 100644 --- a/obsidian-plugin/test/ai-setup.test.ts +++ b/obsidian-plugin/test/ai-setup.test.ts @@ -85,6 +85,22 @@ describe('AI Setup', () => { expect(fs.existsSync(agentsPath)).toBe(true); }); + it('should create skill markdown files under .agents/skills when agents target requested', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + await setupAiFiles(mockApp, tempRoot, ['agents'], ['read-in', 'review'], false); + + const readInSkillPath = path.join(tempRoot, '.agents', 'skills', 'read-in', 'SKILL.md'); + const reviewSkillPath = path.join(tempRoot, '.agents', 'skills', 'review', 'SKILL.md'); + + expect(fs.existsSync(readInSkillPath)).toBe(true); + expect(fs.existsSync(reviewSkillPath)).toBe(true); + }); + it('should skip existing files when overwrite is false', async () => { const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); diff --git a/vscode-ext/src/ai-setup.ts b/vscode-ext/src/ai-setup.ts index 1266974..d9b2cec 100644 --- a/vscode-ext/src/ai-setup.ts +++ b/vscode-ext/src/ai-setup.ts @@ -7,7 +7,7 @@ * claude → CLAUDE.md + .claude/skills//SKILL.md * copilot → .github/copilot-instructions.md * cursor → .cursor/rules - * agents → AGENTS.md (OpenAI Agents, Aider, Codex, etc.) + * agents → AGENTS.md + .agents/skills//SKILL.md (OpenAI Agents, Aider, Codex, etc.) * * Templates and TemplateContext live in @bindery/core (single source of truth). */ @@ -92,6 +92,10 @@ export function setupAiFiles(options: AiSetupOptions): AiSetupResult { case 'agents': writeFile(root, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, result); + for (const skill of skills) { + const skillDir = path.join('.agents', 'skills', skill); + writeFile(root, path.join(skillDir, 'SKILL.md'), renderTemplate(skill, ctx), overwrite, result); + } break; } }