From fb38d1a5e9605693a9e7278cfb515945b9151f9f Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 15:32:43 +0200 Subject: [PATCH 1/9] feat(parser): add instruction content type and INSTRUCTIONS.md parser Add the instruction content type to the type system and implement a parser for INSTRUCTIONS.md files. This is the foundation for replacing rules with instructions as the primary agent context mechanism. - Add 'instruction' to ContextType union - Create CanonicalInstruction interface and InstructionOverrideFields type - Implement parseInstructionContent() with gray-matter frontmatter extraction, field validation, and description-only per-agent overrides - Add comprehensive tests for parsing, validation, overrides, and edge cases --- src/find.ts | 1 + src/instruction-override-parser.test.ts | 249 ++++++++++++++++++ src/instruction-parser.test.ts | 336 ++++++++++++++++++++++++ src/instruction-parser.ts | 117 +++++++++ src/types.ts | 21 +- 5 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 src/instruction-override-parser.test.ts create mode 100644 src/instruction-parser.test.ts create mode 100644 src/instruction-parser.ts diff --git a/src/find.ts b/src/find.ts index 527eff1..964ea28 100644 --- a/src/find.ts +++ b/src/find.ts @@ -342,6 +342,7 @@ async function promptContextSelection( rule: [] as string[], prompt: [] as string[], agent: [] as string[], + instruction: [] as string[], }; for (const item of picked) { byType[item.type].push(item.name); diff --git a/src/instruction-override-parser.test.ts b/src/instruction-override-parser.test.ts new file mode 100644 index 0000000..994a6d7 --- /dev/null +++ b/src/instruction-override-parser.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { parseInstructionContent } from './instruction-parser.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build an INSTRUCTIONS.md string with YAML frontmatter that supports nested + * objects. Uses raw YAML string construction for agent override blocks. + */ +function instructionYaml(yaml: string, body = ''): string { + return `---\n${yaml}\n---\n\n${body}`; +} + +const BASE_YAML = `name: coding-standards +description: Follow consistent coding standards across the project`; + +// --------------------------------------------------------------------------- +// Override extraction — happy paths +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — per-agent overrides', () => { + it('parses an instruction with a github-copilot description override', () => { + const yaml = `${BASE_YAML} +github-copilot: + description: Copilot-specific coding standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides).toBeDefined(); + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific coding standards', + }); + expect(result.warnings).toEqual([]); + }); + + it('parses an instruction with a claude-code description override', () => { + const yaml = `${BASE_YAML} +claude-code: + description: Claude-specific coding standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['claude-code']).toEqual({ + description: 'Claude-specific coding standards', + }); + expect(result.warnings).toEqual([]); + }); + + it('parses an instruction with multiple agent override blocks', () => { + const yaml = `${BASE_YAML} +github-copilot: + description: Copilot-specific standards +claude-code: + description: Claude-specific standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific standards', + }); + expect(result.instruction.overrides!['claude-code']).toEqual({ + description: 'Claude-specific standards', + }); + }); + + it('parses an instruction with cursor description override', () => { + const yaml = `${BASE_YAML} +cursor: + description: Cursor-specific coding standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['cursor']).toEqual({ + description: 'Cursor-specific coding standards', + }); + }); + + it('parses an instruction with opencode description override', () => { + const yaml = `${BASE_YAML} +opencode: + description: OpenCode-specific coding standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['opencode']).toEqual({ + description: 'OpenCode-specific coding standards', + }); + }); +}); + +// --------------------------------------------------------------------------- +// No overrides — backward compatibility +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — no overrides (backward compatible)', () => { + it('returns undefined overrides when no override blocks present', () => { + const result = parseInstructionContent(instructionYaml(BASE_YAML)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides).toBeUndefined(); + expect(result.warnings).toEqual([]); + }); + + it('returns warnings as empty array on success', () => { + const result = parseInstructionContent(instructionYaml(BASE_YAML, 'Body text')); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown agent key warnings +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — unknown agent key warnings', () => { + it('warns on unknown agent key', () => { + const yaml = `${BASE_YAML} +fake-agent: + description: Unknown agent`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('fake-agent'); + expect(result.instruction.overrides).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Non-overridable fields are ignored +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — non-overridable fields', () => { + it('ignores name in override block', () => { + const yaml = `${BASE_YAML} +github-copilot: + name: different-name + description: Copilot-specific standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific standards', + }); + expect(result.instruction.name).toBe('coding-standards'); + }); + + it('ignores schema-version in override block', () => { + const yaml = `${BASE_YAML} +github-copilot: + schema-version: 2 + description: Copilot-specific standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific standards', + }); + }); + + it('ignores body in override block', () => { + const yaml = `${BASE_YAML} +github-copilot: + body: should be ignored + description: Copilot-specific standards`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific standards', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Override validation errors +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — override validation', () => { + it('warns on non-string description in override', () => { + const yaml = `${BASE_YAML} +github-copilot: + description: 42`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('github-copilot'); + expect(result.warnings[0]).toContain('description'); + }); + + it('warns on non-object override block', () => { + const yaml = `${BASE_YAML} +github-copilot: just-a-string`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('github-copilot'); + expect(result.warnings[0]).toContain('object'); + }); + + it('accepts valid override blocks alongside invalid ones', () => { + const yaml = `${BASE_YAML} +github-copilot: + description: Copilot-specific standards +claude-code: + description: 42`; + const result = parseInstructionContent(instructionYaml(yaml)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction.overrides!['github-copilot']).toEqual({ + description: 'Copilot-specific standards', + }); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('claude-code'); + }); +}); diff --git a/src/instruction-parser.test.ts b/src/instruction-parser.test.ts new file mode 100644 index 0000000..573037e --- /dev/null +++ b/src/instruction-parser.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from 'vitest'; +import { parseInstructionContent } from './instruction-parser.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function instructionmd(frontmatter: Record, body = ''): string { + const lines = Object.entries(frontmatter).map(([key, value]) => { + if (Array.isArray(value)) { + return `${key}:\n${value.map((v) => ` - ${v}`).join('\n')}`; + } + if (typeof value === 'string') { + return `${key}: ${value}`; + } + return `${key}: ${value}`; + }); + return `---\n${lines.join('\n')}\n---\n\n${body}`; +} + +const VALID_FRONTMATTER = { + name: 'coding-standards', + description: 'Follow consistent coding standards across the project', +}; + +const MINIMAL_FRONTMATTER = { + name: 'coding-standards', + description: 'Follow consistent coding standards across the project', +}; + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — valid instructions', () => { + it('parses a fully specified INSTRUCTIONS.md', () => { + const content = instructionmd(VALID_FRONTMATTER, '## Standards\n\nUse consistent formatting.'); + const result = parseInstructionContent(content); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction).toEqual({ + name: 'coding-standards', + description: 'Follow consistent coding standards across the project', + schemaVersion: 1, + body: '## Standards\n\nUse consistent formatting.', + }); + }); + + it('parses an INSTRUCTIONS.md with only required fields', () => { + const result = parseInstructionContent(instructionmd(MINIMAL_FRONTMATTER, 'Follow the rules.')); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.instruction).toEqual({ + name: 'coding-standards', + description: 'Follow consistent coding standards across the project', + schemaVersion: 1, + body: 'Follow the rules.', + }); + }); + + it('defaults schema-version to 1 when omitted', () => { + const result = parseInstructionContent(instructionmd(MINIMAL_FRONTMATTER)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.instruction.schemaVersion).toBe(1); + }); + + it('accepts schema-version: 1 explicitly', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, 'schema-version': 1 }) + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.instruction.schemaVersion).toBe(1); + }); + + it('accepts empty body', () => { + const result = parseInstructionContent(instructionmd(MINIMAL_FRONTMATTER)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.instruction.body).toBe(''); + }); + + it('preserves body exactly (no transformation)', () => { + const body = '## Section\n\n- Item 1\n- Item 2\n\n```ts\nconst x = 1;\n```'; + const result = parseInstructionContent(instructionmd(MINIMAL_FRONTMATTER, body)); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.instruction.body).toBe(body); + }); + + it('trims trailing whitespace from body', () => { + const result = parseInstructionContent(instructionmd(MINIMAL_FRONTMATTER, 'Body text \n\n')); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.instruction.body).toBe('Body text'); + }); + + it('accepts single-word kebab-case name', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'standards' }) + ); + expect(result.ok).toBe(true); + }); + + it('accepts name with only numbers and hyphens', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'rule-1-2-3' }) + ); + expect(result.ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Name validation +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — name validation', () => { + it('rejects missing name', () => { + const { name: _, ...fm } = MINIMAL_FRONTMATTER; + const result = parseInstructionContent(instructionmd(fm)); + expect(result).toEqual({ ok: false, error: 'missing required field: name' }); + }); + + it('rejects empty name', () => { + const result = parseInstructionContent(instructionmd({ ...MINIMAL_FRONTMATTER, name: '' })); + expect(result.ok).toBe(false); + }); + + it('rejects name exceeding 128 characters', () => { + const longName = 'a'.repeat(129); + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: longName }) + ); + expect(result).toEqual({ ok: false, error: 'name exceeds 128 characters' }); + }); + + it('accepts name at exactly 128 characters', () => { + const maxName = 'a'.repeat(128); + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: maxName }) + ); + expect(result.ok).toBe(true); + }); + + it('rejects name not in kebab-case (uppercase)', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'Coding-Standards' }) + ); + expect(result).toEqual({ + ok: false, + error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "my-instruction")', + }); + }); + + it('rejects name with underscores', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'coding_standards' }) + ); + expect(result).toEqual({ + ok: false, + error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "my-instruction")', + }); + }); + + it('rejects name with spaces', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'coding standards' }) + ); + expect(result).toEqual({ + ok: false, + error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "my-instruction")', + }); + }); + + it('rejects name with leading hyphen', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: '-coding-standards' }) + ); + expect(result.ok).toBe(false); + }); + + it('rejects name with trailing hyphen', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'coding-standards-' }) + ); + expect(result.ok).toBe(false); + }); + + it('rejects name with consecutive hyphens', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, name: 'coding--standards' }) + ); + expect(result.ok).toBe(false); + }); + + it('rejects numeric name (YAML parses bare numbers)', () => { + const result = parseInstructionContent(instructionmd({ ...MINIMAL_FRONTMATTER, name: 123 })); + expect(result).toEqual({ ok: false, error: 'name must be a string' }); + }); +}); + +// --------------------------------------------------------------------------- +// Description validation +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — description validation', () => { + it('rejects missing description', () => { + const { description: _, ...fm } = MINIMAL_FRONTMATTER; + const result = parseInstructionContent(instructionmd(fm)); + expect(result).toEqual({ ok: false, error: 'missing required field: description' }); + }); + + it('rejects description exceeding 512 characters', () => { + const longDesc = 'x'.repeat(513); + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, description: longDesc }) + ); + expect(result).toEqual({ ok: false, error: 'description exceeds 512 characters' }); + }); + + it('accepts description at exactly 512 characters', () => { + const maxDesc = 'x'.repeat(512); + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, description: maxDesc }) + ); + expect(result.ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Schema version validation +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — schema-version validation', () => { + it('rejects unsupported future schema-version', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, 'schema-version': 2 }) + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('unsupported schema-version 2'); + expect(result.error).toContain('upgrade dotai'); + }); + + it('rejects schema-version 0', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, 'schema-version': 0 }) + ); + expect(result).toEqual({ ok: false, error: 'schema-version must be >= 1' }); + }); + + it('rejects non-integer schema-version', () => { + const result = parseInstructionContent( + instructionmd({ ...MINIMAL_FRONTMATTER, 'schema-version': 1.5 }) + ); + expect(result).toEqual({ ok: false, error: 'schema-version must be an integer' }); + }); + + it('rejects string schema-version', () => { + const content = `--- +name: coding-standards +description: Follow consistent coding standards +schema-version: "1" +--- +`; + const result = parseInstructionContent(content); + expect(result).toEqual({ ok: false, error: 'schema-version must be an integer' }); + }); +}); + +// --------------------------------------------------------------------------- +// Unknown frontmatter key warnings +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — unknown field warnings', () => { + it('warns on unknown frontmatter key', () => { + const content = `--- +name: coding-standards +description: Follow consistent coding standards +severity: error +--- +`; + const result = parseInstructionContent(content); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('severity'); + }); + + it('warns on multiple unknown frontmatter keys', () => { + const content = `--- +name: coding-standards +description: Follow consistent coding standards +activation: always +globs: "*.ts" +--- +`; + const result = parseInstructionContent(content); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.warnings).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// Malformed frontmatter +// --------------------------------------------------------------------------- + +describe('parseInstructionContent — malformed input', () => { + it('rejects malformed YAML frontmatter', () => { + const content = `--- +name: coding-standards +description: [invalid yaml +--- +`; + const result = parseInstructionContent(content); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('invalid YAML frontmatter'); + } + }); +}); diff --git a/src/instruction-parser.ts b/src/instruction-parser.ts new file mode 100644 index 0000000..6090826 --- /dev/null +++ b/src/instruction-parser.ts @@ -0,0 +1,117 @@ +import matter from 'gray-matter'; +import type { CanonicalInstruction, InstructionOverrideFields, TargetAgent } from './types.ts'; +import { + SUPPORTED_SCHEMA_VERSION, + validateName, + validateDescription, + validateSchemaVersion, +} from './validation.ts'; +import { extractOverrides } from './override-parser.ts'; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +/** Result of parsing an INSTRUCTIONS.md file. */ +export type ParseInstructionResult = + | { ok: true; instruction: CanonicalInstruction; warnings: string[] } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Override support +// --------------------------------------------------------------------------- + +/** Base field names recognized in INSTRUCTIONS.md frontmatter (not override blocks). */ +const INSTRUCTION_BASE_FIELDS: ReadonlySet = new Set([ + 'name', + 'description', + 'schema-version', +]); + +/** Fields that are not allowed in override blocks (identity / structural). */ +const NON_OVERRIDABLE_INSTRUCTION_FIELDS: ReadonlySet = new Set([ + 'name', + 'schema-version', + 'body', +]); + +/** + * Extract and validate instruction override fields from an agent override block. + * Instructions only support description overrides. + */ +function extractInstructionOverrideFields( + agentData: Record, + _agentName: TargetAgent +): { fields: InstructionOverrideFields; error: string | null } { + const fields: InstructionOverrideFields = {}; + + for (const key of Object.keys(agentData)) { + if (NON_OVERRIDABLE_INSTRUCTION_FIELDS.has(key)) { + // Silently ignore non-overridable fields + continue; + } + } + + if ('description' in agentData) { + const err = validateDescription(agentData.description); + if (err) return { fields, error: err }; + fields.description = agentData.description as string; + } + + return { fields, error: null }; +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/** + * Parse and validate an INSTRUCTIONS.md file from its raw content string. + * + * Returns a discriminated union: `{ ok: true, instruction, warnings }` on + * success, `{ ok: false, error }` on validation failure. + */ +export function parseInstructionContent(content: string): ParseInstructionResult { + let data: Record; + let body: string; + try { + const parsed = matter(content); + data = parsed.data; + body = parsed.content; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: `invalid YAML frontmatter: ${message}` }; + } + + // Validate required fields. + const nameError = validateName(data.name, 'my-instruction'); + if (nameError) return { ok: false, error: nameError }; + + const descriptionError = validateDescription(data.description); + if (descriptionError) return { ok: false, error: descriptionError }; + + const schemaVersionRaw = data['schema-version']; + const versionError = validateSchemaVersion(schemaVersionRaw); + if (versionError) return { ok: false, error: versionError }; + + // Extract per-agent overrides + const { overrides, warnings } = extractOverrides( + data, + INSTRUCTION_BASE_FIELDS, + extractInstructionOverrideFields + ); + + const instruction: CanonicalInstruction = { + name: data.name as string, + description: data.description as string, + schemaVersion: + typeof schemaVersionRaw === 'number' ? schemaVersionRaw : SUPPORTED_SCHEMA_VERSION, + body: body.trim(), + }; + + if (overrides) { + instruction.overrides = overrides; + } + + return { ok: true, instruction, warnings }; +} diff --git a/src/types.ts b/src/types.ts index 6f0b5a5..5ca2cfb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,7 +68,7 @@ export interface RemoteSkill { export type TargetAgent = 'github-copilot' | 'claude-code' | 'cursor' | 'opencode'; /** Context item types supported by dotai. */ -export type ContextType = 'skill' | 'rule' | 'prompt' | 'agent'; +export type ContextType = 'skill' | 'rule' | 'prompt' | 'agent' | 'instruction'; /** How a discovered item was authored. */ export type ContextFormat = 'canonical' | `native:${TargetAgent}`; @@ -182,6 +182,25 @@ export interface CanonicalAgent { overrides?: Partial>; } +/** Fields that can be overridden per target agent for instructions (description only). */ +export type InstructionOverrideFields = { description?: string }; + +/** + * Canonical INSTRUCTIONS.md representation after parsing and validation. + */ +export interface CanonicalInstruction { + /** Kebab-case identifier, <= 128 chars. */ + name: string; + /** Human-readable description, <= 512 chars. */ + description: string; + /** Schema version (default 1). */ + schemaVersion: number; + /** The markdown body (everything after frontmatter). */ + body: string; + /** Per-agent override blocks from frontmatter. */ + overrides?: Partial>; +} + /** * Output of a transpiler — one file to write (or append) during install. */ From 958b33a6d5cd8ba0000d2dda49dac6ab01f3d03f Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 15:39:21 +0200 Subject: [PATCH 2/9] feat: add instruction transpilers and AGENTS.md deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-agent instruction transpilers that convert CanonicalInstruction into marker-based append content for each target agent's project-wide instruction file. Implement output-path deduplication so Cursor and OpenCode (both targeting AGENTS.md) produce only one write. - Add InstructionsConfig type and instructionsConfig to TargetAgentConfig - Configure all 4 agents: Copilot → .github/copilot-instructions.md, Claude Code → CLAUDE.md, Cursor → AGENTS.md, OpenCode → AGENTS.md - Update getOutputDir() to handle 'instruction' context type - Create instruction-transpilers.ts with transpiler registry, per-agent transpilers, override merging, and output-path deduplication - Add 48 tests covering all agents, deduplication, overrides, and edges Closes #19 --- src/instruction-transpilers.test.ts | 410 ++++++++++++++++++++++++++++ src/instruction-transpilers.ts | 216 +++++++++++++++ src/target-agents.ts | 36 ++- 3 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 src/instruction-transpilers.test.ts create mode 100644 src/instruction-transpilers.ts diff --git a/src/instruction-transpilers.test.ts b/src/instruction-transpilers.test.ts new file mode 100644 index 0000000..a63b91e --- /dev/null +++ b/src/instruction-transpilers.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect } from 'vitest'; +import { + copilotInstructionTranspiler, + claudeCodeInstructionTranspiler, + cursorInstructionTranspiler, + opencodeInstructionTranspiler, + instructionTranspilers, + transpileInstruction, + transpileInstructionForAllAgents, +} from './instruction-transpilers.ts'; +import { TARGET_AGENTS } from './target-agents.ts'; +import type { CanonicalInstruction, DiscoveredItem, TargetAgent } from './types.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeInstruction(overrides: Partial = {}): CanonicalInstruction { + return { + name: 'code-style', + description: 'Enforce consistent code style across the project', + schemaVersion: 1, + body: 'Use 2-space indentation.\n\nPrefer const over let.', + ...overrides, + }; +} + +function makeDiscoveredInstructionItem(overrides: Partial = {}): DiscoveredItem { + return { + type: 'instruction', + format: 'canonical', + name: 'code-style', + description: 'Enforce consistent code style across the project', + sourcePath: '/repo/instructions/code-style/INSTRUCTIONS.md', + rawContent: [ + '---', + 'name: code-style', + 'description: Enforce consistent code style across the project', + '---', + '', + 'Use 2-space indentation.', + '', + 'Prefer const over let.', + ].join('\n'), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// canTranspile +// --------------------------------------------------------------------------- + +describe('canTranspile', () => { + const canonicalInstruction = makeDiscoveredInstructionItem(); + const nativeItem = makeDiscoveredInstructionItem({ + format: 'native:github-copilot', + }); + const skillItem = makeDiscoveredInstructionItem({ type: 'skill' }); + const ruleItem = makeDiscoveredInstructionItem({ type: 'rule' }); + const promptItem = makeDiscoveredInstructionItem({ type: 'prompt' }); + + const transpilers = [ + ['copilot', copilotInstructionTranspiler], + ['claude-code', claudeCodeInstructionTranspiler], + ['cursor', cursorInstructionTranspiler], + ['opencode', opencodeInstructionTranspiler], + ] as const; + + it.each(transpilers)('%s accepts canonical instructions', (_name, transpiler) => { + expect(transpiler.canTranspile(canonicalInstruction)).toBe(true); + }); + + it.each(transpilers)('%s rejects native items', (_name, transpiler) => { + expect(transpiler.canTranspile(nativeItem)).toBe(false); + }); + + it.each(transpilers)('%s rejects skill items', (_name, transpiler) => { + expect(transpiler.canTranspile(skillItem)).toBe(false); + }); + + it.each(transpilers)('%s rejects rule items', (_name, transpiler) => { + expect(transpiler.canTranspile(ruleItem)).toBe(false); + }); + + it.each(transpilers)('%s rejects prompt items', (_name, transpiler) => { + expect(transpiler.canTranspile(promptItem)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Copilot instruction transpiler +// --------------------------------------------------------------------------- + +describe('Copilot instruction transpiler', () => { + it('targets .github/copilot-instructions.md in append mode', () => { + const instruction = makeInstruction(); + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + + expect(output.filename).toBe('copilot-instructions.md'); + expect(output.outputDir).toBe('.github'); + expect(output.mode).toBe('append'); + }); + + it('includes heading and description blockquote', () => { + const instruction = makeInstruction(); + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + + expect(output.content).toContain('## code-style'); + expect(output.content).toContain('> Enforce consistent code style across the project'); + }); + + it('includes body content', () => { + const instruction = makeInstruction(); + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + + expect(output.content).toContain('Use 2-space indentation.'); + expect(output.content).toContain('Prefer const over let.'); + }); +}); + +// --------------------------------------------------------------------------- +// Claude Code instruction transpiler +// --------------------------------------------------------------------------- + +describe('Claude Code instruction transpiler', () => { + it('targets CLAUDE.md in append mode', () => { + const instruction = makeInstruction(); + const output = claudeCodeInstructionTranspiler.transform(instruction, 'claude-code'); + + expect(output.filename).toBe('CLAUDE.md'); + expect(output.outputDir).toBe('.'); + expect(output.mode).toBe('append'); + }); + + it('includes heading and description blockquote', () => { + const instruction = makeInstruction(); + const output = claudeCodeInstructionTranspiler.transform(instruction, 'claude-code'); + + expect(output.content).toContain('## code-style'); + expect(output.content).toContain('> Enforce consistent code style across the project'); + }); + + it('includes body content', () => { + const instruction = makeInstruction(); + const output = claudeCodeInstructionTranspiler.transform(instruction, 'claude-code'); + + expect(output.content).toContain('Use 2-space indentation.'); + expect(output.content).toContain('Prefer const over let.'); + }); +}); + +// --------------------------------------------------------------------------- +// Cursor instruction transpiler +// --------------------------------------------------------------------------- + +describe('Cursor instruction transpiler', () => { + it('targets AGENTS.md in append mode', () => { + const instruction = makeInstruction(); + const output = cursorInstructionTranspiler.transform(instruction, 'cursor'); + + expect(output.filename).toBe('AGENTS.md'); + expect(output.outputDir).toBe('.'); + expect(output.mode).toBe('append'); + }); + + it('includes heading and description blockquote', () => { + const instruction = makeInstruction(); + const output = cursorInstructionTranspiler.transform(instruction, 'cursor'); + + expect(output.content).toContain('## code-style'); + expect(output.content).toContain('> Enforce consistent code style across the project'); + }); +}); + +// --------------------------------------------------------------------------- +// OpenCode instruction transpiler +// --------------------------------------------------------------------------- + +describe('OpenCode instruction transpiler', () => { + it('targets AGENTS.md in append mode', () => { + const instruction = makeInstruction(); + const output = opencodeInstructionTranspiler.transform(instruction, 'opencode'); + + expect(output.filename).toBe('AGENTS.md'); + expect(output.outputDir).toBe('.'); + expect(output.mode).toBe('append'); + }); + + it('includes heading and description blockquote', () => { + const instruction = makeInstruction(); + const output = opencodeInstructionTranspiler.transform(instruction, 'opencode'); + + expect(output.content).toContain('## code-style'); + expect(output.content).toContain('> Enforce consistent code style across the project'); + }); +}); + +// --------------------------------------------------------------------------- +// transpileInstruction (integrated) +// --------------------------------------------------------------------------- + +describe('transpileInstruction', () => { + it('transpiles canonical instruction for copilot', () => { + const item = makeDiscoveredInstructionItem(); + const output = transpileInstruction(item, 'github-copilot'); + + expect(output).not.toBeNull(); + expect(output!.filename).toBe('copilot-instructions.md'); + expect(output!.outputDir).toBe('.github'); + expect(output!.mode).toBe('append'); + expect(output!.content).toContain('## code-style'); + }); + + it('transpiles canonical instruction for claude-code', () => { + const item = makeDiscoveredInstructionItem(); + const output = transpileInstruction(item, 'claude-code'); + + expect(output).not.toBeNull(); + expect(output!.filename).toBe('CLAUDE.md'); + expect(output!.outputDir).toBe('.'); + expect(output!.mode).toBe('append'); + }); + + it('transpiles canonical instruction for cursor', () => { + const item = makeDiscoveredInstructionItem(); + const output = transpileInstruction(item, 'cursor'); + + expect(output).not.toBeNull(); + expect(output!.filename).toBe('AGENTS.md'); + expect(output!.outputDir).toBe('.'); + expect(output!.mode).toBe('append'); + }); + + it('transpiles canonical instruction for opencode', () => { + const item = makeDiscoveredInstructionItem(); + const output = transpileInstruction(item, 'opencode'); + + expect(output).not.toBeNull(); + expect(output!.filename).toBe('AGENTS.md'); + expect(output!.outputDir).toBe('.'); + expect(output!.mode).toBe('append'); + }); + + it('returns null for invalid canonical content', () => { + const item = makeDiscoveredInstructionItem({ + rawContent: '---\n---\n\nNo frontmatter fields', + }); + + const output = transpileInstruction(item, 'github-copilot'); + expect(output).toBeNull(); + }); + + it('applies per-agent description override', () => { + const item = makeDiscoveredInstructionItem({ + rawContent: [ + '---', + 'name: code-style', + 'description: Default description', + 'github-copilot:', + ' description: Copilot-specific description', + '---', + '', + 'Body content.', + ].join('\n'), + }); + + const copilotOutput = transpileInstruction(item, 'github-copilot'); + expect(copilotOutput).not.toBeNull(); + expect(copilotOutput!.content).toContain('> Copilot-specific description'); + expect(copilotOutput!.content).not.toContain('> Default description'); + + // Other agents get the default description + const claudeOutput = transpileInstruction(item, 'claude-code'); + expect(claudeOutput).not.toBeNull(); + expect(claudeOutput!.content).toContain('> Default description'); + }); +}); + +// --------------------------------------------------------------------------- +// transpileInstructionForAllAgents +// --------------------------------------------------------------------------- + +describe('transpileInstructionForAllAgents', () => { + it('produces outputs for all agents from canonical instruction', () => { + const item = makeDiscoveredInstructionItem(); + const outputs = transpileInstructionForAllAgents(item, TARGET_AGENTS); + + // 4 agents but Cursor + OpenCode share AGENTS.md → 3 unique outputs + expect(outputs).toHaveLength(3); + + const filenames = outputs.map((o) => `${o.outputDir}/${o.filename}`).sort(); + expect(filenames).toEqual(['./AGENTS.md', './CLAUDE.md', '.github/copilot-instructions.md']); + }); + + it('all outputs use append mode', () => { + const item = makeDiscoveredInstructionItem(); + const outputs = transpileInstructionForAllAgents(item, TARGET_AGENTS); + + for (const output of outputs) { + expect(output.mode).toBe('append'); + } + }); + + it('deduplicates AGENTS.md for Cursor and OpenCode', () => { + const item = makeDiscoveredInstructionItem(); + const agents: TargetAgent[] = ['cursor', 'opencode']; + + const outputs = transpileInstructionForAllAgents(item, agents); + + // Should produce exactly one output, not two + expect(outputs).toHaveLength(1); + expect(outputs[0]!.filename).toBe('AGENTS.md'); + expect(outputs[0]!.outputDir).toBe('.'); + }); + + it('handles subset of target agents', () => { + const item = makeDiscoveredInstructionItem(); + const agents: TargetAgent[] = ['github-copilot']; + + const outputs = transpileInstructionForAllAgents(item, agents); + + expect(outputs).toHaveLength(1); + expect(outputs[0]!.filename).toBe('copilot-instructions.md'); + }); + + it('returns empty array for invalid content', () => { + const item = makeDiscoveredInstructionItem({ + rawContent: 'not valid frontmatter content', + }); + + const outputs = transpileInstructionForAllAgents(item, TARGET_AGENTS); + expect(outputs).toHaveLength(0); + }); + + it('returns empty array for empty agent list', () => { + const item = makeDiscoveredInstructionItem(); + const outputs = transpileInstructionForAllAgents(item, []); + + expect(outputs).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Transpiler registry +// --------------------------------------------------------------------------- + +describe('instructionTranspilers registry', () => { + it('has entries for all four target agents', () => { + expect(Object.keys(instructionTranspilers).sort()).toEqual([ + 'claude-code', + 'cursor', + 'github-copilot', + 'opencode', + ]); + }); + + it('all entries implement canTranspile and transform', () => { + for (const [, transpiler] of Object.entries(instructionTranspilers)) { + expect(typeof transpiler.canTranspile).toBe('function'); + expect(typeof transpiler.transform).toBe('function'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +describe('edge cases', () => { + it('handles instruction with empty body', () => { + const instruction = makeInstruction({ body: '' }); + + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + expect(output.content).toContain('## code-style'); + expect(output.content).toContain('>'); + }); + + it('handles instruction with very long body', () => { + const body = 'x'.repeat(10000); + const instruction = makeInstruction({ body }); + + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + expect(output.content).toContain(body); + }); + + it('preserves markdown formatting in body', () => { + const body = [ + '## Style Rules', + '', + '- Use 2-space indentation', + '- Prefer const over let', + '', + '```typescript', + 'const x = 1;', + '```', + ].join('\n'); + const instruction = makeInstruction({ body }); + + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + expect(output.content).toContain('## Style Rules'); + expect(output.content).toContain('```typescript'); + }); + + it('handles instruction name with numbers', () => { + const instruction = makeInstruction({ name: 'style-v2' }); + + const output = copilotInstructionTranspiler.transform(instruction, 'github-copilot'); + expect(output.content).toContain('## style-v2'); + }); +}); diff --git a/src/instruction-transpilers.ts b/src/instruction-transpilers.ts new file mode 100644 index 0000000..13e518b --- /dev/null +++ b/src/instruction-transpilers.ts @@ -0,0 +1,216 @@ +import type { Transpiler } from './transpiler.ts'; +import type { + CanonicalInstruction, + DiscoveredItem, + TargetAgent, + TranspiledOutput, +} from './types.ts'; +import { parseInstructionContent } from './instruction-parser.ts'; +import { getTargetAgentConfig } from './target-agents.ts'; +import { mergeOverrides } from './override-parser.ts'; + +// --------------------------------------------------------------------------- +// Instruction transpilers — canonical INSTRUCTIONS.md → per-agent output +// +// Each transpiler converts a CanonicalInstruction into a marker-based append +// section for the target agent's project-wide instruction file (e.g., +// AGENTS.md, CLAUDE.md, .github/copilot-instructions.md). +// +// All transpilers use `mode: 'append'` with `` +// markers. The installer uses `upsertSection()` to insert or update the +// content between markers without disturbing hand-written content. +// +// Reference: prd-instruction-transpilers.md +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Shared content builder +// +// All instruction transpilers produce the same markdown structure: +// ## +// > +// +// +// This mirrors the append rule transpiler pattern. +// --------------------------------------------------------------------------- + +function buildInstructionContent(instruction: CanonicalInstruction): string { + const lines: string[] = []; + + lines.push(`## ${instruction.name}`); + lines.push(''); + lines.push(`> ${instruction.description}`); + lines.push(''); + lines.push(instruction.body); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// GitHub Copilot instruction transpiler (→ .github/copilot-instructions.md) +// --------------------------------------------------------------------------- + +export const copilotInstructionTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'instruction' && item.format === 'canonical'; + }, + + transform(instruction: CanonicalInstruction, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('github-copilot'); + + return { + filename: config.instructionsConfig.filename, + content: buildInstructionContent(instruction), + outputDir: config.instructionsConfig.outputDir, + mode: 'append', + }; + }, +}; + +// --------------------------------------------------------------------------- +// Claude Code instruction transpiler (→ CLAUDE.md) +// --------------------------------------------------------------------------- + +export const claudeCodeInstructionTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'instruction' && item.format === 'canonical'; + }, + + transform(instruction: CanonicalInstruction, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('claude-code'); + + return { + filename: config.instructionsConfig.filename, + content: buildInstructionContent(instruction), + outputDir: config.instructionsConfig.outputDir, + mode: 'append', + }; + }, +}; + +// --------------------------------------------------------------------------- +// Cursor instruction transpiler (→ AGENTS.md) +// --------------------------------------------------------------------------- + +export const cursorInstructionTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'instruction' && item.format === 'canonical'; + }, + + transform(instruction: CanonicalInstruction, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('cursor'); + + return { + filename: config.instructionsConfig.filename, + content: buildInstructionContent(instruction), + outputDir: config.instructionsConfig.outputDir, + mode: 'append', + }; + }, +}; + +// --------------------------------------------------------------------------- +// OpenCode instruction transpiler (→ AGENTS.md) +// --------------------------------------------------------------------------- + +export const opencodeInstructionTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'instruction' && item.format === 'canonical'; + }, + + transform(instruction: CanonicalInstruction, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('opencode'); + + return { + filename: config.instructionsConfig.filename, + content: buildInstructionContent(instruction), + outputDir: config.instructionsConfig.outputDir, + mode: 'append', + }; + }, +}; + +// --------------------------------------------------------------------------- +// Transpiler registry +// --------------------------------------------------------------------------- + +/** Map of target agents to their instruction transpilers. */ +export const instructionTranspilers: Record> = { + 'github-copilot': copilotInstructionTranspiler, + 'claude-code': claudeCodeInstructionTranspiler, + cursor: cursorInstructionTranspiler, + opencode: opencodeInstructionTranspiler, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Transpile a canonical instruction for a specific target agent. + * + * Parses the raw content to extract the CanonicalInstruction, merges + * per-agent overrides, then delegates to the appropriate transpiler. + * Returns `null` if parsing fails. + */ +export function transpileInstruction( + item: DiscoveredItem, + targetAgent: TargetAgent +): TranspiledOutput | null { + const transpiler = instructionTranspilers[targetAgent]; + + const parsed = parseInstructionContent(item.rawContent); + if (!parsed.ok) { + return null; + } + + // Merge per-agent overrides on top of base fields + const instruction = mergeOverrides(parsed.instruction, targetAgent) as CanonicalInstruction; + + return transpiler.transform(instruction, targetAgent); +} + +/** + * Resolve the output path key for a target agent's instruction config. + * Used for deduplication: agents sharing the same key target the same file. + */ +function outputPathKey(agent: TargetAgent): string { + const config = getTargetAgentConfig(agent); + const { outputDir, filename } = config.instructionsConfig; + return `${outputDir}/${filename}`; +} + +/** + * Transpile a canonical instruction for all target agents, with + * output-path deduplication. + * + * When multiple agents target the same output file (e.g., Cursor and + * OpenCode both target `AGENTS.md`), the instruction content is emitted + * only once. Per-agent description overrides are still applied: the first + * agent in the list whose override differs gets its version emitted, and + * subsequent agents sharing the same path are skipped. + * + * Returns an array of TranspiledOutputs, one per unique output path. + */ +export function transpileInstructionForAllAgents( + item: DiscoveredItem, + agents: readonly TargetAgent[] +): TranspiledOutput[] { + const seen = new Set(); + const outputs: TranspiledOutput[] = []; + + for (const agent of agents) { + const key = outputPathKey(agent); + if (seen.has(key)) { + continue; // Deduplicate: skip agents sharing the same output file + } + seen.add(key); + + const output = transpileInstruction(item, agent); + if (output !== null) { + outputs.push(output); + } + } + + return outputs; +} diff --git a/src/target-agents.ts b/src/target-agents.ts index 28c3eba..dd9c66a 100644 --- a/src/target-agents.ts +++ b/src/target-agents.ts @@ -1,7 +1,7 @@ import type { TargetAgent, ContextType } from './types.ts'; // --------------------------------------------------------------------------- -// Target agent registry for dotai transpilation (rules, skills, prompts, agents) +// Target agent registry for dotai transpilation (rules, skills, prompts, agents, instructions) // // This is separate from the upstream `agents.ts` (skills-only registry with // 40+ agents) to avoid merge conflicts and keep concerns separated. The @@ -19,6 +19,19 @@ export interface ContextTypeConfig { extension: string; } +/** + * Configuration for instruction output within a target agent. + * + * Instructions use append mode: all instructions are written as marker-based + * sections in a single project-wide file (e.g., `AGENTS.md`, `CLAUDE.md`). + */ +export interface InstructionsConfig { + /** Directory containing the output file (relative to project root). */ + outputDir: string; + /** Target filename (e.g., `AGENTS.md`, `CLAUDE.md`). */ + filename: string; +} + /** * Configuration for native passthrough discovery within a source repo. * Used to find agent-native rule files that should be installed without @@ -78,6 +91,8 @@ export interface TargetAgentConfig { agentsConfig?: ContextTypeConfig; /** Native agent file discovery locations in source repos. */ nativeAgentDiscovery?: NativeAgentDiscovery; + /** Instructions output configuration (append-mode, project-wide file). */ + instructionsConfig: InstructionsConfig; } /** @@ -115,6 +130,10 @@ export const targetAgents: Record = { sourceDir: '.github/agents', pattern: '*.agent.md', }, + instructionsConfig: { + outputDir: '.github', + filename: 'copilot-instructions.md', + }, }, 'claude-code': { name: 'claude-code', @@ -144,6 +163,10 @@ export const targetAgents: Record = { sourceDir: '.claude/agents', pattern: '*.md', }, + instructionsConfig: { + outputDir: '.', + filename: 'CLAUDE.md', + }, }, cursor: { name: 'cursor', @@ -157,6 +180,10 @@ export const targetAgents: Record = { sourceDir: '.cursor/rules', pattern: '*.mdc', }, + instructionsConfig: { + outputDir: '.', + filename: 'AGENTS.md', + }, // Cursor has no prompt/command system }, opencode: { @@ -187,6 +214,10 @@ export const targetAgents: Record = { sourceDir: '.opencode/agents', pattern: '*.md', }, + instructionsConfig: { + outputDir: '.', + filename: 'AGENTS.md', + }, }, }; @@ -216,6 +247,9 @@ export function getOutputDir(agent: TargetAgent, contextType: ContextType): stri if (contextType === 'agent') { return config.agentsConfig?.outputDir; } + if (contextType === 'instruction') { + return config.instructionsConfig.outputDir; + } return config.rulesConfig.outputDir; } From 999ee4f5e3f7e7d7888790f921881fafdd2d2fd9 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 15:49:29 +0200 Subject: [PATCH 3/9] feat: add INSTRUCTIONS.md discovery for local and remote sources Add local discovery of root-level INSTRUCTIONS.md via discoverCanonicalInstructions() in rule-discovery.ts, and remote discovery via a new pattern in find-discovery.ts. Only the package root is scanned (no subdirectory scanning). Wire instructions into the find command display output and update RemoteContextSummary. --- README.md | 17 +-- docs/cli-reference.md | 27 ++-- src/find-discovery.test.ts | 68 +++++++++- src/find-discovery.ts | 10 +- src/find.ts | 13 +- src/instruction-discovery.test.ts | 214 ++++++++++++++++++++++++++++++ src/rule-discovery.ts | 94 ++++++++++--- 7 files changed, 403 insertions(+), 40 deletions(-) create mode 100644 src/instruction-discovery.test.ts diff --git a/README.md b/README.md index 888e732..a52526c 100644 --- a/README.md +++ b/README.md @@ -57,17 +57,18 @@ npx dotai add owner/repo --targets copilot,claude,cursor npx dotai add owner/repo --rule code-style --skill db-migrate ``` -dotai discovers skills, rules, prompts, and agent definitions in the source +dotai discovers skills, rules, prompts, agent definitions, and instructions in the source repo and transpiles them for your selected targets. ## What dotai installs -| Layer | Canonical file | Install behavior | -| ------- | -------------- | ------------------------------ | -| Skills | `SKILL.md` | Passthrough (symlink or copy) | -| Rules | `RULES.md` | Transpile per target | -| Prompts | `PROMPT.md` | Transpile per supported target | -| Agents | `AGENT.md` | Transpile per supported target | +| Layer | Canonical file | Install behavior | +| ------------ | ----------------- | ------------------------------ | +| Skills | `SKILL.md` | Passthrough (symlink or copy) | +| Rules | `RULES.md` | Transpile per target | +| Prompts | `PROMPT.md` | Transpile per supported target | +| Agents | `AGENT.md` | Transpile per supported target | +| Instructions | `INSTRUCTIONS.md` | Append per target | See [Source repo layout](docs/cli-reference.md#source-repo-layout) for where to place these files in your repo so dotai discovers them. @@ -108,7 +109,7 @@ npx dotai add ./my-local-context # local path | Claude Code | ✅ | ✅ | ✅ | ✅ | | OpenCode | ✅ | ✅ | ✅ | ✅ | | Cursor | ✅ | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | -| Codex | ✅ | — | — | — | +| Codex | ✅ | — | — | — | - **Cursor prompts:** Cursor reads Copilot's `.github/prompts/` path. Canonical `PROMPT.md` is not transpiled to a Cursor-specific format. - **Cursor agents:** Cursor reads `.github/agents/` from the Copilot path. Canonical `AGENT.md` transpiles to Copilot format, which Cursor picks up. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index edb8b80..8fa4791 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,7 +24,7 @@ Full option tables, examples, and authoring format for `dotai`. For a quick over > **`--targets`:** A single flag for both skill install targets and transpilation targets. For skills, any of the supported targets (e.g., `--targets cursor,claude-code`). For rules, prompts, and agents, the 4 transpilation targets: copilot, claude, cursor, opencode. When omitted, all detected targets are used for skills and all 4 transpilation targets for rules/prompts/agents. -> **Zero-flag mode:** Running `dotai add owner/repo` with no type-specific flags discovers all content types (skills, rules, prompts, agents) and presents an interactive grouped selection. Use `dotai find owner/repo` for a non-interactive preview. +> **Zero-flag mode:** Running `dotai add owner/repo` with no type-specific flags discovers all content types (skills, rules, prompts, agents, instructions) and presents an interactive grouped selection. Use `dotai find owner/repo` for a non-interactive preview. > **`--append`:** Instead of writing individual rule files (e.g., `.github/instructions/code-style.instructions.md`), rules are appended as marker-delimited sections into `AGENTS.md` (Copilot) and `CLAUDE.md` (Claude Code). Useful for projects that prefer a single monolithic instruction file. Only applies to Copilot and Claude Code targets; other targets always get individual files. @@ -71,7 +71,7 @@ Found in vercel-labs/agent-skills: ``` - **Install selected skill only** installs the single skill you picked from the search results. -- **Install all context from this repo** installs every skill, rule, prompt, and agent in the repo. +- **Install all context from this repo** installs every skill, rule, prompt, agent, and instruction in the repo. - **Pick individual items** opens a multi-select where you choose exactly which items to install. If the GitHub Trees API is unreachable (rate limit, private repo, network error), the preview step is skipped and the selected skill is installed directly. @@ -133,11 +133,11 @@ Convert native agent-specific rule files into canonical `RULES.md` format. ### Supported native formats -| Agent | Source directory | Parsed fields | -| -------------- | ---------------------------------------- | --------------------------------- | -| Cursor | `.cursor/rules/*.mdc` | description, alwaysApply, globs | -| Claude Code | `.claude/rules/*.md` | description, globs | -| GitHub Copilot | `.github/instructions/*.instructions.md` | applyTo | +| Agent | Source directory | Parsed fields | +| -------------- | ---------------------------------------- | ------------------------------- | +| Cursor | `.cursor/rules/*.mdc` | description, alwaysApply, globs | +| Claude Code | `.claude/rules/*.md` | description, globs | +| GitHub Copilot | `.github/instructions/*.instructions.md` | applyTo | ## list command options @@ -302,6 +302,16 @@ Each type is discovered in two places: If a root-level file and a subdirectory file share the same `name` (from frontmatter), the root-level file takes priority. +### Instructions + +Instructions are discovered only at the package root: + +| Type | Root file | Subdirectory pattern | +| ------------ | ----------------- | -------------------- | +| Instructions | `INSTRUCTIONS.md` | _(none)_ | + +Only one `INSTRUCTIONS.md` per package is supported. Subdirectory files are ignored. + ### Skills Skills use a richer discovery strategy. dotai checks, in order: @@ -317,6 +327,7 @@ A source repo with multiple context types might look like this: ``` my-context-repo/ + INSTRUCTIONS.md # root-level instructions RULES.md # single root-level rule rules/ code-style/ @@ -340,7 +351,7 @@ my-context-repo/ deploy.md ``` -Every canonical file (`RULES.md`, `PROMPT.md`, `AGENT.md`, `SKILL.md`) must contain YAML frontmatter with at least `name` and `description`: +Every canonical file (`RULES.md`, `PROMPT.md`, `AGENT.md`, `SKILL.md`, `INSTRUCTIONS.md`) must contain YAML frontmatter with at least `name` and `description`: ```markdown --- diff --git a/src/find-discovery.test.ts b/src/find-discovery.test.ts index d0f0369..6f8886f 100644 --- a/src/find-discovery.test.ts +++ b/src/find-discovery.test.ts @@ -48,29 +48,45 @@ describe('discoverRemoteContext', () => { blob('rules/code-style/RULES.md'), blob('prompts/review-code/PROMPT.md'), blob('agents/reviewer/AGENT.md'), + blob('INSTRUCTIONS.md'), ]; - const result = discoverRemoteContext(entries); + const result = discoverRemoteContext(entries, 'my-repo'); expect(result.skills).toHaveLength(1); expect(result.rules).toHaveLength(1); expect(result.prompts).toHaveLength(1); expect(result.agents).toHaveLength(1); + expect(result.instructions).toHaveLength(1); expect(result.rules[0]!.name).toBe('code-style'); expect(result.prompts[0]!.name).toBe('review-code'); expect(result.agents[0]!.name).toBe('reviewer'); + expect(result.instructions[0]!.name).toBe('my-repo'); }); it('discovers root-level items for all types', () => { - const entries = [blob('SKILL.md'), blob('RULES.md'), blob('PROMPT.md'), blob('AGENT.md')]; + const entries = [ + blob('SKILL.md'), + blob('RULES.md'), + blob('PROMPT.md'), + blob('AGENT.md'), + blob('INSTRUCTIONS.md'), + ]; const result = discoverRemoteContext(entries, 'my-repo'); expect(result.skills).toHaveLength(1); expect(result.rules).toHaveLength(1); expect(result.prompts).toHaveLength(1); expect(result.agents).toHaveLength(1); - - for (const list of [result.skills, result.rules, result.prompts, result.agents]) { + expect(result.instructions).toHaveLength(1); + + for (const list of [ + result.skills, + result.rules, + result.prompts, + result.agents, + result.instructions, + ]) { expect(list[0]!.name).toBe('my-repo'); } }); @@ -107,6 +123,7 @@ describe('discoverRemoteContext', () => { expect(result.rules).toHaveLength(0); expect(result.prompts).toHaveLength(0); expect(result.agents).toHaveLength(0); + expect(result.instructions).toHaveLength(0); }); it('handles empty tree', () => { @@ -116,6 +133,7 @@ describe('discoverRemoteContext', () => { expect(result.rules).toHaveLength(0); expect(result.prompts).toHaveLength(0); expect(result.agents).toHaveLength(0); + expect(result.instructions).toHaveLength(0); }); it('ignores deeply nested context files', () => { @@ -141,6 +159,48 @@ describe('discoverRemoteContext', () => { expect(result.rules.map((r) => r.name).sort()).toEqual(['code-style', 'my-repo']); }); + // ----------------------------------------------------------------------- + // Canonical INSTRUCTIONS.md patterns + // ----------------------------------------------------------------------- + + it('discovers root-level INSTRUCTIONS.md', () => { + const entries = [blob('INSTRUCTIONS.md')]; + const result = discoverRemoteContext(entries, 'my-repo'); + + expect(result.instructions).toHaveLength(1); + expect(result.instructions[0]).toEqual({ + name: 'my-repo', + path: 'INSTRUCTIONS.md', + type: 'instruction', + }); + }); + + it('falls back to "root" for INSTRUCTIONS.md when no repo name given', () => { + const entries = [blob('INSTRUCTIONS.md')]; + const result = discoverRemoteContext(entries); + + expect(result.instructions).toHaveLength(1); + expect(result.instructions[0]!.name).toBe('root'); + }); + + it('ignores INSTRUCTIONS.md in subdirectories', () => { + const entries = [ + blob('instructions/sub/INSTRUCTIONS.md'), + blob('src/INSTRUCTIONS.md'), + blob('docs/INSTRUCTIONS.md'), + ]; + const result = discoverRemoteContext(entries); + + expect(result.instructions).toHaveLength(0); + }); + + it('INSTRUCTIONS.md does not have native field', () => { + const entries = [blob('INSTRUCTIONS.md')]; + const result = discoverRemoteContext(entries, 'my-repo'); + + expect(result.instructions[0]!.native).toBeUndefined(); + }); + // ----------------------------------------------------------------------- // Native agent-specific patterns // ----------------------------------------------------------------------- diff --git a/src/find-discovery.ts b/src/find-discovery.ts index 5ad9cf1..808b3fc 100644 --- a/src/find-discovery.ts +++ b/src/find-discovery.ts @@ -15,6 +15,7 @@ export interface RemoteContextSummary { rules: RemoteContextItem[]; prompts: RemoteContextItem[]; agents: RemoteContextItem[]; + instructions: RemoteContextItem[]; } /** @@ -26,6 +27,7 @@ const PATTERNS: Array<{ regex: RegExp; type: ContextType }> = [ { regex: /^(?:rules\/([^/]+)\/)?RULES\.md$/, type: 'rule' }, { regex: /^(?:prompts\/([^/]+)\/)?PROMPT\.md$/, type: 'prompt' }, { regex: /^(?:agents\/([^/]+)\/)?AGENT\.md$/, type: 'agent' }, + { regex: /^INSTRUCTIONS\.md$/, type: 'instruction' }, ]; /** @@ -106,7 +108,13 @@ export function discoverRemoteContext( tree: GitHubTreeEntry[], repoName?: string ): RemoteContextSummary { - const summary: RemoteContextSummary = { skills: [], rules: [], prompts: [], agents: [] }; + const summary: RemoteContextSummary = { + skills: [], + rules: [], + prompts: [], + agents: [], + instructions: [], + }; const fallbackName = repoName ?? 'root'; for (const entry of tree) { diff --git a/src/find.ts b/src/find.ts index 964ea28..09ae81f 100644 --- a/src/find.ts +++ b/src/find.ts @@ -276,6 +276,10 @@ async function promptContextSelection( console.log(formatContextLine(summary.prompts.length, 'prompt', summary.prompts)); if (summary.agents.length > 0) console.log(formatContextLine(summary.agents.length, 'agent', summary.agents)); + if (summary.instructions.length > 0) + console.log( + formatContextLine(summary.instructions.length, 'instruction', summary.instructions) + ); console.log(); @@ -317,6 +321,10 @@ async function promptContextSelection( ...summary.rules.map((i) => ({ value: i, label: formatPickLabel(i, 'rule') })), ...summary.prompts.map((i) => ({ value: i, label: formatPickLabel(i, 'prompt') })), ...summary.agents.map((i) => ({ value: i, label: formatPickLabel(i, 'agent') })), + ...summary.instructions.map((i) => ({ + value: i, + label: formatPickLabel(i, 'instruction'), + })), ]; // Pre-select the skill the user originally chose @@ -399,6 +407,7 @@ ${DIM} 2) npx dotai add ${RESET}`; ...summary.rules, ...summary.prompts, ...summary.agents, + ...summary.instructions, ]; if (allItems.length > 0) { @@ -416,12 +425,13 @@ ${DIM} 2) npx dotai add ${RESET}`; byType[key].push(item); } - const typeOrder = ['skill', 'rule', 'prompt', 'agent'] as const; + const typeOrder = ['skill', 'rule', 'prompt', 'agent', 'instruction'] as const; const typeLabels: Record = { skill: 'Skills', rule: 'Rules', prompt: 'Prompts', agent: 'Agents', + instruction: 'Instructions', }; for (const type of typeOrder) { @@ -508,6 +518,7 @@ ${DIM} 2) npx dotai add ${RESET}`; summary.rules.length + summary.prompts.length + summary.agents.length + + summary.instructions.length + summary.skills.filter((s) => s.name !== skillName).length; if (totalOther > 0) { diff --git a/src/instruction-discovery.test.ts b/src/instruction-discovery.test.ts new file mode 100644 index 0000000..baef400 --- /dev/null +++ b/src/instruction-discovery.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { discover, filterByType } from './rule-discovery.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Index into an array with a length assertion, avoiding TS "Object is possibly undefined" errors. */ +function at(arr: T[], index: number): T { + expect(arr.length).toBeGreaterThan(index); + return arr[index]!; +} + +function instructionmd(frontmatter: Record, body = ''): string { + const lines = Object.entries(frontmatter).map(([key, value]) => { + if (typeof value === 'string') { + return `${key}: ${value}`; + } + return `${key}: ${value}`; + }); + return `---\n${lines.join('\n')}\n---\n\n${body}`; +} + +const VALID_INSTRUCTION = { + name: 'project-guidelines', + description: 'Project-wide coding guidelines', +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('instruction discovery', () => { + let testDir: string; + + beforeEach(async () => { + testDir = join( + tmpdir(), + `dotai-instruction-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + await mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + // ------------------------------------------------------------------------- + // Root INSTRUCTIONS.md discovery + // ------------------------------------------------------------------------- + + it('discovers root INSTRUCTIONS.md', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION, 'Follow these guidelines.') + ); + + const result = await discover(testDir); + const instructions = filterByType(result.items, 'instruction'); + expect(instructions).toHaveLength(1); + expect(at(instructions, 0).name).toBe('project-guidelines'); + expect(at(instructions, 0).format).toBe('canonical'); + expect(at(instructions, 0).type).toBe('instruction'); + expect(at(instructions, 0).description).toBe('Project-wide coding guidelines'); + }); + + it('preserves rawContent for discovered instructions', async () => { + const content = instructionmd(VALID_INSTRUCTION, 'Body content here.'); + await writeFile(join(testDir, 'INSTRUCTIONS.md'), content); + + const result = await discover(testDir); + const instructions = filterByType(result.items, 'instruction'); + expect(instructions).toHaveLength(1); + expect(at(instructions, 0).rawContent).toBe(content); + }); + + // ------------------------------------------------------------------------- + // Subdirectory INSTRUCTIONS.md is ignored + // ------------------------------------------------------------------------- + + it('ignores INSTRUCTIONS.md in subdirectories', async () => { + await mkdir(join(testDir, 'instructions', 'sub'), { recursive: true }); + await writeFile( + join(testDir, 'instructions', 'sub', 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION) + ); + + const result = await discover(testDir); + const instructions = filterByType(result.items, 'instruction'); + expect(instructions).toHaveLength(0); + }); + + it('discovers root but not subdirectory INSTRUCTIONS.md', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION, 'Root instruction') + ); + await mkdir(join(testDir, 'instructions', 'extra'), { recursive: true }); + await writeFile( + join(testDir, 'instructions', 'extra', 'INSTRUCTIONS.md'), + instructionmd({ name: 'extra', description: 'Extra instruction' }, 'Extra body') + ); + + const result = await discover(testDir); + const instructions = filterByType(result.items, 'instruction'); + expect(instructions).toHaveLength(1); + expect(at(instructions, 0).name).toBe('project-guidelines'); + }); + + // ------------------------------------------------------------------------- + // File size limit enforced + // ------------------------------------------------------------------------- + + it('warns on files exceeding maxFileSize', async () => { + const bigContent = instructionmd(VALID_INSTRUCTION, 'x'.repeat(200)); + await writeFile(join(testDir, 'INSTRUCTIONS.md'), bigContent); + + const result = await discover(testDir, { maxFileSize: 50 }); + expect(filterByType(result.items, 'instruction')).toHaveLength(0); + expect(result.warnings.some((w) => w.type === 'file-too-large')).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Missing file handled gracefully + // ------------------------------------------------------------------------- + + it('returns empty results when INSTRUCTIONS.md does not exist', async () => { + const result = await discover(testDir); + const instructions = filterByType(result.items, 'instruction'); + expect(instructions).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + // ------------------------------------------------------------------------- + // Parse error handling + // ------------------------------------------------------------------------- + + it('warns on invalid frontmatter', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd({ name: 123, description: 'test' }) + ); + + const result = await discover(testDir); + expect(filterByType(result.items, 'instruction')).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(at(result.warnings, 0).type).toBe('parse-error'); + }); + + it('warns on missing required fields', async () => { + await writeFile(join(testDir, 'INSTRUCTIONS.md'), '---\nname: test\n---\n\nNo description'); + + const result = await discover(testDir); + expect(filterByType(result.items, 'instruction')).toHaveLength(0); + expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Type filter + // ------------------------------------------------------------------------- + + it('discovers only instructions when types is ["instruction"]', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION, 'Instructions body') + ); + // Add other content types to verify they are excluded + await writeFile( + join(testDir, 'RULES.md'), + '---\nname: test-rule\ndescription: A rule\nactivation: auto\n---\n\nRule body' + ); + + const result = await discover(testDir, { types: ['instruction'] }); + expect(filterByType(result.items, 'instruction')).toHaveLength(1); + expect(filterByType(result.items, 'rule')).toHaveLength(0); + }); + + it('excludes instructions when type filter does not include instruction', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION, 'Instructions body') + ); + + const result = await discover(testDir, { types: ['rule'] }); + expect(filterByType(result.items, 'instruction')).toHaveLength(0); + }); + + // ------------------------------------------------------------------------- + // Mixed discovery + // ------------------------------------------------------------------------- + + it('discovers instructions alongside other content types', async () => { + await writeFile( + join(testDir, 'INSTRUCTIONS.md'), + instructionmd(VALID_INSTRUCTION, 'Instructions body') + ); + await writeFile( + join(testDir, 'RULES.md'), + '---\nname: test-rule\ndescription: A rule\nactivation: auto\n---\n\nRule body' + ); + await writeFile( + join(testDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: A skill\n---\n\nSkill body' + ); + + const result = await discover(testDir); + expect(filterByType(result.items, 'instruction')).toHaveLength(1); + expect(filterByType(result.items, 'rule')).toHaveLength(1); + expect(filterByType(result.items, 'skill')).toHaveLength(1); + }); +}); diff --git a/src/rule-discovery.ts b/src/rule-discovery.ts index cc83cb2..aec178c 100644 --- a/src/rule-discovery.ts +++ b/src/rule-discovery.ts @@ -1,6 +1,7 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join, resolve, sep } from 'path'; import { parseAgentContent } from './agent-parser.ts'; +import { parseInstructionContent } from './instruction-parser.ts'; import { parsePromptContent } from './prompt-parser.ts'; import { parseRuleContent } from './rule-parser.ts'; import { targetAgents } from './target-agents.ts'; @@ -11,7 +12,13 @@ import type { ContextFormat, ContextType, DiscoveredItem, TargetAgent } from './ // --------------------------------------------------------------------------- /** All context types, used as default when no type filter is provided. */ -const ALL_TYPES: readonly ContextType[] = ['skill', 'rule', 'prompt', 'agent'] as const; +const ALL_TYPES: readonly ContextType[] = [ + 'skill', + 'rule', + 'prompt', + 'agent', + 'instruction', +] as const; /** Maximum number of discovered items per context type. */ const MAX_ITEMS_PER_TYPE = 500; @@ -437,6 +444,51 @@ async function discoverCanonicalSkills( return items; } +// --------------------------------------------------------------------------- +// Canonical INSTRUCTIONS.md discovery (root-only) +// --------------------------------------------------------------------------- + +async function discoverCanonicalInstructions( + basePath: string, + maxItems: number, + maxFileSize: number, + warnings: DiscoveryWarning[] +): Promise { + const items: DiscoveredItem[] = []; + + // Only root-level INSTRUCTIONS.md — no subdirectory scanning + const rootInstructionPath = join(basePath, 'INSTRUCTIONS.md'); + if (!(await fileExists(rootInstructionPath))) { + return items; + } + if (!isWithinBase(rootInstructionPath, basePath)) { + return items; + } + + const result = await safeReadFile(rootInstructionPath, maxFileSize); + if ('error' in result) { + warnings.push({ type: 'file-too-large', message: result.error, path: rootInstructionPath }); + return items; + } + + const parsed = parseInstructionContent(result.content); + if (!parsed.ok) { + warnings.push({ type: 'parse-error', message: parsed.error, path: rootInstructionPath }); + return items; + } + + items.push({ + type: 'instruction', + format: 'canonical', + name: parsed.instruction.name, + description: parsed.instruction.description, + sourcePath: rootInstructionPath, + rawContent: result.content, + }); + + return items; +} + // --------------------------------------------------------------------------- // Native passthrough rules discovery // --------------------------------------------------------------------------- @@ -666,8 +718,9 @@ async function discoverNativeAgents( * Discover all canonical and native context items in a source repo. * * Discovers `SKILL.md` (canonical), `RULES.md` (canonical + native passthrough), - * `PROMPT.md` (canonical + native passthrough), and `AGENT.md` (canonical + - * native passthrough) files. Each item is tagged with `type` and `format`. + * `PROMPT.md` (canonical + native passthrough), `AGENT.md` (canonical + + * native passthrough), and `INSTRUCTIONS.md` (canonical, root-only) files. + * Each item is tagged with `type` and `format`. * * Security: * - Caps discovery at `maxItemsPerType` per context type (default 500). @@ -692,21 +745,25 @@ export async function discover( // Helper that returns an empty array for skipped types const empty = (): Promise => Promise.resolve([]); - // Discover in parallel — skills, rules, prompts, and agents are independent - const [skills, canonicalRules, canonicalPrompts, canonicalAgents] = await Promise.all([ - typesToDiscover.has('skill') - ? discoverCanonicalSkills(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('rule') - ? discoverCanonicalRules(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('prompt') - ? discoverCanonicalPrompts(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('agent') - ? discoverCanonicalAgents(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - ]); + // Discover in parallel — skills, rules, prompts, agents, and instructions are independent + const [skills, canonicalRules, canonicalPrompts, canonicalAgents, canonicalInstructions] = + await Promise.all([ + typesToDiscover.has('skill') + ? discoverCanonicalSkills(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('rule') + ? discoverCanonicalRules(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('prompt') + ? discoverCanonicalPrompts(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('agent') + ? discoverCanonicalAgents(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('instruction') + ? discoverCanonicalInstructions(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + ]); // Native rules share the cap with canonical rules const nativeRules = typesToDiscover.has('rule') @@ -750,6 +807,7 @@ export async function discover( ...nativePrompts, ...canonicalAgents, ...nativeAgents, + ...canonicalInstructions, ], warnings, }; From ac0fbb49a81f5bec28da89a9bb56c8151fcb5274 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 16:20:23 +0200 Subject: [PATCH 4/9] feat: wire instruction content type into install pipeline, add command, and CLI - planRuleWrites() handles type: 'instruction' via transpileInstructionForAllAgents - addInstructions() follows same pattern as addRules/addPrompts/addAgents - CONTEXT_CONFIGS extended with instruction entry for zero-flag discovery - --instruction/-i flag added to CLI, 'instruction' added to VALID_CONTEXT_TYPES - VALID_TYPES in dotai-lock.ts includes 'instruction' - check and rule-check updated for instruction entries - 32 new tests: e2e-instruction (18) + instruction-pipeline (14) - README.md updated with instruction support across all sections --- README.md | 27 +- src/add-options.ts | 6 + src/add.ts | 90 ++++- src/check.ts | 8 +- src/cli-parse.test.ts | 4 +- src/cli-parse.ts | 1 + src/dotai-lock.ts | 8 +- src/instruction-pipeline.test.ts | 281 ++++++++++++++++ src/rule-add.ts | 191 +++++++++++ src/rule-check.ts | 20 +- src/rule-installer.ts | 48 ++- tests/e2e-instruction.test.ts | 547 +++++++++++++++++++++++++++++++ tests/e2e-utils.ts | 26 +- 13 files changed, 1206 insertions(+), 51 deletions(-) create mode 100644 src/instruction-pipeline.test.ts create mode 100644 tests/e2e-instruction.test.ts diff --git a/README.md b/README.md index a52526c..108a1a9 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Share AI agent context across tools and teams. -dotai takes canonical context files — skills, rules, prompts, and agent -definitions — and installs them into the config directories of supported AI -coding agents. Write once, distribute everywhere. Your team gets consistent AI +dotai takes canonical context files — skills, rules, prompts, agent +definitions, and instructions — and installs them into the config directories +of supported AI coding agents. Write once, distribute everywhere. Your team gets consistent AI behavior across Copilot, Claude Code, Cursor, and more. Requires Node.js 18+ (or Bun/Deno). @@ -29,8 +29,8 @@ Keeping rules, prompts, and skills in sync across agents is manual and error-prone. dotai solves this with **canonical authoring**: write a single `RULES.md`, -`PROMPT.md`, or `AGENT.md` and dotai transpiles it into every target agent's -native format automatically. +`PROMPT.md`, `AGENT.md`, or `INSTRUCTIONS.md` and dotai transpiles it into every +target agent's native format automatically. - **Write once** — one canonical file fans out to all targets - **5 targets** — Copilot, Claude Code, Cursor, Codex, OpenCode @@ -103,17 +103,18 @@ npx dotai add ./my-local-context # local path
Transpilation support by agent -| Agent | Skills | Rules | Prompts | Agents | -| -------------- | ------ | ----- | ----------------------- | ------------------------- | -| GitHub Copilot | ✅ | ✅ | ✅ | ✅ | -| Claude Code | ✅ | ✅ | ✅ | ✅ | -| OpenCode | ✅ | ✅ | ✅ | ✅ | -| Cursor | ✅ | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | -| Codex | ✅ | — | — | — | +| Agent | Skills | Rules | Prompts | Agents | Instructions | +| -------------- | ------ | ----- | ----------------------- | ------------------------- | ------------ | +| GitHub Copilot | ✅ | ✅ | ✅ | ✅ | ✅ | +| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | +| OpenCode | ✅ | ✅ | ✅ | ✅ | ✅ | +| Cursor | ✅ | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | ✅ | +| Codex | ✅ | — | — | — | — | - **Cursor prompts:** Cursor reads Copilot's `.github/prompts/` path. Canonical `PROMPT.md` is not transpiled to a Cursor-specific format. - **Cursor agents:** Cursor reads `.github/agents/` from the Copilot path. Canonical `AGENT.md` transpiles to Copilot format, which Cursor picks up. - **OpenCode rules:** OpenCode rules are plain markdown (no frontmatter). After installing, add the output paths to the `instructions` array in `opencode.json`. +- **Instructions:** `INSTRUCTIONS.md` content is appended as marker-delimited sections to project-wide files (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`). Cursor and OpenCode share `AGENTS.md`.
@@ -128,7 +129,7 @@ Skill installs target [5 targets](docs/supported-targets.md). dotai started as a fork of [vercel-labs/skills](https://github.com/vercel-labs/skills) / [skills.sh](https://skills.sh). The inherited skills install pipeline remains first-class. dotai extends it with -transpilation of rules, prompts, and agent definitions to multiple targets. +transpilation of rules, prompts, agent definitions, and instructions to multiple targets. ## Acknowledgements diff --git a/src/add-options.ts b/src/add-options.ts index 8804e69..62a8a79 100644 --- a/src/add-options.ts +++ b/src/add-options.ts @@ -10,6 +10,7 @@ export interface AddOptions { rule?: string[]; prompt?: string[]; customAgent?: string[]; + instruction?: string[]; all?: boolean; fullDepth?: boolean; copy?: boolean; @@ -62,6 +63,11 @@ export function parseAddOptions(args: string[]): { source: string[]; options: Ad const { values, nextIndex } = consumeMultiValues(args, i + 1); options.customAgent.push(...values); i = nextIndex - 1; + } else if (arg === '-i' || arg === '--instruction') { + options.instruction = options.instruction || []; + const { values, nextIndex } = consumeMultiValues(args, i + 1); + options.instruction.push(...values); + i = nextIndex - 1; } else if (arg === '--append') { options.append = true; } else if (arg === '--gitignore') { diff --git a/src/add.ts b/src/add.ts index f6ea331..5b3272d 100644 --- a/src/add.ts +++ b/src/add.ts @@ -8,7 +8,13 @@ import { multiselect } from './add-agents.ts'; export { promptForAgents } from './add-agents.ts'; import { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts'; import { CommandError } from './command-result.ts'; -import { addRules, addPrompts, addAgents, resolveTargetAgents } from './rule-add.ts'; +import { + addRules, + addPrompts, + addAgents, + addInstructions, + resolveTargetAgents, +} from './rule-add.ts'; import { TARGET_AGENTS } from './target-agents.ts'; import { discoverSkills, getSkillDisplayName, filterSkills } from './skill-discovery.ts'; import { discover } from './rule-discovery.ts'; @@ -94,7 +100,7 @@ interface ContextInstallConfig { }>; } -const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent', ContextInstallConfig> = { +const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent' | 'instruction', ContextInstallConfig> = { rule: { noun: 'rule', getNames: (opts) => opts.rule ?? [], @@ -147,6 +153,23 @@ const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent', ContextInstallConfig> return { ...result, itemsInstalled: result.agentsInstalled }; }, }, + instruction: { + noun: 'instruction', + getNames: (opts) => opts.instruction ?? [], + install: async ({ source, sourcePath, projectRoot, names, agents, options }) => { + const result = await addInstructions({ + source, + sourcePath, + projectRoot, + instructionNames: names, + targets: agents, + dryRun: options.dryRun, + force: options.force, + gitignore: options.gitignore, + }); + return { ...result, itemsInstalled: result.instructionsInstalled }; + }, + }, }; /** @@ -156,7 +179,7 @@ const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent', ContextInstallConfig> * transpile → install pipeline, and displays results using @clack/prompts. */ async function handleContextInstall( - contextType: 'rule' | 'prompt' | 'agent', + contextType: 'rule' | 'prompt' | 'agent' | 'instruction', source: string, skillsDir: string, options: AddOptions, @@ -262,7 +285,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< // For each requested type, if the user hasn't already specified explicit names // via --rule/--prompt/--custom-agent/--skill, set them to ['*'] (discover all). if (options.type && options.type.length > 0) { - const validTypes: ContextType[] = ['skill', 'rule', 'prompt', 'agent']; + const validTypes: ContextType[] = ['skill', 'rule', 'prompt', 'agent', 'instruction']; const invalidTypes = options.type.filter((t) => !validTypes.includes(t)); if (invalidTypes.length > 0) { @@ -288,6 +311,12 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< if (requestedTypes.has('agent') && (!options.customAgent || options.customAgent.length === 0)) { options.customAgent = ['*']; } + if ( + requestedTypes.has('instruction') && + (!options.instruction || options.instruction.length === 0) + ) { + options.instruction = ['*']; + } if (requestedTypes.has('skill') && (!options.skill || options.skill.length === 0)) { options.skill = ['*']; } @@ -361,11 +390,12 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< if (options.rule && options.rule.length > 0) { await handleContextInstall('rule', source, skillsDir, options, spinner); - // If no --skill, --prompt, or --custom-agent flag was also specified, we're done after rule install + // If no --skill, --prompt, --custom-agent, or --instruction flag was also specified, we're done after rule install if ( (!options.skill || options.skill.length === 0) && (!options.prompt || options.prompt.length === 0) && - (!options.customAgent || options.customAgent.length === 0) + (!options.customAgent || options.customAgent.length === 0) && + (!options.instruction || options.instruction.length === 0) ) { await cleanup(tempDir); return; @@ -378,10 +408,11 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< if (options.prompt && options.prompt.length > 0) { await handleContextInstall('prompt', source, skillsDir, options, spinner); - // If no --skill or --custom-agent flag was also specified, we're done after prompt install + // If no --skill, --custom-agent, or --instruction flag was also specified, we're done after prompt install if ( (!options.skill || options.skill.length === 0) && - (!options.customAgent || options.customAgent.length === 0) + (!options.customAgent || options.customAgent.length === 0) && + (!options.instruction || options.instruction.length === 0) ) { await cleanup(tempDir); return; @@ -394,7 +425,23 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< if (options.customAgent && options.customAgent.length > 0) { await handleContextInstall('agent', source, skillsDir, options, spinner); - // If no --skill flag was also specified, we're done after agent install + // If no --skill or --instruction flag was also specified, we're done after agent install + if ( + (!options.skill || options.skill.length === 0) && + (!options.instruction || options.instruction.length === 0) + ) { + await cleanup(tempDir); + return; + } + } + + // ─── Instruction install flow (--instruction flag) ─── + // When --instruction is specified, run the dotai instruction transpilation pipeline. + // Instructions use append mode — all outputs go to project-wide files. + if (options.instruction && options.instruction.length > 0) { + await handleContextInstall('instruction', source, skillsDir, options, spinner); + + // If no --skill flag was also specified, we're done after instruction install if (!options.skill || options.skill.length === 0) { await cleanup(tempDir); return; @@ -408,7 +455,8 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< (options.skill && options.skill.length > 0) || (options.rule && options.rule.length > 0) || (options.prompt && options.prompt.length > 0) || - (options.customAgent && options.customAgent.length > 0); + (options.customAgent && options.customAgent.length > 0) || + (options.instruction && options.instruction.length > 0); if (!hasTypeFlags) { spinner.start('Discovering context...'); @@ -425,12 +473,15 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< const rules = fullResult.items.filter((i) => i.type === 'rule'); const prompts = fullResult.items.filter((i) => i.type === 'prompt'); const customAgents = fullResult.items.filter((i) => i.type === 'agent'); + const instructions = fullResult.items.filter((i) => i.type === 'instruction'); - const hasNonSkillContent = rules.length + prompts.length + customAgents.length > 0; + const hasNonSkillContent = + rules.length + prompts.length + customAgents.length + instructions.length > 0; // If non-skill content exists, present unified selection if (hasNonSkillContent && skills.length > 0 && !options.yes && process.stdin.isTTY) { - const totalItems = skills.length + rules.length + prompts.length + customAgents.length; + const totalItems = + skills.length + rules.length + prompts.length + customAgents.length + instructions.length; spinner.stop(`Found ${pc.green(String(totalItems))} item${totalItems !== 1 ? 's' : ''}`); // Build grouped options for groupMultiselect @@ -464,6 +515,16 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< hint: a.description.length > 60 ? a.description.slice(0, 57) + '...' : a.description, })); } + if (instructions.length > 0) { + grouped[`Instructions (${instructions.length})`] = instructions.map((instr) => ({ + value: { name: instr.name, type: 'instruction' }, + label: instr.name, + hint: + instr.description.length > 60 + ? instr.description.slice(0, 57) + '...' + : instr.description, + })); + } const selected = await p.groupMultiselect({ message: `Select items to install ${pc.dim('(space to toggle)')}`, @@ -484,11 +545,13 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< const pickedRules = picks.filter((p) => p.type === 'rule').map((p) => p.name); const pickedPrompts = picks.filter((p) => p.type === 'prompt').map((p) => p.name); const pickedAgents = picks.filter((p) => p.type === 'agent').map((p) => p.name); + const pickedInstructions = picks.filter((p) => p.type === 'instruction').map((p) => p.name); if (pickedSkills.length > 0) options.skill = pickedSkills; if (pickedRules.length > 0) options.rule = pickedRules; if (pickedPrompts.length > 0) options.prompt = pickedPrompts; if (pickedAgents.length > 0) options.customAgent = pickedAgents; + if (pickedInstructions.length > 0) options.instruction = pickedInstructions; // Re-run the type-specific handlers for any non-skill content if (pickedRules.length > 0) { @@ -500,6 +563,9 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< if (pickedAgents.length > 0) { await handleContextInstall('agent', source, skillsDir, options, spinner); } + if (pickedInstructions.length > 0) { + await handleContextInstall('instruction', source, skillsDir, options, spinner); + } // If no skills were selected, we're done if (pickedSkills.length === 0) { diff --git a/src/check.ts b/src/check.ts index f856bb6..c344119 100644 --- a/src/check.ts +++ b/src/check.ts @@ -160,13 +160,15 @@ export async function runCheck(_args: string[] = []): Promise { } } - // ── Check rules, prompts, and agents (project lock: .dotai-lock.json) ── + // ── Check rules, prompts, agents, and instructions (project lock: .dotai-lock.json) ── const projectRoot = process.cwd(); const ruleCheck = await checkRuleUpdates(projectRoot); if (ruleCheck.totalChecked > 0) { hasAnyItems = true; - console.log(`${DIM}Checking ${ruleCheck.totalChecked} rule/prompt/agent(s)...${RESET}`); + console.log( + `${DIM}Checking ${ruleCheck.totalChecked} rule/prompt/agent/instruction(s)...${RESET}` + ); if (ruleCheck.updates.length > 0) { totalUpdates += ruleCheck.updates.length; @@ -279,7 +281,7 @@ export async function runUpdate(): Promise { } } - // ── Update rules, prompts, and agents (project lock: .dotai-lock.json) ── + // ── Update rules, prompts, agents, and instructions (project lock: .dotai-lock.json) ── const projectRoot = process.cwd(); const ruleResult = await updateRules(projectRoot); diff --git a/src/cli-parse.test.ts b/src/cli-parse.test.ts index 19f92cc..1dce20d 100644 --- a/src/cli-parse.test.ts +++ b/src/cli-parse.test.ts @@ -88,8 +88,8 @@ describe('consumeMultiValues', () => { }); describe('VALID_CONTEXT_TYPES', () => { - it('should contain all four context types', () => { - expect(VALID_CONTEXT_TYPES).toEqual(['skill', 'rule', 'prompt', 'agent']); + it('should contain all five context types', () => { + expect(VALID_CONTEXT_TYPES).toEqual(['skill', 'rule', 'prompt', 'agent', 'instruction']); }); }); diff --git a/src/cli-parse.ts b/src/cli-parse.ts index f476009..899f076 100644 --- a/src/cli-parse.ts +++ b/src/cli-parse.ts @@ -6,6 +6,7 @@ export const VALID_CONTEXT_TYPES: readonly ContextType[] = [ 'rule', 'prompt', 'agent', + 'instruction', ] as const; /** diff --git a/src/dotai-lock.ts b/src/dotai-lock.ts index 65f84fc..8815a41 100644 --- a/src/dotai-lock.ts +++ b/src/dotai-lock.ts @@ -293,7 +293,13 @@ export { LockVersionError } from './lock-version-error.ts'; // Internal validation // --------------------------------------------------------------------------- -const VALID_TYPES: ReadonlySet = new Set(['skill', 'rule', 'prompt', 'agent']); +const VALID_TYPES: ReadonlySet = new Set([ + 'skill', + 'rule', + 'prompt', + 'agent', + 'instruction', +]); const VALID_AGENTS: ReadonlySet = new Set([ 'github-copilot', 'claude-code', diff --git a/src/instruction-pipeline.test.ts b/src/instruction-pipeline.test.ts new file mode 100644 index 0000000..55be728 --- /dev/null +++ b/src/instruction-pipeline.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import type { DiscoveredItem } from './types.ts'; +import { + planRuleWrites, + executeInstallPipeline, + type InstallPipelineOptions, +} from './rule-installer.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a minimal valid INSTRUCTIONS.md content string. */ +function makeInstructionContent( + name: string, + opts: { description?: string; body?: string } = {} +): string { + const desc = opts.description ?? `Description for ${name}`; + return [ + '---', + `name: ${name}`, + `description: ${desc}`, + '---', + '', + opts.body ?? `Instruction body for ${name}.`, + ].join('\n'); +} + +/** Create a DiscoveredItem for a canonical instruction. */ +function canonicalInstruction( + name: string, + opts: { description?: string; body?: string } = {} +): DiscoveredItem { + return { + type: 'instruction', + format: 'canonical', + name, + description: opts.description ?? `Description for ${name}`, + sourcePath: `/fake/source/INSTRUCTIONS.md`, + rawContent: makeInstructionContent(name, opts), + }; +} + +/** Create base pipeline options. */ +function baseOptions( + projectRoot: string, + overrides: Partial = {} +): InstallPipelineOptions { + return { + projectRoot, + source: 'test/repo', + lockEntries: [], + force: false, + dryRun: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('install-pipeline — instructions', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'dotai-instr-pipeline-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ------------------------------------------------------------------------- + // planRuleWrites — instruction items + // ------------------------------------------------------------------------- + + describe('planRuleWrites — instructions', () => { + it('transpiles a canonical instruction to 3 unique outputs (deduplicated)', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir); + + const { writes, skipped } = planRuleWrites(items, opts); + + expect(skipped).toHaveLength(0); + // 4 agents, but cursor + opencode share AGENTS.md → 3 unique outputs + expect(writes).toHaveLength(3); + }); + + it('all instruction outputs use append mode', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir); + + const { writes } = planRuleWrites(items, opts); + + for (const write of writes) { + expect(write.planned.output.mode).toBe('append'); + } + }); + + it('attaches correct metadata to planned writes', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { source: 'acme/repo' }); + + const { writes } = planRuleWrites(items, opts); + + for (const write of writes) { + expect(write.planned.type).toBe('instruction'); + expect(write.planned.name).toBe('code-style'); + expect(write.planned.format).toBe('canonical'); + expect(write.planned.source).toBe('acme/repo'); + } + }); + + it('respects agent subset filter', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); + + const { writes } = planRuleWrites(items, opts); + + expect(writes).toHaveLength(1); + expect(writes[0]!.agent).toBe('github-copilot'); + }); + + it('produces copilot output in .github directory', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); + + const { writes } = planRuleWrites(items, opts); + + expect(writes[0]!.planned.absolutePath).toBe( + join(tmpDir, '.github', 'copilot-instructions.md') + ); + }); + + it('produces claude-code output in project root', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['claude-code'] }); + + const { writes } = planRuleWrites(items, opts); + + expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'CLAUDE.md')); + }); + + it('produces cursor output in project root AGENTS.md', () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['cursor'] }); + + const { writes } = planRuleWrites(items, opts); + + expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'AGENTS.md')); + }); + + it('handles mixed instructions + rules + prompts together', () => { + // Import helpers would make this complex — test with just instructions + const items = [ + canonicalInstruction('code-style'), + canonicalInstruction('security-guidelines'), + ]; + const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); + + const { writes } = planRuleWrites(items, opts); + + // 2 instructions × 1 agent = 2 writes + expect(writes).toHaveLength(2); + }); + }); + + // ------------------------------------------------------------------------- + // executeInstallPipeline — instruction writes + // ------------------------------------------------------------------------- + + describe('executeInstallPipeline — instruction writes', () => { + it('writes instructions as marker sections in all target files', async () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir); + + const result = await executeInstallPipeline(items, opts); + + expect(result.success).toBe(true); + expect(result.written).toHaveLength(3); + + // Copilot: .github/copilot-instructions.md + const copilotPath = join(tmpDir, '.github', 'copilot-instructions.md'); + expect(existsSync(copilotPath)).toBe(true); + const copilotContent = readFileSync(copilotPath, 'utf-8'); + expect(copilotContent).toContain(''); + expect(copilotContent).toContain(''); + + // Claude: CLAUDE.md + const claudePath = join(tmpDir, 'CLAUDE.md'); + expect(existsSync(claudePath)).toBe(true); + const claudeContent = readFileSync(claudePath, 'utf-8'); + expect(claudeContent).toContain(''); + + // Cursor + OpenCode: AGENTS.md (shared) + const agentsPath = join(tmpDir, 'AGENTS.md'); + expect(existsSync(agentsPath)).toBe(true); + const agentsContent = readFileSync(agentsPath, 'utf-8'); + expect(agentsContent).toContain(''); + }); + + it('instruction content includes name, description, and body', async () => { + const items = [ + canonicalInstruction('code-style', { + description: 'Team coding standards', + body: 'Always use strict TypeScript.', + }), + ]; + const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); + + const result = await executeInstallPipeline(items, opts); + + expect(result.success).toBe(true); + const content = readFileSync(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); + expect(content).toContain('## code-style'); + expect(content).toContain('> Team coding standards'); + expect(content).toContain('Always use strict TypeScript.'); + }); + + it('preserves existing content when appending instructions', async () => { + // Pre-create AGENTS.md with user content + writeFileSync(join(tmpDir, 'AGENTS.md'), '# My Project\n\nHand-written instructions.\n'); + + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['cursor'], force: true }); + + const result = await executeInstallPipeline(items, opts); + + expect(result.success).toBe(true); + const content = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); + expect(content).toContain('# My Project'); + expect(content).toContain('Hand-written instructions.'); + expect(content).toContain(''); + }); + + it('idempotent re-install does not duplicate sections', async () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { targets: ['cursor'] }); + + await executeInstallPipeline(items, opts); + const result2 = await executeInstallPipeline(items, opts); + + expect(result2.success).toBe(true); + const content = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); + const startCount = (content.match(//g) || []).length; + expect(startCount).toBe(1); + }); + + it('dry-run reports instruction writes without creating files', async () => { + const items = [canonicalInstruction('code-style')]; + const opts = baseOptions(tmpDir, { dryRun: true }); + + const result = await executeInstallPipeline(items, opts); + + expect(result.success).toBe(true); + expect(result.writes).toHaveLength(3); + expect(result.written).toHaveLength(0); + expect(existsSync(join(tmpDir, '.github', 'copilot-instructions.md'))).toBe(false); + expect(existsSync(join(tmpDir, 'CLAUDE.md'))).toBe(false); + expect(existsSync(join(tmpDir, 'AGENTS.md'))).toBe(false); + }); + + it('writes multiple instructions as separate sections in same file', async () => { + const items = [canonicalInstruction('code-style'), canonicalInstruction('security')]; + const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); + + const result = await executeInstallPipeline(items, opts); + + expect(result.success).toBe(true); + const content = readFileSync(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).toContain(''); + }); + }); +}); diff --git a/src/rule-add.ts b/src/rule-add.ts index eb66179..247e71a 100644 --- a/src/rule-add.ts +++ b/src/rule-add.ts @@ -652,3 +652,194 @@ export async function addAgents(options: AgentAddOptions): Promise { + const messages: string[] = []; + + // 1. Discover instructions in source repo (skip other types for performance) + const { items, warnings } = await discover(options.sourcePath, { types: ['instruction'] }); + + // Surface discovery warnings + for (const warning of warnings) { + messages.push( + pc.yellow(`Warning: ${warning.message}${warning.path ? ` (${warning.path})` : ''}`) + ); + } + + // 2. Filter to instructions only (items are already filtered, but filterByType is a safety net) + const allInstructions = filterByType(items, 'instruction'); + + if (allInstructions.length === 0) { + return { + success: false, + instructionsInstalled: 0, + writtenPaths: [], + messages, + error: 'No instructions found in source repository.', + }; + } + + // 3. Filter by requested instruction names + const selectedInstructions = filterItemsByName(allInstructions, options.instructionNames); + + if (selectedInstructions.length === 0) { + const availableNames = allInstructions.map((i) => i.name).join(', '); + return { + success: false, + instructionsInstalled: 0, + writtenPaths: [], + messages, + error: `No matching instructions found for: ${options.instructionNames.join(', ')}. Available: ${availableNames}`, + }; + } + + messages.push(`Found ${selectedInstructions.length} instruction(s) to install`); + + // 4. Read existing lock file for collision detection + const { lock } = await readDotaiLock(options.projectRoot); + + // 5. Run install pipeline + const targets = options.targets ?? [...TARGET_AGENTS]; + const result = await executeInstallPipeline(selectedInstructions, { + projectRoot: options.projectRoot, + targets, + source: options.source, + lockEntries: lock.items, + force: options.force, + dryRun: options.dryRun, + }); + + // Report collisions + if (result.collisions.length > 0) { + for (const collision of result.collisions) { + messages.push(pc.red(`Conflict: ${collision.message}`)); + } + } + + // Report skipped items + for (const skip of result.skipped) { + messages.push(pc.yellow(`Skipped: ${skip.item.name} — ${skip.reason}`)); + } + + if (!result.success) { + return { + success: false, + instructionsInstalled: 0, + writtenPaths: result.written, + messages, + error: result.error, + }; + } + + // Dry-run: report plan without writing lock + if (options.dryRun) { + for (const write of result.writes) { + messages.push(pc.dim(`Would write: ${write.planned.absolutePath}`)); + } + return { + success: true, + instructionsInstalled: selectedInstructions.length, + writtenPaths: [], + messages, + }; + } + + // 6. Update lock file on successful write + if (result.written.length > 0) { + let updatedLock = lock; + const installedNames = new Set(); + + // Group written paths by instruction name + for (const write of result.writes) { + installedNames.add(write.planned.name); + } + + for (const instrName of installedNames) { + const instrItem = selectedInstructions.find((i) => i.name === instrName); + if (!instrItem) continue; + + const instrWrites = result.writes.filter((w) => w.planned.name === instrName); + const instrAgents = [...new Set(instrWrites.map((w) => w.agent))]; + const outputPaths = instrWrites.map((w) => w.planned.absolutePath); + + const entry: LockEntry = { + type: 'instruction', + name: instrName, + source: options.source, + format: instrItem.format, + agents: instrAgents, + hash: computeContentHash(instrItem.rawContent), + installedAt: new Date().toISOString(), + outputs: outputPaths, + append: true, + ...(options.gitignore && { gitignored: true }), + }; + + updatedLock = upsertLockEntry(updatedLock, entry); + } + + await writeDotaiLock(updatedLock, options.projectRoot); + messages.push(`Updated ${pc.dim('.dotai-lock.json')}`); + + // Add output paths to .gitignore when --gitignore is used + if (options.gitignore) { + await addToGitignore(options.projectRoot, result.written); + messages.push(`Updated ${pc.dim('.gitignore')} with output paths`); + } + } + + return { + success: true, + instructionsInstalled: selectedInstructions.length, + writtenPaths: result.written, + messages, + }; +} diff --git a/src/rule-check.ts b/src/rule-check.ts index ecd769c..601fd71 100644 --- a/src/rule-check.ts +++ b/src/rule-check.ts @@ -16,10 +16,10 @@ import { TARGET_AGENTS } from './target-agents.ts'; import type { ContextType, LockEntry, TargetAgent } from './types.ts'; // --------------------------------------------------------------------------- -// Rule, prompt & agent check/update — reads .dotai-lock.json and compares content hashes +// Rule, prompt, agent & instruction check/update — reads .dotai-lock.json and compares content hashes // -// For `dotai check`: reports which rules/prompts/agents have changed upstream. -// For `dotai update`: re-discovers, re-transpiles, and re-installs changed rules/prompts/agents. +// For `dotai check`: reports which rules/prompts/agents/instructions have changed upstream. +// For `dotai update`: re-discovers, re-transpiles, and re-installs changed rules/prompts/agents/instructions. // // The flow per source repo: // 1. Read lock entries grouped by source (rules, prompts, and agents) @@ -92,7 +92,8 @@ export async function checkRuleUpdates(projectRoot: string): Promise * Append transpilers output to the project root (`outputDir: '.'`) with * well-known filenames like `AGENTS.md` and `CLAUDE.md`. */ -const APPEND_FILENAME_TO_AGENT: ReadonlyArray<{ filename: string; agent: TargetAgent }> = [ - { filename: 'AGENTS.md', agent: 'github-copilot' }, - { filename: 'CLAUDE.md', agent: 'claude-code' }, +const APPEND_FILENAME_TO_AGENT: ReadonlyArray<{ + outputDir: string; + filename: string; + agent: TargetAgent; +}> = [ + // Rules (append mode): Copilot → AGENTS.md, Claude → CLAUDE.md + { outputDir: '.', filename: 'AGENTS.md', agent: 'github-copilot' }, + { outputDir: '.', filename: 'CLAUDE.md', agent: 'claude-code' }, + // Instructions: Copilot → .github/copilot-instructions.md + { outputDir: '.github', filename: 'copilot-instructions.md', agent: 'github-copilot' }, + // Instructions: Claude → CLAUDE.md (already covered above) + // Instructions: Cursor + OpenCode → AGENTS.md (deduplicated by transpiler) + { outputDir: '.', filename: 'AGENTS.md', agent: 'cursor' }, ]; /** @@ -370,10 +388,14 @@ function resolveAgentFromOutput( output: TranspiledOutput, agents: readonly TargetAgent[] ): TargetAgent | null { - // Check append-mode outputs first (outputDir === '.' with well-known filenames) - if (output.mode === 'append' && output.outputDir === '.') { - for (const { filename, agent } of APPEND_FILENAME_TO_AGENT) { - if (output.filename === filename && agents.includes(agent)) { + // Check append-mode outputs first (well-known dir+filename combinations) + if (output.mode === 'append') { + for (const { outputDir, filename, agent } of APPEND_FILENAME_TO_AGENT) { + if ( + output.outputDir === outputDir && + output.filename === filename && + agents.includes(agent) + ) { return agent; } } diff --git a/tests/e2e-instruction.test.ts b/tests/e2e-instruction.test.ts new file mode 100644 index 0000000..5fd0da2 --- /dev/null +++ b/tests/e2e-instruction.test.ts @@ -0,0 +1,547 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { addInstructions } from '../src/rule-add.ts'; +import type { TargetAgent } from '../src/types.ts'; +import { + createTempProjectDir, + createTestSourceRepo, + readLockFileFromDisk, + makeSimpleInstructionContent, +} from './e2e-utils.ts'; + +// --------------------------------------------------------------------------- +// addInstructions — end-to-end instruction install pipeline tests +// +// These tests exercise the full flow: discover INSTRUCTIONS.md → transpile +// to marker sections → write to target files → update lock file. +// +// Test plan requirements from plan.md: +// 1. Canonical INSTRUCTIONS.md → correct marker sections in target files +// 2. Coexistence with existing file content (hand-written content preserved) +// 3. Idempotent re-install (running add twice doesn't duplicate content) +// 4. Lock file updated with type: 'instruction' entry +// 5. --type instruction filter works (tested via addInstructions directly) +// 6. Dry-run mode plans but doesn't write +// --------------------------------------------------------------------------- + +describe('addInstructions — e2e', () => { + let tempDir: string; + let projectDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ tempDir, projectDir, cleanup } = await createTempProjectDir('e2e-instr-')); + }); + + afterEach(async () => { + await cleanup(); + }); + + // ------------------------------------------------------------------------- + // 1. Canonical INSTRUCTIONS.md → marker sections in target files + // ------------------------------------------------------------------------- + + describe('canonical instruction transpilation', () => { + it('installs instruction as marker sections in all target files', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [ + { + name: 'coding-standards', + description: 'Team coding standards', + body: 'Use TypeScript strict mode.', + }, + ], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + }); + + expect(result.success).toBe(true); + expect(result.instructionsInstalled).toBe(1); + + // Copilot: .github/copilot-instructions.md (append mode) + const copilotPath = join(projectDir, '.github', 'copilot-instructions.md'); + expect(existsSync(copilotPath)).toBe(true); + const copilotContent = readFileSync(copilotPath, 'utf-8'); + expect(copilotContent).toContain(''); + expect(copilotContent).toContain(''); + expect(copilotContent).toContain('Use TypeScript strict mode.'); + + // Claude Code: CLAUDE.md (append mode) + const claudePath = join(projectDir, 'CLAUDE.md'); + expect(existsSync(claudePath)).toBe(true); + const claudeContent = readFileSync(claudePath, 'utf-8'); + expect(claudeContent).toContain(''); + expect(claudeContent).toContain(''); + expect(claudeContent).toContain('Use TypeScript strict mode.'); + + // Cursor + OpenCode: AGENTS.md (shared, deduplicated) + const agentsPath = join(projectDir, 'AGENTS.md'); + expect(existsSync(agentsPath)).toBe(true); + const agentsContent = readFileSync(agentsPath, 'utf-8'); + expect(agentsContent).toContain(''); + expect(agentsContent).toContain(''); + expect(agentsContent).toContain('Use TypeScript strict mode.'); + }); + + it('marker sections contain instruction name and description', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [ + { + name: 'code-review', + description: 'Code review guidelines', + body: 'Always review PRs.', + }, + ], + 'instruction' + ); + + await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + }); + + const copilotContent = readFileSync( + join(projectDir, '.github', 'copilot-instructions.md'), + 'utf-8' + ); + expect(copilotContent).toContain('## code-review'); + expect(copilotContent).toContain('> Code review guidelines'); + expect(copilotContent).toContain('Always review PRs.'); + }); + + it('respects --targets filter for instruction install', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + targets: ['github-copilot'], + }); + + expect(result.success).toBe(true); + + // Copilot output should exist + expect(existsSync(join(projectDir, '.github', 'copilot-instructions.md'))).toBe(true); + + // Other targets should NOT exist + expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false); + expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Coexistence with existing file content + // ------------------------------------------------------------------------- + + describe('coexistence with existing content', () => { + it('preserves hand-written AGENTS.md content when appending instruction', async () => { + // Pre-create AGENTS.md with user content + writeFileSync( + join(projectDir, 'AGENTS.md'), + '# My Project\n\nThese are my custom instructions.\n' + ); + + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'team-standards', description: 'Team standards', body: 'Use ESLint.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + targets: ['cursor'], + }); + + expect(result.success).toBe(true); + + const content = readFileSync(join(projectDir, 'AGENTS.md'), 'utf-8'); + // Original content preserved + expect(content).toContain('# My Project'); + expect(content).toContain('These are my custom instructions.'); + // New instruction appended + expect(content).toContain(''); + expect(content).toContain('Use ESLint.'); + expect(content).toContain(''); + }); + + it('preserves hand-written CLAUDE.md content', async () => { + writeFileSync(join(projectDir, 'CLAUDE.md'), '# Claude Guidelines\n\nBe helpful.\n'); + + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'guidelines', description: 'Guidelines', body: 'Follow guidelines.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + targets: ['claude-code'], + }); + + expect(result.success).toBe(true); + + const content = readFileSync(join(projectDir, 'CLAUDE.md'), 'utf-8'); + expect(content).toContain('# Claude Guidelines'); + expect(content).toContain('Be helpful.'); + expect(content).toContain(''); + }); + + it('preserves existing copilot-instructions.md content', async () => { + mkdirSync(join(projectDir, '.github'), { recursive: true }); + writeFileSync( + join(projectDir, '.github', 'copilot-instructions.md'), + 'Existing Copilot instructions.\n' + ); + + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'new-rules', description: 'New rules', body: 'New content.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + targets: ['github-copilot'], + }); + + expect(result.success).toBe(true); + + const content = readFileSync(join(projectDir, '.github', 'copilot-instructions.md'), 'utf-8'); + expect(content).toContain('Existing Copilot instructions.'); + expect(content).toContain(''); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Idempotent re-install + // ------------------------------------------------------------------------- + + describe('idempotent re-install', () => { + it('running add twice does not duplicate marker sections', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const opts = { + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'] as string[], + targets: ['cursor'] as TargetAgent[], + }; + + // First install + const result1 = await addInstructions(opts); + expect(result1.success).toBe(true); + + // Second install (same content) + const result2 = await addInstructions(opts); + expect(result2.success).toBe(true); + const content2 = readFileSync(join(projectDir, 'AGENTS.md'), 'utf-8'); + + // Should have exactly one marker section, not two + const startCount = (content2.match(//g) || []).length; + expect(startCount).toBe(1); + + const endCount = (content2.match(//g) || []).length; + expect(endCount).toBe(1); + }); + + it('idempotent across all target files', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'coding-rules', description: 'Rules', body: 'Follow rules.' }], + 'instruction' + ); + + const opts = { + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'] as string[], + }; + + await addInstructions(opts); + await addInstructions(opts); + + // Check each file has exactly one section + for (const filePath of [ + join(projectDir, '.github', 'copilot-instructions.md'), + join(projectDir, 'CLAUDE.md'), + join(projectDir, 'AGENTS.md'), + ]) { + const content = readFileSync(filePath, 'utf-8'); + const startCount = (content.match(//g) || []).length; + expect(startCount).toBe(1); + } + }); + }); + + // ------------------------------------------------------------------------- + // 4. Lock file updated with type: 'instruction' entry + // ------------------------------------------------------------------------- + + describe('lock file integration', () => { + it('creates .dotai-lock.json with instruction entry', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'coding-standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + }); + + expect(result.success).toBe(true); + + // Verify lock file was created + expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); + + const lock = await readLockFileFromDisk(projectDir); + expect(lock.version).toBe(1); + expect(lock.items).toHaveLength(1); + + const entry = lock.items[0]!; + expect(entry.type).toBe('instruction'); + expect(entry.name).toBe('coding-standards'); + expect(entry.source).toBe('test/repo'); + expect(entry.format).toBe('canonical'); + expect(entry.hash).toBeTruthy(); + expect(entry.installedAt).toBeTruthy(); + expect(entry.append).toBe(true); + }); + + it('lock entry records correct agents', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Standards body.' }], + 'instruction' + ); + + await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + targets: ['github-copilot', 'claude-code'], + }); + + const lock = await readLockFileFromDisk(projectDir); + const entry = lock.items[0]!; + expect(entry.agents).toContain('github-copilot'); + expect(entry.agents).toContain('claude-code'); + }); + + it('lock entry output paths match written files', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'rules', description: 'Rules', body: 'Follow rules.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + }); + + const lock = await readLockFileFromDisk(projectDir); + const entry = lock.items[0]!; + + // Every output path should exist on disk + for (const outputPath of entry.outputs) { + expect(existsSync(outputPath)).toBe(true); + } + + // Output paths should match written paths + expect(new Set(entry.outputs)).toEqual(new Set(result.writtenPaths)); + }); + + it('re-install updates lock entry without duplication', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const opts = { + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'] as string[], + }; + + await addInstructions(opts); + await addInstructions(opts); + + const lock = await readLockFileFromDisk(projectDir); + // Should still have exactly one entry, not two + const instructionEntries = lock.items.filter((e) => e.type === 'instruction'); + expect(instructionEntries).toHaveLength(1); + }); + }); + + // ------------------------------------------------------------------------- + // 5. --type instruction filter (via instructionNames param) + // ------------------------------------------------------------------------- + + describe('instruction name filtering', () => { + it('returns error when no instructions found in source', async () => { + // Create empty source repo (no INSTRUCTIONS.md) + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const.' }], + 'rule' // Create rules, not instructions + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + }); + + expect(result.success).toBe(false); + expect(result.instructionsInstalled).toBe(0); + expect(result.error).toContain('No instructions found'); + }); + + it('installs only matching instruction by name', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'coding-standards', description: 'Standards', body: 'Standards content.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['coding-standards'], + }); + + expect(result.success).toBe(true); + expect(result.instructionsInstalled).toBe(1); + }); + + it('returns error when filtering by non-existent instruction name', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'coding-standards', description: 'Standards', body: 'Standards content.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['nonexistent-instruction'], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('No matching instructions'); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Dry-run mode + // ------------------------------------------------------------------------- + + describe('dry-run mode', () => { + it('plans writes without creating files', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.instructionsInstalled).toBe(1); + expect(result.writtenPaths).toHaveLength(0); + + // No files should exist on disk + expect(existsSync(join(projectDir, '.github', 'copilot-instructions.md'))).toBe(false); + expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false); + expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false); + }); + + it('dry-run does not create lock file', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + dryRun: true, + }); + + expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); + }); + + it('dry-run messages include planned write paths', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'standards', description: 'Standards', body: 'Follow standards.' }], + 'instruction' + ); + + const result = await addInstructions({ + source: 'test/repo', + sourcePath: sourceRepo, + projectRoot: projectDir, + instructionNames: ['*'], + dryRun: true, + }); + + // Messages should mention planned paths + const allMessages = result.messages.join('\n'); + expect(allMessages).toContain('Would write'); + }); + }); +}); diff --git a/tests/e2e-utils.ts b/tests/e2e-utils.ts index db3a6d6..3e998d6 100644 --- a/tests/e2e-utils.ts +++ b/tests/e2e-utils.ts @@ -170,6 +170,7 @@ export function writeCanonicalFile( prompt: `prompts/${name}/PROMPT.md`, agent: `agents/${name}/AGENT.md`, skill: `skills/${name}/SKILL.md`, + instruction: `INSTRUCTIONS.md`, }; const relPath = fileMap[type]!; const absPath = join(sourceRoot, relPath); @@ -509,6 +510,25 @@ ${body} `; } +/** + * Create a canonical INSTRUCTIONS.md with simple frontmatter. + * + * Matches the factory pattern from cli-lock-integration tests. + */ +export function makeSimpleInstructionContent( + name: string, + description: string, + body: string +): string { + return `--- +name: ${name} +description: ${description} +--- + +${body} +`; +} + /** * Create a canonical AGENT.md with simple frontmatter. * @@ -558,27 +578,31 @@ ${body} export async function createTestSourceRepo( baseDir: string, items: Array<{ name: string; description: string; body: string }>, - type: 'rule' | 'agent' | 'prompt' = 'rule' + type: 'rule' | 'agent' | 'prompt' | 'instruction' = 'rule' ): Promise { const dirNames: Record = { rule: 'source-repo', agent: 'agent-source-repo', prompt: 'prompt-source-repo', + instruction: 'instruction-source-repo', }; const fileNames: Record = { rule: 'RULES.md', agent: 'AGENT.md', prompt: 'PROMPT.md', + instruction: 'INSTRUCTIONS.md', }; const subdirNames: Record = { rule: 'rules', agent: 'agents', prompt: 'prompts', + instruction: 'instructions', }; const contentFns: Record string> = { rule: makeSimpleRulesContent, agent: makeSimpleAgentContent, prompt: makeSimplePromptContent, + instruction: makeSimpleInstructionContent, }; const repoDir = join(baseDir, dirNames[type]!); From 62f163fcda1453290410d01ef12eb437352def26 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 16:28:18 +0200 Subject: [PATCH 5/9] feat(init): add instruction template to dotai init command --- README.md | 22 +++++++++++----------- src/init.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/init.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 108a1a9..1def4c9 100644 --- a/README.md +++ b/README.md @@ -86,17 +86,17 @@ npx dotai add ./my-local-context # local path ## Commands -| Command | Description | -| --------------- | --------------------------------------------------------- | -| `add ` | Discover, select, transpile, and install context | -| `remove [name]` | Remove installed context | -| `list` | List installed items | -| `find [query]` | Search for skills & preview all context in a repo | -| `import` | Convert native agent rules to canonical `RULES.md` format | -| `check` | Check for available updates | -| `update` | Update installed items to latest versions | -| `init [name]` | Create a context template (skill, rule, prompt, agent) | -| `restore` | Restore from lock files | +| Command | Description | +| --------------- | ------------------------------------------------------------------- | +| `add ` | Discover, select, transpile, and install context | +| `remove [name]` | Remove installed context | +| `list` | List installed items | +| `find [query]` | Search for skills & preview all context in a repo | +| `import` | Convert native agent rules to canonical `RULES.md` format | +| `check` | Check for available updates | +| `update` | Update installed items to latest versions | +| `init [name]` | Create a context template (skill, rule, prompt, agent, instruction) | +| `restore` | Restore from lock files | ## Supported Targets diff --git a/src/init.test.ts b/src/init.test.ts index 756207d..83cfa86 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -199,6 +199,50 @@ describe('init command', () => { expect(existsSync(rulePath)).toBe(true); }); + it('should initialize an instruction with init instruction ', () => { + const output = stripLogo(runCliOutput(['init', 'instruction', 'my-instructions'], testDir)); + expect(output).toContain('Initialized instruction: my-instructions'); + expect(output).toContain('my-instructions/INSTRUCTIONS.md'); + + const instructionPath = join(testDir, 'my-instructions', 'INSTRUCTIONS.md'); + expect(existsSync(instructionPath)).toBe(true); + + const content = readFileSync(instructionPath, 'utf-8'); + expect(content).toContain('name: my-instructions'); + expect(content).toContain('description: Describe what this instruction does'); + expect(content).toContain('Your instruction content here.'); + }); + + it('should init INSTRUCTIONS.md in cwd when no name provided for instruction', () => { + const output = stripLogo(runCliOutput(['init', 'instruction'], testDir)); + expect(output).toContain('Initialized instruction:'); + expect(output).toContain('Created:\n INSTRUCTIONS.md'); + expect(existsSync(join(testDir, 'INSTRUCTIONS.md'))).toBe(true); + }); + + it('should show error if instruction already exists', () => { + runCliOutput(['init', 'instruction', 'existing-instruction'], testDir); + const output = stripLogo( + runCliOutput(['init', 'instruction', 'existing-instruction'], testDir) + ); + expect(output).toContain('Instruction already exists'); + }); + + it('should support --instruction flag for init', () => { + const output = stripLogo(runCliOutput(['init', '--instruction', 'my-instruction'], testDir)); + expect(output).toContain('Initialized instruction: my-instruction'); + + const instructionPath = join(testDir, 'my-instruction', 'INSTRUCTIONS.md'); + expect(existsSync(instructionPath)).toBe(true); + }); + + it('should reject instruction names with path traversal', () => { + const escapeName = `escape-instruction-${Date.now()}`; + const output = stripLogo(runCliOutput(['init', 'instruction', `../${escapeName}`], testDir)); + expect(output).toContain('Invalid name'); + expect(existsSync(join(testDir, `../${escapeName}`, 'INSTRUCTIONS.md'))).toBe(false); + }); + describe('name validation', () => { // Use a unique escape name per test run to avoid collisions with // stale files from prior runs (e.g. /tmp/escape/ leftover from diff --git a/src/init.ts b/src/init.ts index 101aab9..0eb80db 100644 --- a/src/init.ts +++ b/src/init.ts @@ -84,6 +84,23 @@ Provide instructions for the agent here. `${DIM}Installing:${RESET}\n ${DIM}From repo:${RESET} ${TEXT}npx dotai add / --custom-agent ${name}${RESET}`, }, + instruction: { + file: 'INSTRUCTIONS.md', + noun: 'instruction', + generateContent: (name: string) => `--- +name: ${name} +description: Describe what this instruction does +--- + +Your instruction content here. +`, + extraNextSteps: [ + ` 3. Keep body content agent-agnostic ${DIM}(it is passed verbatim to all target agents)${RESET}`, + ], + installSection: (name: string) => + `${DIM}Installing:${RESET}\n ${DIM}From repo:${RESET} ${TEXT}npx dotai add / --instruction ${name}${RESET}`, + }, + skill: { file: 'SKILL.md', noun: 'skill', @@ -212,6 +229,15 @@ export function runInit(args: string[]): void { return; } + // Instruction template + if (typeArg === 'instruction' || typeArg === '--instruction') { + const config = TEMPLATE_CONFIGS['instruction']!; + const name = args[1] || basename(cwd); + const hasName = args[1] !== undefined; + initTemplate(config, name, hasName, cwd); + return; + } + // Default: skill template const config = TEMPLATE_CONFIGS['skill']!; const name = args[0] || basename(cwd); From 2e54c48d820df54ef0f90105220cfa6125f94ee3 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 16:40:47 +0200 Subject: [PATCH 6/9] feat: support instructions in list, remove commands and fix gitignore behavior Add instruction content type support to the list and remove commands, and ensure instruction target files are never gitignored since they (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md) should always be committed and visible to the team. - list: show instruction entries with filtering, empty states, and global-scope notes - remove: include 'instruction' in type guards and function signatures - rule-add: remove gitignore flag and addToGitignore call for instructions - docs: add 'instruction' to --type flag descriptions in CLI reference and AGENTS.md project description - tests: add 16 tests for instruction list/remove/gitignore behavior --- AGENTS.md | 2 +- docs/cli-reference.md | 58 +++--- src/instruction-commands.test.ts | 316 +++++++++++++++++++++++++++++++ src/list.ts | 77 ++++++-- src/remove.ts | 29 +-- src/rule-add.ts | 11 +- 6 files changed, 427 insertions(+), 66 deletions(-) create mode 100644 src/instruction-commands.test.ts diff --git a/AGENTS.md b/AGENTS.md index 394ef18..9f3fe26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -`dotai` is a CLI tool for universal context distribution to AI coding agents. It installs "skills" (SKILL.md), "rules" (RULES.md), "prompts" (PROMPT.md), and "agents" (AGENT.md) into the configuration directories of 40+ supported AI agents. It is a divergent fork of `vercel-labs/skills`. +`dotai` is a CLI tool for universal context distribution to AI coding agents. It installs "skills" (SKILL.md), "rules" (RULES.md), "prompts" (PROMPT.md), "agents" (AGENT.md), and "instructions" (INSTRUCTIONS.md) into the configuration directories of 40+ supported AI agents. It is a divergent fork of `vercel-labs/skills`. # Agent Instructions diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8fa4791..74442c3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,23 +4,23 @@ Full option tables, examples, and authoring format for `dotai`. For a quick over ## add command options -| Option | Description | -| ---------------------------- | ---------------------------------------------------------------------------- | -| `-g, --global` | Install to user directory instead of project | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`; comma-separated) | -| `-s, --skill ` | Install specific skills by name (repeatable; supports `'*'`) | -| `-r, --rule ` | Install specific canonical rules by name (repeatable) | -| `-p, --prompt ` | Install specific canonical prompts by name (repeatable) | -| `--custom-agent ` | Install specific canonical custom agents by name (repeatable) | -| `-a, --targets ` | Targets (comma-separated; use `'*'` for all) | -| `--copy` | Copy files instead of symlinking skills | -| `--dry-run` | Preview writes without making changes | -| `--force` | Overwrite conflicting managed/unmanaged outputs | -| `--append` | Append rules to `AGENTS.md`/`CLAUDE.md` instead of per-rule files | -| `--gitignore` | Add transpiled output paths to `.gitignore` (managed section) | -| `--full-depth` | Search all subdirectories even when a root `SKILL.md` exists | -| `-y, --yes` | Skip confirmation prompts | -| `--all` | Shorthand for `--skill '*' --targets '*' -y` | +| Option | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| `-g, --global` | Install to user directory instead of project | +| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | +| `-s, --skill ` | Install specific skills by name (repeatable; supports `'*'`) | +| `-r, --rule ` | Install specific canonical rules by name (repeatable) | +| `-p, --prompt ` | Install specific canonical prompts by name (repeatable) | +| `--custom-agent ` | Install specific canonical custom agents by name (repeatable) | +| `-a, --targets ` | Targets (comma-separated; use `'*'` for all) | +| `--copy` | Copy files instead of symlinking skills | +| `--dry-run` | Preview writes without making changes | +| `--force` | Overwrite conflicting managed/unmanaged outputs | +| `--append` | Append rules to `AGENTS.md`/`CLAUDE.md` instead of per-rule files | +| `--gitignore` | Add transpiled output paths to `.gitignore` (managed section) | +| `--full-depth` | Search all subdirectories even when a root `SKILL.md` exists | +| `-y, --yes` | Skip confirmation prompts | +| `--all` | Shorthand for `--skill '*' --targets '*' -y` | > **`--targets`:** A single flag for both skill install targets and transpilation targets. For skills, any of the supported targets (e.g., `--targets cursor,claude-code`). For rules, prompts, and agents, the 4 transpilation targets: copilot, claude, cursor, opencode. When omitted, all detected targets are used for skills and all 4 transpilation targets for rules/prompts/agents. @@ -38,13 +38,13 @@ Supported target aliases include values such as `claude-code` and `codex`. See [ ## remove command options -| Option | Description | -| ---------------------------- | ---------------------------------------------------------------------------- | -| `-g, --global` | Remove from global scope | -| `-a, --targets ` | Remove from specific targets (use `'*'` for all targets) | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`; comma-separated) | -| `-y, --yes` | Skip confirmation prompts | -| `--all` | Remove all installed items | +| Option | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| `-g, --global` | Remove from global scope | +| `-a, --targets ` | Remove from specific targets (use `'*'` for all targets) | +| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | +| `-y, --yes` | Skip confirmation prompts | +| `--all` | Remove all installed items | ## find command @@ -141,11 +141,11 @@ Convert native agent-specific rule files into canonical `RULES.md` format. ## list command options -| Option | Description | -| ---------------------------- | ---------------------------------------------------------------------------- | -| `-g, --global` | List global context (default: project) | -| `-a, --targets ` | Filter by specific targets | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`; comma-separated) | +| Option | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------- | +| `-g, --global` | List global context (default: project) | +| `-a, --targets ` | Filter by specific targets | +| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | ## Installation Scope diff --git a/src/instruction-commands.test.ts b/src/instruction-commands.test.ts new file mode 100644 index 0000000..fc55cd7 --- /dev/null +++ b/src/instruction-commands.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync, mkdtempSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { runCli } from './test-utils.ts'; +import { parseListOptions } from './list.ts'; +import { addToGitignore, readManagedPaths } from './gitignore.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a .dotai-lock.json with instruction entries. */ +function writeLockWithInstructions( + testDir: string, + entries: Array<{ + name: string; + source?: string; + agents?: string[]; + hash?: string; + outputs?: string[]; + append?: boolean; + gitignored?: boolean; + }>, + extraEntries: Array> = [] +): void { + const items = entries.map((e) => ({ + type: 'instruction', + name: e.name, + source: e.source ?? 'owner/repo', + format: 'canonical', + agents: e.agents ?? ['github-copilot', 'claude-code'], + hash: e.hash ?? 'abc123', + installedAt: '2025-06-01T00:00:00.000Z', + outputs: e.outputs ?? [], + append: e.append ?? true, + ...(e.gitignored !== undefined && { gitignored: e.gitignored }), + })); + + writeFileSync( + join(testDir, '.dotai-lock.json'), + JSON.stringify({ version: 1, items: [...items, ...extraEntries] }) + ); +} + +// --------------------------------------------------------------------------- +// List command — instruction support +// --------------------------------------------------------------------------- + +describe('list command — instruction support', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `instruction-list-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should show instructions with --type instruction', () => { + writeLockWithInstructions(testDir, [{ name: 'coding-standards', source: 'acme/dotai-config' }]); + + const result = runCli(['list', '--type', 'instruction'], testDir); + expect(result.stdout).toContain('coding-standards'); + expect(result.stdout).toContain('Instructions'); + expect(result.stdout).toContain('acme/dotai-config'); + expect(result.exitCode).toBe(0); + }); + + it('should show instruction source and agent targets', () => { + writeLockWithInstructions(testDir, [ + { name: 'team-rules', source: 'org/config', agents: ['cursor', 'opencode'] }, + ]); + + const result = runCli(['list', '-t', 'instruction'], testDir); + expect(result.stdout).toContain('team-rules'); + expect(result.stdout).toContain('org/config'); + expect(result.stdout).toContain('Cursor'); + expect(result.stdout).toContain('OpenCode'); + expect(result.exitCode).toBe(0); + }); + + it('should show instructions alongside rules by default', () => { + writeLockWithInstructions( + testDir, + [{ name: 'my-instruction' }], + [ + { + type: 'rule', + name: 'my-rule', + source: 'owner/repo', + format: 'canonical', + agents: ['cursor'], + hash: 'xyz', + installedAt: '2025-01-01T00:00:00.000Z', + outputs: [], + }, + ] + ); + + const result = runCli(['list'], testDir); + expect(result.stdout).toContain('Rules'); + expect(result.stdout).toContain('my-rule'); + expect(result.stdout).toContain('Instructions'); + expect(result.stdout).toContain('my-instruction'); + expect(result.exitCode).toBe(0); + }); + + it('should not show instructions when --type rule is specified', () => { + writeLockWithInstructions( + testDir, + [{ name: 'hidden-instruction' }], + [ + { + type: 'rule', + name: 'visible-rule', + source: 'owner/repo', + format: 'canonical', + agents: ['cursor'], + hash: 'abc', + installedAt: '2025-01-01T00:00:00.000Z', + outputs: [], + }, + ] + ); + + const result = runCli(['list', '--type', 'rule'], testDir); + expect(result.stdout).toContain('visible-rule'); + expect(result.stdout).not.toContain('hidden-instruction'); + expect(result.stdout).not.toContain('Instructions'); + expect(result.exitCode).toBe(0); + }); + + it('should show empty state for --type instruction with no instructions', () => { + const result = runCli(['list', '--type', 'instruction'], testDir); + expect(result.stdout).toContain('No project instructions found'); + expect(result.exitCode).toBe(0); + }); + + it('should filter instructions by agent', () => { + writeLockWithInstructions(testDir, [ + { name: 'cursor-instr', agents: ['cursor'] }, + { name: 'copilot-instr', agents: ['github-copilot'] }, + ]); + + const result = runCli(['list', '-t', 'instruction', '-a', 'cursor'], testDir); + expect(result.stdout).toContain('cursor-instr'); + expect(result.stdout).not.toContain('copilot-instr'); + expect(result.exitCode).toBe(0); + }); + + it('should explain instructions are project-scoped for --type instruction -g', () => { + writeLockWithInstructions(testDir, [{ name: 'some-instruction' }]); + + const result = runCli(['list', '--type', 'instruction', '-g'], testDir); + expect(result.stdout).toContain('project-scoped'); + expect(result.stdout).not.toContain('some-instruction'); + expect(result.exitCode).toBe(0); + }); + + describe('parseListOptions — instruction type', () => { + it('should parse --type instruction', () => { + const options = parseListOptions(['--type', 'instruction']); + expect(options.type).toEqual(['instruction']); + }); + + it('should parse comma-separated types including instruction', () => { + const options = parseListOptions(['-t', 'rule,instruction']); + expect(options.type).toEqual(['rule', 'instruction']); + }); + + it('should parse all five types comma-separated', () => { + const options = parseListOptions(['-t', 'skill,rule,prompt,agent,instruction']); + expect(options.type).toEqual(['skill', 'rule', 'prompt', 'agent', 'instruction']); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Remove command — instruction support +// --------------------------------------------------------------------------- + +describe('remove command — instruction support', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `instruction-remove-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should remove instruction by name with --type instruction', () => { + // Create target file with marker section + const agentsFile = join(testDir, 'AGENTS.md'); + writeFileSync( + agentsFile, + '# My Project\n\n\nFollow coding standards.\n\n' + ); + + writeLockWithInstructions(testDir, [ + { + name: 'coding-standards', + outputs: [agentsFile], + append: true, + }, + ]); + + const result = runCli(['remove', 'coding-standards', '--type', 'instruction', '-y'], testDir); + expect(result.stdout).toContain('Successfully removed'); + expect(result.exitCode).toBe(0); + + // Verify marker section was removed + const content = readFileSync(agentsFile, 'utf-8'); + expect(content).not.toContain('dotai:coding-standards:start'); + expect(content).not.toContain('Follow coding standards'); + expect(content).toContain('# My Project'); + }); + + it('should remove instruction lock entry', () => { + const agentsFile = join(testDir, 'AGENTS.md'); + writeFileSync( + agentsFile, + '\nTest\n\n' + ); + + writeLockWithInstructions(testDir, [ + { + name: 'test-instr', + outputs: [agentsFile], + append: true, + }, + ]); + + runCli(['remove', 'test-instr', '--type', 'instruction', '-y'], testDir); + + // Lock file should have no items + const lock = JSON.parse(readFileSync(join(testDir, '.dotai-lock.json'), 'utf-8')); + expect(lock.items).toHaveLength(0); + }); + + it('should handle --type instruction with no instructions', () => { + writeFileSync(join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [] })); + + const result = runCli(['remove', '--type', 'instruction', '-y'], testDir); + expect(result.stdout).toContain('No instruction'); + expect(result.stdout).toContain('to remove'); + expect(result.exitCode).toBe(0); + }); + + it('should remove all instructions with --all --type instruction', () => { + const agentsFile = join(testDir, 'AGENTS.md'); + const claudeFile = join(testDir, 'CLAUDE.md'); + writeFileSync(agentsFile, '\nA\n\n'); + writeFileSync(claudeFile, '\nB\n\n'); + + writeLockWithInstructions(testDir, [ + { name: 'instr-a', outputs: [agentsFile], append: true }, + { name: 'instr-b', outputs: [claudeFile], append: true }, + ]); + + const result = runCli(['remove', '--all', '--type', 'instruction', '-y'], testDir); + expect(result.stdout).toContain('Successfully removed'); + expect(result.stdout).toContain('2 item'); + expect(result.exitCode).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Gitignore — instruction target files never gitignored +// --------------------------------------------------------------------------- + +describe('gitignore — instruction target files', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'dotai-instr-gitignore-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should not include instruction outputs in .gitignore even when other entries are gitignored', async () => { + // Simulate: add a rule with gitignore, then add an instruction + // The instruction outputs should not appear in .gitignore + await addToGitignore(tmpDir, [join(tmpDir, '.cursor/rules/code-style.mdc')]); + + const paths = await readManagedPaths(tmpDir); + expect(paths).toContain('.cursor/rules/code-style.mdc'); + // Instruction target files should never be in gitignore + expect(paths).not.toContain('AGENTS.md'); + expect(paths).not.toContain('CLAUDE.md'); + expect(paths).not.toContain('.github/copilot-instructions.md'); + }); + + it('instruction lock entries should not have gitignored flag', () => { + // Create a lock file with instruction entries — verify they lack gitignored + writeLockWithInstructions(tmpDir, [ + { name: 'test-instr', outputs: [join(tmpDir, 'AGENTS.md')] }, + ]); + + const lock = JSON.parse(readFileSync(join(tmpDir, '.dotai-lock.json'), 'utf-8')); + const instrEntry = lock.items.find((e: Record) => e.type === 'instruction'); + expect(instrEntry).toBeDefined(); + expect(instrEntry.gitignored).toBeUndefined(); + }); +}); diff --git a/src/list.ts b/src/list.ts index d7e0ab6..520e21c 100644 --- a/src/list.ts +++ b/src/list.ts @@ -54,6 +54,7 @@ export async function runList(args: string[]): Promise { const showRules = !options.type || options.type.includes('rule'); const showPrompts = !options.type || options.type.includes('prompt'); const showAgents = !options.type || options.type.includes('agent'); + const showInstructions = !options.type || options.type.includes('instruction'); // Validate agent filter if provided let agentFilter: AgentType[] | undefined; @@ -81,9 +82,9 @@ export async function runList(args: string[]): Promise { lockedSkills = await getAllLockedSkills(); } - // ── Read dotai lock file once (used for rules, prompts, agents) ── + // ── Read dotai lock file once (used for rules, prompts, agents, instructions) ── let dotaiLock: DotaiLockFile | null = null; - if (showRules || showPrompts || showAgents) { + if (showRules || showPrompts || showAgents || showInstructions) { try { const { lock } = await readDotaiLock(process.cwd()); dotaiLock = lock; @@ -121,22 +122,29 @@ export async function runList(args: string[]): Promise { agentEntries = filterAndSort(getLockEntriesByType(dotaiLock, 'agent')); } + // ── Fetch instructions (project-scoped only) ── + let instructionEntries: LockEntry[] = []; + if (showInstructions && !scope && dotaiLock) { + instructionEntries = filterAndSort(getLockEntriesByType(dotaiLock, 'instruction')); + } + const cwd = process.cwd(); const scopeLabel = scope ? 'Global' : 'Project'; const hasSkills = installedSkills.length > 0; const hasRules = ruleEntries.length > 0; const hasPrompts = promptEntries.length > 0; const hasAgents = agentEntries.length > 0; + const hasInstructions = instructionEntries.length > 0; // ── Empty state ── - if (!hasSkills && !hasRules && !hasPrompts && !hasAgents) { + if (!hasSkills && !hasRules && !hasPrompts && !hasAgents && !hasInstructions) { console.log(`${BOLD}${scopeLabel}${RESET}`); console.log(); - if (scope && (showRules || showPrompts || showAgents) && !showSkills) { - // User asked for --type rule/prompt/agent -g — explain they are project-scoped + if (scope && (showRules || showPrompts || showAgents || showInstructions) && !showSkills) { + // User asked for --type rule/prompt/agent/instruction -g — explain they are project-scoped console.log( - `${DIM}Rules, prompts, and agents are project-scoped (use without -g to see them)${RESET}` + `${DIM}Rules, prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` ); return; } @@ -150,12 +158,19 @@ export async function runList(args: string[]): Promise { console.log(`${DIM}Add prompts with: npx dotai add --prompt ${RESET}`); return; } - if (!showSkills && !showRules && !showPrompts && showAgents) { + if (!showSkills && !showRules && !showPrompts && showAgents && !showInstructions) { console.log(`${DIM}No project agents found.${RESET}`); console.log(`${DIM}Add agents with: npx dotai add --custom-agent ${RESET}`); return; } - if (showSkills && !showRules && !showPrompts && !showAgents) { + if (!showSkills && !showRules && !showPrompts && !showAgents && showInstructions) { + console.log(`${DIM}No project instructions found.${RESET}`); + console.log( + `${DIM}Add instructions with: npx dotai add --instruction ${RESET}` + ); + return; + } + if (showSkills && !showRules && !showPrompts && !showAgents && !showInstructions) { console.log(`${DIM}No ${scopeLabel.toLowerCase()} skills found.${RESET}`); if (scope) { console.log(`${DIM}Try listing project skills without -g${RESET}`); @@ -166,10 +181,15 @@ export async function runList(args: string[]): Promise { } // Default: show generic empty state console.log(`${DIM}No ${scopeLabel.toLowerCase()} context found.${RESET}`); - console.log(`${DIM}Add skills with: npx dotai add ${RESET}`); - console.log(`${DIM}Add rules with: npx dotai add --rule ${RESET}`); - console.log(`${DIM}Add prompts with: npx dotai add --prompt ${RESET}`); - console.log(`${DIM}Add agents with: npx dotai add --custom-agent ${RESET}`); + console.log(`${DIM}Add skills with: npx dotai add ${RESET}`); + console.log(`${DIM}Add rules with: npx dotai add --rule ${RESET}`); + console.log(`${DIM}Add prompts with: npx dotai add --prompt ${RESET}`); + console.log( + `${DIM}Add agents with: npx dotai add --custom-agent ${RESET}` + ); + console.log( + `${DIM}Add instructions with: npx dotai add --instruction ${RESET}` + ); return; } @@ -281,16 +301,39 @@ export async function runList(args: string[]): Promise { console.log(); } - // ── Global mode note about rules/prompts/agents ── - if (scope && (showRules || showPrompts || showAgents) && !hasRules && !hasPrompts && !hasAgents) { - // Check if there are rules, prompts, or agents in the project to mention + // ── Instructions section ── + if (hasInstructions && showInstructions) { + console.log(`${BOLD}Instructions${RESET}`); + console.log(); + for (const entry of instructionEntries) { + printRule(entry); // Same display format works for instructions + } + console.log(); + } + + // ── Global mode note about rules/prompts/agents/instructions ── + if ( + scope && + (showRules || showPrompts || showAgents || showInstructions) && + !hasRules && + !hasPrompts && + !hasAgents && + !hasInstructions + ) { + // Check if there are rules, prompts, agents, or instructions in the project to mention if (dotaiLock) { const projectRules = getLockEntriesByType(dotaiLock, 'rule'); const projectPrompts = getLockEntriesByType(dotaiLock, 'prompt'); const projectAgents = getLockEntriesByType(dotaiLock, 'agent'); - if (projectRules.length > 0 || projectPrompts.length > 0 || projectAgents.length > 0) { + const projectInstructions = getLockEntriesByType(dotaiLock, 'instruction'); + if ( + projectRules.length > 0 || + projectPrompts.length > 0 || + projectAgents.length > 0 || + projectInstructions.length > 0 + ) { console.log( - `${DIM}Rules, prompts, and agents are project-scoped (use without -g to see them)${RESET}` + `${DIM}Rules, prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` ); console.log(); } diff --git a/src/remove.ts b/src/remove.ts index cb08da3..b66f483 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -27,33 +27,36 @@ export interface RemoveOptions { } export async function removeCommand(skillNames: string[], options: RemoveOptions) { - // If --type is specified and includes only rule/prompt/agent (not skill), use dotai-lock removal + // If --type is specified and includes only rule/prompt/agent/instruction (not skill), use dotai-lock removal const typeFilter = options.type; const onlyDotaiTypes = typeFilter && typeFilter.length > 0 && - typeFilter.every((t) => t === 'rule' || t === 'prompt' || t === 'agent'); + typeFilter.every((t) => t === 'rule' || t === 'prompt' || t === 'agent' || t === 'instruction'); if (onlyDotaiTypes) { await removeDotaiManagedItems( skillNames, options, - typeFilter as Array<'rule' | 'prompt' | 'agent'> + typeFilter as Array<'rule' | 'prompt' | 'agent' | 'instruction'> ); return; } // If --type includes skill (or no type filter), run the existing skill removal flow - // and also handle rule/prompt/agent removal if those types are included - const includesRulesOrPromptsOrAgents = + // and also handle rule/prompt/agent/instruction removal if those types are included + const includesDotaiTypes = typeFilter && - (typeFilter.includes('rule') || typeFilter.includes('prompt') || typeFilter.includes('agent')); + (typeFilter.includes('rule') || + typeFilter.includes('prompt') || + typeFilter.includes('agent') || + typeFilter.includes('instruction')); - if (includesRulesOrPromptsOrAgents) { + if (includesDotaiTypes) { // Remove dotai-managed items first const dotaiTypes = typeFilter.filter( - (t) => t === 'rule' || t === 'prompt' || t === 'agent' - ) as Array<'rule' | 'prompt' | 'agent'>; + (t) => t === 'rule' || t === 'prompt' || t === 'agent' || t === 'instruction' + ) as Array<'rule' | 'prompt' | 'agent' | 'instruction'>; await removeDotaiManagedItems(skillNames, options, dotaiTypes); } @@ -365,17 +368,17 @@ export function parseRemoveOptions(args: string[]): { skills: string[]; options: } // --------------------------------------------------------------------------- -// Dotai-managed item removal (rules + prompts + agents via .dotai-lock.json) +// Dotai-managed item removal (rules + prompts + agents + instructions via .dotai-lock.json) // --------------------------------------------------------------------------- /** - * Remove dotai-managed items (rules, prompts, and/or agents) tracked in `.dotai-lock.json`. + * Remove dotai-managed items (rules, prompts, agents, and/or instructions) tracked in `.dotai-lock.json`. * Deletes output files and removes entries from the lock file. */ async function removeDotaiManagedItems( names: string[], options: RemoveOptions, - types: Array<'rule' | 'prompt' | 'agent'> + types: Array<'rule' | 'prompt' | 'agent' | 'instruction'> ): Promise { const cwd = process.cwd(); const spinner = p.spinner(); @@ -388,7 +391,7 @@ async function removeDotaiManagedItems( lock = result.lock; } catch { spinner.stop('No dotai lock file found'); - p.outro(pc.yellow('No rules, prompts, or agents found to remove.')); + p.outro(pc.yellow('No rules, prompts, agents, or instructions found to remove.')); return; } diff --git a/src/rule-add.ts b/src/rule-add.ts index 247e71a..da51074 100644 --- a/src/rule-add.ts +++ b/src/rule-add.ts @@ -820,7 +820,8 @@ export async function addInstructions( installedAt: new Date().toISOString(), outputs: outputPaths, append: true, - ...(options.gitignore && { gitignored: true }), + // Instructions are never gitignored — target files (AGENTS.md, CLAUDE.md, + // .github/copilot-instructions.md) should always be committed. }; updatedLock = upsertLockEntry(updatedLock, entry); @@ -829,11 +830,9 @@ export async function addInstructions( await writeDotaiLock(updatedLock, options.projectRoot); messages.push(`Updated ${pc.dim('.dotai-lock.json')}`); - // Add output paths to .gitignore when --gitignore is used - if (options.gitignore) { - await addToGitignore(options.projectRoot, result.written); - messages.push(`Updated ${pc.dim('.gitignore')} with output paths`); - } + // Instructions are never added to .gitignore — their target files + // (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md) must always + // be committed and visible to the team. } return { From 2767cd65eadfb27964fc30437108de0cd18f0d31 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 16:49:37 +0200 Subject: [PATCH 7/9] test(check): add instruction check and update tests Verify checkRuleUpdates and updateRules handle instruction entries: - detect unchanged instruction content (no false positives) - detect changed upstream instruction content - report error when instruction is removed from source - check instructions alongside rules in the same source - update instruction files with append markers on content change - update lock file hash after instruction update - preserve installedAt timestamp on instruction update --- src/instruction-check.test.ts | 388 ++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 src/instruction-check.test.ts diff --git a/src/instruction-check.test.ts b/src/instruction-check.test.ts new file mode 100644 index 0000000..db57628 --- /dev/null +++ b/src/instruction-check.test.ts @@ -0,0 +1,388 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { existsSync, readFileSync } from 'fs'; +import { checkRuleUpdates, updateRules } from './rule-check.ts'; +import { + writeDotaiLock, + createEmptyLock, + upsertLockEntry, + computeContentHash, +} from './dotai-lock.ts'; +import type { DotaiLockFile } from './dotai-lock.ts'; +import type { LockEntry, TargetAgent } from './types.ts'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function createTempDir(): Promise { + return mkdtemp(join(tmpdir(), 'instruction-check-test-')); +} + +/** Create canonical INSTRUCTIONS.md content. */ +function makeInstructionContent(name: string, description: string, body: string): string { + return `--- +name: ${name} +description: ${description} +schema-version: 1 +--- + +${body} +`; +} + +/** Create a source repo with an INSTRUCTIONS.md at the root. */ +async function createSourceRepoWithInstruction( + tempDir: string, + instruction: { name: string; description: string; body: string } +): Promise { + const repoDir = join(tempDir, 'source-repo'); + await mkdir(repoDir, { recursive: true }); + await writeFile( + join(repoDir, 'INSTRUCTIONS.md'), + makeInstructionContent(instruction.name, instruction.description, instruction.body) + ); + return repoDir; +} + +/** Create a lock entry for an instruction. */ +function makeInstructionLockEntry( + name: string, + source: string, + rawContent: string, + agents: TargetAgent[] = ['github-copilot', 'claude-code', 'cursor', 'opencode'] +): LockEntry { + return { + type: 'instruction', + name, + source, + format: 'canonical', + agents, + hash: computeContentHash(rawContent), + installedAt: '2026-02-28T00:00:00.000Z', + outputs: [], + append: true, + }; +} + +// --------------------------------------------------------------------------- +// checkRuleUpdates — instruction entries +// --------------------------------------------------------------------------- + +describe('checkRuleUpdates — instructions', () => { + let tempDir: string; + let projectDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + projectDir = join(tempDir, 'project'); + await mkdir(projectDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('detects no updates when instruction content is unchanged', async () => { + const content = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Use TypeScript strict mode.' + ); + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'Use TypeScript strict mode.', + }); + + let lock = createEmptyLock(); + lock = upsertLockEntry(lock, makeInstructionLockEntry('coding-standards', sourceRepo, content)); + await writeDotaiLock(lock, projectDir); + + const result = await checkRuleUpdates(projectDir); + + expect(result.totalChecked).toBe(1); + expect(result.updates).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('detects update when instruction content has changed', async () => { + const originalContent = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Use TypeScript strict mode.' + ); + // Source has updated content + await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'Use TypeScript strict mode. Always use const.', + }); + + let lock = createEmptyLock(); + const sourceRepo = join(tempDir, 'source-repo'); + lock = upsertLockEntry( + lock, + makeInstructionLockEntry('coding-standards', sourceRepo, originalContent) + ); + await writeDotaiLock(lock, projectDir); + + const result = await checkRuleUpdates(projectDir); + + expect(result.totalChecked).toBe(1); + expect(result.updates).toHaveLength(1); + expect(result.updates[0]!.entry.name).toBe('coding-standards'); + expect(result.updates[0]!.entry.type).toBe('instruction'); + expect(result.updates[0]!.currentHash).toBe(computeContentHash(originalContent)); + expect(result.updates[0]!.latestHash).not.toBe(result.updates[0]!.currentHash); + }); + + it('reports error when instruction is no longer in source', async () => { + const content = makeInstructionContent('old-instruction', 'Old instruction', 'Old body'); + // Source has a different instruction + await createSourceRepoWithInstruction(tempDir, { + name: 'new-instruction', + description: 'New instruction', + body: 'New body', + }); + + let lock = createEmptyLock(); + const sourceRepo = join(tempDir, 'source-repo'); + lock = upsertLockEntry(lock, makeInstructionLockEntry('old-instruction', sourceRepo, content)); + await writeDotaiLock(lock, projectDir); + + const result = await checkRuleUpdates(projectDir); + + expect(result.totalChecked).toBe(1); + expect(result.updates).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]!.error).toContain('Instruction'); + expect(result.errors[0]!.error).toContain('no longer found'); + }); + + it('checks instructions alongside rules', async () => { + // Create source repo with both a rule and an instruction + const sourceRepo = join(tempDir, 'source-repo'); + await mkdir(sourceRepo, { recursive: true }); + + const ruleContent = `--- +name: code-style +description: Enforce code style +globs: + - "*.ts" +activation: always +--- + +Use const over let. +`; + await writeFile(join(sourceRepo, 'RULES.md'), ruleContent); + + const instrContent = makeInstructionContent( + 'team-standards', + 'Team standards', + 'Follow our coding guidelines.' + ); + await writeFile(join(sourceRepo, 'INSTRUCTIONS.md'), instrContent); + + let lock = createEmptyLock(); + // Add rule entry + const ruleEntry: LockEntry = { + type: 'rule', + name: 'code-style', + source: sourceRepo, + format: 'canonical', + agents: ['github-copilot', 'claude-code', 'cursor', 'opencode'], + hash: computeContentHash(ruleContent), + installedAt: '2026-02-28T00:00:00.000Z', + outputs: [], + }; + lock = upsertLockEntry(lock, ruleEntry); + // Add instruction entry + lock = upsertLockEntry( + lock, + makeInstructionLockEntry('team-standards', sourceRepo, instrContent) + ); + await writeDotaiLock(lock, projectDir); + + const result = await checkRuleUpdates(projectDir); + + expect(result.totalChecked).toBe(2); + expect(result.updates).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// updateRules — instruction entries +// --------------------------------------------------------------------------- + +describe('updateRules — instructions', () => { + let tempDir: string; + let projectDir: string; + + beforeEach(async () => { + tempDir = await createTempDir(); + projectDir = join(tempDir, 'project'); + await mkdir(projectDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('reports all up to date when instruction content is unchanged', async () => { + const content = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Use TypeScript strict mode.' + ); + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'Use TypeScript strict mode.', + }); + + let lock = createEmptyLock(); + lock = upsertLockEntry(lock, makeInstructionLockEntry('coding-standards', sourceRepo, content)); + await writeDotaiLock(lock, projectDir); + + const result = await updateRules(projectDir); + + expect(result.totalChecked).toBe(1); + expect(result.successCount).toBe(0); + expect(result.failCount).toBe(0); + }); + + it('updates instruction when content has changed', async () => { + const originalContent = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Use TypeScript strict mode.' + ); + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'Use TypeScript strict mode. Always prefer const.', + }); + + let lock = createEmptyLock(); + lock = upsertLockEntry( + lock, + makeInstructionLockEntry('coding-standards', sourceRepo, originalContent) + ); + await writeDotaiLock(lock, projectDir); + + const result = await updateRules(projectDir); + + expect(result.totalChecked).toBe(1); + expect(result.successCount).toBe(1); + expect(result.failCount).toBe(0); + expect(result.messages.some((m) => m.includes('Updated: coding-standards'))).toBe(true); + }); + + it('writes updated instruction files to disk using append markers', async () => { + const originalContent = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Old body' + ); + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'New body content', + }); + + let lock = createEmptyLock(); + lock = upsertLockEntry( + lock, + makeInstructionLockEntry('coding-standards', sourceRepo, originalContent) + ); + await writeDotaiLock(lock, projectDir); + + await updateRules(projectDir); + + // Instructions use append mode — verify marker sections exist in target files. + // Copilot: .github/copilot-instructions.md + const copilotPath = join(projectDir, '.github', 'copilot-instructions.md'); + expect(existsSync(copilotPath)).toBe(true); + const copilotContent = readFileSync(copilotPath, 'utf-8'); + expect(copilotContent).toContain('dotai:coding-standards:start'); + expect(copilotContent).toContain('New body content'); + expect(copilotContent).toContain('dotai:coding-standards:end'); + + // Claude: CLAUDE.md + const claudePath = join(projectDir, 'CLAUDE.md'); + expect(existsSync(claudePath)).toBe(true); + const claudeContent = readFileSync(claudePath, 'utf-8'); + expect(claudeContent).toContain('New body content'); + + // Cursor + OpenCode share AGENTS.md + const agentsPath = join(projectDir, 'AGENTS.md'); + expect(existsSync(agentsPath)).toBe(true); + const agentsContent = readFileSync(agentsPath, 'utf-8'); + expect(agentsContent).toContain('New body content'); + }); + + it('updates lock file with new hash after instruction update', async () => { + const originalContent = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Old body' + ); + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'New body', + }); + + let lock = createEmptyLock(); + lock = upsertLockEntry( + lock, + makeInstructionLockEntry('coding-standards', sourceRepo, originalContent) + ); + await writeDotaiLock(lock, projectDir); + + const originalHash = computeContentHash(originalContent); + + await updateRules(projectDir); + + const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); + const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; + + const updatedEntry = updatedLock.items.find((i) => i.name === 'coding-standards'); + expect(updatedEntry).toBeDefined(); + expect(updatedEntry!.hash).not.toBe(originalHash); + expect(updatedEntry!.type).toBe('instruction'); + }); + + it('preserves installedAt on instruction update', async () => { + const originalContent = makeInstructionContent( + 'coding-standards', + 'Team coding standards', + 'Old' + ); + const originalInstalledAt = '2025-01-01T00:00:00.000Z'; + const sourceRepo = await createSourceRepoWithInstruction(tempDir, { + name: 'coding-standards', + description: 'Team coding standards', + body: 'New', + }); + + const entry = makeInstructionLockEntry('coding-standards', sourceRepo, originalContent); + entry.installedAt = originalInstalledAt; + + let lock = createEmptyLock(); + lock = upsertLockEntry(lock, entry); + await writeDotaiLock(lock, projectDir); + + await updateRules(projectDir); + + const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); + const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; + + const updatedEntry = updatedLock.items.find((i) => i.name === 'coding-standards'); + expect(updatedEntry!.installedAt).toBe(originalInstalledAt); + }); +}); From 3e7411ccf8bd2a5ec41c5413e6765424dedda26b Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 18:36:36 +0200 Subject: [PATCH 8/9] feat: remove rule content type from codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all rule-specific code as a breaking change (Phase 2). This includes deleting rule modules (parser, transpilers, reverse-transpiler, import), removing 'rule' from the ContextType union, cleaning up target agent configs, and stripping rule branches from the install pipeline, discovery, check/update, CLI commands, and all test files. Renamed shared modules from rule-prefixed to context-prefixed names (rule-add → context-add, rule-installer → context-installer, rule-discovery → context-discovery, rule-check → context-check). Legacy rule entries in .dotai-lock.json are silently filtered out during lock file read so existing lock files remain valid. BREAKING CHANGE: The 'rule' content type, --rule/-r flag, --append flag, and 'import' command are removed. --- src/add-options.test.ts | 88 +- src/add-options.ts | 10 - src/add.test.ts | 60 +- src/add.ts | 82 +- src/agent-transpilers.test.ts | 2 +- src/agent-transpilers.ts | 2 +- src/check.ts | 6 +- src/cli-parse.test.ts | 46 +- src/cli-parse.ts | 1 - src/cli.test.ts | 10 +- src/cli.ts | 54 +- src/collisions.test.ts | 18 +- src/{rule-add.ts => context-add.ts} | 206 +-- src/{rule-check.ts => context-check.ts} | 48 +- ...rule-discovery.ts => context-discovery.ts} | 200 +-- ...rule-installer.ts => context-installer.ts} | 40 +- src/dotai-lock.test.ts | 94 +- src/dotai-lock.ts | 9 +- src/find-discovery.test.ts | 105 +- src/find-discovery.ts | 14 - src/find.ts | 14 +- src/import.test.ts | 298 ---- src/import.ts | 283 ---- src/init.test.ts | 42 - src/init.ts | 30 - src/instruction-check.test.ts | 49 +- src/instruction-commands.test.ts | 47 +- src/instruction-discovery.test.ts | 15 +- src/instruction-pipeline.test.ts | 24 +- src/instruction-transpilers.test.ts | 2 +- src/list.test.ts | 198 +-- src/list.ts | 63 +- src/override-parser.test.ts | 56 +- src/parser-parity.test.ts | 85 +- src/prompt-transpilers.test.ts | 6 +- src/prompt-transpilers.ts | 2 +- src/remove.test.ts | 63 +- src/remove.ts | 23 +- src/restore.ts | 61 +- src/reverse-transpiler.test.ts | 480 ------ src/reverse-transpiler.ts | 314 ---- src/rule-check.test.ts | 766 ---------- src/rule-discovery.test.ts | 1044 ------------- src/rule-installer.test.ts | 1353 ----------------- src/rule-override-parser.test.ts | 328 ---- src/rule-parser.test.ts | 367 ----- src/rule-parser.ts | 217 --- src/rule-transpiler-overrides.test.ts | 212 --- src/rule-transpilers.test.ts | 864 ----------- src/rule-transpilers.ts | 382 ----- src/target-agents.ts | 61 +- src/types.ts | 32 +- src/utils.ts | 15 + tests/append-integration.test.ts | 377 ----- tests/cli-subprocess.test.ts | 303 +--- tests/cross-platform-paths.test.ts | 84 +- tests/debug-addRules.test.ts | 87 -- tests/e2e-canonical-install.test.ts | 311 +--- tests/e2e-cli-matrix.test.ts | 343 ++--- tests/e2e-collision.test.ts | 283 ++-- tests/e2e-instruction.test.ts | 4 +- tests/e2e-native-passthrough.test.ts | 236 +-- tests/e2e-remove.test.ts | 267 ++-- tests/e2e-update-flow.test.ts | 304 ++-- tests/e2e-utils.ts | 67 +- tests/gitignore-integration.test.ts | 219 +-- tests/install-integration.test.ts | 725 --------- tests/lock-integration.test.ts | 676 -------- tests/restore.test.ts | 370 ++--- 69 files changed, 1346 insertions(+), 12201 deletions(-) rename src/{rule-add.ts => context-add.ts} (76%) rename src/{rule-check.ts => context-check.ts} (85%) rename src/{rule-discovery.ts => context-discovery.ts} (77%) rename src/{rule-installer.ts => context-installer.ts} (89%) delete mode 100644 src/import.test.ts delete mode 100644 src/import.ts delete mode 100644 src/reverse-transpiler.test.ts delete mode 100644 src/reverse-transpiler.ts delete mode 100644 src/rule-check.test.ts delete mode 100644 src/rule-discovery.test.ts delete mode 100644 src/rule-installer.test.ts delete mode 100644 src/rule-override-parser.test.ts delete mode 100644 src/rule-parser.test.ts delete mode 100644 src/rule-parser.ts delete mode 100644 src/rule-transpiler-overrides.test.ts delete mode 100644 src/rule-transpilers.test.ts delete mode 100644 src/rule-transpilers.ts delete mode 100644 tests/append-integration.test.ts delete mode 100644 tests/debug-addRules.test.ts delete mode 100644 tests/install-integration.test.ts delete mode 100644 tests/lock-integration.test.ts diff --git a/src/add-options.test.ts b/src/add-options.test.ts index 80c9159..b9640ba 100644 --- a/src/add-options.test.ts +++ b/src/add-options.test.ts @@ -1,39 +1,12 @@ import { describe, it, expect } from 'vitest'; import { parseAddOptions } from './add-options.ts'; -import { resolveTargetAgents } from './rule-add.ts'; +import { resolveTargetAgents } from './context-add.ts'; // --------------------------------------------------------------------------- -// parseAddOptions — rule-related flags +// parseAddOptions — basic flags // --------------------------------------------------------------------------- -describe('parseAddOptions — rule-related flags', () => { - it('parses single --rule flag', () => { - const { source, options } = parseAddOptions(['owner/repo', '--rule', 'code-style']); - expect(source).toEqual(['owner/repo']); - expect(options.rule).toEqual(['code-style']); - }); - - it('parses -r shorthand', () => { - const { options } = parseAddOptions(['owner/repo', '-r', 'code-style']); - expect(options.rule).toEqual(['code-style']); - }); - - it('parses multiple --rule flags', () => { - const { options } = parseAddOptions([ - 'owner/repo', - '--rule', - 'code-style', - '--rule', - 'security', - ]); - expect(options.rule).toEqual(['code-style', 'security']); - }); - - it('parses --rule with multiple space-separated values', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style', 'security']); - expect(options.rule).toEqual(['code-style', 'security']); - }); - +describe('parseAddOptions — basic flags', () => { it('parses --targets comma-separated', () => { const { options } = parseAddOptions(['owner/repo', '--targets', 'copilot,claude,cursor']); expect(options.targets).toEqual(['copilot', 'claude', 'cursor']); @@ -45,90 +18,59 @@ describe('parseAddOptions — rule-related flags', () => { }); it('parses --dry-run flag', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style', '--dry-run']); + const { options } = parseAddOptions(['owner/repo', '--prompt', 'review-code', '--dry-run']); expect(options.dryRun).toBe(true); }); it('parses --force flag', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style', '--force']); + const { options } = parseAddOptions(['owner/repo', '--prompt', 'review-code', '--force']); expect(options.force).toBe(true); }); - it('parses combined rule + targets + dry-run + force flags', () => { + it('parses combined prompt + targets + dry-run + force flags', () => { const { source, options } = parseAddOptions([ 'owner/repo', - '--rule', - 'code-style', + '--prompt', + 'review-code', '--targets', 'copilot,claude', '--dry-run', '--force', ]); expect(source).toEqual(['owner/repo']); - expect(options.rule).toEqual(['code-style']); + expect(options.prompt).toEqual(['review-code']); expect(options.targets).toEqual(['copilot', 'claude']); expect(options.dryRun).toBe(true); expect(options.force).toBe(true); }); - it('parses --rule and --skill together', () => { + it('parses --prompt and --skill together', () => { const { options } = parseAddOptions([ 'owner/repo', - '--rule', - 'code-style', + '--prompt', + 'review-code', '--skill', 'my-skill', ]); - expect(options.rule).toEqual(['code-style']); + expect(options.prompt).toEqual(['review-code']); expect(options.skill).toEqual(['my-skill']); }); it('treats unrecognized args as source', () => { const { source, options } = parseAddOptions(['owner/repo']); expect(source).toEqual(['owner/repo']); - expect(options.rule).toBeUndefined(); expect(options.targets).toBeUndefined(); expect(options.dryRun).toBeUndefined(); expect(options.force).toBeUndefined(); - expect(options.append).toBeUndefined(); - }); - - it('parses --append flag', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style', '--append']); - expect(options.append).toBe(true); - }); - - it('parses --append with --rule, --targets, --dry-run, --force', () => { - const { source, options } = parseAddOptions([ - 'owner/repo', - '--rule', - 'code-style', - '--targets', - 'copilot,claude', - '--append', - '--dry-run', - '--force', - ]); - expect(source).toEqual(['owner/repo']); - expect(options.rule).toEqual(['code-style']); - expect(options.targets).toEqual(['copilot', 'claude']); - expect(options.append).toBe(true); - expect(options.dryRun).toBe(true); - expect(options.force).toBe(true); - }); - - it('--append defaults to undefined when not specified', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style']); - expect(options.append).toBeUndefined(); }); it('parses --gitignore flag', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style', '--gitignore']); + const { options } = parseAddOptions(['owner/repo', '--prompt', 'review-code', '--gitignore']); expect(options.gitignore).toBe(true); }); it('--gitignore defaults to undefined when not specified', () => { - const { options } = parseAddOptions(['owner/repo', '--rule', 'code-style']); + const { options } = parseAddOptions(['owner/repo', '--prompt', 'review-code']); expect(options.gitignore).toBeUndefined(); }); diff --git a/src/add-options.ts b/src/add-options.ts index 62a8a79..4478463 100644 --- a/src/add-options.ts +++ b/src/add-options.ts @@ -7,7 +7,6 @@ export interface AddOptions { targets?: string[]; yes?: boolean; skill?: string[]; - rule?: string[]; prompt?: string[]; customAgent?: string[]; instruction?: string[]; @@ -16,8 +15,6 @@ export interface AddOptions { copy?: boolean; dryRun?: boolean; force?: boolean; - /** Use append mode for rules — write to AGENTS.md/CLAUDE.md instead of per-rule files. */ - append?: boolean; /** Add transpiled output paths to .gitignore (opt-in). */ gitignore?: boolean; /** Filter discovery to specific context types (skill, rule, prompt, agent). */ @@ -48,11 +45,6 @@ export function parseAddOptions(args: string[]): { source: string[]; options: Ad const { values, nextIndex } = consumeMultiValues(args, i + 1); options.skill.push(...values); i = nextIndex - 1; - } else if (arg === '-r' || arg === '--rule') { - options.rule = options.rule || []; - const { values, nextIndex } = consumeMultiValues(args, i + 1); - options.rule.push(...values); - i = nextIndex - 1; } else if (arg === '-p' || arg === '--prompt') { options.prompt = options.prompt || []; const { values, nextIndex } = consumeMultiValues(args, i + 1); @@ -68,8 +60,6 @@ export function parseAddOptions(args: string[]): { source: string[]; options: Ad const { values, nextIndex } = consumeMultiValues(args, i + 1); options.instruction.push(...values); i = nextIndex - 1; - } else if (arg === '--append') { - options.append = true; } else if (arg === '--gitignore') { options.gitignore = true; } else if (arg === '--dry-run') { diff --git a/src/add.test.ts b/src/add.test.ts index e40c6f5..e736358 100644 --- a/src/add.test.ts +++ b/src/add.test.ts @@ -473,13 +473,6 @@ describe('parseAddOptions', () => { expect(result.options.prompt).toEqual(['review-code', 'fix-bug']); }); - it('should parse --prompt with --rule combination', () => { - const result = parseAddOptions(['source', '--rule', 'code-style', '--prompt', 'review-code']); - expect(result.source).toEqual(['source']); - expect(result.options.rule).toEqual(['code-style']); - expect(result.options.prompt).toEqual(['review-code']); - }); - it('should parse --prompt with --skill combination', () => { const result = parseAddOptions(['source', '--skill', 'my-skill', '-p', 'review-code']); expect(result.source).toEqual(['source']); @@ -499,19 +492,6 @@ describe('parseAddOptions', () => { expect(result.options.customAgent).toEqual(['architect', 'reviewer']); }); - it('should parse --custom-agent with --rule combination', () => { - const result = parseAddOptions([ - 'source', - '--rule', - 'code-style', - '--custom-agent', - 'architect', - ]); - expect(result.source).toEqual(['source']); - expect(result.options.rule).toEqual(['code-style']); - expect(result.options.customAgent).toEqual(['architect']); - }); - it('should parse --custom-agent with --prompt combination', () => { const result = parseAddOptions([ 'source', @@ -552,9 +532,9 @@ describe('parseAddOptions', () => { }); it('should parse --type flag with single type', () => { - const result = parseAddOptions(['source', '--type', 'rule']); + const result = parseAddOptions(['source', '--type', 'prompt']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule']); + expect(result.options.type).toEqual(['prompt']); }); it('should parse -t short flag for type', () => { @@ -564,55 +544,55 @@ describe('parseAddOptions', () => { }); it('should parse --type with comma-separated values', () => { - const result = parseAddOptions(['source', '--type', 'rule,prompt']); + const result = parseAddOptions(['source', '--type', 'skill,prompt']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule', 'prompt']); + expect(result.options.type).toEqual(['skill', 'prompt']); }); it('should parse --type with multiple space-separated values', () => { - const result = parseAddOptions(['source', '--type', 'rule', 'prompt', 'agent']); + const result = parseAddOptions(['source', '--type', 'skill', 'prompt', 'agent']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule', 'prompt', 'agent']); + expect(result.options.type).toEqual(['skill', 'prompt', 'agent']); }); it('should parse --type with all four types', () => { - const result = parseAddOptions(['source', '--type', 'skill,rule,prompt,agent']); + const result = parseAddOptions(['source', '--type', 'skill,prompt,agent,instruction']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['skill', 'rule', 'prompt', 'agent']); + expect(result.options.type).toEqual(['skill', 'prompt', 'agent', 'instruction']); }); it('should deduplicate --type values', () => { - const result = parseAddOptions(['source', '--type', 'rule,rule,prompt']); + const result = parseAddOptions(['source', '--type', 'prompt,prompt,skill']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule', 'prompt']); + expect(result.options.type).toEqual(['prompt', 'skill']); }); it('should normalize --type values to lowercase', () => { - const result = parseAddOptions(['source', '--type', 'Rule,PROMPT']); + const result = parseAddOptions(['source', '--type', 'Skill,PROMPT']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule', 'prompt']); + expect(result.options.type).toEqual(['skill', 'prompt']); }); it('should parse --type with other flags', () => { const result = parseAddOptions([ 'source', '--type', - 'rule', + 'prompt', '--targets', 'copilot,claude', '--force', ]); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule']); + expect(result.options.type).toEqual(['prompt']); expect(result.options.targets).toEqual(['copilot', 'claude']); expect(result.options.force).toBe(true); }); - it('should parse --type with explicit --rule names (type sets flow, names filter)', () => { - const result = parseAddOptions(['source', '--type', 'rule', '--rule', 'code-style']); + it('should parse --type with explicit --prompt names (type sets flow, names filter)', () => { + const result = parseAddOptions(['source', '--type', 'prompt', '--prompt', 'review-code']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule']); - expect(result.options.rule).toEqual(['code-style']); + expect(result.options.type).toEqual(['prompt']); + expect(result.options.prompt).toEqual(['review-code']); }); it('should handle --type with no following value', () => { @@ -634,9 +614,9 @@ describe('parseAddOptions', () => { }); it('should filter empty segments between valid values', () => { - const result = parseAddOptions(['source', '--type', 'rule,,prompt']); + const result = parseAddOptions(['source', '--type', 'prompt,,skill']); expect(result.source).toEqual(['source']); - expect(result.options.type).toEqual(['rule', 'prompt']); + expect(result.options.type).toEqual(['prompt', 'skill']); }); it('should parse --copy flag', () => { diff --git a/src/add.ts b/src/add.ts index 5b3272d..8b0b092 100644 --- a/src/add.ts +++ b/src/add.ts @@ -8,16 +8,10 @@ import { multiselect } from './add-agents.ts'; export { promptForAgents } from './add-agents.ts'; import { cloneRepo, cleanupTempDir, GitCloneError } from './git.ts'; import { CommandError } from './command-result.ts'; -import { - addRules, - addPrompts, - addAgents, - addInstructions, - resolveTargetAgents, -} from './rule-add.ts'; +import { addPrompts, addAgents, addInstructions, resolveTargetAgents } from './context-add.ts'; import { TARGET_AGENTS } from './target-agents.ts'; import { discoverSkills, getSkillDisplayName, filterSkills } from './skill-discovery.ts'; -import { discover } from './rule-discovery.ts'; +import { discover } from './context-discovery.ts'; import { installSkillForAgent, isSkillInstalled, getCanonicalPath } from './skill-installer.ts'; import { agents } from './agents.ts'; import { track, setVersion, fetchAuditData } from './telemetry.ts'; @@ -50,7 +44,7 @@ setVersion(version); /** * Resolve transpilation target agents from --targets flag, or default to all four. * - * When --targets is used for transpilation (rules/prompts/agents), values are + * When --targets is used for transpilation (prompts/agents/instructions), values are * resolved as TargetAgent names or aliases (copilot, claude, cursor). * * Throws CommandError if invalid or no valid targets are specified. @@ -100,25 +94,7 @@ interface ContextInstallConfig { }>; } -const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent' | 'instruction', ContextInstallConfig> = { - rule: { - noun: 'rule', - getNames: (opts) => opts.rule ?? [], - install: async ({ source, sourcePath, projectRoot, names, agents, options }) => { - const result = await addRules({ - source, - sourcePath, - projectRoot, - ruleNames: names, - targets: agents, - dryRun: options.dryRun, - force: options.force, - append: options.append, - gitignore: options.gitignore, - }); - return { ...result, itemsInstalled: result.rulesInstalled }; - }, - }, +const CONTEXT_CONFIGS: Record<'prompt' | 'agent' | 'instruction', ContextInstallConfig> = { prompt: { noun: 'prompt', getNames: (opts) => opts.prompt ?? [], @@ -173,13 +149,13 @@ const CONTEXT_CONFIGS: Record<'rule' | 'prompt' | 'agent' | 'instruction', Conte }; /** - * Generic handler for rule, prompt, and agent install flows. + * Generic handler for prompt, agent, and instruction install flows. * * Resolves `--targets` names to TargetAgent[], runs the appropriate discovery → * transpile → install pipeline, and displays results using @clack/prompts. */ async function handleContextInstall( - contextType: 'rule' | 'prompt' | 'agent' | 'instruction', + contextType: 'prompt' | 'agent' | 'instruction', source: string, skillsDir: string, options: AddOptions, @@ -283,9 +259,9 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< // --type expands into the corresponding type-specific options. // For each requested type, if the user hasn't already specified explicit names - // via --rule/--prompt/--custom-agent/--skill, set them to ['*'] (discover all). + // via --prompt/--custom-agent/--skill, set them to ['*'] (discover all). if (options.type && options.type.length > 0) { - const validTypes: ContextType[] = ['skill', 'rule', 'prompt', 'agent', 'instruction']; + const validTypes: ContextType[] = ['skill', 'prompt', 'agent', 'instruction']; const invalidTypes = options.type.filter((t) => !validTypes.includes(t)); if (invalidTypes.length > 0) { @@ -302,9 +278,6 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< const requestedTypes = new Set(options.type); - if (requestedTypes.has('rule') && (!options.rule || options.rule.length === 0)) { - options.rule = ['*']; - } if (requestedTypes.has('prompt') && (!options.prompt || options.prompt.length === 0)) { options.prompt = ['*']; } @@ -384,24 +357,6 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< } } - // ─── Rule install flow (--rule flag) ─── - // When --rule is specified, run the dotai rule transpilation pipeline. - // This is separate from the skill flow and uses discovery + transpilers + collision detection. - if (options.rule && options.rule.length > 0) { - await handleContextInstall('rule', source, skillsDir, options, spinner); - - // If no --skill, --prompt, --custom-agent, or --instruction flag was also specified, we're done after rule install - if ( - (!options.skill || options.skill.length === 0) && - (!options.prompt || options.prompt.length === 0) && - (!options.customAgent || options.customAgent.length === 0) && - (!options.instruction || options.instruction.length === 0) - ) { - await cleanup(tempDir); - return; - } - } - // ─── Prompt install flow (--prompt flag) ─── // When --prompt is specified, run the dotai prompt transpilation pipeline. // This is separate from the skill flow and uses discovery + transpilers + collision detection. @@ -453,7 +408,6 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< // a unified interactive selection grouped by type. const hasTypeFlags = (options.skill && options.skill.length > 0) || - (options.rule && options.rule.length > 0) || (options.prompt && options.prompt.length > 0) || (options.customAgent && options.customAgent.length > 0) || (options.instruction && options.instruction.length > 0); @@ -470,18 +424,16 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< }), ]); - const rules = fullResult.items.filter((i) => i.type === 'rule'); const prompts = fullResult.items.filter((i) => i.type === 'prompt'); const customAgents = fullResult.items.filter((i) => i.type === 'agent'); const instructions = fullResult.items.filter((i) => i.type === 'instruction'); - const hasNonSkillContent = - rules.length + prompts.length + customAgents.length + instructions.length > 0; + const hasNonSkillContent = prompts.length + customAgents.length + instructions.length > 0; // If non-skill content exists, present unified selection if (hasNonSkillContent && skills.length > 0 && !options.yes && process.stdin.isTTY) { const totalItems = - skills.length + rules.length + prompts.length + customAgents.length + instructions.length; + skills.length + prompts.length + customAgents.length + instructions.length; spinner.stop(`Found ${pc.green(String(totalItems))} item${totalItems !== 1 ? 's' : ''}`); // Build grouped options for groupMultiselect @@ -494,13 +446,6 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description, })); } - if (rules.length > 0) { - grouped[`Rules (${rules.length})`] = rules.map((r) => ({ - value: { name: r.name, type: 'rule' }, - label: r.name, - hint: r.description.length > 60 ? r.description.slice(0, 57) + '...' : r.description, - })); - } if (prompts.length > 0) { grouped[`Prompts (${prompts.length})`] = prompts.map((pr) => ({ value: { name: pr.name, type: 'prompt' }, @@ -542,21 +487,16 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< // Map selections back to type-specific options const pickedSkills = picks.filter((p) => p.type === 'skill').map((p) => p.name); - const pickedRules = picks.filter((p) => p.type === 'rule').map((p) => p.name); const pickedPrompts = picks.filter((p) => p.type === 'prompt').map((p) => p.name); const pickedAgents = picks.filter((p) => p.type === 'agent').map((p) => p.name); const pickedInstructions = picks.filter((p) => p.type === 'instruction').map((p) => p.name); if (pickedSkills.length > 0) options.skill = pickedSkills; - if (pickedRules.length > 0) options.rule = pickedRules; if (pickedPrompts.length > 0) options.prompt = pickedPrompts; if (pickedAgents.length > 0) options.customAgent = pickedAgents; if (pickedInstructions.length > 0) options.instruction = pickedInstructions; // Re-run the type-specific handlers for any non-skill content - if (pickedRules.length > 0) { - await handleContextInstall('rule', source, skillsDir, options, spinner); - } if (pickedPrompts.length > 0) { await handleContextInstall('prompt', source, skillsDir, options, spinner); } @@ -592,7 +532,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< } } - // When hasTypeFlags is true (user passed --skill, --rule, etc.), we need + // When hasTypeFlags is true (user passed --skill, --prompt, etc.), we need // to discover skills fresh. When !hasTypeFlags, unified discovery above // already handled non-skill content and set options.skill if skills were // picked. In either case, we need to discover skills here for the install flow. diff --git a/src/agent-transpilers.test.ts b/src/agent-transpilers.test.ts index c3587f3..033362e 100644 --- a/src/agent-transpilers.test.ts +++ b/src/agent-transpilers.test.ts @@ -56,7 +56,7 @@ describe('canTranspile', () => { const canonicalAgent = makeDiscoveredAgentItem(); const nativeAgent = makeDiscoveredAgentItem({ format: 'native:github-copilot' }); const skillItem = makeDiscoveredAgentItem({ type: 'skill' }); - const ruleItem = makeDiscoveredAgentItem({ type: 'rule' }); + const ruleItem = makeDiscoveredAgentItem({ type: 'prompt' }); const promptItem = makeDiscoveredAgentItem({ type: 'prompt' }); it.each([ diff --git a/src/agent-transpilers.ts b/src/agent-transpilers.ts index 61dc9db..8310385 100644 --- a/src/agent-transpilers.ts +++ b/src/agent-transpilers.ts @@ -9,7 +9,7 @@ import type { import { parseAgentContent } from './agent-parser.ts'; import { getTargetAgentConfig } from './target-agents.ts'; import { resolveModel, type ModelOverrides } from './model-aliases.ts'; -import { quoteYaml } from './rule-transpilers.ts'; +import { quoteYaml } from './utils.ts'; import { mergeOverrides } from './override-parser.ts'; // --------------------------------------------------------------------------- diff --git a/src/check.ts b/src/check.ts index c344119..e08fbef 100644 --- a/src/check.ts +++ b/src/check.ts @@ -5,7 +5,7 @@ import { readSkillLock, type SkillLockEntry, } from './skill-lock.ts'; -import { checkRuleUpdates, updateRules } from './rule-check.ts'; +import { checkContextUpdates, updateContext } from './context-check.ts'; import { track } from './telemetry.ts'; import { RESET, DIM, TEXT } from './utils.ts'; @@ -162,7 +162,7 @@ export async function runCheck(_args: string[] = []): Promise { // ── Check rules, prompts, agents, and instructions (project lock: .dotai-lock.json) ── const projectRoot = process.cwd(); - const ruleCheck = await checkRuleUpdates(projectRoot); + const ruleCheck = await checkContextUpdates(projectRoot); if (ruleCheck.totalChecked > 0) { hasAnyItems = true; @@ -283,7 +283,7 @@ export async function runUpdate(): Promise { // ── Update rules, prompts, agents, and instructions (project lock: .dotai-lock.json) ── const projectRoot = process.cwd(); - const ruleResult = await updateRules(projectRoot); + const ruleResult = await updateContext(projectRoot); if (ruleResult.totalChecked > 0) { hasAnyItems = true; diff --git a/src/cli-parse.test.ts b/src/cli-parse.test.ts index 1dce20d..e91901d 100644 --- a/src/cli-parse.test.ts +++ b/src/cli-parse.test.ts @@ -88,8 +88,8 @@ describe('consumeMultiValues', () => { }); describe('VALID_CONTEXT_TYPES', () => { - it('should contain all five context types', () => { - expect(VALID_CONTEXT_TYPES).toEqual(['skill', 'rule', 'prompt', 'agent', 'instruction']); + it('should contain all four context types', () => { + expect(VALID_CONTEXT_TYPES).toEqual(['skill', 'prompt', 'agent', 'instruction']); }); }); @@ -99,50 +99,50 @@ describe('parseTypeFlag', () => { }; it('should parse a single type value', () => { - const { types, nextIndex } = parseTypeFlag([], ['rule'], 0, throwError); - expect(types).toEqual(['rule']); + const { types, nextIndex } = parseTypeFlag([], ['prompt'], 0, throwError); + expect(types).toEqual(['prompt']); expect(nextIndex).toBe(1); }); it('should parse comma-separated type values', () => { - const { types } = parseTypeFlag([], ['rule,prompt'], 0, throwError); - expect(types).toEqual(['rule', 'prompt']); + const { types } = parseTypeFlag([], ['prompt,agent'], 0, throwError); + expect(types).toEqual(['prompt', 'agent']); }); it('should parse space-separated type values', () => { - const { types, nextIndex } = parseTypeFlag([], ['rule', 'prompt', '--flag'], 0, throwError); - expect(types).toEqual(['rule', 'prompt']); + const { types, nextIndex } = parseTypeFlag([], ['prompt', 'agent', '--flag'], 0, throwError); + expect(types).toEqual(['prompt', 'agent']); expect(nextIndex).toBe(2); }); it('should normalize to lowercase', () => { - const { types } = parseTypeFlag([], ['RULE'], 0, throwError); - expect(types).toEqual(['rule']); + const { types } = parseTypeFlag([], ['PROMPT'], 0, throwError); + expect(types).toEqual(['prompt']); }); it('should normalize mixed-case comma-separated values', () => { - const { types } = parseTypeFlag([], ['Rule,PROMPT,Agent'], 0, throwError); - expect(types).toEqual(['rule', 'prompt', 'agent']); + const { types } = parseTypeFlag([], ['Skill,PROMPT,Agent'], 0, throwError); + expect(types).toEqual(['skill', 'prompt', 'agent']); }); it('should deduplicate values', () => { - const { types } = parseTypeFlag([], ['rule,rule,prompt'], 0, throwError); - expect(types).toEqual(['rule', 'prompt']); + const { types } = parseTypeFlag([], ['prompt,prompt,agent'], 0, throwError); + expect(types).toEqual(['prompt', 'agent']); }); it('should merge with existing values and deduplicate', () => { - const { types } = parseTypeFlag(['rule'], ['prompt,rule'], 0, throwError); - expect(types).toEqual(['rule', 'prompt']); + const { types } = parseTypeFlag(['prompt'], ['agent,prompt'], 0, throwError); + expect(types).toEqual(['prompt', 'agent']); }); it('should filter empty segments from CSV', () => { - const { types } = parseTypeFlag([], ['rule,,prompt'], 0, throwError); - expect(types).toEqual(['rule', 'prompt']); + const { types } = parseTypeFlag([], ['prompt,,agent'], 0, throwError); + expect(types).toEqual(['prompt', 'agent']); }); it('should accept all four valid types', () => { - const { types } = parseTypeFlag([], ['skill,rule,prompt,agent'], 0, throwError); - expect(types).toEqual(['skill', 'rule', 'prompt', 'agent']); + const { types } = parseTypeFlag([], ['skill,prompt,agent,instruction'], 0, throwError); + expect(types).toEqual(['skill', 'prompt', 'agent', 'instruction']); }); it('should throw on invalid type value', () => { @@ -163,13 +163,11 @@ describe('parseTypeFlag', () => { it('should include valid types message in error for invalid type', () => { expect(() => parseTypeFlag([], ['bad'], 0, throwError)).toThrow( - 'Valid types: skill, rule, prompt, agent' + 'Valid types: skill, prompt, agent' ); }); it('should include valid types message in error for missing value', () => { - expect(() => parseTypeFlag([], [], 0, throwError)).toThrow( - 'Valid types: skill, rule, prompt, agent' - ); + expect(() => parseTypeFlag([], [], 0, throwError)).toThrow('Valid types: skill, prompt, agent'); }); }); diff --git a/src/cli-parse.ts b/src/cli-parse.ts index 899f076..8f330ef 100644 --- a/src/cli-parse.ts +++ b/src/cli-parse.ts @@ -3,7 +3,6 @@ import type { ContextType } from './types.ts'; /** Valid context types accepted by --type flags. */ export const VALID_CONTEXT_TYPES: readonly ContextType[] = [ 'skill', - 'rule', 'prompt', 'agent', 'instruction', diff --git a/src/cli.test.ts b/src/cli.test.ts index cbc6a15..6d50038 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -55,12 +55,10 @@ describe('dotai CLI', () => { const output = runCliOutput(['add', '--help-all']); expect(output).toContain('Usage: dotai add [options]'); expect(output).toContain('-s, --skill'); - expect(output).toContain('-r, --rule'); expect(output).toContain('-p, --prompt'); expect(output).toContain('-a, --targets'); expect(output).toContain('--dry-run'); expect(output).toContain('--force'); - expect(output).toContain('--append'); expect(output).toContain('--gitignore'); expect(output).toContain('-y, --yes'); expect(output).toContain('--all'); @@ -80,19 +78,21 @@ describe('dotai CLI', () => { describe('remove --help', () => { it('should describe removing all context types', () => { const output = runCliOutput(['remove', '--help']); - expect(output).toContain('Remove installed context (skills, rules, prompts, or agents)'); + expect(output).toContain( + 'Remove installed context (skills, prompts, agents, or instructions)' + ); }); it('should document --type option', () => { const output = runCliOutput(['remove', '--help']); expect(output).toContain('-t, --type'); - expect(output).toContain('skill, rule, prompt, agent'); + expect(output).toContain('skill, prompt, agent, instruction'); }); it('should include --type examples', () => { const output = runCliOutput(['remove', '--help']); - expect(output).toContain('--type rule'); expect(output).toContain('--type prompt'); + expect(output).toContain('--type skill,prompt'); }); }); diff --git a/src/cli.ts b/src/cli.ts index 81e589b..afeb1ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,6 @@ import { runAdd, parseAddOptions, initTelemetry } from './add.ts'; import { runCheck, runUpdate } from './check.ts'; import { CommandError } from './command-result.ts'; import { runFind } from './find.ts'; -import { runImport } from './import.ts'; import { runInit } from './init.ts'; import { runInstallFromLock } from './restore.ts'; import { runList } from './list.ts'; @@ -87,9 +86,6 @@ function showBanner(): void { console.log( ` ${DIM}$${RESET} ${TEXT}npx dotai init ${DIM}[name]${RESET} ${DIM}Create a context template${RESET}` ); - console.log( - ` ${DIM}$${RESET} ${TEXT}npx dotai import${RESET} ${DIM}Import native rules as canonical${RESET}` - ); console.log( ` ${DIM}$${RESET} ${TEXT}npx dotai experimental_sync${RESET} ${DIM}Sync skills from node_modules${RESET}` ); @@ -109,7 +105,6 @@ ${BOLD}Commands:${RESET} remove [names] Remove installed context list, ls List installed context find [query] Search for skills & context - import Import native agent rules as canonical check Check for available updates update Update installed items restore Restore from lock files @@ -121,8 +116,8 @@ ${BOLD}Options:${RESET} ${BOLD}Examples:${RESET} ${DIM}$${RESET} dotai add owner/repo ${DIM}# interactive install (all types)${RESET} - ${DIM}$${RESET} dotai add owner/repo --rule code-style ${DIM}# install a rule${RESET} - ${DIM}$${RESET} dotai add owner/repo --type rule,prompt ${DIM}# install all rules and prompts${RESET} + ${DIM}$${RESET} dotai add owner/repo --skill my-skill ${DIM}# install a skill${RESET} + ${DIM}$${RESET} dotai add owner/repo --type skill,prompt ${DIM}# install all skills and prompts${RESET} ${DIM}$${RESET} dotai find owner/repo ${DIM}# browse available context${RESET} ${DIM}$${RESET} dotai remove ${DIM}# interactive remove${RESET} @@ -136,22 +131,22 @@ function showAddHelp(): void { ${BOLD}Usage:${RESET} dotai add [options] ${BOLD}Description:${RESET} - Add context (skills, rules, prompts, agents) from a source repository. + Add context (skills, prompts, agents, instructions) from a source repository. Sources can be GitHub shorthand, full URLs, or local paths. With no flags, presents an interactive selection of all discovered content. ${BOLD}Essentials:${RESET} GitHub shorthand (owner/repo), URL, or local path -a, --targets Target agents (comma-separated; use '*' for all) - -t, --type Filter by type (skill, rule, prompt, agent; comma-separated) + -t, --type Filter by type (skill, prompt, agent, instruction; comma-separated) -g, --global Install globally (user-level) -y, --yes Skip confirmation prompts ${BOLD}Examples:${RESET} ${DIM}$${RESET} dotai add vercel-labs/agent-skills ${DIM}# interactive (all types)${RESET} - ${DIM}$${RESET} dotai add owner/repo --rule code-style ${DIM}# install a specific rule${RESET} + ${DIM}$${RESET} dotai add owner/repo --skill my-skill ${DIM}# install a specific skill${RESET} ${DIM}$${RESET} dotai add owner/repo --targets copilot,claude ${DIM}# target specific agents${RESET} - ${DIM}$${RESET} dotai add owner/repo --type rule,prompt -y ${DIM}# install all rules and prompts${RESET} + ${DIM}$${RESET} dotai add owner/repo --type skill,prompt -y ${DIM}# install all skills and prompts${RESET} Run ${BOLD}dotai add --help-all${RESET} for all options. Discover skills at ${TEXT}https://skills.sh/${RESET} @@ -163,28 +158,27 @@ function showAddHelpAll(): void { ${BOLD}Usage:${RESET} dotai add [options] ${BOLD}Description:${RESET} - Add context (skills, rules, prompts, agents) from a source repository. + Add context (skills, prompts, agents, instructions) from a source repository. Sources can be GitHub shorthand, full URLs, or local paths. With no flags, presents an interactive selection of all discovered content. ${BOLD}Content Selection:${RESET} -s, --skill Install specific skills (use '*' for all) - -r, --rule Install specific rules -p, --prompt Install specific prompts --custom-agent Install specific agents (AGENT.md context) - -t, --type Filter by type (skill, rule, prompt, agent; comma-separated) + -t, --type Filter by type (skill, prompt, agent, instruction; comma-separated) ${BOLD}Target Options:${RESET} -a, --targets Target agents (comma-separated; use '*' for all) For skills: any of the ${DIM}41 supported agents${RESET} - For rules/prompts/agents: copilot, claude, cursor, windsurf, cline + For prompts/agents: copilot, claude, cursor, windsurf, cline ${BOLD}Install Options:${RESET} -g, --global Install globally (user-level) --copy Copy files instead of symlinking --dry-run Preview writes without making changes --force Overwrite conflicting outputs - --append Append rules to AGENTS.md/CLAUDE.md instead of per-rule files + --append Append instructions to AGENTS.md/CLAUDE.md instead of per-file output --gitignore Add transpiled output paths to .gitignore --full-depth Search all subdirectories even when a root SKILL.md exists --all Shorthand for --skill '*' --targets '*' -y @@ -192,10 +186,10 @@ ${BOLD}Install Options:${RESET} ${BOLD}Examples:${RESET} ${DIM}$${RESET} dotai add vercel-labs/agent-skills ${DIM}# interactive (all types)${RESET} - ${DIM}$${RESET} dotai add owner/repo --rule code-style ${DIM}# install a specific rule${RESET} + ${DIM}$${RESET} dotai add owner/repo --skill my-skill ${DIM}# install a specific skill${RESET} ${DIM}$${RESET} dotai add owner/repo --targets copilot,claude ${DIM}# target specific agents${RESET} ${DIM}$${RESET} dotai add owner/repo --prompt review-code ${DIM}# install a prompt${RESET} - ${DIM}$${RESET} dotai add owner/repo --type rule,prompt -y ${DIM}# install all rules and prompts${RESET} + ${DIM}$${RESET} dotai add owner/repo --type skill,prompt -y ${DIM}# install all skills and prompts${RESET} ${DIM}$${RESET} dotai add owner/repo --all -g ${DIM}# install everything globally${RESET} Discover skills at ${TEXT}https://skills.sh/${RESET} @@ -207,18 +201,18 @@ function showListHelp(): void { ${BOLD}Usage:${RESET} dotai list [options] ${BOLD}Description:${RESET} - List installed context (skills, rules, prompts, agents). + List installed context (skills, prompts, agents, instructions). ${BOLD}Options:${RESET} -g, --global List global context (default: project) -a, --targets Filter by specific targets - -t, --type Filter by type (skill, rule, prompt, agent; comma-separated) + -t, --type Filter by type (skill, prompt, agent, instruction; comma-separated) ${BOLD}Examples:${RESET} ${DIM}$${RESET} dotai list ${DIM}# list project installs${RESET} ${DIM}$${RESET} dotai ls -g ${DIM}# list global installs${RESET} ${DIM}$${RESET} dotai ls -a claude-code ${DIM}# filter by agent${RESET} - ${DIM}$${RESET} dotai ls -t rule ${DIM}# list only rules${RESET} + ${DIM}$${RESET} dotai ls -t prompt ${DIM}# list only prompts${RESET} Discover skills at ${TEXT}https://skills.sh/${RESET} `); @@ -229,7 +223,7 @@ function showRemoveHelp(): void { ${BOLD}Usage:${RESET} dotai remove [names...] [options] ${BOLD}Description:${RESET} - Remove installed context (skills, rules, prompts, or agents). + Remove installed context (skills, prompts, agents, or instructions). If no names are provided, an interactive selection menu will be shown. Use --type to target specific context types. @@ -240,7 +234,7 @@ ${BOLD}Options:${RESET} -g, --global Remove from global scope (~/) instead of project scope -a, --targets Remove from specific targets (use '*' for all targets) -s, --skill Specify skills to remove (use '*' for all skills) - -t, --type Filter by context type (skill, rule, prompt, agent; comma-separated) + -t, --type Filter by context type (skill, prompt, agent, instruction; comma-separated) -y, --yes Skip confirmation prompts --all Shorthand for --skill '*' --targets '*' -y @@ -250,9 +244,9 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} dotai remove skill1 skill2 -y ${DIM}# remove multiple, skip confirm${RESET} ${DIM}$${RESET} dotai remove --global my-skill ${DIM}# remove from global scope${RESET} ${DIM}$${RESET} dotai rm --targets claude-code my-skill ${DIM}# remove from specific target${RESET} - ${DIM}$${RESET} dotai remove --type rule code-style ${DIM}# remove a specific rule${RESET} + ${DIM}$${RESET} dotai remove --type prompt review-code ${DIM}# remove a specific prompt${RESET} ${DIM}$${RESET} dotai remove --type prompt -y ${DIM}# remove all prompts${RESET} - ${DIM}$${RESET} dotai remove --type rule,prompt ${DIM}# interactive rule/prompt removal${RESET} + ${DIM}$${RESET} dotai remove --type skill,prompt ${DIM}# interactive skill/prompt removal${RESET} ${DIM}$${RESET} dotai remove --all ${DIM}# remove all skills${RESET} Discover skills at ${TEXT}https://skills.sh/${RESET} @@ -288,16 +282,6 @@ async function main(): Promise { console.log(); runInit(restArgs); break; - case 'import': { - if (restArgs.includes('--help') || restArgs.includes('-h')) { - runImport(restArgs); - break; - } - showLogo(); - console.log(); - runImport(restArgs); - break; - } case 'a': case 'i': case 'install': diff --git a/src/collisions.test.ts b/src/collisions.test.ts index 616f05b..94d73a7 100644 --- a/src/collisions.test.ts +++ b/src/collisions.test.ts @@ -63,7 +63,7 @@ function makePlannedWrite( return createPlannedWrite( output, projectRoot, - overrides.type ?? 'rule', + overrides.type ?? 'prompt', overrides.name ?? 'code-style', overrides.format ?? 'canonical', overrides.source ?? 'acme/repo-a' @@ -72,7 +72,7 @@ function makePlannedWrite( function makeLockEntry(overrides: Partial = {}): LockEntry { return { - type: 'rule', + type: 'prompt', name: 'code-style', source: 'acme/repo-a', format: 'canonical', @@ -107,7 +107,7 @@ describe('collisions', () => { const pw = createPlannedWrite( output, '/project', - 'rule', + 'prompt', 'code-style', 'canonical', 'acme/repo' @@ -120,12 +120,12 @@ describe('collisions', () => { const pw = createPlannedWrite( output, '/project', - 'rule', + 'prompt', 'code-style', 'canonical', 'acme/repo' ); - expect(pw.type).toBe('rule'); + expect(pw.type).toBe('prompt'); expect(pw.name).toBe('code-style'); expect(pw.format).toBe('canonical'); expect(pw.source).toBe('acme/repo'); @@ -307,7 +307,7 @@ describe('collisions', () => { const pw = makePlannedWrite(testDir, { type: 'skill', name: 'code-style' }); const lockEntry = makeLockEntry({ - type: 'rule', + type: 'prompt', name: 'code-style', source: 'acme/repo-b', }); @@ -321,7 +321,7 @@ describe('collisions', () => { expect(collisions.filter((c) => c.kind === 'same-name')).toEqual([]); }); - it('allows a prompt and a rule with the same name (no collision)', () => { + it('allows a prompt and an instruction with the same name (no collision)', () => { const pw = makePlannedWrite(testDir, { type: 'prompt', name: 'review-code', @@ -330,7 +330,7 @@ describe('collisions', () => { }); const lockEntry = makeLockEntry({ - type: 'rule', + type: 'instruction', name: 'review-code', source: 'acme/repo-a', }); @@ -340,7 +340,7 @@ describe('collisions', () => { lockEntries: [lockEntry], }); - // No same-name collision because types differ (prompt vs rule) + // No same-name collision because types differ (prompt vs instruction) expect(collisions.filter((c) => c.kind === 'same-name')).toEqual([]); }); diff --git a/src/rule-add.ts b/src/context-add.ts similarity index 76% rename from src/rule-add.ts rename to src/context-add.ts index da51074..7e102e4 100644 --- a/src/rule-add.ts +++ b/src/context-add.ts @@ -1,6 +1,6 @@ import pc from 'picocolors'; -import { discover, filterByType } from './rule-discovery.ts'; -import { executeInstallPipeline } from './rule-installer.ts'; +import { discover, filterByType } from './context-discovery.ts'; +import { executeInstallPipeline } from './context-installer.ts'; import { readDotaiLock, writeDotaiLock, @@ -13,57 +13,17 @@ import { addToGitignore } from './gitignore.ts'; import type { DiscoveredItem, LockEntry, TargetAgent } from './types.ts'; // --------------------------------------------------------------------------- -// Rule & prompt install — wires discovery → transpile → install pipeline +// Context install — wires discovery → transpile → install pipeline // -// This module handles the `dotai add owner/repo --rule `, -// `dotai add owner/repo --prompt `, and -// `dotai add owner/repo --agent ` flows. +// This module handles the `dotai add owner/repo --prompt `, +// `dotai add owner/repo --agent `, and +// `dotai add owner/repo --instruction ` flows. // It uses the discovery engine and install pipeline, // updating the dotai lock file on success. // // Skills are not handled here — they use the existing upstream installer.ts. // --------------------------------------------------------------------------- -/** - * Options for rule installation. - */ -export interface RuleAddOptions { - /** Source identifier (e.g., "owner/repo") for lock file tracking. */ - source: string; - /** Absolute path to the cloned/local source repo. */ - sourcePath: string; - /** Absolute path to the project root directory. */ - projectRoot: string; - /** Rule names to install. Empty or ['*'] means all rules. */ - ruleNames: string[]; - /** Target agents to install for. Defaults to all five. */ - targets?: TargetAgent[]; - /** Preview planned writes without executing them. */ - dryRun?: boolean; - /** Overwrite collisions instead of aborting. */ - force?: boolean; - /** Use append mode for Copilot (AGENTS.md) and Claude Code (CLAUDE.md). */ - append?: boolean; - /** Add transpiled output paths to .gitignore (opt-in). */ - gitignore?: boolean; -} - -/** - * Result of rule installation. - */ -export interface RuleAddResult { - /** Whether the installation succeeded. */ - success: boolean; - /** Number of rules installed. */ - rulesInstalled: number; - /** Paths of files written. */ - writtenPaths: string[]; - /** Warning/info messages for CLI output. */ - messages: string[]; - /** Error message if installation failed. */ - error?: string; -} - /** Agent name aliases for --targets flag (short names to TargetAgent). */ const AGENT_ALIASES: Record = { copilot: 'github-copilot', @@ -157,160 +117,6 @@ function filterItemsByName(items: DiscoveredItem[], names: string[]): Discovered return items.filter((item) => lowerNames.has(item.name.toLowerCase())); } -/** - * Execute the rule install flow: - * - * 1. Discover rules in source repo - * 2. Filter by --rule names - * 3. Run install pipeline (transpile, collision check, write) - * 4. Update dotai lock file on success - */ -export async function addRules(options: RuleAddOptions): Promise { - const messages: string[] = []; - - // 1. Discover rules in source repo (skip other types for performance) - const { items, warnings } = await discover(options.sourcePath, { types: ['rule'] }); - - // Surface discovery warnings - for (const warning of warnings) { - messages.push( - pc.yellow(`Warning: ${warning.message}${warning.path ? ` (${warning.path})` : ''}`) - ); - } - - // 2. Filter to rules only (items are already filtered, but filterByType is a safety net) - const allRules = filterByType(items, 'rule'); - - if (allRules.length === 0) { - return { - success: false, - rulesInstalled: 0, - writtenPaths: [], - messages, - error: 'No rules found in source repository.', - }; - } - - // 3. Filter by requested rule names - const selectedRules = filterItemsByName(allRules, options.ruleNames); - - if (selectedRules.length === 0) { - const availableNames = allRules.map((r) => r.name).join(', '); - return { - success: false, - rulesInstalled: 0, - writtenPaths: [], - messages, - error: `No matching rules found for: ${options.ruleNames.join(', ')}. Available: ${availableNames}`, - }; - } - - messages.push(`Found ${selectedRules.length} rule(s) to install`); - - // 4. Read existing lock file for collision detection - const { lock } = await readDotaiLock(options.projectRoot); - - // 5. Run install pipeline - const targets = options.targets ?? [...TARGET_AGENTS]; - const modelOverrides = await loadModelOverrides(options.projectRoot); - const result = await executeInstallPipeline(selectedRules, { - projectRoot: options.projectRoot, - targets, - source: options.source, - lockEntries: lock.items, - force: options.force, - dryRun: options.dryRun, - append: options.append, - modelOverrides, - }); - - // Report collisions - if (result.collisions.length > 0) { - for (const collision of result.collisions) { - messages.push(pc.red(`Conflict: ${collision.message}`)); - } - } - - // Report skipped items - for (const skip of result.skipped) { - messages.push(pc.yellow(`Skipped: ${skip.item.name} — ${skip.reason}`)); - } - - if (!result.success) { - return { - success: false, - rulesInstalled: 0, - writtenPaths: result.written, - messages, - error: result.error, - }; - } - - // Dry-run: report plan without writing lock - if (options.dryRun) { - for (const write of result.writes) { - messages.push(pc.dim(`Would write: ${write.planned.absolutePath}`)); - } - return { - success: true, - rulesInstalled: selectedRules.length, - writtenPaths: [], - messages, - }; - } - - // 6. Update lock file on successful write - if (result.written.length > 0) { - let updatedLock = lock; - const installedNames = new Set(); - - // Group written paths by rule name - for (const write of result.writes) { - installedNames.add(write.planned.name); - } - - for (const ruleName of installedNames) { - const ruleItem = selectedRules.find((r) => r.name === ruleName); - if (!ruleItem) continue; - - const ruleWrites = result.writes.filter((w) => w.planned.name === ruleName); - const ruleAgents = [...new Set(ruleWrites.map((w) => w.agent))]; - const outputPaths = ruleWrites.map((w) => w.planned.absolutePath); - - const entry: LockEntry = { - type: 'rule', - name: ruleName, - source: options.source, - format: ruleItem.format, - agents: ruleAgents, - hash: computeContentHash(ruleItem.rawContent), - installedAt: new Date().toISOString(), - outputs: outputPaths, - ...(options.append && { append: true }), - ...(options.gitignore && { gitignored: true }), - }; - - updatedLock = upsertLockEntry(updatedLock, entry); - } - - await writeDotaiLock(updatedLock, options.projectRoot); - messages.push(`Updated ${pc.dim('.dotai-lock.json')}`); - - // Add output paths to .gitignore when --gitignore is used - if (options.gitignore) { - await addToGitignore(options.projectRoot, result.written); - messages.push(`Updated ${pc.dim('.gitignore')} with output paths`); - } - } - - return { - success: true, - rulesInstalled: selectedRules.length, - writtenPaths: result.written, - messages, - }; -} - /** * Execute the prompt install flow: * diff --git a/src/rule-check.ts b/src/context-check.ts similarity index 85% rename from src/rule-check.ts rename to src/context-check.ts index 601fd71..ad05a2a 100644 --- a/src/rule-check.ts +++ b/src/context-check.ts @@ -2,8 +2,8 @@ import pc from 'picocolors'; import { resolve } from 'path'; import { cloneRepo, cleanupTempDir } from './git.ts'; import { parseSource } from './source-parser.ts'; -import { discover, filterByType } from './rule-discovery.ts'; -import { executeInstallPipeline } from './rule-installer.ts'; +import { discover, filterByType } from './context-discovery.ts'; +import { executeInstallPipeline } from './context-installer.ts'; import { readDotaiLock, writeDotaiLock, @@ -16,15 +16,15 @@ import { TARGET_AGENTS } from './target-agents.ts'; import type { ContextType, LockEntry, TargetAgent } from './types.ts'; // --------------------------------------------------------------------------- -// Rule, prompt, agent & instruction check/update — reads .dotai-lock.json and compares content hashes +// Context check/update — reads .dotai-lock.json and compares content hashes // -// For `dotai check`: reports which rules/prompts/agents/instructions have changed upstream. -// For `dotai update`: re-discovers, re-transpiles, and re-installs changed rules/prompts/agents/instructions. +// For `dotai check`: reports which prompts/agents/instructions have changed upstream. +// For `dotai update`: re-discovers, re-transpiles, and re-installs changed items. // // The flow per source repo: -// 1. Read lock entries grouped by source (rules, prompts, and agents) +// 1. Read lock entries grouped by source (prompts, agents, and instructions) // 2. Clone (or resolve local path to) the source repo -// 3. Discover rules/prompts/agents in the freshly fetched source +// 3. Discover prompts/agents/instructions in the freshly fetched source // 4. Compare content hashes for each locked entry // 5. (update only) Re-run install pipeline for changed entries // --------------------------------------------------------------------------- @@ -54,7 +54,7 @@ export interface RuleCheckError { /** * Result of checking for rule updates. */ -export interface RuleCheckResult { +export interface ContextCheckResult { /** Total number of rules checked. */ totalChecked: number; /** Rules with available updates. */ @@ -66,7 +66,7 @@ export interface RuleCheckResult { /** * Result of updating rules. */ -export interface RuleUpdateResult { +export interface ContextUpdateResult { /** Total number of rules checked. */ totalChecked: number; /** Number of rules successfully updated. */ @@ -82,18 +82,17 @@ export interface RuleUpdateResult { // --------------------------------------------------------------------------- /** - * Check installed rules, prompts, and agents for available updates. + * Check installed prompts, agents, and instructions for available updates. * * Reads `.dotai-lock.json`, fetches each source repo, and compares - * content hashes for all installed rules, prompts, and agents. + * content hashes for all installed prompts, agents, and instructions. */ -export async function checkRuleUpdates(projectRoot: string): Promise { +export async function checkContextUpdates(projectRoot: string): Promise { const { lock } = await readDotaiLock(projectRoot); - const ruleEntries = getLockEntriesByType(lock, 'rule'); const promptEntries = getLockEntriesByType(lock, 'prompt'); const agentEntries = getLockEntriesByType(lock, 'agent'); const instructionEntries = getLockEntriesByType(lock, 'instruction'); - const allEntries = [...ruleEntries, ...promptEntries, ...agentEntries, ...instructionEntries]; + const allEntries = [...promptEntries, ...agentEntries, ...instructionEntries]; if (allEntries.length === 0) { return { totalChecked: 0, updates: [], errors: [] }; @@ -114,7 +113,7 @@ export async function checkRuleUpdates(projectRoot: string): Promise { +export async function updateContext(projectRoot: string): Promise { const messages: string[] = []; const resolvedRoot = resolve(projectRoot); - // Read lock file and gather all rule/prompt/agent entries + // Read lock file and gather all prompt/agent/instruction entries const { lock } = await readDotaiLock(resolvedRoot); - const ruleEntries = getLockEntriesByType(lock, 'rule'); const promptEntries = getLockEntriesByType(lock, 'prompt'); const agentEntries = getLockEntriesByType(lock, 'agent'); const instructionEntries = getLockEntriesByType(lock, 'instruction'); - const allEntries = [...ruleEntries, ...promptEntries, ...agentEntries, ...instructionEntries]; + const allEntries = [...promptEntries, ...agentEntries, ...instructionEntries]; if (allEntries.length === 0) { return { totalChecked: 0, successCount: 0, failCount: 0, messages }; @@ -217,7 +213,7 @@ export async function updateRules(projectRoot: string): Promise { - const items: DiscoveredItem[] = []; - const seenNames = new Set(); - - async function tryAddRule(filePath: string): Promise { - if (items.length >= maxItems) { - return false; // Cap reached — caller will add warning - } - if (!isWithinBase(filePath, basePath)) { - return false; - } - const result = await safeReadFile(filePath, maxFileSize); - if ('error' in result) { - warnings.push({ type: 'file-too-large', message: result.error, path: filePath }); - return false; - } - - const parsed = parseRuleContent(result.content); - if (!parsed.ok) { - warnings.push({ type: 'parse-error', message: parsed.error, path: filePath }); - return false; - } - - if (seenNames.has(parsed.rule.name)) { - return false; // Duplicate name — first one wins - } - - seenNames.add(parsed.rule.name); - items.push({ - type: 'rule', - format: 'canonical', - name: parsed.rule.name, - description: parsed.rule.description, - sourcePath: filePath, - rawContent: result.content, - }); - return true; - } - - // 1. Root RULES.md - const rootRulesPath = join(basePath, 'RULES.md'); - if (await fileExists(rootRulesPath)) { - await tryAddRule(rootRulesPath); - } - - // 2. rules/*/RULES.md - const rulesDir = join(basePath, 'rules'); - const ruleDirs = await listDirs(rulesDir); - for (const dir of ruleDirs) { - if (items.length >= maxItems) { - warnings.push({ - type: 'cap-reached', - message: `discovery capped at ${maxItems} rules`, - }); - break; - } - const ruleFile = join(rulesDir, dir, 'RULES.md'); - if (await fileExists(ruleFile)) { - await tryAddRule(ruleFile); - } - } - - return items; -} - // --------------------------------------------------------------------------- // Canonical PROMPT.md discovery // --------------------------------------------------------------------------- @@ -490,11 +409,11 @@ async function discoverCanonicalInstructions( } // --------------------------------------------------------------------------- -// Native passthrough rules discovery +// Native passthrough discovery helpers // --------------------------------------------------------------------------- /** - * Derive a kebab-case name from a native rule filename. + * Derive a kebab-case name from a native filename. * Strips the agent-specific extension and returns the base name. * * Examples: @@ -511,65 +430,6 @@ function deriveNameFromFilename(filename: string, extension: string): string { return dotIndex > 0 ? filename.slice(0, dotIndex) : filename; } -async function discoverNativeRules( - basePath: string, - maxItems: number, - maxFileSize: number, - existingCount: number, - warnings: DiscoveryWarning[] -): Promise { - const items: DiscoveredItem[] = []; - const seenPaths = new Set(); - let totalCount = existingCount; - - for (const [agentName, config] of Object.entries(targetAgents)) { - const agent = agentName as TargetAgent; - const { sourceDir, pattern } = config.nativeRuleDiscovery; - const searchDir = join(basePath, sourceDir); - - const files = await listFiles(searchDir); - for (const file of files) { - if (totalCount + items.length >= maxItems) { - warnings.push({ - type: 'cap-reached', - message: `discovery capped at ${maxItems} rules (including native)`, - }); - return items; - } - - if (!matchesPattern(file, pattern)) { - continue; - } - - const filePath = join(searchDir, file); - if (seenPaths.has(filePath) || !isWithinBase(filePath, basePath)) { - continue; - } - seenPaths.add(filePath); - - const result = await safeReadFile(filePath, maxFileSize); - if ('error' in result) { - warnings.push({ type: 'file-too-large', message: result.error, path: filePath }); - continue; - } - - const name = deriveNameFromFilename(file, config.rulesConfig.extension); - const format: ContextFormat = `native:${agent}`; - - items.push({ - type: 'rule', - format, - name, - description: `Native ${config.displayName} rule`, - sourcePath: filePath, - rawContent: result.content, - }); - } - } - - return items; -} - // --------------------------------------------------------------------------- // Native passthrough prompts discovery // --------------------------------------------------------------------------- @@ -717,10 +577,9 @@ async function discoverNativeAgents( /** * Discover all canonical and native context items in a source repo. * - * Discovers `SKILL.md` (canonical), `RULES.md` (canonical + native passthrough), - * `PROMPT.md` (canonical + native passthrough), `AGENT.md` (canonical + - * native passthrough), and `INSTRUCTIONS.md` (canonical, root-only) files. - * Each item is tagged with `type` and `format`. + * Discovers `SKILL.md` (canonical), `PROMPT.md` (canonical + native passthrough), + * `AGENT.md` (canonical + native passthrough), and `INSTRUCTIONS.md` + * (canonical, root-only) files. Each item is tagged with `type` and `format`. * * Security: * - Caps discovery at `maxItemsPerType` per context type (default 500). @@ -745,36 +604,21 @@ export async function discover( // Helper that returns an empty array for skipped types const empty = (): Promise => Promise.resolve([]); - // Discover in parallel — skills, rules, prompts, agents, and instructions are independent - const [skills, canonicalRules, canonicalPrompts, canonicalAgents, canonicalInstructions] = - await Promise.all([ - typesToDiscover.has('skill') - ? discoverCanonicalSkills(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('rule') - ? discoverCanonicalRules(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('prompt') - ? discoverCanonicalPrompts(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('agent') - ? discoverCanonicalAgents(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - typesToDiscover.has('instruction') - ? discoverCanonicalInstructions(resolvedBase, maxItems, maxFileSize, warnings) - : empty(), - ]); - - // Native rules share the cap with canonical rules - const nativeRules = typesToDiscover.has('rule') - ? await discoverNativeRules( - resolvedBase, - maxItems, - maxFileSize, - canonicalRules.length, - warnings - ) - : []; + // Discover in parallel — skills, prompts, agents, and instructions are independent + const [skills, canonicalPrompts, canonicalAgents, canonicalInstructions] = await Promise.all([ + typesToDiscover.has('skill') + ? discoverCanonicalSkills(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('prompt') + ? discoverCanonicalPrompts(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('agent') + ? discoverCanonicalAgents(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + typesToDiscover.has('instruction') + ? discoverCanonicalInstructions(resolvedBase, maxItems, maxFileSize, warnings) + : empty(), + ]); // Native prompts share the cap with canonical prompts const nativePrompts = typesToDiscover.has('prompt') @@ -801,8 +645,6 @@ export async function discover( return { items: [ ...skills, - ...canonicalRules, - ...nativeRules, ...canonicalPrompts, ...nativePrompts, ...canonicalAgents, diff --git a/src/rule-installer.ts b/src/context-installer.ts similarity index 89% rename from src/rule-installer.ts rename to src/context-installer.ts index 44f732d..ad155cd 100644 --- a/src/rule-installer.ts +++ b/src/context-installer.ts @@ -7,10 +7,8 @@ import type { PlannedWrite, LockEntry, Collision, - ContextType, } from './types.ts'; import type { ModelOverrides } from './model-aliases.ts'; -import { transpileRuleForAllAgents } from './rule-transpilers.ts'; import { transpilePromptForAllAgents } from './prompt-transpilers.ts'; import { transpileAgentForAllAgents } from './agent-transpilers.ts'; import { transpileInstructionForAllAgents } from './instruction-transpilers.ts'; @@ -23,9 +21,9 @@ import { upsertSection } from './append-markers.ts'; // Orchestrates the full dotai install flow: // discover → transpile → check collisions → install // -// This module handles transpiled rule, prompt, and agent outputs. Skill installation -// uses the existing installer.ts symlink/copy semantics and is not part of -// this pipeline. +// This module handles transpiled prompt, agent, and instruction outputs. +// Skill installation uses the existing installer.ts symlink/copy semantics +// and is not part of this pipeline. // // Transactional semantics: all collision checks run before any writes. // On write failure, all files written so far are rolled back. @@ -90,15 +88,15 @@ const ALL_AGENTS: readonly TargetAgent[] = [ ] as const; /** - * Plan transpiled outputs for discovered rule and prompt items. + * Plan transpiled outputs for discovered prompt, agent, and instruction items. * - * Transpiles each rule/prompt item for all target agents and builds PlannedWrite + * Transpiles each item for all target agents and builds PlannedWrite * entries with resolved absolute paths and metadata. * * Skills are not transpiled — they use existing symlink/copy semantics * via installer.ts and are skipped here. */ -export function planRuleWrites( +export function planContextWrites( items: DiscoveredItem[], options: InstallPipelineOptions ): { writes: PipelineWrite[]; skipped: Array<{ item: DiscoveredItem; reason: string }> } { @@ -112,12 +110,7 @@ export function planRuleWrites( continue; } - if ( - item.type !== 'rule' && - item.type !== 'prompt' && - item.type !== 'agent' && - item.type !== 'instruction' - ) { + if (item.type !== 'prompt' && item.type !== 'agent' && item.type !== 'instruction') { skipped.push({ item, reason: `unsupported context type: ${item.type}` }); continue; } @@ -127,9 +120,7 @@ export function planRuleWrites( ? transpileInstructionForAllAgents(item, agents) : item.type === 'agent' ? transpileAgentForAllAgents(item, agents, options.modelOverrides) - : item.type === 'prompt' - ? transpilePromptForAllAgents(item, agents, options.modelOverrides) - : transpileRuleForAllAgents(item, agents, options.append); + : transpilePromptForAllAgents(item, agents, options.modelOverrides); if (outputs.length === 0) { skipped.push({ item, reason: 'transpilation produced no outputs' }); @@ -178,7 +169,7 @@ export async function executeInstallPipeline( const dryRun = options.dryRun ?? false; // Phase 1: Plan — transpile and build planned writes - const { writes, skipped } = planRuleWrites(items, options); + const { writes, skipped } = planContextWrites(items, options); if (writes.length === 0) { return { @@ -342,13 +333,8 @@ async function rollbackWrites(paths: string[], appendSnapshots: AppendSnapshot[] // Internal: agent resolution from TranspiledOutput // --------------------------------------------------------------------------- -/** Output directory prefixes for each target agent (rules + prompts + agents). */ +/** Output directory prefixes for each target agent (prompts + agents). */ const OUTPUT_DIR_TO_AGENT: ReadonlyArray<{ prefix: string; agent: TargetAgent }> = [ - // Rules - { prefix: '.github/instructions', agent: 'github-copilot' }, - { prefix: '.claude/rules', agent: 'claude-code' }, - { prefix: '.cursor/rules', agent: 'cursor' }, - { prefix: '.opencode/rules', agent: 'opencode' }, // Prompts { prefix: '.github/prompts', agent: 'github-copilot' }, { prefix: '.claude/commands', agent: 'claude-code' }, @@ -369,12 +355,10 @@ const APPEND_FILENAME_TO_AGENT: ReadonlyArray<{ filename: string; agent: TargetAgent; }> = [ - // Rules (append mode): Copilot → AGENTS.md, Claude → CLAUDE.md - { outputDir: '.', filename: 'AGENTS.md', agent: 'github-copilot' }, - { outputDir: '.', filename: 'CLAUDE.md', agent: 'claude-code' }, // Instructions: Copilot → .github/copilot-instructions.md { outputDir: '.github', filename: 'copilot-instructions.md', agent: 'github-copilot' }, - // Instructions: Claude → CLAUDE.md (already covered above) + // Instructions: Claude → CLAUDE.md + { outputDir: '.', filename: 'CLAUDE.md', agent: 'claude-code' }, // Instructions: Cursor + OpenCode → AGENTS.md (deduplicated by transpiler) { outputDir: '.', filename: 'AGENTS.md', agent: 'cursor' }, ]; diff --git a/src/dotai-lock.test.ts b/src/dotai-lock.test.ts index 183c9ab..c831519 100644 --- a/src/dotai-lock.test.ts +++ b/src/dotai-lock.test.ts @@ -26,7 +26,7 @@ import type { LockEntry } from './types.js'; function makeLockEntry(overrides: Partial = {}): LockEntry { return { - type: 'rule', + type: 'prompt', name: 'code-style', source: 'acme/repo', format: 'canonical', @@ -172,9 +172,9 @@ describe('readDotaiLock', () => { items: [ validEntry, { type: 'invalid-type', name: 'bad' }, // invalid type - { type: 'rule', name: '' }, // empty name + { type: 'prompt', name: '' }, // empty name { - type: 'rule', + type: 'prompt', name: 'ok', source: '', format: 'canonical', @@ -194,6 +194,30 @@ describe('readDotaiLock', () => { expect(result.lock.items[0]!.name).toBe('code-style'); }); + it('silently drops legacy rule entries from lock file', async () => { + const promptEntry = makeLockEntry({ type: 'prompt', name: 'my-prompt' }); + // Manually construct a rule entry (can't use makeLockEntry since 'rule' is + // no longer a valid ContextType) + const ruleEntry = { + type: 'rule', + name: 'my-rule', + source: 'acme/repo', + format: 'canonical', + agents: ['cursor', 'github-copilot', 'claude-code', 'opencode'], + hash: 'abc123', + installedAt: '2026-02-28T12:00:00.000Z', + outputs: ['/project/.cursor/rules/my-rule.mdc'], + }; + const lock = { version: 1, items: [promptEntry, ruleEntry] }; + await writeFile(getDotaiLockPath(tmpDir), JSON.stringify(lock), 'utf-8'); + + const result = await readDotaiLock(tmpDir); + // Rule entry should be silently dropped + expect(result.lock.items).toHaveLength(1); + expect(result.lock.items[0]!.type).toBe('prompt'); + expect(result.lock.items[0]!.name).toBe('my-prompt'); + }); + it('validates agent names in items', async () => { const badEntry = makeLockEntry({ agents: ['invalid-agent' as 'cursor'] }); const lock = { version: 1, items: [badEntry] }; @@ -257,8 +281,8 @@ describe('writeDotaiLock', () => { version: 1, items: [ makeLockEntry({ type: 'skill', name: 'zeta' }), - makeLockEntry({ type: 'rule', name: 'beta' }), - makeLockEntry({ type: 'rule', name: 'alpha' }), + makeLockEntry({ type: 'prompt', name: 'beta' }), + makeLockEntry({ type: 'prompt', name: 'alpha' }), makeLockEntry({ type: 'skill', name: 'alpha' }), ], }; @@ -268,7 +292,7 @@ describe('writeDotaiLock', () => { const content = await readFile(getDotaiLockPath(tmpDir), 'utf-8'); const parsed = JSON.parse(content) as DotaiLockFile; const keys = parsed.items.map((i) => `${i.type}:${i.name}`); - expect(keys).toEqual(['rule:alpha', 'rule:beta', 'skill:alpha', 'skill:zeta']); + expect(keys).toEqual(['prompt:alpha', 'prompt:beta', 'skill:alpha', 'skill:zeta']); }); it('appends trailing newline', async () => { @@ -315,7 +339,7 @@ describe('writeDotaiLock', () => { describe('round-trip', () => { it('preserves all lock entry fields through write/read', async () => { const entry = makeLockEntry({ - type: 'rule', + type: 'prompt', name: 'security', source: 'org/security-rules', format: 'canonical', @@ -333,8 +357,8 @@ describe('round-trip', () => { it('preserves multiple items', async () => { const items = [ - makeLockEntry({ type: 'rule', name: 'alpha' }), - makeLockEntry({ type: 'rule', name: 'beta' }), + makeLockEntry({ type: 'prompt', name: 'alpha' }), + makeLockEntry({ type: 'prompt', name: 'beta' }), makeLockEntry({ type: 'skill', name: 'gamma' }), ]; @@ -362,13 +386,13 @@ describe('round-trip', () => { expect(result.lock.items[0]).toEqual(entry); }); - it('sorts prompt entries alongside rules and skills', async () => { + it('sorts prompt entries alongside other types and skills', async () => { const lock: DotaiLockFile = { version: 1, items: [ makeLockEntry({ type: 'skill', name: 'zeta' }), makeLockEntry({ type: 'prompt', name: 'review' }), - makeLockEntry({ type: 'rule', name: 'alpha' }), + makeLockEntry({ type: 'prompt', name: 'alpha' }), makeLockEntry({ type: 'prompt', name: 'deploy' }), ], }; @@ -376,7 +400,7 @@ describe('round-trip', () => { await writeDotaiLock(lock, tmpDir); const result = await readDotaiLock(tmpDir); const keys = result.lock.items.map((i) => `${i.type}:${i.name}`); - expect(keys).toEqual(['prompt:deploy', 'prompt:review', 'rule:alpha', 'skill:zeta']); + expect(keys).toEqual(['prompt:alpha', 'prompt:deploy', 'prompt:review', 'skill:zeta']); }); }); @@ -389,36 +413,36 @@ describe('findLockEntry', () => { const lock: DotaiLockFile = { version: 1, items: [ - makeLockEntry({ type: 'rule', name: 'alpha' }), - makeLockEntry({ type: 'rule', name: 'beta' }), + makeLockEntry({ type: 'prompt', name: 'alpha' }), + makeLockEntry({ type: 'prompt', name: 'beta' }), makeLockEntry({ type: 'skill', name: 'alpha' }), ], }; - const found = findLockEntry(lock, 'rule', 'beta'); + const found = findLockEntry(lock, 'prompt', 'beta'); expect(found).toBeDefined(); expect(found!.name).toBe('beta'); - expect(found!.type).toBe('rule'); + expect(found!.type).toBe('prompt'); }); it('returns undefined when not found', () => { const lock = createEmptyLock(); - expect(findLockEntry(lock, 'rule', 'nonexistent')).toBeUndefined(); + expect(findLockEntry(lock, 'prompt', 'nonexistent')).toBeUndefined(); }); it('distinguishes between types with the same name', () => { const lock: DotaiLockFile = { version: 1, items: [ - makeLockEntry({ type: 'rule', name: 'auth', source: 'rule-source' }), + makeLockEntry({ type: 'prompt', name: 'auth', source: 'prompt-source' }), makeLockEntry({ type: 'skill', name: 'auth', source: 'skill-source' }), ], }; - const ruleEntry = findLockEntry(lock, 'rule', 'auth'); + const promptEntry = findLockEntry(lock, 'prompt', 'auth'); const skillEntry = findLockEntry(lock, 'skill', 'auth'); - expect(ruleEntry!.source).toBe('rule-source'); + expect(promptEntry!.source).toBe('prompt-source'); expect(skillEntry!.source).toBe('skill-source'); }); }); @@ -490,7 +514,7 @@ describe('removeLockEntry', () => { items: [makeLockEntry({ name: 'alpha' }), makeLockEntry({ name: 'beta' })], }; - const { lock: updated, removed } = removeLockEntry(lock, 'rule', 'alpha'); + const { lock: updated, removed } = removeLockEntry(lock, 'prompt', 'alpha'); expect(updated.items).toHaveLength(1); expect(updated.items[0]!.name).toBe('beta'); expect(removed).toBeDefined(); @@ -503,7 +527,7 @@ describe('removeLockEntry', () => { items: [makeLockEntry({ name: 'alpha' })], }; - const { lock: updated, removed } = removeLockEntry(lock, 'rule', 'nonexistent'); + const { lock: updated, removed } = removeLockEntry(lock, 'prompt', 'nonexistent'); expect(updated.items).toHaveLength(1); expect(removed).toBeUndefined(); }); @@ -514,7 +538,7 @@ describe('removeLockEntry', () => { items: [makeLockEntry()], }; - const { lock: updated } = removeLockEntry(lock, 'rule', 'code-style'); + const { lock: updated } = removeLockEntry(lock, 'prompt', 'code-style'); expect(lock.items).toHaveLength(1); expect(updated.items).toHaveLength(0); }); @@ -523,12 +547,12 @@ describe('removeLockEntry', () => { const lock: DotaiLockFile = { version: 1, items: [ - makeLockEntry({ type: 'rule', name: 'auth' }), + makeLockEntry({ type: 'prompt', name: 'auth' }), makeLockEntry({ type: 'skill', name: 'auth' }), ], }; - const { lock: updated } = removeLockEntry(lock, 'rule', 'auth'); + const { lock: updated } = removeLockEntry(lock, 'prompt', 'auth'); expect(updated.items).toHaveLength(1); expect(updated.items[0]!.type).toBe('skill'); }); @@ -543,15 +567,15 @@ describe('getLockEntriesByType', () => { const lock: DotaiLockFile = { version: 1, items: [ - makeLockEntry({ type: 'rule', name: 'a' }), + makeLockEntry({ type: 'prompt', name: 'a' }), makeLockEntry({ type: 'skill', name: 'b' }), - makeLockEntry({ type: 'rule', name: 'c' }), + makeLockEntry({ type: 'prompt', name: 'c' }), ], }; - const rules = getLockEntriesByType(lock, 'rule'); - expect(rules).toHaveLength(2); - expect(rules.map((r) => r.name)).toEqual(['a', 'c']); + const prompts = getLockEntriesByType(lock, 'prompt'); + expect(prompts).toHaveLength(2); + expect(prompts.map((r) => r.name)).toEqual(['a', 'c']); }); it('returns empty array when no entries match', () => { @@ -563,7 +587,7 @@ describe('getLockEntriesByType', () => { const lock: DotaiLockFile = { version: 1, items: [ - makeLockEntry({ type: 'rule', name: 'a' }), + makeLockEntry({ type: 'instruction', name: 'a' }), makeLockEntry({ type: 'prompt', name: 'b' }), makeLockEntry({ type: 'skill', name: 'c' }), makeLockEntry({ type: 'prompt', name: 'd' }), @@ -717,7 +741,7 @@ describe('edge cases', () => { version: 1, items: [ { - type: 'rule', + type: 'prompt', name: 'bad-outputs', source: 'acme/repo', format: 'canonical', @@ -769,7 +793,7 @@ describe('edge cases', () => { version: 1, items: [ { - type: 'rule', + type: 'prompt', name: 'bad-gitignored', source: 'acme/repo', format: 'canonical', @@ -792,7 +816,7 @@ describe('edge cases', () => { version: 1, items: [ { - type: 'rule', + type: 'prompt', name: 'bad-append', source: 'acme/repo', format: 'canonical', @@ -815,7 +839,7 @@ describe('edge cases', () => { version: 1, items: [ { - type: 'rule', + type: 'prompt', name: 'no-hash', source: 'acme/repo', format: 'canonical', diff --git a/src/dotai-lock.ts b/src/dotai-lock.ts index 8815a41..10bf2ea 100644 --- a/src/dotai-lock.ts +++ b/src/dotai-lock.ts @@ -21,7 +21,7 @@ const CURRENT_VERSION = 1; * * This file is project-scoped and always committed to version control. * Items are keyed by composite `(type, name)` to support multiple context - * types sharing the same name (e.g., a skill and a rule both named "auth"). + * types sharing the same name (e.g., a skill and a prompt both named "auth"). */ export interface DotaiLockFile { /** Schema version — reject future versions, migrate older ones. */ @@ -107,7 +107,12 @@ function validateAndMigrate(parsed: unknown): ReadLockResult { // Validate each item has required fields const validItems = (obj.items as unknown[]).filter(isValidLockEntry); - let lock: DotaiLockFile = { version, items: validItems }; + // Filter out legacy 'rule' entries — they are accepted by VALID_TYPES for + // backward compatibility (so old lock files parse without errors) but are + // silently dropped so the rest of the pipeline never sees them. + const activeItems = validItems.filter((item) => (item.type as string) !== 'rule'); + + let lock: DotaiLockFile = { version, items: activeItems }; // Run sequential migrations if needed if (version < CURRENT_VERSION) { diff --git a/src/find-discovery.test.ts b/src/find-discovery.test.ts index 6f8886f..65a18b6 100644 --- a/src/find-discovery.test.ts +++ b/src/find-discovery.test.ts @@ -45,7 +45,6 @@ describe('discoverRemoteContext', () => { it('discovers all context types', () => { const entries = [ blob('skills/react/SKILL.md'), - blob('rules/code-style/RULES.md'), blob('prompts/review-code/PROMPT.md'), blob('agents/reviewer/AGENT.md'), blob('INSTRUCTIONS.md'), @@ -53,12 +52,10 @@ describe('discoverRemoteContext', () => { const result = discoverRemoteContext(entries, 'my-repo'); expect(result.skills).toHaveLength(1); - expect(result.rules).toHaveLength(1); expect(result.prompts).toHaveLength(1); expect(result.agents).toHaveLength(1); expect(result.instructions).toHaveLength(1); - expect(result.rules[0]!.name).toBe('code-style'); expect(result.prompts[0]!.name).toBe('review-code'); expect(result.agents[0]!.name).toBe('reviewer'); expect(result.instructions[0]!.name).toBe('my-repo'); @@ -67,7 +64,6 @@ describe('discoverRemoteContext', () => { it('discovers root-level items for all types', () => { const entries = [ blob('SKILL.md'), - blob('RULES.md'), blob('PROMPT.md'), blob('AGENT.md'), blob('INSTRUCTIONS.md'), @@ -75,18 +71,11 @@ describe('discoverRemoteContext', () => { const result = discoverRemoteContext(entries, 'my-repo'); expect(result.skills).toHaveLength(1); - expect(result.rules).toHaveLength(1); expect(result.prompts).toHaveLength(1); expect(result.agents).toHaveLength(1); expect(result.instructions).toHaveLength(1); - for (const list of [ - result.skills, - result.rules, - result.prompts, - result.agents, - result.instructions, - ]) { + for (const list of [result.skills, result.prompts, result.agents, result.instructions]) { expect(list[0]!.name).toBe('my-repo'); } }); @@ -120,7 +109,6 @@ describe('discoverRemoteContext', () => { const result = discoverRemoteContext(entries); expect(result.skills).toHaveLength(0); - expect(result.rules).toHaveLength(0); expect(result.prompts).toHaveLength(0); expect(result.agents).toHaveLength(0); expect(result.instructions).toHaveLength(0); @@ -130,33 +118,32 @@ describe('discoverRemoteContext', () => { const result = discoverRemoteContext([]); expect(result.skills).toHaveLength(0); - expect(result.rules).toHaveLength(0); expect(result.prompts).toHaveLength(0); expect(result.agents).toHaveLength(0); expect(result.instructions).toHaveLength(0); }); it('ignores deeply nested context files', () => { - const entries = [blob('src/skills/react/SKILL.md'), blob('foo/rules/bar/RULES.md')]; + const entries = [blob('src/skills/react/SKILL.md'), blob('foo/prompts/bar/PROMPT.md')]; const result = discoverRemoteContext(entries); expect(result.skills).toHaveLength(0); - expect(result.rules).toHaveLength(0); + expect(result.prompts).toHaveLength(0); }); it('handles mixed root and directory items', () => { const entries = [ blob('SKILL.md'), blob('skills/react/SKILL.md'), - blob('RULES.md'), - blob('rules/code-style/RULES.md'), + blob('PROMPT.md'), + blob('prompts/review-code/PROMPT.md'), ]; const result = discoverRemoteContext(entries, 'my-repo'); expect(result.skills).toHaveLength(2); - expect(result.rules).toHaveLength(2); + expect(result.prompts).toHaveLength(2); expect(result.skills.map((s) => s.name).sort()).toEqual(['my-repo', 'react']); - expect(result.rules.map((r) => r.name).sort()).toEqual(['code-style', 'my-repo']); + expect(result.prompts.map((p) => p.name).sort()).toEqual(['my-repo', 'review-code']); }); // ----------------------------------------------------------------------- @@ -205,34 +192,10 @@ describe('discoverRemoteContext', () => { // Native agent-specific patterns // ----------------------------------------------------------------------- - it('discovers Cursor native rules', () => { - const entries = [blob('.cursor/rules/no-any.mdc')]; - const result = discoverRemoteContext(entries); - - expect(result.rules).toHaveLength(1); - expect(result.rules[0]).toEqual({ - name: 'no-any', - path: '.cursor/rules/no-any.mdc', - type: 'rule', - native: 'cursor', - }); - }); - - it('discovers Claude Code native rules, prompts, and agents', () => { - const entries = [ - blob('.claude/rules/code-style.md'), - blob('.claude/commands/deploy.md'), - blob('.claude/agents/reviewer.md'), - ]; + it('discovers Claude Code native prompts and agents', () => { + const entries = [blob('.claude/commands/deploy.md'), blob('.claude/agents/reviewer.md')]; const result = discoverRemoteContext(entries); - expect(result.rules).toHaveLength(1); - expect(result.rules[0]).toMatchObject({ - name: 'code-style', - type: 'rule', - native: 'claude-code', - }); - expect(result.prompts).toHaveLength(1); expect(result.prompts[0]).toMatchObject({ name: 'deploy', @@ -248,21 +211,13 @@ describe('discoverRemoteContext', () => { }); }); - it('discovers GitHub Copilot native rules, prompts, and agents', () => { + it('discovers GitHub Copilot native prompts and agents', () => { const entries = [ - blob('.github/instructions/testing.instructions.md'), blob('.github/prompts/review.prompt.md'), blob('.github/agents/security.agent.md'), ]; const result = discoverRemoteContext(entries); - expect(result.rules).toHaveLength(1); - expect(result.rules[0]).toMatchObject({ - name: 'testing', - type: 'rule', - native: 'github-copilot', - }); - expect(result.prompts).toHaveLength(1); expect(result.prompts[0]).toMatchObject({ name: 'review', @@ -278,47 +233,37 @@ describe('discoverRemoteContext', () => { }); }); - it('mixes canonical and native items', () => { + it('mixes canonical and native prompts', () => { const entries = [ - blob('rules/code-style/RULES.md'), - blob('.cursor/rules/no-any.mdc'), - blob('.claude/rules/imports.md'), - blob('.github/instructions/testing.instructions.md'), + blob('prompts/review-code/PROMPT.md'), + blob('.claude/commands/deploy.md'), + blob('.github/prompts/review.prompt.md'), ]; const result = discoverRemoteContext(entries); - expect(result.rules).toHaveLength(4); + expect(result.prompts).toHaveLength(3); - const canonical = result.rules.filter((r) => !r.native); - const native = result.rules.filter((r) => r.native); + const canonical = result.prompts.filter((p) => !p.native); + const native = result.prompts.filter((p) => p.native); expect(canonical).toHaveLength(1); - expect(canonical[0]!.name).toBe('code-style'); - expect(native).toHaveLength(3); - expect(native.map((r) => r.native).sort()).toEqual(['claude-code', 'cursor', 'github-copilot']); + expect(canonical[0]!.name).toBe('review-code'); + expect(native).toHaveLength(2); + expect(native.map((p) => p.native).sort()).toEqual(['claude-code', 'github-copilot']); }); it('ignores native files in subdirectories', () => { - const entries = [blob('.cursor/rules/nested/deep.mdc'), blob('.claude/rules/sub/dir.md')]; - const result = discoverRemoteContext(entries); - - expect(result.rules).toHaveLength(0); - }); - - it('ignores native files with wrong extension', () => { - const entries = [ - blob('.cursor/rules/readme.txt'), - blob('.cursor/rules/notes.md'), // .md is not .mdc for Cursor - ]; + const entries = [blob('.claude/commands/nested/deep.md'), blob('.claude/agents/sub/dir.md')]; const result = discoverRemoteContext(entries); - expect(result.rules).toHaveLength(0); + expect(result.prompts).toHaveLength(0); + expect(result.agents).toHaveLength(0); }); it('canonical items do not have native field', () => { - const entries = [blob('rules/style/RULES.md'), blob('SKILL.md')]; + const entries = [blob('prompts/review/PROMPT.md'), blob('SKILL.md')]; const result = discoverRemoteContext(entries, 'my-repo'); - expect(result.rules[0]!.native).toBeUndefined(); + expect(result.prompts[0]!.native).toBeUndefined(); expect(result.skills[0]!.native).toBeUndefined(); }); }); diff --git a/src/find-discovery.ts b/src/find-discovery.ts index 808b3fc..894fcb9 100644 --- a/src/find-discovery.ts +++ b/src/find-discovery.ts @@ -12,7 +12,6 @@ export interface RemoteContextItem { export interface RemoteContextSummary { skills: RemoteContextItem[]; - rules: RemoteContextItem[]; prompts: RemoteContextItem[]; agents: RemoteContextItem[]; instructions: RemoteContextItem[]; @@ -24,7 +23,6 @@ export interface RemoteContextSummary { */ const PATTERNS: Array<{ regex: RegExp; type: ContextType }> = [ { regex: /^(?:skills\/([^/]+)\/)?SKILL\.md$/, type: 'skill' }, - { regex: /^(?:rules\/([^/]+)\/)?RULES\.md$/, type: 'rule' }, { regex: /^(?:prompts\/([^/]+)\/)?PROMPT\.md$/, type: 'prompt' }, { regex: /^(?:agents\/([^/]+)\/)?AGENT\.md$/, type: 'agent' }, { regex: /^INSTRUCTIONS\.md$/, type: 'instruction' }, @@ -48,17 +46,6 @@ function buildNativeMatchers(): NativeMatcher[] { const matchers: NativeMatcher[] = []; for (const config of Object.values(targetAgents)) { - // Native rules - const ruleDiscovery = config.nativeRuleDiscovery; - const ruleExt = ruleDiscovery.pattern.replace('*', ''); - matchers.push({ - dirPrefix: ruleDiscovery.sourceDir + '/', - extension: ruleExt, - type: 'rule', - agentName: config.name, - agentDisplayName: config.displayName, - }); - // Native prompts if (config.nativePromptDiscovery) { const promptExt = config.nativePromptDiscovery.pattern.replace('*', ''); @@ -110,7 +97,6 @@ export function discoverRemoteContext( ): RemoteContextSummary { const summary: RemoteContextSummary = { skills: [], - rules: [], prompts: [], agents: [], instructions: [], diff --git a/src/find.ts b/src/find.ts index 09ae81f..ff770c8 100644 --- a/src/find.ts +++ b/src/find.ts @@ -270,8 +270,6 @@ async function promptContextSelection( if (summary.skills.length > 0) console.log(formatContextLine(summary.skills.length, 'skill', summary.skills)); - if (summary.rules.length > 0) - console.log(formatContextLine(summary.rules.length, 'rule', summary.rules)); if (summary.prompts.length > 0) console.log(formatContextLine(summary.prompts.length, 'prompt', summary.prompts)); if (summary.agents.length > 0) @@ -318,7 +316,6 @@ async function promptContextSelection( // "pick" — multi-select from all discovered items const allItems = [ ...summary.skills.map((i) => ({ value: i, label: formatPickLabel(i, 'skill') })), - ...summary.rules.map((i) => ({ value: i, label: formatPickLabel(i, 'rule') })), ...summary.prompts.map((i) => ({ value: i, label: formatPickLabel(i, 'prompt') })), ...summary.agents.map((i) => ({ value: i, label: formatPickLabel(i, 'agent') })), ...summary.instructions.map((i) => ({ @@ -347,7 +344,6 @@ async function promptContextSelection( const addArgs: string[] = [pkg]; const byType = { skill: [] as string[], - rule: [] as string[], prompt: [] as string[], agent: [] as string[], instruction: [] as string[], @@ -359,9 +355,6 @@ async function promptContextSelection( if (byType.skill.length > 0) { addArgs.push('--skill', ...byType.skill); } - if (byType.rule.length > 0) { - addArgs.push('--rule', ...byType.rule); - } if (byType.prompt.length > 0) { addArgs.push('--prompt', ...byType.prompt); } @@ -404,7 +397,6 @@ ${DIM} 2) npx dotai add ${RESET}`; const summary = discoverRemoteContext(tree, repoName); const allItems = [ ...summary.skills, - ...summary.rules, ...summary.prompts, ...summary.agents, ...summary.instructions, @@ -425,10 +417,9 @@ ${DIM} 2) npx dotai add ${RESET}`; byType[key].push(item); } - const typeOrder = ['skill', 'rule', 'prompt', 'agent', 'instruction'] as const; + const typeOrder = ['skill', 'prompt', 'agent', 'instruction'] as const; const typeLabels: Record = { skill: 'Skills', - rule: 'Rules', prompt: 'Prompts', agent: 'Agents', instruction: 'Instructions', @@ -447,7 +438,7 @@ ${DIM} 2) npx dotai add ${RESET}`; } console.log(`${DIM}Install with:${RESET} npx dotai add ${query}`); - console.log(`${DIM}Or specific items:${RESET} npx dotai add ${query} --rule `); + console.log(`${DIM}Or specific items:${RESET} npx dotai add ${query} --prompt `); console.log(); return; } @@ -515,7 +506,6 @@ ${DIM} 2) npx dotai add ${RESET}`; const repoName = pkg.includes('/') ? pkg.split('/')[1]! : pkg; const summary = discoverRemoteContext(tree, repoName); const totalOther = - summary.rules.length + summary.prompts.length + summary.agents.length + summary.instructions.length + diff --git a/src/import.test.ts b/src/import.test.ts deleted file mode 100644 index f826e15..0000000 --- a/src/import.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, mkdtempSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { executeImport } from './import.ts'; -import { parseRuleContent } from './rule-parser.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -let tmpDir: string; - -function createFile(relativePath: string, content: string): void { - const fullPath = join(tmpDir, relativePath); - const dir = fullPath.replace(/[/\\][^/\\]+$/, ''); - mkdirSync(dir, { recursive: true }); - writeFileSync(fullPath, content); -} - -function cursorRule(opts?: { - description?: string; - alwaysApply?: boolean; - globs?: string; - body?: string; -}): string { - const lines: string[] = ['---']; - if (opts?.description !== undefined) lines.push(`description: "${opts.description}"`); - if (opts?.globs !== undefined) lines.push(`globs: ${opts.globs}`); - lines.push(`alwaysApply: ${opts?.alwaysApply ?? false}`); - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Cursor rule body.'); - return lines.join('\n'); -} - -function claudeCodeRule(opts?: { description?: string; globs?: string[]; body?: string }): string { - const lines: string[] = ['---']; - if (opts?.description !== undefined) lines.push(`description: "${opts.description}"`); - if (opts?.globs && opts.globs.length > 0) { - lines.push('globs:'); - for (const g of opts.globs) lines.push(` - "${g}"`); - } - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Claude Code rule body.'); - return lines.join('\n'); -} - -function copilotRule(opts?: { applyTo?: string; body?: string }): string { - const lines: string[] = ['---']; - lines.push(`applyTo: "${opts?.applyTo ?? '**'}"`); - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Copilot rule body.'); - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// Setup / Teardown -// --------------------------------------------------------------------------- - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'dotai-import-test-')); -}); - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); -}); - -// --------------------------------------------------------------------------- -// Discovery + parsing -// --------------------------------------------------------------------------- - -describe('import pipeline — discovery + parsing', () => { - it('discovers Cursor rules from .cursor/rules/*.mdc', () => { - createFile('.cursor/rules/code-style.mdc', cursorRule({ description: 'Style' })); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.imported).toHaveLength(1); - expect(result.imported[0]!.agent).toBe('cursor'); - expect(result.imported[0]!.name).toBe('code-style'); - }); - - it('discovers Claude Code rules from .claude/rules/*.md', () => { - createFile('.claude/rules/testing.md', claudeCodeRule({ description: 'Testing' })); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.imported).toHaveLength(1); - expect(result.imported[0]!.agent).toBe('claude-code'); - }); - - it('discovers Copilot rules from .github/instructions/*.instructions.md', () => { - createFile( - '.github/instructions/api-patterns.instructions.md', - copilotRule({ applyTo: '*.ts' }) - ); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.imported).toHaveLength(1); - expect(result.imported[0]!.agent).toBe('github-copilot'); - expect(result.imported[0]!.name).toBe('api-patterns'); - }); - - it('discovers rules from multiple agents simultaneously', () => { - createFile('.cursor/rules/style.mdc', cursorRule({ description: 'Style' })); - createFile('.claude/rules/testing.md', claudeCodeRule({ description: 'Testing' })); - createFile( - '.github/instructions/api-patterns.instructions.md', - copilotRule({ applyTo: '*.ts' }) - ); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.imported.length).toBeGreaterThanOrEqual(3); - }); - - it('reports warning for files that fail to parse', () => { - // Use content that will cause gray-matter to throw a YAML parse error - createFile( - '.cursor/rules/bad.mdc', - '---\n: :\n - : :\n invalid: [unterminated\n---\n\nBody.' - ); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.warnings.length).toBeGreaterThan(0); - }); -}); - -// --------------------------------------------------------------------------- -// Output -// --------------------------------------------------------------------------- - -describe('import pipeline — output', () => { - it('writes rules/{name}/RULES.md for each parsed rule', () => { - createFile('.cursor/rules/code-style.mdc', cursorRule({ description: 'Style rules' })); - - executeImport({ projectRoot: tmpDir }); - - const outputPath = join(tmpDir, 'rules', 'code-style', 'RULES.md'); - expect(existsSync(outputPath)).toBe(true); - }); - - it('output files parse successfully via parseRuleContent()', () => { - createFile( - '.cursor/rules/code-style.mdc', - cursorRule({ description: 'Style', alwaysApply: true }) - ); - - executeImport({ projectRoot: tmpDir }); - - const outputPath = join(tmpDir, 'rules', 'code-style', 'RULES.md'); - const content = readFileSync(outputPath, 'utf-8'); - const parsed = parseRuleContent(content); - - expect(parsed.ok).toBe(true); - if (parsed.ok) { - expect(parsed.rule.name).toBe('code-style'); - expect(parsed.rule.description).toBe('Style'); - } - }); - - it('respects --output dir flag', () => { - createFile('.cursor/rules/test.mdc', cursorRule()); - - executeImport({ projectRoot: tmpDir, outputDir: 'custom-rules' }); - - const outputPath = join(tmpDir, 'custom-rules', 'test', 'RULES.md'); - expect(existsSync(outputPath)).toBe(true); - }); - - it('creates output directories as needed', () => { - createFile('.cursor/rules/new-rule.mdc', cursorRule()); - - executeImport({ projectRoot: tmpDir }); - - const ruleDir = join(tmpDir, 'rules', 'new-rule'); - expect(existsSync(ruleDir)).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Deduplication -// --------------------------------------------------------------------------- - -describe('import pipeline — deduplication', () => { - it('keeps first rule when same name from multiple agents', () => { - // Both will produce name "code-style" - createFile('.cursor/rules/code-style.mdc', cursorRule({ description: 'Cursor style' })); - createFile('.claude/rules/code-style.md', claudeCodeRule({ description: 'Claude style' })); - - const result = executeImport({ projectRoot: tmpDir }); - - // One imported, one warned about as duplicate - expect(result.imported).toHaveLength(1); - expect(result.warnings.some((w) => w.includes('duplicate'))).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Collision handling -// --------------------------------------------------------------------------- - -describe('import pipeline — collision handling', () => { - it('skips when rules/{name}/RULES.md already exists', () => { - createFile('.cursor/rules/existing.mdc', cursorRule()); - createFile( - 'rules/existing/RULES.md', - '---\nname: existing\ndescription: Already here\nactivation: always\n---\n\nExisting.' - ); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.skipped).toHaveLength(1); - expect(result.skipped[0]!.reason).toContain('already exists'); - }); - - it('overwrites with --force', () => { - createFile('.cursor/rules/existing.mdc', cursorRule({ description: 'New content' })); - createFile( - 'rules/existing/RULES.md', - '---\nname: existing\ndescription: Old\nactivation: always\n---\n\nOld.' - ); - - const result = executeImport({ projectRoot: tmpDir, force: true }); - - expect(result.imported).toHaveLength(1); - expect(result.skipped).toHaveLength(0); - - const content = readFileSync(join(tmpDir, 'rules', 'existing', 'RULES.md'), 'utf-8'); - expect(content).toContain('New content'); - }); - - it('reports skipped files', () => { - createFile('.cursor/rules/test.mdc', cursorRule()); - createFile( - 'rules/test/RULES.md', - '---\nname: test\ndescription: Existing\nactivation: always\n---\n\nBody.' - ); - - const result = executeImport({ projectRoot: tmpDir }); - - expect(result.skipped).toHaveLength(1); - expect(result.skipped[0]!.outputPath).toContain('test/RULES.md'); - }); -}); - -// --------------------------------------------------------------------------- -// --dry-run -// --------------------------------------------------------------------------- - -describe('import pipeline — --dry-run', () => { - it('reports what would be written', () => { - createFile('.cursor/rules/test.mdc', cursorRule()); - - const result = executeImport({ projectRoot: tmpDir, dryRun: true }); - - expect(result.imported).toHaveLength(1); - }); - - it('creates no files', () => { - createFile('.cursor/rules/test.mdc', cursorRule()); - - executeImport({ projectRoot: tmpDir, dryRun: true }); - - expect(existsSync(join(tmpDir, 'rules', 'test', 'RULES.md'))).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// --from filtering -// --------------------------------------------------------------------------- - -describe('import pipeline — --from filtering', () => { - it('only imports from specified agents', () => { - createFile('.cursor/rules/cursor-rule.mdc', cursorRule()); - createFile('.claude/rules/claude-rule.md', claudeCodeRule()); - - const result = executeImport({ projectRoot: tmpDir, from: ['cursor'] }); - - expect(result.imported).toHaveLength(1); - expect(result.imported[0]!.agent).toBe('cursor'); - }); - - it('ignores rules from other agents', () => { - createFile('.cursor/rules/test.mdc', cursorRule()); - createFile('.claude/rules/test2.md', claudeCodeRule()); - - const result = executeImport({ projectRoot: tmpDir, from: ['claude-code'] }); - - expect(result.imported).toHaveLength(1); - expect(result.imported[0]!.agent).toBe('claude-code'); - }); -}); diff --git a/src/import.ts b/src/import.ts deleted file mode 100644 index 66c39be..0000000 --- a/src/import.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; -import { join, resolve } from 'path'; -import { targetAgents } from './target-agents.ts'; -import { reverseTranspilers, serializeCanonicalRule } from './reverse-transpiler.ts'; -import type { CanonicalRule, TargetAgent } from './types.ts'; -import { RESET, DIM, TEXT, YELLOW } from './utils.ts'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface ImportOptions { - /** Project root directory (default: cwd). */ - projectRoot?: string; - /** Output directory for canonical rules (default: 'rules/'). */ - outputDir?: string; - /** Only import from these agents (default: all detected). */ - from?: TargetAgent[]; - /** Overwrite existing canonical rules. */ - force?: boolean; - /** Preview only — don't write files. */ - dryRun?: boolean; -} - -interface ImportedRule { - rule: CanonicalRule; - agent: TargetAgent; - sourcePath: string; -} - -interface ImportResult { - imported: Array<{ name: string; outputPath: string; agent: TargetAgent }>; - skipped: Array<{ name: string; outputPath: string; reason: string }>; - warnings: string[]; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * List files in a directory, returning empty array if it doesn't exist. - */ -function listFiles(dir: string): string[] { - try { - return readdirSync(dir, { withFileTypes: true }) - .filter((e) => e.isFile()) - .map((e) => e.name); - } catch { - return []; - } -} - -/** - * Match a filename against a simple glob pattern (e.g. "*.md", "*.mdc"). - */ -function matchesPattern(filename: string, pattern: string): boolean { - if (pattern.startsWith('*')) { - return filename.endsWith(pattern.slice(1)); - } - return filename === pattern; -} - -// --------------------------------------------------------------------------- -// Discovery -// --------------------------------------------------------------------------- - -function discoverNativeRuleFiles( - projectRoot: string, - fromAgents?: TargetAgent[] -): Array<{ agent: TargetAgent; filePath: string; filename: string }> { - const results: Array<{ agent: TargetAgent; filePath: string; filename: string }> = []; - - for (const [agentName, config] of Object.entries(targetAgents)) { - const agent = agentName as TargetAgent; - - // Filter by --from flag - if (fromAgents && !fromAgents.includes(agent)) { - continue; - } - - const { sourceDir, pattern } = config.nativeRuleDiscovery; - const searchDir = join(projectRoot, sourceDir); - - const files = listFiles(searchDir); - for (const file of files) { - if (matchesPattern(file, pattern)) { - results.push({ - agent, - filePath: join(searchDir, file), - filename: file, - }); - } - } - } - - return results; -} - -// --------------------------------------------------------------------------- -// Main import pipeline -// --------------------------------------------------------------------------- - -export function runImport(args: string[]): void { - const options = parseImportOptions(args); - - if (args.includes('--help') || args.includes('-h')) { - showImportHelp(); - return; - } - - const result = executeImport(options); - - // Report results - if (result.imported.length > 0) { - const verb = options.dryRun ? 'Would import' : 'Imported'; - console.log( - `${TEXT}${verb} ${result.imported.length} rule${result.imported.length === 1 ? '' : 's'} from native formats:${RESET}` - ); - for (const item of result.imported) { - console.log(` ${item.outputPath} ${DIM}(from ${item.agent})${RESET}`); - } - } - - if (result.skipped.length > 0) { - if (result.imported.length > 0) console.log(); - console.log( - `${DIM}Skipped ${result.skipped.length} rule${result.skipped.length === 1 ? '' : 's'}:${RESET}` - ); - for (const item of result.skipped) { - console.log(` ${item.outputPath} ${DIM}${item.reason}${RESET}`); - } - } - - for (const warning of result.warnings) { - console.log(`${YELLOW}warning:${RESET} ${warning}`); - } - - if (result.imported.length === 0 && result.skipped.length === 0 && result.warnings.length === 0) { - console.log(`${DIM}No native rule files found to import.${RESET}`); - } -} - -export function executeImport(options: ImportOptions = {}): ImportResult { - const projectRoot = resolve(options.projectRoot ?? process.cwd()); - const outputDir = options.outputDir ?? 'rules'; - const force = options.force ?? false; - const dryRun = options.dryRun ?? false; - - const result: ImportResult = { - imported: [], - skipped: [], - warnings: [], - }; - - // 1. Discover native rule files - const nativeFiles = discoverNativeRuleFiles(projectRoot, options.from); - - // 2. Parse each file via matching reverse transpiler - const parsedRules: ImportedRule[] = []; - for (const { agent, filePath, filename } of nativeFiles) { - const transpiler = reverseTranspilers[agent]; - let content: string; - try { - content = readFileSync(filePath, 'utf-8'); - } catch { - result.warnings.push(`failed to read ${filePath}`); - continue; - } - - const parseResult = transpiler.parse(content, filename); - if (!parseResult.ok) { - result.warnings.push(`${filePath}: ${parseResult.error}`); - continue; - } - - parsedRules.push({ rule: parseResult.rule, agent, sourcePath: filePath }); - } - - // 3. Deduplicate by name (first wins) - const seenNames = new Map(); - const uniqueRules: ImportedRule[] = []; - - for (const item of parsedRules) { - if (seenNames.has(item.rule.name)) { - const existingAgent = seenNames.get(item.rule.name)!; - result.warnings.push( - `duplicate rule name "${item.rule.name}" from ${item.agent} (already seen from ${existingAgent})` - ); - continue; - } - seenNames.set(item.rule.name, item.agent); - uniqueRules.push(item); - } - - // 4. Collision check + write - for (const { rule, agent } of uniqueRules) { - const ruleDir = join(projectRoot, outputDir, rule.name); - const outputPath = join(ruleDir, 'RULES.md'); - const displayPath = `${outputDir}/${rule.name}/RULES.md`; - - if (existsSync(outputPath) && !force) { - result.skipped.push({ - name: rule.name, - outputPath: displayPath, - reason: 'already exists (use --force to overwrite)', - }); - continue; - } - - if (!dryRun) { - mkdirSync(ruleDir, { recursive: true }); - writeFileSync(outputPath, serializeCanonicalRule(rule)); - } - - result.imported.push({ - name: rule.name, - outputPath: displayPath, - agent, - }); - } - - return result; -} - -// --------------------------------------------------------------------------- -// CLI option parsing -// --------------------------------------------------------------------------- - -export function parseImportOptions(args: string[]): ImportOptions { - const options: ImportOptions = {}; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]!; - - if (arg === '--from' && i + 1 < args.length) { - const agentNames = args[++i]!.split(',').map((a) => a.trim()); - // Resolve common shorthands - const resolved: TargetAgent[] = agentNames.map((a) => { - if (a === 'copilot') return 'github-copilot'; - if (a === 'claude') return 'claude-code'; - return a as TargetAgent; - }); - options.from = resolved; - } else if (arg === '--output' && i + 1 < args.length) { - options.outputDir = args[++i]!; - } else if (arg === '--force') { - options.force = true; - } else if (arg === '--dry-run') { - options.dryRun = true; - } - } - - return options; -} - -// --------------------------------------------------------------------------- -// Help -// --------------------------------------------------------------------------- - -function showImportHelp(): void { - console.log(` -Usage: dotai import [options] - -Convert native agent-specific rule files into canonical RULES.md format. -Discovers rules from agent directories in the current project and writes -them as canonical rules/ subdirectories. - -Options: - --from Comma-separated list of agents to import from - (default: all detected). Aliases: copilot, claude - --output Output directory for canonical rules (default: rules/) - --force Overwrite existing canonical rules with the same name - --dry-run Preview imports without writing files - -h, --help Show this help message - -Examples: - dotai import - dotai import --from cursor,claude-code - dotai import --output rules/ --dry-run - dotai import --force -`); -} diff --git a/src/init.test.ts b/src/init.test.ts index 83cfa86..04e0623 100644 --- a/src/init.test.ts +++ b/src/init.test.ts @@ -163,42 +163,6 @@ describe('init command', () => { expect(existsSync(agentPath)).toBe(true); }); - it('should initialize a rule with init rule ', () => { - const output = stripLogo(runCliOutput(['init', 'rule', 'no-console'], testDir)); - expect(output).toContain('Initialized rule: no-console'); - expect(output).toContain('no-console/RULES.md'); - - const rulePath = join(testDir, 'no-console', 'RULES.md'); - expect(existsSync(rulePath)).toBe(true); - - const content = readFileSync(rulePath, 'utf-8'); - expect(content).toContain('name: no-console'); - expect(content).toContain('description: Describe what this rule enforces'); - expect(content).toContain('globs:'); - expect(content).toContain('activation: always'); - }); - - it('should init RULES.md in cwd when no name provided for rule', () => { - const output = stripLogo(runCliOutput(['init', 'rule'], testDir)); - expect(output).toContain('Initialized rule:'); - expect(output).toContain('Created:\n RULES.md'); - expect(existsSync(join(testDir, 'RULES.md'))).toBe(true); - }); - - it('should show error if rule already exists', () => { - runCliOutput(['init', 'rule', 'existing-rule'], testDir); - const output = stripLogo(runCliOutput(['init', 'rule', 'existing-rule'], testDir)); - expect(output).toContain('Rule already exists'); - }); - - it('should support --rule flag for init', () => { - const output = stripLogo(runCliOutput(['init', '--rule', 'my-rule'], testDir)); - expect(output).toContain('Initialized rule: my-rule'); - - const rulePath = join(testDir, 'my-rule', 'RULES.md'); - expect(existsSync(rulePath)).toBe(true); - }); - it('should initialize an instruction with init instruction ', () => { const output = stripLogo(runCliOutput(['init', 'instruction', 'my-instructions'], testDir)); expect(output).toContain('Initialized instruction: my-instructions'); @@ -282,12 +246,6 @@ describe('init command', () => { expect(existsSync(join(testDir, `../${escapeName}`, 'AGENT.md'))).toBe(false); }); - it('should reject rule names with path traversal', () => { - const output = stripLogo(runCliOutput(['init', 'rule', `../${escapeName}`], testDir)); - expect(output).toContain('Invalid name'); - expect(existsSync(join(testDir, `../${escapeName}`, 'RULES.md'))).toBe(false); - }); - it('should reject names with uppercase letters', () => { const output = stripLogo(runCliOutput(['init', 'MySkill'], testDir)); expect(output).toContain('Invalid name'); diff --git a/src/init.ts b/src/init.ts index 0eb80db..5ab54f6 100644 --- a/src/init.ts +++ b/src/init.ts @@ -24,27 +24,6 @@ interface TemplateConfig { } const TEMPLATE_CONFIGS: Record = { - rule: { - file: 'RULES.md', - noun: 'rule', - generateContent: (name: string) => `--- -name: ${name} -description: Describe what this rule enforces -globs: - - '*.ts' - - '*.tsx' -activation: always ---- - -Your rule instructions here. -`, - extraNextSteps: [ - ` 3. Keep body content agent-agnostic ${DIM}(it is passed verbatim to all target agents)${RESET}`, - ], - installSection: (name: string) => - `${DIM}Installing:${RESET}\n ${DIM}From repo:${RESET} ${TEXT}npx dotai add / --rule ${name}${RESET}`, - }, - prompt: { file: 'PROMPT.md', noun: 'prompt', @@ -202,15 +181,6 @@ export function runInit(args: string[]): void { // Determine which template type to create const typeArg = args[0]; - // Rule template - if (typeArg === 'rule' || typeArg === '--rule') { - const config = TEMPLATE_CONFIGS['rule']!; - const name = args[1] || basename(cwd); - const hasName = args[1] !== undefined; - initTemplate(config, name, hasName, cwd); - return; - } - // Prompt template if (typeArg === 'prompt' || typeArg === '--prompt') { const config = TEMPLATE_CONFIGS['prompt']!; diff --git a/src/instruction-check.test.ts b/src/instruction-check.test.ts index db57628..c72587f 100644 --- a/src/instruction-check.test.ts +++ b/src/instruction-check.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; import { existsSync, readFileSync } from 'fs'; -import { checkRuleUpdates, updateRules } from './rule-check.ts'; +import { checkContextUpdates, updateContext } from './context-check.ts'; import { writeDotaiLock, createEmptyLock, @@ -68,10 +68,10 @@ function makeInstructionLockEntry( } // --------------------------------------------------------------------------- -// checkRuleUpdates — instruction entries +// checkContextUpdates — instruction entries // --------------------------------------------------------------------------- -describe('checkRuleUpdates — instructions', () => { +describe('checkContextUpdates — instructions', () => { let tempDir: string; let projectDir: string; @@ -101,7 +101,7 @@ describe('checkRuleUpdates — instructions', () => { lock = upsertLockEntry(lock, makeInstructionLockEntry('coding-standards', sourceRepo, content)); await writeDotaiLock(lock, projectDir); - const result = await checkRuleUpdates(projectDir); + const result = await checkContextUpdates(projectDir); expect(result.totalChecked).toBe(1); expect(result.updates).toHaveLength(0); @@ -129,7 +129,7 @@ describe('checkRuleUpdates — instructions', () => { ); await writeDotaiLock(lock, projectDir); - const result = await checkRuleUpdates(projectDir); + const result = await checkContextUpdates(projectDir); expect(result.totalChecked).toBe(1); expect(result.updates).toHaveLength(1); @@ -153,7 +153,7 @@ describe('checkRuleUpdates — instructions', () => { lock = upsertLockEntry(lock, makeInstructionLockEntry('old-instruction', sourceRepo, content)); await writeDotaiLock(lock, projectDir); - const result = await checkRuleUpdates(projectDir); + const result = await checkContextUpdates(projectDir); expect(result.totalChecked).toBe(1); expect(result.updates).toHaveLength(0); @@ -162,22 +162,19 @@ describe('checkRuleUpdates — instructions', () => { expect(result.errors[0]!.error).toContain('no longer found'); }); - it('checks instructions alongside rules', async () => { - // Create source repo with both a rule and an instruction + it('checks instructions alongside prompts', async () => { + // Create source repo with both a prompt and an instruction const sourceRepo = join(tempDir, 'source-repo'); await mkdir(sourceRepo, { recursive: true }); - const ruleContent = `--- + const promptContent = `--- name: code-style description: Enforce code style -globs: - - "*.ts" -activation: always --- Use const over let. `; - await writeFile(join(sourceRepo, 'RULES.md'), ruleContent); + await writeFile(join(sourceRepo, 'PROMPT.md'), promptContent); const instrContent = makeInstructionContent( 'team-standards', @@ -187,18 +184,18 @@ Use const over let. await writeFile(join(sourceRepo, 'INSTRUCTIONS.md'), instrContent); let lock = createEmptyLock(); - // Add rule entry - const ruleEntry: LockEntry = { - type: 'rule', + // Add prompt entry + const promptEntry: LockEntry = { + type: 'prompt', name: 'code-style', source: sourceRepo, format: 'canonical', agents: ['github-copilot', 'claude-code', 'cursor', 'opencode'], - hash: computeContentHash(ruleContent), + hash: computeContentHash(promptContent), installedAt: '2026-02-28T00:00:00.000Z', outputs: [], }; - lock = upsertLockEntry(lock, ruleEntry); + lock = upsertLockEntry(lock, promptEntry); // Add instruction entry lock = upsertLockEntry( lock, @@ -206,7 +203,7 @@ Use const over let. ); await writeDotaiLock(lock, projectDir); - const result = await checkRuleUpdates(projectDir); + const result = await checkContextUpdates(projectDir); expect(result.totalChecked).toBe(2); expect(result.updates).toHaveLength(0); @@ -215,10 +212,10 @@ Use const over let. }); // --------------------------------------------------------------------------- -// updateRules — instruction entries +// updateContext — instruction entries // --------------------------------------------------------------------------- -describe('updateRules — instructions', () => { +describe('updateContext — instructions', () => { let tempDir: string; let projectDir: string; @@ -248,7 +245,7 @@ describe('updateRules — instructions', () => { lock = upsertLockEntry(lock, makeInstructionLockEntry('coding-standards', sourceRepo, content)); await writeDotaiLock(lock, projectDir); - const result = await updateRules(projectDir); + const result = await updateContext(projectDir); expect(result.totalChecked).toBe(1); expect(result.successCount).toBe(0); @@ -274,7 +271,7 @@ describe('updateRules — instructions', () => { ); await writeDotaiLock(lock, projectDir); - const result = await updateRules(projectDir); + const result = await updateContext(projectDir); expect(result.totalChecked).toBe(1); expect(result.successCount).toBe(1); @@ -301,7 +298,7 @@ describe('updateRules — instructions', () => { ); await writeDotaiLock(lock, projectDir); - await updateRules(projectDir); + await updateContext(projectDir); // Instructions use append mode — verify marker sections exist in target files. // Copilot: .github/copilot-instructions.md @@ -346,7 +343,7 @@ describe('updateRules — instructions', () => { const originalHash = computeContentHash(originalContent); - await updateRules(projectDir); + await updateContext(projectDir); const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; @@ -377,7 +374,7 @@ describe('updateRules — instructions', () => { lock = upsertLockEntry(lock, entry); await writeDotaiLock(lock, projectDir); - await updateRules(projectDir); + await updateContext(projectDir); const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; diff --git a/src/instruction-commands.test.ts b/src/instruction-commands.test.ts index fc55cd7..05369cb 100644 --- a/src/instruction-commands.test.ts +++ b/src/instruction-commands.test.ts @@ -84,14 +84,14 @@ describe('list command — instruction support', () => { expect(result.exitCode).toBe(0); }); - it('should show instructions alongside rules by default', () => { + it('should show instructions alongside prompts by default', () => { writeLockWithInstructions( testDir, [{ name: 'my-instruction' }], [ { - type: 'rule', - name: 'my-rule', + type: 'prompt', + name: 'my-prompt', source: 'owner/repo', format: 'canonical', agents: ['cursor'], @@ -103,38 +103,13 @@ describe('list command — instruction support', () => { ); const result = runCli(['list'], testDir); - expect(result.stdout).toContain('Rules'); - expect(result.stdout).toContain('my-rule'); + expect(result.stdout).toContain('Prompts'); + expect(result.stdout).toContain('my-prompt'); expect(result.stdout).toContain('Instructions'); expect(result.stdout).toContain('my-instruction'); expect(result.exitCode).toBe(0); }); - it('should not show instructions when --type rule is specified', () => { - writeLockWithInstructions( - testDir, - [{ name: 'hidden-instruction' }], - [ - { - type: 'rule', - name: 'visible-rule', - source: 'owner/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'abc', - installedAt: '2025-01-01T00:00:00.000Z', - outputs: [], - }, - ] - ); - - const result = runCli(['list', '--type', 'rule'], testDir); - expect(result.stdout).toContain('visible-rule'); - expect(result.stdout).not.toContain('hidden-instruction'); - expect(result.stdout).not.toContain('Instructions'); - expect(result.exitCode).toBe(0); - }); - it('should show empty state for --type instruction with no instructions', () => { const result = runCli(['list', '--type', 'instruction'], testDir); expect(result.stdout).toContain('No project instructions found'); @@ -169,13 +144,13 @@ describe('list command — instruction support', () => { }); it('should parse comma-separated types including instruction', () => { - const options = parseListOptions(['-t', 'rule,instruction']); - expect(options.type).toEqual(['rule', 'instruction']); + const options = parseListOptions(['-t', 'skill,instruction']); + expect(options.type).toEqual(['skill', 'instruction']); }); - it('should parse all five types comma-separated', () => { - const options = parseListOptions(['-t', 'skill,rule,prompt,agent,instruction']); - expect(options.type).toEqual(['skill', 'rule', 'prompt', 'agent', 'instruction']); + it('should parse all four types comma-separated', () => { + const options = parseListOptions(['-t', 'skill,prompt,agent,instruction']); + expect(options.type).toEqual(['skill', 'prompt', 'agent', 'instruction']); }); }); }); @@ -290,7 +265,7 @@ describe('gitignore — instruction target files', () => { }); it('should not include instruction outputs in .gitignore even when other entries are gitignored', async () => { - // Simulate: add a rule with gitignore, then add an instruction + // Simulate: add a skill with gitignore, then add an instruction // The instruction outputs should not appear in .gitignore await addToGitignore(tmpDir, [join(tmpDir, '.cursor/rules/code-style.mdc')]); diff --git a/src/instruction-discovery.test.ts b/src/instruction-discovery.test.ts index baef400..dc699b3 100644 --- a/src/instruction-discovery.test.ts +++ b/src/instruction-discovery.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdir, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; -import { discover, filterByType } from './rule-discovery.ts'; +import { discover, filterByType } from './context-discovery.ts'; // --------------------------------------------------------------------------- // Helpers @@ -169,13 +169,13 @@ describe('instruction discovery', () => { ); // Add other content types to verify they are excluded await writeFile( - join(testDir, 'RULES.md'), - '---\nname: test-rule\ndescription: A rule\nactivation: auto\n---\n\nRule body' + join(testDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: A skill\n---\n\nSkill body' ); const result = await discover(testDir, { types: ['instruction'] }); expect(filterByType(result.items, 'instruction')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(0); + expect(filterByType(result.items, 'skill')).toHaveLength(0); }); it('excludes instructions when type filter does not include instruction', async () => { @@ -184,7 +184,7 @@ describe('instruction discovery', () => { instructionmd(VALID_INSTRUCTION, 'Instructions body') ); - const result = await discover(testDir, { types: ['rule'] }); + const result = await discover(testDir, { types: ['skill'] }); expect(filterByType(result.items, 'instruction')).toHaveLength(0); }); @@ -197,10 +197,6 @@ describe('instruction discovery', () => { join(testDir, 'INSTRUCTIONS.md'), instructionmd(VALID_INSTRUCTION, 'Instructions body') ); - await writeFile( - join(testDir, 'RULES.md'), - '---\nname: test-rule\ndescription: A rule\nactivation: auto\n---\n\nRule body' - ); await writeFile( join(testDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A skill\n---\n\nSkill body' @@ -208,7 +204,6 @@ describe('instruction discovery', () => { const result = await discover(testDir); expect(filterByType(result.items, 'instruction')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); expect(filterByType(result.items, 'skill')).toHaveLength(1); }); }); diff --git a/src/instruction-pipeline.test.ts b/src/instruction-pipeline.test.ts index 55be728..e533a9c 100644 --- a/src/instruction-pipeline.test.ts +++ b/src/instruction-pipeline.test.ts @@ -4,10 +4,10 @@ import { join } from 'path'; import { tmpdir } from 'os'; import type { DiscoveredItem } from './types.ts'; import { - planRuleWrites, + planContextWrites, executeInstallPipeline, type InstallPipelineOptions, -} from './rule-installer.ts'; +} from './context-installer.ts'; // --------------------------------------------------------------------------- // Helpers @@ -75,15 +75,15 @@ describe('install-pipeline — instructions', () => { }); // ------------------------------------------------------------------------- - // planRuleWrites — instruction items + // planContextWrites — instruction items // ------------------------------------------------------------------------- - describe('planRuleWrites — instructions', () => { + describe('planContextWrites — instructions', () => { it('transpiles a canonical instruction to 3 unique outputs (deduplicated)', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir); - const { writes, skipped } = planRuleWrites(items, opts); + const { writes, skipped } = planContextWrites(items, opts); expect(skipped).toHaveLength(0); // 4 agents, but cursor + opencode share AGENTS.md → 3 unique outputs @@ -94,7 +94,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); for (const write of writes) { expect(write.planned.output.mode).toBe('append'); @@ -105,7 +105,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir, { source: 'acme/repo' }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); for (const write of writes) { expect(write.planned.type).toBe('instruction'); @@ -119,7 +119,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); expect(writes).toHaveLength(1); expect(writes[0]!.agent).toBe('github-copilot'); @@ -129,7 +129,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); expect(writes[0]!.planned.absolutePath).toBe( join(tmpDir, '.github', 'copilot-instructions.md') @@ -140,7 +140,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir, { targets: ['claude-code'] }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'CLAUDE.md')); }); @@ -149,7 +149,7 @@ describe('install-pipeline — instructions', () => { const items = [canonicalInstruction('code-style')]; const opts = baseOptions(tmpDir, { targets: ['cursor'] }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'AGENTS.md')); }); @@ -162,7 +162,7 @@ describe('install-pipeline — instructions', () => { ]; const opts = baseOptions(tmpDir, { targets: ['github-copilot'] }); - const { writes } = planRuleWrites(items, opts); + const { writes } = planContextWrites(items, opts); // 2 instructions × 1 agent = 2 writes expect(writes).toHaveLength(2); diff --git a/src/instruction-transpilers.test.ts b/src/instruction-transpilers.test.ts index a63b91e..6920e7c 100644 --- a/src/instruction-transpilers.test.ts +++ b/src/instruction-transpilers.test.ts @@ -56,7 +56,7 @@ describe('canTranspile', () => { format: 'native:github-copilot', }); const skillItem = makeDiscoveredInstructionItem({ type: 'skill' }); - const ruleItem = makeDiscoveredInstructionItem({ type: 'rule' }); + const ruleItem = makeDiscoveredInstructionItem({ type: 'prompt' }); const promptItem = makeDiscoveredInstructionItem({ type: 'prompt' }); const transpilers = [ diff --git a/src/list.test.ts b/src/list.test.ts index 6bb01e2..7a57ea8 100644 --- a/src/list.test.ts +++ b/src/list.test.ts @@ -64,8 +64,8 @@ describe('list command', () => { }); it('should parse --type with single value', () => { - const options = parseListOptions(['--type', 'rule']); - expect(options.type).toEqual(['rule']); + const options = parseListOptions(['--type', 'instruction']); + expect(options.type).toEqual(['instruction']); }); it('should parse -t short flag', () => { @@ -79,57 +79,57 @@ describe('list command', () => { }); it('should parse multiple --type flags', () => { - const options = parseListOptions(['--type', 'skill', '--type', 'rule']); - expect(options.type).toEqual(['skill', 'rule']); + const options = parseListOptions(['--type', 'skill', '--type', 'instruction']); + expect(options.type).toEqual(['skill', 'instruction']); }); it('should parse comma-separated --type values', () => { - const options = parseListOptions(['--type', 'rule,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const options = parseListOptions(['--type', 'instruction,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should parse comma-separated --type with all four types', () => { - const options = parseListOptions(['-t', 'skill,rule,prompt,agent']); - expect(options.type).toEqual(['skill', 'rule', 'prompt', 'agent']); + const options = parseListOptions(['-t', 'skill,instruction,prompt,agent']); + expect(options.type).toEqual(['skill', 'instruction', 'prompt', 'agent']); }); it('should deduplicate comma-separated --type values', () => { - const options = parseListOptions(['--type', 'rule,rule,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const options = parseListOptions(['--type', 'instruction,instruction,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should deduplicate across repeated flags and comma values', () => { - const options = parseListOptions(['--type', 'rule,prompt', '--type', 'rule']); - expect(options.type).toEqual(['rule', 'prompt']); + const options = parseListOptions(['--type', 'instruction,prompt', '--type', 'instruction']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should parse --type with other flags', () => { - const options = parseListOptions(['-g', '--type', 'rule', '-a', 'cursor']); + const options = parseListOptions(['-g', '--type', 'instruction', '-a', 'cursor']); expect(options.global).toBe(true); - expect(options.type).toEqual(['rule']); + expect(options.type).toEqual(['instruction']); expect(options.targets).toEqual(['cursor']); }); it('should parse --type with other flags', () => { - const options = parseListOptions(['-g', '--type', 'rule', '-a', 'cursor']); + const options = parseListOptions(['-g', '--type', 'instruction', '-a', 'cursor']); expect(options.global).toBe(true); - expect(options.type).toEqual(['rule']); + expect(options.type).toEqual(['instruction']); expect(options.targets).toEqual(['cursor']); }); it('should normalize --type values to lowercase', () => { - const options = parseListOptions(['--type', 'RULE']); - expect(options.type).toEqual(['rule']); + const options = parseListOptions(['--type', 'INSTRUCTION']); + expect(options.type).toEqual(['instruction']); }); it('should normalize mixed-case comma-separated --type values', () => { - const options = parseListOptions(['--type', 'Rule,PROMPT,Agent']); - expect(options.type).toEqual(['rule', 'prompt', 'agent']); + const options = parseListOptions(['--type', 'Instruction,PROMPT,Agent']); + expect(options.type).toEqual(['instruction', 'prompt', 'agent']); }); it('should filter empty segments from comma-separated --type', () => { - const options = parseListOptions(['--type', 'rule,,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const options = parseListOptions(['--type', 'instruction,,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should combine -g with --type agent', () => { @@ -340,12 +340,12 @@ description: A test skill it('should show error for invalid --type value', () => { const result = runCli(['list', '--type', 'invalid'], testDir); expect(result.stdout).toContain('Invalid type: invalid'); - expect(result.stdout).toContain('Valid types: skill, rule, prompt'); + expect(result.stdout).toContain('Valid types: skill, prompt, agent, instruction'); expect(result.exitCode).toBe(1); }); it('should show error for invalid value in comma-separated --type', () => { - const result = runCli(['list', '--type', 'rule,invalid'], testDir); + const result = runCli(['list', '--type', 'instruction,invalid'], testDir); expect(result.stdout).toContain('Invalid type: invalid'); expect(result.exitCode).toBe(1); }); @@ -370,14 +370,14 @@ description: A test skill ` ); - // Create a .dotai-lock.json with a rule + // Create a .dotai-lock.json with an instruction writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', + type: 'instruction', name: 'code-style', source: 'owner/repo', format: 'canonical', @@ -393,63 +393,20 @@ description: A test skill const result = runCli(['list', '--type', 'skill'], testDir); expect(result.stdout).toContain('test-skill'); expect(result.stdout).toContain('Skills'); - expect(result.stdout).not.toContain('Rules'); + expect(result.stdout).not.toContain('Instructions'); expect(result.stdout).not.toContain('code-style'); expect(result.exitCode).toBe(0); }); - it('should show only rules with --type rule', () => { - // Create a skill - const skillDir = join(testDir, '.agents', 'skills', 'test-skill'); - mkdirSync(skillDir, { recursive: true }); - writeFileSync( - join(skillDir, 'SKILL.md'), - `--- -name: test-skill -description: A test skill ---- -# Test Skill -` - ); - - // Create a .dotai-lock.json with a rule - writeFileSync( - join(testDir, '.dotai-lock.json'), - JSON.stringify({ - version: 1, - items: [ - { - type: 'rule', - name: 'code-style', - source: 'owner/repo', - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: 'abc123', - installedAt: '2025-01-01T00:00:00.000Z', - outputs: ['.github/instructions/code-style.instructions.md'], - }, - ], - }) - ); - - const result = runCli(['list', '--type', 'rule'], testDir); - expect(result.stdout).toContain('code-style'); - expect(result.stdout).toContain('Rules'); - expect(result.stdout).toContain('owner/repo'); - expect(result.stdout).not.toContain('Skills'); - expect(result.stdout).not.toContain('test-skill'); - expect(result.exitCode).toBe(0); - }); - - it('should show rules with agent display names', () => { + it('should show instructions with agent display names', () => { writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', - name: 'testing-rule', + type: 'instruction', + name: 'testing-guidelines', source: 'test/repo', format: 'canonical', agents: ['cursor', 'opencode'], @@ -462,13 +419,13 @@ description: A test skill ); const result = runCli(['list'], testDir); - expect(result.stdout).toContain('testing-rule'); + expect(result.stdout).toContain('testing-guidelines'); expect(result.stdout).toContain('Cursor'); expect(result.stdout).toContain('OpenCode'); expect(result.exitCode).toBe(0); }); - it('should show both skills and rules by default', () => { + it('should show both skills and instructions by default', () => { // Create a skill const skillDir = join(testDir, '.agents', 'skills', 'my-skill'); mkdirSync(skillDir, { recursive: true }); @@ -482,15 +439,15 @@ description: A skill ` ); - // Create a .dotai-lock.json with a rule + // Create a .dotai-lock.json with an instruction writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', - name: 'my-rule', + type: 'instruction', + name: 'my-instruction', source: 'owner/repo', format: 'canonical', agents: ['github-copilot'], @@ -505,12 +462,12 @@ description: A skill const result = runCli(['list'], testDir); expect(result.stdout).toContain('Skills'); expect(result.stdout).toContain('my-skill'); - expect(result.stdout).toContain('Rules'); - expect(result.stdout).toContain('my-rule'); + expect(result.stdout).toContain('Instructions'); + expect(result.stdout).toContain('my-instruction'); expect(result.exitCode).toBe(0); }); - it('should show no rules section when no rules are installed', () => { + it('should show no instructions section when no instructions are installed', () => { const skillDir = join(testDir, '.agents', 'skills', 'test-skill'); mkdirSync(skillDir, { recursive: true }); writeFileSync( @@ -525,25 +482,19 @@ description: A test skill const result = runCli(['list'], testDir); expect(result.stdout).toContain('test-skill'); - expect(result.stdout).not.toContain('Rules'); - expect(result.exitCode).toBe(0); - }); - - it('should show empty state for --type rule with no rules', () => { - const result = runCli(['list', '--type', 'rule'], testDir); - expect(result.stdout).toContain('No project rules found'); + expect(result.stdout).not.toContain('Instructions'); expect(result.exitCode).toBe(0); }); - it('should filter rules by agent', () => { + it('should filter instructions by agent', () => { writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', - name: 'cursor-rule', + type: 'instruction', + name: 'cursor-instruction', source: 'owner/repo', format: 'canonical', agents: ['cursor'], @@ -552,8 +503,8 @@ description: A test skill outputs: [], }, { - type: 'rule', - name: 'opencode-rule', + type: 'instruction', + name: 'opencode-instruction', source: 'owner/repo', format: 'canonical', agents: ['opencode'], @@ -565,40 +516,13 @@ description: A test skill }) ); - const result = runCli(['list', '-t', 'rule', '-a', 'cursor'], testDir); - expect(result.stdout).toContain('cursor-rule'); - expect(result.stdout).not.toContain('opencode-rule'); - expect(result.exitCode).toBe(0); - }); - - it('should explain rules are project-scoped for --type rule -g', () => { - // Create a .dotai-lock.json with a rule in the project - writeFileSync( - join(testDir, '.dotai-lock.json'), - JSON.stringify({ - version: 1, - items: [ - { - type: 'rule', - name: 'some-rule', - source: 'owner/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'abc', - installedAt: '2025-01-01T00:00:00.000Z', - outputs: [], - }, - ], - }) - ); - - const result = runCli(['list', '--type', 'rule', '-g'], testDir); - expect(result.stdout).toContain('project-scoped'); - expect(result.stdout).not.toContain('some-rule'); + const result = runCli(['list', '-t', 'instruction', '-a', 'cursor'], testDir); + expect(result.stdout).toContain('cursor-instruction'); + expect(result.stdout).not.toContain('opencode-instruction'); expect(result.exitCode).toBe(0); }); - it('should show dim note about rules when using -g with project rules', () => { + it('should show dim note about instructions when using -g with project instructions', () => { // Create a global skill so there's output const globalSkillDir = join(homedir(), '.agents', 'skills', 'global-test-skill-list'); mkdirSync(globalSkillDir, { recursive: true }); @@ -612,15 +536,15 @@ description: A global test skill ` ); - // Create a .dotai-lock.json with a rule in the project + // Create a .dotai-lock.json with an instruction in the project writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', - name: 'project-rule', + type: 'instruction', + name: 'project-instruction', source: 'owner/repo', format: 'canonical', agents: ['cursor'], @@ -635,7 +559,7 @@ description: A global test skill try { const result = runCli(['list', '-g'], testDir); expect(result.stdout).toContain('project-scoped'); - expect(result.stdout).not.toContain('project-rule'); + expect(result.stdout).not.toContain('project-instruction'); expect(result.exitCode).toBe(0); } finally { // Clean up the global skill we created @@ -643,14 +567,14 @@ description: A global test skill } }); it('should show only prompts with --type prompt', () => { - // Create a .dotai-lock.json with a rule and a prompt + // Create a .dotai-lock.json with an instruction and a prompt writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', + type: 'instruction', name: 'code-style', source: 'owner/repo', format: 'canonical', @@ -676,20 +600,20 @@ description: A global test skill const result = runCli(['list', '--type', 'prompt'], testDir); expect(result.stdout).toContain('review-code'); expect(result.stdout).toContain('Prompts'); - expect(result.stdout).not.toContain('Rules'); + expect(result.stdout).not.toContain('Instructions'); expect(result.stdout).not.toContain('code-style'); expect(result.exitCode).toBe(0); }); - it('should show prompts alongside rules by default', () => { + it('should show prompts alongside instructions by default', () => { writeFileSync( join(testDir, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ { - type: 'rule', - name: 'my-rule', + type: 'instruction', + name: 'my-instruction', source: 'owner/repo', format: 'canonical', agents: ['cursor'], @@ -712,8 +636,8 @@ description: A global test skill ); const result = runCli(['list'], testDir); - expect(result.stdout).toContain('Rules'); - expect(result.stdout).toContain('my-rule'); + expect(result.stdout).toContain('Instructions'); + expect(result.stdout).toContain('my-instruction'); expect(result.stdout).toContain('Prompts'); expect(result.stdout).toContain('my-prompt'); expect(result.exitCode).toBe(0); @@ -781,7 +705,7 @@ description: A global test skill expect(result.stdout).toContain('dotai list'); expect(result.stdout).toContain('dotai ls -g'); expect(result.stdout).toContain('dotai ls -a claude-code'); - expect(result.stdout).toContain('dotai ls -t rule'); + expect(result.stdout).toContain('dotai ls -t prompt'); }); }); diff --git a/src/list.ts b/src/list.ts index 520e21c..9adcc4f 100644 --- a/src/list.ts +++ b/src/list.ts @@ -51,7 +51,6 @@ export async function runList(args: string[]): Promise { // Determine which types to show const showSkills = !options.type || options.type.includes('skill'); - const showRules = !options.type || options.type.includes('rule'); const showPrompts = !options.type || options.type.includes('prompt'); const showAgents = !options.type || options.type.includes('agent'); const showInstructions = !options.type || options.type.includes('instruction'); @@ -82,14 +81,14 @@ export async function runList(args: string[]): Promise { lockedSkills = await getAllLockedSkills(); } - // ── Read dotai lock file once (used for rules, prompts, agents, instructions) ── + // ── Read dotai lock file once (used for prompts, agents, instructions) ── let dotaiLock: DotaiLockFile | null = null; - if (showRules || showPrompts || showAgents || showInstructions) { + if (showPrompts || showAgents || showInstructions) { try { const { lock } = await readDotaiLock(process.cwd()); dotaiLock = lock; } catch { - // Lock file doesn't exist or is corrupt — no rules/prompts/agents to show + // Lock file doesn't exist or is corrupt — no prompts/agents to show } } @@ -104,12 +103,6 @@ export async function runList(args: string[]): Promise { return filtered; } - // ── Fetch rules (project-scoped only) ── - let ruleEntries: LockEntry[] = []; - if (showRules && !scope && dotaiLock) { - ruleEntries = filterAndSort(getLockEntriesByType(dotaiLock, 'rule')); - } - // ── Fetch prompts (project-scoped only) ── let promptEntries: LockEntry[] = []; if (showPrompts && !scope && dotaiLock) { @@ -131,46 +124,40 @@ export async function runList(args: string[]): Promise { const cwd = process.cwd(); const scopeLabel = scope ? 'Global' : 'Project'; const hasSkills = installedSkills.length > 0; - const hasRules = ruleEntries.length > 0; const hasPrompts = promptEntries.length > 0; const hasAgents = agentEntries.length > 0; const hasInstructions = instructionEntries.length > 0; // ── Empty state ── - if (!hasSkills && !hasRules && !hasPrompts && !hasAgents && !hasInstructions) { + if (!hasSkills && !hasPrompts && !hasAgents && !hasInstructions) { console.log(`${BOLD}${scopeLabel}${RESET}`); console.log(); - if (scope && (showRules || showPrompts || showAgents || showInstructions) && !showSkills) { - // User asked for --type rule/prompt/agent/instruction -g — explain they are project-scoped + if (scope && (showPrompts || showAgents || showInstructions) && !showSkills) { + // User asked for --type prompt/agent/instruction -g — explain they are project-scoped console.log( - `${DIM}Rules, prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` + `${DIM}Prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` ); return; } - if (!showSkills && showRules && !showPrompts && !showAgents) { - console.log(`${DIM}No project rules found.${RESET}`); - console.log(`${DIM}Add rules with: npx dotai add --rule ${RESET}`); - return; - } - if (!showSkills && !showRules && showPrompts && !showAgents) { + if (!showSkills && showPrompts && !showAgents) { console.log(`${DIM}No project prompts found.${RESET}`); console.log(`${DIM}Add prompts with: npx dotai add --prompt ${RESET}`); return; } - if (!showSkills && !showRules && !showPrompts && showAgents && !showInstructions) { + if (!showSkills && !showPrompts && showAgents && !showInstructions) { console.log(`${DIM}No project agents found.${RESET}`); console.log(`${DIM}Add agents with: npx dotai add --custom-agent ${RESET}`); return; } - if (!showSkills && !showRules && !showPrompts && !showAgents && showInstructions) { + if (!showSkills && !showPrompts && !showAgents && showInstructions) { console.log(`${DIM}No project instructions found.${RESET}`); console.log( `${DIM}Add instructions with: npx dotai add --instruction ${RESET}` ); return; } - if (showSkills && !showRules && !showPrompts && !showAgents && !showInstructions) { + if (showSkills && !showPrompts && !showAgents && !showInstructions) { console.log(`${DIM}No ${scopeLabel.toLowerCase()} skills found.${RESET}`); if (scope) { console.log(`${DIM}Try listing project skills without -g${RESET}`); @@ -182,7 +169,6 @@ export async function runList(args: string[]): Promise { // Default: show generic empty state console.log(`${DIM}No ${scopeLabel.toLowerCase()} context found.${RESET}`); console.log(`${DIM}Add skills with: npx dotai add ${RESET}`); - console.log(`${DIM}Add rules with: npx dotai add --rule ${RESET}`); console.log(`${DIM}Add prompts with: npx dotai add --prompt ${RESET}`); console.log( `${DIM}Add agents with: npx dotai add --custom-agent ${RESET}` @@ -271,16 +257,6 @@ export async function runList(args: string[]): Promise { } } - // ── Rules section ── - if (hasRules && showRules) { - console.log(`${BOLD}Rules${RESET}`); - console.log(); - for (const entry of ruleEntries) { - printRule(entry); - } - console.log(); - } - // ── Prompts section ── if (hasPrompts && showPrompts) { console.log(`${BOLD}Prompts${RESET}`); @@ -311,29 +287,22 @@ export async function runList(args: string[]): Promise { console.log(); } - // ── Global mode note about rules/prompts/agents/instructions ── + // ── Global mode note about prompts/agents/instructions ── if ( scope && - (showRules || showPrompts || showAgents || showInstructions) && - !hasRules && + (showPrompts || showAgents || showInstructions) && !hasPrompts && !hasAgents && !hasInstructions ) { - // Check if there are rules, prompts, agents, or instructions in the project to mention + // Check if there are prompts, agents, or instructions in the project to mention if (dotaiLock) { - const projectRules = getLockEntriesByType(dotaiLock, 'rule'); const projectPrompts = getLockEntriesByType(dotaiLock, 'prompt'); const projectAgents = getLockEntriesByType(dotaiLock, 'agent'); const projectInstructions = getLockEntriesByType(dotaiLock, 'instruction'); - if ( - projectRules.length > 0 || - projectPrompts.length > 0 || - projectAgents.length > 0 || - projectInstructions.length > 0 - ) { + if (projectPrompts.length > 0 || projectAgents.length > 0 || projectInstructions.length > 0) { console.log( - `${DIM}Rules, prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` + `${DIM}Prompts, agents, and instructions are project-scoped (use without -g to see them)${RESET}` ); console.log(); } diff --git a/src/override-parser.test.ts b/src/override-parser.test.ts index fd6c628..3f5b2dc 100644 --- a/src/override-parser.test.ts +++ b/src/override-parser.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { extractOverrides, mergeOverrides } from './override-parser.ts'; -import type { CanonicalRule, TargetAgent } from './types.ts'; +import type { CanonicalPrompt, TargetAgent } from './types.ts'; // --------------------------------------------------------------------------- // extractOverrides @@ -126,14 +126,13 @@ describe('extractOverrides', () => { // --------------------------------------------------------------------------- describe('mergeOverrides', () => { - function makeRule( - overrides?: Partial>> - ): CanonicalRule { + function makePrompt( + overrides?: Partial>> + ): CanonicalPrompt { return { - name: 'code-style', + name: 'review-code', description: 'Base description', - globs: ['*.ts'], - activation: 'auto', + tools: ['search'], schemaVersion: 1, body: 'Body text', overrides, @@ -141,14 +140,13 @@ describe('mergeOverrides', () => { } it('returns base fields unchanged when no overrides exist', () => { - const rule = makeRule(); - const merged = mergeOverrides(rule, 'github-copilot'); + const prompt = makePrompt(); + const merged = mergeOverrides(prompt, 'github-copilot'); expect(merged).toEqual({ - name: 'code-style', + name: 'review-code', description: 'Base description', - globs: ['*.ts'], - activation: 'auto', + tools: ['search'], schemaVersion: 1, body: 'Body text', }); @@ -156,42 +154,42 @@ describe('mergeOverrides', () => { }); it('returns base fields when target agent has no override', () => { - const rule = makeRule({ - cursor: { activation: 'always' }, + const prompt = makePrompt({ + cursor: { description: 'Cursor-specific' }, }); - const merged = mergeOverrides(rule, 'github-copilot'); + const merged = mergeOverrides(prompt, 'github-copilot'); - expect(merged.activation).toBe('auto'); + expect(merged.description).toBe('Base description'); expect('overrides' in merged).toBe(false); }); it('merges override fields for matching target agent', () => { - const rule = makeRule({ - 'github-copilot': { activation: 'always' }, + const prompt = makePrompt({ + 'github-copilot': { description: 'Copilot-specific' }, }); - const merged = mergeOverrides(rule, 'github-copilot'); + const merged = mergeOverrides(prompt, 'github-copilot'); - expect(merged.activation).toBe('always'); - expect(merged.description).toBe('Base description'); + expect(merged.description).toBe('Copilot-specific'); + expect(merged.name).toBe('review-code'); expect('overrides' in merged).toBe(false); }); it('merges multiple override fields', () => { - const rule = makeRule({ - 'claude-code': { severity: 'error', description: 'Claude-specific' }, + const prompt = makePrompt({ + 'claude-code': { agent: 'plan', description: 'Claude-specific' }, }); - const merged = mergeOverrides(rule, 'claude-code'); + const merged = mergeOverrides(prompt, 'claude-code'); - expect(merged.severity).toBe('error'); + expect(merged.agent).toBe('plan'); expect(merged.description).toBe('Claude-specific'); - expect(merged.activation).toBe('auto'); + expect(merged.tools).toEqual(['search']); }); it('strips overrides field from result', () => { - const rule = makeRule({ - 'github-copilot': { activation: 'always' }, + const prompt = makePrompt({ + 'github-copilot': { description: 'Copilot-specific' }, }); - const merged = mergeOverrides(rule, 'github-copilot'); + const merged = mergeOverrides(prompt, 'github-copilot'); expect('overrides' in merged).toBe(false); }); diff --git a/src/parser-parity.test.ts b/src/parser-parity.test.ts index b220d4b..aefa80e 100644 --- a/src/parser-parity.test.ts +++ b/src/parser-parity.test.ts @@ -113,11 +113,11 @@ describe('--targets parity across commands', () => { describe('--type parity between list and remove', () => { it('should parse --type with a single value', () => { - const list = parseListOptions(['--type', 'rule']); - const { options: remove } = parseRemoveOptions(['--type', 'rule']); + const list = parseListOptions(['--type', 'instruction']); + const { options: remove } = parseRemoveOptions(['--type', 'instruction']); - expect(list.type).toEqual(['rule']); - expect(remove.type).toEqual(['rule']); + expect(list.type).toEqual(['instruction']); + expect(remove.type).toEqual(['instruction']); }); it('should parse -t short flag', () => { @@ -129,69 +129,74 @@ describe('--type parity between list and remove', () => { }); it('should parse comma-separated type values', () => { - const list = parseListOptions(['--type', 'rule,prompt']); - const { options: remove } = parseRemoveOptions(['--type', 'rule,prompt']); + const list = parseListOptions(['--type', 'instruction,prompt']); + const { options: remove } = parseRemoveOptions(['--type', 'instruction,prompt']); - expect(list.type).toEqual(['rule', 'prompt']); - expect(remove.type).toEqual(['rule', 'prompt']); + expect(list.type).toEqual(['instruction', 'prompt']); + expect(remove.type).toEqual(['instruction', 'prompt']); }); it('should parse all four types comma-separated', () => { - const list = parseListOptions(['-t', 'skill,rule,prompt,agent']); - const { options: remove } = parseRemoveOptions(['-t', 'skill,rule,prompt,agent']); + const list = parseListOptions(['-t', 'skill,instruction,prompt,agent']); + const { options: remove } = parseRemoveOptions(['-t', 'skill,instruction,prompt,agent']); - expect(list.type).toEqual(['skill', 'rule', 'prompt', 'agent']); - expect(remove.type).toEqual(['skill', 'rule', 'prompt', 'agent']); + expect(list.type).toEqual(['skill', 'instruction', 'prompt', 'agent']); + expect(remove.type).toEqual(['skill', 'instruction', 'prompt', 'agent']); }); it('should normalize to lowercase', () => { - const list = parseListOptions(['--type', 'RULE']); - const { options: remove } = parseRemoveOptions(['--type', 'RULE']); + const list = parseListOptions(['--type', 'INSTRUCTION']); + const { options: remove } = parseRemoveOptions(['--type', 'INSTRUCTION']); - expect(list.type).toEqual(['rule']); - expect(remove.type).toEqual(['rule']); + expect(list.type).toEqual(['instruction']); + expect(remove.type).toEqual(['instruction']); }); it('should normalize mixed-case CSV values', () => { - const list = parseListOptions(['--type', 'Rule,PROMPT']); - const { options: remove } = parseRemoveOptions(['--type', 'Rule,PROMPT']); + const list = parseListOptions(['--type', 'Instruction,PROMPT']); + const { options: remove } = parseRemoveOptions(['--type', 'Instruction,PROMPT']); - expect(list.type).toEqual(['rule', 'prompt']); - expect(remove.type).toEqual(['rule', 'prompt']); + expect(list.type).toEqual(['instruction', 'prompt']); + expect(remove.type).toEqual(['instruction', 'prompt']); }); it('should deduplicate CSV values', () => { - const list = parseListOptions(['--type', 'rule,rule,prompt']); - const { options: remove } = parseRemoveOptions(['--type', 'rule,rule,prompt']); + const list = parseListOptions(['--type', 'instruction,instruction,prompt']); + const { options: remove } = parseRemoveOptions(['--type', 'instruction,instruction,prompt']); - expect(list.type).toEqual(['rule', 'prompt']); - expect(remove.type).toEqual(['rule', 'prompt']); + expect(list.type).toEqual(['instruction', 'prompt']); + expect(remove.type).toEqual(['instruction', 'prompt']); }); it('should deduplicate across repeated flags', () => { - const list = parseListOptions(['--type', 'rule,prompt', '--type', 'rule']); - const { options: remove } = parseRemoveOptions(['--type', 'rule,prompt', '--type', 'rule']); + const list = parseListOptions(['--type', 'instruction,prompt', '--type', 'instruction']); + const { options: remove } = parseRemoveOptions([ + '--type', + 'instruction,prompt', + '--type', + 'instruction', + ]); - expect(list.type).toEqual(['rule', 'prompt']); - expect(remove.type).toEqual(['rule', 'prompt']); + expect(list.type).toEqual(['instruction', 'prompt']); + expect(remove.type).toEqual(['instruction', 'prompt']); }); it('should filter empty segments from CSV', () => { - const list = parseListOptions(['--type', 'rule,,prompt']); - const { options: remove } = parseRemoveOptions(['--type', 'rule,,prompt']); + const list = parseListOptions(['--type', 'instruction,,prompt']); + const { options: remove } = parseRemoveOptions(['--type', 'instruction,,prompt']); - expect(list.type).toEqual(['rule', 'prompt']); - expect(remove.type).toEqual(['rule', 'prompt']); + expect(list.type).toEqual(['instruction', 'prompt']); + expect(remove.type).toEqual(['instruction', 'prompt']); }); it('should parse --type alongside --agent', () => { - const list = parseListOptions(['--type', 'rule', '-a', 'cursor']); - const { options: remove } = parseRemoveOptions(['--type', 'rule', '-a', 'cursor']); + const list = parseListOptions(['--type', 'instruction', '-a', 'cursor']); + const { options: remove } = parseRemoveOptions(['--type', 'instruction', '-a', 'cursor']); - expect(list.type).toEqual(['rule']); + expect(list.type).toEqual(['instruction']); expect(list.targets).toEqual(['cursor']); - expect(remove.type).toEqual(['rule']); + expect(remove.type).toEqual(['instruction']); expect(remove.targets).toEqual(['cursor']); }); }); @@ -217,15 +222,15 @@ describe('--type intentional differences', () => { it('remove: --type consumes only one token (not greedy)', () => { // remove's --type consumes a single token; subsequent non-flag args are // treated as positional skill names - const { skills, options } = parseRemoveOptions(['--type', 'rule', 'my-skill']); - expect(options.type).toEqual(['rule']); + const { skills, options } = parseRemoveOptions(['--type', 'instruction', 'my-skill']); + expect(options.type).toEqual(['instruction']); expect(skills).toEqual(['my-skill']); }); it('list: --type consumes multiple space-separated values (greedy)', () => { // list has no positional args, so --type can safely consume multiple values - const options = parseListOptions(['--type', 'rule', 'prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const options = parseListOptions(['--type', 'instruction', 'prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); }); diff --git a/src/prompt-transpilers.test.ts b/src/prompt-transpilers.test.ts index 2dba7ad..b501d6f 100644 --- a/src/prompt-transpilers.test.ts +++ b/src/prompt-transpilers.test.ts @@ -59,7 +59,7 @@ describe('canTranspile', () => { const canonicalPrompt = makeDiscoveredPromptItem(); const nativePrompt = makeDiscoveredPromptItem({ format: 'native:github-copilot' }); const skillItem = makeDiscoveredPromptItem({ type: 'skill' }); - const ruleItem = makeDiscoveredPromptItem({ type: 'rule' }); + const agentItem = makeDiscoveredPromptItem({ type: 'agent' }); it.each([ ['copilot', copilotPromptTranspiler], @@ -85,8 +85,8 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotPromptTranspiler], ['claude-code', claudeCodePromptTranspiler], - ] as const)('%s rejects rule items', (_name, transpiler) => { - expect(transpiler.canTranspile(ruleItem)).toBe(false); + ] as const)('%s rejects agent items', (_name, transpiler) => { + expect(transpiler.canTranspile(agentItem)).toBe(false); }); }); diff --git a/src/prompt-transpilers.ts b/src/prompt-transpilers.ts index 3c59436..8ea7210 100644 --- a/src/prompt-transpilers.ts +++ b/src/prompt-transpilers.ts @@ -9,7 +9,7 @@ import type { import { parsePromptContent } from './prompt-parser.ts'; import { getTargetAgentConfig } from './target-agents.ts'; import { resolveModel, type ModelOverrides } from './model-aliases.ts'; -import { quoteYaml } from './rule-transpilers.ts'; +import { quoteYaml } from './utils.ts'; import { mergeOverrides } from './override-parser.ts'; // --------------------------------------------------------------------------- diff --git a/src/remove.test.ts b/src/remove.test.ts index 3884d44..bf6606c 100644 --- a/src/remove.test.ts +++ b/src/remove.test.ts @@ -347,12 +347,12 @@ This is a test skill. it('should show error for invalid --type value', () => { const result = runCli(['remove', '--type', 'invalid', '-y'], testDir); expect(result.stdout).toContain('Invalid type: invalid'); - expect(result.stdout).toContain('Valid types: skill, rule, prompt, agent'); + expect(result.stdout).toContain('Valid types: skill, prompt, agent, instruction'); expect(result.exitCode).toBe(1); }); it('should show error for invalid value in comma-separated --type', () => { - const result = runCli(['remove', '--type', 'rule,invalid', '-y'], testDir); + const result = runCli(['remove', '--type', 'instruction,invalid', '-y'], testDir); expect(result.stdout).toContain('Invalid type: invalid'); expect(result.exitCode).toBe(1); }); @@ -360,8 +360,8 @@ This is a test skill. describe('parseRemoveOptions', () => { it('should parse --type with single value', () => { - const { options } = parseRemoveOptions(['--type', 'rule']); - expect(options.type).toEqual(['rule']); + const { options } = parseRemoveOptions(['--type', 'instruction']); + expect(options.type).toEqual(['instruction']); }); it('should parse -t short flag', () => { @@ -370,46 +370,57 @@ This is a test skill. }); it('should parse comma-separated --type values', () => { - const { options } = parseRemoveOptions(['--type', 'rule,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const { options } = parseRemoveOptions(['--type', 'instruction,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should parse comma-separated --type with all four types', () => { - const { options } = parseRemoveOptions(['-t', 'skill,rule,prompt,agent']); - expect(options.type).toEqual(['skill', 'rule', 'prompt', 'agent']); + const { options } = parseRemoveOptions(['-t', 'skill,instruction,prompt,agent']); + expect(options.type).toEqual(['skill', 'instruction', 'prompt', 'agent']); }); it('should deduplicate comma-separated --type values', () => { - const { options } = parseRemoveOptions(['--type', 'rule,rule,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const { options } = parseRemoveOptions(['--type', 'instruction,instruction,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should deduplicate across repeated flags and comma values', () => { - const { options } = parseRemoveOptions(['--type', 'rule,prompt', '--type', 'rule']); - expect(options.type).toEqual(['rule', 'prompt']); + const { options } = parseRemoveOptions([ + '--type', + 'instruction,prompt', + '--type', + 'instruction', + ]); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should parse multiple --type flags', () => { - const { options } = parseRemoveOptions(['--type', 'skill', '--type', 'rule']); - expect(options.type).toEqual(['skill', 'rule']); + const { options } = parseRemoveOptions(['--type', 'skill', '--type', 'instruction']); + expect(options.type).toEqual(['skill', 'instruction']); }); it('should parse --type with other flags', () => { - const { skills, options } = parseRemoveOptions(['my-skill', '-g', '--type', 'rule', '-y']); + const { skills, options } = parseRemoveOptions([ + 'my-skill', + '-g', + '--type', + 'instruction', + '-y', + ]); expect(skills).toEqual(['my-skill']); expect(options.global).toBe(true); expect(options.yes).toBe(true); - expect(options.type).toEqual(['rule']); + expect(options.type).toEqual(['instruction']); }); it('should lowercase --type values', () => { - const { options } = parseRemoveOptions(['--type', 'Rule,PROMPT']); - expect(options.type).toEqual(['rule', 'prompt']); + const { options } = parseRemoveOptions(['--type', 'Instruction,PROMPT']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should not consume positional args as type values', () => { - const { skills, options } = parseRemoveOptions(['--type', 'rule', 'my-skill']); - expect(options.type).toEqual(['rule']); + const { skills, options } = parseRemoveOptions(['--type', 'instruction', 'my-skill']); + expect(options.type).toEqual(['instruction']); expect(skills).toEqual(['my-skill']); }); @@ -430,14 +441,14 @@ This is a test skill. }); it('should filter empty segments from comma-separated --type', () => { - const { options } = parseRemoveOptions(['--type', 'rule,,prompt']); - expect(options.type).toEqual(['rule', 'prompt']); + const { options } = parseRemoveOptions(['--type', 'instruction,,prompt']); + expect(options.type).toEqual(['instruction', 'prompt']); }); it('should combine --all with --type', () => { - const { options } = parseRemoveOptions(['--all', '--type', 'rule']); + const { options } = parseRemoveOptions(['--all', '--type', 'instruction']); expect(options.all).toBe(true); - expect(options.type).toEqual(['rule']); + expect(options.type).toEqual(['instruction']); }); it('should combine --all with positional skill name', () => { @@ -458,10 +469,10 @@ This is a test skill. }); it('should throw CommandError for invalid value in comma-separated --type', () => { - expect(() => parseRemoveOptions(['--type', 'rule,bogus'])).toThrow(CommandError); + expect(() => parseRemoveOptions(['--type', 'instruction,bogus'])).toThrow(CommandError); try { - parseRemoveOptions(['--type', 'rule,bogus']); + parseRemoveOptions(['--type', 'instruction,bogus']); } catch (error) { expect(error).toBeInstanceOf(CommandError); expect((error as CommandError).exitCode).toBe(1); diff --git a/src/remove.ts b/src/remove.ts index b66f483..3b92606 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -27,36 +27,35 @@ export interface RemoveOptions { } export async function removeCommand(skillNames: string[], options: RemoveOptions) { - // If --type is specified and includes only rule/prompt/agent/instruction (not skill), use dotai-lock removal + // If --type is specified and includes only prompt/agent/instruction (not skill), use dotai-lock removal const typeFilter = options.type; const onlyDotaiTypes = typeFilter && typeFilter.length > 0 && - typeFilter.every((t) => t === 'rule' || t === 'prompt' || t === 'agent' || t === 'instruction'); + typeFilter.every((t) => t === 'prompt' || t === 'agent' || t === 'instruction'); if (onlyDotaiTypes) { await removeDotaiManagedItems( skillNames, options, - typeFilter as Array<'rule' | 'prompt' | 'agent' | 'instruction'> + typeFilter as Array<'prompt' | 'agent' | 'instruction'> ); return; } // If --type includes skill (or no type filter), run the existing skill removal flow - // and also handle rule/prompt/agent/instruction removal if those types are included + // and also handle prompt/agent/instruction removal if those types are included const includesDotaiTypes = typeFilter && - (typeFilter.includes('rule') || - typeFilter.includes('prompt') || + (typeFilter.includes('prompt') || typeFilter.includes('agent') || typeFilter.includes('instruction')); if (includesDotaiTypes) { // Remove dotai-managed items first const dotaiTypes = typeFilter.filter( - (t) => t === 'rule' || t === 'prompt' || t === 'agent' || t === 'instruction' - ) as Array<'rule' | 'prompt' | 'agent' | 'instruction'>; + (t) => t === 'prompt' || t === 'agent' || t === 'instruction' + ) as Array<'prompt' | 'agent' | 'instruction'>; await removeDotaiManagedItems(skillNames, options, dotaiTypes); } @@ -368,17 +367,17 @@ export function parseRemoveOptions(args: string[]): { skills: string[]; options: } // --------------------------------------------------------------------------- -// Dotai-managed item removal (rules + prompts + agents + instructions via .dotai-lock.json) +// Dotai-managed item removal (prompts + agents + instructions via .dotai-lock.json) // --------------------------------------------------------------------------- /** - * Remove dotai-managed items (rules, prompts, agents, and/or instructions) tracked in `.dotai-lock.json`. + * Remove dotai-managed items (prompts, agents, and/or instructions) tracked in `.dotai-lock.json`. * Deletes output files and removes entries from the lock file. */ async function removeDotaiManagedItems( names: string[], options: RemoveOptions, - types: Array<'rule' | 'prompt' | 'agent' | 'instruction'> + types: Array<'prompt' | 'agent' | 'instruction'> ): Promise { const cwd = process.cwd(); const spinner = p.spinner(); @@ -391,7 +390,7 @@ async function removeDotaiManagedItems( lock = result.lock; } catch { spinner.stop('No dotai lock file found'); - p.outro(pc.yellow('No rules, prompts, agents, or instructions found to remove.')); + p.outro(pc.yellow('No prompts, agents, or instructions found to remove.')); return; } diff --git a/src/restore.ts b/src/restore.ts index 5078b2c..0f37489 100644 --- a/src/restore.ts +++ b/src/restore.ts @@ -5,7 +5,7 @@ import { runAdd } from './add.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { getUniversalAgents } from './agents.ts'; import { readDotaiLock, getLockEntriesByType } from './dotai-lock.ts'; -import { addRules, addPrompts, addAgents } from './rule-add.ts'; +import { addPrompts, addAgents } from './context-add.ts'; import { parseSource } from './source-parser.ts'; import { cloneRepo, cleanupTempDir } from './git.ts'; import type { LockEntry, TargetAgent } from './types.ts'; @@ -13,11 +13,11 @@ import type { LockEntry, TargetAgent } from './types.ts'; /** * Install all context from lock files: * - Skills from skills-lock.json - * - Rules, prompts, and agents from .dotai-lock.json + * - Prompts and agents from .dotai-lock.json * * Groups items by source and calls the appropriate installer for each group. * Skills install to .agents/skills/ (universal agents). - * Rules, prompts, and agents install to agent-specific directories. + * Prompts and agents install to agent-specific directories. * * node_modules skills are handled via experimental_sync. */ @@ -27,7 +27,7 @@ export async function runInstallFromLock(args: string[]): Promise { // --- Phase 1: Restore skills from skills-lock.json --- const skillsFound = await restoreSkills(cwd, args); - // --- Phase 2: Restore rules, prompts, and agents from .dotai-lock.json --- + // --- Phase 2: Restore prompts and agents from .dotai-lock.json --- const contextFound = await restoreCanonicalEntries(cwd); // If nothing was found in either lock file, inform the user @@ -116,30 +116,29 @@ async function restoreSkills(cwd: string, args: string[]): Promise { } /** - * Restore rules, prompts, and agents from .dotai-lock.json. + * Restore prompts and agents from .dotai-lock.json. * * Groups entries by source so each repo is cloned only once. - * Calls addRules/addPrompts/addAgents for each source group, then cleans up temp dirs. - * Returns true if any rules, prompts, or agents were found in the lock file. + * Calls addPrompts/addAgents for each source group, then cleans up temp dirs. + * Returns true if any prompts or agents were found in the lock file. */ async function restoreCanonicalEntries(cwd: string): Promise { const { lock } = await readDotaiLock(cwd); - const ruleEntries = getLockEntriesByType(lock, 'rule'); const promptEntries = getLockEntriesByType(lock, 'prompt'); const agentEntries = getLockEntriesByType(lock, 'agent'); - if (ruleEntries.length === 0 && promptEntries.length === 0 && agentEntries.length === 0) { + if (promptEntries.length === 0 && agentEntries.length === 0) { return false; } - const totalCount = ruleEntries.length + promptEntries.length + agentEntries.length; + const totalCount = promptEntries.length + agentEntries.length; p.log.info( - `Restoring ${pc.cyan(String(totalCount))} ${describeTypes(ruleEntries.length, promptEntries.length, agentEntries.length)} from .dotai-lock.json` + `Restoring ${pc.cyan(String(totalCount))} ${describeTypes(promptEntries.length, agentEntries.length)} from .dotai-lock.json` ); // Group all entries by source - const bySource = groupBySource([...ruleEntries, ...promptEntries, ...agentEntries]); + const bySource = groupBySource([...promptEntries, ...agentEntries]); for (const [source, entries] of bySource) { let tempDir: string | undefined; @@ -181,7 +180,7 @@ async function restoreCanonicalEntries(cwd: string): Promise { } /** - * Install rules, prompts, and agents from a single source path. + * Install prompts and agents from a single source path. * * Respects the `gitignored` flag from lock entries: entries that were * originally installed with `--gitignore` are restored with the same flag @@ -197,13 +196,9 @@ async function installFromSource( entries: LockEntry[] ): Promise { // Partition entries by type and gitignored status - const ruleEntries = entries.filter((e) => e.type === 'rule'); const promptEntries = entries.filter((e) => e.type === 'prompt'); const agentEntries = entries.filter((e) => e.type === 'agent'); - // Install rules — split by gitignored status if needed - await installGroup(ruleEntries, 'rule', source, sourcePath, projectRoot); - // Install prompts — split by gitignored status if needed await installGroup(promptEntries, 'prompt', source, sourcePath, projectRoot); @@ -222,7 +217,7 @@ async function installFromSource( */ async function installGroup( entries: LockEntry[], - type: 'rule' | 'prompt' | 'agent', + type: 'prompt' | 'agent', source: string, sourcePath: string, projectRoot: string @@ -260,7 +255,7 @@ async function installGroup( */ async function installEntries( entries: LockEntry[], - type: 'rule' | 'prompt' | 'agent', + type: 'prompt' | 'agent', source: string, sourcePath: string, projectRoot: string, @@ -272,30 +267,7 @@ async function installEntries( // Compute the union of agents from all entries in this batch const agents = [...new Set(entries.flatMap((e) => e.agents))] as TargetAgent[]; - if (type === 'rule') { - const result = await addRules({ - source, - sourcePath, - projectRoot, - ruleNames: names, - force: true, // Overwrite existing — we're restoring from lock - gitignore, - append, - targets: agents, - }); - - for (const msg of result.messages) { - p.log.message(msg); - } - - if (result.success) { - p.log.success( - `Restored ${pc.cyan(String(result.rulesInstalled))} rule${result.rulesInstalled !== 1 ? 's' : ''} from ${pc.dim(source)}` - ); - } else if (result.error) { - p.log.error(`Rules from ${pc.cyan(source)}: ${result.error}`); - } - } else if (type === 'prompt') { + if (type === 'prompt') { const result = await addPrompts({ source, sourcePath, @@ -361,9 +333,8 @@ function groupBySource(entries: LockEntry[]): Map { /** * Describe the types being restored for human-readable output. */ -function describeTypes(ruleCount: number, promptCount: number, agentCount: number): string { +function describeTypes(promptCount: number, agentCount: number): string { const parts: string[] = []; - if (ruleCount > 0) parts.push(`rule${ruleCount !== 1 ? 's' : ''}`); if (promptCount > 0) parts.push(`prompt${promptCount !== 1 ? 's' : ''}`); if (agentCount > 0) parts.push(`agent${agentCount !== 1 ? 's' : ''}`); return parts.join(', ').replace(/, ([^,]+)$/, ' and $1'); diff --git a/src/reverse-transpiler.test.ts b/src/reverse-transpiler.test.ts deleted file mode 100644 index 0ac714e..0000000 --- a/src/reverse-transpiler.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { toKebabCase, serializeCanonicalRule, reverseTranspilers } from './reverse-transpiler.ts'; -import { parseRuleContent } from './rule-parser.ts'; -import { - cursorRuleTranspiler, - claudeCodeRuleTranspiler, - copilotRuleTranspiler, -} from './rule-transpilers.ts'; -import type { CanonicalRule } from './types.ts'; - -// --------------------------------------------------------------------------- -// Native rule content factories -// --------------------------------------------------------------------------- - -function cursorRule(opts?: { - description?: string; - alwaysApply?: boolean; - globs?: string; - body?: string; -}): string { - const lines: string[] = ['---']; - if (opts?.description !== undefined) lines.push(`description: "${opts.description}"`); - if (opts?.globs !== undefined) lines.push(`globs: ${opts.globs}`); - lines.push(`alwaysApply: ${opts?.alwaysApply ?? false}`); - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Cursor rule body.'); - return lines.join('\n'); -} - -function claudeCodeRule(opts?: { description?: string; globs?: string[]; body?: string }): string { - const lines: string[] = ['---']; - if (opts?.description !== undefined) lines.push(`description: "${opts.description}"`); - if (opts?.globs && opts.globs.length > 0) { - lines.push('globs:'); - for (const g of opts.globs) { - lines.push(` - "${g}"`); - } - } - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Claude Code rule body.'); - return lines.join('\n'); -} - -function copilotRule(opts?: { applyTo?: string; body?: string }): string { - const lines: string[] = ['---']; - lines.push(`applyTo: "${opts?.applyTo ?? '**'}"`); - lines.push('---'); - lines.push(''); - lines.push(opts?.body ?? 'Copilot rule body.'); - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// toKebabCase -// --------------------------------------------------------------------------- - -describe('toKebabCase', () => { - it('converts filenames with spaces', () => { - expect(toKebabCase('My Rule.md')).toBe('my-rule'); - }); - - it('converts filenames with underscores', () => { - expect(toKebabCase('my_rule.md')).toBe('my-rule'); - }); - - it('converts filenames with dots', () => { - expect(toKebabCase('my.rule.md')).toBe('my-rule'); - }); - - it('strips .mdc extension', () => { - expect(toKebabCase('code-style.mdc')).toBe('code-style'); - }); - - it('strips .md extension', () => { - expect(toKebabCase('code-style.md')).toBe('code-style'); - }); - - it('strips .instructions.md extension', () => { - expect(toKebabCase('code-style.instructions.md')).toBe('code-style'); - }); - - it('strips specific extension when provided', () => { - expect(toKebabCase('code-style.instructions.md', '.instructions.md')).toBe('code-style'); - }); - - it('collapses consecutive hyphens', () => { - expect(toKebabCase('my--rule.md')).toBe('my-rule'); - }); - - it('lowercases mixed-case names', () => { - expect(toKebabCase('MyRule.md')).toBe('myrule'); - }); - - it('handles names with multiple spaces and underscores', () => { - expect(toKebabCase('My Cool_Rule.md')).toBe('my-cool-rule'); - }); -}); - -// --------------------------------------------------------------------------- -// serializeCanonicalRule -// --------------------------------------------------------------------------- - -describe('serializeCanonicalRule', () => { - function makeRule(overrides: Partial = {}): CanonicalRule { - return { - name: 'test-rule', - description: 'A test rule', - globs: [], - activation: 'always', - schemaVersion: 1, - body: 'Rule body content.', - ...overrides, - }; - } - - it('produces valid YAML frontmatter + markdown body', () => { - const rule = makeRule(); - const content = serializeCanonicalRule(rule); - - expect(content).toContain('---'); - expect(content).toContain('name: test-rule'); - expect(content).toContain('description: "A test rule"'); - expect(content).toContain('activation: always'); - expect(content).toContain('Rule body content.'); - }); - - it('round-trips through parseRuleContent()', () => { - const rule = makeRule({ - description: 'Enforce code style', - globs: ['*.ts', '*.tsx'], - activation: 'glob', - body: '## Style\n\nUse const.', - }); - - const serialized = serializeCanonicalRule(rule); - const parsed = parseRuleContent(serialized); - - expect(parsed.ok).toBe(true); - if (!parsed.ok) return; - - expect(parsed.rule.name).toBe(rule.name); - expect(parsed.rule.description).toBe(rule.description); - expect(parsed.rule.globs).toEqual(rule.globs); - expect(parsed.rule.activation).toBe(rule.activation); - expect(parsed.rule.body).toBe(rule.body); - }); - - it('quotes descriptions with special YAML chars', () => { - const rule = makeRule({ description: 'Use this: always follow "rules"' }); - const content = serializeCanonicalRule(rule); - - expect(content).toContain('description: "Use this: always follow \\"rules\\""'); - - // Verify it still round-trips - const parsed = parseRuleContent(content); - expect(parsed.ok).toBe(true); - if (parsed.ok) { - expect(parsed.rule.description).toBe('Use this: always follow "rules"'); - } - }); - - it('omits globs array when empty', () => { - const rule = makeRule({ globs: [] }); - const content = serializeCanonicalRule(rule); - - expect(content).not.toMatch(/^globs:/m); - }); - - it('serializes non-empty globs as YAML array', () => { - const rule = makeRule({ globs: ['*.ts', '*.tsx'] }); - const content = serializeCanonicalRule(rule); - - expect(content).toContain('globs:'); - expect(content).toContain(" - '*.ts'"); - expect(content).toContain(" - '*.tsx'"); - }); -}); - -// --------------------------------------------------------------------------- -// Cursor reverse parser -// --------------------------------------------------------------------------- - -describe('Cursor reverse parser', () => { - const parser = reverseTranspilers['cursor']; - - it('extracts description from frontmatter', () => { - const content = cursorRule({ description: 'Enforce style' }); - const result = parser.parse(content, 'code-style.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.description).toBe('Enforce style'); - } - }); - - it('alwaysApply: true → activation: always', () => { - const content = cursorRule({ alwaysApply: true }); - const result = parser.parse(content, 'test.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('always'); - } - }); - - it('alwaysApply: false + globs → activation: glob, splits comma-separated globs', () => { - const content = cursorRule({ alwaysApply: false, globs: '*.ts, *.tsx' }); - const result = parser.parse(content, 'test.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('glob'); - expect(result.rule.globs).toEqual(['*.ts', '*.tsx']); - } - }); - - it('alwaysApply: false + no globs → activation: auto', () => { - const content = cursorRule({ alwaysApply: false }); - const result = parser.parse(content, 'test.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('auto'); - } - }); - - it('derives kebab-case name from .mdc filename', () => { - const content = cursorRule(); - const result = parser.parse(content, 'My_Rule.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.name).toBe('my-rule'); - } - }); - - it('preserves markdown body', () => { - const content = cursorRule({ body: '## Style\n\nUse const.' }); - const result = parser.parse(content, 'test.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.body).toBe('## Style\n\nUse const.'); - } - }); - - it('uses placeholder description when missing', () => { - const content = '---\nalwaysApply: true\n---\n\nBody.'; - const result = parser.parse(content, 'test.mdc'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.description).toBe('Imported from Cursor'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Claude Code reverse parser -// --------------------------------------------------------------------------- - -describe('Claude Code reverse parser', () => { - const parser = reverseTranspilers['claude-code']; - - it('extracts description from frontmatter', () => { - const content = claudeCodeRule({ description: 'Enforce style' }); - const result = parser.parse(content, 'code-style.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.description).toBe('Enforce style'); - } - }); - - it('globs present → activation: glob', () => { - const content = claudeCodeRule({ globs: ['*.ts', '*.tsx'] }); - const result = parser.parse(content, 'test.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('glob'); - expect(result.rule.globs).toEqual(['*.ts', '*.tsx']); - } - }); - - it('no globs → activation: always', () => { - const content = claudeCodeRule(); - const result = parser.parse(content, 'test.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('always'); - } - }); - - it('preserves globs array as-is', () => { - const content = claudeCodeRule({ globs: ['src/**/*.ts', 'lib/**/*.js'] }); - const result = parser.parse(content, 'test.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.globs).toEqual(['src/**/*.ts', 'lib/**/*.js']); - } - }); - - it('derives name from .md filename', () => { - const content = claudeCodeRule(); - const result = parser.parse(content, 'code-style.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.name).toBe('code-style'); - } - }); - - it('preserves markdown body', () => { - const content = claudeCodeRule({ body: '## Instructions\n\nDo this.' }); - const result = parser.parse(content, 'test.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.body).toBe('## Instructions\n\nDo this.'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Copilot reverse parser -// --------------------------------------------------------------------------- - -describe('Copilot reverse parser', () => { - const parser = reverseTranspilers['github-copilot']; - - it('applyTo: "**" → activation: always', () => { - const content = copilotRule({ applyTo: '**' }); - const result = parser.parse(content, 'code-style.instructions.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('always'); - } - }); - - it('applyTo: "*.ts, *.tsx" → activation: glob, splits and trims', () => { - const content = copilotRule({ applyTo: '*.ts, *.tsx' }); - const result = parser.parse(content, 'code-style.instructions.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.activation).toBe('glob'); - expect(result.rule.globs).toEqual(['*.ts', '*.tsx']); - } - }); - - it('uses placeholder description', () => { - const content = copilotRule(); - const result = parser.parse(content, 'test.instructions.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.description).toBe('Imported from GitHub Copilot'); - } - }); - - it('strips .instructions.md from filename for name', () => { - const content = copilotRule(); - const result = parser.parse(content, 'code-style.instructions.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.name).toBe('code-style'); - } - }); - - it('preserves markdown body', () => { - const content = copilotRule({ body: 'Follow these instructions.' }); - const result = parser.parse(content, 'test.instructions.md'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.rule.body).toBe('Follow these instructions.'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Round-trip: forward → reverse -// --------------------------------------------------------------------------- - -describe('round-trip: forward → reverse', () => { - function makeRule(overrides: Partial = {}): CanonicalRule { - return { - name: 'test-rule', - description: 'A test rule for round-tripping', - globs: ['*.ts'], - activation: 'glob', - schemaVersion: 1, - body: '## Instructions\n\nFollow these guidelines.', - ...overrides, - }; - } - - it('Cursor round-trip (lossy: auto/manual collapse to auto)', () => { - const original = makeRule({ activation: 'always', globs: [] }); - const transpiled = cursorRuleTranspiler.transform(original, 'cursor'); - const reversed = reverseTranspilers['cursor'].parse(transpiled.content, 'test-rule.mdc'); - - expect(reversed.ok).toBe(true); - if (!reversed.ok) return; - - expect(reversed.rule.name).toBe(original.name); - expect(reversed.rule.activation).toBe('always'); - expect(reversed.rule.body).toBe(original.body); - }); - - it('Cursor round-trip preserves glob activation', () => { - const original = makeRule({ activation: 'glob', globs: ['*.ts', '*.tsx'] }); - const transpiled = cursorRuleTranspiler.transform(original, 'cursor'); - const reversed = reverseTranspilers['cursor'].parse(transpiled.content, 'test-rule.mdc'); - - expect(reversed.ok).toBe(true); - if (!reversed.ok) return; - - expect(reversed.rule.activation).toBe('glob'); - expect(reversed.rule.globs).toEqual(['*.ts', '*.tsx']); - }); - - it('Claude Code round-trip (lossy: always/auto collapse to always)', () => { - const original = makeRule({ activation: 'always', globs: [] }); - const transpiled = claudeCodeRuleTranspiler.transform(original, 'claude-code'); - const reversed = reverseTranspilers['claude-code'].parse(transpiled.content, 'test-rule.md'); - - expect(reversed.ok).toBe(true); - if (!reversed.ok) return; - - expect(reversed.rule.name).toBe(original.name); - expect(reversed.rule.activation).toBe('always'); - expect(reversed.rule.body).toBe(original.body); - }); - - it('Copilot round-trip (lossy: description lost)', () => { - const original = makeRule({ activation: 'glob', globs: ['*.ts', '*.tsx'] }); - const transpiled = copilotRuleTranspiler.transform(original, 'github-copilot'); - const reversed = reverseTranspilers['github-copilot'].parse( - transpiled.content, - 'test-rule.instructions.md' - ); - - expect(reversed.ok).toBe(true); - if (!reversed.ok) return; - - expect(reversed.rule.name).toBe(original.name); - expect(reversed.rule.description).toBe('Imported from GitHub Copilot'); // lossy - expect(reversed.rule.activation).toBe('glob'); - expect(reversed.rule.globs).toEqual(['*.ts', '*.tsx']); - }); -}); - -// --------------------------------------------------------------------------- -// Registry -// --------------------------------------------------------------------------- - -describe('reverseTranspilers registry', () => { - it('has entries for all 4 target agents', () => { - expect(Object.keys(reverseTranspilers).sort()).toEqual([ - 'claude-code', - 'cursor', - 'github-copilot', - 'opencode', - ]); - }); - - it('all entries have parse method', () => { - for (const transpiler of Object.values(reverseTranspilers)) { - expect(typeof transpiler.parse).toBe('function'); - } - }); -}); diff --git a/src/reverse-transpiler.ts b/src/reverse-transpiler.ts deleted file mode 100644 index 6fc98ba..0000000 --- a/src/reverse-transpiler.ts +++ /dev/null @@ -1,314 +0,0 @@ -import matter from 'gray-matter'; -import type { CanonicalRule, RuleActivation, TargetAgent } from './types.ts'; -import { quoteYaml } from './rule-transpilers.ts'; - -// --------------------------------------------------------------------------- -// Reverse transpilers — native agent rule files → canonical CanonicalRule -// -// Each reverse transpiler reads an agent's native rule format and produces -// a CanonicalRule that can be serialized as a valid RULES.md file. -// --------------------------------------------------------------------------- - -/** - * Result of reverse-parsing a native rule file. - */ -export type ReverseParseResult = { ok: true; rule: CanonicalRule } | { ok: false; error: string }; - -/** - * A reverse transpiler reads an agent's native rule format and produces - * a CanonicalRule. - */ -export interface ReverseTranspiler { - agent: TargetAgent; - parse(content: string, filename: string): ReverseParseResult; -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -/** - * Convert a filename to a kebab-case name suitable for canonical rules. - * Strips the given extension (or common extensions), lowercases, - * and replaces spaces/underscores/dots with hyphens. - */ -export function toKebabCase(filename: string, extension?: string): string { - let stem = filename; - - // Strip the specific extension if provided - if (extension && stem.endsWith(extension)) { - stem = stem.slice(0, -extension.length); - } else { - // Strip common extensions in order of specificity - const extensions = ['.instructions.md', '.mdc', '.md']; - for (const ext of extensions) { - if (stem.endsWith(ext)) { - stem = stem.slice(0, -ext.length); - break; - } - } - } - - return stem - .toLowerCase() - .replace(/[\s_.]+/g, '-') // spaces, underscores, dots → hyphens - .replace(/-{2,}/g, '-') // collapse consecutive hyphens - .replace(/^-|-$/g, ''); // trim leading/trailing hyphens -} - -/** - * Serialize a CanonicalRule into valid RULES.md content with YAML frontmatter. - * The output round-trips through `parseRuleContent()`. - */ -export function serializeCanonicalRule(rule: CanonicalRule): string { - const lines: string[] = ['---']; - lines.push(`name: ${rule.name}`); - lines.push(`description: ${quoteYaml(rule.description)}`); - - if (rule.globs.length > 0) { - lines.push('globs:'); - for (const glob of rule.globs) { - lines.push(` - '${glob}'`); - } - } - - lines.push(`activation: ${rule.activation}`); - lines.push('---'); - lines.push(''); - - if (rule.body) { - lines.push(rule.body); - lines.push(''); - } - - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// Cursor reverse parser (.cursor/rules/*.mdc) -// --------------------------------------------------------------------------- - -/** - * Pre-process Cursor .mdc content to quote the globs field. - * Cursor uses `globs: *.ts, *.tsx` which is invalid YAML (the * is - * interpreted as a YAML alias). We quote the value before parsing. - */ -function quoteCursorGlobs(content: string): string { - return content.replace(/^(globs:\s*)(.+)$/m, (_match, prefix: string, value: string) => { - // Already quoted - if (value.startsWith('"') || value.startsWith("'")) return _match; - return `${prefix}"${value}"`; - }); -} - -const cursorReverseTranspiler: ReverseTranspiler = { - agent: 'cursor', - - parse(content: string, filename: string): ReverseParseResult { - let data: Record; - let body: string; - try { - const parsed = matter(quoteCursorGlobs(content)); - data = parsed.data; - body = parsed.content.trim(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `invalid frontmatter: ${message}` }; - } - - const name = toKebabCase(filename, '.mdc'); - if (!name) { - return { ok: false, error: 'could not derive name from filename' }; - } - - const description = - typeof data.description === 'string' && data.description.length > 0 - ? data.description - : `Imported from Cursor`; - - // Activation mapping: - // alwaysApply: true → always - // alwaysApply: false + globs → glob - // alwaysApply: false + no globs → auto - let activation: RuleActivation; - let globs: string[] = []; - - if (data.alwaysApply === true) { - activation = 'always'; - } else if (typeof data.globs === 'string' && data.globs.length > 0) { - activation = 'glob'; - globs = data.globs - .split(',') - .map((g: string) => g.trim()) - .filter((g: string) => g.length > 0); - } else { - activation = 'auto'; - } - - return { - ok: true, - rule: { - name, - description, - globs, - activation, - schemaVersion: 1, - body, - }, - }; - }, -}; - -// --------------------------------------------------------------------------- -// Claude Code reverse parser (.claude/rules/*.md) -// --------------------------------------------------------------------------- - -const claudeCodeReverseTranspiler: ReverseTranspiler = { - agent: 'claude-code', - - parse(content: string, filename: string): ReverseParseResult { - let data: Record; - let body: string; - try { - const parsed = matter(content); - data = parsed.data; - body = parsed.content.trim(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `invalid frontmatter: ${message}` }; - } - - const name = toKebabCase(filename, '.md'); - if (!name) { - return { ok: false, error: 'could not derive name from filename' }; - } - - const description = - typeof data.description === 'string' && data.description.length > 0 - ? data.description - : `Imported from Claude Code`; - - // Claude Code: globs present → glob; absent → always - let activation: RuleActivation; - let globs: string[] = []; - - if (Array.isArray(data.globs) && data.globs.length > 0) { - activation = 'glob'; - globs = data.globs.filter((g): g is string => typeof g === 'string'); - } else { - activation = 'always'; - } - - return { - ok: true, - rule: { - name, - description, - globs, - activation, - schemaVersion: 1, - body, - }, - }; - }, -}; - -// --------------------------------------------------------------------------- -// Copilot reverse parser (.github/instructions/*.instructions.md) -// --------------------------------------------------------------------------- - -const copilotReverseTranspiler: ReverseTranspiler = { - agent: 'github-copilot', - - parse(content: string, filename: string): ReverseParseResult { - let data: Record; - let body: string; - try { - const parsed = matter(content); - data = parsed.data; - body = parsed.content.trim(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `invalid frontmatter: ${message}` }; - } - - const name = toKebabCase(filename, '.instructions.md'); - if (!name) { - return { ok: false, error: 'could not derive name from filename' }; - } - - const description = 'Imported from GitHub Copilot'; - - // applyTo: "**" → always; otherwise → glob - let activation: RuleActivation; - let globs: string[] = []; - - const applyTo = typeof data.applyTo === 'string' ? data.applyTo : '**'; - - if (applyTo === '**') { - activation = 'always'; - } else { - activation = 'glob'; - globs = applyTo - .split(',') - .map((g) => g.trim()) - .filter((g) => g.length > 0); - } - - return { - ok: true, - rule: { - name, - description, - globs, - activation, - schemaVersion: 1, - body, - }, - }; - }, -}; - -// --------------------------------------------------------------------------- -// OpenCode reverse parser (.opencode/rules/*.md) -// -// OpenCode rule files are plain markdown with no YAML frontmatter. -// The entire content is treated as the body. Name is derived from -// the filename. -// --------------------------------------------------------------------------- - -const opencodeReverseTranspiler: ReverseTranspiler = { - agent: 'opencode', - - parse(content: string, filename: string): ReverseParseResult { - const name = toKebabCase(filename, '.md'); - if (!name) { - return { ok: false, error: 'could not derive name from file' }; - } - - const body = content.trim(); - - return { - ok: true, - rule: { - name, - description: 'Imported from OpenCode', - globs: [], - activation: 'always', - schemaVersion: 1, - body, - }, - }; - }, -}; - -// --------------------------------------------------------------------------- -// Registry -// --------------------------------------------------------------------------- - -export const reverseTranspilers: Record = { - cursor: cursorReverseTranspiler, - 'claude-code': claudeCodeReverseTranspiler, - 'github-copilot': copilotReverseTranspiler, - opencode: opencodeReverseTranspiler, -}; diff --git a/src/rule-check.test.ts b/src/rule-check.test.ts deleted file mode 100644 index b66dca3..0000000 --- a/src/rule-check.test.ts +++ /dev/null @@ -1,766 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { checkRuleUpdates, updateRules } from './rule-check.ts'; -import { - writeDotaiLock, - createEmptyLock, - upsertLockEntry, - computeContentHash, -} from './dotai-lock.ts'; -import type { DotaiLockFile } from './dotai-lock.ts'; -import type { LockEntry, TargetAgent } from './types.ts'; -import { existsSync, readFileSync } from 'fs'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Create a temp directory for tests. */ -async function createTempDir(): Promise { - return mkdtemp(join(tmpdir(), 'rule-check-test-')); -} - -/** Create a canonical RULES.md file with standard frontmatter. */ -function makeRulesContent(name: string, description: string, body: string): string { - return `--- -name: ${name} -description: ${description} -globs: - - "*.ts" -activation: always ---- - -${body} -`; -} - -/** Create a source repo directory structure with canonical rules. */ -async function createSourceRepo( - tempDir: string, - rules: Array<{ name: string; description: string; body: string }> -): Promise { - const repoDir = join(tempDir, 'source-repo'); - await mkdir(repoDir, { recursive: true }); - - if (rules.length === 1) { - // Single rule at root - const rule = rules[0]!; - await writeFile( - join(repoDir, 'RULES.md'), - makeRulesContent(rule.name, rule.description, rule.body) - ); - } else { - // Multiple rules in rules/ subdirectories - const rulesDir = join(repoDir, 'rules'); - await mkdir(rulesDir, { recursive: true }); - for (const rule of rules) { - const ruleDir = join(rulesDir, rule.name); - await mkdir(ruleDir, { recursive: true }); - await writeFile( - join(ruleDir, 'RULES.md'), - makeRulesContent(rule.name, rule.description, rule.body) - ); - } - } - - return repoDir; -} - -/** Create a lock entry for a rule. */ -function makeLockEntry( - name: string, - source: string, - rawContent: string, - agents: TargetAgent[] = ['github-copilot', 'claude-code', 'cursor', 'opencode'] -): LockEntry { - return { - type: 'rule', - name, - source, - format: 'canonical', - agents, - hash: computeContentHash(rawContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: agents.map((a) => `/project/.${a}/rules/${name}.md`), - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('checkRuleUpdates', () => { - let tempDir: string; - let projectDir: string; - - beforeEach(async () => { - tempDir = await createTempDir(); - projectDir = join(tempDir, 'project'); - await mkdir(projectDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('returns empty result when no rules in lock file', async () => { - await writeDotaiLock(createEmptyLock(), projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(0); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('returns empty result when lock file does not exist', async () => { - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(0); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('detects no updates when content is unchanged', async () => { - const content = makeRulesContent('code-style', 'Enforce code style', 'Use const over let'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }, - ]); - - // Write lock file with matching hash - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, content)); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('detects update when content has changed', async () => { - const originalContent = makeRulesContent( - 'code-style', - 'Enforce code style', - 'Use const over let' - ); - // Source has updated content - await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Updated: Use const always' }, - ]); - - let lock = createEmptyLock(); - const sourceRepo = join(tempDir, 'source-repo'); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, originalContent)); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(1); - expect(result.updates[0]!.entry.name).toBe('code-style'); - expect(result.updates[0]!.currentHash).toBe(computeContentHash(originalContent)); - expect(result.updates[0]!.latestHash).not.toBe(result.updates[0]!.currentHash); - }); - - it('reports error when source repo does not exist', async () => { - const content = makeRulesContent('code-style', 'Enforce code style', 'body'); - const nonExistentPath = join(tempDir, 'does-not-exist'); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', nonExistentPath, content)); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(0); - // Rule is no longer found in the (empty) source - expect(result.errors).toHaveLength(1); - expect(result.errors[0]!.entry.name).toBe('code-style'); - }); - - it('reports error when rule is no longer in source', async () => { - const content = makeRulesContent('old-rule', 'Old rule', 'old body'); - // Source repo has a different rule - await createSourceRepo(tempDir, [ - { name: 'new-rule', description: 'New rule', body: 'new body' }, - ]); - - let lock = createEmptyLock(); - const sourceRepo = join(tempDir, 'source-repo'); - lock = upsertLockEntry(lock, makeLockEntry('old-rule', sourceRepo, content)); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]!.error).toContain('no longer found'); - }); - - it('handles multiple rules from same source', async () => { - const content1 = makeRulesContent('rule-a', 'Rule A', 'body a'); - const content2 = makeRulesContent('rule-b', 'Rule B', 'body b'); - - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'rule-a', description: 'Rule A', body: 'body a' }, - { name: 'rule-b', description: 'Rule B', body: 'body b CHANGED' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('rule-a', sourceRepo, content1)); - lock = upsertLockEntry(lock, makeLockEntry('rule-b', sourceRepo, content2)); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(2); - expect(result.updates).toHaveLength(1); - expect(result.updates[0]!.entry.name).toBe('rule-b'); - expect(result.errors).toHaveLength(0); - }); - - it('only checks rule entries (not skills)', async () => { - let lock = createEmptyLock(); - const skillEntry: LockEntry = { - type: 'skill', - name: 'my-skill', - source: join(tempDir, 'source-repo'), - format: 'canonical', - agents: ['github-copilot'], - hash: 'abc123', - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [], - }; - lock = upsertLockEntry(lock, skillEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(0); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('checks prompt entries alongside rules', async () => { - const ruleContent = makeRulesContent('code-style', 'Enforce code style', 'Use const'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const' }, - ]); - - // Create a prompt in the same source repo - const promptContent = `--- -name: review-code -description: Review code for issues ---- - -Review the code for bugs. -`; - const promptDir = join(sourceRepo, 'prompts', 'review-code'); - await mkdir(promptDir, { recursive: true }); - await writeFile(join(promptDir, 'PROMPT.md'), promptContent); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, ruleContent)); - - const promptEntry: LockEntry = { - type: 'prompt', - name: 'review-code', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(promptContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: ['/project/.github/prompts/review-code.prompt.md'], - }; - lock = upsertLockEntry(lock, promptEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - // Should check both the rule and the prompt - expect(result.totalChecked).toBe(2); - expect(result.errors).toHaveLength(0); - }); - - it('detects prompt update when content has changed', async () => { - const sourceRepo = join(tempDir, 'source-repo'); - await mkdir(sourceRepo, { recursive: true }); - - // Source has updated prompt content - const updatedPromptContent = `--- -name: review-code -description: Review code for issues ---- - -Review the code for bugs and security issues. -`; - await writeFile(join(sourceRepo, 'PROMPT.md'), updatedPromptContent); - - const originalPromptContent = `--- -name: review-code -description: Review code for issues ---- - -Review the code for bugs. -`; - - let lock = createEmptyLock(); - const promptEntry: LockEntry = { - type: 'prompt', - name: 'review-code', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(originalPromptContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: ['/project/.github/prompts/review-code.prompt.md'], - }; - lock = upsertLockEntry(lock, promptEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(1); - expect(result.updates[0]!.entry.name).toBe('review-code'); - expect(result.updates[0]!.entry.type).toBe('prompt'); - }); - - it('checks agent entries alongside rules and prompts', async () => { - const ruleContent = makeRulesContent('code-style', 'Enforce code style', 'Use const'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const' }, - ]); - - // Create an agent in the same source repo - const agentContent = `--- -name: architect -description: Senior architect for code review ---- - -You are a senior software architect. -`; - const agentDir = join(sourceRepo, 'agents', 'architect'); - await mkdir(agentDir, { recursive: true }); - await writeFile(join(agentDir, 'AGENT.md'), agentContent); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, ruleContent)); - - const agentEntry: LockEntry = { - type: 'agent', - name: 'architect', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(agentContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [join(projectDir, '.github', 'agents', 'architect.agent.md')], - }; - lock = upsertLockEntry(lock, agentEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - // Should check both the rule and the agent - expect(result.totalChecked).toBe(2); - expect(result.errors).toHaveLength(0); - }); - - it('detects agent update when content has changed', async () => { - const sourceRepo = join(tempDir, 'source-repo'); - await mkdir(sourceRepo, { recursive: true }); - - // Source has updated agent content - const updatedAgentContent = `--- -name: architect -description: Senior architect for code review ---- - -You are a senior software architect. Focus on security. -`; - await writeFile(join(sourceRepo, 'AGENT.md'), updatedAgentContent); - - const originalAgentContent = `--- -name: architect -description: Senior architect for code review ---- - -You are a senior software architect. -`; - - let lock = createEmptyLock(); - const agentEntry: LockEntry = { - type: 'agent', - name: 'architect', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(originalAgentContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [join(projectDir, '.github', 'agents', 'architect.agent.md')], - }; - lock = upsertLockEntry(lock, agentEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(1); - expect(result.updates[0]!.entry.name).toBe('architect'); - expect(result.updates[0]!.entry.type).toBe('agent'); - }); - - it('reports error when agent is no longer in source', async () => { - const sourceRepo = join(tempDir, 'source-repo'); - await mkdir(sourceRepo, { recursive: true }); - - // Source has a different agent, not the one in lock - const differentAgent = `--- -name: reviewer -description: Code reviewer ---- - -Review code. -`; - await writeFile(join(sourceRepo, 'AGENT.md'), differentAgent); - - const originalAgent = `--- -name: architect -description: Senior architect ---- - -Architect things. -`; - - let lock = createEmptyLock(); - const agentEntry: LockEntry = { - type: 'agent', - name: 'architect', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(originalAgent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [], - }; - lock = upsertLockEntry(lock, agentEntry); - await writeDotaiLock(lock, projectDir); - - const result = await checkRuleUpdates(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]!.error).toContain('Agent'); - expect(result.errors[0]!.error).toContain('no longer found'); - }); -}); - -describe('updateRules', () => { - let tempDir: string; - let projectDir: string; - - beforeEach(async () => { - tempDir = await createTempDir(); - projectDir = join(tempDir, 'project'); - await mkdir(projectDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('returns empty result when no rules in lock file', async () => { - await writeDotaiLock(createEmptyLock(), projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(0); - expect(result.successCount).toBe(0); - expect(result.failCount).toBe(0); - }); - - it('reports all up to date when content is unchanged', async () => { - const content = makeRulesContent('code-style', 'Enforce code style', 'Use const'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, content)); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.successCount).toBe(0); - expect(result.failCount).toBe(0); - }); - - it('updates rule when content has changed', async () => { - const originalContent = makeRulesContent('code-style', 'Enforce code style', 'Use const'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const ALWAYS' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, originalContent)); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.successCount).toBe(1); - expect(result.failCount).toBe(0); - expect(result.messages.some((m) => m.includes('Updated: code-style'))).toBe(true); - }); - - it('writes updated transpiled files to disk', async () => { - const originalContent = makeRulesContent('code-style', 'Enforce code style', 'Old body'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'New body content' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, originalContent)); - await writeDotaiLock(lock, projectDir); - - await updateRules(projectDir); - - // Verify transpiled files exist for all 4 agents - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - - // Verify updated content is in transpiled output - const cursorContent = readFileSync( - join(projectDir, '.cursor', 'rules', 'code-style.mdc'), - 'utf-8' - ); - expect(cursorContent).toContain('New body content'); - }); - - it('updates lock file with new hash', async () => { - const originalContent = makeRulesContent('code-style', 'Enforce code style', 'Old body'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'New body' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, originalContent)); - await writeDotaiLock(lock, projectDir); - - const originalHash = computeContentHash(originalContent); - - await updateRules(projectDir); - - // Read updated lock file - const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); - const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; - - const updatedEntry = updatedLock.items.find((i) => i.name === 'code-style'); - expect(updatedEntry).toBeDefined(); - expect(updatedEntry!.hash).not.toBe(originalHash); - }); - - it('preserves installedAt on update', async () => { - const originalContent = makeRulesContent('code-style', 'Enforce code style', 'Old'); - const originalInstalledAt = '2025-01-01T00:00:00.000Z'; - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'New' }, - ]); - - const entry = makeLockEntry('code-style', sourceRepo, originalContent); - entry.installedAt = originalInstalledAt; - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, entry); - await writeDotaiLock(lock, projectDir); - - await updateRules(projectDir); - - const updatedLockContent = readFileSync(join(projectDir, '.dotai-lock.json'), 'utf-8'); - const updatedLock = JSON.parse(updatedLockContent) as DotaiLockFile; - - const updatedEntry = updatedLock.items.find((i) => i.name === 'code-style'); - expect(updatedEntry!.installedAt).toBe(originalInstalledAt); - }); - - it('does not write lock file if no updates', async () => { - const content = makeRulesContent('code-style', 'Enforce code style', 'Same body'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Same body' }, - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('code-style', sourceRepo, content)); - await writeDotaiLock(lock, projectDir); - - // Record lock file modification time - const { statSync } = await import('fs'); - const mtimeBefore = statSync(join(projectDir, '.dotai-lock.json')).mtimeMs; - - // Small delay to ensure mtime would change if file were written - await new Promise((r) => setTimeout(r, 50)); - - await updateRules(projectDir); - - const mtimeAfter = statSync(join(projectDir, '.dotai-lock.json')).mtimeMs; - expect(mtimeAfter).toBe(mtimeBefore); - }); - - it('handles rule removed from source gracefully', async () => { - const content = makeRulesContent('old-rule', 'Old rule', 'body'); - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'other-rule', description: 'Other rule', body: 'other body' }, - ]); - - // Lock has old-rule, but source only has other-rule now - // We need the check to detect a change first, so use different content - let lock = createEmptyLock(); - // old-rule has a hash that won't match anything in the source - const entry = makeLockEntry('old-rule', sourceRepo, content); - lock = upsertLockEntry(lock, entry); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - // The rule is not found in source — reported as error in check, not as update - // So updateRules won't try to update it - expect(result.totalChecked).toBe(1); - }); - - it('handles multiple rules with mixed updates', async () => { - const contentA = makeRulesContent('rule-a', 'Rule A', 'body a'); - const contentB = makeRulesContent('rule-b', 'Rule B', 'body b'); - - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'rule-a', description: 'Rule A', body: 'body a' }, // unchanged - { name: 'rule-b', description: 'Rule B', body: 'body b UPDATED' }, // changed - ]); - - let lock = createEmptyLock(); - lock = upsertLockEntry(lock, makeLockEntry('rule-a', sourceRepo, contentA)); - lock = upsertLockEntry(lock, makeLockEntry('rule-b', sourceRepo, contentB)); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(2); - expect(result.successCount).toBe(1); - expect(result.failCount).toBe(0); - expect(result.messages.some((m) => m.includes('Updated: rule-b'))).toBe(true); - }); - - it('updates prompt when content has changed', async () => { - const sourceRepo = join(tempDir, 'source-repo'); - await mkdir(sourceRepo, { recursive: true }); - - // Source has updated prompt content - const updatedPromptContent = `--- -name: review-code -description: Review code for issues ---- - -Review the code for bugs and security issues. -`; - await writeFile(join(sourceRepo, 'PROMPT.md'), updatedPromptContent); - - const originalPromptContent = `--- -name: review-code -description: Review code for issues ---- - -Review the code for bugs. -`; - - let lock = createEmptyLock(); - const promptEntry: LockEntry = { - type: 'prompt', - name: 'review-code', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(originalPromptContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [ - join(projectDir, '.github', 'prompts', 'review-code.prompt.md'), - join(projectDir, '.claude', 'commands', 'review-code.md'), - ], - }; - lock = upsertLockEntry(lock, promptEntry); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.successCount).toBe(1); - expect(result.failCount).toBe(0); - expect(result.messages.some((m) => m.includes('Updated: review-code'))).toBe(true); - - // Verify transpiled prompt files exist - expect(existsSync(join(projectDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'commands', 'review-code.md'))).toBe(true); - }); - - it('updates agent when content has changed', async () => { - const sourceRepo = join(tempDir, 'source-repo'); - await mkdir(sourceRepo, { recursive: true }); - - // Source has updated agent content - const updatedAgentContent = `--- -name: architect -description: Senior architect for code review ---- - -You are a senior software architect. Focus on security. -`; - await writeFile(join(sourceRepo, 'AGENT.md'), updatedAgentContent); - - const originalAgentContent = `--- -name: architect -description: Senior architect for code review ---- - -You are a senior software architect. -`; - - let lock = createEmptyLock(); - const agentEntry: LockEntry = { - type: 'agent', - name: 'architect', - source: sourceRepo, - format: 'canonical', - agents: ['github-copilot', 'claude-code'], - hash: computeContentHash(originalAgentContent), - installedAt: '2026-02-28T00:00:00.000Z', - outputs: [ - join(projectDir, '.github', 'agents', 'architect.agent.md'), - join(projectDir, '.claude', 'agents', 'architect.md'), - ], - }; - lock = upsertLockEntry(lock, agentEntry); - await writeDotaiLock(lock, projectDir); - - const result = await updateRules(projectDir); - - expect(result.totalChecked).toBe(1); - expect(result.successCount).toBe(1); - expect(result.failCount).toBe(0); - expect(result.messages.some((m) => m.includes('Updated: architect'))).toBe(true); - - // Verify transpiled agent files exist - expect(existsSync(join(projectDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'agents', 'architect.md'))).toBe(true); - }); -}); diff --git a/src/rule-discovery.test.ts b/src/rule-discovery.test.ts deleted file mode 100644 index 4111a32..0000000 --- a/src/rule-discovery.test.ts +++ /dev/null @@ -1,1044 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, rm, writeFile } from 'fs/promises'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { discover, filterByType, filterByFormat } from './rule-discovery.ts'; -import { - targetAgents, - TARGET_AGENTS, - getTargetAgentConfig, - getOutputDir, - getRuleExtension, - getPromptExtension, - getAgentExtension, -} from './target-agents.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Index into an array with a length assertion, avoiding TS "Object is possibly undefined" errors. */ -function at(arr: T[], index: number): T { - expect(arr.length).toBeGreaterThan(index); - return arr[index]!; -} - -function rulemd(frontmatter: Record, body = ''): string { - const lines = Object.entries(frontmatter).map(([key, value]) => { - if (Array.isArray(value)) { - return `${key}:\n${value.map((v) => ` - "${v}"`).join('\n')}`; - } - if (typeof value === 'string') { - return `${key}: ${value}`; - } - return `${key}: ${value}`; - }); - return `---\n${lines.join('\n')}\n---\n\n${body}`; -} - -function skillmd(name: string, description: string): string { - return `---\nname: ${name}\ndescription: ${description}\n---\n\nSkill content here.`; -} - -function promptmd(frontmatter: Record, body = ''): string { - const lines = Object.entries(frontmatter).map(([key, value]) => { - if (Array.isArray(value)) { - return `${key}:\n${value.map((v) => ` - "${v}"`).join('\n')}`; - } - if (typeof value === 'string') { - return `${key}: ${value}`; - } - return `${key}: ${value}`; - }); - return `---\n${lines.join('\n')}\n---\n\n${body}`; -} - -function agentmd(frontmatter: Record, body = ''): string { - const lines = Object.entries(frontmatter).map(([key, value]) => { - if (Array.isArray(value)) { - return `${key}:\n${value.map((v) => ` - "${v}"`).join('\n')}`; - } - if (typeof value === 'string') { - return `${key}: ${value}`; - } - return `${key}: ${value}`; - }); - return `---\n${lines.join('\n')}\n---\n\n${body}`; -} - -const VALID_RULE = { - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - globs: ['*.ts', '*.tsx'], - activation: 'auto', -}; - -// --------------------------------------------------------------------------- -// Target agents registry tests -// --------------------------------------------------------------------------- - -describe('target-agents registry', () => { - it('has exactly 4 target agents', () => { - expect(TARGET_AGENTS).toHaveLength(4); - expect(TARGET_AGENTS).toContain('github-copilot'); - expect(TARGET_AGENTS).toContain('claude-code'); - expect(TARGET_AGENTS).toContain('cursor'); - expect(TARGET_AGENTS).toContain('opencode'); - }); - - it('each agent has required config fields', () => { - for (const agent of TARGET_AGENTS) { - const config = targetAgents[agent]; - expect(config.name).toBe(agent); - expect(config.displayName).toBeTruthy(); - expect(config.skillsDir).toBeTruthy(); - expect(config.rulesConfig.outputDir).toBeTruthy(); - expect(config.rulesConfig.extension).toMatch(/^\./); - expect(config.nativeRuleDiscovery.sourceDir).toBeTruthy(); - expect(config.nativeRuleDiscovery.pattern).toBeTruthy(); - } - }); - - it('maps correct rules output directories', () => { - expect(targetAgents['github-copilot'].rulesConfig.outputDir).toBe('.github/instructions'); - expect(targetAgents['claude-code'].rulesConfig.outputDir).toBe('.claude/rules'); - expect(targetAgents['cursor'].rulesConfig.outputDir).toBe('.cursor/rules'); - }); - - it('maps correct rules extensions', () => { - expect(getRuleExtension('github-copilot')).toBe('.instructions.md'); - expect(getRuleExtension('claude-code')).toBe('.md'); - expect(getRuleExtension('cursor')).toBe('.mdc'); - }); - - it('getTargetAgentConfig returns correct config', () => { - const config = getTargetAgentConfig('cursor'); - expect(config.name).toBe('cursor'); - expect(config.displayName).toBe('Cursor'); - }); - - it('getOutputDir returns skills dir for skills', () => { - expect(getOutputDir('github-copilot', 'skill')).toBe('.agents/skills'); - expect(getOutputDir('claude-code', 'skill')).toBe('.claude/skills'); - expect(getOutputDir('cursor', 'skill')).toBe('.cursor/skills'); - }); - - it('getOutputDir returns rules dir for rules', () => { - expect(getOutputDir('github-copilot', 'rule')).toBe('.github/instructions'); - expect(getOutputDir('cursor', 'rule')).toBe('.cursor/rules'); - }); - - it('getOutputDir returns prompts dir for agents that support prompts', () => { - expect(getOutputDir('github-copilot', 'prompt')).toBe('.github/prompts'); - expect(getOutputDir('claude-code', 'prompt')).toBe('.claude/commands'); - }); - - it('getOutputDir returns undefined for agents that do not support prompts', () => { - expect(getOutputDir('cursor', 'prompt')).toBeUndefined(); - }); - - it('maps correct prompt output directories', () => { - expect(targetAgents['github-copilot'].promptsConfig?.outputDir).toBe('.github/prompts'); - expect(targetAgents['claude-code'].promptsConfig?.outputDir).toBe('.claude/commands'); - }); - - it('maps correct prompt extensions', () => { - expect(getPromptExtension('github-copilot')).toBe('.prompt.md'); - expect(getPromptExtension('claude-code')).toBe('.md'); - }); - - it('returns undefined prompt extension for unsupported agents', () => { - expect(getPromptExtension('cursor')).toBeUndefined(); - }); - - it('agents without promptsConfig have no prompt support', () => { - expect(targetAgents['cursor'].promptsConfig).toBeUndefined(); - }); - - it('nativePromptDiscovery paths are correct for supporting agents', () => { - expect(targetAgents['github-copilot'].nativePromptDiscovery?.sourceDir).toBe('.github/prompts'); - expect(targetAgents['github-copilot'].nativePromptDiscovery?.pattern).toBe('*.prompt.md'); - expect(targetAgents['claude-code'].nativePromptDiscovery?.sourceDir).toBe('.claude/commands'); - expect(targetAgents['claude-code'].nativePromptDiscovery?.pattern).toBe('*.md'); - }); - - it('maps correct agent output directories', () => { - expect(targetAgents['github-copilot'].agentsConfig?.outputDir).toBe('.github/agents'); - expect(targetAgents['claude-code'].agentsConfig?.outputDir).toBe('.claude/agents'); - }); - - it('maps correct agent extensions', () => { - expect(getAgentExtension('github-copilot')).toBe('.agent.md'); - expect(getAgentExtension('claude-code')).toBe('.md'); - }); - - it('returns undefined agent extension for unsupported agents', () => { - expect(getAgentExtension('cursor')).toBeUndefined(); - }); - - it('agents without agentsConfig have no agent support', () => { - expect(targetAgents['cursor'].agentsConfig).toBeUndefined(); - }); - - it('getOutputDir returns agents dir for agents that support agents', () => { - expect(getOutputDir('github-copilot', 'agent')).toBe('.github/agents'); - expect(getOutputDir('claude-code', 'agent')).toBe('.claude/agents'); - }); - - it('getOutputDir returns undefined for agents that do not support agents', () => { - expect(getOutputDir('cursor', 'agent')).toBeUndefined(); - }); - - it('nativeAgentDiscovery paths are correct for supporting agents', () => { - expect(targetAgents['github-copilot'].nativeAgentDiscovery?.sourceDir).toBe('.github/agents'); - expect(targetAgents['github-copilot'].nativeAgentDiscovery?.pattern).toBe('*.agent.md'); - expect(targetAgents['claude-code'].nativeAgentDiscovery?.sourceDir).toBe('.claude/agents'); - expect(targetAgents['claude-code'].nativeAgentDiscovery?.pattern).toBe('*.md'); - }); -}); - -// --------------------------------------------------------------------------- -// Discovery tests -// --------------------------------------------------------------------------- - -describe('discover', () => { - let testDir: string; - - beforeEach(async () => { - testDir = join( - tmpdir(), - `dotai-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}` - ); - await mkdir(testDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(testDir, { recursive: true, force: true }); - }); - - // ------------------------------------------------------------------------- - // Empty repo - // ------------------------------------------------------------------------- - - it('returns empty results for empty directory', async () => { - const result = await discover(testDir); - expect(result.items).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Canonical RULES.md discovery - // ------------------------------------------------------------------------- - - describe('canonical rules', () => { - it('discovers root RULES.md', async () => { - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Rule body')); - - const result = await discover(testDir); - const rules = filterByType(result.items, 'rule'); - expect(rules).toHaveLength(1); - expect(at(rules, 0).name).toBe('code-style'); - expect(at(rules, 0).format).toBe('canonical'); - expect(at(rules, 0).type).toBe('rule'); - expect(at(rules, 0).description).toBe('Enforce TypeScript code style conventions'); - }); - - it('discovers rules/*/RULES.md', async () => { - await mkdir(join(testDir, 'rules', 'code-style'), { recursive: true }); - await mkdir(join(testDir, 'rules', 'security'), { recursive: true }); - await writeFile(join(testDir, 'rules', 'code-style', 'RULES.md'), rulemd(VALID_RULE)); - await writeFile( - join(testDir, 'rules', 'security', 'RULES.md'), - rulemd({ name: 'security', description: 'Security rules', activation: 'always' }) - ); - - const result = await discover(testDir); - const rules = filterByType(result.items, 'rule'); - expect(rules).toHaveLength(2); - const names = rules.map((r) => r.name).sort(); - expect(names).toEqual(['code-style', 'security']); - expect(rules.every((r) => r.format === 'canonical')).toBe(true); - }); - - it('deduplicates by name — root wins over rules/ subdir', async () => { - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Root body')); - await mkdir(join(testDir, 'rules', 'code-style'), { recursive: true }); - await writeFile( - join(testDir, 'rules', 'code-style', 'RULES.md'), - rulemd(VALID_RULE, 'Subdir body') - ); - - const result = await discover(testDir); - const rules = filterByType(result.items, 'rule'); - expect(rules).toHaveLength(1); - expect(at(rules, 0).rawContent).toContain('Root body'); - }); - - it('warns on invalid frontmatter', async () => { - await writeFile(join(testDir, 'RULES.md'), rulemd({ name: 123, description: 'test' })); - - const result = await discover(testDir); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(result.warnings).toHaveLength(1); - expect(at(result.warnings, 0).type).toBe('parse-error'); - }); - - it('warns on missing required fields', async () => { - await writeFile(join(testDir, 'RULES.md'), '---\nname: test\n---\n\nNo description'); - - const result = await discover(testDir); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Canonical SKILL.md discovery - // ------------------------------------------------------------------------- - - describe('canonical skills', () => { - it('discovers root SKILL.md', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A test skill')); - - const result = await discover(testDir); - const skills = filterByType(result.items, 'skill'); - expect(skills).toHaveLength(1); - expect(at(skills, 0).name).toBe('my-skill'); - expect(at(skills, 0).format).toBe('canonical'); - expect(at(skills, 0).type).toBe('skill'); - }); - - it('discovers skills/*/SKILL.md', async () => { - await mkdir(join(testDir, 'skills', 'db-migrate'), { recursive: true }); - await mkdir(join(testDir, 'skills', 'api-test'), { recursive: true }); - await writeFile( - join(testDir, 'skills', 'db-migrate', 'SKILL.md'), - skillmd('db-migrate', 'Database migration') - ); - await writeFile( - join(testDir, 'skills', 'api-test', 'SKILL.md'), - skillmd('api-test', 'API testing') - ); - - const result = await discover(testDir); - const skills = filterByType(result.items, 'skill'); - expect(skills).toHaveLength(2); - const names = skills.map((s) => s.name).sort(); - expect(names).toEqual(['api-test', 'db-migrate']); - }); - - it('deduplicates by name — root wins', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'Root skill')); - await mkdir(join(testDir, 'skills', 'my-skill'), { recursive: true }); - await writeFile( - join(testDir, 'skills', 'my-skill', 'SKILL.md'), - skillmd('my-skill', 'Subdir skill') - ); - - const result = await discover(testDir); - const skills = filterByType(result.items, 'skill'); - expect(skills).toHaveLength(1); - expect(at(skills, 0).description).toBe('Root skill'); - }); - - it('skips SKILL.md without name', async () => { - await writeFile(join(testDir, 'SKILL.md'), '---\ndescription: No name\n---\n'); - - const result = await discover(testDir); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); - }); - - it('skips SKILL.md without description', async () => { - await writeFile(join(testDir, 'SKILL.md'), '---\nname: test\n---\n'); - - const result = await discover(testDir); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Native passthrough rules discovery - // ------------------------------------------------------------------------- - - describe('native rules', () => { - it('discovers .cursor/rules/*.mdc', async () => { - await mkdir(join(testDir, '.cursor', 'rules'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'rules', 'code-style.mdc'), 'Cursor rule content'); - - const result = await discover(testDir); - const nativeRules = filterByFormat(result.items, 'native:cursor'); - expect(nativeRules).toHaveLength(1); - expect(at(nativeRules, 0).name).toBe('code-style'); - expect(at(nativeRules, 0).type).toBe('rule'); - expect(at(nativeRules, 0).format).toBe('native:cursor'); - }); - - it('discovers .github/instructions/*.instructions.md', async () => { - await mkdir(join(testDir, '.github', 'instructions'), { recursive: true }); - await writeFile( - join(testDir, '.github', 'instructions', 'code-style.instructions.md'), - 'Copilot instruction' - ); - - const result = await discover(testDir); - const nativeRules = filterByFormat(result.items, 'native:github-copilot'); - expect(nativeRules).toHaveLength(1); - expect(at(nativeRules, 0).name).toBe('code-style'); - }); - - it('discovers .claude/rules/*.md', async () => { - await mkdir(join(testDir, '.claude', 'rules'), { recursive: true }); - await writeFile(join(testDir, '.claude', 'rules', 'style.md'), 'Claude rule'); - - const result = await discover(testDir); - const nativeRules = filterByFormat(result.items, 'native:claude-code'); - expect(nativeRules).toHaveLength(1); - expect(at(nativeRules, 0).name).toBe('style'); - }); - - it('ignores files that do not match pattern', async () => { - await mkdir(join(testDir, '.cursor', 'rules'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'rules', 'readme.txt'), 'Not a rule'); - await writeFile(join(testDir, '.cursor', 'rules', 'code-style.mdc'), 'A rule'); - - const result = await discover(testDir); - const nativeRules = filterByFormat(result.items, 'native:cursor'); - expect(nativeRules).toHaveLength(1); - expect(at(nativeRules, 0).name).toBe('code-style'); - }); - - it('ignores non-existent native directories', async () => { - // No native dirs created - const result = await discover(testDir); - expect(result.items).toHaveLength(0); - expect(result.warnings).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // Mixed discovery - // ------------------------------------------------------------------------- - - describe('mixed discovery', () => { - it('discovers skills and rules together', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Rule body')); - - const result = await discover(testDir); - expect(result.items).toHaveLength(2); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - }); - - it('discovers canonical + native rules together', async () => { - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE)); - await mkdir(join(testDir, '.cursor', 'rules'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'rules', 'lint.mdc'), 'Cursor rule'); - - const result = await discover(testDir); - const rules = filterByType(result.items, 'rule'); - expect(rules).toHaveLength(2); - expect(filterByFormat(rules, 'canonical')).toHaveLength(1); - expect(filterByFormat(rules, 'native:cursor')).toHaveLength(1); - }); - }); - - // ------------------------------------------------------------------------- - // Caps and limits - // ------------------------------------------------------------------------- - - describe('caps and limits', () => { - it('caps rules discovery at maxItemsPerType', async () => { - await mkdir(join(testDir, 'rules'), { recursive: true }); - // Create 5 rules but cap at 3 - for (let i = 0; i < 5; i++) { - const dir = join(testDir, 'rules', `rule-${i}`); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'RULES.md'), - rulemd({ name: `rule-${i}`, description: `Rule ${i}`, activation: 'always' }) - ); - } - - const result = await discover(testDir, { maxItemsPerType: 3 }); - const rules = filterByType(result.items, 'rule'); - expect(rules.length).toBeLessThanOrEqual(3); - expect(result.warnings.some((w) => w.type === 'cap-reached')).toBe(true); - }); - - it('caps skills discovery at maxItemsPerType', async () => { - await mkdir(join(testDir, 'skills'), { recursive: true }); - for (let i = 0; i < 5; i++) { - const dir = join(testDir, 'skills', `skill-${i}`); - await mkdir(dir, { recursive: true }); - await writeFile(join(dir, 'SKILL.md'), skillmd(`skill-${i}`, `Skill ${i}`)); - } - - const result = await discover(testDir, { maxItemsPerType: 3 }); - const skills = filterByType(result.items, 'skill'); - expect(skills.length).toBeLessThanOrEqual(3); - expect(result.warnings.some((w) => w.type === 'cap-reached')).toBe(true); - }); - - it('warns on files exceeding maxFileSize', async () => { - const bigContent = rulemd(VALID_RULE, 'x'.repeat(200)); - await writeFile(join(testDir, 'RULES.md'), bigContent); - - const result = await discover(testDir, { maxFileSize: 50 }); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'file-too-large')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Filter helpers - // ------------------------------------------------------------------------- - - describe('filter helpers', () => { - it('filterByType returns only matching type', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE)); - - const result = await discover(testDir); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - }); - - it('filterByFormat returns only matching format', async () => { - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE)); - await mkdir(join(testDir, '.cursor', 'rules'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'rules', 'lint.mdc'), 'Cursor native'); - - const result = await discover(testDir); - expect(filterByFormat(result.items, 'canonical')).toHaveLength(1); - expect(filterByFormat(result.items, 'native:cursor')).toHaveLength(1); - }); - }); - - // ------------------------------------------------------------------------- - // Canonical PROMPT.md discovery - // ------------------------------------------------------------------------- - - describe('canonical prompts', () => { - const VALID_PROMPT = { - name: 'review-code', - description: 'Review code for bugs and style issues', - }; - - it('discovers root PROMPT.md', async () => { - await writeFile(join(testDir, 'PROMPT.md'), promptmd(VALID_PROMPT, 'Review the code.')); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).name).toBe('review-code'); - expect(at(prompts, 0).format).toBe('canonical'); - expect(at(prompts, 0).type).toBe('prompt'); - expect(at(prompts, 0).description).toBe('Review code for bugs and style issues'); - }); - - it('discovers prompts/*/PROMPT.md', async () => { - await mkdir(join(testDir, 'prompts', 'review-code'), { recursive: true }); - await mkdir(join(testDir, 'prompts', 'gen-tests'), { recursive: true }); - await writeFile( - join(testDir, 'prompts', 'review-code', 'PROMPT.md'), - promptmd(VALID_PROMPT, 'Review body') - ); - await writeFile( - join(testDir, 'prompts', 'gen-tests', 'PROMPT.md'), - promptmd({ name: 'gen-tests', description: 'Generate unit tests' }, 'Test body') - ); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(2); - const names = prompts.map((p) => p.name).sort(); - expect(names).toEqual(['gen-tests', 'review-code']); - expect(prompts.every((p) => p.format === 'canonical')).toBe(true); - }); - - it('deduplicates by name — root wins over prompts/ subdir', async () => { - await writeFile(join(testDir, 'PROMPT.md'), promptmd(VALID_PROMPT, 'Root body')); - await mkdir(join(testDir, 'prompts', 'review-code'), { recursive: true }); - await writeFile( - join(testDir, 'prompts', 'review-code', 'PROMPT.md'), - promptmd(VALID_PROMPT, 'Subdir body') - ); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).rawContent).toContain('Root body'); - }); - - it('warns on invalid frontmatter', async () => { - await writeFile(join(testDir, 'PROMPT.md'), promptmd({ name: 123, description: 'test' })); - - const result = await discover(testDir); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - expect(result.warnings).toHaveLength(1); - expect(at(result.warnings, 0).type).toBe('parse-error'); - }); - - it('warns on missing required fields', async () => { - await writeFile(join(testDir, 'PROMPT.md'), '---\nname: test\n---\n\nNo description'); - - const result = await discover(testDir); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Native passthrough prompts discovery - // ------------------------------------------------------------------------- - - describe('native prompts', () => { - it('discovers .github/prompts/*.prompt.md', async () => { - await mkdir(join(testDir, '.github', 'prompts'), { recursive: true }); - await writeFile( - join(testDir, '.github', 'prompts', 'review-code.prompt.md'), - 'Copilot prompt content' - ); - - const result = await discover(testDir); - const nativePrompts = filterByFormat(result.items, 'native:github-copilot'); - const prompts = nativePrompts.filter((i) => i.type === 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).name).toBe('review-code'); - expect(at(prompts, 0).type).toBe('prompt'); - expect(at(prompts, 0).format).toBe('native:github-copilot'); - }); - - it('discovers .claude/commands/*.md', async () => { - await mkdir(join(testDir, '.claude', 'commands'), { recursive: true }); - await writeFile( - join(testDir, '.claude', 'commands', 'gen-tests.md'), - 'Claude command content' - ); - - const result = await discover(testDir); - const nativePrompts = filterByFormat(result.items, 'native:claude-code'); - const prompts = nativePrompts.filter((i) => i.type === 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).name).toBe('gen-tests'); - expect(at(prompts, 0).type).toBe('prompt'); - }); - - it('ignores files that do not match prompt pattern', async () => { - await mkdir(join(testDir, '.github', 'prompts'), { recursive: true }); - await writeFile(join(testDir, '.github', 'prompts', 'readme.txt'), 'Not a prompt'); - await writeFile(join(testDir, '.github', 'prompts', 'review.prompt.md'), 'A prompt'); - - const result = await discover(testDir); - const nativePrompts = filterByFormat(result.items, 'native:github-copilot'); - const prompts = nativePrompts.filter((i) => i.type === 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).name).toBe('review'); - }); - - it('does not discover native prompts from agents without nativePromptDiscovery', async () => { - // Cursor has no native prompt discovery - await mkdir(join(testDir, '.cursor', 'prompts'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'prompts', 'test.md'), 'Not discovered'); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // Prompt caps and limits - // ------------------------------------------------------------------------- - - describe('prompt caps and limits', () => { - it('caps prompt discovery at maxItemsPerType', async () => { - await mkdir(join(testDir, 'prompts'), { recursive: true }); - for (let i = 0; i < 5; i++) { - const dir = join(testDir, 'prompts', `prompt-${i}`); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'PROMPT.md'), - promptmd({ name: `prompt-${i}`, description: `Prompt ${i}` }) - ); - } - - const result = await discover(testDir, { maxItemsPerType: 3 }); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts.length).toBeLessThanOrEqual(3); - expect(result.warnings.some((w) => w.type === 'cap-reached')).toBe(true); - }); - - it('warns on prompt files exceeding maxFileSize', async () => { - const bigContent = promptmd( - { name: 'big-prompt', description: 'A big prompt' }, - 'x'.repeat(200) - ); - await writeFile(join(testDir, 'PROMPT.md'), bigContent); - - const result = await discover(testDir, { maxFileSize: 50 }); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'file-too-large')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Mixed discovery with prompts - // ------------------------------------------------------------------------- - - describe('mixed discovery with prompts', () => { - it('discovers skills, rules, and prompts together', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Rule body')); - await writeFile( - join(testDir, 'PROMPT.md'), - promptmd({ name: 'my-prompt', description: 'A prompt' }, 'Prompt body') - ); - - const result = await discover(testDir); - expect(result.items).toHaveLength(3); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - }); - - it('discovers canonical + native prompts together', async () => { - await writeFile( - join(testDir, 'PROMPT.md'), - promptmd({ name: 'review-code', description: 'Review code' }, 'Review body') - ); - await mkdir(join(testDir, '.github', 'prompts'), { recursive: true }); - await writeFile(join(testDir, '.github', 'prompts', 'deploy.prompt.md'), 'Copilot prompt'); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(2); - expect(filterByFormat(prompts, 'canonical')).toHaveLength(1); - expect(filterByFormat(prompts, 'native:github-copilot')).toHaveLength(1); - }); - - it('filterByType returns only prompts', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE)); - await writeFile( - join(testDir, 'PROMPT.md'), - promptmd({ name: 'my-prompt', description: 'A prompt' }, 'Body') - ); - - const result = await discover(testDir); - const prompts = filterByType(result.items, 'prompt'); - expect(prompts).toHaveLength(1); - expect(at(prompts, 0).name).toBe('my-prompt'); - expect(at(prompts, 0).type).toBe('prompt'); - }); - }); - - // ------------------------------------------------------------------------- - // Canonical AGENT.md discovery - // ------------------------------------------------------------------------- - - describe('canonical agents', () => { - const VALID_AGENT = { - name: 'architect', - description: 'Senior architect for system design and code review', - }; - - it('discovers root AGENT.md', async () => { - await writeFile( - join(testDir, 'AGENT.md'), - agentmd(VALID_AGENT, 'You are a senior architect.') - ); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).name).toBe('architect'); - expect(at(agents, 0).format).toBe('canonical'); - expect(at(agents, 0).type).toBe('agent'); - expect(at(agents, 0).description).toBe('Senior architect for system design and code review'); - }); - - it('discovers agents/*/AGENT.md', async () => { - await mkdir(join(testDir, 'agents', 'architect'), { recursive: true }); - await mkdir(join(testDir, 'agents', 'reviewer'), { recursive: true }); - await writeFile( - join(testDir, 'agents', 'architect', 'AGENT.md'), - agentmd(VALID_AGENT, 'Architect body') - ); - await writeFile( - join(testDir, 'agents', 'reviewer', 'AGENT.md'), - agentmd({ name: 'reviewer', description: 'Code reviewer' }, 'Reviewer body') - ); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(2); - const names = agents.map((a) => a.name).sort(); - expect(names).toEqual(['architect', 'reviewer']); - expect(agents.every((a) => a.format === 'canonical')).toBe(true); - }); - - it('deduplicates by name — root wins over agents/ subdir', async () => { - await writeFile(join(testDir, 'AGENT.md'), agentmd(VALID_AGENT, 'Root body')); - await mkdir(join(testDir, 'agents', 'architect'), { recursive: true }); - await writeFile( - join(testDir, 'agents', 'architect', 'AGENT.md'), - agentmd(VALID_AGENT, 'Subdir body') - ); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).rawContent).toContain('Root body'); - }); - - it('warns on invalid frontmatter', async () => { - await writeFile(join(testDir, 'AGENT.md'), agentmd({ name: 123, description: 'test' })); - - const result = await discover(testDir); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - expect(result.warnings).toHaveLength(1); - expect(at(result.warnings, 0).type).toBe('parse-error'); - }); - - it('warns on missing required fields', async () => { - await writeFile(join(testDir, 'AGENT.md'), '---\nname: test\n---\n\nNo description'); - - const result = await discover(testDir); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'parse-error')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Native passthrough agents discovery - // ------------------------------------------------------------------------- - - describe('native agents', () => { - it('discovers .github/agents/*.agent.md', async () => { - await mkdir(join(testDir, '.github', 'agents'), { recursive: true }); - await writeFile( - join(testDir, '.github', 'agents', 'architect.agent.md'), - 'Copilot agent content' - ); - - const result = await discover(testDir); - const nativeAgents = filterByFormat(result.items, 'native:github-copilot'); - const agents = nativeAgents.filter((i) => i.type === 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).name).toBe('architect'); - expect(at(agents, 0).type).toBe('agent'); - expect(at(agents, 0).format).toBe('native:github-copilot'); - }); - - it('discovers .claude/agents/*.md', async () => { - await mkdir(join(testDir, '.claude', 'agents'), { recursive: true }); - await writeFile(join(testDir, '.claude', 'agents', 'reviewer.md'), 'Claude agent content'); - - const result = await discover(testDir); - const nativeAgents = filterByFormat(result.items, 'native:claude-code'); - const agents = nativeAgents.filter((i) => i.type === 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).name).toBe('reviewer'); - expect(at(agents, 0).type).toBe('agent'); - }); - - it('ignores files that do not match agent pattern', async () => { - await mkdir(join(testDir, '.github', 'agents'), { recursive: true }); - await writeFile(join(testDir, '.github', 'agents', 'readme.txt'), 'Not an agent'); - await writeFile(join(testDir, '.github', 'agents', 'architect.agent.md'), 'An agent'); - - const result = await discover(testDir); - const nativeAgents = filterByFormat(result.items, 'native:github-copilot'); - const agents = nativeAgents.filter((i) => i.type === 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).name).toBe('architect'); - }); - - it('does not discover native agents from agents without nativeAgentDiscovery', async () => { - // Cursor has no native agent discovery - await mkdir(join(testDir, '.cursor', 'agents'), { recursive: true }); - await writeFile(join(testDir, '.cursor', 'agents', 'test.md'), 'Not discovered'); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // Agent caps and limits - // ------------------------------------------------------------------------- - - describe('agent caps and limits', () => { - it('caps agent discovery at maxItemsPerType', async () => { - await mkdir(join(testDir, 'agents'), { recursive: true }); - for (let i = 0; i < 5; i++) { - const dir = join(testDir, 'agents', `agent-${i}`); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, 'AGENT.md'), - agentmd({ name: `agent-${i}`, description: `Agent ${i}` }) - ); - } - - const result = await discover(testDir, { maxItemsPerType: 3 }); - const agents = filterByType(result.items, 'agent'); - expect(agents.length).toBeLessThanOrEqual(3); - expect(result.warnings.some((w) => w.type === 'cap-reached')).toBe(true); - }); - - it('warns on agent files exceeding maxFileSize', async () => { - const bigContent = agentmd( - { name: 'big-agent', description: 'A big agent' }, - 'x'.repeat(200) - ); - await writeFile(join(testDir, 'AGENT.md'), bigContent); - - const result = await discover(testDir, { maxFileSize: 50 }); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - expect(result.warnings.some((w) => w.type === 'file-too-large')).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // Mixed discovery with agents - // ------------------------------------------------------------------------- - - describe('mixed discovery with agents', () => { - it('discovers skills, rules, prompts, and agents together', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Rule body')); - await writeFile( - join(testDir, 'PROMPT.md'), - promptmd({ name: 'my-prompt', description: 'A prompt' }, 'Prompt body') - ); - await writeFile( - join(testDir, 'AGENT.md'), - agentmd({ name: 'my-agent', description: 'An agent' }, 'Agent body') - ); - - const result = await discover(testDir); - expect(result.items).toHaveLength(4); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - expect(filterByType(result.items, 'agent')).toHaveLength(1); - }); - - it('discovers canonical + native agents together', async () => { - await writeFile( - join(testDir, 'AGENT.md'), - agentmd({ name: 'architect', description: 'An architect' }, 'Architect body') - ); - await mkdir(join(testDir, '.github', 'agents'), { recursive: true }); - await writeFile(join(testDir, '.github', 'agents', 'reviewer.agent.md'), 'Copilot agent'); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(2); - expect(filterByFormat(agents, 'canonical')).toHaveLength(1); - expect(filterByFormat(agents, 'native:github-copilot')).toHaveLength(1); - }); - - it('filterByType returns only agents', async () => { - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE)); - await writeFile( - join(testDir, 'AGENT.md'), - agentmd({ name: 'my-agent', description: 'An agent' }, 'Body') - ); - - const result = await discover(testDir); - const agents = filterByType(result.items, 'agent'); - expect(agents).toHaveLength(1); - expect(at(agents, 0).name).toBe('my-agent'); - expect(at(agents, 0).type).toBe('agent'); - }); - }); - - // ------------------------------------------------------------------------- - // Type filter — discover() with types option - // ------------------------------------------------------------------------- - - describe('type filter', () => { - beforeEach(async () => { - // Set up a repo with all four types - await writeFile(join(testDir, 'SKILL.md'), skillmd('my-skill', 'A skill')); - await writeFile(join(testDir, 'RULES.md'), rulemd(VALID_RULE, 'Rule body')); - await writeFile( - join(testDir, 'PROMPT.md'), - promptmd({ name: 'my-prompt', description: 'A prompt' }, 'Prompt body') - ); - await writeFile( - join(testDir, 'AGENT.md'), - agentmd({ name: 'my-agent', description: 'An agent' }, 'Agent body') - ); - }); - - it('discovers all types when types option is omitted', async () => { - const result = await discover(testDir); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - expect(filterByType(result.items, 'agent')).toHaveLength(1); - }); - - it('discovers all types when types is empty array', async () => { - const result = await discover(testDir, { types: [] }); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - expect(filterByType(result.items, 'agent')).toHaveLength(1); - }); - - it('discovers only rules when types is ["rule"]', async () => { - const result = await discover(testDir, { types: ['rule'] }); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - }); - - it('discovers only prompts when types is ["prompt"]', async () => { - const result = await discover(testDir, { types: ['prompt'] }); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - }); - - it('discovers only agents when types is ["agent"]', async () => { - const result = await discover(testDir, { types: ['agent'] }); - expect(filterByType(result.items, 'agent')).toHaveLength(1); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - }); - - it('discovers only skills when types is ["skill"]', async () => { - const result = await discover(testDir, { types: ['skill'] }); - expect(filterByType(result.items, 'skill')).toHaveLength(1); - expect(filterByType(result.items, 'rule')).toHaveLength(0); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - }); - - it('discovers multiple types when specified', async () => { - const result = await discover(testDir, { types: ['rule', 'prompt'] }); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(1); - expect(filterByType(result.items, 'skill')).toHaveLength(0); - expect(filterByType(result.items, 'agent')).toHaveLength(0); - }); - - it('type filter works with other options', async () => { - const result = await discover(testDir, { types: ['rule'], maxItemsPerType: 10 }); - expect(filterByType(result.items, 'rule')).toHaveLength(1); - expect(filterByType(result.items, 'prompt')).toHaveLength(0); - }); - }); -}); diff --git a/src/rule-installer.test.ts b/src/rule-installer.test.ts deleted file mode 100644 index 1734861..0000000 --- a/src/rule-installer.test.ts +++ /dev/null @@ -1,1353 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, mkdtempSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import type { DiscoveredItem, LockEntry, TargetAgent } from './types.ts'; -import { - planRuleWrites, - executeInstallPipeline, - type InstallPipelineOptions, -} from './rule-installer.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Create a minimal valid RULES.md content string. */ -function makeRuleContent( - name: string, - opts: { description?: string; activation?: string; globs?: string[] } = {} -): string { - const desc = opts.description ?? `Description for ${name}`; - const activation = opts.activation ?? 'always'; - const globLines = - opts.globs && opts.globs.length > 0 - ? `globs:\n${opts.globs.map((g) => ` - "${g}"`).join('\n')}\n` - : ''; - - return [ - '---', - `name: ${name}`, - `description: ${desc}`, - `activation: ${activation}`, - globLines ? globLines.trimEnd() : null, - '---', - '', - `## ${name}`, - '', - `Body content for ${name}.`, - ] - .filter((line) => line !== null) - .join('\n'); -} - -/** Create a DiscoveredItem for a canonical rule. */ -function canonicalRule( - name: string, - opts: { description?: string; activation?: string; globs?: string[] } = {} -): DiscoveredItem { - return { - type: 'rule', - format: 'canonical', - name, - description: opts.description ?? `Description for ${name}`, - sourcePath: `/fake/source/rules/${name}/RULES.md`, - rawContent: makeRuleContent(name, opts), - }; -} - -/** Create a DiscoveredItem for a native passthrough rule. */ -function nativeRule(name: string, agent: TargetAgent): DiscoveredItem { - return { - type: 'rule', - format: `native:${agent}`, - name, - description: `Native ${agent} rule`, - sourcePath: `/fake/source/.${agent}/rules/${name}.md`, - rawContent: `Native content for ${name}`, - }; -} - -/** Create a DiscoveredItem for a skill (should be skipped by pipeline). */ -function skillItem(name: string): DiscoveredItem { - return { - type: 'skill', - format: 'canonical', - name, - description: `Skill: ${name}`, - sourcePath: `/fake/source/skills/${name}/SKILL.md`, - rawContent: `---\nname: ${name}\ndescription: Skill ${name}\n---\nSkill body`, - }; -} - -/** Create a minimal valid PROMPT.md content string. */ -function makePromptContent( - name: string, - opts: { description?: string; agent?: string; tools?: string[] } = {} -): string { - const desc = opts.description ?? `Description for ${name}`; - const agentLine = opts.agent ? `agent: ${opts.agent}\n` : ''; - const toolLines = - opts.tools && opts.tools.length > 0 - ? `tools:\n${opts.tools.map((t) => ` - ${t}`).join('\n')}\n` - : ''; - - return [ - '---', - `name: ${name}`, - `description: ${desc}`, - agentLine ? agentLine.trimEnd() : null, - toolLines ? toolLines.trimEnd() : null, - '---', - '', - `Prompt body for ${name}.`, - ] - .filter((line) => line !== null) - .join('\n'); -} - -/** Create a DiscoveredItem for a canonical prompt. */ -function canonicalPrompt( - name: string, - opts: { description?: string; agent?: string; tools?: string[] } = {} -): DiscoveredItem { - return { - type: 'prompt', - format: 'canonical', - name, - description: opts.description ?? `Description for ${name}`, - sourcePath: `/fake/source/prompts/${name}/PROMPT.md`, - rawContent: makePromptContent(name, opts), - }; -} - -/** Create a DiscoveredItem for a native passthrough prompt. */ -function nativePrompt(name: string, agent: TargetAgent): DiscoveredItem { - return { - type: 'prompt', - format: `native:${agent}`, - name, - description: `Native ${agent} prompt`, - sourcePath: `/fake/source/.${agent}/prompts/${name}.md`, - rawContent: `Native prompt content for ${name}`, - }; -} - -/** Create a minimal valid AGENT.md content string. */ -function makeAgentContent( - name: string, - opts: { - description?: string; - model?: string; - tools?: string[]; - disallowedTools?: string[]; - maxTurns?: number; - background?: boolean; - } = {} -): string { - const desc = opts.description ?? `Description for ${name}`; - const modelLine = opts.model ? `model: ${opts.model}\n` : ''; - const toolLines = - opts.tools && opts.tools.length > 0 - ? `tools:\n${opts.tools.map((t) => ` - ${t}`).join('\n')}\n` - : ''; - const disallowedToolLines = - opts.disallowedTools && opts.disallowedTools.length > 0 - ? `disallowed-tools:\n${opts.disallowedTools.map((t) => ` - ${t}`).join('\n')}\n` - : ''; - const maxTurnsLine = opts.maxTurns !== undefined ? `max-turns: ${opts.maxTurns}\n` : ''; - const backgroundLine = opts.background !== undefined ? `background: ${opts.background}\n` : ''; - - return [ - '---', - `name: ${name}`, - `description: ${desc}`, - modelLine ? modelLine.trimEnd() : null, - toolLines ? toolLines.trimEnd() : null, - disallowedToolLines ? disallowedToolLines.trimEnd() : null, - maxTurnsLine ? maxTurnsLine.trimEnd() : null, - backgroundLine ? backgroundLine.trimEnd() : null, - '---', - '', - `Agent body for ${name}.`, - ] - .filter((line) => line !== null) - .join('\n'); -} - -/** Create a DiscoveredItem for a canonical agent. */ -function canonicalAgent( - name: string, - opts: { - description?: string; - model?: string; - tools?: string[]; - disallowedTools?: string[]; - maxTurns?: number; - background?: boolean; - } = {} -): DiscoveredItem { - return { - type: 'agent', - format: 'canonical', - name, - description: opts.description ?? `Description for ${name}`, - sourcePath: `/fake/source/agents/${name}/AGENT.md`, - rawContent: makeAgentContent(name, opts), - }; -} - -/** Create a DiscoveredItem for a native passthrough agent. */ -function nativeAgent(name: string, agent: TargetAgent): DiscoveredItem { - return { - type: 'agent', - format: `native:${agent}`, - name, - description: `Native ${agent} agent`, - sourcePath: `/fake/source/.${agent}/agents/${name}.md`, - rawContent: `Native agent content for ${name}`, - }; -} - -/** Create base pipeline options. */ -function baseOptions( - projectRoot: string, - overrides: Partial = {} -): InstallPipelineOptions { - return { - projectRoot, - source: 'test/repo', - lockEntries: [], - force: false, - dryRun: false, - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('install-pipeline', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'dotai-pipeline-')); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - // ------------------------------------------------------------------------- - // planRuleWrites - // ------------------------------------------------------------------------- - - describe('planRuleWrites', () => { - it('transpiles a canonical rule to all 4 agents', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(4); - - const agents = writes.map((w) => w.agent); - expect(agents).toContain('github-copilot'); - expect(agents).toContain('claude-code'); - expect(agents).toContain('cursor'); - expect(agents).toContain('opencode'); - }); - - it('respects agent subset filter', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor', 'opencode'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(2); - const agents = writes.map((w) => w.agent); - expect(agents).toContain('cursor'); - expect(agents).toContain('opencode'); - }); - - it('skips skill items (handled by existing installer)', () => { - const items = [skillItem('db-migrate')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(0); // skills are silently skipped, not "skipped with reason" - }); - - it('handles native passthrough — only targets matching agent', () => { - const items = [nativeRule('code-style', 'cursor')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('cursor'); - }); - - it('native passthrough produces no output for non-matching agents', () => { - const items = [nativeRule('code-style', 'cursor')]; - const opts = baseOptions(tmpDir, { - targets: ['opencode'] as const, - }); - - const { writes, skipped } = planRuleWrites(items, opts); - - // native:cursor with only opencode target → transpilation produces no output - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(1); - expect(skipped[0]!.reason).toContain('no outputs'); - }); - - it('resolves absolute paths correctly', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.planned.absolutePath).toBe( - join(tmpDir, '.cursor', 'rules', 'code-style.mdc') - ); - }); - - it('attaches correct metadata to planned writes', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor'] as const, - source: 'acme/repo', - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes[0]!.planned.type).toBe('rule'); - expect(writes[0]!.planned.name).toBe('code-style'); - expect(writes[0]!.planned.format).toBe('canonical'); - expect(writes[0]!.planned.source).toBe('acme/repo'); - }); - - it('handles multiple rules in one batch', () => { - const items = [canonicalRule('code-style'), canonicalRule('security')]; - const opts = baseOptions(tmpDir); - - const { writes } = planRuleWrites(items, opts); - - // 2 rules × 4 agents = 8 writes - expect(writes).toHaveLength(8); - }); - - it('handles mixed canonical + native rules', () => { - const items = [canonicalRule('code-style'), nativeRule('lint', 'cursor')]; - const opts = baseOptions(tmpDir); - - const { writes } = planRuleWrites(items, opts); - - // 1 canonical × 4 agents + 1 native × 1 agent = 5 - expect(writes).toHaveLength(5); - }); - - it('skips items with invalid content', () => { - const badRule: DiscoveredItem = { - type: 'rule', - format: 'canonical', - name: 'bad-rule', - description: 'Invalid rule', - sourcePath: '/fake/source/rules/bad-rule/RULES.md', - rawContent: '---\n---\nNo frontmatter fields', - }; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites([badRule], opts); - - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(1); - expect(skipped[0]!.reason).toContain('no outputs'); - }); - - it('returns empty for empty input', () => { - const { writes, skipped } = planRuleWrites([], baseOptions(tmpDir)); - - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — dry-run mode - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — dry-run', () => { - it('reports planned writes without creating files', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { dryRun: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(4); - expect(result.written).toHaveLength(0); - - // No files should exist on disk - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(false); - }); - - it('dry-run still detects collisions', async () => { - // Create a pre-existing file - const conflictDir = join(tmpDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'code-style.mdc'), 'existing content'); - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { dryRun: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(false); - expect(result.collisions).toHaveLength(1); - expect(result.collisions[0]!.kind).toBe('file-exists'); - expect(result.written).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — real writes - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — writes', () => { - it('writes transpiled rules to all 4 agent directories', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(4); - - // Verify files exist on disk - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect( - existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - }); - - it('creates target directories that do not exist', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { targets: ['cursor'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - }); - - it('written files have correct transpiled content', async () => { - const items = [canonicalRule('code-style', { activation: 'always' })]; - const opts = baseOptions(tmpDir, { targets: ['cursor'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'), 'utf-8'); - - // Cursor format has alwaysApply: true for "always" activation - expect(content).toContain('alwaysApply: true'); - expect(content).toContain('description:'); - }); - - it('writes multiple rules in one pass', async () => { - const items = [canonicalRule('code-style'), canonicalRule('security')]; - const opts = baseOptions(tmpDir, { targets: ['cursor'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'security.mdc'))).toBe(true); - }); - - it('succeeds with empty item list', async () => { - const result = await executeInstallPipeline([], baseOptions(tmpDir)); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(0); - expect(result.written).toHaveLength(0); - }); - - it('skips skills without error', async () => { - const items = [skillItem('db-migrate')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(0); - expect(result.written).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — collision handling - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — collisions', () => { - it('blocks on pre-existing user file', async () => { - const conflictDir = join(tmpDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'code-style.mdc'), 'user content'); - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(false); - expect(result.collisions.length).toBeGreaterThan(0); - expect(result.error).toContain('collision'); - expect(result.written).toHaveLength(0); - }); - - it('--force overrides pre-existing file', async () => { - const conflictDir = join(tmpDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'code-style.mdc'), 'user content'); - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { force: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.collisions.length).toBeGreaterThan(0); - expect(result.written).toHaveLength(4); - - // File should be overwritten with transpiled content - const content = readFileSync(join(conflictDir, 'code-style.mdc'), 'utf-8'); - expect(content).not.toBe('user content'); - }); - - it('blocks on same-name collision from different source', async () => { - const existingEntry: LockEntry = { - type: 'rule', - name: 'code-style', - source: 'other/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'abc123', - installedAt: new Date().toISOString(), - outputs: [join(tmpDir, '.cursor', 'rules', 'code-style.mdc')], - }; - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { lockEntries: [existingEntry] }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(false); - expect(result.collisions.some((c) => c.kind === 'same-name')).toBe(true); - }); - - it('allows re-install from same source (update path)', async () => { - const existingEntry: LockEntry = { - type: 'rule', - name: 'code-style', - source: 'test/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'abc123', - installedAt: new Date().toISOString(), - outputs: [join(tmpDir, '.cursor', 'rules', 'code-style.mdc')], - }; - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - lockEntries: [existingEntry], - targets: ['cursor'] as const, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.collisions).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — rollback - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — rollback', () => { - it('rolls back all writes if a write fails', async () => { - // Write cursor rule first, then make a dir read-only to trigger failure - // We'll simulate this by writing to a path that can't be created - const items = [canonicalRule('code-style')]; - - // Create a file where a directory is expected to force a write error - const blockerPath = join(tmpDir, '.opencode'); - writeFileSync(blockerPath, 'I am a file, not a directory'); - - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - expect(result.written).toHaveLength(0); - - // Files written before the failure should have been cleaned up - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — agent filtering - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — agent filtering', () => { - it('installs only to specified agents', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor', 'opencode'] as const, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(tmpDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - // Others should NOT exist - expect( - existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(false); - expect(existsSync(join(tmpDir, '.claude', 'rules', 'code-style.md'))).toBe(false); - }); - - it('single agent install', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - expect( - existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — native passthrough - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — native passthrough', () => { - it('installs native rule content unchanged to matching agent', async () => { - const items = [nativeRule('lint', 'cursor')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - - const content = readFileSync(join(tmpDir, '.cursor', 'rules', 'lint.mdc'), 'utf-8'); - expect(content).toBe('Native content for lint'); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — mixed items - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — mixed items', () => { - it('handles canonical rules + skills + native rules together', async () => { - const items = [ - canonicalRule('code-style'), - skillItem('db-migrate'), - nativeRule('lint', 'opencode'), - ]; - const opts = baseOptions(tmpDir, { targets: ['cursor', 'opencode'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - // canonical rule: 2 agents = 2 writes - // skill: skipped (0 writes) - // native opencode: 1 write - expect(result.written).toHaveLength(3); - }); - }); - - // ------------------------------------------------------------------------- - // planRuleWrites — prompt items - // ------------------------------------------------------------------------- - - describe('planRuleWrites — prompts', () => { - it('transpiles a canonical prompt to Copilot, Claude Code, and OpenCode', () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - // Copilot, Claude Code, and OpenCode support canonical prompt transpilation - expect(writes).toHaveLength(3); - - const agents = writes.map((w) => w.agent); - expect(agents).toContain('github-copilot'); - expect(agents).toContain('claude-code'); - expect(agents).toContain('opencode'); - }); - - it('respects agent subset filter for prompts', () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('github-copilot'); - }); - - it('produces no output for agents that do not support prompts', () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor'] as const, - }); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(1); - expect(skipped[0]!.reason).toContain('no outputs'); - }); - - it('resolves absolute paths correctly for prompts', () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.planned.absolutePath).toBe( - join(tmpDir, '.github', 'prompts', 'review-code.prompt.md') - ); - }); - - it('attaches correct metadata to prompt planned writes', () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - source: 'acme/repo', - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes[0]!.planned.type).toBe('prompt'); - expect(writes[0]!.planned.name).toBe('review-code'); - expect(writes[0]!.planned.format).toBe('canonical'); - expect(writes[0]!.planned.source).toBe('acme/repo'); - }); - - it('handles multiple prompts in one batch', () => { - const items = [canonicalPrompt('review-code'), canonicalPrompt('explain-code')]; - const opts = baseOptions(tmpDir); - - const { writes } = planRuleWrites(items, opts); - - // 2 prompts × 3 supported agents = 6 writes - expect(writes).toHaveLength(6); - }); - - it('handles native prompt passthrough — only targets matching agent', () => { - const items = [nativePrompt('review', 'github-copilot')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('github-copilot'); - }); - - it('native prompt passthrough for opencode', () => { - const items = [nativePrompt('deploy', 'opencode')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('opencode'); - }); - - it('handles mixed rules + prompts + skills together', () => { - const items = [ - canonicalRule('code-style'), - canonicalPrompt('review-code'), - skillItem('db-migrate'), - ]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - // canonical rule: 4 agents - // canonical prompt: 3 agents (copilot + claude-code + opencode) - // skill: silently skipped - expect(writes).toHaveLength(7); - expect(skipped).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — prompt writes - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — prompt writes', () => { - it('writes transpiled prompts to Copilot, Claude Code, and OpenCode directories', async () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(3); - - // Verify files exist on disk - expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'commands', 'review-code.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.opencode', 'commands', 'review-code.md'))).toBe(true); - }); - - it('written Copilot prompt has correct content', async () => { - const items = [canonicalPrompt('review-code', { agent: 'plan' })]; - const opts = baseOptions(tmpDir, { targets: ['github-copilot'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync( - join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'), - 'utf-8' - ); - expect(content).toContain('description:'); - expect(content).toContain('agent: "plan"'); - expect(content).toContain('Prompt body for review-code.'); - }); - - it('written Claude Code prompt has correct content', async () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { targets: ['claude-code'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync(join(tmpDir, '.claude', 'commands', 'review-code.md'), 'utf-8'); - expect(content).toContain('> Description for review-code'); - expect(content).toContain('Prompt body for review-code.'); - }); - - it('dry-run reports prompt writes without creating files', async () => { - const items = [canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { dryRun: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(3); - expect(result.written).toHaveLength(0); - expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(false); - }); - - it('installs native prompt content unchanged', async () => { - const items = [nativePrompt('deploy', 'github-copilot')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - const content = readFileSync(join(tmpDir, '.github', 'prompts', 'deploy.prompt.md'), 'utf-8'); - expect(content).toBe('Native prompt content for deploy'); - }); - - it('handles mixed rules + prompts in a single pipeline execution', async () => { - const items = [canonicalRule('code-style'), canonicalPrompt('review-code')]; - const opts = baseOptions(tmpDir, { targets: ['github-copilot', 'claude-code'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - // rule: 2 agents, prompt: 2 agents = 4 writes - expect(result.written).toHaveLength(4); - - // Rule files - expect( - existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - - // Prompt files - expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'commands', 'review-code.md'))).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // planRuleWrites — agent items - // ------------------------------------------------------------------------- - - describe('planRuleWrites — agents', () => { - it('transpiles a canonical agent to Copilot, Claude Code, and OpenCode', () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - // Copilot, Claude Code, and OpenCode support agent transpilation - expect(writes).toHaveLength(3); - - const agents = writes.map((w) => w.agent); - expect(agents).toContain('github-copilot'); - expect(agents).toContain('claude-code'); - expect(agents).toContain('opencode'); - }); - - it('respects agent subset filter for agents', () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('github-copilot'); - }); - - it('produces no output for agents that do not support agent transpilation', () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { - targets: ['cursor'] as const, - }); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(0); - expect(skipped).toHaveLength(1); - expect(skipped[0]!.reason).toContain('no outputs'); - }); - - it('resolves absolute paths correctly for agents', () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.planned.absolutePath).toBe( - join(tmpDir, '.github', 'agents', 'architect.agent.md') - ); - }); - - it('attaches correct metadata to agent planned writes', () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { - targets: ['github-copilot'] as const, - source: 'acme/repo', - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes[0]!.planned.type).toBe('agent'); - expect(writes[0]!.planned.name).toBe('architect'); - expect(writes[0]!.planned.format).toBe('canonical'); - expect(writes[0]!.planned.source).toBe('acme/repo'); - }); - - it('handles multiple agents in one batch', () => { - const items = [canonicalAgent('architect'), canonicalAgent('reviewer')]; - const opts = baseOptions(tmpDir); - - const { writes } = planRuleWrites(items, opts); - - // 2 agents × 3 supported target agents = 6 writes - expect(writes).toHaveLength(6); - }); - - it('handles native agent passthrough — only targets matching agent', () => { - const items = [nativeAgent('architect', 'github-copilot')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('github-copilot'); - }); - - it('native agent passthrough for claude-code', () => { - const items = [nativeAgent('reviewer', 'claude-code')]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(1); - expect(writes[0]!.agent).toBe('claude-code'); - }); - - it('handles mixed rules + prompts + agents + skills together', () => { - const items = [ - canonicalRule('code-style'), - canonicalPrompt('review-code'), - canonicalAgent('architect'), - skillItem('db-migrate'), - ]; - const opts = baseOptions(tmpDir); - - const { writes, skipped } = planRuleWrites(items, opts); - - // canonical rule: 4 agents - // canonical prompt: 3 agents (copilot + claude-code + opencode) - // canonical agent: 3 agents (copilot + claude-code + opencode) - // skill: silently skipped - expect(writes).toHaveLength(10); - expect(skipped).toHaveLength(0); - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — agent writes - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — agent writes', () => { - it('writes transpiled agents to Copilot, Claude Code, and OpenCode directories', async () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(3); - - // Verify files exist on disk - expect(existsSync(join(tmpDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'agents', 'architect.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.opencode', 'agents', 'architect.md'))).toBe(true); - }); - - it('written Copilot agent has correct content', async () => { - const items = [ - canonicalAgent('architect', { model: 'claude-sonnet-4', tools: ['Read', 'Grep'] }), - ]; - const opts = baseOptions(tmpDir, { targets: ['github-copilot'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync( - join(tmpDir, '.github', 'agents', 'architect.agent.md'), - 'utf-8' - ); - expect(content).toContain('name: "architect"'); - expect(content).toContain('description:'); - expect(content).toContain('model: "claude-sonnet-4"'); - expect(content).toContain(' - Read'); - expect(content).toContain(' - Grep'); - expect(content).toContain('Agent body for architect.'); - }); - - it('written Claude Code agent has correct content', async () => { - const items = [ - canonicalAgent('architect', { - tools: ['Read'], - disallowedTools: ['Edit'], - maxTurns: 25, - background: false, - }), - ]; - const opts = baseOptions(tmpDir, { targets: ['claude-code'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync(join(tmpDir, '.claude', 'agents', 'architect.md'), 'utf-8'); - expect(content).toContain('name: "architect"'); - expect(content).toContain('description:'); - expect(content).toContain(' - Read'); - expect(content).toContain('disallowed-tools:'); - expect(content).toContain(' - Edit'); - expect(content).toContain('max-turns: 25'); - expect(content).toContain('background: false'); - expect(content).toContain('Agent body for architect.'); - }); - - it('dry-run reports agent writes without creating files', async () => { - const items = [canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { dryRun: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(3); - expect(result.written).toHaveLength(0); - expect(existsSync(join(tmpDir, '.github', 'agents', 'architect.agent.md'))).toBe(false); - }); - - it('installs native agent content unchanged', async () => { - const items = [nativeAgent('architect', 'github-copilot')]; - const opts = baseOptions(tmpDir); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - const content = readFileSync( - join(tmpDir, '.github', 'agents', 'architect.agent.md'), - 'utf-8' - ); - expect(content).toBe('Native agent content for architect'); - }); - - it('handles mixed rules + prompts + agents in a single pipeline execution', async () => { - const items = [ - canonicalRule('code-style'), - canonicalPrompt('review-code'), - canonicalAgent('architect'), - ]; - const opts = baseOptions(tmpDir, { targets: ['github-copilot', 'claude-code'] as const }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - // rule: 2 agents, prompt: 2 agents, agent: 2 agents = 6 writes - expect(result.written).toHaveLength(6); - - // Rule files - expect( - existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - - // Prompt files - expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'commands', 'review-code.md'))).toBe(true); - - // Agent files - expect(existsSync(join(tmpDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); - expect(existsSync(join(tmpDir, '.claude', 'agents', 'architect.md'))).toBe(true); - }); - }); - - // ------------------------------------------------------------------------- - // planRuleWrites — append mode - // ------------------------------------------------------------------------- - - describe('planRuleWrites — append mode', () => { - it('produces append outputs for copilot and claude-code with per-rule for others', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { append: true }); - - const { writes, skipped } = planRuleWrites(items, opts); - - expect(skipped).toHaveLength(0); - // 4 agents: copilot (AGENTS.md), claude-code (CLAUDE.md), cursor, opencode - expect(writes).toHaveLength(4); - - const copilotWrite = writes.find((w) => w.agent === 'github-copilot'); - expect(copilotWrite).toBeDefined(); - expect(copilotWrite!.planned.output.mode).toBe('append'); - expect(copilotWrite!.planned.output.filename).toBe('AGENTS.md'); - - const claudeWrite = writes.find((w) => w.agent === 'claude-code'); - expect(claudeWrite).toBeDefined(); - expect(claudeWrite!.planned.output.mode).toBe('append'); - expect(claudeWrite!.planned.output.filename).toBe('CLAUDE.md'); - - // Cursor and OpenCode remain per-rule file mode - const cursorWrite = writes.find((w) => w.agent === 'cursor'); - expect(cursorWrite!.planned.output.mode).toBe('write'); - }); - - it('resolves append absolute paths to project root', () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot'] as const, - }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'AGENTS.md')); - }); - - it('append mode does not affect native passthrough', () => { - const items = [nativeRule('lint', 'github-copilot')]; - const opts = baseOptions(tmpDir, { append: true }); - - const { writes } = planRuleWrites(items, opts); - - expect(writes).toHaveLength(1); - expect(writes[0]!.planned.output.mode).toBe('write'); - expect(writes[0]!.planned.output.filename).not.toBe('AGENTS.md'); - }); - - it('append mode does not affect prompts or agents', () => { - const items = [canonicalPrompt('review-code'), canonicalAgent('architect')]; - const opts = baseOptions(tmpDir, { append: true }); - - const { writes } = planRuleWrites(items, opts); - - // prompt: 3 agents, agent: 3 agents = 6 - expect(writes).toHaveLength(6); - for (const w of writes) { - expect(w.planned.output.mode).toBe('write'); - } - }); - }); - - // ------------------------------------------------------------------------- - // executeInstallPipeline — append mode writes - // ------------------------------------------------------------------------- - - describe('executeInstallPipeline — append mode', () => { - it('creates AGENTS.md and CLAUDE.md with marker sections', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot', 'claude-code'] as const, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); - - const agentsMd = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - - const claudeMd = readFileSync(join(tmpDir, 'CLAUDE.md'), 'utf-8'); - expect(claudeMd).toContain(''); - expect(claudeMd).toContain(''); - }); - - it('preserves existing content in target files', async () => { - // Pre-create AGENTS.md with user content - writeFileSync(join(tmpDir, 'AGENTS.md'), '# My Project\n\nCustom instructions here.\n'); - - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot'] as const, - force: true, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); - expect(content).toContain('# My Project'); - expect(content).toContain('Custom instructions here.'); - expect(content).toContain(''); - }); - - it('appends multiple rules to the same file', async () => { - const items = [canonicalRule('code-style'), canonicalRule('security')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot'] as const, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - const content = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); - expect(content).toContain(''); - expect(content).toContain(''); - expect(content).toContain(''); - expect(content).toContain(''); - }); - - it('updates existing marker section on re-install (idempotent)', async () => { - // First install - const items1 = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot'] as const, - }); - - await executeInstallPipeline(items1, opts); - const content1 = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); - - // Re-install same rule - const result2 = await executeInstallPipeline(items1, opts); - expect(result2.success).toBe(true); - - const content2 = readFileSync(join(tmpDir, 'AGENTS.md'), 'utf-8'); - // Should have exactly one section, not duplicated - const startCount = (content2.match(//g) || []).length; - expect(startCount).toBe(1); - }); - - it('dry-run does not write append files', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { - append: true, - targets: ['github-copilot'] as const, - dryRun: true, - }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(1); - expect(result.written).toHaveLength(0); - expect(existsSync(join(tmpDir, 'AGENTS.md'))).toBe(false); - }); - - it('mixes append and per-rule writes in one pipeline', async () => { - const items = [canonicalRule('code-style')]; - const opts = baseOptions(tmpDir, { append: true }); - - const result = await executeInstallPipeline(items, opts); - - expect(result.success).toBe(true); - // 4 writes: AGENTS.md, CLAUDE.md, .cursor/rules, .opencode/rules - expect(result.written).toHaveLength(4); - - // Append targets - expect(existsSync(join(tmpDir, 'AGENTS.md'))).toBe(true); - expect(existsSync(join(tmpDir, 'CLAUDE.md'))).toBe(true); - - // Per-rule targets - expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(tmpDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - }); - }); -}); diff --git a/src/rule-override-parser.test.ts b/src/rule-override-parser.test.ts deleted file mode 100644 index 6af22f5..0000000 --- a/src/rule-override-parser.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseRuleContent } from './rule-parser.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Build a RULES.md string with YAML frontmatter that supports nested objects. - * Uses raw YAML string construction for agent override blocks. - */ -function ruleYaml(yaml: string, body = ''): string { - return `---\n${yaml}\n---\n\n${body}`; -} - -const BASE_YAML = `name: code-style -description: Enforce TypeScript code style conventions -globs: - - "*.ts" - - "*.tsx" -activation: auto`; - -// --------------------------------------------------------------------------- -// Override extraction — happy paths -// --------------------------------------------------------------------------- - -describe('parseRuleContent — per-agent overrides', () => { - it('parses a rule with a github-copilot activation override', () => { - const yaml = `${BASE_YAML} -github-copilot: - activation: always`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides).toBeDefined(); - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - expect(result.warnings).toEqual([]); - }); - - it('parses a rule with a claude-code severity override', () => { - const yaml = `${BASE_YAML} -claude-code: - severity: error`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['claude-code']).toEqual({ - severity: 'error', - }); - expect(result.warnings).toEqual([]); - }); - - it('parses a rule with multiple agent override blocks', () => { - const yaml = `${BASE_YAML} -github-copilot: - activation: always -claude-code: - severity: error -cursor: - activation: manual`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - expect(result.rule.overrides!['claude-code']).toEqual({ - severity: 'error', - }); - expect(result.rule.overrides!['cursor']).toEqual({ - activation: 'manual', - }); - }); - - it('parses override with description field', () => { - const yaml = `${BASE_YAML} -opencode: - description: OpenCode-specific description`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['opencode']).toEqual({ - description: 'OpenCode-specific description', - }); - }); - - it('parses override with globs field', () => { - const yaml = `${BASE_YAML} -cursor: - globs: - - "*.js" - - "*.jsx"`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['cursor']).toEqual({ - globs: ['*.js', '*.jsx'], - }); - }); - - it('parses override with multiple fields', () => { - const yaml = `${BASE_YAML} -github-copilot: - activation: always - description: Always apply for Copilot - severity: error`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - description: 'Always apply for Copilot', - severity: 'error', - }); - }); -}); - -// --------------------------------------------------------------------------- -// No overrides — backward compatibility -// --------------------------------------------------------------------------- - -describe('parseRuleContent — no overrides (backward compatible)', () => { - it('returns undefined overrides when no override blocks present', () => { - const result = parseRuleContent(ruleYaml(BASE_YAML)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides).toBeUndefined(); - expect(result.warnings).toEqual([]); - }); - - it('returns warnings as empty array on success', () => { - const result = parseRuleContent(ruleYaml(BASE_YAML, 'Body text')); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// Unknown agent key warnings -// --------------------------------------------------------------------------- - -describe('parseRuleContent — unknown agent key warnings', () => { - it('warns on unknown agent key', () => { - const yaml = `${BASE_YAML} -fake-agent: - activation: always`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('fake-agent'); - expect(result.rule.overrides).toBeUndefined(); - }); - - it('warns on multiple unknown agent keys', () => { - const yaml = `${BASE_YAML} -not-an-agent: - activation: always -also-not-an-agent: - severity: info`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(2); - expect(result.warnings[0]).toContain('not-an-agent'); - expect(result.warnings[1]).toContain('also-not-an-agent'); - }); -}); - -// --------------------------------------------------------------------------- -// Non-overridable fields are ignored -// --------------------------------------------------------------------------- - -describe('parseRuleContent — non-overridable fields', () => { - it('ignores name in override block', () => { - const yaml = `${BASE_YAML} -github-copilot: - name: different-name - activation: always`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - // name should not appear in overrides; activation should - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - // Base name unchanged - expect(result.rule.name).toBe('code-style'); - }); - - it('ignores schema-version in override block', () => { - const yaml = `${BASE_YAML} -github-copilot: - schema-version: 2 - activation: always`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - }); - - it('ignores body in override block', () => { - const yaml = `${BASE_YAML} -github-copilot: - body: should be ignored - activation: always`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - }); -}); - -// --------------------------------------------------------------------------- -// Override validation errors -// --------------------------------------------------------------------------- - -describe('parseRuleContent — override validation', () => { - it('warns on invalid activation value in override', () => { - const yaml = `${BASE_YAML} -github-copilot: - activation: sometimes`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('github-copilot'); - expect(result.warnings[0]).toContain('activation'); - // Invalid override block should not be in overrides - expect(result.rule.overrides).toBeUndefined(); - }); - - it('warns on non-string severity in override', () => { - const yaml = `${BASE_YAML} -claude-code: - severity: 42`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('claude-code'); - expect(result.warnings[0]).toContain('severity'); - }); - - it('warns on non-array globs in override', () => { - const yaml = `${BASE_YAML} -cursor: - globs: "*.ts"`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('cursor'); - expect(result.warnings[0]).toContain('globs'); - }); - - it('warns on non-object override block', () => { - const yaml = `${BASE_YAML} -github-copilot: just-a-string`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('github-copilot'); - expect(result.warnings[0]).toContain('object'); - }); - - it('accepts valid override blocks alongside invalid ones', () => { - const yaml = `${BASE_YAML} -github-copilot: - activation: always -claude-code: - severity: 42`; - const result = parseRuleContent(ruleYaml(yaml)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - // Valid override should be present - expect(result.rule.overrides!['github-copilot']).toEqual({ - activation: 'always', - }); - // Invalid override should produce a warning - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toContain('claude-code'); - }); -}); diff --git a/src/rule-parser.test.ts b/src/rule-parser.test.ts deleted file mode 100644 index 1ba2786..0000000 --- a/src/rule-parser.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseRuleContent } from './rule-parser.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function rulemd(frontmatter: Record, body = ''): string { - const lines = Object.entries(frontmatter).map(([key, value]) => { - if (Array.isArray(value)) { - return `${key}:\n${value.map((v) => ` - "${v}"`).join('\n')}`; - } - if (typeof value === 'string') { - return `${key}: ${value}`; - } - return `${key}: ${value}`; - }); - return `---\n${lines.join('\n')}\n---\n\n${body}`; -} - -const VALID_FRONTMATTER = { - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - globs: ['*.ts', '*.tsx'], - activation: 'auto', - severity: 'warning', -}; - -// --------------------------------------------------------------------------- -// Happy path -// --------------------------------------------------------------------------- - -describe('parseRuleContent — valid rules', () => { - it('parses a fully specified RULES.md', () => { - const content = rulemd( - VALID_FRONTMATTER, - '## TypeScript Code Style\n\nUse `const` over `let`.' - ); - const result = parseRuleContent(content); - - expect(result.ok).toBe(true); - if (!result.ok) return; - - expect(result.rule).toEqual({ - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - globs: ['*.ts', '*.tsx'], - activation: 'auto', - severity: 'warning', - schemaVersion: 1, - body: '## TypeScript Code Style\n\nUse `const` over `let`.', - }); - }); - - it('defaults activation to "always" when omitted', () => { - const { activation: _, ...fm } = VALID_FRONTMATTER; - const result = parseRuleContent(rulemd(fm)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.activation).toBe('always'); - }); - - it('defaults globs to empty array when omitted', () => { - const { globs: _, ...fm } = VALID_FRONTMATTER; - const result = parseRuleContent(rulemd(fm)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.globs).toEqual([]); - }); - - it('omits severity when not provided', () => { - const { severity: _, ...fm } = VALID_FRONTMATTER; - const result = parseRuleContent(rulemd(fm)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.severity).toBeUndefined(); - }); - - it('defaults schema-version to 1 when omitted', () => { - const result = parseRuleContent(rulemd(VALID_FRONTMATTER)); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.schemaVersion).toBe(1); - }); - - it('accepts schema-version: 1 explicitly', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, 'schema-version': 1 })); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.schemaVersion).toBe(1); - }); - - it('parses all four activation values', () => { - for (const activation of ['always', 'auto', 'manual', 'glob'] as const) { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, activation })); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.activation).toBe(activation); - } - }); - - it('accepts name with only numbers and hyphens', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'rule-1-2-3' })); - expect(result.ok).toBe(true); - }); - - it('accepts single-word kebab-case name', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'style' })); - expect(result.ok).toBe(true); - }); - - it('trims trailing whitespace from body', () => { - const result = parseRuleContent(rulemd(VALID_FRONTMATTER, 'Body text \n\n')); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.body).toBe('Body text'); - }); - - it('accepts empty body', () => { - const result = parseRuleContent(rulemd(VALID_FRONTMATTER)); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.rule.body).toBe(''); - }); -}); - -// --------------------------------------------------------------------------- -// Name validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — name validation', () => { - it('rejects missing name', () => { - const { name: _, ...fm } = VALID_FRONTMATTER; - const result = parseRuleContent(rulemd(fm)); - expect(result).toEqual({ ok: false, error: 'missing required field: name' }); - }); - - it('rejects empty name', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: '' })); - // gray-matter parses `name:` with no value as empty string - expect(result.ok).toBe(false); - }); - - it('rejects name with uppercase letters', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'Code-Style' })); - expect(result).toEqual({ - ok: false, - error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "code-style")', - }); - }); - - it('rejects name with underscores', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'code_style' })); - expect(result).toEqual({ - ok: false, - error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "code-style")', - }); - }); - - it('rejects name with spaces', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'code style' })); - expect(result).toEqual({ - ok: false, - error: 'name must be kebab-case (lowercase alphanumeric and hyphens, e.g. "code-style")', - }); - }); - - it('rejects name with leading hyphen', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: '-code-style' })); - expect(result.ok).toBe(false); - }); - - it('rejects name with trailing hyphen', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'code-style-' })); - expect(result.ok).toBe(false); - }); - - it('rejects name with consecutive hyphens', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 'code--style' })); - expect(result.ok).toBe(false); - }); - - it('rejects path traversal in name', () => { - const result = parseRuleContent( - rulemd({ ...VALID_FRONTMATTER, name: '../../../etc/cron.d/malicious' }) - ); - expect(result.ok).toBe(false); - }); - - it('rejects name exceeding 128 characters', () => { - const longName = 'a'.repeat(129); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: longName })); - expect(result).toEqual({ ok: false, error: 'name exceeds 128 characters' }); - }); - - it('accepts name at exactly 128 characters', () => { - // Build a valid 128-char kebab-case name: "a" repeated 128 times - const maxName = 'a'.repeat(128); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: maxName })); - expect(result.ok).toBe(true); - }); - - it('rejects numeric name (YAML parses bare numbers)', () => { - // gray-matter will parse `name: 123` as a number - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, name: 123 })); - expect(result).toEqual({ ok: false, error: 'name must be a string' }); - }); -}); - -// --------------------------------------------------------------------------- -// Description validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — description validation', () => { - it('rejects missing description', () => { - const { description: _, ...fm } = VALID_FRONTMATTER; - const result = parseRuleContent(rulemd(fm)); - expect(result).toEqual({ ok: false, error: 'missing required field: description' }); - }); - - it('rejects description exceeding 512 characters', () => { - const longDesc = 'x'.repeat(513); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, description: longDesc })); - expect(result).toEqual({ ok: false, error: 'description exceeds 512 characters' }); - }); - - it('accepts description at exactly 512 characters', () => { - const maxDesc = 'x'.repeat(512); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, description: maxDesc })); - expect(result.ok).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// Globs validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — globs validation', () => { - it('rejects non-array globs (bare glob triggers YAML error)', () => { - // `globs: *.ts` without quotes is invalid YAML — `*` is a YAML alias indicator. - // gray-matter's js-yaml parser throws, which we surface as an error. - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, globs: '*.ts' })); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toContain('invalid YAML frontmatter'); - } - }); - - it('rejects non-array globs (quoted string)', () => { - // A properly quoted string value for globs should be rejected as non-array. - const content = `--- -name: code-style -description: Enforce TypeScript code style conventions -globs: "src/**" ---- -`; - const result = parseRuleContent(content); - expect(result).toEqual({ ok: false, error: 'globs must be an array of strings' }); - }); - - it('rejects globs with non-string entries', () => { - const content = `--- -name: code-style -description: Enforce TypeScript code style conventions -globs: - - 123 ---- -`; - const result = parseRuleContent(content); - expect(result).toEqual({ ok: false, error: 'globs[0] must be a string' }); - }); - - it('rejects globs exceeding 50 entries', () => { - const tooMany = Array.from({ length: 51 }, (_, i) => `*.ext${i}`); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, globs: tooMany })); - expect(result).toEqual({ ok: false, error: 'globs exceeds 50 entries' }); - }); - - it('accepts globs at exactly 50 entries', () => { - const maxGlobs = Array.from({ length: 50 }, (_, i) => `*.ext${i}`); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, globs: maxGlobs })); - expect(result.ok).toBe(true); - }); - - it('rejects individual glob exceeding 256 characters', () => { - const longGlob = '*'.repeat(257); - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, globs: [longGlob] })); - expect(result).toEqual({ ok: false, error: 'globs[0] exceeds 256 characters' }); - }); -}); - -// --------------------------------------------------------------------------- -// Activation validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — activation validation', () => { - it('rejects invalid activation value', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, activation: 'sometimes' })); - expect(result).toEqual({ - ok: false, - error: 'activation must be one of: always, auto, manual, glob', - }); - }); - - it('rejects non-string activation', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, activation: true })); - expect(result).toEqual({ ok: false, error: 'activation must be a string' }); - }); -}); - -// --------------------------------------------------------------------------- -// Severity validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — severity validation', () => { - it('rejects non-string severity', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, severity: 42 })); - expect(result).toEqual({ ok: false, error: 'severity must be a string' }); - }); - - it('accepts any string severity', () => { - for (const severity of ['error', 'warning', 'info', 'custom-level']) { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, severity })); - expect(result.ok).toBe(true); - } - }); -}); - -// --------------------------------------------------------------------------- -// Schema version validation -// --------------------------------------------------------------------------- - -describe('parseRuleContent — schema-version validation', () => { - it('rejects unsupported future schema-version', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, 'schema-version': 2 })); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error).toContain('unsupported schema-version 2'); - expect(result.error).toContain('upgrade dotai'); - }); - - it('rejects schema-version 0', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, 'schema-version': 0 })); - expect(result).toEqual({ ok: false, error: 'schema-version must be >= 1' }); - }); - - it('rejects non-integer schema-version', () => { - const result = parseRuleContent(rulemd({ ...VALID_FRONTMATTER, 'schema-version': 1.5 })); - expect(result).toEqual({ ok: false, error: 'schema-version must be an integer' }); - }); - - it('rejects string schema-version', () => { - const content = `--- -name: code-style -description: Enforce TypeScript code style conventions -schema-version: "1" ---- -`; - const result = parseRuleContent(content); - expect(result).toEqual({ ok: false, error: 'schema-version must be an integer' }); - }); -}); diff --git a/src/rule-parser.ts b/src/rule-parser.ts deleted file mode 100644 index fb9e6c8..0000000 --- a/src/rule-parser.ts +++ /dev/null @@ -1,217 +0,0 @@ -import matter from 'gray-matter'; -import type { CanonicalRule, RuleActivation, RuleOverrideFields, TargetAgent } from './types.ts'; -import { - SUPPORTED_SCHEMA_VERSION, - validateName, - validateDescription, - validateSchemaVersion, -} from './validation.ts'; -import { extractOverrides } from './override-parser.ts'; - -// --------------------------------------------------------------------------- -// Rule-specific validation constants -// --------------------------------------------------------------------------- - -/** Maximum number of glob entries. */ -const MAX_GLOBS_COUNT = 50; - -/** Maximum length for each individual glob pattern. */ -const MAX_GLOB_LENGTH = 256; - -/** Valid activation values. */ -const VALID_ACTIVATIONS: readonly RuleActivation[] = ['always', 'auto', 'manual', 'glob'] as const; - -// --------------------------------------------------------------------------- -// Error types -// --------------------------------------------------------------------------- - -/** Result of parsing a RULES.md file. */ -export type ParseRuleResult = - | { ok: true; rule: CanonicalRule; warnings: string[] } - | { ok: false; error: string }; - -// --------------------------------------------------------------------------- -// Validation helpers -// --------------------------------------------------------------------------- - -function validateGlobs(globs: unknown): string | null { - if (globs === undefined || globs === null) { - // Globs are optional — will default to empty array. - return null; - } - if (!Array.isArray(globs)) { - return 'globs must be an array of strings'; - } - if (globs.length > MAX_GLOBS_COUNT) { - return `globs exceeds ${MAX_GLOBS_COUNT} entries`; - } - for (let i = 0; i < globs.length; i++) { - const entry = globs[i]; - if (typeof entry !== 'string') { - return `globs[${i}] must be a string`; - } - if (entry.length > MAX_GLOB_LENGTH) { - return `globs[${i}] exceeds ${MAX_GLOB_LENGTH} characters`; - } - } - return null; -} - -function validateActivation(activation: unknown): string | null { - if (activation === undefined || activation === null) { - // Optional — will default to 'always'. - return null; - } - if (typeof activation !== 'string') { - return 'activation must be a string'; - } - if (!VALID_ACTIVATIONS.includes(activation as RuleActivation)) { - return `activation must be one of: ${VALID_ACTIVATIONS.join(', ')}`; - } - return null; -} - -function validateSeverity(severity: unknown): string | null { - if (severity === undefined || severity === null) { - return null; - } - if (typeof severity !== 'string') { - return 'severity must be a string'; - } - return null; -} - -// --------------------------------------------------------------------------- -// Override support -// --------------------------------------------------------------------------- - -/** Base field names recognized in RULES.md frontmatter (not override blocks). */ -const RULE_BASE_FIELDS: ReadonlySet = new Set([ - 'name', - 'description', - 'globs', - 'activation', - 'severity', - 'schema-version', -]); - -/** Fields that are not allowed in override blocks (identity / structural). */ -const NON_OVERRIDABLE_RULE_FIELDS: ReadonlySet = new Set([ - 'name', - 'schema-version', - 'body', -]); - -/** - * Extract and validate rule override fields from an agent override block. - */ -function extractRuleOverrideFields( - agentData: Record, - _agentName: TargetAgent -): { fields: RuleOverrideFields; error: string | null } { - const fields: RuleOverrideFields = {}; - - for (const key of Object.keys(agentData)) { - if (NON_OVERRIDABLE_RULE_FIELDS.has(key)) { - // Silently ignore non-overridable fields - continue; - } - } - - // Validate and extract each overridable field - if ('description' in agentData) { - const err = validateDescription(agentData.description); - if (err) return { fields, error: err }; - fields.description = agentData.description as string; - } - - if ('globs' in agentData) { - const err = validateGlobs(agentData.globs); - if (err) return { fields, error: err }; - fields.globs = Array.isArray(agentData.globs) ? (agentData.globs as string[]) : []; - } - - if ('activation' in agentData) { - const err = validateActivation(agentData.activation); - if (err) return { fields, error: err }; - fields.activation = agentData.activation as RuleActivation; - } - - if ('severity' in agentData) { - const err = validateSeverity(agentData.severity); - if (err) return { fields, error: err }; - fields.severity = agentData.severity as string; - } - - return { fields, error: null }; -} - -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - -/** - * Parse and validate a RULES.md file from its raw content string. - * - * Returns a discriminated union: `{ ok: true, rule, warnings }` on success, - * `{ ok: false, error }` on validation failure. - */ -export function parseRuleContent(content: string): ParseRuleResult { - let data: Record; - let body: string; - try { - const parsed = matter(content); - data = parsed.data; - body = parsed.content; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: `invalid YAML frontmatter: ${message}` }; - } - - // Validate each field, collecting the first error. - const nameError = validateName(data.name, 'code-style'); - if (nameError) return { ok: false, error: nameError }; - - const descriptionError = validateDescription(data.description); - if (descriptionError) return { ok: false, error: descriptionError }; - - const globsError = validateGlobs(data.globs); - if (globsError) return { ok: false, error: globsError }; - - const activationError = validateActivation(data.activation); - if (activationError) return { ok: false, error: activationError }; - - const severityError = validateSeverity(data.severity); - if (severityError) return { ok: false, error: severityError }; - - const schemaVersionRaw = data['schema-version']; - const versionError = validateSchemaVersion(schemaVersionRaw); - if (versionError) return { ok: false, error: versionError }; - - // Extract per-agent overrides - const { overrides, warnings } = extractOverrides( - data, - RULE_BASE_FIELDS, - extractRuleOverrideFields - ); - - const rule: CanonicalRule = { - name: data.name as string, - description: data.description as string, - globs: Array.isArray(data.globs) ? (data.globs as string[]) : [], - activation: (data.activation as RuleActivation) ?? 'always', - schemaVersion: - typeof schemaVersionRaw === 'number' ? schemaVersionRaw : SUPPORTED_SCHEMA_VERSION, - body: body.trim(), - }; - - if (data.severity !== undefined && data.severity !== null) { - rule.severity = data.severity as string; - } - - if (overrides) { - rule.overrides = overrides; - } - - return { ok: true, rule, warnings }; -} diff --git a/src/rule-transpiler-overrides.test.ts b/src/rule-transpiler-overrides.test.ts deleted file mode 100644 index 824447d..0000000 --- a/src/rule-transpiler-overrides.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - transpileRule, - cursorRuleTranspiler, - copilotRuleTranspiler, - claudeCodeRuleTranspiler, -} from './rule-transpilers.ts'; -import { mergeOverrides } from './override-parser.ts'; -import type { CanonicalRule, DiscoveredItem, TargetAgent } from './types.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeRule(overrides?: Partial): CanonicalRule { - return { - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - globs: ['*.ts', '*.tsx'], - activation: 'auto', - schemaVersion: 1, - body: '## TypeScript Code Style\n\n- Use `const` over `let`', - ...overrides, - }; -} - -function makeDiscoveredItem( - overrideYaml: string, - overrides: Partial = {} -): DiscoveredItem { - return { - type: 'rule', - format: 'canonical', - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - sourcePath: '/repo/rules/code-style/RULES.md', - rawContent: [ - '---', - 'name: code-style', - 'description: Enforce TypeScript code style conventions', - 'globs:', - ' - "*.ts"', - ' - "*.tsx"', - 'activation: auto', - overrideYaml, - '---', - '', - '## TypeScript Code Style', - '', - '- Use `const` over `let`', - ].join('\n'), - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// mergeOverrides integration with transpilers -// --------------------------------------------------------------------------- - -describe('rule transpiler override merging', () => { - it('copilot uses overridden activation: always', () => { - const rule = makeRule({ - overrides: { - 'github-copilot': { activation: 'always' }, - }, - }); - const merged = mergeOverrides(rule, 'github-copilot') as CanonicalRule; - const output = copilotRuleTranspiler.transform(merged, 'github-copilot'); - - // activation: always → applyTo: "**" (same as base for copilot, - // but the rule's base activation is "auto" which also maps to "**") - // More meaningful: test that merged.activation === 'always' - expect(merged.activation).toBe('always'); - expect(output.content).toContain('applyTo: "**"'); - }); - - it('cursor uses overridden activation: always', () => { - const rule = makeRule({ - overrides: { - cursor: { activation: 'always' }, - }, - }); - const merged = mergeOverrides(rule, 'cursor') as CanonicalRule; - const output = cursorRuleTranspiler.transform(merged, 'cursor'); - - expect(merged.activation).toBe('always'); - expect(output.content).toContain('alwaysApply: true'); - }); - - it('non-overridden agent uses base activation', () => { - const rule = makeRule({ - overrides: { - 'github-copilot': { activation: 'always' }, - }, - }); - const merged = mergeOverrides(rule, 'claude-code') as CanonicalRule; - - expect(merged.activation).toBe('auto'); - }); - - it('claude-code severity override appears in output', () => { - const rule = makeRule({ - severity: 'warning', - overrides: { - 'claude-code': { severity: 'error' }, - }, - }); - const mergedClaude = mergeOverrides(rule, 'claude-code') as CanonicalRule; - const mergedCopilot = mergeOverrides(rule, 'github-copilot') as CanonicalRule; - - expect(mergedClaude.severity).toBe('error'); - expect(mergedCopilot.severity).toBe('warning'); - }); - - it('description override is used in transpiled output', () => { - const rule = makeRule({ - overrides: { - 'claude-code': { description: 'Claude-specific description' }, - }, - }); - const merged = mergeOverrides(rule, 'claude-code') as CanonicalRule; - const output = claudeCodeRuleTranspiler.transform(merged, 'claude-code'); - - expect(output.content).toContain('description: "Claude-specific description"'); - }); - - it('base description used for non-overridden agent', () => { - const rule = makeRule({ - overrides: { - 'claude-code': { description: 'Claude-specific description' }, - }, - }); - const merged = mergeOverrides(rule, 'cursor') as CanonicalRule; - const output = cursorRuleTranspiler.transform(merged, 'cursor'); - - expect(output.content).toContain('description: "Enforce TypeScript code style conventions"'); - }); - - it('globs override used in transpiled output', () => { - const rule = makeRule({ - activation: 'glob', - overrides: { - cursor: { globs: ['*.py'] }, - }, - }); - const merged = mergeOverrides(rule, 'cursor') as CanonicalRule; - const output = cursorRuleTranspiler.transform(merged, 'cursor'); - - expect(output.content).toContain('*.py'); - expect(output.content).not.toContain('*.ts'); - }); - - it('rule with no overrides produces identical output to today', () => { - const rule = makeRule(); - const merged = mergeOverrides(rule, 'cursor') as CanonicalRule; - const output = cursorRuleTranspiler.transform(merged, 'cursor'); - const directOutput = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toBe(directOutput.content); - }); -}); - -// --------------------------------------------------------------------------- -// transpileRule integration (end-to-end with raw content) -// --------------------------------------------------------------------------- - -describe('transpileRule with overrides in raw content', () => { - it('copilot uses overridden activation from raw content', () => { - const item = makeDiscoveredItem('github-copilot:\n activation: always'); - const copilotOutput = transpileRule(item, 'github-copilot'); - - expect(copilotOutput).not.toBeNull(); - // Copilot maps all non-glob activations to applyTo: "**" - expect(copilotOutput!.content).toContain('applyTo: "**"'); - }); - - it('cursor uses base activation when copilot has override', () => { - const item = makeDiscoveredItem('github-copilot:\n activation: always'); - const cursorOutput = transpileRule(item, 'cursor'); - - expect(cursorOutput).not.toBeNull(); - // Base activation is "auto" → alwaysApply: false - expect(cursorOutput!.content).toContain('alwaysApply: false'); - }); - - it('cursor uses overridden activation: always from raw content', () => { - const item = makeDiscoveredItem('cursor:\n activation: always'); - const cursorOutput = transpileRule(item, 'cursor'); - - expect(cursorOutput).not.toBeNull(); - expect(cursorOutput!.content).toContain('alwaysApply: true'); - }); - - it('cursor uses base activation when override is for different agent', () => { - const item = makeDiscoveredItem('cursor:\n activation: always'); - const claudeOutput = transpileRule(item, 'claude-code'); - - expect(claudeOutput).not.toBeNull(); - // Claude Code doesn't directly expose activation, but uses base description - expect(claudeOutput!.content).toContain( - 'description: "Enforce TypeScript code style conventions"' - ); - }); - - it('no overrides produces same output as before', () => { - const item = makeDiscoveredItem(''); - const output = transpileRule(item, 'cursor'); - - expect(output).not.toBeNull(); - expect(output!.content).toContain('alwaysApply: false'); - }); -}); diff --git a/src/rule-transpilers.test.ts b/src/rule-transpilers.test.ts deleted file mode 100644 index 218854a..0000000 --- a/src/rule-transpilers.test.ts +++ /dev/null @@ -1,864 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - cursorRuleTranspiler, - copilotRuleTranspiler, - claudeCodeRuleTranspiler, - copilotAppendRuleTranspiler, - claudeCodeAppendRuleTranspiler, - nativePassthrough, - ruleTranspilers, - appendRuleTranspilers, - transpileRule, - transpileRuleForAllAgents, - quoteYaml, -} from './rule-transpilers.ts'; -import { TARGET_AGENTS } from './target-agents.ts'; -import type { CanonicalRule, DiscoveredItem, RuleActivation, TargetAgent } from './types.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeRule(overrides: Partial = {}): CanonicalRule { - return { - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - globs: ['*.ts', '*.tsx'], - activation: 'auto', - schemaVersion: 1, - body: '## TypeScript Code Style\n\n- Use `const` over `let` wherever possible', - ...overrides, - }; -} - -function makeDiscoveredItem(overrides: Partial = {}): DiscoveredItem { - return { - type: 'rule', - format: 'canonical', - name: 'code-style', - description: 'Enforce TypeScript code style conventions', - sourcePath: '/repo/rules/code-style/RULES.md', - rawContent: [ - '---', - 'name: code-style', - 'description: Enforce TypeScript code style conventions', - 'globs:', - ' - "*.ts"', - ' - "*.tsx"', - 'activation: auto', - '---', - '', - '## TypeScript Code Style', - '', - '- Use `const` over `let` wherever possible', - ].join('\n'), - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// canTranspile -// --------------------------------------------------------------------------- - -describe('canTranspile', () => { - const canonicalRule = makeDiscoveredItem(); - const nativeRule = makeDiscoveredItem({ format: 'native:cursor' }); - const skillItem = makeDiscoveredItem({ type: 'skill' }); - - it.each([ - ['cursor', cursorRuleTranspiler], - ['copilot', copilotRuleTranspiler], - ['claude-code', claudeCodeRuleTranspiler], - ] as const)('%s accepts canonical rules', (_name, transpiler) => { - expect(transpiler.canTranspile(canonicalRule)).toBe(true); - }); - - it.each([ - ['cursor', cursorRuleTranspiler], - ['copilot', copilotRuleTranspiler], - ['claude-code', claudeCodeRuleTranspiler], - ] as const)('%s rejects native rules', (_name, transpiler) => { - expect(transpiler.canTranspile(nativeRule)).toBe(false); - }); - - it.each([ - ['cursor', cursorRuleTranspiler], - ['copilot', copilotRuleTranspiler], - ['claude-code', claudeCodeRuleTranspiler], - ] as const)('%s rejects skill items', (_name, transpiler) => { - expect(transpiler.canTranspile(skillItem)).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// Cursor transpiler -// --------------------------------------------------------------------------- - -describe('Cursor transpiler', () => { - it('produces .mdc file in .cursor/rules/', () => { - const rule = makeRule(); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.filename).toBe('code-style.mdc'); - expect(output.outputDir).toBe('.cursor/rules'); - expect(output.mode).toBe('write'); - }); - - it('includes description in frontmatter', () => { - const rule = makeRule(); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('description: "Enforce TypeScript code style conventions"'); - }); - - it('sets alwaysApply: true for always activation', () => { - const rule = makeRule({ activation: 'always' }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('alwaysApply: true'); - }); - - it('sets alwaysApply: false for auto activation', () => { - const rule = makeRule({ activation: 'auto' }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('alwaysApply: false'); - }); - - it('sets alwaysApply: false for manual activation', () => { - const rule = makeRule({ activation: 'manual' }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('alwaysApply: false'); - }); - - it('includes comma-separated globs for glob activation', () => { - const rule = makeRule({ activation: 'glob', globs: ['*.ts', '*.tsx'] }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('globs: *.ts, *.tsx'); - expect(output.content).toContain('alwaysApply: false'); - }); - - it('omits globs for non-glob activation', () => { - const rule = makeRule({ activation: 'always', globs: ['*.ts'] }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).not.toMatch(/^globs:/m); - }); - - it('includes rule body after frontmatter', () => { - const rule = makeRule({ body: 'Use const over let.' }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).toContain('---\n\nUse const over let.\n'); - }); - - it('handles empty globs with glob activation', () => { - const rule = makeRule({ activation: 'glob', globs: [] }); - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - - expect(output.content).not.toMatch(/^globs:/m); - }); -}); - -// --------------------------------------------------------------------------- -// Copilot transpiler -// --------------------------------------------------------------------------- - -describe('Copilot transpiler', () => { - it('produces .instructions.md file in .github/instructions/', () => { - const rule = makeRule(); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.filename).toBe('code-style.instructions.md'); - expect(output.outputDir).toBe('.github/instructions'); - expect(output.mode).toBe('write'); - }); - - it('sets applyTo to glob list for glob activation', () => { - const rule = makeRule({ activation: 'glob', globs: ['*.ts', '*.tsx'] }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('applyTo: "*.ts, *.tsx"'); - }); - - it('sets applyTo to ** for always activation', () => { - const rule = makeRule({ activation: 'always' }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('applyTo: "**"'); - }); - - it('sets applyTo to ** for auto activation', () => { - const rule = makeRule({ activation: 'auto' }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('applyTo: "**"'); - }); - - it('sets applyTo to ** for manual activation', () => { - const rule = makeRule({ activation: 'manual' }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('applyTo: "**"'); - }); - - it('includes rule body after frontmatter', () => { - const rule = makeRule({ body: 'Use const over let.' }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('---\n\nUse const over let.\n'); - }); - - it('sets applyTo to ** when glob activation but empty globs', () => { - const rule = makeRule({ activation: 'glob', globs: [] }); - const output = copilotRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('applyTo: "**"'); - }); -}); - -// --------------------------------------------------------------------------- -// Claude Code transpiler -// --------------------------------------------------------------------------- - -describe('Claude Code transpiler', () => { - it('produces .md file in .claude/rules/', () => { - const rule = makeRule(); - const output = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.filename).toBe('code-style.md'); - expect(output.outputDir).toBe('.claude/rules'); - expect(output.mode).toBe('write'); - }); - - it('includes description in frontmatter', () => { - const rule = makeRule(); - const output = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('description: "Enforce TypeScript code style conventions"'); - }); - - it('includes globs array when present', () => { - const rule = makeRule({ globs: ['*.ts', '*.tsx'] }); - const output = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('globs:'); - expect(output.content).toContain(' - "*.ts"'); - expect(output.content).toContain(' - "*.tsx"'); - }); - - it('omits globs when empty', () => { - const rule = makeRule({ globs: [] }); - const output = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).not.toMatch(/^globs:/m); - }); - - it('includes rule body after frontmatter', () => { - const rule = makeRule({ body: 'Use const over let.' }); - const output = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('---\n\nUse const over let.\n'); - }); -}); - -// --------------------------------------------------------------------------- -// Copilot append transpiler -// --------------------------------------------------------------------------- - -describe('Copilot append transpiler', () => { - it('produces AGENTS.md in project root with append mode', () => { - const rule = makeRule(); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.filename).toBe('AGENTS.md'); - expect(output.outputDir).toBe('.'); - expect(output.mode).toBe('append'); - }); - - it('includes rule name as h2 heading', () => { - const rule = makeRule(); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('## code-style'); - }); - - it('includes description as blockquote', () => { - const rule = makeRule(); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('> Enforce TypeScript code style conventions'); - }); - - it('includes "Applies to" with globs for glob activation', () => { - const rule = makeRule({ activation: 'glob', globs: ['*.ts', '*.tsx'] }); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('**Applies to:** `*.ts`, `*.tsx`'); - }); - - it('omits "Applies to" for non-glob activation', () => { - const rule = makeRule({ activation: 'always' }); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).not.toContain('Applies to'); - }); - - it('omits "Applies to" for auto activation', () => { - const rule = makeRule({ activation: 'auto' }); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).not.toContain('Applies to'); - }); - - it('includes rule body', () => { - const rule = makeRule({ body: 'Use const over let.' }); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).toContain('Use const over let.'); - }); - - it('omits "Applies to" when glob activation but empty globs', () => { - const rule = makeRule({ activation: 'glob', globs: [] }); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).not.toContain('Applies to'); - }); - - it('has no YAML frontmatter', () => { - const rule = makeRule(); - const output = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - - expect(output.content).not.toContain('---'); - expect(output.content).not.toContain('applyTo'); - }); - - it('accepts canonical rules in canTranspile', () => { - const item = makeDiscoveredItem(); - expect(copilotAppendRuleTranspiler.canTranspile(item)).toBe(true); - }); - - it('rejects non-canonical rules in canTranspile', () => { - const item = makeDiscoveredItem({ format: 'native:cursor' }); - expect(copilotAppendRuleTranspiler.canTranspile(item)).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// Claude Code append transpiler -// --------------------------------------------------------------------------- - -describe('Claude Code append transpiler', () => { - it('produces CLAUDE.md in project root with append mode', () => { - const rule = makeRule(); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.filename).toBe('CLAUDE.md'); - expect(output.outputDir).toBe('.'); - expect(output.mode).toBe('append'); - }); - - it('includes rule name as h2 heading', () => { - const rule = makeRule(); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('## code-style'); - }); - - it('includes description as blockquote', () => { - const rule = makeRule(); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('> Enforce TypeScript code style conventions'); - }); - - it('includes "Applies to" with globs when present', () => { - const rule = makeRule({ globs: ['*.ts', '*.tsx'] }); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('**Applies to:** `*.ts`, `*.tsx`'); - }); - - it('omits "Applies to" when no globs', () => { - const rule = makeRule({ globs: [] }); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).not.toContain('Applies to'); - }); - - it('includes rule body', () => { - const rule = makeRule({ body: 'Use const over let.' }); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('Use const over let.'); - }); - - it('has no YAML frontmatter', () => { - const rule = makeRule(); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).not.toContain('---'); - expect(output.content).not.toContain('description:'); - }); - - it('shows globs regardless of activation mode', () => { - // Claude Code append shows globs whenever they exist, regardless of activation - const rule = makeRule({ activation: 'always', globs: ['*.ts'] }); - const output = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - - expect(output.content).toContain('**Applies to:** `*.ts`'); - }); - - it('accepts canonical rules in canTranspile', () => { - const item = makeDiscoveredItem(); - expect(claudeCodeAppendRuleTranspiler.canTranspile(item)).toBe(true); - }); - - it('rejects non-canonical rules in canTranspile', () => { - const item = makeDiscoveredItem({ format: 'native:cursor' }); - expect(claudeCodeAppendRuleTranspiler.canTranspile(item)).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// Native passthrough -// --------------------------------------------------------------------------- - -describe('nativePassthrough', () => { - it('returns TranspiledOutput for matching agent', () => { - const item = makeDiscoveredItem({ - format: 'native:cursor', - name: 'my-rule', - rawContent: 'Native cursor rule content', - }); - - const output = nativePassthrough(item, 'cursor'); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('my-rule.mdc'); - expect(output!.content).toBe('Native cursor rule content'); - expect(output!.outputDir).toBe('.cursor/rules'); - expect(output!.mode).toBe('write'); - }); - - it('returns null for non-matching agent', () => { - const item = makeDiscoveredItem({ format: 'native:cursor' }); - - expect(nativePassthrough(item, 'opencode')).toBeNull(); - expect(nativePassthrough(item, 'claude-code')).toBeNull(); - expect(nativePassthrough(item, 'github-copilot')).toBeNull(); - expect(nativePassthrough(item, 'claude-code')).toBeNull(); - }); - - it('uses correct extension for each agent', () => { - const agents: Array<[TargetAgent, string, string]> = [ - ['cursor', '.mdc', '.cursor/rules'], - ['github-copilot', '.instructions.md', '.github/instructions'], - ['claude-code', '.md', '.claude/rules'], - ['opencode', '.md', '.opencode/rules'], - ]; - - for (const [agent, expectedExt, expectedDir] of agents) { - const item = makeDiscoveredItem({ - format: `native:${agent}`, - name: 'test-rule', - rawContent: `content for ${agent}`, - }); - - const output = nativePassthrough(item, agent); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe(`test-rule${expectedExt}`); - expect(output!.outputDir).toBe(expectedDir); - } - }); - - it('preserves raw content unchanged', () => { - const rawContent = '---\ncustom: frontmatter\n---\n\nAgent-specific content here'; - const item = makeDiscoveredItem({ - format: 'native:opencode', - rawContent, - }); - - const output = nativePassthrough(item, 'opencode'); - - expect(output!.content).toBe(rawContent); - }); -}); - -// --------------------------------------------------------------------------- -// transpileRule (integrated) -// --------------------------------------------------------------------------- - -describe('transpileRule', () => { - it('transpiles canonical rule for cursor', () => { - const item = makeDiscoveredItem(); - const output = transpileRule(item, 'cursor'); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('code-style.mdc'); - expect(output!.outputDir).toBe('.cursor/rules'); - expect(output!.content).toContain('alwaysApply: false'); - }); - - it('transpiles canonical rule for all 6 agents', () => { - const item = makeDiscoveredItem(); - - for (const agent of TARGET_AGENTS) { - const output = transpileRule(item, agent); - expect(output).not.toBeNull(); - expect(output!.mode).toBe('write'); - } - }); - - it('returns null for invalid canonical content', () => { - const item = makeDiscoveredItem({ rawContent: '---\n---\n\nNo frontmatter' }); - - const output = transpileRule(item, 'cursor'); - - expect(output).toBeNull(); - }); - - it('uses native passthrough for native format items', () => { - const item = makeDiscoveredItem({ - format: 'native:cursor', - rawContent: 'Native content', - }); - - const cursorOutput = transpileRule(item, 'cursor'); - expect(cursorOutput).not.toBeNull(); - expect(cursorOutput!.content).toBe('Native content'); - - const claudeOutput = transpileRule(item, 'claude-code'); - expect(claudeOutput).toBeNull(); - }); - - it('uses append transpiler for copilot when append=true', () => { - const item = makeDiscoveredItem(); - const output = transpileRule(item, 'github-copilot', true); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('AGENTS.md'); - expect(output!.outputDir).toBe('.'); - expect(output!.mode).toBe('append'); - expect(output!.content).toContain('## code-style'); - }); - - it('uses append transpiler for claude-code when append=true', () => { - const item = makeDiscoveredItem(); - const output = transpileRule(item, 'claude-code', true); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('CLAUDE.md'); - expect(output!.outputDir).toBe('.'); - expect(output!.mode).toBe('append'); - expect(output!.content).toContain('## code-style'); - }); - - it('falls back to per-rule file for cursor when append=true', () => { - const item = makeDiscoveredItem(); - const output = transpileRule(item, 'cursor', true); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('code-style.mdc'); - expect(output!.outputDir).toBe('.cursor/rules'); - expect(output!.mode).toBe('write'); - }); - - it('falls back to per-rule file for opencode when append=true', () => { - const item = makeDiscoveredItem(); - const output = transpileRule(item, 'opencode', true); - - expect(output).not.toBeNull(); - expect(output!.filename).toBe('code-style.md'); - expect(output!.outputDir).toBe('.opencode/rules'); - expect(output!.mode).toBe('write'); - }); - - it('uses native passthrough even when append=true', () => { - const item = makeDiscoveredItem({ - format: 'native:github-copilot', - rawContent: 'Native copilot content', - }); - - const output = transpileRule(item, 'github-copilot', true); - expect(output).not.toBeNull(); - expect(output!.content).toBe('Native copilot content'); - expect(output!.mode).toBe('write'); - }); -}); - -// --------------------------------------------------------------------------- -// transpileRuleForAllAgents -// --------------------------------------------------------------------------- - -describe('transpileRuleForAllAgents', () => { - it('produces outputs for all 4 agents from canonical rule', () => { - const item = makeDiscoveredItem(); - const outputs = transpileRuleForAllAgents(item, TARGET_AGENTS); - - expect(outputs).toHaveLength(4); - - const dirs = outputs.map((o) => o.outputDir).sort(); - expect(dirs).toEqual([ - '.claude/rules', - '.cursor/rules', - '.github/instructions', - '.opencode/rules', - ]); - }); - - it('produces output for only matching agent from native rule', () => { - const item = makeDiscoveredItem({ - format: 'native:cursor', - rawContent: 'Cursor-specific content', - }); - - const outputs = transpileRuleForAllAgents(item, TARGET_AGENTS); - - expect(outputs).toHaveLength(1); - const first = outputs[0]!; - expect(first.outputDir).toBe('.cursor/rules'); - expect(first.content).toBe('Cursor-specific content'); - }); - - it('handles subset of target agents', () => { - const item = makeDiscoveredItem(); - const agents: TargetAgent[] = ['cursor', 'opencode']; - - const outputs = transpileRuleForAllAgents(item, agents); - - expect(outputs).toHaveLength(2); - expect(outputs.map((o) => o.outputDir).sort()).toEqual(['.cursor/rules', '.opencode/rules']); - }); - - it('returns empty array for native rule with no matching agent in subset', () => { - const item = makeDiscoveredItem({ format: 'native:cursor' }); - - const outputs = transpileRuleForAllAgents(item, ['opencode', 'claude-code']); - - expect(outputs).toHaveLength(0); - }); - - it('uses append transpilers for copilot and claude-code when append=true', () => { - const item = makeDiscoveredItem(); - const outputs = transpileRuleForAllAgents(item, TARGET_AGENTS, true); - - expect(outputs).toHaveLength(4); - - const appendOutputs = outputs.filter((o) => o.mode === 'append'); - expect(appendOutputs).toHaveLength(2); - - const appendFiles = appendOutputs.map((o) => o.filename).sort(); - expect(appendFiles).toEqual(['AGENTS.md', 'CLAUDE.md']); - - const writeOutputs = outputs.filter((o) => o.mode === 'write'); - expect(writeOutputs).toHaveLength(2); - - const writeDirs = writeOutputs.map((o) => o.outputDir).sort(); - expect(writeDirs).toEqual(['.cursor/rules', '.opencode/rules']); - }); - - it('mixes per-rule and append outputs correctly', () => { - const item = makeDiscoveredItem(); - const agents: TargetAgent[] = ['github-copilot', 'cursor']; - const outputs = transpileRuleForAllAgents(item, agents, true); - - expect(outputs).toHaveLength(2); - expect(outputs[0]!.filename).toBe('AGENTS.md'); - expect(outputs[0]!.mode).toBe('append'); - expect(outputs[1]!.filename).toBe('code-style.mdc'); - expect(outputs[1]!.mode).toBe('write'); - }); -}); - -// --------------------------------------------------------------------------- -// Transpiler registry -// --------------------------------------------------------------------------- - -describe('ruleTranspilers registry', () => { - it('has entries for all 4 target agents', () => { - expect(Object.keys(ruleTranspilers).sort()).toEqual([...TARGET_AGENTS].sort()); - }); - - it('all entries implement canTranspile and transform', () => { - for (const agent of TARGET_AGENTS) { - const transpiler = ruleTranspilers[agent]; - expect(typeof transpiler.canTranspile).toBe('function'); - expect(typeof transpiler.transform).toBe('function'); - } - }); -}); - -describe('appendRuleTranspilers registry', () => { - it('has entries for copilot and claude-code only', () => { - expect(Object.keys(appendRuleTranspilers).sort()).toEqual(['claude-code', 'github-copilot']); - }); - - it('all entries implement canTranspile and transform', () => { - for (const transpiler of Object.values(appendRuleTranspilers)) { - expect(typeof transpiler!.canTranspile).toBe('function'); - expect(typeof transpiler!.transform).toBe('function'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Cross-cutting: activation mapping across all agents -// --------------------------------------------------------------------------- - -describe('activation mapping across agents', () => { - const activations: RuleActivation[] = ['always', 'auto', 'manual', 'glob']; - - it.each(activations)('all transpilers handle "%s" activation without errors', (activation) => { - const rule = makeRule({ - activation, - globs: activation === 'glob' ? ['*.ts'] : [], - }); - - for (const agent of TARGET_AGENTS) { - const output = ruleTranspilers[agent].transform(rule, agent); - expect(output).toBeDefined(); - expect(output.content.length).toBeGreaterThan(0); - expect(output.mode).toBe('write'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Edge cases -// --------------------------------------------------------------------------- - -describe('edge cases', () => { - it('handles rule with empty body', () => { - const rule = makeRule({ body: '' }); - - for (const agent of TARGET_AGENTS) { - const output = ruleTranspilers[agent].transform(rule, agent); - expect(output.content).toBeDefined(); - } - }); - - it('handles rule with very long body', () => { - const body = 'x'.repeat(10000); - const rule = makeRule({ body }); - - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(output.content).toContain(body); - }); - - it('handles rule with single glob', () => { - const rule = makeRule({ activation: 'glob', globs: ['*.py'] }); - - const cursorOutput = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(cursorOutput.content).toContain('globs: *.py'); - - const copilotOutput = copilotRuleTranspiler.transform(rule, 'github-copilot'); - expect(copilotOutput.content).toContain('applyTo: "*.py"'); - }); - - it('preserves markdown formatting in body', () => { - const body = [ - '## Heading', - '', - '- List item 1', - '- List item 2', - '', - '```typescript', - 'const x = 1;', - '```', - ].join('\n'); - const rule = makeRule({ body }); - - for (const agent of TARGET_AGENTS) { - const output = ruleTranspilers[agent].transform(rule, agent); - expect(output.content).toContain('## Heading'); - expect(output.content).toContain('```typescript'); - } - }); - - it('handles rule name with numbers', () => { - const rule = makeRule({ name: 'rule-v2' }); - - const output = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(output.filename).toBe('rule-v2.mdc'); - }); -}); - -// --------------------------------------------------------------------------- -// quoteYaml -// --------------------------------------------------------------------------- - -describe('quoteYaml', () => { - it('wraps plain string in double quotes', () => { - expect(quoteYaml('hello world')).toBe('"hello world"'); - }); - - it('escapes internal double quotes', () => { - expect(quoteYaml('say "hello"')).toBe('"say \\"hello\\""'); - }); - - it('escapes backslashes', () => { - expect(quoteYaml('path\\to\\file')).toBe('"path\\\\to\\\\file"'); - }); - - it('handles colons safely', () => { - expect(quoteYaml('key: value')).toBe('"key: value"'); - }); - - it('handles empty string', () => { - expect(quoteYaml('')).toBe('""'); - }); - - it('handles string with mixed special characters', () => { - expect(quoteYaml('desc: "quoted" and \\backslash')).toBe( - '"desc: \\"quoted\\" and \\\\backslash"' - ); - }); -}); - -// --------------------------------------------------------------------------- -// YAML injection prevention -// --------------------------------------------------------------------------- - -describe('YAML injection prevention', () => { - it('descriptions containing colons produce valid YAML in all frontmatter transpilers', () => { - const rule = makeRule({ description: 'Use this: always follow the rules' }); - - // Cursor and Claude Code both use YAML frontmatter with description - const cursorOutput = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(cursorOutput.content).toContain('description: "Use this: always follow the rules"'); - - const claudeOutput = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - expect(claudeOutput.content).toContain('description: "Use this: always follow the rules"'); - }); - - it('descriptions containing double quotes are escaped', () => { - const rule = makeRule({ description: 'Use "strict" mode always' }); - - const cursorOutput = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(cursorOutput.content).toContain('description: "Use \\"strict\\" mode always"'); - - const claudeOutput = claudeCodeRuleTranspiler.transform(rule, 'claude-code'); - expect(claudeOutput.content).toContain('description: "Use \\"strict\\" mode always"'); - }); - - it('descriptions containing backslashes are escaped', () => { - const rule = makeRule({ description: 'Use path\\to\\file format' }); - - const cursorOutput = cursorRuleTranspiler.transform(rule, 'cursor'); - expect(cursorOutput.content).toContain('description: "Use path\\\\to\\\\file format"'); - }); - - it('append transpilers pass descriptions through as markdown (no YAML quoting)', () => { - const rule = makeRule({ description: 'Use this: always follow the "rules"' }); - - const copilotAppendOutput = copilotAppendRuleTranspiler.transform(rule, 'github-copilot'); - expect(copilotAppendOutput.content).toContain('> Use this: always follow the "rules"'); - - const claudeAppendOutput = claudeCodeAppendRuleTranspiler.transform(rule, 'claude-code'); - expect(claudeAppendOutput.content).toContain('> Use this: always follow the "rules"'); - }); -}); diff --git a/src/rule-transpilers.ts b/src/rule-transpilers.ts deleted file mode 100644 index 0487d5c..0000000 --- a/src/rule-transpilers.ts +++ /dev/null @@ -1,382 +0,0 @@ -import type { Transpiler } from './transpiler.ts'; -import type { - CanonicalRule, - ContextFormat, - DiscoveredItem, - TargetAgent, - TranspiledOutput, -} from './types.ts'; -import { parseRuleContent } from './rule-parser.ts'; -import { getTargetAgentConfig } from './target-agents.ts'; -import { mergeOverrides } from './override-parser.ts'; - -// --------------------------------------------------------------------------- -// Rule transpilers — canonical RULES.md → per-agent output -// -// Each transpiler converts a CanonicalRule into the target agent's native -// rule file format. The `Transpiler` interface guarantees -// consistent shape across all implementations. -// -// Reference: dotai-plan.md Phase 5 (Transpilation Engine) -// Activation mapping: dotai-plan.md Phase 2 (Activation mapping table) -// --------------------------------------------------------------------------- - -/** - * Quote a string value for safe inclusion in YAML frontmatter. - * Wraps the value in double quotes and escapes internal double-quote - * and backslash characters. This prevents YAML injection from values - * containing colons, quotes, or other special characters. - */ -export function quoteYaml(value: string): string { - const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; -} - -// --------------------------------------------------------------------------- -// Cursor transpiler (.cursor/rules/*.mdc) -// -// Cursor uses YAML frontmatter with: -// - description: string -// - globs: string (comma-separated) — only when activation is "glob" -// - alwaysApply: boolean — true when activation is "always" -// --------------------------------------------------------------------------- - -function cursorAlwaysApply(rule: CanonicalRule): boolean { - return rule.activation === 'always'; -} - -function cursorGlobs(rule: CanonicalRule): string | undefined { - if (rule.activation === 'glob' && rule.globs.length > 0) { - return rule.globs.join(', '); - } - return undefined; -} - -export const cursorRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const config = getTargetAgentConfig('cursor'); - const lines: string[] = ['---']; - lines.push(`description: ${quoteYaml(rule.description)}`); - - const globs = cursorGlobs(rule); - if (globs !== undefined) { - lines.push(`globs: ${globs}`); - } - - lines.push(`alwaysApply: ${cursorAlwaysApply(rule)}`); - lines.push('---'); - lines.push(''); - lines.push(rule.body); - lines.push(''); - - return { - filename: `${rule.name}${config.rulesConfig.extension}`, - content: lines.join('\n'), - outputDir: config.rulesConfig.outputDir, - mode: 'write', - }; - }, -}; - -// --------------------------------------------------------------------------- -// Copilot transpiler (.github/instructions/*.instructions.md) -// -// Copilot uses YAML frontmatter with: -// - applyTo: string (glob pattern or "**" for always) -// --------------------------------------------------------------------------- - -function copilotApplyTo(rule: CanonicalRule): string { - if (rule.activation === 'glob' && rule.globs.length > 0) { - // Copilot applyTo supports a single glob or comma-separated globs - return rule.globs.join(', '); - } - // "always", "auto", "manual" all map to apply-to-all - return '**'; -} - -export const copilotRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const config = getTargetAgentConfig('github-copilot'); - const lines: string[] = ['---']; - lines.push(`applyTo: "${copilotApplyTo(rule)}"`); - lines.push('---'); - lines.push(''); - lines.push(rule.body); - lines.push(''); - - return { - filename: `${rule.name}${config.rulesConfig.extension}`, - content: lines.join('\n'), - outputDir: config.rulesConfig.outputDir, - mode: 'write', - }; - }, -}; - -// --------------------------------------------------------------------------- -// Claude Code transpiler (.claude/rules/*.md) -// -// Claude Code rule files use YAML frontmatter with: -// - description: string — for model-based activation -// - globs: string[] — optional file scoping -// --------------------------------------------------------------------------- - -export const claudeCodeRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const config = getTargetAgentConfig('claude-code'); - const lines: string[] = ['---']; - lines.push(`description: ${quoteYaml(rule.description)}`); - - if (rule.globs.length > 0) { - lines.push('globs:'); - for (const glob of rule.globs) { - lines.push(` - "${glob}"`); - } - } - - lines.push('---'); - lines.push(''); - lines.push(rule.body); - lines.push(''); - - return { - filename: `${rule.name}${config.rulesConfig.extension}`, - content: lines.join('\n'), - outputDir: config.rulesConfig.outputDir, - mode: 'write', - }; - }, -}; - -// --------------------------------------------------------------------------- -// OpenCode transpiler (.opencode/rules/*.md) -// -// OpenCode rule files are plain markdown — no YAML frontmatter. The body -// is written directly without any wrapper. -// --------------------------------------------------------------------------- - -export const opencodeRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const config = getTargetAgentConfig('opencode'); - - return { - filename: `${rule.name}${config.rulesConfig.extension}`, - content: rule.body + '\n', - outputDir: config.rulesConfig.outputDir, - mode: 'write', - }; - }, -}; - -// --------------------------------------------------------------------------- -// Copilot append transpiler (→ AGENTS.md with markers) -// -// In append mode, rules are written as marked sections into a monolithic -// AGENTS.md file instead of individual .instructions.md files. The section -// body is plain markdown under a heading — no `applyTo` frontmatter. -// --------------------------------------------------------------------------- - -export const copilotAppendRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const lines: string[] = []; - - lines.push(`## ${rule.name}`); - lines.push(''); - lines.push(`> ${rule.description}`); - lines.push(''); - - if (rule.activation === 'glob' && rule.globs.length > 0) { - lines.push(`**Applies to:** ${rule.globs.map((g) => `\`${g}\``).join(', ')}`); - lines.push(''); - } - - lines.push(rule.body); - - return { - filename: 'AGENTS.md', - content: lines.join('\n'), - outputDir: '.', - mode: 'append', - }; - }, -}; - -// --------------------------------------------------------------------------- -// Claude Code append transpiler (→ CLAUDE.md with markers) -// -// In append mode, rules are written as marked sections into a monolithic -// CLAUDE.md file instead of individual .md files in .claude/rules/. -// The section body is plain markdown under a heading — no frontmatter. -// --------------------------------------------------------------------------- - -export const claudeCodeAppendRuleTranspiler: Transpiler = { - canTranspile(item: DiscoveredItem): boolean { - return item.type === 'rule' && item.format === 'canonical'; - }, - - transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { - const lines: string[] = []; - - lines.push(`## ${rule.name}`); - lines.push(''); - lines.push(`> ${rule.description}`); - lines.push(''); - - if (rule.globs.length > 0) { - lines.push(`**Applies to:** ${rule.globs.map((g) => `\`${g}\``).join(', ')}`); - lines.push(''); - } - - lines.push(rule.body); - - return { - filename: 'CLAUDE.md', - content: lines.join('\n'), - outputDir: '.', - mode: 'append', - }; - }, -}; - -// --------------------------------------------------------------------------- -// Native passthrough handler -// -// Native items (format: "native:") skip transpilation entirely. -// They are installed as-is to the matching agent's output directory. -// --------------------------------------------------------------------------- - -/** - * Create a TranspiledOutput for a native passthrough rule. - * The content is passed through unchanged — no transpilation occurs. - * - * Returns `null` if the target agent doesn't match the item's native format. - */ -export function nativePassthrough( - item: DiscoveredItem, - targetAgent: TargetAgent -): TranspiledOutput | null { - const expectedFormat: ContextFormat = `native:${targetAgent}`; - if (item.format !== expectedFormat) { - return null; - } - - const config = getTargetAgentConfig(targetAgent); - const extension = config.rulesConfig.extension; - const filename = `${item.name}${extension}`; - - return { - filename, - content: item.rawContent, - outputDir: config.rulesConfig.outputDir, - mode: 'write', - }; -} - -// --------------------------------------------------------------------------- -// Transpiler registry -// --------------------------------------------------------------------------- - -/** Map of target agents to their rule transpilers (per-rule file mode). */ -export const ruleTranspilers: Record> = { - cursor: cursorRuleTranspiler, - 'github-copilot': copilotRuleTranspiler, - 'claude-code': claudeCodeRuleTranspiler, - opencode: opencodeRuleTranspiler, -}; - -/** - * Map of target agents that support append mode to their append transpilers. - * Only Copilot (AGENTS.md) and Claude Code (CLAUDE.md) have append variants; - * the other agents always use per-rule files. - */ -export const appendRuleTranspilers: Partial>> = { - 'github-copilot': copilotAppendRuleTranspiler, - 'claude-code': claudeCodeAppendRuleTranspiler, -}; - -/** - * Transpile a canonical rule for a specific target agent. - * - * Parses the raw content to extract the CanonicalRule, then delegates - * to the appropriate transpiler. Returns `null` if parsing fails. - * - * When `append` is true, uses the append-mode transpiler for agents that - * support it (Copilot → AGENTS.md, Claude Code → CLAUDE.md). Agents - * without append support fall back to per-rule file transpilation. - */ -export function transpileRule( - item: DiscoveredItem, - targetAgent: TargetAgent, - append?: boolean -): TranspiledOutput | null { - // Native passthrough: install as-is to matching agent - if (item.format !== 'canonical') { - return nativePassthrough(item, targetAgent); - } - - const parsed = parseRuleContent(item.rawContent); - if (!parsed.ok) { - return null; - } - - // Merge per-agent overrides on top of base fields - const rule = mergeOverrides(parsed.rule, targetAgent) as CanonicalRule; - - // Use append transpiler if requested and available for this agent - if (append) { - const appendTranspiler = appendRuleTranspilers[targetAgent]; - if (appendTranspiler) { - return appendTranspiler.transform(rule, targetAgent); - } - } - - const transpiler = ruleTranspilers[targetAgent]; - return transpiler.transform(rule, targetAgent); -} - -/** - * Transpile a canonical rule for all target agents. - * - * Returns an array of TranspiledOutputs — one per agent that can - * receive the transpiled rule. Native passthrough items only produce - * output for their matching agent. - * - * When `append` is true, uses append-mode transpilers for agents that - * support it (Copilot, Claude Code). Other agents use per-rule files. - */ -export function transpileRuleForAllAgents( - item: DiscoveredItem, - agents: readonly TargetAgent[], - append?: boolean -): TranspiledOutput[] { - const outputs: TranspiledOutput[] = []; - - for (const agent of agents) { - const output = transpileRule(item, agent, append); - if (output !== null) { - outputs.push(output); - } - } - - return outputs; -} diff --git a/src/target-agents.ts b/src/target-agents.ts index dd9c66a..8fa9c0a 100644 --- a/src/target-agents.ts +++ b/src/target-agents.ts @@ -1,7 +1,7 @@ import type { TargetAgent, ContextType } from './types.ts'; // --------------------------------------------------------------------------- -// Target agent registry for dotai transpilation (rules, skills, prompts, agents, instructions) +// Target agent registry for dotai transpilation (skills, prompts, agents, instructions) // // This is separate from the upstream `agents.ts` (skills-only registry with // 40+ agents) to avoid merge conflicts and keep concerns separated. The @@ -32,18 +32,6 @@ export interface InstructionsConfig { filename: string; } -/** - * Configuration for native passthrough discovery within a source repo. - * Used to find agent-native rule files that should be installed without - * transpilation. - */ -export interface NativeRuleDiscovery { - /** Directory to search for native rule files (relative to repo root). */ - sourceDir: string; - /** Glob pattern for matching native rule files within sourceDir. */ - pattern: string; -} - /** * Configuration for native prompt file discovery within a source repo. * Used to find agent-native prompt/command files that should be installed @@ -79,10 +67,6 @@ export interface TargetAgentConfig { displayName: string; /** Skills output directory (relative to project root). */ skillsDir: string; - /** Rules output configuration (per-rule file output). */ - rulesConfig: ContextTypeConfig; - /** Native rule file discovery locations in source repos. */ - nativeRuleDiscovery: NativeRuleDiscovery; /** Prompts output configuration. Undefined = agent does not support prompts. */ promptsConfig?: ContextTypeConfig; /** Native prompt file discovery locations in source repos. */ @@ -97,7 +81,7 @@ export interface TargetAgentConfig { /** * The four target agents for dotai transpilation, with their - * rules + skills path configurations. + * context-type path configurations. * * Reference: dotai-plan.md Phase 4 (Agent Registry) */ @@ -106,14 +90,6 @@ export const targetAgents: Record = { name: 'github-copilot', displayName: 'GitHub Copilot', skillsDir: '.agents/skills', - rulesConfig: { - outputDir: '.github/instructions', - extension: '.instructions.md', - }, - nativeRuleDiscovery: { - sourceDir: '.github/instructions', - pattern: '*.instructions.md', - }, promptsConfig: { outputDir: '.github/prompts', extension: '.prompt.md', @@ -139,14 +115,6 @@ export const targetAgents: Record = { name: 'claude-code', displayName: 'Claude Code', skillsDir: '.claude/skills', - rulesConfig: { - outputDir: '.claude/rules', - extension: '.md', - }, - nativeRuleDiscovery: { - sourceDir: '.claude/rules', - pattern: '*.md', - }, promptsConfig: { outputDir: '.claude/commands', extension: '.md', @@ -172,14 +140,6 @@ export const targetAgents: Record = { name: 'cursor', displayName: 'Cursor', skillsDir: '.cursor/skills', - rulesConfig: { - outputDir: '.cursor/rules', - extension: '.mdc', - }, - nativeRuleDiscovery: { - sourceDir: '.cursor/rules', - pattern: '*.mdc', - }, instructionsConfig: { outputDir: '.', filename: 'AGENTS.md', @@ -190,14 +150,6 @@ export const targetAgents: Record = { name: 'opencode', displayName: 'OpenCode', skillsDir: '.opencode/skills', - rulesConfig: { - outputDir: '.opencode/rules', - extension: '.md', - }, - nativeRuleDiscovery: { - sourceDir: '.opencode/rules', - pattern: '*.md', - }, promptsConfig: { outputDir: '.opencode/commands', extension: '.md', @@ -250,14 +202,7 @@ export function getOutputDir(agent: TargetAgent, contextType: ContextType): stri if (contextType === 'instruction') { return config.instructionsConfig.outputDir; } - return config.rulesConfig.outputDir; -} - -/** - * Get the file extension for transpiled rule output for a given target agent. - */ -export function getRuleExtension(agent: TargetAgent): string { - return targetAgents[agent].rulesConfig.extension; + return undefined; } /** diff --git a/src/types.ts b/src/types.ts index 5ca2cfb..7d93c57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,14 +68,11 @@ export interface RemoteSkill { export type TargetAgent = 'github-copilot' | 'claude-code' | 'cursor' | 'opencode'; /** Context item types supported by dotai. */ -export type ContextType = 'skill' | 'rule' | 'prompt' | 'agent' | 'instruction'; +export type ContextType = 'skill' | 'prompt' | 'agent' | 'instruction'; /** How a discovered item was authored. */ export type ContextFormat = 'canonical' | `native:${TargetAgent}`; -/** Rule activation modes. */ -export type RuleActivation = 'always' | 'auto' | 'manual' | 'glob'; - /** * A discovered context item from a source repo, tagged with type and format. * This is the output of the discovery phase before transpilation. @@ -95,33 +92,6 @@ export interface DiscoveredItem { rawContent: string; } -/** Fields that can be overridden per target agent (excludes identity and structural fields). */ -export type RuleOverrideFields = Partial< - Omit ->; - -/** - * Canonical RULES.md representation after parsing and validation. - */ -export interface CanonicalRule { - /** Kebab-case identifier, ≤ 128 chars. */ - name: string; - /** Human-readable description, ≤ 512 chars. */ - description: string; - /** File glob patterns for scoping, ≤ 50 entries. */ - globs: string[]; - /** When this rule activates. */ - activation: RuleActivation; - /** Optional severity level. */ - severity?: string; - /** Schema version (default 1). */ - schemaVersion: number; - /** The markdown body (everything after frontmatter). */ - body: string; - /** Per-agent override blocks from frontmatter. */ - overrides?: Partial>; -} - /** Fields that can be overridden per target agent for prompts. */ export type PromptOverrideFields = Partial< Omit diff --git a/src/utils.ts b/src/utils.ts index 1a66036..edf8461 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -75,3 +75,18 @@ export function kebabToTitle(s: string): string { .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' '); } + +// --------------------------------------------------------------------------- +// YAML utilities +// --------------------------------------------------------------------------- + +/** + * Quote a string value for safe inclusion in YAML frontmatter. + * Wraps the value in double quotes and escapes internal double-quote + * and backslash characters. This prevents YAML injection from values + * containing colons, quotes, or other special characters. + */ +export function quoteYaml(value: string): string { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; +} diff --git a/tests/append-integration.test.ts b/tests/append-integration.test.ts deleted file mode 100644 index 40cb768..0000000 --- a/tests/append-integration.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { writeFile, readFile } from 'fs/promises'; -import { join } from 'path'; -import { existsSync } from 'fs'; -import { addRules } from '../src/rule-add.ts'; -import { updateRules } from '../src/rule-check.ts'; -import { runCli } from '../src/test-utils.ts'; -import { - createTempProjectDir, - makeSimpleRulesContent, - createTestSourceRepo, - readLockFileFromDisk, -} from './e2e-utils.ts'; - -// --------------------------------------------------------------------------- -// Append mode — integration tests -// --------------------------------------------------------------------------- - -describe('addRules --append integration', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('append mode creates AGENTS.md and CLAUDE.md with markers', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Code style rules', body: 'Use const over let' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - - // AGENTS.md should exist with markers - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain('Use const over let'); - - // CLAUDE.md should exist with markers - const claudeMd = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8'); - expect(claudeMd).toContain(''); - expect(claudeMd).toContain(''); - expect(claudeMd).toContain('Use const over let'); - - // Per-rule files should still be written for cursor and opencode - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - - // Per-rule files should NOT be written for copilot/claude (append mode instead) - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(false); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(false); - }); - - it('append mode lock entry has append: true', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(1); - expect(lock.items[0]!.append).toBe(true); - expect(lock.items[0]!.outputs).toContain(join(projectDir, 'AGENTS.md')); - expect(lock.items[0]!.outputs).toContain(join(projectDir, 'CLAUDE.md')); - }); - - it('append mode appends multiple rules to the same files', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const over let' }, - { name: 'security', description: 'Security', body: 'Validate all inputs' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(2); - - // AGENTS.md should have both sections - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain('Use const over let'); - expect(agentsMd).toContain('Validate all inputs'); - - // Lock should have 2 entries, both with append - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(2); - expect(lock.items.every((e) => e.append === true)).toBe(true); - }); - - it('append mode preserves existing file content', async () => { - // Pre-populate AGENTS.md with user content - await writeFile(join(projectDir, 'AGENTS.md'), '# My Project\n\nExisting instructions.\n'); - - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - // User content preserved - expect(agentsMd).toContain('# My Project'); - expect(agentsMd).toContain('Existing instructions.'); - // Dotai section added - expect(agentsMd).toContain(''); - expect(agentsMd).toContain('Use const'); - }); - - it('append mode re-install is idempotent (updates section)', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Version 1' }, - ]); - - // First install - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - // Modify source - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Version 2') - ); - - // Re-install - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain('Version 2'); - expect(agentsMd).not.toContain('Version 1'); - - // Should have exactly one start/end marker pair - const startCount = agentsMd.split('').length - 1; - const endCount = agentsMd.split('').length - 1; - expect(startCount).toBe(1); - expect(endCount).toBe(1); - }); - - it('append mode dry-run does not write files', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - dryRun: true, - }); - - expect(result.success).toBe(true); - expect(result.writtenPaths).toHaveLength(0); - expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false); - expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// Append mode — remove integration tests (via CLI subprocess) -// --------------------------------------------------------------------------- - -describe('CLI --append add + remove integration', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('add --append then remove --type rule removes section from AGENTS.md', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const over let' }, - ]); - - // Install with append - const addResult = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--append', '-y'], - projectDir - ); - expect(addResult.exitCode).toBe(0); - - // Verify AGENTS.md exists with markers - const agentsBefore = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsBefore).toContain(''); - - // Remove the rule - const removeResult = runCli(['remove', 'code-style', '--type', 'rule', '-y'], projectDir); - expect(removeResult.exitCode).toBe(0); - - // AGENTS.md should no longer have the section (file may be empty/deleted) - if (existsSync(join(projectDir, 'AGENTS.md'))) { - const agentsAfter = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsAfter).not.toContain(''); - expect(agentsAfter).not.toContain(''); - } - - // CLAUDE.md should also be cleaned up - if (existsSync(join(projectDir, 'CLAUDE.md'))) { - const claudeAfter = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8'); - expect(claudeAfter).not.toContain(''); - } - - // Lock should be empty - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(0); - }); - - it('remove preserves other sections when removing one append rule', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - { name: 'security', description: 'Security', body: 'Validate inputs' }, - ]); - - // Install both with append - const addResult = runCli(['add', sourceRepo, '--rule', '*', '--append', '-y'], projectDir); - expect(addResult.exitCode).toBe(0); - - // Verify both sections exist - const agentsBefore = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsBefore).toContain(''); - expect(agentsBefore).toContain(''); - - // Remove only code-style - const removeResult = runCli(['remove', 'code-style', '--type', 'rule', '-y'], projectDir); - expect(removeResult.exitCode).toBe(0); - - // AGENTS.md should still have security section but not code-style - const agentsAfter = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsAfter).not.toContain(''); - expect(agentsAfter).toContain(''); - expect(agentsAfter).toContain('Validate inputs'); - - // Lock should have only security - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(1); - expect(lock.items[0]!.name).toBe('security'); - }); - - it('remove deletes empty file after removing last append section', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - // Install with append - runCli(['add', sourceRepo, '--rule', 'code-style', '--append', '-y'], projectDir); - - // AGENTS.md should exist - expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(true); - - // Remove - runCli(['remove', 'code-style', '--type', 'rule', '-y'], projectDir); - - // AGENTS.md should be deleted (was only dotai content) - expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false); - expect(existsSync(join(projectDir, 'CLAUDE.md'))).toBe(false); - }); - - it('remove preserves user content in append target after section removal', async () => { - // Pre-populate AGENTS.md with user content - await writeFile(join(projectDir, 'AGENTS.md'), '# My Project\n\nProject instructions here.\n'); - - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - // Install with append - runCli(['add', sourceRepo, '--rule', 'code-style', '--append', '-y'], projectDir); - - // Verify section was added - const before = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(before).toContain(''); - expect(before).toContain('# My Project'); - - // Remove - runCli(['remove', 'code-style', '--type', 'rule', '-y'], projectDir); - - // AGENTS.md should still exist with user content, but without dotai section - expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(true); - const after = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(after).toContain('# My Project'); - expect(after).toContain('Project instructions here.'); - expect(after).not.toContain(''); - expect(after).not.toContain(''); - }); - - it('update preserves append mode in lock entries', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Version 1' }, - ]); - - // Install with append - const addResult = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--append', '-y'], - projectDir - ); - expect(addResult.exitCode).toBe(0); - - // Verify lock has append: true - const lockBefore = await readLockFileFromDisk(projectDir); - expect(lockBefore.items[0]!.append).toBe(true); - - // Modify source - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Version 2') - ); - - // Update - const updateResult = await updateRules(projectDir); - expect(updateResult.successCount).toBe(1); - - // Lock should still have append: true - const lockAfter = await readLockFileFromDisk(projectDir); - expect(lockAfter.items[0]!.append).toBe(true); - - // AGENTS.md should have updated content - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain('Version 2'); - expect(agentsMd).not.toContain('Version 1'); - }); -}); diff --git a/tests/cli-subprocess.test.ts b/tests/cli-subprocess.test.ts index 11f0f0b..3de436f 100644 --- a/tests/cli-subprocess.test.ts +++ b/tests/cli-subprocess.test.ts @@ -1,308 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdir, writeFile, readFile } from 'fs/promises'; +import { mkdir, writeFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; import { runCli } from '../src/test-utils.ts'; -import { - createTempProjectDir, - makeSimpleRulesContent, - makeSimpleAgentContent, - createTestSourceRepo, - readLockFileFromDisk, -} from './e2e-utils.ts'; - -// --------------------------------------------------------------------------- -// CLI subprocess tests for --rule flag -// --------------------------------------------------------------------------- - -describe('CLI --rule subprocess tests', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('add --rule installs rule and creates lock file', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const over let' }, - ]); - - const result = runCli(['add', sourceRepo, '--rule', 'code-style', '-y'], projectDir); - - // Should succeed (exit code 0) - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('rule(s) installed'); - - // Verify lock file was created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(1); - expect(lock.items[0]!.name).toBe('code-style'); - expect(lock.items[0]!.type).toBe('rule'); - - // Verify transpiled files exist - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - }); - - it('add --rule --dry-run does not create files', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); - - const result = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--dry-run', '-y'], - projectDir - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Dry'); - - // No lock file or transpiled files - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - expect(existsSync(join(projectDir, '.cursor'))).toBe(false); - }); - - it('add --rule --targets limits target agents', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); - - const result = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--targets', 'cursor,opencode', '-y'], - projectDir - ); - - expect(result.exitCode).toBe(0); - - // Only cursor and opencode should have files - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - - // Other agents should not - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(false); - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(false); - - // Lock file should only list targeted agents - const lock = await readLockFileFromDisk(projectDir); - const agents = lock.items[0]!.agents; - expect(agents).toHaveLength(2); - expect(agents).toContain('cursor'); - expect(agents).toContain('opencode'); - }); - - it('add --rule --force overrides existing file', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - // Create pre-existing user file - const cursorDir = join(projectDir, '.cursor', 'rules'); - await mkdir(cursorDir, { recursive: true }); - await writeFile(join(cursorDir, 'code-style.mdc'), 'user content'); - - const result = runCli(['add', sourceRepo, '--rule', 'code-style', '--force', '-y'], projectDir); - - expect(result.exitCode).toBe(0); - - // File should be overwritten - const content = await readFile(join(cursorDir, 'code-style.mdc'), 'utf-8'); - expect(content).toContain('Use const'); - expect(content).not.toBe('user content'); - - // Lock file should exist - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - }); - - it('add --rule with nonexistent rule name reports error', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - const result = runCli(['add', sourceRepo, '--rule', 'nonexistent', '-y'], projectDir); - - // Should fail - expect(result.exitCode).toBe(0); // CLI doesn't exit(1) on rule-not-found, it reports the error - expect(result.stdout).toContain('No matching rules'); - - // No lock file should be created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - }); -}); - -// --------------------------------------------------------------------------- -// CLI subprocess tests for --custom-agent flag -// --------------------------------------------------------------------------- - -describe('CLI --custom-agent subprocess tests', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('add --custom-agent installs agent and creates lock file', async () => { - const sourceRepo = await createTestSourceRepo( - tempDir, - [{ name: 'architect', description: 'System design agent', body: 'You are an architect.' }], - 'agent' - ); - - const result = runCli(['add', sourceRepo, '--custom-agent', 'architect', '-y'], projectDir); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('agent(s) installed'); - - // Verify lock file was created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - - const lock = await readLockFileFromDisk(projectDir); - const agentEntries = lock.items.filter((i) => i.type === 'agent'); - expect(agentEntries).toHaveLength(1); - expect(agentEntries[0]!.name).toBe('architect'); - expect(agentEntries[0]!.type).toBe('agent'); - - // Verify transpiled files exist for Copilot, Claude Code, and OpenCode - expect(existsSync(join(projectDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'agents', 'architect.md'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'agents', 'architect.md'))).toBe(true); - - // No agent files for Cursor (no agent support) - expect(existsSync(join(projectDir, '.cursor', 'agents'))).toBe(false); - }); - - it('add --custom-agent --dry-run does not create files', async () => { - const sourceRepo = await createTestSourceRepo( - tempDir, - [{ name: 'architect', description: 'System design agent', body: 'You are an architect.' }], - 'agent' - ); - - const result = runCli( - ['add', sourceRepo, '--custom-agent', 'architect', '--dry-run', '-y'], - projectDir - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Dry'); - - // No lock file or transpiled files - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - expect(existsSync(join(projectDir, '.github'))).toBe(false); - }); - - it('add --custom-agent --targets limits target agents', async () => { - const sourceRepo = await createTestSourceRepo( - tempDir, - [{ name: 'architect', description: 'System design agent', body: 'You are an architect.' }], - 'agent' - ); - - const result = runCli( - ['add', sourceRepo, '--custom-agent', 'architect', '--targets', 'copilot', '-y'], - projectDir - ); - - expect(result.exitCode).toBe(0); - - // Only Copilot should have the agent file - expect(existsSync(join(projectDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); - - // Claude Code should not - expect(existsSync(join(projectDir, '.claude', 'agents', 'architect.md'))).toBe(false); - }); - - it('add --custom-agent with nonexistent agent name reports error', async () => { - const sourceRepo = await createTestSourceRepo( - tempDir, - [{ name: 'architect', description: 'System design agent', body: 'You are an architect.' }], - 'agent' - ); - - const result = runCli(['add', sourceRepo, '--custom-agent', 'nonexistent', '-y'], projectDir); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('No matching agents'); - - // No lock file should be created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - }); - - it('add --custom-agent with wildcard installs all agents', async () => { - const sourceRepo = await createTestSourceRepo( - tempDir, - [ - { name: 'architect', description: 'System design', body: 'You are an architect.' }, - { name: 'reviewer', description: 'Code reviewer', body: 'You review code.' }, - ], - 'agent' - ); - - const result = runCli(['add', sourceRepo, '--custom-agent', '*', '-y'], projectDir); - - expect(result.exitCode).toBe(0); - - const lock = await readLockFileFromDisk(projectDir); - const agentEntries = lock.items.filter((i) => i.type === 'agent'); - expect(agentEntries).toHaveLength(2); - const names = agentEntries.map((e) => e.name).sort(); - expect(names).toEqual(['architect', 'reviewer']); - }); - - it('add --custom-agent alongside --rule installs both', async () => { - // Create a source repo with both a rule and an agent - const repoDir = join(tempDir, 'mixed-source'); - await mkdir(repoDir, { recursive: true }); - - // Add a rule at root - await writeFile( - join(repoDir, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style rules', 'Use const over let') - ); - - // Add an agent in agents/ directory - const agentsDir = join(repoDir, 'agents', 'architect'); - await mkdir(agentsDir, { recursive: true }); - await writeFile( - join(agentsDir, 'AGENT.md'), - makeSimpleAgentContent('architect', 'System design agent', 'You are an architect.') - ); - - const result = runCli( - ['add', repoDir, '--rule', 'code-style', '--custom-agent', 'architect', '-y'], - projectDir - ); - - expect(result.exitCode).toBe(0); - - const lock = await readLockFileFromDisk(projectDir); - const ruleEntries = lock.items.filter((i) => i.type === 'rule'); - const agentEntries = lock.items.filter((i) => i.type === 'agent'); - expect(ruleEntries).toHaveLength(1); - expect(agentEntries).toHaveLength(1); - expect(ruleEntries[0]!.name).toBe('code-style'); - expect(agentEntries[0]!.name).toBe('architect'); - }); -}); +import { createTempProjectDir } from './e2e-utils.ts'; // --------------------------------------------------------------------------- // CLI skill subprocess tests diff --git a/tests/cross-platform-paths.test.ts b/tests/cross-platform-paths.test.ts index 0c84774..beed58e 100644 --- a/tests/cross-platform-paths.test.ts +++ b/tests/cross-platform-paths.test.ts @@ -294,37 +294,53 @@ describe('createPlannedWrite — output path construction', () => { it('produces a resolved absolute path from projectRoot + outputDir + filename', () => { const projectRoot = '/home/user/project'; - const output = makeOutput('.github/instructions', 'my-rule.instructions.md'); - const pw = createPlannedWrite(output, projectRoot, 'rule', 'my-rule', 'canonical', 'test/repo'); + const output = makeOutput('.github/instructions', 'my-prompt.instructions.md'); + const pw = createPlannedWrite( + output, + projectRoot, + 'prompt', + 'my-prompt', + 'canonical', + 'test/repo' + ); // resolve(join(...)) always produces an absolute path with OS-native separators - const expected = resolve(join(projectRoot, '.github/instructions', 'my-rule.instructions.md')); + const expected = resolve( + join(projectRoot, '.github/instructions', 'my-prompt.instructions.md') + ); expect(pw.absolutePath).toBe(expected); }); it('normalizes paths with forward slashes in outputDir', () => { const projectRoot = '/home/user/project'; - const output = makeOutput('.cursor/rules', 'my-rule.mdc'); - const pw = createPlannedWrite(output, projectRoot, 'rule', 'my-rule', 'canonical', 'test/repo'); + const output = makeOutput('.github/prompts', 'my-prompt.prompt.md'); + const pw = createPlannedWrite( + output, + projectRoot, + 'prompt', + 'my-prompt', + 'canonical', + 'test/repo' + ); - expect(pw.absolutePath).toBe(resolve(join(projectRoot, '.cursor/rules', 'my-rule.mdc'))); + expect(pw.absolutePath).toBe( + resolve(join(projectRoot, '.github/prompts', 'my-prompt.prompt.md')) + ); // Path should not contain double separators expect(pw.absolutePath).not.toMatch(/[/\\]{2}/); }); - it('handles all five agent output directories', () => { + it('handles multiple agent output directories', () => { const projectRoot = '/home/user/project'; const agentOutputDirs: Array<{ dir: string; filename: string }> = [ { dir: '.github/instructions', filename: 'r.instructions.md' }, - { dir: '.claude/rules', filename: 'r.md' }, - { dir: '.cursor/rules', filename: 'r.mdc' }, - { dir: '.windsurf/rules', filename: 'r.md' }, - { dir: '.clinerules', filename: 'r.md' }, + { dir: '.github/prompts', filename: 'r.prompt.md' }, + { dir: '.claude/commands', filename: 'r.md' }, ]; for (const { dir, filename } of agentOutputDirs) { const output = makeOutput(dir, filename); - const pw = createPlannedWrite(output, projectRoot, 'rule', 'r', 'canonical', 'test/repo'); + const pw = createPlannedWrite(output, projectRoot, 'prompt', 'r', 'canonical', 'test/repo'); // Should be an absolute path expect(pw.absolutePath).toBe(resolve(pw.absolutePath)); @@ -374,17 +390,17 @@ describe('lock file path consistency', () => { tempDir = setup(); try { const outputPaths = [ - resolve(join(tempDir, '.github/instructions/my-rule.instructions.md')), - resolve(join(tempDir, '.cursor/rules/my-rule.mdc')), - resolve(join(tempDir, '.claude/rules/my-rule.md')), + resolve(join(tempDir, '.github/prompts/my-prompt.prompt.md')), + resolve(join(tempDir, '.claude/commands/my-prompt.md')), + resolve(join(tempDir, '.opencode/prompts/my-prompt.md')), ]; const entry: LockEntry = { - type: 'rule', - name: 'my-rule', + type: 'prompt', + name: 'my-prompt', source: 'test/repo', format: 'canonical', - agents: ['github-copilot', 'cursor', 'claude-code'] as TargetAgent[], + agents: ['github-copilot', 'claude-code', 'opencode'] as TargetAgent[], hash: 'abc123', installedAt: new Date().toISOString(), outputs: outputPaths, @@ -407,16 +423,16 @@ describe('lock file path consistency', () => { try { // Simulate paths as they would appear with forward slashes const outputPaths = [ - '/home/user/project/.github/instructions/test.instructions.md', - '/home/user/project/.cursor/rules/test.mdc', + '/home/user/project/.github/prompts/test.prompt.md', + '/home/user/project/.claude/commands/test.md', ]; const entry: LockEntry = { - type: 'rule', + type: 'prompt', name: 'test', source: 'test/repo', format: 'canonical', - agents: ['github-copilot', 'cursor'] as TargetAgent[], + agents: ['github-copilot', 'claude-code'] as TargetAgent[], hash: 'def456', installedAt: new Date().toISOString(), outputs: outputPaths, @@ -436,17 +452,17 @@ describe('lock file path consistency', () => { it('stores multiple entries with distinct output paths', async () => { tempDir = setup(); try { - const ruleEntry: LockEntry = { - type: 'rule', + const instructionEntry: LockEntry = { + type: 'instruction', name: 'code-style', source: 'test/repo', format: 'canonical', - agents: ['github-copilot', 'cursor'] as TargetAgent[], + agents: ['github-copilot', 'claude-code'] as TargetAgent[], hash: 'aaa', installedAt: new Date().toISOString(), outputs: [ resolve(join(tempDir, '.github/instructions/code-style.instructions.md')), - resolve(join(tempDir, '.cursor/rules/code-style.mdc')), + resolve(join(tempDir, '.claude/instructions/code-style.md')), ], }; @@ -462,20 +478,20 @@ describe('lock file path consistency', () => { }; let lock = createEmptyLock(); - lock = upsertLockEntry(lock, ruleEntry); + lock = upsertLockEntry(lock, instructionEntry); lock = upsertLockEntry(lock, promptEntry); await writeDotaiLock(lock, tempDir); const { lock: readBack } = await readDotaiLock(tempDir); expect(readBack.items).toHaveLength(2); - // Items are sorted by (type, name) — prompt:review comes before rule:code-style + // Items are sorted by (type, name) — instruction:code-style comes before prompt:review + const instruction = readBack.items.find((i) => i.type === 'instruction'); const prompt = readBack.items.find((i) => i.type === 'prompt'); - const rule = readBack.items.find((i) => i.type === 'rule'); + expect(instruction).toBeDefined(); expect(prompt).toBeDefined(); - expect(rule).toBeDefined(); + expect(instruction!.outputs).toEqual(instructionEntry.outputs); expect(prompt!.outputs).toEqual(promptEntry.outputs); - expect(rule!.outputs).toEqual(ruleEntry.outputs); } finally { cleanup(tempDir); } @@ -493,12 +509,12 @@ describe('backslash path handling', () => { const projectRoot = '/home/user/project'; const output: TranspiledOutput = { content: 'test', - outputDir: '.cursor/rules', - filename: 'test.mdc', + outputDir: '.github/prompts', + filename: 'test.prompt.md', mode: 'write', }; - const pw = createPlannedWrite(output, projectRoot, 'rule', 'test', 'canonical', 'test/repo'); + const pw = createPlannedWrite(output, projectRoot, 'prompt', 'test', 'canonical', 'test/repo'); // resolve() always produces an absolute path expect(pw.absolutePath).toBe(resolve(pw.absolutePath)); diff --git a/tests/debug-addRules.test.ts b/tests/debug-addRules.test.ts deleted file mode 100644 index c69d52f..0000000 --- a/tests/debug-addRules.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'; -import { execSync } from 'child_process'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { addRules } from '../src/rule-add.ts'; -import { executeInstallPipeline } from '../src/rule-installer.ts'; -import { discover, filterByType } from '../src/rule-discovery.ts'; - -describe('debug addRules', () => { - it('shows full pipeline result', async () => { - const projectRoot = mkdtempSync(join(tmpdir(), 'dbg-proj-')); - execSync('git init --initial-branch=main', { cwd: projectRoot, stdio: 'ignore' }); - - const sourceRepo = mkdtempSync(join(tmpdir(), 'dbg-src-')); - const ruleDir = join(sourceRepo, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeFileSync( - join(ruleDir, 'RULES.md'), - `--- -name: code-style -description: Code style guidelines -activation: always ---- - -Use consistent formatting. -` - ); - - // Step 1: Check discovery - const { items, warnings } = await discover(sourceRepo, { types: ['rule'] }); - console.log( - 'DISCOVERY items:', - items.length, - items.map((i) => `${i.type}:${i.name}:${i.format}`) - ); - console.log('DISCOVERY warnings:', warnings); - - const allRules = filterByType(items, 'rule'); - console.log('FILTERED rules:', allRules.length); - - // Step 2: Run install pipeline directly - const pipelineResult = await executeInstallPipeline(allRules, { - projectRoot, - source: 'test/e2e-repo', - }); - - console.log('PIPELINE success:', pipelineResult.success); - console.log( - 'PIPELINE writes:', - pipelineResult.writes.length, - pipelineResult.writes.map((w) => `${w.agent}:${w.planned.absolutePath}`) - ); - console.log('PIPELINE written:', pipelineResult.written.length, pipelineResult.written); - console.log('PIPELINE skipped:', pipelineResult.skipped); - console.log('PIPELINE collisions:', pipelineResult.collisions); - console.log('PIPELINE error:', pipelineResult.error); - - // Step 3: Check if files actually exist - const opencodePath = join(projectRoot, '.opencode', 'rules'); - console.log('.opencode/rules exists:', existsSync(opencodePath)); - if (existsSync(opencodePath)) { - console.log('.opencode/rules contents:', readdirSync(opencodePath)); - } - - // Step 4: Now run addRules - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - force: true, // force to overwrite from pipeline above - }); - - console.log('ADDRULES success:', result.success); - console.log('ADDRULES rulesInstalled:', result.rulesInstalled); - console.log('ADDRULES writtenPaths:', result.writtenPaths.length, result.writtenPaths); - console.log('ADDRULES error:', result.error); - console.log('ADDRULES messages:', result.messages); - - // Check lock file - const lockPath = join(projectRoot, '.dotai-lock.json'); - console.log('LOCK EXISTS:', existsSync(lockPath)); - - expect(true).toBe(true); - }); -}); diff --git a/tests/e2e-canonical-install.test.ts b/tests/e2e-canonical-install.test.ts index 88a9f64..327f2be 100644 --- a/tests/e2e-canonical-install.test.ts +++ b/tests/e2e-canonical-install.test.ts @@ -4,7 +4,6 @@ import { existsSync, readFileSync } from 'fs'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, @@ -13,12 +12,10 @@ import { getExpectedOutputPath, assertLockEntry, assertLockEntryCount, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; -import type { TargetAgent } from '../src/types.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; // --------------------------------------------------------------------------- // E2E Canonical Install Tests @@ -29,7 +26,7 @@ import type { TargetAgent } from '../src/types.ts'; // // Unlike unit tests that test individual functions, these tests create a // real source repo with canonical files and a real project directory, then -// run the complete addRules/addPrompts/addAgents flow. +// run the complete addPrompts/addAgents flow. // --------------------------------------------------------------------------- describe('E2E canonical install', () => { @@ -46,220 +43,6 @@ describe('E2E canonical install', () => { cleanupProject(sourceRepo); }); - // ------------------------------------------------------------------------- - // Canonical rule → all 4 agents → lock updated - // ------------------------------------------------------------------------- - - describe('canonical rule install', () => { - it('installs a canonical rule to all 4 agents and updates lock', async () => { - // Create a canonical rule in the source repo - const ruleContent = makeRuleContent('code-style', { - description: 'Code style guidelines', - activation: 'always', - body: 'Use consistent formatting.', - }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', ruleContent); - - // Run the full install flow - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - // Verify success - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(4); - - // Verify output files exist for all 4 agents - for (const agent of ALL_AGENTS) { - const outputPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); - assertFileExists(outputPath); - } - - // Verify agent-specific content format - // Cursor: .mdc with alwaysApply frontmatter - const cursorContent = readFileSync( - getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style'), - 'utf-8' - ); - expect(cursorContent).toContain('alwaysApply: true'); - expect(cursorContent).toContain('Use consistent formatting.'); - - // Copilot: .instructions.md with applyTo frontmatter - const copilotContent = readFileSync( - getExpectedOutputPath(projectRoot, 'github-copilot', 'rule', 'code-style'), - 'utf-8' - ); - expect(copilotContent).toContain('applyTo:'); - expect(copilotContent).toContain('Use consistent formatting.'); - - // Claude Code: plain .md - const claudeContent = readFileSync( - getExpectedOutputPath(projectRoot, 'claude-code', 'rule', 'code-style'), - 'utf-8' - ); - expect(claudeContent).toContain('Use consistent formatting.'); - - // Verify lock file - const lockEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - source: 'test/e2e-repo', - format: 'canonical', - outputCount: 4, - }); - expect(lockEntry.agents).toHaveLength(4); - expect(lockEntry.hash).toBeTruthy(); - await assertLockEntryCount(projectRoot, 1); - }); - - it('installs multiple canonical rules in one pass', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style rule body.' }) - ); - writeCanonicalFile( - sourceRepo, - 'rule', - 'security', - makeRuleContent('security', { body: 'Security rule body.' }) - ); - - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(2); - // 2 rules x 4 agents = 8 output files - expect(result.writtenPaths).toHaveLength(8); - - // Both rules should have output files for all agents - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'security')); - } - - // Lock file should have 2 entries - await assertLockEntryCount(projectRoot, 2); - await assertLockEntry(projectRoot, 'rule', 'code-style'); - await assertLockEntry(projectRoot, 'rule', 'security'); - }); - - it('installs a rule with glob activation', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'ts-style', - makeRuleContent('ts-style', { - activation: 'glob', - globs: ['**/*.ts', '**/*.tsx'], - body: 'TypeScript style guidelines.', - }) - ); - - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - - // Cursor output should have glob-specific frontmatter - const cursorContent = readFileSync( - getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'ts-style'), - 'utf-8' - ); - expect(cursorContent).toContain('alwaysApply: false'); - expect(cursorContent).toContain('**/*.ts'); - - // Copilot output should have glob in applyTo - const copilotContent = readFileSync( - getExpectedOutputPath(projectRoot, 'github-copilot', 'rule', 'ts-style'), - 'utf-8' - ); - expect(copilotContent).toContain('**/*.ts'); - }); - - it('installs a rule to a subset of agents', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) - ); - - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - targets: ['cursor', 'opencode'], - }); - - expect(result.success).toBe(true); - expect(result.writtenPaths).toHaveLength(2); - - // Only cursor and opencode should have output files - assertFileExists(getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style')); - assertFileExists(getExpectedOutputPath(projectRoot, 'opencode', 'rule', 'code-style')); - - // Others should NOT exist - assertFileNotExists( - getExpectedOutputPath(projectRoot, 'github-copilot', 'rule', 'code-style') - ); - assertFileNotExists(getExpectedOutputPath(projectRoot, 'claude-code', 'rule', 'code-style')); - - // Lock entry should reflect only the 2 agents - const lockEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - outputCount: 2, - }); - expect(lockEntry.agents.sort()).toEqual(['cursor', 'opencode']); - }); - - it('filters rules by name', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) - ); - writeCanonicalFile( - sourceRepo, - 'rule', - 'security', - makeRuleContent('security', { body: 'Security body.' }) - ); - - const result = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['security'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - - // Only security should be installed - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'security')); - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); - } - - await assertLockEntryCount(projectRoot, 1); - await assertLockEntry(projectRoot, 'rule', 'security'); - }); - }); - // ------------------------------------------------------------------------- // Canonical prompt → Copilot + Claude + OpenCode → lock updated // ------------------------------------------------------------------------- @@ -506,14 +289,8 @@ describe('E2E canonical install', () => { // ------------------------------------------------------------------------- describe('mixed canonical types', () => { - it('installs rules, prompts, and agents from the same source repo', async () => { - // Populate source repo with all three context types - writeCanonicalFile( - sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style guidelines.' }) - ); + it('installs prompts and agents from the same source repo', async () => { + // Populate source repo with both context types writeCanonicalFile( sourceRepo, 'prompt', @@ -527,15 +304,7 @@ describe('E2E canonical install', () => { makeAgentContent('architect', { body: 'Architecture planning.' }) ); - // Install all three types sequentially - const ruleResult = await addRules({ - source: 'test/e2e-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - expect(ruleResult.success).toBe(true); - + // Install both types sequentially const promptResult = await addPrompts({ source: 'test/e2e-repo', sourcePath: sourceRepo, @@ -553,10 +322,6 @@ describe('E2E canonical install', () => { expect(agentResult.success).toBe(true); // Verify all output files exist - // Rule: 4 agents - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); - } // Prompt: 3 agents for (const agent of PROMPT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'review-code')); @@ -566,9 +331,8 @@ describe('E2E canonical install', () => { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'architect')); } - // Lock file should have 3 entries (one per context item) - await assertLockEntryCount(projectRoot, 3); - await assertLockEntry(projectRoot, 'rule', 'code-style', { source: 'test/e2e-repo' }); + // Lock file should have 2 entries (one per context item) + await assertLockEntryCount(projectRoot, 2); await assertLockEntry(projectRoot, 'prompt', 'review-code', { source: 'test/e2e-repo' }); await assertLockEntry(projectRoot, 'agent', 'architect', { source: 'test/e2e-repo' }); }); @@ -582,47 +346,46 @@ describe('E2E canonical install', () => { it('stores a non-empty hash for each installed item', async () => { writeCanonicalFile( sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) + 'prompt', + 'review-code', + makePromptContent('review-code', { body: 'Review body.' }) ); - await addRules({ + await addPrompts({ source: 'test/e2e-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const entry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const entry = await assertLockEntry(projectRoot, 'prompt', 'review-code'); // Hash should be a SHA-256 hex string (64 chars) expect(entry.hash).toMatch(/^[a-f0-9]{64}$/); }); it('different content produces different hashes', async () => { - // Install first rule writeCanonicalFile( sourceRepo, - 'rule', - 'rule-a', - makeRuleContent('rule-a', { body: 'Content A.' }) + 'prompt', + 'prompt-a', + makePromptContent('prompt-a', { body: 'Content A.' }) ); writeCanonicalFile( sourceRepo, - 'rule', - 'rule-b', - makeRuleContent('rule-b', { body: 'Content B.' }) + 'prompt', + 'prompt-b', + makePromptContent('prompt-b', { body: 'Content B.' }) ); - await addRules({ + await addPrompts({ source: 'test/e2e-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const entryA = await assertLockEntry(projectRoot, 'rule', 'rule-a'); - const entryB = await assertLockEntry(projectRoot, 'rule', 'rule-b'); + const entryA = await assertLockEntry(projectRoot, 'prompt', 'prompt-a'); + const entryB = await assertLockEntry(projectRoot, 'prompt', 'prompt-b'); expect(entryA.hash).not.toBe(entryB.hash); }); }); @@ -635,27 +398,27 @@ describe('E2E canonical install', () => { it('reports planned writes without creating files or updating lock', async () => { writeCanonicalFile( sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) + 'prompt', + 'review-code', + makePromptContent('review-code', { body: 'Review body.' }) ); - const result = await addRules({ + const result = await addPrompts({ source: 'test/e2e-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], dryRun: true, }); expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); + expect(result.promptsInstalled).toBe(1); // No files written in dry-run expect(result.writtenPaths).toHaveLength(0); // No output files should exist - for (const agent of ALL_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); + for (const agent of PROMPT_AGENTS) { + assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'review-code')); } // No lock file should be created @@ -671,19 +434,19 @@ describe('E2E canonical install', () => { it('every lock output path corresponds to a file on disk', async () => { writeCanonicalFile( sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) + 'prompt', + 'review-code', + makePromptContent('review-code', { body: 'Review body.' }) ); - await addRules({ + await addPrompts({ source: 'test/e2e-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const entry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const entry = await assertLockEntry(projectRoot, 'prompt', 'review-code'); for (const outputPath of entry.outputs) { expect(existsSync(outputPath)).toBe(true); } diff --git a/tests/e2e-cli-matrix.test.ts b/tests/e2e-cli-matrix.test.ts index 7a87551..fdd0b99 100644 --- a/tests/e2e-cli-matrix.test.ts +++ b/tests/e2e-cli-matrix.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, @@ -14,22 +13,20 @@ import { assertLockEntry, assertLockEntryCount, assertNoLockEntry, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; import { removeCommand } from '../src/remove.ts'; -import { checkRuleUpdates, updateRules } from '../src/rule-check.ts'; -import { runList } from '../src/list.ts'; +import { checkContextUpdates, updateContext } from '../src/context-check.ts'; import { runCli } from '../src/test-utils.ts'; // --------------------------------------------------------------------------- // E2E CLI Matrix Tests // -// Focused matrix suite exercising high-value four-type CLI scenarios at the -// command entrypoint level. Covers: -// 1. add with --rule, --prompt, --custom-agent, and mixed --type +// Focused matrix suite exercising high-value CLI scenarios at the command +// entrypoint level. Covers: +// 1. add with --prompt, --custom-agent, and mixed --type // 2. remove --type for non-skill contexts // 3. list default + --type agent behavior // 4. check/update messaging for non-skill lock entries @@ -40,7 +37,7 @@ import { runCli } from '../src/test-utils.ts'; // subprocess calls (for routing/output assertions). // --------------------------------------------------------------------------- -describe('E2E CLI matrix: four-type flows', () => { +describe('E2E CLI matrix: multi-type flows', () => { let projectRoot: string; let sourceRepo: string; let oldCwd: string; @@ -58,18 +55,11 @@ describe('E2E CLI matrix: four-type flows', () => { }); // ------------------------------------------------------------------------- - // 1. add with --rule, --prompt, --custom-agent + // 1. add with --prompt, --custom-agent // ------------------------------------------------------------------------- describe('add with individual type flags', () => { - it('--rule installs only rules from a source with mixed types', async () => { - // Source has rules, prompts, and agents - writeCanonicalFile( - sourceRepo, - 'rule', - 'style-guide', - makeRuleContent('style-guide', { body: 'Follow the style guide.' }) - ); + it('--prompt installs only prompts from a source with mixed types', async () => { writeCanonicalFile( sourceRepo, 'prompt', @@ -83,41 +73,6 @@ describe('E2E CLI matrix: four-type flows', () => { makeAgentContent('helper', { body: 'I help with code.' }) ); - // Install only rules - const result = await addRules({ - source: 'team/shared-context', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - - // Rule files exist for all 6 agents - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'style-guide')); - } - - // No prompts or agents installed - await assertLockEntryCount(projectRoot, 1); - await assertLockEntry(projectRoot, 'rule', 'style-guide'); - }); - - it('--prompt installs only prompts from a source with mixed types', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'style-guide', - makeRuleContent('style-guide', { body: 'Follow the style guide.' }) - ); - writeCanonicalFile( - sourceRepo, - 'prompt', - 'review', - makePromptContent('review', { body: 'Review the code.' }) - ); - // Install only prompts const result = await addPrompts({ source: 'team/shared-context', @@ -134,7 +89,7 @@ describe('E2E CLI matrix: four-type flows', () => { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'review')); } - // Only prompt in lock, no rules + // Only prompt in lock, no agents await assertLockEntryCount(projectRoot, 1); await assertLockEntry(projectRoot, 'prompt', 'review'); }); @@ -142,9 +97,9 @@ describe('E2E CLI matrix: four-type flows', () => { it('--custom-agent installs only agents from a source with mixed types', async () => { writeCanonicalFile( sourceRepo, - 'rule', - 'style-guide', - makeRuleContent('style-guide', { body: 'Follow the style guide.' }) + 'prompt', + 'review', + makePromptContent('review', { body: 'Review the code.' }) ); writeCanonicalFile( sourceRepo, @@ -172,7 +127,7 @@ describe('E2E CLI matrix: four-type flows', () => { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'reviewer')); } - // Only agent in lock, no rules + // Only agent in lock, no prompts await assertLockEntryCount(projectRoot, 1); await assertLockEntry(projectRoot, 'agent', 'reviewer'); }); @@ -183,13 +138,7 @@ describe('E2E CLI matrix: four-type flows', () => { // ------------------------------------------------------------------------- describe('add with mixed types', () => { - it('installs rules and prompts from the same source in sequence', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'code-style', - makeRuleContent('code-style', { body: 'Consistent code style.' }) - ); + it('installs prompts and agents from the same source in sequence', async () => { writeCanonicalFile( sourceRepo, 'prompt', @@ -203,41 +152,30 @@ describe('E2E CLI matrix: four-type flows', () => { makeAgentContent('architect', { body: 'Architecture planning.' }) ); - // Simulate --type rule,prompt by running both flows - const ruleResult = await addRules({ + // Simulate --type prompt,agent by running both flows + const promptResult = await addPrompts({ source: 'team/context', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - expect(ruleResult.success).toBe(true); + expect(promptResult.success).toBe(true); - const promptResult = await addPrompts({ + const agentResult = await addAgents({ source: 'team/context', sourcePath: sourceRepo, projectRoot, - promptNames: ['*'], + agentNames: ['*'], }); - expect(promptResult.success).toBe(true); + expect(agentResult.success).toBe(true); - // Rule + prompt installed, agent NOT installed + // Prompt + agent installed await assertLockEntryCount(projectRoot, 2); - await assertLockEntry(projectRoot, 'rule', 'code-style'); await assertLockEntry(projectRoot, 'prompt', 'explain'); - - // Agent files should NOT exist - for (const agent of AGENT_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'architect')); - } + await assertLockEntry(projectRoot, 'agent', 'architect'); }); - it('installs all three types from the same source', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'lint', - makeRuleContent('lint', { body: 'Lint all files.' }) - ); + it('installs both prompts and agents from the same source', async () => { writeCanonicalFile( sourceRepo, 'prompt', @@ -251,13 +189,7 @@ describe('E2E CLI matrix: four-type flows', () => { makeAgentContent('tester', { body: 'You write and run tests.' }) ); - // Install all three types - const ruleResult = await addRules({ - source: 'team/full', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); + // Install both types const promptResult = await addPrompts({ source: 'team/full', sourcePath: sourceRepo, @@ -271,19 +203,14 @@ describe('E2E CLI matrix: four-type flows', () => { agentNames: ['*'], }); - expect(ruleResult.success).toBe(true); expect(promptResult.success).toBe(true); expect(agentResult.success).toBe(true); - await assertLockEntryCount(projectRoot, 3); - await assertLockEntry(projectRoot, 'rule', 'lint'); + await assertLockEntryCount(projectRoot, 2); await assertLockEntry(projectRoot, 'prompt', 'debug'); await assertLockEntry(projectRoot, 'agent', 'tester'); // Verify output files exist for each type's supported agents - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'lint')); - } for (const agent of PROMPT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'debug')); } @@ -346,70 +273,12 @@ describe('E2E CLI matrix: four-type flows', () => { } }); - it('--type rule only removes rules, leaving prompts and agents intact', async () => { - // Install all three types - writeCanonicalFile( - sourceRepo, - 'rule', - 'shared', - makeRuleContent('shared', { body: 'Shared rule.' }) - ); + it('--all --type agent removes all agents but keeps prompts intact', async () => { writeCanonicalFile( sourceRepo, 'prompt', - 'shared', - makePromptContent('shared', { body: 'Shared prompt.' }) - ); - writeCanonicalFile( - sourceRepo, - 'agent', - 'shared', - makeAgentContent('shared', { body: 'Shared agent.' }) - ); - - await addRules({ - source: 'team/ctx', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - await addPrompts({ - source: 'team/ctx', - sourcePath: sourceRepo, - projectRoot, - promptNames: ['*'], - }); - await addAgents({ - source: 'team/ctx', - sourcePath: sourceRepo, - projectRoot, - agentNames: ['*'], - }); - - await assertLockEntryCount(projectRoot, 3); - - // Remove only rules named "shared" - process.chdir(projectRoot); - await removeCommand(['shared'], { type: ['rule'], yes: true }); - - // Rule gone - await assertNoLockEntry(projectRoot, 'rule', 'shared'); - for (const agent of ALL_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'shared')); - } - - // Prompt and agent still present - await assertLockEntry(projectRoot, 'prompt', 'shared'); - await assertLockEntry(projectRoot, 'agent', 'shared'); - await assertLockEntryCount(projectRoot, 2); - }); - - it('--all --type agent removes all agents but keeps rules and prompts', async () => { - writeCanonicalFile( - sourceRepo, - 'rule', - 'my-rule', - makeRuleContent('my-rule', { body: 'Rule body.' }) + 'my-prompt', + makePromptContent('my-prompt', { body: 'Prompt body.' }) ); writeCanonicalFile( sourceRepo, @@ -424,11 +293,11 @@ describe('E2E CLI matrix: four-type flows', () => { makeAgentContent('agent-b', { body: 'Agent B.' }) ); - await addRules({ + await addPrompts({ source: 'team/ctx', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); await addAgents({ source: 'team/ctx', @@ -446,8 +315,8 @@ describe('E2E CLI matrix: four-type flows', () => { await assertNoLockEntry(projectRoot, 'agent', 'agent-a'); await assertNoLockEntry(projectRoot, 'agent', 'agent-b'); - // Rule still present - await assertLockEntry(projectRoot, 'rule', 'my-rule'); + // Prompt still present + await assertLockEntry(projectRoot, 'prompt', 'my-prompt'); await assertLockEntryCount(projectRoot, 1); }); }); @@ -481,7 +350,6 @@ describe('E2E CLI matrix: four-type flows', () => { const result = runCli(['list', '--type', 'agent'], projectRoot); expect(result.stdout).toContain('my-agent'); expect(result.stdout).toContain('Agents'); - expect(result.stdout).not.toContain('Rules'); expect(result.stdout).not.toContain('Prompts'); expect(result.stdout).not.toContain('Skills'); expect(result.exitCode).toBe(0); @@ -493,7 +361,7 @@ describe('E2E CLI matrix: four-type flows', () => { expect(result.exitCode).toBe(0); }); - it('list shows all four types by default when all are present', () => { + it('list shows all types by default when all are present', () => { // Create a skill const skillDir = join(projectRoot, '.agents', 'skills', 'my-skill'); mkdirSync(skillDir, { recursive: true }); @@ -507,22 +375,12 @@ description: A test skill ` ); - // Create lock with rule, prompt, and agent + // Create lock with prompt and agent writeFileSync( join(projectRoot, '.dotai-lock.json'), JSON.stringify({ version: 1, items: [ - { - type: 'rule', - name: 'my-rule', - source: 'team/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'aaa', - installedAt: '2025-01-01T00:00:00.000Z', - outputs: [], - }, { type: 'prompt', name: 'my-prompt', @@ -550,8 +408,6 @@ description: A test skill const result = runCli(['list'], projectRoot); expect(result.stdout).toContain('Skills'); expect(result.stdout).toContain('my-skill'); - expect(result.stdout).toContain('Rules'); - expect(result.stdout).toContain('my-rule'); expect(result.stdout).toContain('Prompts'); expect(result.stdout).toContain('my-prompt'); expect(result.stdout).toContain('Agents'); @@ -601,9 +457,8 @@ description: A test skill // ------------------------------------------------------------------------- describe('check/update for non-skill context', () => { - it('check detects updates across all three non-skill types', async () => { - // Install rule, prompt, and agent - writeCanonicalFile(sourceRepo, 'rule', 'lint', makeRuleContent('lint', { body: 'Lint v1.' })); + it('check detects updates across both non-skill types', async () => { + // Install prompt and agent writeCanonicalFile( sourceRepo, 'prompt', @@ -617,12 +472,6 @@ description: A test skill makeAgentContent('helper', { body: 'Helper v1.' }) ); - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, @@ -636,10 +485,9 @@ description: A test skill agentNames: ['*'], }); - await assertLockEntryCount(projectRoot, 3); + await assertLockEntryCount(projectRoot, 2); - // Modify all three - writeCanonicalFile(sourceRepo, 'rule', 'lint', makeRuleContent('lint', { body: 'Lint v2.' })); + // Modify both writeCanonicalFile( sourceRepo, 'prompt', @@ -653,18 +501,17 @@ description: A test skill makeAgentContent('helper', { body: 'Helper v2.' }) ); - // Check detects all 3 - const checkResult = await checkRuleUpdates(projectRoot); - expect(checkResult.totalChecked).toBe(3); - expect(checkResult.updates).toHaveLength(3); + // Check detects both + const checkResult = await checkContextUpdates(projectRoot); + expect(checkResult.totalChecked).toBe(2); + expect(checkResult.updates).toHaveLength(2); expect(checkResult.errors).toHaveLength(0); const types = checkResult.updates.map((u) => u.entry.type).sort(); - expect(types).toEqual(['agent', 'prompt', 'rule']); + expect(types).toEqual(['agent', 'prompt']); }); - it('update applies changes to all three non-skill types', async () => { - writeCanonicalFile(sourceRepo, 'rule', 'lint', makeRuleContent('lint', { body: 'Lint v1.' })); + it('update applies changes to both non-skill types', async () => { writeCanonicalFile( sourceRepo, 'prompt', @@ -678,12 +525,6 @@ description: A test skill makeAgentContent('helper', { body: 'Helper v1.' }) ); - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, @@ -698,12 +539,10 @@ description: A test skill }); // Save initial hashes - const initialRule = await assertLockEntry(projectRoot, 'rule', 'lint'); const initialPrompt = await assertLockEntry(projectRoot, 'prompt', 'review'); const initialAgent = await assertLockEntry(projectRoot, 'agent', 'helper'); - // Modify all three - writeCanonicalFile(sourceRepo, 'rule', 'lint', makeRuleContent('lint', { body: 'Lint v2.' })); + // Modify both writeCanonicalFile( sourceRepo, 'prompt', @@ -718,24 +557,19 @@ description: A test skill ); // Update all - const updateResult = await updateRules(projectRoot); - expect(updateResult.totalChecked).toBe(3); - expect(updateResult.successCount).toBe(3); + const updateResult = await updateContext(projectRoot); + expect(updateResult.totalChecked).toBe(2); + expect(updateResult.successCount).toBe(2); expect(updateResult.failCount).toBe(0); // Verify hashes changed - const updatedRule = await assertLockEntry(projectRoot, 'rule', 'lint'); const updatedPrompt = await assertLockEntry(projectRoot, 'prompt', 'review'); const updatedAgent = await assertLockEntry(projectRoot, 'agent', 'helper'); - expect(updatedRule.hash).not.toBe(initialRule.hash); expect(updatedPrompt.hash).not.toBe(initialPrompt.hash); expect(updatedAgent.hash).not.toBe(initialAgent.hash); // Verify output content updated - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'lint'), 'Lint v2.'); - } for (const agent of PROMPT_AGENTS) { assertFileExists( getExpectedOutputPath(projectRoot, agent, 'prompt', 'review'), @@ -765,7 +599,7 @@ description: A test skill agentNames: ['*'], }); - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(0); expect(checkResult.errors).toHaveLength(0); @@ -808,45 +642,43 @@ description: A test skill // ------------------------------------------------------------------------- describe('onboarding: shared command installs expected context', () => { - it('team-shared rule install produces correct output files and lock', async () => { - // Scenario: A developer shares "dotai add team/rules --rule code-style -y" + it('team-shared prompt install produces correct output files and lock', async () => { + // Scenario: A developer shares "dotai add team/prompts --prompt code-style -y" // We simulate this at the pipeline level (deterministic, no network). - // 1. Source repo has a rule that a teammate created + // 1. Source repo has a prompt that a teammate created writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Team code style guidelines', - activation: 'always', body: 'Use 2-space indentation. Prefer const over let.', }) ); // 2. New developer runs the shared command (simulated via pipeline) - const result = await addRules({ - source: 'team/shared-rules', + const result = await addPrompts({ + source: 'team/shared-prompts', sourcePath: sourceRepo, projectRoot, - ruleNames: ['code-style'], + promptNames: ['code-style'], }); // 3. Verify: install succeeded expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); + expect(result.promptsInstalled).toBe(1); - // 4. Verify: output files exist for all 6 target agents - for (const agent of ALL_AGENTS) { - const outputPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); + // 4. Verify: output files exist for supported agents + for (const agent of PROMPT_AGENTS) { + const outputPath = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); assertFileExists(outputPath, 'Use 2-space indentation'); } // 5. Verify: lock file is correct - const lockEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - source: 'team/shared-rules', + const lockEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style', { + source: 'team/shared-prompts', format: 'canonical', - outputCount: 4, }); expect(lockEntry.hash).toMatch(/^[a-f0-9]{64}$/); await assertLockEntryCount(projectRoot, 1); @@ -921,62 +753,62 @@ description: A test skill it('full onboarding: install → list → update → remove lifecycle', async () => { // Complete lifecycle test - // 1. Install a rule + // 1. Install a prompt writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'testing', - makeRuleContent('testing', { + makePromptContent('testing', { description: 'Testing guidelines', body: 'Write tests first. v1.', }) ); - const installResult = await addRules({ + const installResult = await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(installResult.success).toBe(true); - // 2. List shows the rule - const listResult = runCli(['list', '--type', 'rule'], projectRoot); + // 2. List shows the prompt + const listResult = runCli(['list', '--type', 'prompt'], projectRoot); expect(listResult.stdout).toContain('testing'); - expect(listResult.stdout).toContain('Rules'); + expect(listResult.stdout).toContain('Prompts'); // 3. Modify source → check detects update writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'testing', - makeRuleContent('testing', { + makePromptContent('testing', { description: 'Testing guidelines', body: 'Write tests first. Always. v2.', }) ); - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.updates).toHaveLength(1); expect(checkResult.updates[0]!.entry.name).toBe('testing'); // 4. Update applies the change - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.successCount).toBe(1); - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'testing'), 'v2.'); + for (const agent of PROMPT_AGENTS) { + assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'testing'), 'v2.'); } // 5. Remove cleans up process.chdir(projectRoot); - await removeCommand(['testing'], { type: ['rule'], yes: true }); + await removeCommand(['testing'], { type: ['prompt'], yes: true }); - await assertNoLockEntry(projectRoot, 'rule', 'testing'); + await assertNoLockEntry(projectRoot, 'prompt', 'testing'); await assertLockEntryCount(projectRoot, 0); - for (const agent of ALL_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'testing')); + for (const agent of PROMPT_AGENTS) { + assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'testing')); } }); }); @@ -985,11 +817,10 @@ description: A test skill // 8. CLI help and messaging consistency // ------------------------------------------------------------------------- - describe('CLI help reflects four-type support', () => { - it('add --help mentions all four content types', () => { + describe('CLI help reflects type support', () => { + it('add --help mentions supported content types', () => { const result = runCli(['add', '--help']); expect(result.stdout).toContain('skill'); - expect(result.stdout).toContain('rule'); expect(result.stdout).toContain('prompt'); expect(result.stdout).toContain('agent'); }); @@ -997,7 +828,9 @@ description: A test skill it('remove --help mentions --type option', () => { const result = runCli(['remove', '--help']); expect(result.stdout).toContain('--type'); - expect(result.stdout).toContain('skill, rule, prompt, agent'); + expect(result.stdout).toContain('skill'); + expect(result.stdout).toContain('prompt'); + expect(result.stdout).toContain('agent'); }); it('remove --help shows --type in options', () => { diff --git a/tests/e2e-collision.test.ts b/tests/e2e-collision.test.ts index 9db50a9..494c08c 100644 --- a/tests/e2e-collision.test.ts +++ b/tests/e2e-collision.test.ts @@ -3,24 +3,19 @@ import { existsSync, readFileSync } from 'fs'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, - writeNativeFile, assertFileExists, assertFileNotExists, getExpectedOutputPath, assertLockEntry, assertLockEntryCount, - assertNoLockEntry, writeUserFile, - readOutputFile, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; // --------------------------------------------------------------------------- // E2E Collision and Force Tests @@ -57,27 +52,31 @@ describe('E2E collision tests', () => { // ------------------------------------------------------------------------- describe('user-owned file collision', () => { - it('detects collision when user file exists at rule target path', async () => { + it('detects collision when user file exists at prompt target path (copilot)', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style guidelines', - activation: 'always', body: 'Use consistent formatting.', }) ); - // Pre-create a user-owned file at the Cursor output path - const cursorPath = getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style'); - writeUserFile(cursorPath, 'my custom cursor rules'); + // Pre-create a user-owned file at the Copilot output path + const copilotPath = getExpectedOutputPath( + projectRoot, + 'github-copilot', + 'prompt', + 'code-style' + ); + writeUserFile(copilotPath, 'my custom copilot prompts'); - const result = await addRules({ + const result = await addPrompts({ source: 'test/collision-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(result.success).toBe(false); @@ -86,7 +85,7 @@ describe('E2E collision tests', () => { expect(result.writtenPaths).toHaveLength(0); // User file should still be intact - expect(readFileSync(cursorPath, 'utf-8')).toBe('my custom cursor rules'); + expect(readFileSync(copilotPath, 'utf-8')).toBe('my custom copilot prompts'); // No lock file created expect(existsSync(`${projectRoot}/.dotai-lock.json`)).toBe(false); @@ -160,45 +159,43 @@ describe('E2E collision tests', () => { // ------------------------------------------------------------------------- describe('same-name collision from different source', () => { - it('detects collision when rule with same name is installed from different source', async () => { - // Install a rule from source 1 + it('detects collision when prompt with same name is installed from different source (via addPrompts)', async () => { + // Install a prompt from source 1 writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'security', - makeRuleContent('security', { - description: 'Security rules v1', - activation: 'always', - body: 'Original security rules.', + makePromptContent('security', { + description: 'Security prompts v1', + body: 'Original security prompts.', }) ); - const firstResult = await addRules({ - source: 'team-a/rules', + const firstResult = await addPrompts({ + source: 'team-a/prompts', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(firstResult.success).toBe(true); - expect(firstResult.rulesInstalled).toBe(1); + expect(firstResult.promptsInstalled).toBe(1); - // Try to install same-named rule from source 2 + // Try to install same-named prompt from source 2 writeCanonicalFile( sourceRepo2, - 'rule', + 'prompt', 'security', - makeRuleContent('security', { - description: 'Security rules v2', - activation: 'always', - body: 'Different security rules.', + makePromptContent('security', { + description: 'Security prompts v2', + body: 'Different security prompts.', }) ); - const secondResult = await addRules({ - source: 'team-b/rules', + const secondResult = await addPrompts({ + source: 'team-b/prompts', sourcePath: sourceRepo2, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(secondResult.success).toBe(false); @@ -207,14 +204,14 @@ describe('E2E collision tests', () => { expect(secondResult.writtenPaths).toHaveLength(0); // Original files still contain v1 content - for (const agent of ALL_AGENTS) { - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'security'); - assertFileExists(path, 'Original security rules.'); + for (const agent of PROMPT_AGENTS) { + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'security'); + assertFileExists(path, 'Original security prompts.'); } // Lock still references first source - await assertLockEntry(projectRoot, 'rule', 'security', { - source: 'team-a/rules', + await assertLockEntry(projectRoot, 'prompt', 'security', { + source: 'team-a/prompts', }); await assertLockEntryCount(projectRoot, 1); }); @@ -273,51 +270,49 @@ describe('E2E collision tests', () => { // ------------------------------------------------------------------------- describe('re-install from same source', () => { - it('allows re-install of rule from same source (treated as update)', async () => { + it('allows re-install of prompt from same source (treated as update)', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style v1', - activation: 'always', body: 'Version 1.', }) ); - const firstResult = await addRules({ + const firstResult = await addPrompts({ source: 'test/same-source', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(firstResult.success).toBe(true); // Modify content and re-install from the same source writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style v2', - activation: 'always', body: 'Version 2.', }) ); - const secondResult = await addRules({ + const secondResult = await addPrompts({ source: 'test/same-source', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(secondResult.success).toBe(true); - expect(secondResult.rulesInstalled).toBe(1); + expect(secondResult.promptsInstalled).toBe(1); // Files now contain v2 content - for (const agent of ALL_AGENTS) { - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); + for (const agent of PROMPT_AGENTS) { + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); assertFileExists(path, 'Version 2.'); } @@ -330,103 +325,98 @@ describe('E2E collision tests', () => { // ------------------------------------------------------------------------- describe('--force overrides collisions', () => { - it('force overwrites user-owned file at rule target path', async () => { + it('force overwrites user-owned file at prompt target path (all agents)', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style guidelines', - activation: 'always', - body: 'Dotai-managed style rules.', + body: 'Dotai-managed style prompts.', }) ); - // Pre-create user-owned files at ALL agent paths - for (const agent of ALL_AGENTS) { - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); + // Pre-create user-owned files at ALL prompt agent paths + for (const agent of PROMPT_AGENTS) { + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); writeUserFile(path, 'user-owned content'); } - const result = await addRules({ + const result = await addPrompts({ source: 'test/force-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], force: true, }); expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(4); + expect(result.promptsInstalled).toBe(1); // All files now contain dotai-managed content - for (const agent of ALL_AGENTS) { - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); - assertFileExists(path, 'Dotai-managed style rules.'); + for (const agent of PROMPT_AGENTS) { + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); + assertFileExists(path, 'Dotai-managed style prompts.'); } // Lock file created - await assertLockEntry(projectRoot, 'rule', 'code-style', { + await assertLockEntry(projectRoot, 'prompt', 'code-style', { source: 'test/force-repo', format: 'canonical', - outputCount: 4, }); }); - it('force overwrites same-name rule from different source', async () => { + it('force overwrites same-name prompt from different source', async () => { // Install from source 1 writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'security', - makeRuleContent('security', { + makePromptContent('security', { description: 'Security v1', - activation: 'always', - body: 'Original rules.', + body: 'Original prompts.', }) ); - const firstResult = await addRules({ - source: 'team-a/rules', + const firstResult = await addPrompts({ + source: 'team-a/prompts', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); expect(firstResult.success).toBe(true); - // Force install same-named rule from source 2 + // Force install same-named prompt from source 2 writeCanonicalFile( sourceRepo2, - 'rule', + 'prompt', 'security', - makeRuleContent('security', { + makePromptContent('security', { description: 'Security v2', - activation: 'always', - body: 'Replacement rules.', + body: 'Replacement prompts.', }) ); - const secondResult = await addRules({ - source: 'team-b/rules', + const secondResult = await addPrompts({ + source: 'team-b/prompts', sourcePath: sourceRepo2, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], force: true, }); expect(secondResult.success).toBe(true); - expect(secondResult.rulesInstalled).toBe(1); + expect(secondResult.promptsInstalled).toBe(1); // Files now contain v2 content - for (const agent of ALL_AGENTS) { - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'security'); - assertFileExists(path, 'Replacement rules.'); + for (const agent of PROMPT_AGENTS) { + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'security'); + assertFileExists(path, 'Replacement prompts.'); } // Lock updated to new source - await assertLockEntry(projectRoot, 'rule', 'security', { - source: 'team-b/rules', + await assertLockEntry(projectRoot, 'prompt', 'security', { + source: 'team-b/prompts', }); }); @@ -517,24 +507,28 @@ describe('E2E collision tests', () => { it('dry-run reports collision without writing files', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style guidelines', - activation: 'always', - body: 'Style rules.', + body: 'Style prompts.', }) ); - // Pre-create user-owned file - const cursorPath = getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style'); - writeUserFile(cursorPath, 'user content'); + // Pre-create user-owned file at the Copilot output path + const copilotPath = getExpectedOutputPath( + projectRoot, + 'github-copilot', + 'prompt', + 'code-style' + ); + writeUserFile(copilotPath, 'user content'); - const result = await addRules({ + const result = await addPrompts({ source: 'test/dryrun-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], dryRun: true, }); @@ -544,7 +538,7 @@ describe('E2E collision tests', () => { expect(result.writtenPaths).toHaveLength(0); // User file still intact - expect(readFileSync(cursorPath, 'utf-8')).toBe('user content'); + expect(readFileSync(copilotPath, 'utf-8')).toBe('user content'); // No lock file created expect(existsSync(`${projectRoot}/.dotai-lock.json`)).toBe(false); @@ -553,40 +547,44 @@ describe('E2E collision tests', () => { it('dry-run with --force reports planned writes without executing', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { + makePromptContent('code-style', { description: 'Code style guidelines', - activation: 'always', - body: 'Style rules.', + body: 'Style prompts.', }) ); - // Pre-create user-owned file - const cursorPath = getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style'); - writeUserFile(cursorPath, 'user content'); + // Pre-create user-owned file at the Copilot output path + const copilotPath = getExpectedOutputPath( + projectRoot, + 'github-copilot', + 'prompt', + 'code-style' + ); + writeUserFile(copilotPath, 'user content'); - const result = await addRules({ + const result = await addPrompts({ source: 'test/dryrun-force-repo', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], dryRun: true, force: true, }); // Force suppresses collision blocking, dry-run prevents writes expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); + expect(result.promptsInstalled).toBe(1); expect(result.writtenPaths).toHaveLength(0); // dry-run = no files written // User file still intact (dry-run didn't overwrite) - expect(readFileSync(cursorPath, 'utf-8')).toBe('user content'); + expect(readFileSync(copilotPath, 'utf-8')).toBe('user content'); // No other agent files created - for (const agent of ALL_AGENTS) { - if (agent === 'cursor') continue; // user file exists - const path = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); + for (const agent of PROMPT_AGENTS) { + if (agent === 'github-copilot') continue; // user file exists + const path = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); assertFileNotExists(path); } @@ -600,54 +598,53 @@ describe('E2E collision tests', () => { // ------------------------------------------------------------------------- describe('no collision for different types with same name', () => { - it('allows rule and prompt with same name from different sources', async () => { - // Install a rule named "review" + it('allows prompt and agent with same name from different sources', async () => { + // Install a prompt named "review" writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'review', - makeRuleContent('review', { - description: 'Review rule', - activation: 'always', - body: 'Review rule body.', + makePromptContent('review', { + description: 'Review prompt', + body: 'Review prompt body.', }) ); - const ruleResult = await addRules({ - source: 'team-a/rules', + const promptResult = await addPrompts({ + source: 'team-a/prompts', sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - expect(ruleResult.success).toBe(true); + expect(promptResult.success).toBe(true); - // Install a prompt named "review" from different source + // Install an agent named "review" from different source writeCanonicalFile( sourceRepo2, - 'prompt', + 'agent', 'review', - makePromptContent('review', { - description: 'Review prompt', - body: 'Review prompt body.', + makeAgentContent('review', { + description: 'Review agent', + body: 'Review agent body.', }) ); - const promptResult = await addPrompts({ - source: 'team-b/prompts', + const agentResult = await addAgents({ + source: 'team-b/agents', sourcePath: sourceRepo2, projectRoot, - promptNames: ['*'], + agentNames: ['*'], }); // No collision — different types have different output paths - expect(promptResult.success).toBe(true); - expect(promptResult.promptsInstalled).toBe(1); + expect(agentResult.success).toBe(true); + expect(agentResult.agentsInstalled).toBe(1); - await assertLockEntry(projectRoot, 'rule', 'review', { - source: 'team-a/rules', - }); await assertLockEntry(projectRoot, 'prompt', 'review', { - source: 'team-b/prompts', + source: 'team-a/prompts', + }); + await assertLockEntry(projectRoot, 'agent', 'review', { + source: 'team-b/agents', }); await assertLockEntryCount(projectRoot, 2); }); diff --git a/tests/e2e-instruction.test.ts b/tests/e2e-instruction.test.ts index 5fd0da2..b29e8a4 100644 --- a/tests/e2e-instruction.test.ts +++ b/tests/e2e-instruction.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; import { join } from 'path'; -import { addInstructions } from '../src/rule-add.ts'; +import { addInstructions } from '../src/context-add.ts'; import type { TargetAgent } from '../src/types.ts'; import { createTempProjectDir, @@ -424,7 +424,7 @@ describe('addInstructions — e2e', () => { const sourceRepo = await createTestSourceRepo( tempDir, [{ name: 'code-style', description: 'Style', body: 'Use const.' }], - 'rule' // Create rules, not instructions + 'prompt' // Create prompts, not instructions ); const result = await addInstructions({ diff --git a/tests/e2e-native-passthrough.test.ts b/tests/e2e-native-passthrough.test.ts index 6ad15df..66a7cb7 100644 --- a/tests/e2e-native-passthrough.test.ts +++ b/tests/e2e-native-passthrough.test.ts @@ -3,7 +3,6 @@ import { readFileSync } from 'fs'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, @@ -13,12 +12,10 @@ import { getExpectedOutputPath, assertLockEntry, assertLockEntryCount, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; -import type { TargetAgent } from '../src/types.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; // --------------------------------------------------------------------------- // E2E Native Passthrough Tests @@ -27,8 +24,8 @@ import type { TargetAgent } from '../src/types.ts'; // passed through without transpilation, and installed only to their matching // agent's output directory. // -// Native passthrough means: a `.cursor/rules/foo.mdc` file in the source repo -// should be installed as-is to `.cursor/rules/foo.mdc` in the project, and +// Native passthrough means: a `.github/copilot-instructions/deploy.prompt.md` +// file in the source repo should be installed as-is to the project, and // NOT to any other agent's directory. // --------------------------------------------------------------------------- @@ -46,149 +43,6 @@ describe('E2E native passthrough', () => { cleanupProject(sourceRepo); }); - // ------------------------------------------------------------------------- - // Native rule passthrough - // ------------------------------------------------------------------------- - - describe('native rule passthrough', () => { - it('installs a native Cursor rule only to Cursor', async () => { - const content = '---\nalwaysApply: true\n---\nNative Cursor rule content.'; - writeNativeFile(sourceRepo, 'rule', 'cursor', 'code-style.mdc', content); - - const result = await addRules({ - source: 'test/native-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(1); - - // Cursor should have the file - const cursorOutput = getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'code-style'); - assertFileExists(cursorOutput); - const writtenContent = readFileSync(cursorOutput, 'utf-8'); - expect(writtenContent).toBe(content); - - // Other agents should NOT have the file - for (const agent of ALL_AGENTS.filter((a) => a !== 'cursor')) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); - } - - // Lock should reflect native format with only cursor - const entry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - source: 'test/native-repo', - format: 'native:cursor', - agents: ['cursor'], - outputCount: 1, - }); - expect(entry.hash).toMatch(/^[a-f0-9]{64}$/); - }); - - it('installs a native Copilot rule only to Copilot', async () => { - const content = '---\napplyTo: "**/*.ts"\n---\nNative Copilot rule.'; - writeNativeFile(sourceRepo, 'rule', 'github-copilot', 'ts-rules.instructions.md', content); - - const result = await addRules({ - source: 'test/native-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.writtenPaths).toHaveLength(1); - - // Copilot should have the file - const copilotOutput = getExpectedOutputPath( - projectRoot, - 'github-copilot', - 'rule', - 'ts-rules' - ); - assertFileExists(copilotOutput); - const writtenContent = readFileSync(copilotOutput, 'utf-8'); - expect(writtenContent).toBe(content); - - // Other agents should NOT have the file - for (const agent of ALL_AGENTS.filter((a) => a !== 'github-copilot')) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'ts-rules')); - } - - await assertLockEntry(projectRoot, 'rule', 'ts-rules', { - format: 'native:github-copilot', - agents: ['github-copilot'], - outputCount: 1, - }); - }); - - it('installs a native Claude Code rule only to Claude Code', async () => { - const content = 'Native Claude Code rule content.'; - writeNativeFile(sourceRepo, 'rule', 'claude-code', 'security.md', content); - - const result = await addRules({ - source: 'test/native-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.writtenPaths).toHaveLength(1); - - const claudeOutput = getExpectedOutputPath(projectRoot, 'claude-code', 'rule', 'security'); - assertFileExists(claudeOutput); - expect(readFileSync(claudeOutput, 'utf-8')).toBe(content); - - // Other agents should NOT have it - for (const agent of ALL_AGENTS.filter((a) => a !== 'claude-code')) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'security')); - } - - await assertLockEntry(projectRoot, 'rule', 'security', { - format: 'native:claude-code', - agents: ['claude-code'], - outputCount: 1, - }); - }); - - it('installs native rules from multiple agents independently', async () => { - writeNativeFile(sourceRepo, 'rule', 'cursor', 'cursor-rule.mdc', 'Cursor native rule.'); - writeNativeFile(sourceRepo, 'rule', 'claude-code', 'claude-rule.md', 'Claude native rule.'); - - const result = await addRules({ - source: 'test/native-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(2); - expect(result.writtenPaths).toHaveLength(2); - - // Cursor rule only in cursor dir - assertFileExists(getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'cursor-rule')); - assertFileNotExists(getExpectedOutputPath(projectRoot, 'claude-code', 'rule', 'cursor-rule')); - - // Claude rule only in claude dir - assertFileExists(getExpectedOutputPath(projectRoot, 'claude-code', 'rule', 'claude-rule')); - assertFileNotExists(getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'claude-rule')); - - await assertLockEntryCount(projectRoot, 2); - await assertLockEntry(projectRoot, 'rule', 'cursor-rule', { - format: 'native:cursor', - agents: ['cursor'], - }); - await assertLockEntry(projectRoot, 'rule', 'claude-rule', { - format: 'native:claude-code', - agents: ['claude-code'], - }); - }); - }); - // ------------------------------------------------------------------------- // Native prompt passthrough // ------------------------------------------------------------------------- @@ -337,59 +191,6 @@ describe('E2E native passthrough', () => { // ------------------------------------------------------------------------- describe('mixed canonical and native', () => { - it('installs both canonical and native rules from the same repo', async () => { - // Canonical rule → goes to all 5 agents - writeCanonicalFile( - sourceRepo, - 'rule', - 'formatting', - makeRuleContent('formatting', { body: 'Canonical formatting rule.' }) - ); - - // Native Cursor rule → goes to cursor only - writeNativeFile( - sourceRepo, - 'rule', - 'cursor', - 'cursor-only.mdc', - '---\nalwaysApply: true\n---\nCursor-only content.' - ); - - const result = await addRules({ - source: 'test/mixed-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(2); - // canonical: 4 files + native: 1 file = 5 total - expect(result.writtenPaths).toHaveLength(5); - - // Canonical rule should be in all agents - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'formatting')); - } - - // Native cursor rule should be in cursor only - assertFileExists(getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'cursor-only')); - for (const agent of ALL_AGENTS.filter((a) => a !== 'cursor')) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'cursor-only')); - } - - // Lock: 2 entries with different formats - await assertLockEntryCount(projectRoot, 2); - await assertLockEntry(projectRoot, 'rule', 'formatting', { - format: 'canonical', - outputCount: 4, - }); - await assertLockEntry(projectRoot, 'rule', 'cursor-only', { - format: 'native:cursor', - outputCount: 1, - }); - }); - it('installs canonical prompts and native prompts from the same repo', async () => { // Canonical prompt → goes to copilot + claude writeCanonicalFile( @@ -490,37 +291,6 @@ describe('E2E native passthrough', () => { // ------------------------------------------------------------------------- describe('content passthrough fidelity', () => { - it('native rule content is byte-identical to source', async () => { - const content = [ - '---', - 'alwaysApply: false', - 'globs:', - ' - "**/*.rs"', - '---', - '', - '# Rust Guidelines', - '', - 'Use `cargo fmt` before committing.', - 'Prefer `thiserror` over manual Error impls.', - '', - ].join('\n'); - - writeNativeFile(sourceRepo, 'rule', 'cursor', 'rust-style.mdc', content); - - await addRules({ - source: 'test/native-repo', - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - const output = readFileSync( - getExpectedOutputPath(projectRoot, 'cursor', 'rule', 'rust-style'), - 'utf-8' - ); - expect(output).toBe(content); - }); - it('native prompt content is byte-identical to source', async () => { const content = [ '---', diff --git a/tests/e2e-remove.test.ts b/tests/e2e-remove.test.ts index 7784478..7cf2419 100644 --- a/tests/e2e-remove.test.ts +++ b/tests/e2e-remove.test.ts @@ -3,7 +3,6 @@ import { existsSync } from 'fs'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, @@ -14,11 +13,10 @@ import { assertLockEntryCount, assertNoLockEntry, getLockFile, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; import { removeCommand } from '../src/remove.ts'; // --------------------------------------------------------------------------- @@ -29,7 +27,7 @@ import { removeCommand } from '../src/remove.ts'; // // Each test: // 1. Creates a source repo with canonical context files -// 2. Installs them via addRules/addPrompts/addAgents +// 2. Installs them via addPrompts/addAgents // 3. Verifies output files and lock entries exist // 4. Calls removeCommand with --yes and --type to remove items // 5. Verifies output files are deleted and lock entries removed @@ -55,79 +53,6 @@ describe('E2E remove flow', () => { cleanupProject(sourceRepo); }); - // ------------------------------------------------------------------------- - // Remove a rule → all output files deleted, lock entry removed - // ------------------------------------------------------------------------- - - describe('remove rule', () => { - it('removes all output files and lock entry for a canonical rule', async () => { - // 1. Install a canonical rule - const content = makeRuleContent('code-style', { body: 'Use const.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', content); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - // Verify install succeeded - for (const agent of ALL_AGENTS) { - const outPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); - assertFileExists(outPath); - } - await assertLockEntry(projectRoot, 'rule', 'code-style'); - await assertLockEntryCount(projectRoot, 1); - - // 2. Remove the rule - process.chdir(projectRoot); - await removeCommand(['code-style'], { type: ['rule'], yes: true }); - - // 3. Verify all output files are deleted - for (const agent of ALL_AGENTS) { - const outPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); - assertFileNotExists(outPath); - } - - // 4. Verify lock entry is removed - await assertNoLockEntry(projectRoot, 'rule', 'code-style'); - await assertLockEntryCount(projectRoot, 0); - }); - - it('removes only the named rule when multiple rules are installed', async () => { - // Install two rules - writeCanonicalFile(sourceRepo, 'rule', 'rule-a', makeRuleContent('rule-a', { body: 'A' })); - writeCanonicalFile(sourceRepo, 'rule', 'rule-b', makeRuleContent('rule-b', { body: 'B' })); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); - - await assertLockEntryCount(projectRoot, 2); - - // Remove only rule-a - process.chdir(projectRoot); - await removeCommand(['rule-a'], { type: ['rule'], yes: true }); - - // rule-a should be gone - for (const agent of ALL_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'rule-a')); - } - await assertNoLockEntry(projectRoot, 'rule', 'rule-a'); - - // rule-b should still exist - for (const agent of ALL_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'rule-b')); - } - await assertLockEntry(projectRoot, 'rule', 'rule-b'); - await assertLockEntryCount(projectRoot, 1); - }); - }); - // ------------------------------------------------------------------------- // Remove a prompt → all output files deleted, lock entry removed // ------------------------------------------------------------------------- @@ -161,6 +86,48 @@ describe('E2E remove flow', () => { await assertNoLockEntry(projectRoot, 'prompt', 'deploy'); await assertLockEntryCount(projectRoot, 0); }); + + it('removes only the named prompt when multiple prompts are installed', async () => { + // Install two prompts + writeCanonicalFile( + sourceRepo, + 'prompt', + 'prompt-a', + makePromptContent('prompt-a', { body: 'A' }) + ); + writeCanonicalFile( + sourceRepo, + 'prompt', + 'prompt-b', + makePromptContent('prompt-b', { body: 'B' }) + ); + + await addPrompts({ + source: sourceRepo, + sourcePath: sourceRepo, + projectRoot, + promptNames: ['*'], + }); + + await assertLockEntryCount(projectRoot, 2); + + // Remove only prompt-a + process.chdir(projectRoot); + await removeCommand(['prompt-a'], { type: ['prompt'], yes: true }); + + // prompt-a should be gone + for (const agent of PROMPT_AGENTS) { + assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'prompt-a')); + } + await assertNoLockEntry(projectRoot, 'prompt', 'prompt-a'); + + // prompt-b should still exist + for (const agent of PROMPT_AGENTS) { + assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'prompt-b')); + } + await assertLockEntry(projectRoot, 'prompt', 'prompt-b'); + await assertLockEntryCount(projectRoot, 1); + }); }); // ------------------------------------------------------------------------- @@ -204,35 +171,35 @@ describe('E2E remove flow', () => { describe('remove non-existent item', () => { it('does not crash when removing an item that does not exist', async () => { - // Install one rule so the lock file exists + // Install one prompt so the lock file exists writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'existing', - makeRuleContent('existing', { body: 'Content.' }) + makePromptContent('existing', { body: 'Content.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); await assertLockEntryCount(projectRoot, 1); // Try to remove a non-existent item — should not crash process.chdir(projectRoot); - await removeCommand(['nonexistent'], { type: ['rule'], yes: true }); + await removeCommand(['nonexistent'], { type: ['prompt'], yes: true }); - // The existing rule should still be intact - await assertLockEntry(projectRoot, 'rule', 'existing'); + // The existing prompt should still be intact + await assertLockEntry(projectRoot, 'prompt', 'existing'); await assertLockEntryCount(projectRoot, 1); }); it('does not crash when lock file does not exist', async () => { // No lock file exists — removeCommand should handle gracefully process.chdir(projectRoot); - await removeCommand(['anything'], { type: ['rule'], yes: true }); + await removeCommand(['anything'], { type: ['prompt'], yes: true }); // Should not throw — just a no-op }); @@ -243,71 +210,71 @@ describe('E2E remove flow', () => { // ------------------------------------------------------------------------- describe('remove --all', () => { - it('removes all rules when --all is specified', async () => { - // Install 3 rules - writeCanonicalFile(sourceRepo, 'rule', 'r1', makeRuleContent('r1', { body: 'R1' })); - writeCanonicalFile(sourceRepo, 'rule', 'r2', makeRuleContent('r2', { body: 'R2' })); - writeCanonicalFile(sourceRepo, 'rule', 'r3', makeRuleContent('r3', { body: 'R3' })); + it('removes all prompts when --all is specified', async () => { + // Install 3 prompts + writeCanonicalFile(sourceRepo, 'prompt', 'p1', makePromptContent('p1', { body: 'P1' })); + writeCanonicalFile(sourceRepo, 'prompt', 'p2', makePromptContent('p2', { body: 'P2' })); + writeCanonicalFile(sourceRepo, 'prompt', 'p3', makePromptContent('p3', { body: 'P3' })); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); await assertLockEntryCount(projectRoot, 3); - // Remove all rules + // Remove all prompts process.chdir(projectRoot); - await removeCommand([], { type: ['rule'], yes: true, all: true }); + await removeCommand([], { type: ['prompt'], yes: true, all: true }); // All output files gone - for (const name of ['r1', 'r2', 'r3']) { - for (const agent of ALL_AGENTS) { - assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'rule', name)); + for (const name of ['p1', 'p2', 'p3']) { + for (const agent of PROMPT_AGENTS) { + assertFileNotExists(getExpectedOutputPath(projectRoot, agent, 'prompt', name)); } - await assertNoLockEntry(projectRoot, 'rule', name); + await assertNoLockEntry(projectRoot, 'prompt', name); } await assertLockEntryCount(projectRoot, 0); }); it('removes all items across types when --all + multiple types', async () => { - // Install a rule and a prompt - writeCanonicalFile( - sourceRepo, - 'rule', - 'my-rule', - makeRuleContent('my-rule', { body: 'Rule.' }) - ); + // Install a prompt and an agent writeCanonicalFile( sourceRepo, 'prompt', 'my-prompt', makePromptContent('my-prompt', { body: 'Prompt.' }) ); + writeCanonicalFile( + sourceRepo, + 'agent', + 'my-agent', + makeAgentContent('my-agent', { body: 'Agent.' }) + ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - await addPrompts({ + await addAgents({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - promptNames: ['*'], + agentNames: ['*'], }); await assertLockEntryCount(projectRoot, 2); - // Remove all rules and prompts + // Remove all prompts and agents process.chdir(projectRoot); - await removeCommand([], { type: ['rule', 'prompt'], yes: true, all: true }); + await removeCommand([], { type: ['prompt', 'agent'], yes: true, all: true }); - await assertNoLockEntry(projectRoot, 'rule', 'my-rule'); await assertNoLockEntry(projectRoot, 'prompt', 'my-prompt'); + await assertNoLockEntry(projectRoot, 'agent', 'my-agent'); await assertLockEntryCount(projectRoot, 0); }); }); @@ -317,47 +284,47 @@ describe('E2E remove flow', () => { // ------------------------------------------------------------------------- describe('type filtering', () => { - it('removes only rules when --type rule, leaving prompts intact', async () => { - // Install a rule and a prompt + it('removes only prompts when --type prompt, leaving agents intact', async () => { + // Install a prompt and an agent with the same name writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'style', - makeRuleContent('style', { body: 'Style guide.' }) + makePromptContent('style', { body: 'Style prompt.' }) ); writeCanonicalFile( sourceRepo, - 'prompt', + 'agent', 'style', - makePromptContent('style', { body: 'Style prompt.' }) + makeAgentContent('style', { body: 'Style agent.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - await addPrompts({ + await addAgents({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - promptNames: ['*'], + agentNames: ['*'], }); await assertLockEntryCount(projectRoot, 2); - // Remove only rules named "style" + // Remove only prompts named "style" process.chdir(projectRoot); - await removeCommand(['style'], { type: ['rule'], yes: true }); + await removeCommand(['style'], { type: ['prompt'], yes: true }); - // Rule should be gone - await assertNoLockEntry(projectRoot, 'rule', 'style'); + // Prompt should be gone + await assertNoLockEntry(projectRoot, 'prompt', 'style'); - // Prompt should still exist - await assertLockEntry(projectRoot, 'prompt', 'style'); - for (const agent of PROMPT_AGENTS) { - assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'style')); + // Agent should still exist + await assertLockEntry(projectRoot, 'agent', 'style'); + for (const agent of AGENT_AGENTS) { + assertFileExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'style')); } await assertLockEntryCount(projectRoot, 1); }); @@ -371,20 +338,20 @@ describe('E2E remove flow', () => { it('lock file has version and empty items array after all items removed', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'cleanup', - makeRuleContent('cleanup', { body: 'Content.' }) + makePromptContent('cleanup', { body: 'Content.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // Remove process.chdir(projectRoot); - await removeCommand(['cleanup'], { type: ['rule'], yes: true }); + await removeCommand(['cleanup'], { type: ['prompt'], yes: true }); // Lock file should still exist with valid structure const lock = await getLockFile(projectRoot); @@ -395,19 +362,19 @@ describe('E2E remove flow', () => { it('output files referenced by lock entry are all deleted', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'verify-outputs', - makeRuleContent('verify-outputs', { body: 'Check outputs.' }) + makePromptContent('verify-outputs', { body: 'Check outputs.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // Get lock entry to know exact output paths - const entry = await assertLockEntry(projectRoot, 'rule', 'verify-outputs'); + const entry = await assertLockEntry(projectRoot, 'prompt', 'verify-outputs'); expect(entry.outputs.length).toBeGreaterThan(0); // All output files should exist before removal @@ -417,7 +384,7 @@ describe('E2E remove flow', () => { // Remove process.chdir(projectRoot); - await removeCommand(['verify-outputs'], { type: ['rule'], yes: true }); + await removeCommand(['verify-outputs'], { type: ['prompt'], yes: true }); // All output files should be gone for (const outputPath of entry.outputs) { @@ -434,24 +401,24 @@ describe('E2E remove flow', () => { it('matches item names case-insensitively', async () => { writeCanonicalFile( sourceRepo, - 'rule', - 'my-rule', - makeRuleContent('my-rule', { body: 'Content.' }) + 'prompt', + 'my-prompt', + makePromptContent('my-prompt', { body: 'Content.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - await assertLockEntry(projectRoot, 'rule', 'my-rule'); + await assertLockEntry(projectRoot, 'prompt', 'my-prompt'); // Remove with different casing process.chdir(projectRoot); - await removeCommand(['MY-RULE'], { type: ['rule'], yes: true }); + await removeCommand(['MY-PROMPT'], { type: ['prompt'], yes: true }); - await assertNoLockEntry(projectRoot, 'rule', 'my-rule'); + await assertNoLockEntry(projectRoot, 'prompt', 'my-prompt'); await assertLockEntryCount(projectRoot, 0); }); }); diff --git a/tests/e2e-update-flow.test.ts b/tests/e2e-update-flow.test.ts index 2923591..a159d8c 100644 --- a/tests/e2e-update-flow.test.ts +++ b/tests/e2e-update-flow.test.ts @@ -4,7 +4,6 @@ import { join } from 'path'; import { createTempProject, cleanupProject, - makeRuleContent, makePromptContent, makeAgentContent, writeCanonicalFile, @@ -12,12 +11,11 @@ import { getExpectedOutputPath, assertLockEntry, assertLockEntryCount, - ALL_AGENTS, PROMPT_AGENTS, AGENT_AGENTS, } from './e2e-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; -import { checkRuleUpdates, updateRules } from '../src/rule-check.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; +import { checkContextUpdates, updateContext } from '../src/context-check.ts'; import { computeContentHash } from '../src/dotai-lock.ts'; // --------------------------------------------------------------------------- @@ -28,10 +26,10 @@ import { computeContentHash } from '../src/dotai-lock.ts'; // // Each test: // 1. Creates a source repo with a canonical context file -// 2. Installs it via addRules/addPrompts/addAgents +// 2. Installs it via addPrompts/addAgents // 3. Modifies the source file content -// 4. Runs checkRuleUpdates to verify detection -// 5. Runs updateRules to apply the update +// 4. Runs checkContextUpdates to verify detection +// 5. Runs updateContext to apply the update // 6. Verifies output file content and lock hash are updated // --------------------------------------------------------------------------- @@ -50,36 +48,36 @@ describe('E2E update flow', () => { }); // ------------------------------------------------------------------------- - // Rule update flow + // Prompt update flow // ------------------------------------------------------------------------- - describe('rule update', () => { + describe('prompt update', () => { it('check detects changed content hash after source modification', async () => { - // 1. Install a canonical rule - const originalContent = makeRuleContent('code-style', { + // 1. Install a canonical prompt + const originalContent = makePromptContent('code-style', { body: 'Use const over let.', }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', originalContent); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', originalContent); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // Verify initial install - const initialEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const initialEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const initialHash = initialEntry.hash; // 2. Modify source content - const updatedContent = makeRuleContent('code-style', { + const updatedContent = makePromptContent('code-style', { body: 'Use const over let. Also prefer arrow functions.', }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', updatedContent); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', updatedContent); // 3. Check detects the update - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(1); @@ -91,18 +89,18 @@ describe('E2E update flow', () => { }); it('check reports no updates when source is unchanged', async () => { - const content = makeRuleContent('code-style', { body: 'Same content.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', content); + const content = makePromptContent('code-style', { body: 'Same content.' }); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', content); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // No modification — check should find nothing - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(0); @@ -111,136 +109,130 @@ describe('E2E update flow', () => { it('update replaces output files with new content and updates lock hash', async () => { // 1. Install - const originalContent = makeRuleContent('code-style', { - body: 'Original rule body.', + const originalContent = makePromptContent('code-style', { + body: 'Original prompt body.', }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', originalContent); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', originalContent); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const initialEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const initialEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const initialHash = initialEntry.hash; // 2. Modify source - const updatedContent = makeRuleContent('code-style', { - body: 'Updated rule body with new guidelines.', + const updatedContent = makePromptContent('code-style', { + body: 'Updated prompt body with new guidelines.', }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', updatedContent); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', updatedContent); // 3. Run update - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.totalChecked).toBe(1); expect(updateResult.successCount).toBe(1); expect(updateResult.failCount).toBe(0); // 4. Verify output files contain new content - for (const agent of ALL_AGENTS) { - const outputPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); - assertFileExists(outputPath, 'Updated rule body with new guidelines.'); + for (const agent of PROMPT_AGENTS) { + const outputPath = getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'); + assertFileExists(outputPath, 'Updated prompt body with new guidelines.'); } // 5. Verify lock hash updated - const updatedEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const updatedEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); expect(updatedEntry.hash).not.toBe(initialHash); expect(updatedEntry.hash).toBe(computeContentHash(updatedContent)); }); it('update preserves installedAt timestamp', async () => { - const originalContent = makeRuleContent('code-style', { body: 'Original.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', originalContent); + const originalContent = makePromptContent('code-style', { body: 'Original.' }); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', originalContent); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const initialEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const initialEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const originalInstalledAt = initialEntry.installedAt; // Modify and update - const updatedContent = makeRuleContent('code-style', { body: 'Updated.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', updatedContent); + const updatedContent = makePromptContent('code-style', { body: 'Updated.' }); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', updatedContent); - await updateRules(projectRoot); + await updateContext(projectRoot); - const updatedEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const updatedEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); expect(updatedEntry.installedAt).toBe(originalInstalledAt); }); - it('update with multiple rules only updates changed ones', async () => { - // Install two rules + it('update with multiple prompts only updates changed ones', async () => { + // Install two prompts writeCanonicalFile( sourceRepo, - 'rule', - 'rule-a', - makeRuleContent('rule-a', { body: 'Rule A body.' }) + 'prompt', + 'prompt-a', + makePromptContent('prompt-a', { body: 'Prompt A body.' }) ); writeCanonicalFile( sourceRepo, - 'rule', - 'rule-b', - makeRuleContent('rule-b', { body: 'Rule B body.' }) + 'prompt', + 'prompt-b', + makePromptContent('prompt-b', { body: 'Prompt B body.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - const initialEntryA = await assertLockEntry(projectRoot, 'rule', 'rule-a'); - const initialEntryB = await assertLockEntry(projectRoot, 'rule', 'rule-b'); + const initialEntryA = await assertLockEntry(projectRoot, 'prompt', 'prompt-a'); + const initialEntryB = await assertLockEntry(projectRoot, 'prompt', 'prompt-b'); - // Only modify rule-b + // Only modify prompt-b writeCanonicalFile( sourceRepo, - 'rule', - 'rule-b', - makeRuleContent('rule-b', { body: 'Rule B UPDATED body.' }) + 'prompt', + 'prompt-b', + makePromptContent('prompt-b', { body: 'Prompt B UPDATED body.' }) ); - // Check should only report rule-b - const checkResult = await checkRuleUpdates(projectRoot); + // Check should only report prompt-b + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(2); expect(checkResult.updates).toHaveLength(1); - expect(checkResult.updates[0]!.entry.name).toBe('rule-b'); + expect(checkResult.updates[0]!.entry.name).toBe('prompt-b'); // Update - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.successCount).toBe(1); - // rule-a hash should be unchanged - const updatedEntryA = await assertLockEntry(projectRoot, 'rule', 'rule-a'); + // prompt-a hash should be unchanged + const updatedEntryA = await assertLockEntry(projectRoot, 'prompt', 'prompt-a'); expect(updatedEntryA.hash).toBe(initialEntryA.hash); - // rule-b hash should be different - const updatedEntryB = await assertLockEntry(projectRoot, 'rule', 'rule-b'); + // prompt-b hash should be different + const updatedEntryB = await assertLockEntry(projectRoot, 'prompt', 'prompt-b'); expect(updatedEntryB.hash).not.toBe(initialEntryB.hash); - // rule-b output should have new content - for (const agent of ALL_AGENTS) { + // prompt-b output should have new content + for (const agent of PROMPT_AGENTS) { assertFileExists( - getExpectedOutputPath(projectRoot, agent, 'rule', 'rule-b'), - 'Rule B UPDATED body.' + getExpectedOutputPath(projectRoot, agent, 'prompt', 'prompt-b'), + 'Prompt B UPDATED body.' ); } }); - }); - // ------------------------------------------------------------------------- - // Prompt update flow - // ------------------------------------------------------------------------- - - describe('prompt update', () => { it('check detects changed prompt content', async () => { const originalContent = makePromptContent('review-code', { body: 'Review the code for bugs.', @@ -264,7 +256,7 @@ describe('E2E update flow', () => { writeCanonicalFile(sourceRepo, 'prompt', 'review-code', updatedContent); // Check - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(1); expect(checkResult.updates[0]!.entry.name).toBe('review-code'); @@ -295,7 +287,7 @@ describe('E2E update flow', () => { writeCanonicalFile(sourceRepo, 'prompt', 'review-code', updatedContent); // Update - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.totalChecked).toBe(1); expect(updateResult.successCount).toBe(1); @@ -340,7 +332,7 @@ describe('E2E update flow', () => { writeCanonicalFile(sourceRepo, 'agent', 'architect', updatedContent); // Check - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(1); expect(checkResult.updates[0]!.entry.name).toBe('architect'); @@ -371,7 +363,7 @@ describe('E2E update flow', () => { writeCanonicalFile(sourceRepo, 'agent', 'architect', updatedContent); // Update - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.totalChecked).toBe(1); expect(updateResult.successCount).toBe(1); @@ -393,13 +385,13 @@ describe('E2E update flow', () => { // ------------------------------------------------------------------------- describe('mixed type update', () => { - it('check and update work across rules, prompts, and agents from same source', async () => { - // Install all three types + it('check and update work across prompts and agents from same source', async () => { + // Install both types writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'Style v1.' }) + makePromptContent('code-style', { body: 'Style v1.' }) ); writeCanonicalFile( sourceRepo, @@ -414,12 +406,6 @@ describe('E2E update flow', () => { makeAgentContent('architect', { body: 'Architect v1.' }) ); - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, @@ -436,16 +422,16 @@ describe('E2E update flow', () => { await assertLockEntryCount(projectRoot, 3); // Save initial hashes - const initialRule = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const initialCodeStyle = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const initialPrompt = await assertLockEntry(projectRoot, 'prompt', 'review-code'); const initialAgent = await assertLockEntry(projectRoot, 'agent', 'architect'); // Modify all three writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'Style v2.' }) + makePromptContent('code-style', { body: 'Style v2.' }) ); writeCanonicalFile( sourceRepo, @@ -461,7 +447,7 @@ describe('E2E update flow', () => { ); // Check detects all 3 updates - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(3); expect(checkResult.updates).toHaveLength(3); expect(checkResult.errors).toHaveLength(0); @@ -470,24 +456,24 @@ describe('E2E update flow', () => { expect(updateNames).toEqual(['architect', 'code-style', 'review-code']); // Update all - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.totalChecked).toBe(3); expect(updateResult.successCount).toBe(3); expect(updateResult.failCount).toBe(0); // Verify all hashes changed - const updatedRule = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const updatedCodeStyle = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const updatedPrompt = await assertLockEntry(projectRoot, 'prompt', 'review-code'); const updatedAgent = await assertLockEntry(projectRoot, 'agent', 'architect'); - expect(updatedRule.hash).not.toBe(initialRule.hash); + expect(updatedCodeStyle.hash).not.toBe(initialCodeStyle.hash); expect(updatedPrompt.hash).not.toBe(initialPrompt.hash); expect(updatedAgent.hash).not.toBe(initialAgent.hash); // Verify output content - for (const agent of ALL_AGENTS) { + for (const agent of PROMPT_AGENTS) { assertFileExists( - getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'), + getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'), 'Style v2.' ); } @@ -506,12 +492,12 @@ describe('E2E update flow', () => { }); it('partial updates only affect changed items', async () => { - // Install rule and prompt + // Install two prompts writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) + makePromptContent('code-style', { body: 'Style body.' }) ); writeCanonicalFile( sourceRepo, @@ -520,12 +506,6 @@ describe('E2E update flow', () => { makePromptContent('review-code', { body: 'Review body.' }) ); - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot, - ruleNames: ['*'], - }); await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, @@ -533,10 +513,10 @@ describe('E2E update flow', () => { promptNames: ['*'], }); - const initialRuleEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); + const initialCodeStyleEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); const initialPromptEntry = await assertLockEntry(projectRoot, 'prompt', 'review-code'); - // Only modify the prompt + // Only modify the review-code prompt writeCanonicalFile( sourceRepo, 'prompt', @@ -544,22 +524,22 @@ describe('E2E update flow', () => { makePromptContent('review-code', { body: 'Review body UPDATED.' }) ); - // Check — only prompt should have update - const checkResult = await checkRuleUpdates(projectRoot); + // Check — only review-code should have update + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(2); expect(checkResult.updates).toHaveLength(1); expect(checkResult.updates[0]!.entry.name).toBe('review-code'); expect(checkResult.updates[0]!.entry.type).toBe('prompt'); // Update - const updateResult = await updateRules(projectRoot); + const updateResult = await updateContext(projectRoot); expect(updateResult.successCount).toBe(1); - // Rule hash unchanged - const updatedRuleEntry = await assertLockEntry(projectRoot, 'rule', 'code-style'); - expect(updatedRuleEntry.hash).toBe(initialRuleEntry.hash); + // code-style hash unchanged + const updatedCodeStyleEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style'); + expect(updatedCodeStyleEntry.hash).toBe(initialCodeStyleEntry.hash); - // Prompt hash changed + // review-code hash changed const updatedPromptEntry = await assertLockEntry(projectRoot, 'prompt', 'review-code'); expect(updatedPromptEntry.hash).not.toBe(initialPromptEntry.hash); }); @@ -571,33 +551,33 @@ describe('E2E update flow', () => { describe('edge cases', () => { it('check reports error when source item is removed from repo', async () => { - // Install a rule + // Install a prompt writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'Style body.' }) + makePromptContent('code-style', { body: 'Style body.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); - // Replace with a different rule (remove code-style, add different-rule) + // Replace with a different prompt (remove code-style, add different-prompt) const { rmSync } = await import('fs'); - rmSync(join(sourceRepo, 'rules', 'code-style'), { recursive: true, force: true }); + rmSync(join(sourceRepo, 'prompts', 'code-style'), { recursive: true, force: true }); writeCanonicalFile( sourceRepo, - 'rule', - 'different-rule', - makeRuleContent('different-rule', { body: 'Different body.' }) + 'prompt', + 'different-prompt', + makePromptContent('different-prompt', { body: 'Different body.' }) ); - // Check should report error for missing rule - const checkResult = await checkRuleUpdates(projectRoot); + // Check should report error for missing prompt + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(0); expect(checkResult.errors).toHaveLength(1); @@ -607,24 +587,24 @@ describe('E2E update flow', () => { it('second update after already-updated content reports no updates', async () => { // Install - const originalContent = makeRuleContent('code-style', { body: 'Original.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', originalContent); + const originalContent = makePromptContent('code-style', { body: 'Original.' }); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', originalContent); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // Modify and update - const updatedContent = makeRuleContent('code-style', { body: 'Updated.' }); - writeCanonicalFile(sourceRepo, 'rule', 'code-style', updatedContent); + const updatedContent = makePromptContent('code-style', { body: 'Updated.' }); + writeCanonicalFile(sourceRepo, 'prompt', 'code-style', updatedContent); - await updateRules(projectRoot); + await updateContext(projectRoot); // Second check — should find no updates - const checkResult = await checkRuleUpdates(projectRoot); + const checkResult = await checkContextUpdates(projectRoot); expect(checkResult.totalChecked).toBe(1); expect(checkResult.updates).toHaveLength(0); expect(checkResult.errors).toHaveLength(0); @@ -633,16 +613,16 @@ describe('E2E update flow', () => { it('update preserves lock entry count (no duplicates)', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'v1.' }) + makePromptContent('code-style', { body: 'v1.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); await assertLockEntryCount(projectRoot, 1); @@ -650,51 +630,51 @@ describe('E2E update flow', () => { // Modify and update writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'v2.' }) + makePromptContent('code-style', { body: 'v2.' }) ); - await updateRules(projectRoot); + await updateContext(projectRoot); // Still 1 entry, not 2 await assertLockEntryCount(projectRoot, 1); }); - it('update outputs all 4 agent files for a rule (same as initial install)', async () => { + it('update outputs all agent files for a prompt (same as initial install)', async () => { writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'v1 body.' }) + makePromptContent('code-style', { body: 'v1 body.' }) ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot, - ruleNames: ['*'], + promptNames: ['*'], }); // Modify and update writeCanonicalFile( sourceRepo, - 'rule', + 'prompt', 'code-style', - makeRuleContent('code-style', { body: 'v2 body.' }) + makePromptContent('code-style', { body: 'v2 body.' }) ); - await updateRules(projectRoot); + await updateContext(projectRoot); - // All 4 agents should have output files with new content - const updatedEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - outputCount: 4, + // All prompt agents should have output files with new content + const updatedEntry = await assertLockEntry(projectRoot, 'prompt', 'code-style', { + outputCount: PROMPT_AGENTS.length, }); - expect(updatedEntry.agents).toHaveLength(4); + expect(updatedEntry.agents).toHaveLength(PROMPT_AGENTS.length); - for (const agent of ALL_AGENTS) { + for (const agent of PROMPT_AGENTS) { assertFileExists( - getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'), + getExpectedOutputPath(projectRoot, agent, 'prompt', 'code-style'), 'v2 body.' ); } diff --git a/tests/e2e-utils.ts b/tests/e2e-utils.ts index 3e998d6..78b6730 100644 --- a/tests/e2e-utils.ts +++ b/tests/e2e-utils.ts @@ -48,34 +48,6 @@ export function cleanupProject(projectRoot: string): void { // Test source repo creation // --------------------------------------------------------------------------- -/** - * Create a minimal RULES.md content string for testing. - */ -export function makeRuleContent( - name: string, - opts: { description?: string; activation?: string; globs?: string[]; body?: string } = {} -): string { - const desc = opts.description ?? `Description for ${name}`; - const activation = opts.activation ?? 'always'; - const globLines = - opts.globs && opts.globs.length > 0 - ? `globs:\n${opts.globs.map((g) => ` - "${g}"`).join('\n')}\n` - : ''; - - return [ - '---', - `name: ${name}`, - `description: ${desc}`, - `activation: ${activation}`, - globLines ? globLines.trimEnd() : null, - '---', - '', - opts.body ?? `Body content for ${name}.`, - ] - .filter((line) => line !== null) - .join('\n'); -} - /** * Create a minimal PROMPT.md content string for testing. */ @@ -166,7 +138,6 @@ export function writeCanonicalFile( content: string ): string { const fileMap: Record = { - rule: `rules/${name}/RULES.md`, prompt: `prompts/${name}/PROMPT.md`, agent: `agents/${name}/AGENT.md`, skill: `skills/${name}/SKILL.md`, @@ -194,9 +165,7 @@ export function writeNativeFile( ): string { const config = targetAgents[agent]; let outputDir: string; - if (type === 'rule') { - outputDir = config.nativeRuleDiscovery.sourceDir; - } else if (type === 'prompt' && config.nativePromptDiscovery) { + if (type === 'prompt' && config.nativePromptDiscovery) { outputDir = config.nativePromptDiscovery.sourceDir; } else if (type === 'agent' && config.nativeAgentDiscovery) { outputDir = config.nativeAgentDiscovery.sourceDir; @@ -285,10 +254,7 @@ export function getExpectedOutputPath( let outputDir: string; let extension: string; - if (type === 'rule') { - outputDir = config.rulesConfig.outputDir; - extension = config.rulesConfig.extension; - } else if (type === 'prompt') { + if (type === 'prompt') { if (!config.promptsConfig) { throw new Error(`Agent ${agent} does not support prompts`); } @@ -491,25 +457,6 @@ export async function createTempProjectDir( // Simple content factories (matching cli-lock-integration originals) // --------------------------------------------------------------------------- -/** - * Create a canonical RULES.md with standard frontmatter. - * - * Uses a fixed `globs: ["*.ts"]` and `activation: always` — matches the - * factory pattern from cli-lock-integration tests. - */ -export function makeSimpleRulesContent(name: string, description: string, body: string): string { - return `--- -name: ${name} -description: ${description} -globs: - - "*.ts" -activation: always ---- - -${body} -`; -} - /** * Create a canonical INSTRUCTIONS.md with simple frontmatter. * @@ -568,38 +515,34 @@ ${body} * * When a single item is provided, the file is placed at the repo root. * When multiple items are provided, each is placed in its own subdirectory - * under a type-specific parent directory (e.g., `rules//RULES.md`). + * under a type-specific parent directory (e.g., `prompts//PROMPT.md`). * * @param baseDir - Parent directory for the source repo * @param items - Array of `{ name, description, body }` for each item - * @param type - Context type: `'rule'` (default), `'agent'`, or `'prompt'` + * @param type - Context type: `'instruction'` (default), `'agent'`, or `'prompt'` * @returns Absolute path to the created source repo directory */ export async function createTestSourceRepo( baseDir: string, items: Array<{ name: string; description: string; body: string }>, - type: 'rule' | 'agent' | 'prompt' | 'instruction' = 'rule' + type: 'agent' | 'prompt' | 'instruction' = 'instruction' ): Promise { const dirNames: Record = { - rule: 'source-repo', agent: 'agent-source-repo', prompt: 'prompt-source-repo', instruction: 'instruction-source-repo', }; const fileNames: Record = { - rule: 'RULES.md', agent: 'AGENT.md', prompt: 'PROMPT.md', instruction: 'INSTRUCTIONS.md', }; const subdirNames: Record = { - rule: 'rules', agent: 'agents', prompt: 'prompts', instruction: 'instructions', }; const contentFns: Record string> = { - rule: makeSimpleRulesContent, agent: makeSimpleAgentContent, prompt: makeSimplePromptContent, instruction: makeSimpleInstructionContent, diff --git a/tests/gitignore-integration.test.ts b/tests/gitignore-integration.test.ts index c458ad6..a111bc9 100644 --- a/tests/gitignore-integration.test.ts +++ b/tests/gitignore-integration.test.ts @@ -3,13 +3,12 @@ import { readFile, writeFile } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; import { relative } from 'path'; -import { addRules, addPrompts } from '../src/rule-add.ts'; +import { addPrompts } from '../src/context-add.ts'; import { runCli } from '../src/test-utils.ts'; import { readManagedPaths } from '../src/gitignore.ts'; import { removeCommand } from '../src/remove.ts'; import { createTempProjectDir, - makeSimpleRulesContent, makeSimplePromptContent, createTestSourceRepo, readLockFileFromDisk, @@ -20,10 +19,10 @@ function toManagedPath(projectRoot: string, outputPath: string): string { } // --------------------------------------------------------------------------- -// addRules --gitignore integration tests +// addPrompts --gitignore integration tests // --------------------------------------------------------------------------- -describe('addRules --gitignore integration', () => { +describe('addPrompts --gitignore integration', () => { let tempDir: string; let projectDir: string; let cleanup: () => Promise; @@ -37,20 +36,22 @@ describe('addRules --gitignore integration', () => { }); it('creates lock entry with gitignored: true and adds paths to .gitignore', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const over let' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style prompts', body: 'Use const over let' }], + 'prompt' + ); - const result = await addRules({ + const result = await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); + expect(result.promptsInstalled).toBe(1); // Lock entry should have gitignored: true const lock = await readLockFileFromDisk(projectDir); @@ -75,15 +76,17 @@ describe('addRules --gitignore integration', () => { }); it('does not set gitignored on lock entry when --gitignore is not used', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style prompts', body: 'Use const' }], + 'prompt' + ); - await addRules({ + await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], }); const lock = await readLockFileFromDisk(projectDir); @@ -94,15 +97,17 @@ describe('addRules --gitignore integration', () => { }); it('dry-run does not modify .gitignore', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); - const result = await addRules({ + const result = await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, dryRun: true, }); @@ -115,22 +120,26 @@ describe('addRules --gitignore integration', () => { expect(existsSync(join(projectDir, '.gitignore'))).toBe(false); }); - it('adds multiple rules with --gitignore', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - { name: 'security', description: 'Security', body: 'Validate inputs' }, - ]); + it('adds multiple prompts with --gitignore', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [ + { name: 'code-style', description: 'Style', body: 'Use const' }, + { name: 'security', description: 'Security', body: 'Validate inputs' }, + ], + 'prompt' + ); - const result = await addRules({ + const result = await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(2); + expect(result.promptsInstalled).toBe(2); // Both lock entries should have gitignored: true const lock = await readLockFileFromDisk(projectDir); @@ -148,19 +157,21 @@ describe('addRules --gitignore integration', () => { } }); - it('preserves existing .gitignore content when adding gitignored rule', async () => { + it('preserves existing .gitignore content when adding gitignored prompt', async () => { // Pre-populate .gitignore await writeFile(join(projectDir, '.gitignore'), 'node_modules/\n.env\n', 'utf-8'); - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); - await addRules({ + await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); @@ -172,16 +183,18 @@ describe('addRules --gitignore integration', () => { }); it('--gitignore with --targets limits output paths', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); - await addRules({ + await addPrompts({ source: 'test/repo', sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], - targets: ['cursor', 'opencode'], + promptNames: ['*'], + targets: ['github-copilot', 'opencode'], gitignore: true, }); @@ -190,19 +203,19 @@ describe('addRules --gitignore integration', () => { expect(lock.items[0]!.agents).toHaveLength(2); expect(lock.items[0]!.outputs).toHaveLength(2); - // Only cursor and opencode paths should be in .gitignore + // Only github-copilot and opencode paths should be in .gitignore const managedPaths = await readManagedPaths(projectDir); expect(managedPaths).toHaveLength(2); - expect(managedPaths.some((p) => p.includes('.cursor/'))).toBe(true); + expect(managedPaths.some((p) => p.includes('.github/prompts/'))).toBe(true); expect(managedPaths.some((p) => p.includes('.opencode/'))).toBe(true); }); }); // --------------------------------------------------------------------------- -// addPrompts --gitignore integration +// addPrompts --gitignore integration (type-specific) // --------------------------------------------------------------------------- -describe('addPrompts --gitignore integration', () => { +describe('addPrompts --gitignore integration (type-specific)', () => { let tempDir: string; let projectDir: string; let cleanup: () => Promise; @@ -265,17 +278,19 @@ describe('--gitignore remove integration', () => { await cleanup(); }); - it('remove of gitignored rule cleans .gitignore', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + it('remove of gitignored prompt cleans .gitignore', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); // Install with --gitignore - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); @@ -283,9 +298,9 @@ describe('--gitignore remove integration', () => { let managedPaths = await readManagedPaths(projectDir); expect(managedPaths.length).toBeGreaterThan(0); - // Remove the rule + // Remove the prompt process.chdir(projectDir); - await removeCommand(['code-style'], { type: ['rule'], yes: true }); + await removeCommand(['code-style'], { type: ['prompt'], yes: true }); // .gitignore managed section should be empty/removed managedPaths = await readManagedPaths(projectDir); @@ -297,17 +312,21 @@ describe('--gitignore remove integration', () => { }); it('remove preserves other gitignored entries', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'rule-a', description: 'Rule A', body: 'Body A' }, - { name: 'rule-b', description: 'Rule B', body: 'Body B' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [ + { name: 'prompt-a', description: 'Prompt A', body: 'Body A' }, + { name: 'prompt-b', description: 'Prompt B', body: 'Body B' }, + ], + 'prompt' + ); // Install both with --gitignore - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); @@ -316,24 +335,24 @@ describe('--gitignore remove integration', () => { const pathCountBefore = managedPaths.length; expect(pathCountBefore).toBeGreaterThan(0); - // Remove only rule-a + // Remove only prompt-a process.chdir(projectDir); - await removeCommand(['rule-a'], { type: ['rule'], yes: true }); + await removeCommand(['prompt-a'], { type: ['prompt'], yes: true }); - // .gitignore should still have rule-b's paths but not rule-a's + // .gitignore should still have prompt-b's paths but not prompt-a's managedPaths = await readManagedPaths(projectDir); expect(managedPaths.length).toBeGreaterThan(0); expect(managedPaths.length).toBeLessThan(pathCountBefore); - // rule-b paths should still be present - expect(managedPaths.some((p) => p.includes('rule-b'))).toBe(true); - // rule-a paths should be gone - expect(managedPaths.some((p) => p.includes('rule-a'))).toBe(false); + // prompt-b paths should still be present + expect(managedPaths.some((p) => p.includes('prompt-b'))).toBe(true); + // prompt-a paths should be gone + expect(managedPaths.some((p) => p.includes('prompt-a'))).toBe(false); - // Lock should only have rule-b + // Lock should only have prompt-b const lock = await readLockFileFromDisk(projectDir); expect(lock.items).toHaveLength(1); - expect(lock.items[0]!.name).toBe('rule-b'); + expect(lock.items[0]!.name).toBe('prompt-b'); expect(lock.items[0]!.gitignored).toBe(true); }); @@ -341,22 +360,24 @@ describe('--gitignore remove integration', () => { // Pre-populate .gitignore with user content await writeFile(join(projectDir, '.gitignore'), 'node_modules/\ndist/\n.env\n', 'utf-8'); - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); // Install with --gitignore - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], gitignore: true, }); - // Remove the rule + // Remove the prompt process.chdir(projectDir); - await removeCommand(['code-style'], { type: ['rule'], yes: true }); + await removeCommand(['code-style'], { type: ['prompt'], yes: true }); // .gitignore should still have user content const gitignoreContent = await readFile(join(projectDir, '.gitignore'), 'utf-8'); @@ -368,25 +389,27 @@ describe('--gitignore remove integration', () => { expect(gitignoreContent).not.toContain('# dotai:end'); }); - it('remove of non-gitignored rule does not touch .gitignore', async () => { + it('remove of non-gitignored prompt does not touch .gitignore', async () => { // Pre-populate .gitignore await writeFile(join(projectDir, '.gitignore'), 'node_modules/\n', 'utf-8'); - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style', body: 'Use const' }], + 'prompt' + ); // Install WITHOUT --gitignore - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], }); - // Remove the rule + // Remove the prompt process.chdir(projectDir); - await removeCommand(['code-style'], { type: ['rule'], yes: true }); + await removeCommand(['code-style'], { type: ['prompt'], yes: true }); // .gitignore should be unchanged const gitignoreContent = await readFile(join(projectDir, '.gitignore'), 'utf-8'); @@ -411,39 +434,43 @@ describe('CLI --gitignore subprocess tests', () => { await cleanup(); }); - it('add --rule --gitignore creates .gitignore with managed section', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const over let' }, - ]); + it('add --prompt --gitignore creates .gitignore with managed section', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style prompts', body: 'Use const over let' }], + 'prompt' + ); const result = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--gitignore', '-y'], + ['add', sourceRepo, '--prompt', 'code-style', '--gitignore', '-y'], projectDir ); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('rule(s) installed'); + expect(result.stdout).toContain('prompt(s) installed'); // .gitignore should exist with markers expect(existsSync(join(projectDir, '.gitignore'))).toBe(true); const gitignoreContent = await readFile(join(projectDir, '.gitignore'), 'utf-8'); expect(gitignoreContent).toContain('# dotai:start'); expect(gitignoreContent).toContain('# dotai:end'); - expect(gitignoreContent).toContain('.cursor/rules/code-style.mdc'); + expect(gitignoreContent).toContain('.github/prompts/code-style.prompt.md'); // Lock should have gitignored: true const lock = await readLockFileFromDisk(projectDir); expect(lock.items[0]!.gitignored).toBe(true); }); - it('add --rule --gitignore then remove cleans .gitignore', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); + it('add --prompt --gitignore then remove cleans .gitignore', async () => { + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Style prompts', body: 'Use const' }], + 'prompt' + ); // Install with --gitignore const addResult = runCli( - ['add', sourceRepo, '--rule', 'code-style', '--gitignore', '-y'], + ['add', sourceRepo, '--prompt', 'code-style', '--gitignore', '-y'], projectDir ); expect(addResult.exitCode).toBe(0); @@ -452,8 +479,8 @@ describe('CLI --gitignore subprocess tests', () => { let gitignoreContent = await readFile(join(projectDir, '.gitignore'), 'utf-8'); expect(gitignoreContent).toContain('# dotai:start'); - // Remove the rule - const removeResult = runCli(['remove', 'code-style', '--type', 'rule', '-y'], projectDir); + // Remove the prompt + const removeResult = runCli(['remove', 'code-style', '--type', 'prompt', '-y'], projectDir); expect(removeResult.exitCode).toBe(0); // .gitignore managed section should be removed diff --git a/tests/install-integration.test.ts b/tests/install-integration.test.ts deleted file mode 100644 index f33873f..0000000 --- a/tests/install-integration.test.ts +++ /dev/null @@ -1,725 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, mkdtempSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { discover } from '../src/rule-discovery.ts'; -import { executeInstallPipeline } from '../src/rule-installer.ts'; -import type { LockEntry, TargetAgent } from '../src/types.ts'; - -// --------------------------------------------------------------------------- -// Integration tests for the full install flow: -// discover → transpile → check collisions → install -// -// These tests create realistic source repo directory structures on disk, -// run the discovery engine over them, then feed discovered items through -// the install pipeline into a separate project directory. -// -// Reference: PRD.md Phase 5 — "Integration tests for install + rollback + -// collision handling" -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** All four target agents. */ -const ALL_AGENTS: readonly TargetAgent[] = [ - 'github-copilot', - 'claude-code', - 'cursor', - 'opencode', -] as const; - -/** Create a canonical RULES.md file with valid frontmatter. */ -function writeRulesMd( - dir: string, - name: string, - opts: { - description?: string; - activation?: string; - globs?: string[]; - body?: string; - } = {} -): void { - const desc = opts.description ?? `Description for ${name}`; - const activation = opts.activation ?? 'always'; - const body = opts.body ?? `## ${name}\n\nRule body for ${name}.`; - - const globLines = - opts.globs && opts.globs.length > 0 - ? `globs:\n${opts.globs.map((g) => ` - "${g}"`).join('\n')}\n` - : ''; - - const content = [ - '---', - `name: ${name}`, - `description: ${desc}`, - `activation: ${activation}`, - globLines ? globLines.trimEnd() : null, - '---', - '', - body, - ] - .filter((line) => line !== null) - .join('\n'); - - writeFileSync(join(dir, 'RULES.md'), content); -} - -/** Create a canonical SKILL.md file with valid frontmatter. */ -function writeSkillMd(dir: string, name: string, description?: string): void { - const desc = description ?? `Skill description for ${name}`; - const content = `---\nname: ${name}\ndescription: ${desc}\n---\n\nSkill body for ${name}.\n`; - writeFileSync(join(dir, 'SKILL.md'), content); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('integration: discover → transpile → install', () => { - let sourceDir: string; - let projectDir: string; - - beforeEach(() => { - sourceDir = mkdtempSync(join(tmpdir(), 'dotai-integ-source-')); - projectDir = mkdtempSync(join(tmpdir(), 'dotai-integ-project-')); - }); - - afterEach(() => { - rmSync(sourceDir, { recursive: true, force: true }); - rmSync(projectDir, { recursive: true, force: true }); - }); - - // ------------------------------------------------------------------------- - // Happy path: single rule → all 4 agents - // ------------------------------------------------------------------------- - - it('discovers and installs a single canonical rule to all 4 agents', async () => { - // Source repo has one canonical rule - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style', { - activation: 'always', - body: '- Use const over let', - }); - - // Discover - const { items, warnings } = await discover(sourceDir); - const rules = items.filter((i) => i.type === 'rule'); - expect(rules).toHaveLength(1); - expect(rules[0]!.name).toBe('code-style'); - expect(rules[0]!.format).toBe('canonical'); - - // Install - const result = await executeInstallPipeline(rules, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(4); - expect(result.collisions).toHaveLength(0); - - // Verify each agent got the file - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - - // Verify content is correctly transpiled - const cursorContent = readFileSync( - join(projectDir, '.cursor', 'rules', 'code-style.mdc'), - 'utf-8' - ); - expect(cursorContent).toContain('alwaysApply: true'); - expect(cursorContent).toContain('- Use const over let'); - - const copilotContent = readFileSync( - join(projectDir, '.github', 'instructions', 'code-style.instructions.md'), - 'utf-8' - ); - expect(copilotContent).toContain('applyTo: "**"'); - - const claudeContent = readFileSync( - join(projectDir, '.claude', 'rules', 'code-style.md'), - 'utf-8' - ); - expect(claudeContent).toContain('description: "Description for code-style"'); - }); - - // ------------------------------------------------------------------------- - // Multiple rules from subdirectories - // ------------------------------------------------------------------------- - - it('discovers and installs multiple rules from rules/ subdirectories', async () => { - // Create two rules - const rule1Dir = join(sourceDir, 'rules', 'code-style'); - const rule2Dir = join(sourceDir, 'rules', 'security'); - mkdirSync(rule1Dir, { recursive: true }); - mkdirSync(rule2Dir, { recursive: true }); - writeRulesMd(rule1Dir, 'code-style'); - writeRulesMd(rule2Dir, 'security', { activation: 'auto' }); - - const { items } = await discover(sourceDir); - const rules = items.filter((i) => i.type === 'rule'); - expect(rules).toHaveLength(2); - - const result = await executeInstallPipeline(rules, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'security.mdc'))).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Root RULES.md + subdirectory rules - // ------------------------------------------------------------------------- - - it('discovers root RULES.md alongside subdirectory rules', async () => { - writeRulesMd(sourceDir, 'root-rule', { body: 'Root rule body' }); - const subDir = join(sourceDir, 'rules', 'sub-rule'); - mkdirSync(subDir, { recursive: true }); - writeRulesMd(subDir, 'sub-rule'); - - const { items } = await discover(sourceDir); - const rules = items.filter((i) => i.type === 'rule'); - expect(rules).toHaveLength(2); - - const names = rules.map((r) => r.name).sort(); - expect(names).toEqual(['root-rule', 'sub-rule']); - }); - - // ------------------------------------------------------------------------- - // Mixed discovery: rules + skills - // ------------------------------------------------------------------------- - - it('discovers both rules and skills; pipeline installs rules and skips skills', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - const skillDir = join(sourceDir, 'skills', 'db-migrate'); - mkdirSync(skillDir, { recursive: true }); - writeSkillMd(skillDir, 'db-migrate'); - - const { items } = await discover(sourceDir); - expect(items.filter((i) => i.type === 'rule')).toHaveLength(1); - expect(items.filter((i) => i.type === 'skill')).toHaveLength(1); - - // Install all items — pipeline should skip skills silently - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); // only the rule - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Native passthrough rules - // ------------------------------------------------------------------------- - - it('discovers native cursor rules and installs via passthrough', async () => { - const nativeDir = join(sourceDir, '.cursor', 'rules'); - mkdirSync(nativeDir, { recursive: true }); - writeFileSync( - join(nativeDir, 'lint-config.mdc'), - '---\ndescription: Lint config\nalwaysApply: true\n---\nLint body' - ); - - const { items } = await discover(sourceDir); - const nativeRules = items.filter((i) => i.format === 'native:cursor'); - expect(nativeRules).toHaveLength(1); - expect(nativeRules[0]!.name).toBe('lint-config'); - - const result = await executeInstallPipeline(nativeRules, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - - const content = readFileSync(join(projectDir, '.cursor', 'rules', 'lint-config.mdc'), 'utf-8'); - // Native passthrough preserves content exactly - expect(content).toContain('Lint body'); - expect(content).toContain('alwaysApply: true'); - }); - - // ------------------------------------------------------------------------- - // Mixed canonical + native in one pass - // ------------------------------------------------------------------------- - - it('installs canonical + native rules from same source repo', async () => { - // Canonical rule - const canonDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(canonDir, { recursive: true }); - writeRulesMd(canonDir, 'code-style'); - - // Native cursor rule - const nativeDir = join(sourceDir, '.cursor', 'rules'); - mkdirSync(nativeDir, { recursive: true }); - writeFileSync(join(nativeDir, 'native-lint.mdc'), 'Native cursor lint content'); - - const { items } = await discover(sourceDir); - - // Install only to cursor — both canonical and native should work - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - - expect(result.success).toBe(true); - // 1 canonical rule → 1 cursor output + 1 native cursor passthrough - expect(result.written).toHaveLength(2); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'native-lint.mdc'))).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Glob activation: correct mapping across agents - // ------------------------------------------------------------------------- - - it('transpiles glob activation correctly for all agents', async () => { - const ruleDir = join(sourceDir, 'rules', 'ts-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'ts-style', { - activation: 'glob', - globs: ['*.ts', '*.tsx'], - body: 'TypeScript style rules', - }); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(4); - - // Cursor: globs as comma-separated string - const cursor = readFileSync(join(projectDir, '.cursor', 'rules', 'ts-style.mdc'), 'utf-8'); - expect(cursor).toContain('globs: *.ts, *.tsx'); - expect(cursor).toContain('alwaysApply: false'); - - // Copilot: applyTo with globs - const copilot = readFileSync( - join(projectDir, '.github', 'instructions', 'ts-style.instructions.md'), - 'utf-8' - ); - expect(copilot).toContain('applyTo:'); - expect(copilot).toContain('*.ts'); - }); - - // ------------------------------------------------------------------------- - // Dry-run mode - // ------------------------------------------------------------------------- - - it('dry-run reports planned writes without creating any files', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - dryRun: true, - }); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(4); - expect(result.written).toHaveLength(0); - - // No files should have been created - expect(existsSync(join(projectDir, '.cursor'))).toBe(false); - expect(existsSync(join(projectDir, '.github'))).toBe(false); - expect(existsSync(join(projectDir, '.claude'))).toBe(false); - expect(existsSync(join(projectDir, '.opencode'))).toBe(false); - }); - - // ------------------------------------------------------------------------- - // Agent subset filtering - // ------------------------------------------------------------------------- - - it('installs only to specified agents, others untouched', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor', 'opencode'] as const, - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); - - // Others should NOT exist - expect(existsSync(join(projectDir, '.github'))).toBe(false); - expect(existsSync(join(projectDir, '.claude'))).toBe(false); - }); - - // ------------------------------------------------------------------------- - // Collision: pre-existing user file blocks install - // ------------------------------------------------------------------------- - - it('blocks install when user file already exists at target path', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - // Pre-existing user file in the project - const conflictDir = join(projectDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'code-style.mdc'), 'user-authored content'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(false); - expect(result.collisions.length).toBeGreaterThan(0); - expect(result.collisions[0]!.kind).toBe('file-exists'); - expect(result.written).toHaveLength(0); - - // User file should be untouched - const content = readFileSync(join(conflictDir, 'code-style.mdc'), 'utf-8'); - expect(content).toBe('user-authored content'); - }); - - // ------------------------------------------------------------------------- - // Collision: --force overrides pre-existing file - // ------------------------------------------------------------------------- - - it('--force overrides pre-existing user files and completes install', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - // Pre-existing user file - const conflictDir = join(projectDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'code-style.mdc'), 'user-authored content'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - force: true, - }); - - expect(result.success).toBe(true); - expect(result.collisions.length).toBeGreaterThan(0); // collisions detected but forced - expect(result.written).toHaveLength(4); - - // File should now have transpiled content, not user content - const content = readFileSync(join(conflictDir, 'code-style.mdc'), 'utf-8'); - expect(content).not.toBe('user-authored content'); - expect(content).toContain('alwaysApply:'); - }); - - // ------------------------------------------------------------------------- - // Collision: same-name from different source - // ------------------------------------------------------------------------- - - it('blocks when same rule name is already installed from a different source', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - const existingEntry: LockEntry = { - type: 'rule', - name: 'code-style', - source: 'other/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'abc123', - installedAt: new Date().toISOString(), - outputs: [join(projectDir, '.cursor', 'rules', 'code-style.mdc')], - }; - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [existingEntry], - }); - - expect(result.success).toBe(false); - expect(result.collisions.some((c) => c.kind === 'same-name')).toBe(true); - expect(result.written).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Update path: re-install from same source succeeds - // ------------------------------------------------------------------------- - - it('allows re-install from same source (update path)', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style', { body: 'Updated rule body' }); - - const existingEntry: LockEntry = { - type: 'rule', - name: 'code-style', - source: 'test/repo', // same source - format: 'canonical', - agents: ['cursor'], - hash: 'old-hash', - installedAt: new Date().toISOString(), - outputs: [join(projectDir, '.cursor', 'rules', 'code-style.mdc')], - }; - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [existingEntry], - targets: ['cursor'] as const, - }); - - expect(result.success).toBe(true); - expect(result.collisions).toHaveLength(0); - expect(result.written).toHaveLength(1); - - const content = readFileSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'), 'utf-8'); - expect(content).toContain('Updated rule body'); - }); - - // ------------------------------------------------------------------------- - // Rollback: write failure cleans up partial files - // ------------------------------------------------------------------------- - - it('rolls back written files when a subsequent write fails', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style'); - - // Block one agent directory by creating a file where a directory is expected - // This causes mkdir to fail when the pipeline tries to create subdirectories - writeFileSync(join(projectDir, '.opencode'), 'blocker-file'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - expect(result.written).toHaveLength(0); - - // Files that were written before the failure should be rolled back - // Cursor is alphabetically before opencode in the output order, so - // it may have been written first — verify cleanup happened - const cursorFile = join(projectDir, '.cursor', 'rules', 'code-style.mdc'); - const copilotFile = join(projectDir, '.github', 'instructions', 'code-style.instructions.md'); - const claudeFile = join(projectDir, '.claude', 'rules', 'code-style.md'); - - // After rollback, none of the successfully-written files should remain - // (the pipeline deletes them on failure) - const remainingFiles = [cursorFile, copilotFile, claudeFile].filter((f) => existsSync(f)); - expect(remainingFiles).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // No items: empty discovery produces no writes - // ------------------------------------------------------------------------- - - it('handles empty source repo gracefully', async () => { - // sourceDir has no rules or skills - const { items } = await discover(sourceDir); - expect(items).toHaveLength(0); - - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(true); - expect(result.writes).toHaveLength(0); - expect(result.written).toHaveLength(0); - }); - - // ------------------------------------------------------------------------- - // Invalid rule: discovery warns, pipeline skips - // ------------------------------------------------------------------------- - - it('discovery warns on invalid rules; pipeline skips them', async () => { - // Valid rule - const validDir = join(sourceDir, 'rules', 'good-rule'); - mkdirSync(validDir, { recursive: true }); - writeRulesMd(validDir, 'good-rule'); - - // Invalid rule (missing required fields) - const invalidDir = join(sourceDir, 'rules', 'bad-rule'); - mkdirSync(invalidDir, { recursive: true }); - writeFileSync(join(invalidDir, 'RULES.md'), '---\n---\nNo frontmatter'); - - const { items, warnings } = await discover(sourceDir); - expect(items.filter((i) => i.type === 'rule')).toHaveLength(1); - expect(warnings.some((w) => w.type === 'parse-error')).toBe(true); - - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - - expect(result.success).toBe(true); - expect(result.written).toHaveLength(1); - expect(existsSync(join(projectDir, '.cursor', 'rules', 'good-rule.mdc'))).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Collision: canonical-native in same batch - // ------------------------------------------------------------------------- - - it('detects canonical-native collision when both target same path', async () => { - // Canonical rule named "lint" - const canonDir = join(sourceDir, 'rules', 'lint'); - mkdirSync(canonDir, { recursive: true }); - writeRulesMd(canonDir, 'lint'); - - // Native cursor rule also named "lint" (will produce same output path) - const nativeDir = join(sourceDir, '.cursor', 'rules'); - mkdirSync(nativeDir, { recursive: true }); - writeFileSync(join(nativeDir, 'lint.mdc'), 'Native lint content'); - - const { items } = await discover(sourceDir); - const canonicalRules = items.filter((i) => i.format === 'canonical' && i.type === 'rule'); - const nativeRules = items.filter((i) => i.format === 'native:cursor'); - expect(canonicalRules).toHaveLength(1); - expect(nativeRules).toHaveLength(1); - - // Install both to cursor — should detect canonical-native collision - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - - // One of them should collide - expect(result.collisions.some((c) => c.kind === 'canonical-native')).toBe(true); - }); - - // ------------------------------------------------------------------------- - // Idempotent re-install: overwrite existing transpiled output - // ------------------------------------------------------------------------- - - it('re-install updates existing transpiled files from same source', async () => { - const ruleDir = join(sourceDir, 'rules', 'code-style'); - mkdirSync(ruleDir, { recursive: true }); - writeRulesMd(ruleDir, 'code-style', { body: 'Original body' }); - - // First install - const { items: items1 } = await discover(sourceDir); - const result1 = await executeInstallPipeline(items1, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - targets: ['cursor'] as const, - }); - expect(result1.success).toBe(true); - - const outputPath = join(projectDir, '.cursor', 'rules', 'code-style.mdc'); - const firstContent = readFileSync(outputPath, 'utf-8'); - expect(firstContent).toContain('Original body'); - - // Build lock entry from first install - const lockEntry: LockEntry = { - type: 'rule', - name: 'code-style', - source: 'test/repo', - format: 'canonical', - agents: ['cursor'], - hash: 'first-hash', - installedAt: new Date().toISOString(), - outputs: [outputPath], - }; - - // Update source content - writeRulesMd(ruleDir, 'code-style', { body: 'Updated body' }); - - // Second install (update) - const { items: items2 } = await discover(sourceDir); - const result2 = await executeInstallPipeline(items2, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [lockEntry], - targets: ['cursor'] as const, - }); - expect(result2.success).toBe(true); - expect(result2.collisions).toHaveLength(0); - - const updatedContent = readFileSync(outputPath, 'utf-8'); - expect(updatedContent).toContain('Updated body'); - expect(updatedContent).not.toContain('Original body'); - }); - - // ------------------------------------------------------------------------- - // Multi-rule collision: one rule collides, entire batch blocked - // ------------------------------------------------------------------------- - - it('blocks entire batch when one rule has a collision', async () => { - const rule1Dir = join(sourceDir, 'rules', 'rule-a'); - const rule2Dir = join(sourceDir, 'rules', 'rule-b'); - mkdirSync(rule1Dir, { recursive: true }); - mkdirSync(rule2Dir, { recursive: true }); - writeRulesMd(rule1Dir, 'rule-a'); - writeRulesMd(rule2Dir, 'rule-b'); - - // Pre-existing file for rule-a only - const conflictDir = join(projectDir, '.cursor', 'rules'); - mkdirSync(conflictDir, { recursive: true }); - writeFileSync(join(conflictDir, 'rule-a.mdc'), 'user content'); - - const { items } = await discover(sourceDir); - const result = await executeInstallPipeline(items, { - projectRoot: projectDir, - source: 'test/repo', - lockEntries: [], - }); - - expect(result.success).toBe(false); - expect(result.written).toHaveLength(0); - - // Neither rule should have been written - expect(existsSync(join(projectDir, '.cursor', 'rules', 'rule-b.mdc'))).toBe(false); - expect(existsSync(join(projectDir, '.opencode', 'rules', 'rule-b.md'))).toBe(false); - }); -}); diff --git a/tests/lock-integration.test.ts b/tests/lock-integration.test.ts deleted file mode 100644 index 828a648..0000000 --- a/tests/lock-integration.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { writeFile, readFile } from 'fs/promises'; -import { join } from 'path'; -import { existsSync } from 'fs'; -import { addRules } from '../src/rule-add.ts'; -import { checkRuleUpdates, updateRules } from '../src/rule-check.ts'; -import { computeContentHash } from '../src/dotai-lock.ts'; -import { - createTempProjectDir, - makeSimpleRulesContent, - createTestSourceRepo, - readLockFileFromDisk, -} from './e2e-utils.ts'; -import { mkdir } from 'fs/promises'; - -// --------------------------------------------------------------------------- -// addRules — integration with lock file -// --------------------------------------------------------------------------- - -describe('addRules → lock file integration', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('creates .dotai-lock.json with installed rule entry', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Code style rules', body: 'Use const over let' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - - // Verify lock file was created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - - const lock = await readLockFileFromDisk(projectDir); - expect(lock.version).toBe(1); - expect(lock.items).toHaveLength(1); - - const entry = lock.items[0]!; - expect(entry.type).toBe('rule'); - expect(entry.name).toBe('code-style'); - expect(entry.source).toBe('test/repo'); - expect(entry.format).toBe('canonical'); - expect(entry.agents).toHaveLength(4); - expect(entry.hash).toBeTruthy(); - expect(entry.installedAt).toBeTruthy(); - expect(entry.outputs).toHaveLength(4); - }); - - it('writes correct content hash in lock entry', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Code style rules', body: 'Use const' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock = await readLockFileFromDisk(projectDir); - const entry = lock.items[0]!; - - // Hash should be the SHA-256 of the raw content (including frontmatter) - const rawContent = makeSimpleRulesContent('code-style', 'Code style rules', 'Use const'); - const expectedHash = computeContentHash(rawContent); - expect(entry.hash).toBe(expectedHash); - }); - - it('records output paths matching actual written files', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock = await readLockFileFromDisk(projectDir); - const entry = lock.items[0]!; - - // Every output path should exist on disk - for (const outputPath of entry.outputs) { - expect(existsSync(outputPath)).toBe(true); - } - - // Output paths should match written paths - expect(new Set(entry.outputs)).toEqual(new Set(result.writtenPaths)); - }); - - it('records correct agents in lock entry based on --targets filter', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style rules', body: 'Use const' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - targets: ['cursor', 'opencode'], - }); - - const lock = await readLockFileFromDisk(projectDir); - const entry = lock.items[0]!; - - expect(entry.agents).toHaveLength(2); - expect(entry.agents).toContain('cursor'); - expect(entry.agents).toContain('opencode'); - expect(entry.outputs).toHaveLength(2); - }); - - it('installs multiple rules with separate lock entries', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - { name: 'security', description: 'Security', body: 'Validate input' }, - ]); - - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(2); - - const names = lock.items.map((e) => e.name).sort(); - expect(names).toEqual(['code-style', 'security']); - - // Each entry should have its own outputs - for (const entry of lock.items) { - expect(entry.outputs.length).toBeGreaterThan(0); - expect(entry.source).toBe('test/repo'); - } - }); - - it('skips lock file write in dry-run mode', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - dryRun: true, - }); - - expect(result.success).toBe(true); - expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(0); - - // Lock file should NOT exist (dry-run) - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - }); - - it('does not write transpiled files in dry-run mode', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - dryRun: true, - }); - - // No agent directories should be created - expect(existsSync(join(projectDir, '.cursor'))).toBe(false); - expect(existsSync(join(projectDir, '.claude'))).toBe(false); - expect(existsSync(join(projectDir, '.github'))).toBe(false); - }); - - it('blocks collision from pre-existing user file', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - // Create a pre-existing user file at one of the target paths - const cursorRulesDir = join(projectDir, '.cursor', 'rules'); - await mkdir(cursorRulesDir, { recursive: true }); - await writeFile(join(cursorRulesDir, 'code-style.mdc'), 'user content'); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('collision'); - - // Lock file should NOT be created - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - - // User file should be untouched - const userContent = await readFile(join(cursorRulesDir, 'code-style.mdc'), 'utf-8'); - expect(userContent).toBe('user content'); - }); - - it('force overrides collision and writes lock entry', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - // Create a pre-existing user file - const cursorRulesDir = join(projectDir, '.cursor', 'rules'); - await mkdir(cursorRulesDir, { recursive: true }); - await writeFile(join(cursorRulesDir, 'code-style.mdc'), 'user content'); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - force: true, - }); - - expect(result.success).toBe(true); - - // Lock file should exist with the forced entry - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(1); - expect(lock.items[0]!.name).toBe('code-style'); - - // Cursor file should be overwritten - const cursorContent = await readFile(join(cursorRulesDir, 'code-style.mdc'), 'utf-8'); - expect(cursorContent).not.toBe('user content'); - expect(cursorContent).toContain('Use const'); - }); - - it('allows re-install from same source (update path)', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Original body' }, - ]); - - // First install - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - // Modify source - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Updated body') - ); - - // Re-install from same source - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - expect(result.success).toBe(true); - - // Lock entry should have updated hash - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(1); - - const updatedContent = makeSimpleRulesContent('code-style', 'Style', 'Updated body'); - expect(lock.items[0]!.hash).toBe(computeContentHash(updatedContent)); - - // Transpiled content should be updated - const cursorContent = await readFile( - join(projectDir, '.cursor', 'rules', 'code-style.mdc'), - 'utf-8' - ); - expect(cursorContent).toContain('Updated body'); - }); - - it('blocks same-name from different source', async () => { - const sourceRepo1 = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'v1' }, - ]); - - // First install from source 1 - await addRules({ - source: 'owner/repo-a', - sourcePath: sourceRepo1, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - // Create a second source repo with same rule name - const sourceRepo2Dir = join(tempDir, 'source-repo-2'); - await mkdir(sourceRepo2Dir, { recursive: true }); - await writeFile( - join(sourceRepo2Dir, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'v2') - ); - - // Second install from different source — should be blocked - const result = await addRules({ - source: 'owner/repo-b', - sourcePath: sourceRepo2Dir, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('collision'); - - // Lock should still have original source - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items[0]!.source).toBe('owner/repo-a'); - }); - - it('returns error when no rules found in source', async () => { - // Empty source repo - const emptyRepo = join(tempDir, 'empty-repo'); - await mkdir(emptyRepo, { recursive: true }); - - const result = await addRules({ - source: 'test/repo', - sourcePath: emptyRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('No rules found'); - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(false); - }); - - it('returns error when requested rule name not found', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - const result = await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['nonexistent'], - }); - - expect(result.success).toBe(false); - expect(result.error).toContain('No matching rules'); - expect(result.error).toContain('code-style'); - }); - - it('preserves installedAt on re-install (update)', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Original' }, - ]); - - // First install - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock1 = await readLockFileFromDisk(projectDir); - const originalInstalledAt = lock1.items[0]!.installedAt; - - // Small delay - await new Promise((r) => setTimeout(r, 50)); - - // Modify source and re-install - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Updated') - ); - - await addRules({ - source: 'test/repo', - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock2 = await readLockFileFromDisk(projectDir); - // addRules creates a new entry with new installedAt — upsertLockEntry preserves - // the original installedAt when the entry already exists - // Note: addRules() sets installedAt = new Date().toISOString(), but upsertLockEntry() - // preserves the original installedAt if the entry existed. The behavior depends on - // whether the entry was already in the lock when upsertLockEntry is called. - // Since we read the lock before install, then upsert, the original is preserved. - expect(lock2.items[0]!.installedAt).toBe(originalInstalledAt); - }); -}); - -// --------------------------------------------------------------------------- -// addRules → checkRuleUpdates → updateRules (lock lifecycle) -// --------------------------------------------------------------------------- - -describe('addRules → check → update lock lifecycle', () => { - let tempDir: string; - let projectDir: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ tempDir, projectDir, cleanup } = await createTempProjectDir()); - }); - - afterEach(async () => { - await cleanup(); - }); - - it('check reports no updates immediately after addRules', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const result = await checkRuleUpdates(projectDir); - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('check detects update after source content changes', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Version 1' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - // Modify source content - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Version 2') - ); - - const result = await checkRuleUpdates(projectDir); - expect(result.totalChecked).toBe(1); - expect(result.updates).toHaveLength(1); - expect(result.updates[0]!.entry.name).toBe('code-style'); - }); - - it('updateRules re-installs changed rules and updates lock hash', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Version 1' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lockBefore = await readLockFileFromDisk(projectDir); - const hashBefore = lockBefore.items[0]!.hash; - - // Modify source - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Version 2') - ); - - const result = await updateRules(projectDir); - expect(result.totalChecked).toBe(1); - expect(result.successCount).toBe(1); - expect(result.failCount).toBe(0); - - // Lock hash should be updated - const lockAfter = await readLockFileFromDisk(projectDir); - expect(lockAfter.items).toHaveLength(1); - expect(lockAfter.items[0]!.hash).not.toBe(hashBefore); - - // Transpiled files should have new content - const cursorContent = await readFile( - join(projectDir, '.cursor', 'rules', 'code-style.mdc'), - 'utf-8' - ); - expect(cursorContent).toContain('Version 2'); - }); - - it('updateRules preserves installedAt from original install', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Version 1' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lockBefore = await readLockFileFromDisk(projectDir); - const originalInstalledAt = lockBefore.items[0]!.installedAt; - - // Modify source - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('code-style', 'Style', 'Version 2') - ); - - await updateRules(projectDir); - - const lockAfter = await readLockFileFromDisk(projectDir); - expect(lockAfter.items[0]!.installedAt).toBe(originalInstalledAt); - }); - - it('check reports error when rule removed from source after install', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - // Remove the rule and replace with a different one - const { unlinkSync } = await import('fs'); - unlinkSync(join(sourceRepo, 'RULES.md')); - await writeFile( - join(sourceRepo, 'RULES.md'), - makeSimpleRulesContent('other-rule', 'Other', 'Other body') - ); - - const result = await checkRuleUpdates(projectDir); - expect(result.totalChecked).toBe(1); - expect(result.errors).toHaveLength(1); - expect(result.errors[0]!.error).toContain('no longer found'); - }); - - it('handles multiple rules with mixed update status', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'rule-a', description: 'Rule A', body: 'Body A' }, - { name: 'rule-b', description: 'Rule B', body: 'Body B' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - // Only modify rule-b - const ruleBDir = join(sourceRepo, 'rules', 'rule-b'); - await writeFile( - join(ruleBDir, 'RULES.md'), - makeSimpleRulesContent('rule-b', 'Rule B', 'Body B Updated') - ); - - // Check — only rule-b should have an update - const checkResult = await checkRuleUpdates(projectDir); - expect(checkResult.totalChecked).toBe(2); - expect(checkResult.updates).toHaveLength(1); - expect(checkResult.updates[0]!.entry.name).toBe('rule-b'); - - // Update — only rule-b should be updated - const updateResult = await updateRules(projectDir); - expect(updateResult.successCount).toBe(1); - - // Both rules should still be in lock - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items).toHaveLength(2); - - // Rule B should have new content in transpiled file - const cursorB = await readFile(join(projectDir, '.cursor', 'rules', 'rule-b.mdc'), 'utf-8'); - expect(cursorB).toContain('Body B Updated'); - }); - - it('no lock file write when updateRules finds no changes', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'code-style', description: 'Style', body: 'Use const' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const { statSync } = await import('fs'); - const mtimeBefore = statSync(join(projectDir, '.dotai-lock.json')).mtimeMs; - - // Small delay to detect mtime changes - await new Promise((r) => setTimeout(r, 50)); - - const result = await updateRules(projectDir); - expect(result.successCount).toBe(0); - - const mtimeAfter = statSync(join(projectDir, '.dotai-lock.json')).mtimeMs; - expect(mtimeAfter).toBe(mtimeBefore); - }); - - it('check returns empty when no lock file exists', async () => { - const result = await checkRuleUpdates(projectDir); - expect(result.totalChecked).toBe(0); - expect(result.updates).toHaveLength(0); - expect(result.errors).toHaveLength(0); - }); - - it('update returns empty when no lock file exists', async () => { - const result = await updateRules(projectDir); - expect(result.totalChecked).toBe(0); - expect(result.successCount).toBe(0); - expect(result.failCount).toBe(0); - }); - - it('lock file is sorted deterministically after multiple operations', async () => { - const sourceRepo = await createTestSourceRepo(tempDir, [ - { name: 'zebra-rule', description: 'Zebra', body: 'Zebra body' }, - { name: 'alpha-rule', description: 'Alpha', body: 'Alpha body' }, - ]); - - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - - const lock = await readLockFileFromDisk(projectDir); - expect(lock.items[0]!.name).toBe('alpha-rule'); - expect(lock.items[1]!.name).toBe('zebra-rule'); - - // Update source — zebra-rule changes - const zebraDir = join(sourceRepo, 'rules', 'zebra-rule'); - await writeFile( - join(zebraDir, 'RULES.md'), - makeSimpleRulesContent('zebra-rule', 'Zebra', 'Zebra body updated') - ); - - await updateRules(projectDir); - - // After update, sorting should still be deterministic - const lockAfter = await readLockFileFromDisk(projectDir); - expect(lockAfter.items[0]!.name).toBe('alpha-rule'); - expect(lockAfter.items[1]!.name).toBe('zebra-rule'); - }); -}); diff --git a/tests/restore.test.ts b/tests/restore.test.ts index 4929236..3c30ead 100644 --- a/tests/restore.test.ts +++ b/tests/restore.test.ts @@ -4,8 +4,9 @@ import { join } from 'path'; import { tmpdir } from 'os'; import { existsSync } from 'fs'; import { runCli } from '../src/test-utils.ts'; -import { addRules, addPrompts, addAgents } from '../src/rule-add.ts'; +import { addPrompts, addAgents } from '../src/context-add.ts'; import { writeDotaiLock, createEmptyLock, readDotaiLock } from '../src/dotai-lock.ts'; +import { createTestSourceRepo } from './e2e-utils.ts'; // --------------------------------------------------------------------------- // Helpers @@ -15,118 +16,12 @@ async function createTempDir(): Promise { return mkdtemp(join(tmpdir(), 'restore-test-')); } -/** Create a canonical RULES.md with standard frontmatter. */ -function makeRulesContent(name: string, description: string, body: string): string { - return `--- -name: ${name} -description: ${description} -globs: - - "*.ts" -activation: always ---- - -${body} -`; -} - -/** Create a canonical PROMPT.md with standard frontmatter. */ -function makePromptContent(name: string, description: string, body: string): string { - return `--- -name: ${name} -description: ${description} ---- - -${body} -`; -} - -/** Create a canonical AGENT.md with standard frontmatter. */ -function makeAgentContent(name: string, description: string, body: string): string { - return `--- -name: ${name} -description: ${description} ---- - -${body} -`; -} - -/** Create a source repo with rules, prompts, and/or agents. */ -async function createSourceRepo( - baseDir: string, - rules: Array<{ name: string; description: string; body: string }>, - prompts: Array<{ name: string; description: string; body: string }> = [], - agents: Array<{ name: string; description: string; body: string }> = [] -): Promise { - const repoDir = join(baseDir, 'source-repo'); - await mkdir(repoDir, { recursive: true }); - - // Write rules - if (rules.length === 1 && prompts.length === 0 && agents.length === 0) { - await writeFile( - join(repoDir, 'RULES.md'), - makeRulesContent(rules[0]!.name, rules[0]!.description, rules[0]!.body) - ); - } else if (rules.length > 0) { - const rulesDir = join(repoDir, 'rules'); - await mkdir(rulesDir, { recursive: true }); - for (const rule of rules) { - const ruleDir = join(rulesDir, rule.name); - await mkdir(ruleDir, { recursive: true }); - await writeFile( - join(ruleDir, 'RULES.md'), - makeRulesContent(rule.name, rule.description, rule.body) - ); - } - } - - // Write prompts - if (prompts.length === 1 && rules.length === 0 && agents.length === 0) { - await writeFile( - join(repoDir, 'PROMPT.md'), - makePromptContent(prompts[0]!.name, prompts[0]!.description, prompts[0]!.body) - ); - } else if (prompts.length > 0) { - const promptsDir = join(repoDir, 'prompts'); - await mkdir(promptsDir, { recursive: true }); - for (const prompt of prompts) { - const promptDir = join(promptsDir, prompt.name); - await mkdir(promptDir, { recursive: true }); - await writeFile( - join(promptDir, 'PROMPT.md'), - makePromptContent(prompt.name, prompt.description, prompt.body) - ); - } - } - - // Write agents - if (agents.length === 1 && rules.length === 0 && prompts.length === 0) { - await writeFile( - join(repoDir, 'AGENT.md'), - makeAgentContent(agents[0]!.name, agents[0]!.description, agents[0]!.body) - ); - } else if (agents.length > 0) { - const agentsDir = join(repoDir, 'agents'); - await mkdir(agentsDir, { recursive: true }); - for (const agent of agents) { - const agentDir = join(agentsDir, agent.name); - await mkdir(agentDir, { recursive: true }); - await writeFile( - join(agentDir, 'AGENT.md'), - makeAgentContent(agent.name, agent.description, agent.body) - ); - } - } - - return repoDir; -} - // --------------------------------------------------------------------------- -// CLI install (no args) → restoreRulesAndPrompts integration tests +// CLI restore — restore prompts and agents from .dotai-lock.json // After the routing change, restore is invoked via 'dotai restore' (not 'dotai install') // --------------------------------------------------------------------------- -describe('dotai restore — restore rules and prompts from .dotai-lock.json', () => { +describe('dotai restore — restore prompts and agents from .dotai-lock.json', () => { let tempDir: string; let projectDir: string; @@ -140,49 +35,12 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () await rm(tempDir, { recursive: true, force: true }); }); - it('restores rules from .dotai-lock.json via restore command', async () => { - // Step 1: Create source repo with a rule - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }, - ]); - - // Step 2: Install the rule first to populate .dotai-lock.json - const addResult = await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); - expect(addResult.success).toBe(true); - - // Verify lock file exists - expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - - // Step 3: Delete the transpiled files (simulate fresh checkout) - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); - await rm(join(projectDir, '.claude'), { recursive: true, force: true }); - await rm(join(projectDir, '.github'), { recursive: true, force: true }); - await rm(join(projectDir, '.windsurf'), { recursive: true, force: true }); - await rm(join(projectDir, '.clinerules'), { recursive: true, force: true }); - - // Step 4: Run dotai restore — should restore from lock - const result = runCli(['restore'], projectDir); - - // Should show restore behavior - expect(result.stdout).toContain('Restoring'); - expect(result.stdout).toContain('.dotai-lock.json'); - - // Transpiled files should be recreated - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(true); - }); - it('restores prompts from .dotai-lock.json via restore command', async () => { // Step 1: Create source repo with a prompt - const sourceRepo = await createSourceRepo( + const sourceRepo = await createTestSourceRepo( tempDir, - [], - [{ name: 'review-code', description: 'Review code for issues', body: 'Review the code.' }] + [{ name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }], + 'prompt' ); // Step 2: Install the prompt first to populate .dotai-lock.json @@ -197,57 +55,67 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () // Verify lock file exists expect(existsSync(join(projectDir, '.dotai-lock.json'))).toBe(true); - // Step 3: Delete transpiled prompt files + // Step 3: Delete the transpiled files (simulate fresh checkout) await rm(join(projectDir, '.github'), { recursive: true, force: true }); await rm(join(projectDir, '.claude'), { recursive: true, force: true }); + await rm(join(projectDir, '.opencode'), { recursive: true, force: true }); - // Step 4: Run dotai restore — should restore prompts from lock + // Step 4: Run dotai restore — should restore from lock const result = runCli(['restore'], projectDir); + // Should show restore behavior expect(result.stdout).toContain('Restoring'); expect(result.stdout).toContain('.dotai-lock.json'); + + // Transpiled files should be recreated + expect(existsSync(join(projectDir, '.github', 'prompts', 'code-style.prompt.md'))).toBe(true); + expect(existsSync(join(projectDir, '.claude', 'commands', 'code-style.md'))).toBe(true); }); - it('restores both rules and prompts from same source', async () => { - // Create source repo with both rules and prompts - const sourceRepo = await createSourceRepo( + it('restores both prompts and agents from same source', async () => { + // Create separate source repos for prompts and agents + const promptRepo = await createTestSourceRepo( tempDir, [{ name: 'code-style', description: 'Enforce code style', body: 'Use const' }], - [{ name: 'review-code', description: 'Review code', body: 'Review the code.' }] + 'prompt' + ); + const agentRepo = await createTestSourceRepo( + tempDir, + [{ name: 'review-code', description: 'Review code', body: 'Review the code.' }], + 'agent' ); // Install both - await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - }); await addPrompts({ - source: sourceRepo, - sourcePath: sourceRepo, + source: promptRepo, + sourcePath: promptRepo, projectRoot: projectDir, promptNames: ['*'], }); + await addAgents({ + source: agentRepo, + sourcePath: agentRepo, + projectRoot: projectDir, + agentNames: ['*'], + force: true, + }); // Verify lock file has both const { lock } = await readDotaiLock(projectDir); expect(lock.items).toHaveLength(2); // Delete transpiled files - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); - await rm(join(projectDir, '.claude'), { recursive: true, force: true }); await rm(join(projectDir, '.github'), { recursive: true, force: true }); - await rm(join(projectDir, '.windsurf'), { recursive: true, force: true }); - await rm(join(projectDir, '.clinerules'), { recursive: true, force: true }); + await rm(join(projectDir, '.claude'), { recursive: true, force: true }); + await rm(join(projectDir, '.opencode'), { recursive: true, force: true }); // Restore const result = runCli(['restore'], projectDir); expect(result.stdout).toContain('Restoring'); - // Should mention both rules and prompts - expect(result.stdout).toContain('rule'); + // Should mention both prompts and agents expect(result.stdout).toContain('prompt'); + expect(result.stdout).toContain('agent'); }); it('shows nothing-found message when both lock files are empty', async () => { @@ -271,11 +139,11 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () const nonExistentSource = join(tempDir, 'does-not-exist'); const lock = createEmptyLock(); lock.items.push({ - type: 'rule', - name: 'phantom-rule', + type: 'prompt', + name: 'phantom-prompt', source: nonExistentSource, format: 'canonical', - agents: ['cursor', 'claude-code', 'github-copilot', 'windsurf', 'cline'], + agents: ['claude-code', 'github-copilot', 'opencode'], hash: 'abc123', installedAt: '2026-03-01T00:00:00.000Z', outputs: [], @@ -298,25 +166,25 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () expect(result.exitCode).toBe(1); }); - it('experimental_install restores rules from .dotai-lock.json', async () => { - // Create source repo and install a rule - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Code style', body: 'Use const' }, - ]); + it('experimental_install restores prompts from .dotai-lock.json', async () => { + // Create source repo and install a prompt + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Code style', body: 'Use const' }], + 'prompt' + ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], }); // Delete transpiled files - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); - await rm(join(projectDir, '.claude'), { recursive: true, force: true }); await rm(join(projectDir, '.github'), { recursive: true, force: true }); - await rm(join(projectDir, '.windsurf'), { recursive: true, force: true }); - await rm(join(projectDir, '.clinerules'), { recursive: true, force: true }); + await rm(join(projectDir, '.claude'), { recursive: true, force: true }); + await rm(join(projectDir, '.opencode'), { recursive: true, force: true }); // Run with experimental_install const result = runCli(['experimental_install'], projectDir); @@ -325,23 +193,25 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () expect(result.stdout).toContain('.dotai-lock.json'); }); - it('restores rules with force (overwrites existing files)', async () => { - // Create source repo and install a rule - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Code style', body: 'Use const' }, - ]); + it('restores prompts with force (overwrites existing files)', async () => { + // Create source repo and install a prompt + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Code style', body: 'Use const' }], + 'prompt' + ); - await addRules({ + await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], + promptNames: ['*'], }); // Modify transpiled file to simulate user edit - const cursorFile = join(projectDir, '.cursor', 'rules', 'code-style.mdc'); - expect(existsSync(cursorFile)).toBe(true); - await writeFile(cursorFile, 'user modified content'); + const copilotFile = join(projectDir, '.github', 'prompts', 'code-style.prompt.md'); + expect(existsSync(copilotFile)).toBe(true); + await writeFile(copilotFile, 'user modified content'); // Restore should overwrite (restore uses force: true) const result = runCli(['restore'], projectDir); @@ -349,18 +219,17 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () expect(result.stdout).toContain('Restoring'); // File should be restored to original content - const restoredContent = await readFile(cursorFile, 'utf-8'); + const restoredContent = await readFile(copilotFile, 'utf-8'); expect(restoredContent).toContain('Use const'); expect(restoredContent).not.toBe('user modified content'); }); it('restores agents from .dotai-lock.json via restore command', async () => { // Step 1: Create source repo with an agent - const sourceRepo = await createSourceRepo( + const sourceRepo = await createTestSourceRepo( tempDir, - [], - [], - [{ name: 'test-helper', description: 'A test helper agent', body: 'Help with testing.' }] + [{ name: 'test-helper', description: 'A test helper agent', body: 'Help with testing.' }], + 'agent' ); // Step 2: Install the agent first to populate .dotai-lock.json @@ -379,11 +248,9 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () expect(agentEntries.length).toBe(1); // Step 3: Delete transpiled files (simulate fresh checkout) - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); - await rm(join(projectDir, '.claude'), { recursive: true, force: true }); await rm(join(projectDir, '.github'), { recursive: true, force: true }); - await rm(join(projectDir, '.windsurf'), { recursive: true, force: true }); - await rm(join(projectDir, '.clinerules'), { recursive: true, force: true }); + await rm(join(projectDir, '.claude'), { recursive: true, force: true }); + await rm(join(projectDir, '.opencode'), { recursive: true, force: true }); // Step 4: Run dotai restore — should restore agents from lock const result = runCli(['restore'], projectDir); @@ -394,102 +261,43 @@ describe('dotai restore — restore rules and prompts from .dotai-lock.json', () expect(result.stdout).toContain('agent'); }); - it('restores append-mode rules with markers instead of per-file outputs', async () => { - // Step 1: Create source repo with a rule - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }, - ]); - - // Step 2: Install the rule with --append to populate .dotai-lock.json - const addResult = await addRules({ - source: sourceRepo, - sourcePath: sourceRepo, - projectRoot: projectDir, - ruleNames: ['*'], - append: true, - }); - expect(addResult.success).toBe(true); - - // Verify lock entry has append: true - const { lock } = await readDotaiLock(projectDir); - const ruleEntry = lock.items.find((e) => e.type === 'rule'); - expect(ruleEntry?.append).toBe(true); - - // Step 3: Delete all transpiled files (simulate fresh checkout) - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); - await rm(join(projectDir, '.windsurf'), { recursive: true, force: true }); - await rm(join(projectDir, '.clinerules'), { recursive: true, force: true }); - // Append-mode outputs - const agentsMdExists = existsSync(join(projectDir, 'AGENTS.md')); - if (agentsMdExists) await rm(join(projectDir, 'AGENTS.md')); - const claudeMdExists = existsSync(join(projectDir, 'CLAUDE.md')); - if (claudeMdExists) await rm(join(projectDir, 'CLAUDE.md')); - - // Step 4: Run dotai restore — should restore with append mode - const result = runCli(['restore'], projectDir); - - expect(result.stdout).toContain('Restoring'); - - // Append-mode outputs should be recreated with markers - const agentsMd = await readFile(join(projectDir, 'AGENTS.md'), 'utf-8'); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain(''); - expect(agentsMd).toContain('Use const over let'); - - const claudeMd = await readFile(join(projectDir, 'CLAUDE.md'), 'utf-8'); - expect(claudeMd).toContain(''); - expect(claudeMd).toContain(''); - expect(claudeMd).toContain('Use const over let'); - - // Per-file outputs should NOT exist for copilot/claude (append mode) - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(false); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(false); - - // Per-file outputs should still exist for cursor, windsurf, cline - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); - }); - - it('restores rules only to agents listed in lock entry', async () => { - // Step 1: Create source repo with a rule - const sourceRepo = await createSourceRepo(tempDir, [ - { name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }, - ]); + it('restores prompts only to agents listed in lock entry', async () => { + // Step 1: Create source repo with a prompt + const sourceRepo = await createTestSourceRepo( + tempDir, + [{ name: 'code-style', description: 'Enforce code style', body: 'Use const over let' }], + 'prompt' + ); - // Step 2: Install the rule for cursor only - const addResult = await addRules({ + // Step 2: Install the prompt for github-copilot only + const addResult = await addPrompts({ source: sourceRepo, sourcePath: sourceRepo, projectRoot: projectDir, - ruleNames: ['*'], - targets: ['cursor'], + promptNames: ['*'], + targets: ['github-copilot'], }); expect(addResult.success).toBe(true); - // Verify lock entry has agents: ['cursor'] only + // Verify lock entry has agents: ['github-copilot'] only const { lock } = await readDotaiLock(projectDir); - const ruleEntry = lock.items.find((e) => e.type === 'rule'); - expect(ruleEntry?.agents).toEqual(['cursor']); + const promptEntry = lock.items.find((e) => e.type === 'prompt'); + expect(promptEntry?.agents).toEqual(['github-copilot']); // Step 3: Delete transpiled files (simulate fresh checkout) - await rm(join(projectDir, '.cursor'), { recursive: true, force: true }); + await rm(join(projectDir, '.github'), { recursive: true, force: true }); - // Step 4: Run dotai restore — should restore only to cursor + // Step 4: Run dotai restore — should restore only to github-copilot const result = runCli(['restore'], projectDir); expect(result.stdout).toContain('Restoring'); - // Cursor files should be recreated - expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); + // Copilot files should be recreated + expect(existsSync(join(projectDir, '.github', 'prompts', 'code-style.prompt.md'))).toBe(true); // Other agents should NOT have files - expect( - existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) - ).toBe(false); - expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(false); - expect(existsSync(join(projectDir, '.windsurf', 'rules', 'code-style.md'))).toBe(false); - expect(existsSync(join(projectDir, '.clinerules', 'code-style.md'))).toBe(false); + expect(existsSync(join(projectDir, '.claude', 'commands', 'code-style.md'))).toBe(false); + expect(existsSync(join(projectDir, '.opencode', 'commands', 'code-style.md'))).toBe(false); }); }); From 780007f22056af0a9140daefe1e813fb7e285951 Mon Sep 17 00:00:00 2001 From: mfaux Date: Fri, 3 Apr 2026 18:51:20 +0200 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20remove=20rules=20=E2=80=94=20CLI=20?= =?UTF-8?q?cleanup,=20docs,=20and=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove rule-related CLI flags, commands, documentation, and examples. Add deprecation errors for --rule, --append, import command, and init rule that direct users to use instructions instead. - Add clear error messages when users pass --rule, --append, or use dotai import / dotai init rule - Remove --append from help text (instructions always use append mode) - Update README, cli-reference, coding-agent-docs, supported-targets to remove all rule references and document instructions as primary - Delete examples/rule/ and examples/rule-with-overrides/ - Create examples/instruction/ with sample INSTRUCTIONS.md - Clean up stale rule references in JSDoc comments and code comments - Add tests for all deprecation error messages Closes #25 --- README.md | 51 ++-- docs/cli-reference.md | 230 +++++++----------- docs/coding-agent-docs.md | 60 ++--- docs/supported-targets.md | 2 +- .../RULES.md => instruction/INSTRUCTIONS.md} | 22 +- examples/rule-with-overrides/RULES.md | 26 -- src/add-options.ts | 2 +- src/add.ts | 2 +- src/append-markers.ts | 6 +- src/cli.test.ts | 51 +++- src/cli.ts | 21 +- src/context-installer.ts | 2 +- src/init.ts | 16 +- src/instruction-pipeline.test.ts | 2 +- src/instruction-transpilers.ts | 2 +- src/override-parser.ts | 2 +- src/restore.ts | 2 +- 17 files changed, 246 insertions(+), 253 deletions(-) rename examples/{rule/RULES.md => instruction/INSTRUCTIONS.md} (50%) delete mode 100644 examples/rule-with-overrides/RULES.md diff --git a/README.md b/README.md index 1def4c9..90c758b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Share AI agent context across tools and teams. -dotai takes canonical context files — skills, rules, prompts, agent +dotai takes canonical context files — skills, prompts, agent definitions, and instructions — and installs them into the config directories of supported AI coding agents. Write once, distribute everywhere. Your team gets consistent AI behavior across Copilot, Claude Code, Cursor, and more. @@ -25,10 +25,10 @@ npx dotai add owner/repo --dry-run # preview without writing files ## Why dotai? AI coding agents each store context in different places with different formats. -Keeping rules, prompts, and skills in sync across agents is manual and +Keeping instructions, prompts, and skills in sync across agents is manual and error-prone. -dotai solves this with **canonical authoring**: write a single `RULES.md`, +dotai solves this with **canonical authoring**: write a single `PROMPT.md`, `AGENT.md`, or `INSTRUCTIONS.md` and dotai transpiles it into every target agent's native format automatically. @@ -53,11 +53,11 @@ npx dotai add owner/repo # Limit to specific targets npx dotai add owner/repo --targets copilot,claude,cursor -# Install specific rules or skills -npx dotai add owner/repo --rule code-style --skill db-migrate +# Install specific instructions or skills +npx dotai add owner/repo --instruction my-instructions --skill db-migrate ``` -dotai discovers skills, rules, prompts, agent definitions, and instructions in the source +dotai discovers skills, prompts, agent definitions, and instructions in the source repo and transpiles them for your selected targets. ## What dotai installs @@ -65,7 +65,6 @@ repo and transpiles them for your selected targets. | Layer | Canonical file | Install behavior | | ------------ | ----------------- | ------------------------------ | | Skills | `SKILL.md` | Passthrough (symlink or copy) | -| Rules | `RULES.md` | Transpile per target | | Prompts | `PROMPT.md` | Transpile per supported target | | Agents | `AGENT.md` | Transpile per supported target | | Instructions | `INSTRUCTIONS.md` | Append per target | @@ -86,34 +85,32 @@ npx dotai add ./my-local-context # local path ## Commands -| Command | Description | -| --------------- | ------------------------------------------------------------------- | -| `add ` | Discover, select, transpile, and install context | -| `remove [name]` | Remove installed context | -| `list` | List installed items | -| `find [query]` | Search for skills & preview all context in a repo | -| `import` | Convert native agent rules to canonical `RULES.md` format | -| `check` | Check for available updates | -| `update` | Update installed items to latest versions | -| `init [name]` | Create a context template (skill, rule, prompt, agent, instruction) | -| `restore` | Restore from lock files | +| Command | Description | +| --------------- | ------------------------------------------------------------- | +| `add ` | Discover, select, transpile, and install context | +| `remove [name]` | Remove installed context | +| `list` | List installed items | +| `find [query]` | Search for skills & preview all context in a repo | +| `check` | Check for available updates | +| `update` | Update installed items to latest versions | +| `init [name]` | Create a context template (skill, prompt, agent, instruction) | +| `restore` | Restore from lock files | ## Supported Targets
Transpilation support by agent -| Agent | Skills | Rules | Prompts | Agents | Instructions | -| -------------- | ------ | ----- | ----------------------- | ------------------------- | ------------ | -| GitHub Copilot | ✅ | ✅ | ✅ | ✅ | ✅ | -| Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | -| OpenCode | ✅ | ✅ | ✅ | ✅ | ✅ | -| Cursor | ✅ | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | ✅ | -| Codex | ✅ | — | — | — | — | +| Agent | Skills | Prompts | Agents | Instructions | +| -------------- | ------ | ----------------------- | ------------------------- | ------------ | +| GitHub Copilot | ✅ | ✅ | ✅ | ✅ | +| Claude Code | ✅ | ✅ | ✅ | ✅ | +| OpenCode | ✅ | ✅ | ✅ | ✅ | +| Cursor | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | ✅ | +| Codex | ✅ | — | — | — | - **Cursor prompts:** Cursor reads Copilot's `.github/prompts/` path. Canonical `PROMPT.md` is not transpiled to a Cursor-specific format. - **Cursor agents:** Cursor reads `.github/agents/` from the Copilot path. Canonical `AGENT.md` transpiles to Copilot format, which Cursor picks up. -- **OpenCode rules:** OpenCode rules are plain markdown (no frontmatter). After installing, add the output paths to the `instructions` array in `opencode.json`. - **Instructions:** `INSTRUCTIONS.md` content is appended as marker-delimited sections to project-wide files (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`). Cursor and OpenCode share `AGENTS.md`.
@@ -129,7 +126,7 @@ Skill installs target [5 targets](docs/supported-targets.md). dotai started as a fork of [vercel-labs/skills](https://github.com/vercel-labs/skills) / [skills.sh](https://skills.sh). The inherited skills install pipeline remains first-class. dotai extends it with -transpilation of rules, prompts, agent definitions, and instructions to multiple targets. +transpilation of prompts, agent definitions, and instructions to multiple targets. ## Acknowledgements diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 74442c3..03ea922 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -4,29 +4,26 @@ Full option tables, examples, and authoring format for `dotai`. For a quick over ## add command options -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------- | -| `-g, --global` | Install to user directory instead of project | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | -| `-s, --skill ` | Install specific skills by name (repeatable; supports `'*'`) | -| `-r, --rule ` | Install specific canonical rules by name (repeatable) | -| `-p, --prompt ` | Install specific canonical prompts by name (repeatable) | -| `--custom-agent ` | Install specific canonical custom agents by name (repeatable) | -| `-a, --targets ` | Targets (comma-separated; use `'*'` for all) | -| `--copy` | Copy files instead of symlinking skills | -| `--dry-run` | Preview writes without making changes | -| `--force` | Overwrite conflicting managed/unmanaged outputs | -| `--append` | Append rules to `AGENTS.md`/`CLAUDE.md` instead of per-rule files | -| `--gitignore` | Add transpiled output paths to `.gitignore` (managed section) | -| `--full-depth` | Search all subdirectories even when a root `SKILL.md` exists | -| `-y, --yes` | Skip confirmation prompts | -| `--all` | Shorthand for `--skill '*' --targets '*' -y` | - -> **`--targets`:** A single flag for both skill install targets and transpilation targets. For skills, any of the supported targets (e.g., `--targets cursor,claude-code`). For rules, prompts, and agents, the 4 transpilation targets: copilot, claude, cursor, opencode. When omitted, all detected targets are used for skills and all 4 transpilation targets for rules/prompts/agents. - -> **Zero-flag mode:** Running `dotai add owner/repo` with no type-specific flags discovers all content types (skills, rules, prompts, agents, instructions) and presents an interactive grouped selection. Use `dotai find owner/repo` for a non-interactive preview. - -> **`--append`:** Instead of writing individual rule files (e.g., `.github/instructions/code-style.instructions.md`), rules are appended as marker-delimited sections into `AGENTS.md` (Copilot) and `CLAUDE.md` (Claude Code). Useful for projects that prefer a single monolithic instruction file. Only applies to Copilot and Claude Code targets; other targets always get individual files. +| Option | Description | +| ---------------------------- | ----------------------------------------------------------------------------------- | +| `-g, --global` | Install to user directory instead of project | +| `-t, --type ` | Filter by context type (`skill`, `prompt`, `agent`, `instruction`; comma-separated) | +| `-s, --skill ` | Install specific skills by name (repeatable; supports `'*'`) | +| `-p, --prompt ` | Install specific canonical prompts by name (repeatable) | +| `--custom-agent ` | Install specific canonical custom agents by name (repeatable) | +| `-i, --instruction ` | Install specific canonical instructions by name (repeatable) | +| `-a, --targets ` | Targets (comma-separated; use `'*'` for all) | +| `--copy` | Copy files instead of symlinking skills | +| `--dry-run` | Preview writes without making changes | +| `--force` | Overwrite conflicting managed/unmanaged outputs | +| `--gitignore` | Add transpiled output paths to `.gitignore` (managed section) | +| `--full-depth` | Search all subdirectories even when a root `SKILL.md` exists | +| `-y, --yes` | Skip confirmation prompts | +| `--all` | Shorthand for `--skill '*' --targets '*' -y` | + +> **`--targets`:** A single flag for both skill install targets and transpilation targets. For skills, any of the supported targets (e.g., `--targets cursor,claude-code`). For prompts, agents, and instructions, the transpilation targets: copilot, claude, cursor, opencode. When omitted, all detected targets are used for skills and all transpilation targets for prompts/agents/instructions. + +> **Zero-flag mode:** Running `dotai add owner/repo` with no type-specific flags discovers all content types (skills, prompts, agents, instructions) and presents an interactive grouped selection. Use `dotai find owner/repo` for a non-interactive preview. > **`--gitignore`:** Adds transpiled output file paths to a managed `# dotai:start` / `# dotai:end` section in `.gitignore`. Use when transpiled outputs should not be committed — only the canonical source files and `.dotai-lock.json` are checked in, and teammates run `dotai add` to regenerate outputs locally. @@ -38,13 +35,13 @@ Supported target aliases include values such as `claude-code` and `codex`. See [ ## remove command options -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------- | -| `-g, --global` | Remove from global scope | -| `-a, --targets ` | Remove from specific targets (use `'*'` for all targets) | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | -| `-y, --yes` | Skip confirmation prompts | -| `--all` | Remove all installed items | +| Option | Description | +| ---------------------------- | ----------------------------------------------------------------------------------- | +| `-g, --global` | Remove from global scope | +| `-a, --targets ` | Remove from specific targets (use `'*'` for all targets) | +| `-t, --type ` | Filter by context type (`skill`, `prompt`, `agent`, `instruction`; comma-separated) | +| `-y, --yes` | Skip confirmation prompts | +| `--all` | Remove all installed items | ## find command @@ -60,8 +57,8 @@ npx dotai find react # search non-interactively and list results ``` Found in vercel-labs/agent-skills: 2 skills react-best-practices, nextjs-patterns - 3 rules code-style, testing, imports 1 prompt review-code + 1 instruction project-setup ? What would you like to install? > Install selected skill only (react-best-practices) @@ -71,7 +68,7 @@ Found in vercel-labs/agent-skills: ``` - **Install selected skill only** installs the single skill you picked from the search results. -- **Install all context from this repo** installs every skill, rule, prompt, agent, and instruction in the repo. +- **Install all context from this repo** installs every skill, prompt, agent, and instruction in the repo. - **Pick individual items** opens a multi-select where you choose exactly which items to install. If the GitHub Trees API is unreachable (rate limit, private repo, network error), the preview step is skipped and the selected skill is installed directly. @@ -85,16 +82,14 @@ Skills (2) react-best-practices nextjs-patterns -Rules (3) - code-style - testing - imports [cursor] - Prompts (1) review-code +Instructions (1) + project-setup + Install with: npx dotai add owner/repo -Or specific items: npx dotai add owner/repo --rule +Or specific items: npx dotai add owner/repo --instruction ``` ### Native context discovery @@ -112,40 +107,13 @@ The following native directories are scanned (derived from the [target-agents re **Non-interactive mode** (with a query argument) prints matching results with install commands, suitable for use inside AI coding agents. -## `import` - -Convert native agent-specific rule files into canonical `RULES.md` format. - - npx dotai import - npx dotai import --from cursor,claude-code - npx dotai import --output rules/ --dry-run - -| Flag | Description | -| ----------------- | --------------------------------------------------------------------- | -| `--from ` | Comma-separated list of agents to import from (default: all detected) | -| `--output ` | Output directory for canonical rules (default: `rules/`) | -| `--force` | Overwrite existing canonical rules with the same name | -| `--dry-run` | Preview imports without writing files | - -> **Note:** Some agent formats lose information during import. For example, Cursor's -> `alwaysApply: false` maps to `activation: auto` (could also mean `manual`), and -> Copilot rules have no description field. Review imported rules and adjust as needed. - -### Supported native formats - -| Agent | Source directory | Parsed fields | -| -------------- | ---------------------------------------- | ------------------------------- | -| Cursor | `.cursor/rules/*.mdc` | description, alwaysApply, globs | -| Claude Code | `.claude/rules/*.md` | description, globs | -| GitHub Copilot | `.github/instructions/*.instructions.md` | applyTo | - ## list command options -| Option | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------- | -| `-g, --global` | List global context (default: project) | -| `-a, --targets ` | Filter by specific targets | -| `-t, --type ` | Filter by context type (`skill`, `rule`, `prompt`, `agent`, `instruction`; comma-separated) | +| Option | Description | +| ---------------------------- | ----------------------------------------------------------------------------------- | +| `-g, --global` | List global context (default: project) | +| `-a, --targets ` | Filter by specific targets | +| `-t, --type ` | Filter by context type (`skill`, `prompt`, `agent`, `instruction`; comma-separated) | ## Installation Scope @@ -163,20 +131,17 @@ npx dotai find owner/repo # Interactive install — discovers all content types npx dotai add owner/repo -# Install one rule and one skill in a single run -npx dotai add owner/repo --rule code-style --skill db-migrate +# Install an instruction and a skill in a single run +npx dotai add owner/repo --instruction project-setup --skill db-migrate # Install only prompts npx dotai add owner/repo --prompt review-code -# Discover and install all rules from a source -npx dotai add owner/repo --type rule +# Discover and install all instructions from a source +npx dotai add owner/repo --type instruction -# Install all rules and prompts from a source -npx dotai add owner/repo --type rule,prompt - -# Install prompts and rules together -npx dotai add owner/repo --prompt review-code --rule code-style +# Install all instructions and prompts from a source +npx dotai add owner/repo --type instruction,prompt # Install a custom agent npx dotai add owner/repo --custom-agent architect @@ -185,13 +150,10 @@ npx dotai add owner/repo --custom-agent architect npx dotai add owner/repo --custom-agent architect --targets copilot,claude # Force replace an existing unmanaged target file -npx dotai add owner/repo --rule code-style --force - -# Append rules to AGENTS.md and CLAUDE.md instead of per-rule files -npx dotai add owner/repo --rule code-style --append +npx dotai add owner/repo --prompt review-code --force # Keep transpiled outputs out of version control -npx dotai add owner/repo --rule code-style --gitignore +npx dotai add owner/repo --instruction project-setup --gitignore # CI-friendly non-interactive install npx dotai add owner/repo --all --targets copilot,claude,cursor,opencode -y @@ -202,14 +164,14 @@ npx dotai add owner/repo --all --targets copilot,claude,cursor,opencode -y Share a single command with teammates to install the same context: ```bash -# Share a rule for your team -npx dotai add owner/repo --rule code-style -y +# Share an instruction for your team +npx dotai add owner/repo --instruction project-setup -y # Share a prompt npx dotai add owner/repo --prompt review-code -y -# Share all rules and prompts from a repo -npx dotai add owner/repo --type rule,prompt -y +# Share all instructions and prompts from a repo +npx dotai add owner/repo --type instruction,prompt -y # CI-friendly: install everything, skip prompts, limit to specific targets npx dotai add owner/repo --all --targets copilot,claude,cursor -y @@ -217,9 +179,9 @@ npx dotai add owner/repo --all --targets copilot,claude,cursor -y ## How transpilation works -When you write a canonical file (`RULES.md`, `PROMPT.md`, `AGENT.md`), dotai splits it into two parts: +When you write a canonical file (`PROMPT.md`, `AGENT.md`, `INSTRUCTIONS.md`), dotai splits it into two parts: -- **Frontmatter** (metadata like `activation`, `globs`, `model`, `tools`) is **mapped per-agent** into each target's native format. +- **Frontmatter** (metadata like `model`, `tools`, `description`) is **mapped per-agent** into each target's native format. - **Body** (everything after the frontmatter) is **passed verbatim** to all targets. No content is filtered, adapted, or rewritten. This means canonical bodies should contain **agent-agnostic instructions** — describe _what_ to do, not _how_ to do it with a specific agent's tools. For example, "run the tests before committing" is portable; "use the Bash tool to run tests" is Claude Code-specific and will land unchanged in Cursor, Copilot, and OpenCode where it won't make sense. @@ -237,11 +199,11 @@ dotai also discovers **native agent-specific files** in source repos and passes A single source repo can contain both canonical and native files. Canonical files fan out to all targets; native files go only to their matching agent. -| Use case | Approach | -| ----------------------------------------------- | --------------------- | -| Agent-agnostic coding standards | Canonical `RULES.md` | -| Agent-specific tool references or workflows | Native file | -| Mix of portable and agent-specific instructions | Both in the same repo | +| Use case | Approach | +| ----------------------------------------------- | --------------------------- | +| Agent-agnostic project-wide instructions | Canonical `INSTRUCTIONS.md` | +| Agent-specific tool references or workflows | Native file | +| Mix of portable and agent-specific instructions | Both in the same repo | ### Per-agent overrides @@ -251,32 +213,34 @@ This lets you keep a single canonical file while tuning specific fields per agen ```markdown --- -name: code-style -description: Enforce TypeScript style conventions -globs: - - '*.ts' - - '*.tsx' -activation: auto +name: review-code +description: Review code for bugs and style issues +model: claude-sonnet-4 +tools: + - codebase_search + - read_file github-copilot: - activation: always + model: gpt-4o claude-code: - severity: error + tools: + - Read + - Grep --- -Use `const` over `let` when the variable is never reassigned. +Review the following code for correctness, performance, and style. ``` -In this example, when transpiling for GitHub Copilot the effective `activation` is `always`. For Claude Code, `severity` is `error`. For Cursor and OpenCode, the base fields are used unchanged. +In this example, when transpiling for GitHub Copilot the effective `model` is `gpt-4o`. For Claude Code, the `tools` list uses Claude-specific tool names. For other agents, the base fields are used unchanged. -Override blocks work on all three canonical types: +Override blocks work on the canonical types that support transpilation: -| Type | Overridable fields | -| ----------- | ------------------------------------------------------------------------------ | -| `RULES.md` | `description`, `globs`, `activation`, `severity` | -| `PROMPT.md` | `description`, `argument-hint`, `agent`, `model`, `tools` | -| `AGENT.md` | `description`, `model`, `tools`, `disallowed-tools`, `max-turns`, `background` | +| Type | Overridable fields | +| ----------------- | ------------------------------------------------------------------------------ | +| `PROMPT.md` | `description`, `argument-hint`, `agent`, `model`, `tools` | +| `AGENT.md` | `description`, `model`, `tools`, `disallowed-tools`, `max-turns`, `background` | +| `INSTRUCTIONS.md` | `description` | Identity fields (`name`, `schema-version`) and structural fields (`body`) cannot be overridden. @@ -284,19 +248,18 @@ Override keys must match a valid target agent (`github-copilot`, `claude-code`, Agent-exclusive fields like `disallowed-tools`, `max-turns`, and `background` can appear in any agent's override block. The transpiler for agents that do not support those fields ignores them, just as it does for base fields. -See [`examples/rule-with-overrides/RULES.md`](../examples/rule-with-overrides/RULES.md) for a complete working example. +See [`examples/`](../examples) for complete working examples. ## Source repo layout dotai discovers context files by convention. It scans the source path (the repo or directory you pass to `dotai add`) for files in specific locations. -### Rules, prompts, and agents +### Prompts and agents Each type is discovered in two places: | Type | Root file | Subdirectory pattern | | ------- | ----------- | --------------------- | -| Rules | `RULES.md` | `rules/*/RULES.md` | | Prompts | `PROMPT.md` | `prompts/*/PROMPT.md` | | Agents | `AGENT.md` | `agents/*/AGENT.md` | @@ -328,12 +291,6 @@ A source repo with multiple context types might look like this: ``` my-context-repo/ INSTRUCTIONS.md # root-level instructions - RULES.md # single root-level rule - rules/ - code-style/ - RULES.md # additional rule - error-handling/ - RULES.md # additional rule prompts/ review-code/ PROMPT.md @@ -351,12 +308,12 @@ my-context-repo/ deploy.md ``` -Every canonical file (`RULES.md`, `PROMPT.md`, `AGENT.md`, `SKILL.md`, `INSTRUCTIONS.md`) must contain YAML frontmatter with at least `name` and `description`: +Every canonical file (`PROMPT.md`, `AGENT.md`, `SKILL.md`, `INSTRUCTIONS.md`) must contain YAML frontmatter with at least `name` and `description`: ```markdown --- -name: code-style -description: Enforce TypeScript style conventions +name: project-setup +description: Project setup instructions for new contributors --- Instructions go here. @@ -381,35 +338,20 @@ description: Run safe database migration workflows Instructions for the AI agent go here. ``` -### Rule (`RULES.md`) +### Instruction (`INSTRUCTIONS.md`) ```markdown --- -name: code-style -description: Enforce TypeScript style conventions -globs: - - '*.ts' - - '*.tsx' -activation: auto +name: project-setup +description: Project setup instructions for new contributors --- -Always use `const` over `let` when the variable is never reassigned. +Follow these conventions when working in this repository. ``` -Supported fields: `name` (required), `description` (required), `globs`, `activation` (`always` | `auto` | `manual` | `glob`), `severity`. - -#### Activation mapping - -The `activation` field controls how each target agent decides when to apply the rule: - -| `activation` | Cursor | Copilot | Claude Code | OpenCode | -| ------------ | ------------------- | ------------------ | ------------------- | -------------- | -| `always` | `alwaysApply: true` | `applyTo: "**"` | always applies | plain markdown | -| `auto` | agent decides | `applyTo: "**"` | agent decides | plain markdown | -| `manual` | manual inclusion | `applyTo: "**"` | manual | plain markdown | -| `glob` | `globs: ` | `applyTo: ` | `globs: ` | plain markdown | +Supported fields: `name` (required), `description` (required). -> **Note:** Claude Code treats `globs` as independent file scoping — globs are emitted whenever present, regardless of activation mode. OpenCode rules are plain markdown with no frontmatter; users add the file path to the `instructions` array in `opencode.json` to activate them. +Instructions are appended as marker-delimited sections to project-wide files (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`). ### Prompt (`PROMPT.md`) diff --git a/docs/coding-agent-docs.md b/docs/coding-agent-docs.md index 7ac0dd3..783c164 100644 --- a/docs/coding-agent-docs.md +++ b/docs/coding-agent-docs.md @@ -10,55 +10,55 @@ dotai transpiles canonical content types into agent-native formats. The table be Project-wide instruction files loaded into every session (e.g., `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`). -| Agent | Docs | -| --- | --- | -| OpenCode | [Rules (AGENTS.md)](https://opencode.ai/docs/rules/) | -| Claude Code | [CLAUDE.md & Memory](https://code.claude.com/docs/en/memory) | -| Codex | [AGENTS.md](https://developers.openai.com/codex/guides/agents-md) | -| Cursor | [Rules (AGENTS.md)](https://cursor.com/docs/rules#agentsmd) | +| Agent | Docs | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | [Rules (AGENTS.md)](https://opencode.ai/docs/rules/) | +| Claude Code | [CLAUDE.md & Memory](https://code.claude.com/docs/en/memory) | +| Codex | [AGENTS.md](https://developers.openai.com/codex/guides/agents-md) | +| Cursor | [Rules (AGENTS.md)](https://cursor.com/docs/rules#agentsmd) | | GitHub Copilot | [Custom Instructions](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) | -### Rules +### Agent-Native Rules -Scoped instruction files with activation conditions (always, auto, glob, manual). +Scoped instruction files with activation conditions, specific to each coding agent. dotai discovers these as native passthrough files when they exist in a source repo. -| Agent | Docs | -| --- | --- | -| OpenCode | [Rules](https://opencode.ai/docs/rules/) — `AGENTS.md` + `opencode.json` `instructions` field | -| Claude Code | [Rules (.claude/rules/)](https://code.claude.com/docs/en/memory#organize-rules-with-clauderules) — path-scoped `.md` files with `paths` frontmatter | -| Codex | [Rules](https://developers.openai.com/codex/rules) — `.rules` files under `.codex/rules/` using Starlark `prefix_rule()` | -| Cursor | [Project Rules](https://cursor.com/docs/rules#project-rules) — `.mdc` files in `.cursor/rules/` with `description`, `globs`, `alwaysApply` frontmatter | +| Agent | Docs | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | [Rules](https://opencode.ai/docs/rules/) — `AGENTS.md` + `opencode.json` `instructions` field | +| Claude Code | [Rules (.claude/rules/)](https://code.claude.com/docs/en/memory#organize-rules-with-clauderules) — path-scoped `.md` files with `paths` frontmatter | +| Codex | [Rules](https://developers.openai.com/codex/rules) — `.rules` files under `.codex/rules/` using Starlark `prefix_rule()` | +| Cursor | [Project Rules](https://cursor.com/docs/rules#project-rules) — `.mdc` files in `.cursor/rules/` with `description`, `globs`, `alwaysApply` frontmatter | | GitHub Copilot | [Path-specific Instructions](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot#creating-path-specific-custom-instructions) — `.instructions.md` files in `.github/instructions/` with `applyTo` frontmatter | ### Skills On-demand, reusable `SKILL.md` packages loaded by the agent when relevant. -| Agent | Docs | -| --- | --- | -| OpenCode | [Agent Skills](https://opencode.ai/docs/skills/) — `.opencode/skills/`, `.agents/skills/` | -| Claude Code | [Skills](https://code.claude.com/docs/en/skills) — `.claude/skills/`, `.agents/skills/` | -| Codex | [Agent Skills](https://developers.openai.com/codex/skills) — `.agents/skills/`, `~/.agents/skills/` | -| Cursor | [Agent Skills](https://cursor.com/docs/context/skills) — `.cursor/skills/`, `.agents/skills/` | -| GitHub Copilot | `.agents/skills/` (via [Agent Skills spec](https://agentskills.io/)) | +| Agent | Docs | +| -------------- | --------------------------------------------------------------------------------------------------- | +| OpenCode | [Agent Skills](https://opencode.ai/docs/skills/) — `.opencode/skills/`, `.agents/skills/` | +| Claude Code | [Skills](https://code.claude.com/docs/en/skills) — `.claude/skills/`, `.agents/skills/` | +| Codex | [Agent Skills](https://developers.openai.com/codex/skills) — `.agents/skills/`, `~/.agents/skills/` | +| Cursor | [Agent Skills](https://cursor.com/docs/context/skills) — `.cursor/skills/`, `.agents/skills/` | +| GitHub Copilot | `.agents/skills/` (via [Agent Skills spec](https://agentskills.io/)) | ### Prompts / Commands Reusable prompt templates invoked explicitly (e.g., `/command-name`). -| Agent | Docs | -| --- | --- | -| OpenCode | [Commands](https://opencode.ai/docs/commands/) — `.opencode/commands/*.md` with `description`, `agent`, `model` frontmatter | -| Claude Code | [Custom Commands](https://code.claude.com/docs/en/memory) — `.claude/commands/*.md` with description blockquote | +| Agent | Docs | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | [Commands](https://opencode.ai/docs/commands/) — `.opencode/commands/*.md` with `description`, `agent`, `model` frontmatter | +| Claude Code | [Custom Commands](https://code.claude.com/docs/en/memory) — `.claude/commands/*.md` with description blockquote | | GitHub Copilot | [Prompt Files](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) — `.github/prompts/*.prompt.md` with `description`, `agent`, `model`, `tools` frontmatter | ### Agents / Subagents Custom agent definitions with specialized prompts, tool restrictions, and models. -| Agent | Docs | -| --- | --- | -| OpenCode | [Agents](https://opencode.ai/docs/agents/) — `.opencode/agents/*.md` or `opencode.json` `agent` field | -| Claude Code | [Subagents](https://code.claude.com/docs/en/sub-agents) — `.claude/agents/*.md` with `name`, `description`, `tools`, `model` frontmatter | -| Codex | [Subagents](https://developers.openai.com/codex/subagents) — `.codex/agents/*.toml` with `name`, `description`, `developer_instructions` | +| Agent | Docs | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenCode | [Agents](https://opencode.ai/docs/agents/) — `.opencode/agents/*.md` or `opencode.json` `agent` field | +| Claude Code | [Subagents](https://code.claude.com/docs/en/sub-agents) — `.claude/agents/*.md` with `name`, `description`, `tools`, `model` frontmatter | +| Codex | [Subagents](https://developers.openai.com/codex/subagents) — `.codex/agents/*.toml` with `name`, `description`, `developer_instructions` | | GitHub Copilot | [Agent Mode](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) — `.github/agents/*.agent.md` with `name`, `description`, `model`, `tools` frontmatter | diff --git a/docs/supported-targets.md b/docs/supported-targets.md index ddff140..66f8a3f 100644 --- a/docs/supported-targets.md +++ b/docs/supported-targets.md @@ -2,7 +2,7 @@ dotai installs `SKILL.md` files into the config directories of its supported targets. **GitHub Copilot**, **Claude Code**, **Codex**, **Cursor**, and **OpenCode** are actively tested. -Rules, prompts, and agent definitions use [transpilation targets](../README.md#supported-targets) (Copilot, Claude Code, Cursor, OpenCode). +Instructions, prompts, and agent definitions use [transpilation targets](../README.md#supported-targets) (Copilot, Claude Code, Cursor, OpenCode).
Full target table diff --git a/examples/rule/RULES.md b/examples/instruction/INSTRUCTIONS.md similarity index 50% rename from examples/rule/RULES.md rename to examples/instruction/INSTRUCTIONS.md index 9ada7f4..2d134e8 100644 --- a/examples/rule/RULES.md +++ b/examples/instruction/INSTRUCTIONS.md @@ -1,20 +1,28 @@ --- -name: typescript-style -description: Enforce TypeScript coding style conventions -globs: - - '*.ts' - - '*.tsx' -activation: always +name: project-conventions +description: Project-wide coding conventions for all contributors --- +Follow these conventions when working in this repository. + +## Code Style + Use `const` over `let` when the variable is never reassigned. Prefer `interface` over `type` for object shapes that may be extended. Use explicit return types on exported functions. -Avoid `any` — use `unknown` when the type is truly unknown, then narrow with type guards. +## Testing + +Write tests for all new features and bug fixes. + +Run the full test suite before submitting changes. + +## Commits + +Use conventional commit messages (e.g., `feat: add feature`, `fix: resolve bug`). diff --git a/examples/rule-with-overrides/RULES.md b/examples/rule-with-overrides/RULES.md deleted file mode 100644 index 2ef50d8..0000000 --- a/examples/rule-with-overrides/RULES.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: code-style -description: Enforce TypeScript style conventions -globs: - - '*.ts' - - '*.tsx' -activation: auto - -github-copilot: - activation: always - -claude-code: - severity: error ---- - - - -Use `const` over `let` when the variable is never reassigned. - -Prefer `interface` over `type` for object shapes that may be extended. - -Use explicit return types on exported functions. - -Avoid `any` — use `unknown` when the type is truly unknown, then narrow with type guards. diff --git a/src/add-options.ts b/src/add-options.ts index 4478463..073f486 100644 --- a/src/add-options.ts +++ b/src/add-options.ts @@ -17,7 +17,7 @@ export interface AddOptions { force?: boolean; /** Add transpiled output paths to .gitignore (opt-in). */ gitignore?: boolean; - /** Filter discovery to specific context types (skill, rule, prompt, agent). */ + /** Filter discovery to specific context types (skill, prompt, agent, instruction). */ type?: ContextType[]; } diff --git a/src/add.ts b/src/add.ts index 8b0b092..1020870 100644 --- a/src/add.ts +++ b/src/add.ts @@ -73,7 +73,7 @@ function resolveAgentsOrDefault(options: AddOptions): TargetAgent[] { /** Config for each context type handled by handleContextInstall. */ interface ContextInstallConfig { - /** Noun used in log/spinner messages (e.g., "rule", "prompt", "agent"). */ + /** Noun used in log/spinner messages (e.g., "prompt", "agent", "instruction"). */ noun: string; /** Extract item names from options. */ getNames: (options: AddOptions) => string[]; diff --git a/src/append-markers.ts b/src/append-markers.ts index e79eae1..98273a5 100644 --- a/src/append-markers.ts +++ b/src/append-markers.ts @@ -1,11 +1,9 @@ // --------------------------------------------------------------------------- -// Marker management for append-mode rule transpilation +// Marker management for append-mode transpilation // // Manages `` / `` sections // in markdown files (AGENTS.md, CLAUDE.md). Enables clean insert, update, and -// remove of rule content without touching user-authored content. -// -// Reference: prd-rule-append-fallback.md Task 1 +// remove of content without touching user-authored content. // --------------------------------------------------------------------------- /** diff --git a/src/cli.test.ts b/src/cli.test.ts index 6d50038..1f12391 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; -import { runCliOutput, stripLogo, hasLogo } from './test-utils.ts'; +import { runCli, runCliOutput, stripLogo, hasLogo } from './test-utils.ts'; describe('dotai CLI', () => { describe('--help', () => { @@ -157,4 +157,53 @@ describe('dotai CLI', () => { expect(hasLogo(output)).toBe(false); }, 60000); }); + + describe('removed rules features', () => { + it('should error when --rule flag is passed to add', () => { + const result = runCli(['add', 'owner/repo', '--rule', 'my-rule']); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('--rule flag has been removed'); + expect(result.stdout).toContain('--instruction'); + }); + + it('should error when --append flag is passed to add', () => { + const result = runCli(['add', 'owner/repo', '--append']); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('--append flag has been removed'); + expect(result.stdout).toContain('append mode'); + }); + + it('should error when import command is used', () => { + const result = runCli(['import']); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('import command has been removed'); + expect(result.stdout).toContain('--instruction'); + }); + + it('should error when init rule is used', () => { + const result = runCli(['init', 'rule']); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('rule template has been removed'); + expect(result.stdout).toContain('dotai init instruction'); + }); + + it('should error when init --rule is used', () => { + const result = runCli(['init', '--rule']); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('rule template has been removed'); + expect(result.stdout).toContain('dotai init instruction'); + }); + + it('should not reference rules in help text', () => { + const output = runCliOutput(['--help']); + expect(output).not.toContain('--rule'); + expect(output).not.toContain('import'); + }); + + it('should not reference --append in add help text', () => { + const output = runCliOutput(['add', '--help-all']); + expect(output).not.toContain('--append'); + expect(output).not.toContain('--rule'); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index afeb1ba..43c34c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { runInstallFromLock } from './restore.ts'; import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; import { runSync, parseSyncOptions } from './sync.ts'; -import { RESET, BOLD, DIM, TEXT } from './utils.ts'; +import { RESET, BOLD, DIM, TEXT, YELLOW } from './utils.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -178,7 +178,6 @@ ${BOLD}Install Options:${RESET} --copy Copy files instead of symlinking --dry-run Preview writes without making changes --force Overwrite conflicting outputs - --append Append instructions to AGENTS.md/CLAUDE.md instead of per-file output --gitignore Add transpiled output paths to .gitignore --full-depth Search all subdirectories even when a root SKILL.md exists --all Shorthand for --skill '*' --targets '*' -y @@ -294,6 +293,18 @@ async function main(): Promise { showAddHelp(); break; } + if (restArgs.includes('--rule') || restArgs.includes('-r')) { + console.log( + `${YELLOW}The --rule flag has been removed.${RESET} Use ${BOLD}--instruction${RESET} instead.\nSee ${TEXT}https://github.com/mfaux/dotai/issues/17${RESET} for details.` + ); + process.exit(1); + } + if (restArgs.includes('--append')) { + console.log( + `${YELLOW}The --append flag has been removed.${RESET} Instructions now always use append mode.\nSee ${TEXT}https://github.com/mfaux/dotai/issues/17${RESET} for details.` + ); + process.exit(1); + } showLogo(); const { source: addSource, options: addOpts } = parseAddOptions(restArgs); await runAdd(addSource, addOpts); @@ -346,6 +357,12 @@ async function main(): Promise { console.log(VERSION); break; + case 'import': + console.log( + `${YELLOW}The import command has been removed.${RESET} Use ${BOLD}dotai add${RESET} with ${BOLD}--instruction${RESET} instead.\nSee ${TEXT}https://github.com/mfaux/dotai/issues/17${RESET} for details.` + ); + process.exit(1); + default: console.log(`Unknown command: ${command}`); console.log(`Run ${BOLD}dotai --help${RESET} for usage.`); diff --git a/src/context-installer.ts b/src/context-installer.ts index ad155cd..3a037e9 100644 --- a/src/context-installer.ts +++ b/src/context-installer.ts @@ -153,7 +153,7 @@ export function planContextWrites( * Execute the full MVP install pipeline. * * Flow: - * 1. Plan: transpile discovered rules → PlannedWrites + * 1. Plan: transpile discovered context → PlannedWrites * 2. Check: detect collisions against lock entries and filesystem * 3. Execute: write files to disk (or report dry-run plan) * diff --git a/src/init.ts b/src/init.ts index 5ab54f6..4709103 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,6 +1,6 @@ import { writeFileSync, existsSync, mkdirSync } from 'fs'; import { basename, join } from 'path'; -import { RESET, DIM, TEXT } from './utils.ts'; +import { RESET, DIM, TEXT, BOLD, YELLOW } from './utils.ts'; import { KEBAB_CASE_PATTERN } from './validation.ts'; // --------------------------------------------------------------------------- @@ -8,9 +8,9 @@ import { KEBAB_CASE_PATTERN } from './validation.ts'; // --------------------------------------------------------------------------- interface TemplateConfig { - /** The markdown filename (e.g. "RULES.md") */ + /** The markdown filename (e.g. "INSTRUCTIONS.md") */ file: string; - /** Human-readable noun (e.g. "rule") */ + /** Human-readable noun (e.g. "instruction") */ noun: string; /** Generate the template content given the name */ generateContent: (name: string) => string; @@ -141,7 +141,7 @@ function initTemplate(config: TemplateConfig, name: string, hasName: boolean, cw const displayPath = hasName ? `${name}/${config.file}` : config.file; if (existsSync(filePath)) { - // Capitalize the noun for display: "rule" → "Rule" + // Capitalize the noun for display: "instruction" → "Instruction" const label = config.noun.charAt(0).toUpperCase() + config.noun.slice(1); console.log(`${TEXT}${label} already exists at ${DIM}${displayPath}${RESET}`); return; @@ -181,6 +181,14 @@ export function runInit(args: string[]): void { // Determine which template type to create const typeArg = args[0]; + // Rule template — removed, show deprecation error + if (typeArg === 'rule' || typeArg === '--rule') { + console.log( + `${YELLOW}The rule template has been removed.${RESET} Use ${BOLD}dotai init instruction${RESET} instead.\nSee ${TEXT}https://github.com/mfaux/dotai/issues/17${RESET} for details.` + ); + process.exit(1); + } + // Prompt template if (typeArg === 'prompt' || typeArg === '--prompt') { const config = TEMPLATE_CONFIGS['prompt']!; diff --git a/src/instruction-pipeline.test.ts b/src/instruction-pipeline.test.ts index e533a9c..64c4049 100644 --- a/src/instruction-pipeline.test.ts +++ b/src/instruction-pipeline.test.ts @@ -154,7 +154,7 @@ describe('install-pipeline — instructions', () => { expect(writes[0]!.planned.absolutePath).toBe(join(tmpDir, 'AGENTS.md')); }); - it('handles mixed instructions + rules + prompts together', () => { + it('handles mixed instructions + prompts together', () => { // Import helpers would make this complex — test with just instructions const items = [ canonicalInstruction('code-style'), diff --git a/src/instruction-transpilers.ts b/src/instruction-transpilers.ts index 13e518b..030b56a 100644 --- a/src/instruction-transpilers.ts +++ b/src/instruction-transpilers.ts @@ -31,7 +31,7 @@ import { mergeOverrides } from './override-parser.ts'; // > // // -// This mirrors the append rule transpiler pattern. +// This mirrors the standard append transpiler pattern. // --------------------------------------------------------------------------- function buildInstructionContent(instruction: CanonicalInstruction): string { diff --git a/src/override-parser.ts b/src/override-parser.ts index 1fc0527..b3ea672 100644 --- a/src/override-parser.ts +++ b/src/override-parser.ts @@ -4,7 +4,7 @@ import { TARGET_AGENTS } from './target-agents.ts'; // --------------------------------------------------------------------------- // Shared override extraction for per-agent frontmatter overrides // -// Canonical files (RULES.md, PROMPT.md, AGENT.md) can include agent-namespaced +// Canonical files (PROMPT.md, AGENT.md, INSTRUCTIONS.md) can include agent-namespaced // blocks in YAML frontmatter. When transpiling for a target agent, its // overrides are merged on top of the base fields. // --------------------------------------------------------------------------- diff --git a/src/restore.ts b/src/restore.ts index 0f37489..e30d592 100644 --- a/src/restore.ts +++ b/src/restore.ts @@ -209,7 +209,7 @@ async function installFromSource( /** * Install a group of same-type entries, splitting by gitignored and append status. * - * Append-mode and per-file mode rules must be installed in separate calls because + * Append-mode and per-file mode entries must be installed in separate calls because * the `append` flag changes how transpilers emit output. Similarly, gitignored and * non-gitignored entries need separate calls to preserve the --gitignore flag. *