diff --git a/.husky/pre-commit b/.husky/pre-commit index e03f6257..62d316e8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,8 +1,8 @@ -# Generate index.md files for ai/ and aidd-custom/ folders +# Generate index.md files for ai/ and aidd-custom/ folders, and sync AGENTS.md / CLAUDE.md node bin/aidd.js --index "$(git rev-parse --show-toplevel)" -# Stage any updated index.md files -git add 'ai/**/index.md' 'aidd-custom/**/index.md' 2>/dev/null || true +# Stage any updated index.md files and synced agent files +git add 'ai/**/index.md' 'aidd-custom/**/index.md' AGENTS.md CLAUDE.md 2>/dev/null || true # Generate ToC for README.md npm run toc diff --git a/AGENTS.md b/AGENTS.md index 8f170e40..01eb8d60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,15 @@ and read `aidd-custom/config.yml` to load configuration into context. ## Task Index fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..01eb8d60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# AI Agent Guidelines + +This project uses AI-assisted development with structured guidance in the `ai/` directory. + +## Directory Structure + +Agents should examine the `ai/*` directory listings to understand the available commands, rules, and workflows. + +## Index Files + +Each folder in the `ai/` directory contains an `index.md` file that describes the purpose and contents of that folder. Agents can read these index files to learn the function of files in each folder without needing to read every file. + +**Important:** The `ai/**/index.md` files are auto-generated from frontmatter. Do not create or edit these files manuallyβ€”they will be overwritten by the pre-commit hook. + +## Progressive Discovery + +Agents should only consume the root index until they need subfolder contents. For example: +- If the project is Python, there is no need to read JavaScript-specific folders +- If working on backend logic, frontend UI folders can be skipped +- Only drill into subfolders when the task requires that specific domain knowledge + +This approach minimizes context consumption and keeps agent responses focused. + +## Vision Document Requirement + +**Before creating or running any task, agents must first read the vision document (`vision.md`) in the project root.** + +The vision document serves as the source of truth for: +- Project goals and objectives +- Key constraints and non-negotiables +- Architectural decisions and rationale +- User experience principles +- Success criteria + +## Conflict Resolution + +If any conflicts are detected between a requested task and the vision document, agents must: + +1. Stop and identify the specific conflict +2. Explain how the task conflicts with the stated vision +3. Ask the user to clarify how to resolve the conflict before proceeding + +Never proceed with a task that contradicts the vision without explicit user approval. + +## Custom Skills and Configuration + +Project-specific customization lives in `aidd-custom/`. Before starting work, +read `aidd-custom/index.md` to discover available project-specific skills, +and read `aidd-custom/config.yml` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (`npm run test:unit`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +```sh +npm run test:e2e +``` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. diff --git a/ai/commands/index.md b/ai/commands/index.md index 7a2431d5..23e07f21 100644 --- a/ai/commands/index.md +++ b/ai/commands/index.md @@ -8,65 +8,43 @@ This index provides an overview of the contents in this directory. **File:** `aidd-fix.md` -*No description available* - ### Commit **File:** `commit.md` -*No description available* - ### discover **File:** `discover.md` -*No description available* - ### execute **File:** `execute.md` -*No description available* - ### help **File:** `help.md` -*No description available* - ### log **File:** `log.md` -*No description available* - ### plan **File:** `plan.md` -*No description available* - ### πŸ”¬ Code Review **File:** `review.md` -*No description available* - ### run-test **File:** `run-test.md` -*No description available* - ### task **File:** `task.md` -*No description available* - ### user-test **File:** `user-test.md` -*No description available* - diff --git a/ai/rules/index.md b/ai/rules/index.md index 6001f82e..dd13d01e 100644 --- a/ai/rules/index.md +++ b/ai/rules/index.md @@ -60,8 +60,6 @@ When writing functional requirements for a user story, use this guide for functi **File:** `review-example.md` -*No description available* - ### πŸ”¬ Code Review **File:** `review.mdc` @@ -84,8 +82,6 @@ when the user asks you to complete a task, use this guide for systematic task/ep **File:** `tdd.mdc` -*No description available* - ### UI/UX Engineer **File:** `ui.mdc` diff --git a/ai/rules/javascript/index.md b/ai/rules/javascript/index.md index e4cc0a42..1972fe9b 100644 --- a/ai/rules/javascript/index.md +++ b/ai/rules/javascript/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `error-causes.mdc` -*No description available* - ### JavaScript IO Guide **File:** `javascript-io-network-effects.mdc` @@ -20,5 +18,3 @@ When you need to make network requests or invoke side-effects, use this guide fo **File:** `javascript.mdc` -*No description available* - diff --git a/ai/skills/aidd-ecs/index.md b/ai/skills/aidd-ecs/index.md index b47a2955..27d7ebbd 100644 --- a/ai/skills/aidd-ecs/index.md +++ b/ai/skills/aidd-ecs/index.md @@ -14,5 +14,3 @@ Enforces @adobe/data/ecs best practices. Use this whenever @adobe/data/ecs is im **File:** `data-modeling.md` -*No description available* - diff --git a/ai/skills/aidd-layout/references/index.md b/ai/skills/aidd-layout/references/index.md index f2ee671e..27bac6cb 100644 --- a/ai/skills/aidd-layout/references/index.md +++ b/ai/skills/aidd-layout/references/index.md @@ -8,5 +8,3 @@ This index provides an overview of the contents in this directory. **File:** `design-tokens.md` -*No description available* - diff --git a/ai/skills/aidd-namespace/index.md b/ai/skills/aidd-namespace/index.md index 325915ab..0f60e144 100644 --- a/ai/skills/aidd-namespace/index.md +++ b/ai/skills/aidd-namespace/index.md @@ -8,8 +8,6 @@ This index provides an overview of the contents in this directory. **File:** `README.md` -*No description available* - ### Type namespace pattern **File:** `SKILL.md` diff --git a/bin/aidd.js b/bin/aidd.js index d9a00acd..916f516b 100755 --- a/bin/aidd.js +++ b/bin/aidd.js @@ -7,7 +7,9 @@ import { fileURLToPath } from "url"; import chalk from "chalk"; import { Command } from "commander"; -import { executeClone, handleCliErrors } from "../lib/cli-core.js"; +import { syncRootAgentFiles } from "../lib/agents-md.js"; +import { executeClone } from "../lib/cli-core.js"; +import { handleCliErrors } from "../lib/error-causes.js"; import { generateAllIndexes } from "../lib/index-generator.js"; const __filename = fileURLToPath(import.meta.url); @@ -35,6 +37,7 @@ const createCli = () => { "-c, --cursor", "create .cursor symlink for Cursor editor integration", ) + .option("--claude", "create .claude symlink for Claude Code integration") .option( "-i, --index", "generate index.md files from frontmatter in ai/ subfolders", @@ -85,7 +88,15 @@ To install for Cursor: npx aidd --cursor -Install without Cursor integration: +To install for Claude Code: + + npx aidd --claude + +To install for both: + + npx aidd --cursor --claude + +Install without editor integration: npx aidd my-project `, @@ -99,7 +110,10 @@ https://paralleldrive.com `, ) .action( - async (targetDirectory, { force, dryRun, verbose, cursor, index }) => { + async ( + targetDirectory, + { force, dryRun, verbose, cursor, claude, index }, + ) => { // Handle --index option separately if (index) { const targetPath = path.resolve(process.cwd(), targetDirectory); @@ -125,15 +139,24 @@ https://paralleldrive.com console.log(chalk.gray(` - ${idx.path}`)); }); } - process.exit(0); } else { console.error(chalk.red(`❌ ${result.error.message}`)); process.exit(1); } + + const syncResults = await syncRootAgentFiles(targetPath); + syncResults + .filter(({ action }) => action !== "unchanged") + .forEach(({ file, action }) => { + console.log(chalk.green(`βœ… ${action} ${file}`)); + }); + + process.exit(0); return; } const result = await executeClone({ + claude, cursor, dryRun, force, @@ -142,14 +165,6 @@ https://paralleldrive.com }); if (!result.success) { - // Create a proper error with cause for handleErrors - const error = new Error(result.error.message, { - cause: result.error.cause || { - code: result.error.code || "UNEXPECTED_ERROR", - }, - }); - - // Use handleErrors instead of manual switching try { handleCliErrors({ CloneError: ({ message, cause }) => { @@ -176,11 +191,11 @@ https://paralleldrive.com "πŸ’‘ Try using --force to overwrite existing files", ); }, - })(error); + })(result.error); } catch { - // Fallback for unexpected errors - console.error(`❌ Unexpected Error: ${result.error.message}`); - if (verbose && result.error.cause) { + // Fallback for unexpected errors (e.g. an error without a cause code) + console.error(`❌ Unexpected Error: ${result.error?.message}`); + if (verbose && result.error?.cause) { console.error("πŸ” Caused by:", result.error.cause); } } diff --git a/bin/cli-help-e2e.test.js b/bin/cli-help-e2e.test.js index 3029c8e7..843334f5 100644 --- a/bin/cli-help-e2e.test.js +++ b/bin/cli-help-e2e.test.js @@ -147,11 +147,12 @@ describe("CLI help command", () => { assert({ given: "CLI help command is run", - should: "show Quick Start section with README format", + should: "show Quick Start section with Cursor and Claude Code options", actual: stdout.includes("Quick Start") && stdout.includes("To install for Cursor:") && - stdout.includes("Install without Cursor integration:"), + stdout.includes("To install for Claude Code:") && + stdout.includes("Install without editor integration:"), expected: true, }); }); diff --git a/lib/agents-index-e2e.test.js b/lib/agents-index-e2e.test.js index 91a9c9f6..6d894732 100644 --- a/lib/agents-index-e2e.test.js +++ b/lib/agents-index-e2e.test.js @@ -67,6 +67,7 @@ Read index.md files (auto-generated from frontmatter). Only use root index until needed. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(agentsPath, customContent); diff --git a/lib/agents-md.d.ts b/lib/agents-md.d.ts index e2aa0c85..43ad3bfb 100644 --- a/lib/agents-md.d.ts +++ b/lib/agents-md.d.ts @@ -2,7 +2,7 @@ * AGENTS.md file management for AI agent directives */ -/** Result of ensureAgentsMd operation */ +/** Result of ensureAgentsMd or ensureClaudeMd operation */ export interface AgentsMdResult { /** Action taken: "created", "appended", or "unchanged" */ action: "created" | "appended" | "unchanged"; @@ -81,17 +81,44 @@ export function appendDirectives( ): Promise; /** - * Ensure AGENTS.md exists with required directives - * - * - If AGENTS.md does not exist, creates it with standard content - * - If AGENTS.md exists but missing directives, appends them - * - If AGENTS.md exists with all directives, does nothing + * Ensure AGENTS.md exists with required directives. + * - If AGENTS.md does not exist, creates it with standard content. + * - If AGENTS.md exists but is missing directives, appends them. + * - If AGENTS.md exists with all directives, does nothing. * * @param targetBase - Base directory for AGENTS.md * @returns Result indicating action taken - * - * @example - * const result = await ensureAgentsMd('/path/to/project'); - * // result.action: "created" | "appended" | "unchanged" */ export function ensureAgentsMd(targetBase: string): Promise; + +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present β†’ create with agentsMdContent. + * - Present but missing AGENTS.md reference and incomplete directives β†’ append pointer. + * - Present with all directives or AGENTS.md reference β†’ leave unchanged. + * + * @param targetBase - Base directory for CLAUDE.md + * @returns Result indicating action taken + */ +export function ensureClaudeMd(targetBase: string): Promise; + +/** Result of a single file sync in syncRootAgentFiles */ +export interface SyncFileResult { + /** Filename that was processed (e.g. "AGENTS.md" or "CLAUDE.md") */ + file: string; + /** Action taken: "created", "updated", or "unchanged" */ + action: "created" | "updated" | "unchanged"; +} + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated. + * + * @param targetBase - Base directory containing AGENTS.md and CLAUDE.md + * @returns Array of results for each file processed + */ +export function syncRootAgentFiles( + targetBase: string, +): Promise; diff --git a/lib/agents-md.js b/lib/agents-md.js index a61e325f..75d40fe6 100644 --- a/lib/agents-md.js +++ b/lib/agents-md.js @@ -20,6 +20,7 @@ const requiredDirectives = [ "vision", // Vision document requirement "conflict", // Conflict resolution "generated", // Auto-generated files warning + "test:e2e", // E2E test instruction before committing ]; // The content for AGENTS.md @@ -72,6 +73,22 @@ Never proceed with a task that contradicts the vision without explicit user appr Project-specific customization lives in \`aidd-custom/\`. Before starting work, read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context. + +## Task Index + +fix bug => /aidd-fix + +## Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). E2E tests are excluded because they perform real installs and can take several minutes. + +**Before committing**, always run the full suite manually: + +\`\`\`sh +npm run test:e2e +\`\`\` + +This ensures integration behaviour is verified without blocking the commit hook or timing out the release pipeline. `; /** @@ -181,6 +198,16 @@ read \`aidd-custom/index.md\` to discover available project-specific skills, and read \`aidd-custom/config.yml\` to load configuration into context.`, keywords: ["aidd-custom"], }, + { + content: `### Testing + +The pre-commit hook runs unit tests only (\`npm run test:unit\`). Run the full E2E suite manually before committing: + +\`\`\`sh +npm run test:e2e +\`\`\``, + keywords: ["test:e2e"], + }, ]; /** @@ -248,15 +275,110 @@ const ensureAgentsMd = async (targetBase) => { }; }; +/** + * Ensure CLAUDE.md exists and references AGENTS.md. + * - Not present β†’ create with agentsMdContent. + * - Present but missing AGENTS.md reference and incomplete directives β†’ append pointer. + * - Present with all directives or AGENTS.md reference β†’ leave unchanged. + */ +const ensureClaudeMd = async (targetBase) => { + const claudePath = path.join(targetBase, "CLAUDE.md"); + const exists = await fs.pathExists(claudePath); + + if (!exists) { + try { + await fs.writeFile(claudePath, agentsMdContent, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to write CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "created", + message: "Created CLAUDE.md with AI agent guidelines", + }; + } + + let existingContent; + try { + existingContent = await fs.readFile(claudePath, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to read CLAUDE.md: ${claudePath}`, + }); + } + + // Treat the file as complete when it already has the full agent directives + // (e.g. created by a previous install) OR explicitly references AGENTS.md. + if ( + hasAllDirectives(existingContent) || + existingContent.includes("AGENTS.md") + ) { + return { + action: "unchanged", + message: "CLAUDE.md already contains agent guidelines", + }; + } + + const appendLine = + "\n\n> **AI agents**: see [AGENTS.md](./AGENTS.md) for project-specific agent guidelines.\n"; + try { + await fs.writeFile(claudePath, existingContent + appendLine, "utf-8"); + } catch (originalError) { + throw createError({ + ...AgentsFileError, + cause: originalError, + message: `Failed to append to CLAUDE.md: ${claudePath}`, + }); + } + return { + action: "appended", + message: "Appended AGENTS.md reference to existing CLAUDE.md", + }; +}; + +/** + * Overwrite AGENTS.md and CLAUDE.md with the current agentsMdContent template + * if either file is missing or its content differs from the template. + * Used by the pre-commit hook to keep committed copies in sync whenever + * agentsMdContent is updated, exactly as ai\/**\/index.md files are regenerated. + * Returns an array of { file, action } objects. + */ +const syncRootAgentFiles = async (targetBase) => { + const files = ["AGENTS.md", "CLAUDE.md"]; + return Promise.all( + files.map(async (filename) => { + const filePath = path.join(targetBase, filename); + const exists = await fs.pathExists(filePath); + if (!exists) { + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "created", file: filename }; + } + const existing = await fs.readFile(filePath, "utf-8"); + if (existing === agentsMdContent) { + return { action: "unchanged", file: filename }; + } + await fs.writeFile(filePath, agentsMdContent, "utf-8"); + return { action: "updated", file: filename }; + }), + ); +}; + export { - ensureAgentsMd, agentsFileExists, - readAgentsFile, - hasAllDirectives, - getMissingDirectives, - writeAgentsFile, - appendDirectives, agentsMdContent, - requiredDirectives, + appendDirectives, directiveAppendSections, + ensureAgentsMd, + ensureClaudeMd, + getMissingDirectives, + hasAllDirectives, + readAgentsFile, + requiredDirectives, + syncRootAgentFiles, + writeAgentsFile, }; diff --git a/lib/agents-md.test.js b/lib/agents-md.test.js index b33c8131..f3401b2b 100644 --- a/lib/agents-md.test.js +++ b/lib/agents-md.test.js @@ -11,11 +11,15 @@ import { appendDirectives, directiveAppendSections, ensureAgentsMd, + ensureClaudeMd, getMissingDirectives, hasAllDirectives, requiredDirectives, + syncRootAgentFiles, } from "./agents-md.js"; +/** @typedef {import('./agents-md.js').SyncFileResult} SyncFileResult */ + describe("agents-md", () => { let tempDir = ""; @@ -58,6 +62,7 @@ describe("agents-md", () => { Root Index from base VISION document requirement CONFLICT resolution + Run NPM RUN TEST:E2E before committing AIDD-CUSTOM folder customization `; @@ -291,6 +296,7 @@ Read index.md files (auto-generated). Only use root index until you need more. Always read the vision document first. Report conflict resolution to the user. +Run npm run test:e2e before committing. Check aidd-custom/ for project-specific skills and configuration. `; await fs.writeFile(path.join(tempDir, "AGENTS.md"), customContent); @@ -363,6 +369,14 @@ Check aidd-custom/ for project-specific skills and configuration. actual: requiredDirectives.includes("aidd-custom"), expected: true, }); + + assert({ + given: "requiredDirectives constant", + should: + "include test:e2e directive so agents know to run E2E tests before committing", + actual: requiredDirectives.includes("test:e2e"), + expected: true, + }); }); }); @@ -443,5 +457,166 @@ Check aidd-custom/ for project-specific skills and configuration. expected: true, }); }); + + test("includes the e2e test directive keyword", () => { + const testingSection = directiveAppendSections.find(({ keywords }) => + keywords.includes("test:e2e"), + ); + + assert({ + given: "directiveAppendSections", + should: "have a section whose keywords include test:e2e", + actual: testingSection?.keywords, + expected: ["test:e2e"], + }); + }); + }); + + describe("agentsMdContent e2e instruction", () => { + test("instructs agents to run test:e2e and explains pre-commit hook exclusion", () => { + assert({ + given: "agentsMdContent", + should: + "include the test:e2e command and explain the pre-commit hook exclusion", + actual: { + hasCommand: agentsMdContent.includes("npm run test:e2e"), + hasExplanation: agentsMdContent.includes("pre-commit hook"), + }, + expected: { hasCommand: true, hasExplanation: true }, + }); + }); + }); +}); + +describe("syncRootAgentFiles", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-sync-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates both files when neither exists", async () => { + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "neither AGENTS.md nor CLAUDE.md exists", + should: "create both files with the current template", + actual: { + actions: results.map(({ action }) => action), + agentsContent: await fs.readFile( + path.join(tempDir, "AGENTS.md"), + "utf-8", + ), + }, + expected: { + actions: ["created", "created"], + agentsContent: agentsMdContent, + }, + }); + }); + + test("returns unchanged when both files already match the template", async () => { + await fs.writeFile( + path.join(tempDir, "AGENTS.md"), + agentsMdContent, + "utf-8", + ); + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + agentsMdContent, + "utf-8", + ); + + const results = await syncRootAgentFiles(tempDir); + + assert({ + given: "both files already match the current template", + should: "report unchanged for both", + actual: results.map(({ action }) => action), + expected: ["unchanged", "unchanged"], + }); + }); + + test("overwrites a stale file whose content differs from the current template", async () => { + await fs.writeFile( + path.join(tempDir, "AGENTS.md"), + "# Old content", + "utf-8", + ); + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + agentsMdContent, + "utf-8", + ); + + const results = await syncRootAgentFiles(tempDir); + const agentsResult = results.find(({ file }) => file === "AGENTS.md"); + + assert({ + given: "AGENTS.md has stale content", + should: "report updated action and overwrite with the current template", + actual: { + action: agentsResult?.action, + content: await fs.readFile(path.join(tempDir, "AGENTS.md"), "utf-8"), + }, + expected: { action: "updated", content: agentsMdContent }, + }); + }); +}); + +describe("ensureClaudeMd", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-claude-test-${Date.now()}`); + await fs.ensureDir(tempDir); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("creates CLAUDE.md with agentsMdContent when no CLAUDE.md exists", async () => { + const result = await ensureClaudeMd(tempDir); + + assert({ + given: "no CLAUDE.md exists", + should: "report created and write the agentsMdContent template", + actual: { + action: result.action, + content: await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"), + }, + expected: { action: "created", content: agentsMdContent }, + }); + }); + + test("appends AGENTS.md reference when CLAUDE.md exists without it", async () => { + await fs.writeFile( + path.join(tempDir, "CLAUDE.md"), + "# My CLAUDE.md\n\nCustom content.", + "utf-8", + ); + + const result = await ensureClaudeMd(tempDir); + const content = await fs.readFile(path.join(tempDir, "CLAUDE.md"), "utf-8"); + + assert({ + given: "CLAUDE.md without AGENTS.md reference or directives", + should: "report appended and append a pointer to AGENTS.md", + actual: { + action: result.action, + content, + }, + expected: { + action: "appended", + content: + "# My CLAUDE.md\n\nCustom content.\n\n> **AI agents**: see [AGENTS.md](./AGENTS.md) for project-specific agent guidelines.\n", + }, + }); }); }); diff --git a/lib/cli-core.d.ts b/lib/cli-core.d.ts index 7c675066..d4dd9cb9 100644 --- a/lib/cli-core.d.ts +++ b/lib/cli-core.d.ts @@ -2,6 +2,8 @@ * CLI core utilities for the aidd clone operation */ +import type { CausedError } from "./error-causes.js"; + /** CLI output logger interface */ export interface Logger { cyan: (msg: string) => void; @@ -21,19 +23,11 @@ export interface ResolvedPaths { targetBase: string; } -/** Error detail shape when executeClone fails */ -export interface ExecuteCloneError { - cause?: unknown; - code?: string; - message: string; - type?: string; -} - /** Result returned by executeClone β€” covers success and error shapes */ export type ExecuteCloneResult = | { paths: ResolvedPaths; success: true } | { dryRun: true; success: true } - | { error: ExecuteCloneError; success: false }; + | { error: CausedError; success: false }; /** * Create aidd-custom/config.yml with default project settings @@ -90,15 +84,5 @@ export function executeClone(options?: { dryRun?: boolean; verbose?: boolean; cursor?: boolean; + claude?: boolean; }): Promise; - -/** - * Error handler factory for CLI structured errors. - * Accepts a map of handlers keyed by error name and returns a function - * that routes a thrown error to the matching handler. - */ -export const handleCliErrors: (handlers: { - CloneError: (error: Error) => unknown; - FileSystemError: (error: Error) => unknown; - ValidationError: (error: Error) => unknown; -}) => (error: Error) => unknown; diff --git a/lib/cli-core.js b/lib/cli-core.js index f58eeae3..04fc50d7 100644 --- a/lib/cli-core.js +++ b/lib/cli-core.js @@ -2,32 +2,20 @@ import path from "path"; import process from "process"; import { fileURLToPath } from "url"; import chalk from "chalk"; -import { createError, errorCauses } from "error-causes"; +import { createError } from "error-causes"; import fs from "fs-extra"; -import { ensureAgentsMd } from "./agents-md.js"; +import { ensureAgentsMd, ensureClaudeMd } from "./agents-md.js"; import { generateIndexRecursive } from "./index-generator.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const CloneError = { code: "CLONE_ERROR", name: "CloneError" }; +const FileSystemError = { code: "FILESYSTEM_ERROR", name: "FileSystemError" }; +const ValidationError = { code: "VALIDATION_ERROR", name: "ValidationError" }; -// Error causes definition using error-causes library -const [cliErrors, handleCliErrors] = errorCauses({ - CloneError: { - code: "CLONE_ERROR", - message: "AI folder cloning failed", - }, - FileSystemError: { - code: "FILESYSTEM_ERROR", - message: "File system operation failed", - }, - ValidationError: { - code: "VALIDATION_ERROR", - message: "Input validation failed", - }, -}); +import { createSymlink } from "./symlinks.js"; -const { ValidationError, FileSystemError, CloneError } = cliErrors; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); // Pure path resolution functions const resolvePaths = ({ @@ -160,43 +148,6 @@ const createAiddCustomConfig = } }; -const createCursorSymlink = - ({ targetBase, force = false }) => - async () => { - const cursorPath = path.join(targetBase, ".cursor"); - const aiRelativePath = "ai"; - - try { - // Check if .cursor already exists - const cursorExists = await fs.pathExists(cursorPath); - - if (cursorExists) { - if (!force) { - throw createError({ - ...ValidationError, - message: ".cursor already exists (use --force to overwrite)", - }); - } - // Remove existing .cursor (file or symlink) - await fs.remove(cursorPath); - } - - // Create symlink - await fs.symlink(aiRelativePath, cursorPath); - } catch (originalError) { - // If it's already our validation error, re-throw - if (originalError.cause?.code === "VALIDATION_ERROR") { - throw originalError; - } - - throw createError({ - ...FileSystemError, - cause: originalError, - message: `Failed to create .cursor symlink: ${originalError.message}`, - }); - } - }; - // Output functions const createLogger = ({ verbose = false, dryRun = false } = {}) => ({ cyan: (msg) => console.log(chalk.cyan(msg)), @@ -246,6 +197,7 @@ const executeClone = async ({ dryRun = false, verbose = false, cursor = false, + claude = false, } = {}) => { try { const logger = createLogger({ dryRun, verbose }); @@ -288,6 +240,11 @@ const executeClone = async ({ const agentsResult = await ensureAgentsMd(paths.targetBase); verbose && logger.verbose(`AGENTS.md: ${agentsResult.message}`); + // Ensure CLAUDE.md exists (Claude Code reads this for project guidelines). + verbose && logger.info("Setting up CLAUDE.md..."); + const claudeResult = await ensureClaudeMd(paths.targetBase); + verbose && logger.verbose(`CLAUDE.md: ${claudeResult.message}`); + // Create aidd-custom/config.yml with project defaults. // Never force-overwrite: config.yml holds user preferences, not framework code. verbose && logger.info("Creating aidd-custom/config.yml..."); @@ -302,10 +259,22 @@ const executeClone = async ({ const customDir = path.join(paths.targetBase, "aidd-custom"); await generateIndexRecursive(customDir); - // Create cursor symlink if requested + // Create editor symlinks if requested if (cursor) { verbose && logger.info("Creating .cursor symlink..."); - await createCursorSymlink({ force, targetBase: paths.targetBase })(); + await createSymlink({ + force, + name: ".cursor", + targetBase: paths.targetBase, + })(); + } + if (claude) { + verbose && logger.info("Creating .claude symlink..."); + await createSymlink({ + force, + name: ".claude", + targetBase: paths.targetBase, + })(); } // Success output @@ -313,27 +282,9 @@ const executeClone = async ({ return { paths, success: true }; } catch (error) { - // Structured error handling using error causes - if (error.cause) { - return { - error: { - cause: error.cause, - code: error.cause?.code, - message: error.message, - }, - success: false, - }; - } - - // Handle unexpected errors - return { - error: { - cause: error.cause, - message: error.message, - type: "unexpected", - }, - success: false, - }; + // Return the original Error object so callers can pass it directly to + // handleCliErrors() without reconstructing a wrapper Error. + return { error, success: false }; } }; @@ -341,7 +292,6 @@ export { createAiddCustomConfig, createLogger, executeClone, - handleCliErrors, resolvePaths, validateSource, validateTarget, diff --git a/lib/cli-core.test.js b/lib/cli-core.test.js index f9ebe080..6684358e 100644 --- a/lib/cli-core.test.js +++ b/lib/cli-core.test.js @@ -204,3 +204,36 @@ describe("executeClone dry-run config.yml status", () => { }); }); }); + +describe("executeClone error result shape", () => { + let tempDir = ""; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `aidd-cli-core-test-${Date.now()}`); + await fs.ensureDir(tempDir); + // Pre-create an ai/ folder so the ai/ already-exists validation fires + await fs.ensureDir(path.join(tempDir, "ai")); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + test("returns failure with a descriptive error message when clone fails", async () => { + /** @type {any} */ + const result = await executeClone({ targetDirectory: tempDir }); + + assert({ + given: "the target already contains an ai/ installation", + should: "report failure with an actionable error message", + actual: { + success: result.success, + message: result.error?.message, + }, + expected: { + success: false, + message: "ai/ folder already exists (use --force to overwrite)", + }, + }); + }); +}); diff --git a/lib/error-causes.d.ts b/lib/error-causes.d.ts new file mode 100644 index 00000000..b16cce33 --- /dev/null +++ b/lib/error-causes.d.ts @@ -0,0 +1,17 @@ +/** Shape of every error created by the error-causes library via createError() */ +export interface CausedError extends Error { + cause: { + code: string; + name: string; + message: string; + [key: string]: unknown; + }; +} + +/** + * Route a thrown CausedError to the matching handler by cause name. + * The returned function is passed directly to .catch() or called with a result.error. + */ +export const handleCliErrors: ( + handlers: Record unknown>, +) => (error: Error) => unknown; diff --git a/lib/error-causes.js b/lib/error-causes.js new file mode 100644 index 00000000..99afb2b7 --- /dev/null +++ b/lib/error-causes.js @@ -0,0 +1,15 @@ +import { errorCauses } from "error-causes"; + +const [, handleCliErrors] = errorCauses({ + CloneError: { code: "CLONE_ERROR", message: "AI folder cloning failed" }, + FileSystemError: { + code: "FILESYSTEM_ERROR", + message: "File system operation failed", + }, + ValidationError: { + code: "VALIDATION_ERROR", + message: "Input validation failed", + }, +}); + +export { handleCliErrors }; diff --git a/lib/exports.test.js b/lib/exports.test.js index 66bb008e..65e2efe8 100644 --- a/lib/exports.test.js +++ b/lib/exports.test.js @@ -127,4 +127,17 @@ describe("package.json exports configuration", () => { expected: true, }); }); + + test("declares Node.js engine requirement", async () => { + const pkg = await import("../package.json", { with: { type: "json" } }); + const nodeEngine = pkg.default.engines?.node; + + assert({ + given: "a user on Node < 18", + should: + "declare engines.node to surface a clear version error at install time", + actual: nodeEngine, + expected: ">=18", + }); + }); }); diff --git a/lib/index-generator.js b/lib/index-generator.js index 7399ccaa..6b3a329f 100644 --- a/lib/index-generator.js +++ b/lib/index-generator.js @@ -122,19 +122,15 @@ const generateFileEntry = async (dirPath, filename) => { let entry = `### ${escapeMarkdownLink(title)}\n\n`; entry += `**File:** \`${escapedFilename}\`\n\n`; - if (frontmatter) { - if (frontmatter.description) { - entry += `${frontmatter.description}\n\n`; - } - if (frontmatter.globs) { - entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; - } - if (frontmatter.alwaysApply === true) { - entry += `**Always active**\n\n`; - } - } else { - // No frontmatter - just note the file exists - entry += `*No description available*\n\n`; + if (frontmatter?.description) { + entry += `${frontmatter.description}\n\n`; + } + + if (frontmatter?.globs) { + entry += `**Applies to:** \`${frontmatter.globs}\`\n\n`; + } + if (frontmatter?.alwaysApply === true) { + entry += `**Always active**\n\n`; } return entry; diff --git a/lib/index-generator.test.js b/lib/index-generator.test.js index 8de96d83..5e0aa0ac 100644 --- a/lib/index-generator.test.js +++ b/lib/index-generator.test.js @@ -203,8 +203,10 @@ Content here.`; assert({ given: "file without frontmatter", - should: "note no description available", - actual: content.includes("No description available"), + should: "include the file title without a description placeholder", + actual: + content.includes("Plain File") && + !content.includes("No description available"), expected: true, }); }); diff --git a/lib/symlinks.js b/lib/symlinks.js new file mode 100644 index 00000000..ef6bc6db --- /dev/null +++ b/lib/symlinks.js @@ -0,0 +1,42 @@ +import path from "path"; +import { createError } from "error-causes"; +import fs from "fs-extra"; + +const FileSystemError = { code: "FILESYSTEM_ERROR", name: "FileSystemError" }; +const ValidationError = { code: "VALIDATION_ERROR", name: "ValidationError" }; + +/** + * Create a symlink at `/` pointing to the relative path `ai`. + * Handles `.cursor`, `.claude`, or any future editor symlink. + * + * Returns an async thunk so call-sites compose it with other thunks: + * await createSymlink({ name: '.claude', targetBase, force })() + * + * @param {{ name: string, targetBase: string, force?: boolean }} options + */ +const createSymlink = + ({ name, targetBase, force = false }) => + async () => { + const symlinkPath = path.join(targetBase, name); + const exists = await fs.pathExists(symlinkPath); + + if (exists && !force) { + throw createError({ + ...ValidationError, + message: `${name} already exists (use --force to overwrite)`, + }); + } + + try { + if (exists) await fs.remove(symlinkPath); + await fs.symlink("ai", symlinkPath); + } catch (/** @type {any} */ originalError) { + throw createError({ + ...FileSystemError, + cause: originalError, + message: `Failed to create ${name} symlink: ${originalError.message}`, + }); + } + }; + +export { createSymlink }; diff --git a/lib/symlinks.test.js b/lib/symlinks.test.js new file mode 100644 index 00000000..61ac416c --- /dev/null +++ b/lib/symlinks.test.js @@ -0,0 +1,158 @@ +// @ts-check +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs-extra"; +import { assert } from "riteway/vitest"; +import { afterEach, beforeEach, describe, test } from "vitest"; + +import { executeClone } from "./cli-core.js"; +import { createSymlink } from "./symlinks.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("createSymlink error dispatch", () => { + const tempTestDir = path.join(__dirname, "temp-symlink-errors-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("existing target without force throws a CausedError", async () => { + await fs.writeFile(path.join(tempTestDir, ".cursor"), "existing"); + + let caughtError; + try { + await createSymlink({ + name: ".cursor", + targetBase: tempTestDir, + force: false, + })(); + } catch (e) { + caughtError = /** @type {any} */ (e); + } + + assert({ + given: "an existing target path and no overwrite permission", + should: "throw with an actionable error message", + actual: caughtError?.message, + expected: ".cursor already exists (use --force to overwrite)", + }); + }); +}); + +describe("claude symlink functionality", () => { + const tempTestDir = path.join(__dirname, "temp-claude-test"); + const claudePath = path.join(tempTestDir, ".claude"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("claude option false should not create symlink", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: false }); + + assert({ + given: "claude option is false", + should: "not create .claude symlink", + actual: await fs.pathExists(claudePath), + expected: false, + }); + }); + + test("claude option true should create symlink pointing to ai/", async () => { + await executeClone({ targetDirectory: tempTestDir, claude: true }); + + const symlinkTarget = await fs.readlink(claudePath); + + assert({ + given: "claude option is true", + should: "create .claude as a symbolic link pointing to ai/", + actual: { + isSymlink: (await fs.lstat(claudePath)).isSymbolicLink(), + target: symlinkTarget, + }, + expected: { isSymlink: true, target: "ai" }, + }); + }); + + test("dry run with claude should not create symlink and still succeed", async () => { + const result = await executeClone({ + targetDirectory: tempTestDir, + claude: true, + dryRun: true, + }); + + assert({ + given: "dry run mode with claude option", + should: "not create the symlink and return success", + actual: { + exists: await fs.pathExists(claudePath), + success: result.success, + }, + expected: { exists: false, success: true }, + }); + }); + + test("existing .claude file should be replaced with force", async () => { + await fs.writeFile(claudePath, "existing content"); + + await executeClone({ + targetDirectory: tempTestDir, + claude: true, + force: true, + }); + + assert({ + given: "existing .claude file with force option", + should: "replace with symlink", + actual: (await fs.lstat(claudePath)).isSymbolicLink(), + expected: true, + }); + }); +}); + +describe("cursor and claude symlinks together", () => { + const tempTestDir = path.join(__dirname, "temp-both-symlinks-test"); + + beforeEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + await fs.ensureDir(tempTestDir); + }); + + afterEach(async () => { + if (await fs.pathExists(tempTestDir)) await fs.remove(tempTestDir); + }); + + test("cursor and claude both true should create both symlinks", async () => { + await executeClone({ + targetDirectory: tempTestDir, + cursor: true, + claude: true, + }); + + assert({ + given: "both cursor and claude options are true", + should: "create both symlinks pointing to ai/", + actual: { + cursor: ( + await fs.lstat(path.join(tempTestDir, ".cursor")) + ).isSymbolicLink(), + claude: ( + await fs.lstat(path.join(tempTestDir, ".claude")) + ).isSymbolicLink(), + }, + expected: { cursor: true, claude: true }, + }); + }); +}); diff --git a/package.json b/package.json index 1d6ff4c2..365f84fc 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "author": "ParallelDrive", + "engines": { + "node": ">=18" + }, "bin": { "aidd": "bin/aidd.js" },