From 88f04155e4802165648f0c862b450eb16b3a57da Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 15 Feb 2026 06:19:44 +0000 Subject: [PATCH 1/2] refactor(init): remove auto-generated CLAUDE.md/AGENTS.md and add /init skill Replace the static CLAUDE.md/AGENTS.md template files that were copied during `atomic init` with a new `/init` builtin skill that dynamically explores the codebase and generates populated project documentation. - Remove CLAUDE.md, AGENTS.md, MODULE_DOCUMENTATION.md, and .atomic/settings.json template files - Remove additional_files/preserve_files references from agent configs - Add `/init` builtin skill that uses sub-agents to discover project metadata and generate tailored CLAUDE.md/AGENTS.md - Reformat skill-commands.ts to consistent 4-space indentation Assistant-model: Claude Code --- .atomic/settings.json | 6 - AGENTS.md | 125 -- CLAUDE.md | 125 -- MODULE_DOCUMENTATION.md | 1827 ----------------------------- package.json | 4 +- src/cli.ts | 2 +- src/commands/init.ts | 8 +- src/config.ts | 12 +- src/ui/commands/skill-commands.ts | 912 ++++++++------ src/utils/copy.ts | 1 - 10 files changed, 539 insertions(+), 2483 deletions(-) delete mode 100644 .atomic/settings.json delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 MODULE_DOCUMENTATION.md diff --git a/.atomic/settings.json b/.atomic/settings.json deleted file mode 100644 index 5b583afe..00000000 --- a/.atomic/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": { - "claude": "opus", - "copilot": "github-copilot/claude-opus-4.6-fast" - } -} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6ca56057..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,125 +0,0 @@ -# [PROJECT_NAME] - -## Overview -[1-2 sentences describing the project purpose] - -## Monorepo Structure -| Path | Type | Purpose | -| ----------------- | ----------- | --------------------------- | -| `apps/web` | Next.js App | Main web application | -| `apps/api` | FastAPI | REST API service | -| `packages/shared` | Library | Shared types and utilities | -| `packages/db` | Library | Database client and schemas | - -## Quick Reference - -### Commands by Workspace -```bash -# Root (orchestration) -pnpm dev # Start all services -pnpm build # Build everything - -# Web App (apps/web) -pnpm --filter web dev # Start web only -pnpm --filter web test # Test web only - -# API (apps/api) -pnpm --filter api dev # Start API only -pnpm --filter api test # Test API only -``` - -### Environment -- Copy `.env.example` → `.env.local` for local development -- Required vars: `DATABASE_URL`, `API_KEY` - -## Progressive Disclosure -Read relevant docs before starting: -- `docs/onboarding.md` — First-time setup -- `docs/architecture.md` — System design decisions -- `docs/[app-name]/README.md` — App-specific details - -## Universal Rules -1. Run `pnpm typecheck && pnpm lint && pnpm test` before commits -2. Keep PRs focused on a single concern -3. Update types in `packages/shared` when changing contracts -``` - ---- - -## Anti-Patterns to Avoid - -### ❌ Don't: Inline Code Style Guidelines -```markdown - -## Code Style -- Use 2 spaces for indentation -- Always use semicolons -- Prefer const over let -- Use arrow functions for callbacks -- Maximum line length: 100 characters -... -``` - -### ✅ Do: Reference Tooling -```markdown -## Code Quality -Formatting and linting are handled by automated tools: -- `pnpm lint` — ESLint + Prettier -- `pnpm format` — Auto-fix formatting - -Run before committing. Don't manually check style—let tools do it. -``` - ---- - -### ❌ Don't: Include Task-Specific Instructions -```markdown - -## Database Migrations -When creating a new migration: -1. Run `prisma migrate dev --name descriptive_name` -2. Update the schema in `prisma/schema.prisma` -3. Run `prisma generate` to update the client -4. Add seed data if necessary in `prisma/seed.ts` -... -``` - -### ✅ Do: Use Progressive Disclosure -```markdown -## Documentation -| Topic | Location | -| --------------------- | -------------------- | -| Database & migrations | `docs/database.md` | -| API design | `docs/api.md` | -| Deployment | `docs/deployment.md` | - -Read relevant docs before starting work on those areas. -``` - ---- - -### ❌ Don't: Auto-Generate with /init -The `/init` command produces generic, bloated files. - -### ✅ Do: Craft It Manually -Spend time thinking about each line. Ask yourself: -- Is this universally applicable to ALL tasks? -- Can the agent infer this from the codebase itself? -- Would a linter/formatter handle this better? -- Can I point to a doc instead of inlining this? - ---- - -## Optimization Checklist - -Before finalizing verify: - -- [ ] **Under 100 lines** (ideally under 60) -- [ ] **Every instruction is universally applicable** to all tasks -- [ ] **No code style rules** (use linters/formatters instead) -- [ ] **No task-specific instructions** (use progressive disclosure) -- [ ] **No code snippets** (use `file:line` pointers) -- [ ] **Clear verification commands** that the agent can run -- [ ] **Progressive disclosure table** pointing to detailed docs -- [ ] **Minimal project structure** (just enough to navigate) - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6ca56057..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,125 +0,0 @@ -# [PROJECT_NAME] - -## Overview -[1-2 sentences describing the project purpose] - -## Monorepo Structure -| Path | Type | Purpose | -| ----------------- | ----------- | --------------------------- | -| `apps/web` | Next.js App | Main web application | -| `apps/api` | FastAPI | REST API service | -| `packages/shared` | Library | Shared types and utilities | -| `packages/db` | Library | Database client and schemas | - -## Quick Reference - -### Commands by Workspace -```bash -# Root (orchestration) -pnpm dev # Start all services -pnpm build # Build everything - -# Web App (apps/web) -pnpm --filter web dev # Start web only -pnpm --filter web test # Test web only - -# API (apps/api) -pnpm --filter api dev # Start API only -pnpm --filter api test # Test API only -``` - -### Environment -- Copy `.env.example` → `.env.local` for local development -- Required vars: `DATABASE_URL`, `API_KEY` - -## Progressive Disclosure -Read relevant docs before starting: -- `docs/onboarding.md` — First-time setup -- `docs/architecture.md` — System design decisions -- `docs/[app-name]/README.md` — App-specific details - -## Universal Rules -1. Run `pnpm typecheck && pnpm lint && pnpm test` before commits -2. Keep PRs focused on a single concern -3. Update types in `packages/shared` when changing contracts -``` - ---- - -## Anti-Patterns to Avoid - -### ❌ Don't: Inline Code Style Guidelines -```markdown - -## Code Style -- Use 2 spaces for indentation -- Always use semicolons -- Prefer const over let -- Use arrow functions for callbacks -- Maximum line length: 100 characters -... -``` - -### ✅ Do: Reference Tooling -```markdown -## Code Quality -Formatting and linting are handled by automated tools: -- `pnpm lint` — ESLint + Prettier -- `pnpm format` — Auto-fix formatting - -Run before committing. Don't manually check style—let tools do it. -``` - ---- - -### ❌ Don't: Include Task-Specific Instructions -```markdown - -## Database Migrations -When creating a new migration: -1. Run `prisma migrate dev --name descriptive_name` -2. Update the schema in `prisma/schema.prisma` -3. Run `prisma generate` to update the client -4. Add seed data if necessary in `prisma/seed.ts` -... -``` - -### ✅ Do: Use Progressive Disclosure -```markdown -## Documentation -| Topic | Location | -| --------------------- | -------------------- | -| Database & migrations | `docs/database.md` | -| API design | `docs/api.md` | -| Deployment | `docs/deployment.md` | - -Read relevant docs before starting work on those areas. -``` - ---- - -### ❌ Don't: Auto-Generate with /init -The `/init` command produces generic, bloated files. - -### ✅ Do: Craft It Manually -Spend time thinking about each line. Ask yourself: -- Is this universally applicable to ALL tasks? -- Can the agent infer this from the codebase itself? -- Would a linter/formatter handle this better? -- Can I point to a doc instead of inlining this? - ---- - -## Optimization Checklist - -Before finalizing verify: - -- [ ] **Under 100 lines** (ideally under 60) -- [ ] **Every instruction is universally applicable** to all tasks -- [ ] **No code style rules** (use linters/formatters instead) -- [ ] **No task-specific instructions** (use progressive disclosure) -- [ ] **No code snippets** (use `file:line` pointers) -- [ ] **Clear verification commands** that the agent can run -- [ ] **Progressive disclosure table** pointing to detailed docs -- [ ] **Minimal project structure** (just enough to navigate) - diff --git a/MODULE_DOCUMENTATION.md b/MODULE_DOCUMENTATION.md deleted file mode 100644 index 2e738be6..00000000 --- a/MODULE_DOCUMENTATION.md +++ /dev/null @@ -1,1827 +0,0 @@ -# Atomic CLI - Complete Module Documentation - -This document provides a comprehensive overview of every file in the `src/` directory, documenting: -1. Each file's purpose -2. Key exported functions/classes/types -3. Test coverage status -4. Testable logic for untested modules - ---- - -## Top-Level Files - -### `cli.ts` (280 lines) -**Purpose:** Main CLI entry point using Commander.js for argument parsing - -**Key Exports:** -- `createProgram()` - Creates and configures the Commander program -- `program` - The main program instance -- `spawnTelemetryUpload()` - Spawns detached background process for telemetry upload -- `main()` - Main async entry point - -**Commands Configured:** -- `init` - Interactive agent setup (default command) -- `chat` - Start interactive chat with agent -- `config set` - Set configuration values -- `update` - Self-update binary installations -- `uninstall` - Remove binary installation -- `upload-telemetry` - Hidden internal command - -**Tests:** ❌ None -**Testable Logic:** -- `createProgram()` - Returns configured Commander instance (test command registration, options, help text) -- `spawnTelemetryUpload()` - Environment variable checks, process spawning (can be tested with mocks) -- Command validation logic (agent choices, theme validation) -- Error output formatting and colored output - ---- - -### `config.ts` (159 lines) -**Purpose:** Agent and SCM (Source Control Management) configuration definitions - -**Key Exports:** -- `AgentConfig` interface - Configuration structure for agents -- `AGENT_CONFIG` - Record of claude/opencode/copilot configurations -- `AgentKey` type - Union type for valid agent keys -- `ScmConfig` interface - Source control configuration structure -- `SCM_CONFIG` - Record of github/sapling-phabricator configurations -- `SourceControlType` type - Union type for valid SCM types -- Helper functions: `isValidAgent()`, `getAgentConfig()`, `getAgentKeys()`, `isValidScm()`, `getScmConfig()`, `getScmKeys()` - -**Tests:** ❌ None -**Testable Logic:** -- Type guards (`isValidAgent()`, `isValidScm()`) - Test with valid/invalid inputs -- Getter functions (`getAgentConfig()`, `getScmConfig()`) - Verify correct config retrieval -- Configuration completeness - Verify all agents have required fields -- SCM_SPECIFIC_COMMANDS array correctness - ---- - -### `version.ts` (7 lines) -**Purpose:** Version management from package.json - -**Key Exports:** -- `VERSION` - String containing the package version - -**Tests:** ❌ None -**Testable Logic:** -- Version string format validation (semver compliance) -- Version export correctness - ---- - -## `commands/` Directory - -### `chat.ts` (241 lines) -**Purpose:** Chat command implementation for interactive agent sessions - -**Key Exports:** -- `chatCommand()` - Main async function to start chat UI -- `createClientForAgentType()` - Factory function for agent clients -- `getAgentDisplayName()` - Map agent type to display name -- `getTheme()` - Convert theme name to Theme object -- `isSlashCommand()`, `parseSlashCommand()`, `handleThemeCommand()` - Slash command utilities -- `ChatCommandOptions` interface - -**Tests:** ❌ None -**Testable Logic:** -- `createClientForAgentType()` - Verify correct client instantiation for each agent type -- `getAgentDisplayName()` - Test mapping correctness -- `parseSlashCommand()` - Test command parsing with various inputs -- `handleThemeCommand()` - Test theme switching logic -- `isSlashCommand()` - Test with valid/invalid slash command formats - ---- - -### `config.ts` (commands) (73 lines) -**Purpose:** Config command implementation for managing CLI settings - -**Key Exports:** -- `configCommand()` - Main async function for config management - -**Subcommands:** -- `set telemetry ` - Enable/disable telemetry - -**Tests:** ❌ None -**Testable Logic:** -- Input validation (subcommand, key, value checking) -- Error messages for invalid inputs -- Telemetry state updates (can mock `setTelemetryEnabled()`) - ---- - -### `init.ts` (458 lines) -**Purpose:** Interactive setup flow for atomic CLI - -**Key Exports:** -- `initCommand()` - Main async function for init flow -- `reconcileScmVariants()` - Exported function to reconcile SCM-specific files -- `getCommandsSubfolder()` - Helper for agent-specific folder names -- `InitOptions` interface - -**Key Logic:** -- Agent selection prompt -- SCM selection prompt -- Directory confirmation -- Telemetry consent handling -- Template file copying with preservation logic -- SCM variant reconciliation (remove opposite SCM files) -- Atomic config persistence - -**Tests:** ✅ `init.test.ts` (111 lines) - -**Test Coverage:** -- `reconcileScmVariants()` - Tests Sapling variant removal, GitHub variant removal, directory-based skills handling -- Edge cases: Missing source/target directories, user-custom files preservation - -**Untested Logic:** -- Full `initCommand()` flow (prompts, file copying, error handling) -- `copyDirPreserving()` internal function -- Preserved file logic (empty file detection, force flag handling) -- Merge file logic (.mcp.json merging) -- WSL installation check - ---- - -### `uninstall.ts` (217 lines) -**Purpose:** Remove binary installations - -**Key Exports:** -- `uninstallCommand()` - Main async function for uninstallation -- `getPathCleanupInstructions()` - Generate shell-specific PATH cleanup instructions -- `UninstallOptions` interface - -**Key Logic:** -- Installation type detection (npm/source installations not supported) -- Dry-run mode -- Binary and data directory removal -- Windows rename strategy (running executable can't be deleted) -- Unix self-deletion -- PATH cleanup instructions - -**Tests:** ❌ None -**Testable Logic:** -- `getPathCleanupInstructions()` - Verify correct instructions for Windows/Unix -- Installation type validation -- File existence checks -- Dry-run output verification -- Error handling for permissions - ---- - -### `update.ts` (299 lines) -**Purpose:** Self-update for binary installations - -**Key Exports:** -- `updateCommand()` - Main async function for updates -- `isNewerVersion()` - Semver comparison function -- `extractConfig()` - Extract config archive to data directory -- Helper functions: `replaceBinaryUnix()`, `replaceBinaryWindows()` - -**Key Logic:** -- Check for latest release via GitHub API -- Version comparison -- Download binary and config archive with progress -- Checksum verification -- Binary replacement (platform-specific) -- Config extraction and installation -- Post-install verification - -**Tests:** ❌ None -**Testable Logic:** -- `isNewerVersion()` - Test semver comparison with various version pairs (1.2.3 vs 1.2.4, 2.0.0 vs 1.9.9, etc.) -- `extractConfig()` - Test archive extraction (can use mock filesystem) -- Platform detection and binary replacement strategy selection -- Error handling for network failures, checksum mismatches - ---- - -## `config/` Directory - -### `index.ts` (10 lines) -**Purpose:** Re-export barrel for config module - -**Key Exports:** -- Re-exports from `copilot-manual.ts` - -**Tests:** ❌ None - ---- - -### `copilot-manual.ts` (178 lines) -**Purpose:** Copilot agent configuration loading from markdown files - -**Key Exports:** -- `CopilotAgent` interface -- `FsOps` interface - Dependency injection for filesystem operations -- `loadAgentsFromDir()` - Load agents from directory -- `loadCopilotAgents()` - Load all agents with priority (local > global) -- `loadCopilotInstructions()` - Load copilot-instructions.md - -**Tests:** ❌ None -**Testable Logic:** -- `loadAgentsFromDir()` - Test with mock filesystem (valid/invalid markdown, missing frontmatter) -- `loadCopilotAgents()` - Test priority resolution (local overrides global) -- `loadCopilotInstructions()` - Test fallback from local to global -- Frontmatter parsing (name, description, tools extraction) -- Error handling (unreadable files, invalid frontmatter) - ---- - -## `graph/` Directory - -### `index.ts` (304 lines) -**Purpose:** Central export hub for graph execution engine - -**Key Exports:** -- All types from `types.ts` -- All types and functions from `annotation.ts` -- All types and functions from `builder.ts` -- All types and functions from `nodes.ts` -- All types and functions from `compiled.ts` -- All types and functions from `checkpointer.ts` -- Error classes from `errors.ts` -- Registry functions from `subagent-registry.ts` -- Bridge class from `subagent-bridge.ts` - -**Tests:** ❌ None (re-export only) - ---- - -### `types.ts` (678 lines) -**Purpose:** Core type definitions for graph execution engine - -**Key Types:** -- `NodeId`, `NodeType`, `NodeDefinition`, `NodeExecuteFn` -- `BaseState`, `ContextWindowUsage` -- `Signal`, `SignalData` -- `ExecutionError`, `RetryConfig`, `DebugReport` -- `NodeResult`, `ExecutionContext` -- `ProgressEvent`, `GraphConfig`, `Checkpointer` -- `EdgeCondition`, `Edge` -- `CompiledGraph` -- `ExecutionStatus`, `ExecutionSnapshot` -- `ModelSpec`, `WorkflowToolContext` - -**Key Constants:** -- `DEFAULT_RETRY_CONFIG`, `DEFAULT_GRAPH_CONFIG` -- `BACKGROUND_COMPACTION_THRESHOLD`, `BUFFER_EXHAUSTION_THRESHOLD` - -**Type Guards:** -- `isNodeType()`, `isSignal()`, `isExecutionStatus()`, `isBaseState()`, `isNodeResult()`, `isDebugReport()` - -**Tests:** ❌ None -**Testable Logic:** -- Type guard functions - Test with valid/invalid inputs -- Default configurations - Verify values match specifications -- Threshold constants - Verify reasonable values - ---- - -### `annotation.ts` (partial view) -**Purpose:** State annotation system for type-safe state management - -**Key Exports:** -- `Reducer` type - Function for merging state values -- `Annotation` interface - Field definition with default and reducer -- `AnnotationRoot` type - Schema combining multiple annotations -- `Reducers` object - Built-in reducers (replace, concat, merge, mergeById) -- `annotation()` - Factory for creating annotations -- `initializeState()`, `applyStateUpdate()` - State management functions -- State-specific annotations: `AtomicStateAnnotation`, `RalphStateAnnotation` -- Type guards: `isFeature()`, `isAtomicWorkflowState()`, `isRalphWorkflowState()` - -**Tests:** ❌ None -**Testable Logic:** -- Built-in reducers (`Reducers.replace`, `concat`, `merge`, `mergeById`) - Test merge behavior -- `initializeState()` - Test state initialization with defaults -- `applyStateUpdate()` - Test reducer application and immutability -- Type guards - Test with valid/invalid state objects - ---- - -### `builder.ts` (partial view) -**Purpose:** Fluent API for building graph-based workflows - -**Key Exports:** -- `GraphBuilder` class - Main builder with fluent API -- `graph()` - Factory function -- Helper factories: `createNode()`, `createDecisionNode()`, `createWaitNode()` -- `LoopConfig`, `ParallelConfig` interfaces - -**Key Methods:** -- `.then()` - Linear node chaining -- `.if()/.else()/.endif()` - Conditional branching -- `.parallel()` - Parallel execution -- `.loop()` - Loop constructs -- `.wait()` - Human-in-the-loop -- `.catch()` - Error handling -- `.compile()` - Generate CompiledGraph - -**Tests:** ❌ None -**Testable Logic:** -- Node chaining - Build simple graph and verify edge creation -- Conditional branching - Test if/else/endif edge generation -- Loop configuration - Verify maxIterations enforcement -- Parallel node creation - Test branch handling -- Graph compilation - Verify startNode, endNodes detection -- Error detection (missing startNode, disconnected nodes) - ---- - -### `checkpointer.ts` (partial view) -**Purpose:** Checkpoint implementations for state persistence - -**Key Exports:** -- `MemorySaver` - In-memory checkpointer -- `FileSaver` - File-based checkpointer -- `ResearchDirSaver` - Research directory with YAML frontmatter -- `SessionDirSaver` - Session directory checkpointer -- `createCheckpointer()` - Factory function -- `CheckpointerType`, `CreateCheckpointerOptions` types - -**Tests:** ❌ None -**Testable Logic:** -- `MemorySaver` - Test save/load/list/delete operations -- `FileSaver` - Test file creation, JSON persistence -- State cloning (structuredClone verification) -- Checkpoint listing and label-based retrieval -- Error handling for missing files - ---- - -### `compiled.ts` -**Purpose:** Graph execution engine with BFS traversal - -**Key Exports:** -- `GraphExecutor` class - Main execution orchestrator -- `executeGraph()`, `streamGraph()` - Public APIs -- `initializeExecutionState()`, `mergeState()` - State utilities -- `StepResult`, `ExecutionResult`, `ExecutionOptions` types - -**Key Logic:** -- BFS-style traversal with queue -- State immutability with annotation reducers -- Exponential backoff retry -- Checkpoint management -- Signal processing (pause/resume) -- Loop detection -- Telemetry integration - -**Tests:** ❌ None -**Testable Logic:** -- State merging with reducers - Test with different annotation types -- Retry logic - Test max attempts, backoff calculation -- Edge following - Test conditional edge evaluation -- Loop detection - Test with cyclic graphs -- Signal emission and handling -- Checkpoint auto-save behavior - ---- - -### `errors.ts` -**Purpose:** Custom error classes for graph execution - -**Key Exports:** -- `SchemaValidationError` - Zod validation errors -- `NodeExecutionError` - Runtime execution errors -- `ErrorFeedback` interface - -**Tests:** ❌ None -**Testable Logic:** -- Error construction - Verify message formatting -- Error serialization - Test error data extraction -- ErrorFeedback structure validation - ---- - -### `nodes.ts` (53.8 KB) -**Purpose:** Node factory functions for graph workflows - -**Key Exports:** -- Node factories: `agentNode()`, `toolNode()`, `decisionNode()`, `waitNode()`, `askUserNode()`, `parallelNode()`, `subgraphNode()`, `contextMonitorNode()`, `customToolNode()`, `subagentNode()`, `parallelSubagentNode()`, `clearContextNode()` -- Context monitoring: `checkContextUsage()`, `compactContext()`, `isContextThresholdExceeded()` -- Client provider: `setClientProvider()`, `getClientProvider()` -- Workflow resolver: `setWorkflowResolver()`, `getWorkflowResolver()` -- Configuration interfaces for each node type - -**Tests:** ❌ None -**Testable Logic:** -- Node creation - Verify NodeDefinition structure for each factory -- Tool argument validation - Test Zod schema validation -- Output mapping - Test state update generation -- Decision routing - Test route selection -- Parallel execution - Test Promise.allSettled behavior -- Context usage calculation - Test with various token counts -- Threshold detection - Test boundary conditions - ---- - -### `nodes/ralph.ts` -**Purpose:** Prompt utilities for /ralph workflow - -**Key Exports:** -- `buildSpecToTasksPrompt()` - Generate task decomposition prompt -- `buildTaskListPreamble()` - Format task list preamble - -**Tests:** ❌ None -**Testable Logic:** -- Prompt generation - Verify prompt contains required elements -- Task list formatting - Test with various task structures -- Dependency tracking (blockedBy field) - Verify correct formatting - ---- - -### `subagent-bridge.ts` -**Purpose:** Sub-agent execution bridge for workflows - -**Key Exports:** -- `SubagentGraphBridge` class -- `spawn()`, `spawnParallel()` methods -- `SubagentResult`, `SubagentSpawnOptions` interfaces -- Singleton accessors: `setSubagentBridge()`, `getSubagentBridge()` - -**Tests:** ❌ None -**Testable Logic:** -- Sub-agent spawning - Test session creation and message sending -- Result persistence - Verify output file creation -- Parallel spawning - Test Promise.allSettled behavior -- Duration measurement - Verify timing accuracy -- Tool invocation counting - Test tool use tracking - ---- - -### `subagent-registry.ts` -**Purpose:** Registry for discovered sub-agents - -**Key Exports:** -- `SubagentTypeRegistry` class -- `SubagentEntry` interface -- Singleton accessors: `getSubagentRegistry()`, `setSubagentRegistry()` -- `populateSubagentRegistry()` - Discovery function - -**Tests:** ❌ None -**Testable Logic:** -- Registry operations - Test register/get/has/getAll/clear -- Agent discovery - Test with mock filesystem -- Priority resolution - Local agents override global -- Name normalization - Case handling - ---- - -## `models/` Directory - -### `index.ts` (8 lines) -**Purpose:** Re-export barrel for models module - -**Tests:** ❌ None - ---- - -### `model-operations.ts` -**Purpose:** Unified model operations interface - -**Key Exports:** -- `UnifiedModelOperations` class -- `ModelOperations` interface -- `SetModelResult` interface -- `CLAUDE_ALIASES` - Model alias mappings - -**Methods:** -- `listModels()` - Get available models for agent -- `setModel()` - Set active model with validation -- `getCurrentModel()`, `getPendingModel()` - Get model state -- `resolveModel()` - Resolve aliases - -**Tests:** ❌ None -**Testable Logic:** -- Model listing - Test with mock SDK clients -- Model validation - Test with valid/invalid model IDs -- Alias resolution - Test Claude aliases -- Agent-specific behavior - Test Copilot session requirement - ---- - -### `model-transform.ts` -**Purpose:** Model format transformations - -**Key Exports:** -- `Model` interface - Unified model format -- Transform functions: `fromClaudeModelInfo()`, `fromCopilotModelInfo()`, `fromOpenCodeModel()`, `fromOpenCodeProvider()` -- `OpenCodeModel` interface - -**Tests:** ❌ None -**Testable Logic:** -- Claude transformation - Test with sample ModelInfo -- Copilot transformation - Test with sample ModelInfo -- OpenCode transformation - Test with sample model data -- Metadata extraction - Verify capabilities, limits, costs -- Error handling - Missing required fields - ---- - -## `sdk/` Directory - -### `index.ts` (119 lines) -**Purpose:** Central export hub for SDK - -**Key Exports:** -- All types from `types.ts` -- Client implementations: `ClaudeAgentClient`, `OpenCodeClient`, `CopilotClient` -- Base utilities: `EventEmitter`, `createAgentEvent`, `stripProviderPrefix` -- Tool types and utilities - -**Tests:** ❌ None - ---- - -### `types.ts` -**Purpose:** Unified SDK interface definitions - -**Key Types:** -- `CodingAgentClient` - Main client interface -- `Session` - Agent session interface -- `AgentMessage`, `AgentEvent` - Communication types -- `SessionConfig`, `PermissionMode`, `OpenCodeAgentMode` - Configuration -- `ToolDefinition`, `ToolContext` - Tool system -- `EventType` + `EventDataMap` - Event types - -**Tests:** ❌ None -**Testable Logic:** -- Type validation - Create sample objects matching interfaces -- Event type completeness - Verify all event types covered - ---- - -### `base-client.ts` -**Purpose:** Shared SDK utilities - -**Key Exports:** -- `EventEmitter` class - Event handling system -- `createAgentEvent()` - Event factory -- `requireRunning()` - State validation -- `ClientState` interface - -**Tests:** ❌ None -**Testable Logic:** -- `EventEmitter` - Test on/emit/removeAllListeners -- `createAgentEvent()` - Verify event structure -- `requireRunning()` - Test with running/stopped states - ---- - -### `claude-client.ts` (40.5 KB) -**Purpose:** Claude Agent SDK implementation - -**Key Exports:** -- `ClaudeAgentClient` class -- `createClaudeAgentClient()` factory -- `ClaudeHookConfig` interface - -**Key Logic:** -- Session lifecycle via SDK's query() -- Hook event mapping to unified EventType -- MCP server integration -- Permission handling -- Session resumption - -**Tests:** ❌ None -**Testable Logic:** -- Client lifecycle - Test start/stop -- Event mapping - Verify HookEvent → AgentEvent transformation -- Tool registration - Test MCP server creation -- Permission callback - Test canUseTool logic -- Session creation - Test config propagation - ---- - -### `opencode-client.ts` (54.8 KB) -**Purpose:** OpenCode SDK implementation - -**Key Exports:** -- `OpenCodeClient` class -- `createOpenCodeClient()` factory -- `OpenCodeClientOptions` interface - -**Key Logic:** -- SSE stream handling -- Agent mode support (build, plan, general, explore) -- MCP bridge generation -- Health checks and auto-start -- Question.asked HITL events - -**Tests:** ✅ `opencode-client.mcp-snapshot.test.ts` (115 lines) - -**Test Coverage:** -- `buildOpenCodeMcpSnapshot()` - Tests snapshot building from status/tools/resources -- Partial snapshot handling - One source succeeds -- Null snapshot - All sources fail -- Auth status mapping -- Tool deduplication -- Resource association - -**Untested Logic:** -- Full client lifecycle (start, session creation, stop) -- SSE stream parsing -- Agent mode switching -- Health check logic -- MCP bridge script generation - ---- - -### `copilot-client.ts` (38.3 KB) -**Purpose:** Copilot SDK implementation - -**Key Exports:** -- `CopilotClient` class -- `createCopilotClient()` factory -- `CopilotPermissionHandler`, `CopilotConnectionMode`, `CopilotClientOptions` - -**Key Logic:** -- Connection modes (stdio, port, cliUrl) -- SessionEvent mapping -- Custom agent loading from .github/agents/ -- Permission handling -- Context compaction tracking - -**Tests:** ❌ None -**Testable Logic:** -- Client lifecycle -- Connection mode selection -- Agent loading from disk -- Event mapping -- Permission handler execution - ---- - -### `init.ts` -**Purpose:** SDK initialization helpers - -**Key Exports:** -- `initClaudeOptions()` - Claude settings sources + permissions -- `initOpenCodeConfigOverrides()` - OpenCode permission rules -- `initCopilotSessionOptions()` - Copilot auto-approval - -**Tests:** ❌ None -**Testable Logic:** -- Options generation - Verify structure for each SDK -- Permission defaults - Test approval/denial rules - ---- - -### `sdk/tools/` Directory - -#### `index.ts` (11 lines) -**Purpose:** Re-export barrel for tools module - -**Tests:** ❌ None - ---- - -#### `discovery.ts` -**Purpose:** Custom tool discovery and loading - -**Key Exports:** -- `discoverToolFiles()` - Scan directories for tool files -- `loadToolsFromDisk()` - Import and convert tools -- `registerCustomTools()` - Register with SDK clients -- `getDiscoveredCustomTools()` - Get loaded tools -- `ToolSource`, `DiscoveredToolFile` types - -**Tests:** ❌ None -**Testable Logic:** -- File discovery - Test with mock filesystem -- Tool import - Test with sample tool modules -- Zod to JSON Schema conversion -- Deduplication (local overrides global) -- Validation wrapper execution -- Output truncation - ---- - -#### `opencode-mcp-bridge.ts` -**Purpose:** MCP server generation for OpenCode - -**Key Exports:** -- `createToolMcpServerScript()` - Generate MCP server script -- `cleanupMcpBridgeScripts()` - Cleanup temp files - -**Tests:** ❌ None -**Testable Logic:** -- Script generation - Verify valid JavaScript output -- Tool serialization - Test with various tool shapes -- MCP protocol implementation - Verify initialize/tools/list/tools/call methods -- Cleanup - Test file removal - ---- - -#### `plugin.ts` -**Purpose:** Type-safe tool definition helper - -**Key Exports:** -- `tool()` function - Identity function for IDE support -- `ToolInput` interface - -**Tests:** ❌ None -**Testable Logic:** -- Type inference - Verify TypeScript types are correct -- Schema access - `tool.schema` returns Zod instance - ---- - -#### `registry.ts` -**Purpose:** Tool registry singleton - -**Key Exports:** -- `ToolRegistry` class - Register/get/getAll/clear operations -- `ToolEntry` interface -- Singleton accessors - -**Tests:** ❌ None -**Testable Logic:** -- Registry operations - Test register/get/has/getAll/clear -- Entry storage - Verify metadata persistence -- Lookup by name - Test case sensitivity - ---- - -#### `schema-utils.ts` -**Purpose:** Zod to JSON Schema conversion - -**Key Exports:** -- `zodToJsonSchema()` - Convert Zod to JSON Schema -- `JsonSchema` interface - -**Tests:** ❌ None -**Testable Logic:** -- Schema conversion - Test with various Zod types (string, number, object, array, optional, etc.) -- Error handling - Invalid Zod schemas - ---- - -#### `todo-write.ts` -**Purpose:** TodoWrite tool implementation - -**Key Exports:** -- `createTodoWriteTool()` - Factory function -- `TodoItem` interface - -**Tests:** ❌ None -**Testable Logic:** -- Tool creation - Verify ToolDefinition structure -- Todo state management - Test add/update/list operations -- Summary generation - Verify counts (done/in progress/pending) - ---- - -#### `truncate.ts` -**Purpose:** Tool output truncation - -**Key Exports:** -- `truncateToolOutput()` - Truncate at 2000 lines or 50KB - -**Tests:** ❌ None -**Testable Logic:** -- Line truncation - Test with > 2000 lines -- Size truncation - Test with > 50KB -- Notice formatting - Verify truncation message - ---- - -## `telemetry/` Directory - -### `index.ts` (45 lines) -**Purpose:** Public API for telemetry module - -**Key Exports:** -- All types from `types.ts` -- Core functions: `isTelemetryEnabled`, `setTelemetryEnabled`, `getTelemetryFilePath` -- Tracking functions: `trackAtomicCommand`, `trackAgentSession`, `createTuiTelemetrySessionTracker` -- Consent: `handleTelemetryConsent` -- Upload: `handleTelemetryUpload`, `filterStaleEvents` - -**Tests:** ❌ None - ---- - -### `types.ts` -**Purpose:** Telemetry event schema - -**Key Types:** -- `TelemetryState` - Persistent state -- `AtomicCommandType`, `AgentType` - Command/agent enums -- `TelemetryEvent` - Discriminated union of all event types -- Event types: `AtomicCommandEvent`, `CliCommandEvent`, `AgentSessionEvent`, `TuiSessionStartEvent`, `TuiSessionEndEvent`, `TuiMessageSubmitEvent`, `TuiCommandExecutionEvent`, `TuiToolLifecycleEvent`, `TuiInterruptEvent` - -**Tests:** ❌ None -**Testable Logic:** -- Type guards for discriminated unions -- Event structure validation - ---- - -### `constants.ts` -**Purpose:** Tracked command registry - -**Key Exports:** -- `ATOMIC_COMMANDS` array - All tracked slash commands -- `AtomicCommand` type - -**Tests:** ❌ None -**Testable Logic:** -- Command completeness - Verify all workflow/skill commands included - ---- - -### `telemetry.ts` -**Purpose:** Core telemetry state management - -**Key Exports:** -- `generateAnonymousId()` - UUID generation -- `getTelemetryFilePath()` - Config file path -- `getOrCreateTelemetryState()` - Lazy init with monthly rotation -- `isTelemetryEnabled()`, `isTelemetryEnabledSync()` - Priority-based checks -- `setTelemetryEnabled()` - Enable/disable with consent tracking - -**Tests:** ❌ None -**Testable Logic:** -- UUID generation - Verify format -- Monthly rotation - Test timestamp comparison -- Priority logic - CI > env > config -- File I/O - Test with mock filesystem - ---- - -### `telemetry-cli.ts` -**Purpose:** CLI command tracking - -**Key Exports:** -- `trackAtomicCommand()` - Log command execution -- `createBaseEvent()` - Event factory - -**Tests:** ❌ None -**Testable Logic:** -- Event structure - Verify fields -- File append - Test with mock filesystem - ---- - -### `telemetry-consent.ts` -**Purpose:** First-run consent flow - -**Key Exports:** -- `isFirstRun()` - Detect first-time setup -- `promptTelemetryConsent()` - Interactive prompt -- `handleTelemetryConsent()` - Full flow orchestration - -**Tests:** ❌ None -**Testable Logic:** -- First-run detection - Test with/without existing state -- Consent persistence - Verify state updates -- Prompt only shown once - Test repeat calls - ---- - -### `telemetry-errors.ts` -**Purpose:** Error handling - -**Key Exports:** -- `handleTelemetryError()` - Silent-by-default error logging - -**Tests:** ❌ None -**Testable Logic:** -- Debug mode logging - Test with ATOMIC_TELEMETRY_DEBUG=1 -- Silent failure - Verify no throws - ---- - -### `telemetry-file-io.ts` -**Purpose:** JSONL file operations - -**Key Exports:** -- `getEventsFilePath()` - Events file path -- `appendEvent()` - Atomic append-only writes - -**Tests:** ❌ None -**Testable Logic:** -- File path generation - Verify format -- JSONL append - Test line format -- Atomic writes - Test concurrent appends (requires OS-level testing) - ---- - -### `telemetry-session.ts` -**Purpose:** Agent session tracking - -**Key Exports:** -- `extractCommandsFromTranscript()` - Parse JSONL transcript -- `createSessionEvent()` - Factory for AgentSessionEvent -- `trackAgentSession()` - Log session - -**Tests:** ❌ None -**Testable Logic:** -- Command extraction - Test with sample transcripts (various formats) -- Skip non-user messages - Verify filtering -- Session event creation - Verify structure - ---- - -### `telemetry-tui.ts` -**Purpose:** Chat UI tracking - -**Key Exports:** -- `TuiTelemetrySessionTracker` class -- Methods: `trackMessageSubmit()`, `trackCommandExecution()`, `trackToolStart()`, `trackToolComplete()`, `trackInterrupt()`, `end()` -- `createTuiTelemetrySessionTracker()` - Factory function - -**Tests:** ❌ None -**Testable Logic:** -- Session tracking - Test lifecycle (start, events, end) -- Event counting - Verify message/tool counts -- Duration calculation - Test timing -- Event emission - Verify event structure - ---- - -### `telemetry-upload.ts` -**Purpose:** Event upload to Azure App Insights - -**Key Exports:** -- `readEventsFromJSONL()` - Parse local buffer -- `filterStaleEvents()` - Remove events > 30 days -- `splitIntoBatches()` - 100-event batches -- `handleTelemetryUpload()` - Main upload flow -- `emitEventsToAppInsights()` - OpenTelemetry emission - -**Tests:** ❌ None -**Testable Logic:** -- JSONL parsing - Test with various line formats -- Stale event filtering - Test with old/new timestamps -- Batch splitting - Test with various event counts -- File claiming - Test atomic lock acquisition -- Cleanup on success - Verify file deletion - ---- - -### `graph-integration.ts` -**Purpose:** Workflow telemetry tracking - -**Key Exports:** -- `trackWorkflowExecution()` - Create tracker with sampling -- `WorkflowTracker` interface - Methods for workflow lifecycle -- `WorkflowTelemetryEvent` type - -**Tests:** ❌ None -**Testable Logic:** -- Sampling logic - Test with various sample rates -- No-op when disabled - Verify no-op implementation -- Event emission - Test with mock telemetry - ---- - -## `ui/` Directory - -### `index.ts` -**Purpose:** Chat UI entry point - -**Key Exports:** -- `startChatUI()` - Main function to start TUI -- `ChatUIConfig` - Configuration interface -- `buildCapabilitiesSystemPrompt()` - System prompt builder - -**Tests:** ❌ None -**Testable Logic:** -- System prompt generation - Verify includes commands/skills/agents -- Theme configuration - Test theme application -- Error boundary - Test error handling -- Telemetry session tracking - Verify tracking calls - ---- - -### `types.ts` -**Purpose:** Shared UI types - -**Key Types:** -- `FooterState` - Footer status bar state -- `EnhancedMessageMeta` - Message metadata -- `VerboseProps`, `TimestampProps`, etc. - Component props - -**Tests:** ❌ None - ---- - -### `ui/commands/` Directory - -#### `index.ts` -**Purpose:** Command system entry point - -**Key Exports:** -- `initializeCommands()`, `initializeCommandsAsync()` - Registration functions -- `parseSlashCommand()`, `isSlashCommand()`, `getCommandPrefix()` - Utilities -- `ParsedSlashCommand` type - -**Tests:** ❌ None -**Testable Logic:** -- Command parsing - Test with various slash command formats -- Command/args splitting - Test edge cases (empty args, special chars) -- Prefix extraction - Test command prefix detection - ---- - -#### `registry.ts` -**Purpose:** Command registry - -**Key Exports:** -- `CommandRegistry` class - Register/lookup/search/execute -- `globalRegistry` singleton -- `CommandDefinition`, `CommandContext`, `CommandResult` interfaces - -**Tests:** ❌ None -**Testable Logic:** -- Registration - Test command/alias registration -- Lookup - Test by name and alias -- Search - Test prefix matching for autocomplete -- Priority sorting - Verify workflow > skill > agent > builtin -- Conflict detection - Test duplicate names/aliases - ---- - -#### `builtin-commands.ts` -**Purpose:** Core slash commands - -**Key Commands:** -- `/help` - List all commands -- `/theme` - Toggle theme -- `/clear` - Clear messages -- `/compact` - Compress context -- `/exit`, `/quit` - Exit app -- `/model` - Switch/list models -- `/mcp` - View/toggle MCP servers -- `/context` - Display context usage - -**Tests:** ❌ None -**Testable Logic:** -- Help command - Verify output format, grouping -- Theme command - Test toggle logic -- Model command - Test listing, switching, validation -- MCP command - Test toggle application -- Context command - Test usage calculation and display - ---- - -#### `workflow-commands.ts` (62.8 KB) -**Purpose:** Workflow command registration - -**Key Exports:** -- `registerWorkflowCommands()` - Register workflow commands -- `loadWorkflowsFromDisk()` - Discover workflows -- `WORKFLOW_DEFINITIONS` - Built-in workflows (/ralph) - -**Tests:** ❌ None -**Testable Logic:** -- Workflow discovery - Test with mock filesystem -- Metadata parsing - Test workflow definition parsing -- Resume functionality - Test session ID handling -- Task decomposition - Test spec parsing -- Workflow state persistence - ---- - -#### `skill-commands.ts` (30.1 KB) -**Purpose:** Skill command registration - -**Key Exports:** -- `registerSkillCommands()` - Register skills -- `discoverAndRegisterDiskSkills()` - Discover from disk -- `BUILTIN_SKILLS` - Embedded skills - -**Built-in Skills:** -- `/research-codebase` -- `/create-spec` -- `/explain-code` -- `/debug-error` -- `/refactor-code` -- `/write-tests` -- `/improve-docs` - -**Tests:** ❌ None -**Testable Logic:** -- Skill discovery - Test with mock filesystem -- Metadata parsing - Test frontmatter extraction -- Priority resolution - Local > global > builtin -- Argument expansion - Test $ARGUMENTS placeholder -- Required argument validation - ---- - -#### `agent-commands.ts` -**Purpose:** Agent command registration - -**Key Exports:** -- `registerAgentCommands()` - Register agents -- `discoverAgentInfos()` - Discover agents from disk - -**Tests:** ❌ None -**Testable Logic:** -- Agent discovery - Test with mock filesystem -- Frontmatter parsing - Test name/description extraction -- Priority resolution - Project > user -- Path expansion - Test tilde expansion - ---- - -### `ui/components/index.ts` -**Purpose:** Component exports - -**Key Components:** -- `Autocomplete`, `UserQuestionDialog`, `ToolResult`, `SkillLoadIndicator`, `QueueIndicator`, `TimestampDisplay`, `AnimatedBlinkIndicator`, `ParallelAgentsTree`, `ModelSelectorDialog`, `ContextInfoDisplay`, `AppErrorBoundary`, `FooterStatus` - -**Tests:** ❌ None (components best tested with integration tests) - ---- - -### `ui/constants/` Directory - -#### `icons.ts` -**Purpose:** Unicode icon definitions - -**Key Exports:** -- `STATUS` - Status indicators -- `TREE` - Tree drawing chars -- `CONNECTOR` - Connector chars -- `ARROW` - Arrow indicators -- `SPINNER_FRAMES` - Braille spinner -- `PROGRESS`, `CHECKBOX`, `SCROLLBAR`, `SEPARATOR`, `MISC` - -**Tests:** ❌ None - ---- - -#### `spinner-verbs.ts` -**Purpose:** Dynamic spinner verbs - -**Key Exports:** -- `SPINNER_VERBS` - Action verbs array -- `COMPLETION_VERBS` - Past-tense verbs -- `getRandomVerb()`, `getRandomCompletionVerb()` - Random selectors - -**Tests:** ❌ None -**Testable Logic:** -- Random selection - Test distribution -- Verb completeness - Verify arrays not empty - ---- - -### `ui/hooks/` Directory - -#### `use-streaming-state.ts` -**Purpose:** Streaming state management hook - -**Key Exports:** -- `useStreamingState()` hook -- `StreamingState`, `ToolExecutionState` types -- Helper functions: `createInitialStreamingState()`, `createToolExecution()`, `getActiveToolExecutions()` - -**Tests:** ❌ None -**Testable Logic:** -- State initialization - Verify default state -- Tool execution tracking - Test lifecycle (pending → running → completed/error) -- Question queueing - Test add/remove operations -- State updates - Verify immutability - ---- - -#### `use-verbose-mode.ts` -**Purpose:** Verbose mode toggle hook - -**Key Exports:** -- `useVerboseMode()` hook - -**Tests:** ❌ None -**Testable Logic:** -- Toggle behavior - Test on/off switching -- Initial state - Verify default - ---- - -#### `use-message-queue.ts` -**Purpose:** Message queue management hook - -**Key Exports:** -- `useMessageQueue()` hook -- `QueuedMessage` type -- Constants: `MAX_QUEUE_SIZE`, `QUEUE_SIZE_WARNING_THRESHOLD` - -**Tests:** ❌ None -**Testable Logic:** -- Enqueue/dequeue - Test FIFO behavior -- Size limits - Test max size enforcement -- Reordering - Test moveUp/moveDown -- Edit operation - Test message modification - ---- - -### `ui/tools/registry.ts` -**Purpose:** Tool-specific renderers - -**Key Exports:** -- `TOOL_RENDERERS` - Registry of tool renderers -- Specific renderers: `readToolRenderer`, `editToolRenderer`, `bashToolRenderer`, etc. -- Helper functions: `getToolRenderer()`, `parseMcpToolName()` - -**Tests:** ❌ None -**Testable Logic:** -- Renderer lookup - Test by tool name -- Default renderer - Test fallback -- Parameter extraction - Test with various tool formats -- Output parsing - Test JSON/plain text parsing -- Language detection - Test from file extensions - ---- - -### `ui/utils/` Directory - -#### `format.ts` -**Purpose:** Formatting utilities - -**Key Exports:** -- `formatDuration()` - ms to readable format -- `formatTimestamp()` - ISO to 12-hour time -- `truncateText()` - Ellipsis truncation - -**Tests:** ❌ None -**Testable Logic:** -- Duration formatting - Test various durations (ms, seconds, minutes) -- Timestamp formatting - Test various ISO timestamps -- Text truncation - Test with various lengths - ---- - -#### `mcp-output.ts` -**Purpose:** MCP server display utilities - -**Key Exports:** -- `buildMcpSnapshotView()` - Build MCP display -- `applyMcpServerToggles()` - Apply toggle overrides -- `getActiveMcpServers()` - Filter active servers - -**Tests:** ✅ `mcp-output.test.ts` (138 lines) - -**Test Coverage:** -- Toggle override application - Verify enabled state changes -- Active server filtering - Test with toggle map -- Snapshot building - Test sorting, masking, tool normalization -- Sensitive value masking - Headers, env vars -- Tool name normalization - Claude MCP prefix removal -- Wildcard tools - Test ['*'] handling -- Runtime tools override config tools -- Config tools whitelist filters runtime tools - -**Untested Logic:** -- Transport format display -- Status indicators - ---- - -#### `transcript-formatter.ts` -**Purpose:** Transcript line formatting - -**Key Exports:** -- `formatTranscript()` - Main formatting function -- `TranscriptLine`, `FormatTranscriptOptions` types - -**Tests:** ✅ `transcript-formatter.hitl.test.ts` (37 lines) - -**Test Coverage:** -- HITL response rendering - Verify canonical text instead of raw JSON - -**Untested Logic:** -- Full transcript formatting (user prompts, thinking traces, tool calls, agent trees, completion summaries) -- File read formatting -- Timestamp/model display -- Footer generation - ---- - -#### `conversation-history-buffer.ts` -**Purpose:** Conversation history persistence - -**Key Exports:** -- `appendToHistoryBuffer()` - Append messages -- `replaceHistoryBuffer()` - Replace entire buffer -- `readHistoryBuffer()` - Read persisted history -- `clearHistoryBuffer()` - Clear buffer -- `appendCompactionSummary()` - Add compaction marker - -**Tests:** ❌ None -**Testable Logic:** -- File operations - Test with mock filesystem -- Message merging - Test with existing history -- Compaction markers - Test insertion -- Error handling - File I/O failures - ---- - -#### `hitl-response.ts` -**Purpose:** Human-in-the-loop response utilities - -**Key Exports:** -- `formatHitlDisplayText()` - Format response for display -- `normalizeHitlAnswer()` - Normalize user answer -- `getHitlResponseRecord()` - Extract response from tool call - -**Tests:** ✅ `hitl-response.test.ts` (73 lines) - -**Test Coverage:** -- `normalizeHitlAnswer()` - Empty answers, declined responses, chat-about-this responses -- `getHitlResponseRecord()` - Legacy output shape, structured hitlResponse field - -**Untested Logic:** -- `formatHitlDisplayText()` complete coverage for all response modes - ---- - -#### `navigation.ts` -**Purpose:** List navigation utilities - -**Key Exports:** -- `navigateUp()`, `navigateDown()` - Wrap-around navigation - -**Tests:** ❌ None -**Testable Logic:** -- Wrap-around behavior - Test boundary conditions -- Empty list handling -- Single item list - ---- - -## `utils/` Directory - -### `atomic-config.ts` -**Purpose:** Project config persistence - -**Key Exports:** -- `AtomicConfig` interface -- `readAtomicConfig()`, `saveAtomicConfig()`, `getSelectedScm()` - -**Tests:** ❌ None -**Testable Logic:** -- Config read/write - Test with mock filesystem -- Merge behavior - Verify existing config preservation -- SCM getter - Test with various config states - ---- - -### `cleanup.ts` -**Purpose:** Windows leftover file cleanup - -**Key Exports:** -- `cleanupWindowsLeftoverFiles()` - Main cleanup -- `cleanupLeftoverFilesAt()`, `tryRemoveFile()` - Helpers - -**Tests:** ❌ None -**Testable Logic:** -- File cleanup - Test with mock filesystem -- Locked file handling - Verify silent failure -- Platform detection - Verify no-op on non-Windows - ---- - -### `colors.ts` -**Purpose:** ANSI color codes - -**Key Exports:** -- `COLORS` object - Color codes - -**Tests:** ❌ None -**Testable Logic:** -- NO_COLOR respect - Test with env var set -- Color code format - Verify ANSI codes - ---- - -### `config-path.ts` -**Purpose:** Config path resolution - -**Key Exports:** -- `detectInstallationType()` - Identify install type -- `getConfigRoot()` - Get config directory -- `getBinaryDataDir()`, `getBinaryPath()`, `getBinaryInstallDir()` - Binary paths -- `configDataDirExists()` - Validate directory - -**Tests:** ❌ None -**Testable Logic:** -- Installation type detection - Test with various env vars -- Path resolution - Test for each install type -- Platform-specific paths - Windows vs Unix -- Error messages - Missing data directory - ---- - -### `copy.ts` -**Purpose:** Directory and file copying - -**Key Exports:** -- `copyDir()` - Recursive copy with exclusions -- `copyFile()` - Single file copy -- `normalizePath()`, `isPathSafe()` - Path utilities -- `pathExists()`, `isDirectory()`, `isFileEmpty()` - File utilities - -**Tests:** ❌ None -**Testable Logic:** -- Recursive copying - Test with mock filesystem -- Exclusion patterns - Test with various exclusions -- Path safety - Test with traversal attempts -- Empty file detection - Test with whitespace-only files - ---- - -### `detect.ts` -**Purpose:** Platform and command detection - -**Key Exports:** -- `isWindows()`, `isMacOS()`, `isLinux()` - Platform checks -- `isCommandInstalled()`, `getCommandPath()` - Command detection -- `getScriptExtension()` - Platform script extensions -- `isWslInstalled()` - WSL detection -- `supportsColor()`, `supportsTrueColor()`, `supports256Color()` - Color support - -**Tests:** ❌ None -**Testable Logic:** -- Platform detection - Test with various process.platform values -- Command existence - Test with mock command paths -- Color support - Test with various COLORTERM values -- WSL detection - Test with mock filesystem - ---- - -### `download.ts` -**Purpose:** GitHub release and file downloads - -**Key Exports:** -- `getLatestRelease()`, `getReleaseByVersion()` - Fetch releases -- `downloadFile()` - Download with progress -- `verifyChecksum()`, `parseChecksums()` - SHA256 verification -- `getBinaryFilename()`, `getConfigArchiveFilename()` - Platform names -- `ChecksumMismatchError` - Custom error - -**Tests:** ❌ None -**Testable Logic:** -- Release fetching - Test with mock GitHub API -- Download with progress - Test progress callbacks -- Checksum verification - Test with known checksums -- Filename generation - Test for each platform -- Rate limit handling - ---- - -### `file-lock.ts` -**Purpose:** File-based locking - -**Key Exports:** -- `acquireLock()`, `tryAcquireLock()` - Lock acquisition -- `releaseLock()` - Lock release -- `withLock()` - Transactional access -- `cleanupStaleLocks()` - Remove dead locks - -**Tests:** ❌ None -**Testable Logic:** -- Lock acquisition/release - Test lifecycle -- Timeout behavior - Test retry logic -- Stale lock cleanup - Test with dead PID -- Concurrent access - Test with multiple processes (integration test) - ---- - -### `markdown.ts` -**Purpose:** Markdown frontmatter parsing - -**Key Exports:** -- `parseMarkdownFrontmatter()` - Extract frontmatter + body - -**Tests:** ❌ None -**Testable Logic:** -- YAML parsing - Test with various frontmatter formats -- Body extraction - Test with/without frontmatter -- Array/object parsing in frontmatter -- Missing frontmatter - Test fallback - ---- - -### `mcp-config.ts` -**Purpose:** MCP config discovery - -**Key Exports:** -- `parseClaudeMcpConfig()` - Parse .mcp.json -- `parseCopilotMcpConfig()` - Parse mcp-config.json -- `parseOpenCodeMcpConfig()` - Parse opencode.json -- `discoverMcpConfigs()` - Unified discovery - -**Tests:** ❌ None -**Testable Logic:** -- Config parsing - Test with sample configs for each format -- Discovery - Test with mock filesystem -- Merging - Test user + project configs -- Built-in defaults - Verify deepwiki included - ---- - -### `merge.ts` -**Purpose:** JSON file merging - -**Key Exports:** -- `mergeJsonFile()` - Deep merge mcpServers - -**Tests:** ❌ None -**Testable Logic:** -- Deep merge - Test with nested objects -- Key preservation - Non-mcpServers keys preserved -- File I/O - Test with mock filesystem - ---- - -### `settings.ts` -**Purpose:** User preference persistence - -**Key Exports:** -- `getModelPreference()`, `saveModelPreference()` - Model selection -- `getReasoningEffortPreference()`, `saveReasoningEffortPreference()` - Reasoning level -- `clearReasoningEffortPreference()` - Clear preference - -**Tests:** ❌ None -**Testable Logic:** -- Preference read/write - Test with mock filesystem -- Agent-specific settings - Test isolation -- Local > global priority - Test override behavior -- Clear operation - Verify removal - ---- - -### `utils/banner/` Directory - -#### `constants.ts` -**Purpose:** Pre-computed banner artwork - -**Key Exports:** -- `LOGO_TRUE_COLOR`, `LOGO` - Banner variants -- `LOGO_MIN_COLS`, `LOGO_MIN_ROWS` - Size requirements - -**Tests:** ❌ None - ---- - -#### `banner.ts` -**Purpose:** Banner display - -**Key Exports:** -- `displayBanner()` - Display with size/color checks - -**Tests:** ❌ None -**Testable Logic:** -- Terminal size check - Test with various dimensions -- Color support detection - Test fallback to 256-color - ---- - -#### `index.ts` -**Purpose:** Re-export - -**Tests:** ❌ None - ---- - -## `workflows/` Directory - -### `index.ts` (8 lines) -**Purpose:** Re-export barrel - -**Tests:** ❌ None - ---- - -### `session.ts` -**Purpose:** Workflow session management - -**Key Exports:** -- `WorkflowSession` interface -- `WORKFLOW_SESSIONS_DIR` constant -- `generateWorkflowSessionId()`, `getWorkflowSessionDir()`, `initWorkflowSession()`, `saveWorkflowSession()`, `saveSubagentOutput()` - -**Tests:** ❌ None -**Testable Logic:** -- Session ID generation - Verify UUID format -- Directory creation - Test with mock filesystem -- Session persistence - Test save/load -- Sub-agent output storage - Test file creation - ---- - -## Test Coverage Summary - -### ✅ **Files WITH Tests (5 files):** - -1. **`src/commands/init.test.ts`** (111 lines) - - **Covers:** `reconcileScmVariants()` function - - **Tests:** Sapling variant removal, GitHub variant removal, directory-based skills, missing directories - -2. **`src/ui/utils/hitl-response.test.ts`** (73 lines) - - **Covers:** `normalizeHitlAnswer()`, `getHitlResponseRecord()` - - **Tests:** Empty answers, declined responses, chat-about-this responses, legacy output shape, structured response field - -3. **`src/ui/utils/transcript-formatter.hitl.test.ts`** (37 lines) - - **Covers:** `formatTranscript()` HITL rendering - - **Tests:** Canonical HITL text rendering (not raw JSON) - -4. **`src/ui/utils/mcp-output.test.ts`** (138 lines) - - **Covers:** `applyMcpServerToggles()`, `buildMcpSnapshotView()`, `getActiveMcpServers()` - - **Tests:** Toggle overrides, active filtering, snapshot building, sensitive masking, tool normalization, wildcard tools, runtime override, whitelist filtering - -5. **`src/sdk/opencode-client.mcp-snapshot.test.ts`** (115 lines) - - **Covers:** `buildOpenCodeMcpSnapshot()` internal method - - **Tests:** Snapshot building from status/tools/resources, partial snapshots, null snapshots, auth status mapping, tool deduplication, resource association - -### ❌ **Files WITHOUT Tests (96+ files):** - -**All other files in:** -- `src/` (cli.ts, config.ts, version.ts) -- `src/commands/` (chat.ts, config.ts, uninstall.ts, update.ts - except init.test.ts) -- `src/config/` (index.ts, copilot-manual.ts) -- `src/graph/` (all 12 files - types, annotation, builder, compiled, checkpointer, errors, nodes, nodes/ralph, subagent-bridge, subagent-registry, index) -- `src/models/` (all 3 files) -- `src/sdk/` (10 files - types, base-client, claude-client, copilot-client, init, index) -- `src/sdk/tools/` (8 files) -- `src/telemetry/` (all 12 files) -- `src/ui/` (2 files - index, types) -- `src/ui/commands/` (5 files) -- `src/ui/components/` (1 file - index) -- `src/ui/constants/` (2 files) -- `src/ui/hooks/` (3 files) -- `src/ui/tools/` (1 file) -- `src/ui/utils/` (2 untested - conversation-history-buffer, navigation) -- `src/utils/` (all 16 files) -- `src/workflows/` (2 files) - ---- - -## Testable Logic in Untested Modules - -### **High-Priority Testable Functions** (Pure functions, data transformations): - -1. **`src/commands/update.ts`** - - `isNewerVersion()` - Semver comparison (pure function) - -2. **`src/graph/annotation.ts`** - - `Reducers.replace()`, `concat()`, `merge()`, `mergeById()` - State merge logic - - `initializeState()`, `applyStateUpdate()` - State management - -3. **`src/graph/types.ts`** - - Type guards: `isNodeType()`, `isSignal()`, `isExecutionStatus()`, `isBaseState()`, `isNodeResult()`, `isDebugReport()` - -4. **`src/models/model-transform.ts`** - - Transform functions: `fromClaudeModelInfo()`, `fromCopilotModelInfo()`, `fromOpenCodeModel()` - -5. **`src/sdk/base-client.ts`** - - `EventEmitter` class - Event handling - - `createAgentEvent()` - Event factory - -6. **`src/sdk/tools/schema-utils.ts`** - - `zodToJsonSchema()` - Zod to JSON Schema conversion - -7. **`src/sdk/tools/truncate.ts`** - - `truncateToolOutput()` - Line/size truncation - -8. **`src/telemetry/telemetry.ts`** - - `isTelemetryEnabled()` - Priority logic (CI > env > config) - - `generateAnonymousId()` - UUID generation - -9. **`src/telemetry/telemetry-upload.ts`** - - `filterStaleEvents()` - Date filtering - - `splitIntoBatches()` - Batching logic - -10. **`src/ui/commands/index.ts`** - - `parseSlashCommand()` - Command parsing - -11. **`src/ui/utils/format.ts`** - - `formatDuration()`, `formatTimestamp()`, `truncateText()` - Formatting utilities - -12. **`src/ui/utils/navigation.ts`** - - `navigateUp()`, `navigateDown()` - Wrap-around navigation - -13. **`src/utils/markdown.ts`** - - `parseMarkdownFrontmatter()` - YAML parsing - -14. **`src/workflows/session.ts`** - - `generateWorkflowSessionId()` - UUID generation - -### **Medium-Priority Testable Functions** (State machines, parsers): - -1. **`src/graph/builder.ts`** - - Node chaining, conditional branching, loop configuration - - Graph compilation (startNode, endNodes detection) - -2. **`src/graph/compiled.ts`** - - State merging with reducers - - Retry logic (max attempts, backoff) - - Edge condition evaluation - -3. **`src/graph/checkpointer.ts`** - - `MemorySaver` operations (save, load, list, delete) - -4. **`src/ui/commands/registry.ts`** - - Registration, lookup, search, priority sorting - -5. **`src/ui/hooks/use-message-queue.ts`** - - Queue operations (enqueue, dequeue, reorder) - -6. **`src/ui/tools/registry.ts`** - - Tool renderer lookup, parameter extraction - -7. **`src/utils/copy.ts`** - - Path safety checks (`isPathSafe()`) - -8. **`src/utils/file-lock.ts`** - - Lock lifecycle (acquire, release, stale cleanup) - -### **Low-Priority Testable Functions** (Integration-heavy, I/O-heavy): - -- File system operations (most `utils/` functions) -- Network operations (`utils/download.ts`) -- Process spawning (`cli.ts`) -- SDK client lifecycle (all `sdk/*-client.ts` files) - ---- - -## Recommendations for Test Coverage - -### **Phase 1: Pure Functions** (Low-hanging fruit) -- Type guards in `graph/types.ts` -- Reducers in `graph/annotation.ts` -- Formatting utilities in `ui/utils/format.ts` -- Navigation in `ui/utils/navigation.ts` -- Version comparison in `commands/update.ts` -- Command parsing in `ui/commands/index.ts` - -### **Phase 2: Data Transformations** -- Model transforms in `models/model-transform.ts` -- Schema conversion in `sdk/tools/schema-utils.ts` -- Truncation in `sdk/tools/truncate.ts` -- Markdown parsing in `utils/markdown.ts` -- Telemetry filtering in `telemetry/telemetry-upload.ts` - -### **Phase 3: State Management** -- State initialization/updates in `graph/annotation.ts` -- Checkpointer operations in `graph/checkpointer.ts` -- Event emitter in `sdk/base-client.ts` -- Message queue in `ui/hooks/use-message-queue.ts` - -### **Phase 4: Complex Logic** -- Graph builder in `graph/builder.ts` -- Graph executor in `graph/compiled.ts` -- Command registry in `ui/commands/registry.ts` -- Tool registry in `ui/tools/registry.ts` - -### **Phase 5: Integration Tests** -- Full `initCommand()` flow -- SDK client lifecycle tests -- File locking under concurrency -- Workflow session management - ---- - -## Statistics - -- **Total TypeScript files:** ~101 files -- **Files with tests:** 5 (5%) -- **Files without tests:** 96 (95%) -- **Test files:** 5 -- **Total test lines:** ~474 lines - -**Test Coverage by Module:** -- `commands/`: 1/5 files (20%) -- `config/`: 0/2 files (0%) -- `graph/`: 0/12 files (0%) -- `models/`: 0/3 files (0%) -- `sdk/`: 1/19 files (5%) -- `telemetry/`: 0/12 files (0%) -- `ui/`: 3/38 files (8%) -- `utils/`: 0/16 files (0%) -- `workflows/`: 0/2 files (0%) - -**Most Testable (Pure Functions):** -1. `graph/types.ts` - Type guards -2. `graph/annotation.ts` - Reducers -3. `ui/utils/format.ts` - Formatters -4. `commands/update.ts` - Version comparison -5. `sdk/tools/schema-utils.ts` - Schema conversion - -**Least Testable (I/O-heavy):** -1. SDK client implementations (claude, opencode, copilot) -2. File system operations in `utils/` -3. Telemetry upload (network operations) -4. CLI entry point with process spawning - ---- - -## Conclusion - -This documentation covers all 101+ files in the `src/` directory. The codebase has minimal test coverage (5%), with the majority of testable logic untested. The most impactful tests to add would be for pure functions (type guards, reducers, formatters) and data transformations (model transforms, schema conversion), as these are critical to correctness and easy to test without complex mocking. diff --git a/package.json b/package.json index db4351ce..d4c8e108 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,7 @@ "src", ".claude", ".opencode", - ".github/skills", - "CLAUDE.md", - "AGENTS.md" + ".github/skills" ], "scripts": { "dev": "bun run src/cli.ts", diff --git a/src/cli.ts b/src/cli.ts index ff2e2f06..748829fc 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,7 +45,7 @@ export function createProgram() { .version(VERSION, "-v, --version", "Show version number") // Global options available to all commands - .option("-f, --force", "Overwrite all config files including CLAUDE.md/AGENTS.md") + .option("-f, --force", "Overwrite all config files") .option("-y, --yes", "Auto-confirm all prompts (non-interactive mode)") .option("--no-banner", "Skip ASCII banner display") diff --git a/src/commands/init.ts b/src/commands/init.ts index c0abf097..69ac34c1 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -319,7 +319,7 @@ export async function initCommand(options: InitOptions = {}): Promise { if (folderExists && !shouldForce && !autoConfirm) { const update = await confirm({ - message: `${agent.folder} already exists. Update config files? (CLAUDE.md/AGENTS.md will be preserved)`, + message: `${agent.folder} already exists. Update config files?`, initialValue: true, active: "Yes, update", inactive: "No, cancel", @@ -342,8 +342,8 @@ export async function initCommand(options: InitOptions = {}): Promise { } // Note: When autoConfirm is true and folder exists, we proceed with the update // but do NOT set shouldForce to true. This preserves the correct behavior where - // --yes auto-confirms prompts but preserved files (CLAUDE.md/AGENTS.md) are still - // protected unless --force is also provided. + // --yes auto-confirms prompts but preserved files are still protected unless + // --force is also provided. // Copy files with spinner const s = spinner(); @@ -385,7 +385,7 @@ export async function initCommand(options: InitOptions = {}): Promise { const shouldPreserve = agent.preserve_files.includes(file); const shouldMerge = agent.merge_files.includes(file); - // Preserved files (CLAUDE.md, AGENTS.md) are only overwritten if: + // Preserved files are only overwritten if: // 1. --force flag is set, OR // 2. The file is empty (0 bytes or whitespace-only) if (shouldPreserve && destExists && !shouldForce) { diff --git a/src/config.ts b/src/config.ts index 33ab5825..9c364cb8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,8 +34,8 @@ export const AGENT_CONFIG: Record = { folder: ".claude", install_url: "https://docs.anthropic.com/en/docs/claude-code/setup", exclude: [".DS_Store"], - additional_files: ["CLAUDE.md", ".mcp.json"], - preserve_files: ["CLAUDE.md"], + additional_files: [".mcp.json"], + preserve_files: [], merge_files: [".mcp.json"], }, opencode: { @@ -51,8 +51,8 @@ export const AGENT_CONFIG: Record = { "package.json", ".DS_Store", ], - additional_files: ["AGENTS.md"], - preserve_files: ["AGENTS.md"], + additional_files: [], + preserve_files: [], merge_files: [], }, copilot: { @@ -63,8 +63,8 @@ export const AGENT_CONFIG: Record = { install_url: "https://github.com/github/copilot-cli?tab=readme-ov-file#installation", exclude: ["workflows", "dependabot.yml", ".DS_Store"], - additional_files: ["AGENTS.md"], - preserve_files: ["AGENTS.md"], + additional_files: [], + preserve_files: [], merge_files: [], }, }; diff --git a/src/ui/commands/skill-commands.ts b/src/ui/commands/skill-commands.ts index 774bc362..1259cdf9 100644 --- a/src/ui/commands/skill-commands.ts +++ b/src/ui/commands/skill-commands.ts @@ -11,9 +11,9 @@ */ import type { - CommandDefinition, - CommandContext, - CommandResult, + CommandDefinition, + CommandContext, + CommandResult, } from "./registry.ts"; import { globalRegistry } from "./registry.ts"; import { existsSync, readdirSync, readFileSync } from "node:fs"; @@ -29,12 +29,12 @@ import { parseMarkdownFrontmatter } from "../../utils/markdown.ts"; * Metadata for a skill command definition. */ export interface SkillMetadata { - /** Skill name (without leading slash) - used as command name */ - name: string; - /** Human-readable description */ - description: string; - /** Alternative names for the skill */ - aliases?: string[]; + /** Skill name (without leading slash) - used as command name */ + name: string; + /** Human-readable description */ + description: string; + /** Alternative names for the skill */ + aliases?: string[]; } /** @@ -45,18 +45,18 @@ export interface SkillMetadata { * and not dependent on external files. */ export interface BuiltinSkill { - /** Skill name (without leading slash) - used as command name */ - name: string; - /** Human-readable description of what the skill does */ - description: string; - /** Alternative command names for the skill */ - aliases?: string[]; - /** Full prompt content (supports $ARGUMENTS placeholder) */ - prompt: string; - /** Hint text showing expected arguments (e.g., "[message] [--amend]") */ - argumentHint?: string; - /** List of required argument names. Command returns an error when any are missing. */ - requiredArguments?: string[]; + /** Skill name (without leading slash) - used as command name */ + name: string; + /** Human-readable description of what the skill does */ + description: string; + /** Alternative command names for the skill */ + aliases?: string[]; + /** Full prompt content (supports $ARGUMENTS placeholder) */ + prompt: string; + /** Hint text showing expected arguments (e.g., "[message] [--amend]") */ + argumentHint?: string; + /** List of required argument names. Command returns an error when any are missing. */ + requiredArguments?: string[]; } // ============================================================================ @@ -70,13 +70,14 @@ export interface BuiltinSkill { * They take priority over disk-based skill definitions. */ export const BUILTIN_SKILLS: BuiltinSkill[] = [ - { - name: "research-codebase", - description: "Document codebase as-is with research directory for historical context", - aliases: ["research"], - argumentHint: "", - requiredArguments: ["research-question"], - prompt: `# Research Codebase + { + name: "research-codebase", + description: + "Document codebase as-is with research directory for historical context", + aliases: ["research"], + argumentHint: "", + requiredArguments: ["research-question"], + prompt: `# Research Codebase You are tasked with conducting comprehensive research across the codebase to answer user questions by spawning parallel sub-agents and synthesizing their findings. @@ -276,14 +277,15 @@ research/ - A collection of research files with comprehensive research findings, properly formatted and linked, ready for consumption to create detailed specifications or design documents. - IMPORTANT: DO NOT generate any other artifacts or files OUTSIDE of the \`research/\` directory.`, - }, - { - name: "create-spec", - description: "Create a detailed execution plan for implementing features or refactors in a codebase by leveraging existing research in the specified `research` directory.", - aliases: ["spec"], - argumentHint: "", - requiredArguments: ["research-path"], - prompt: `You are tasked with creating a spec for implementing a new feature or system change in the codebase by leveraging existing research in the **$ARGUMENTS** path. If no research path is specified, use the entire \`research/\` directory. IMPORTANT: Research documents are located in the \`research/\` directory — do NOT look in the \`specs/\` directory for research. Follow the template below to produce a comprehensive specification as output in the \`specs/\` folder using the findings from RELEVANT research documents found in \`research/\`. Tip: It's good practice to use the \`codebase-research-locator\` and \`codebase-research-analyzer\` agents to help you find and analyze the research documents in the \`research/\` directory. It is also HIGHLY recommended to cite relevant research throughout the spec for additional context. + }, + { + name: "create-spec", + description: + "Create a detailed execution plan for implementing features or refactors in a codebase by leveraging existing research in the specified `research` directory.", + aliases: ["spec"], + argumentHint: "", + requiredArguments: ["research-path"], + prompt: `You are tasked with creating a spec for implementing a new feature or system change in the codebase by leveraging existing research in the **$ARGUMENTS** path. If no research path is specified, use the entire \`research/\` directory. IMPORTANT: Research documents are located in the \`research/\` directory — do NOT look in the \`specs/\` directory for research. Follow the template below to produce a comprehensive specification as output in the \`specs/\` folder using the findings from RELEVANT research documents found in \`research/\`. Tip: It's good practice to use the \`codebase-research-locator\` and \`codebase-research-analyzer\` agents to help you find and analyze the research documents in the \`research/\` directory. It is also HIGHLY recommended to cite relevant research throughout the spec for additional context. Please DO NOT implement anything in this stage, just create the comprehensive spec as described below. @@ -505,7 +507,7 @@ flowchart TB ### 8.3 Test Plan -- **Unit Tests:** +- **Unit Tests:** - **Integration Tests:** - **End-to-End Tests:** @@ -515,14 +517,14 @@ flowchart TB - [ ] Will the Legal team approve the 3rd party library for PDF generation? - [ ] Does the current VPC peering allow connection to the legacy mainframe?`, - }, - { - name: "explain-code", - description: "Explain code functionality in detail.", - aliases: ["explain"], - argumentHint: "", - requiredArguments: ["code-path"], - prompt: `# Analyze and Explain Code Functionality + }, + { + name: "explain-code", + description: "Explain code functionality in detail.", + aliases: ["explain"], + argumentHint: "", + requiredArguments: ["code-path"], + prompt: `# Analyze and Explain Code Functionality ## Available Tools @@ -723,14 +725,15 @@ Remember to: - Include visual diagrams or flowcharts when helpful - Tailor the explanation level to the intended audience - Use DeepWiki to look up external library documentation when encountering unfamiliar dependencies`, - }, - { - name: "prompt-engineer", - description: "Skill: Create, improve, or optimize prompts for Claude using best practices", - aliases: ["prompt"], - argumentHint: "", - requiredArguments: ["prompt-description"], - prompt: `# Prompt Engineering Skill + }, + { + name: "prompt-engineer", + description: + "Skill: Create, improve, or optimize prompts for Claude using best practices", + aliases: ["prompt"], + argumentHint: "", + requiredArguments: ["prompt-description"], + prompt: `# Prompt Engineering Skill This skill provides comprehensive guidance for creating effective prompts for Claude based on Anthropic's official best practices. Use this skill whenever working on prompt design, optimization, or troubleshooting. @@ -900,12 +903,13 @@ Always validate critical outputs, especially for high-stakes applications. No pr **Experimentation** Prompt engineering is iterative. Small changes can have significant impacts. Test variations and measure results.`, - }, - { - name: "testing-anti-patterns", - description: "Skill: Identify and prevent testing anti-patterns when writing tests", - aliases: ["test-patterns"], - prompt: `# Testing Anti-Patterns + }, + { + name: "testing-anti-patterns", + description: + "Skill: Identify and prevent testing anti-patterns when writing tests", + aliases: ["test-patterns"], + prompt: `# Testing Anti-Patterns ## Overview @@ -1097,13 +1101,113 @@ TDD cycle: If TDD reveals you're testing mock behavior, you've gone wrong. Fix: Test real behavior or question why you're mocking at all.`, - }, - { - name: "frontend-design", - description: "Create distinctive, production-grade frontend interfaces with high design quality", - aliases: ["fd", "design"], - argumentHint: "", - prompt: `This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. + }, + { + name: "init", + description: + "Generate CLAUDE.md and AGENTS.md by exploring the codebase", + prompt: `# Generate CLAUDE.md and AGENTS.md + +You are tasked with exploring the current codebase with the codebase-analyzer, codebase-locator, codebase-pattern-finder sub-agents and generating populated \`CLAUDE.md\` and \`AGENTS.md\` files at the project root. These files provide coding agents with the context they need to work effectively in this repository. + +## Steps + +1. **Explore the codebase to discover project metadata:** + - Read \`package.json\`, \`Cargo.toml\`, \`go.mod\`, \`pyproject.toml\`, \`Gemfile\`, \`pom.xml\`, or similar manifest files + - Scan the top-level directory structure (\`src/\`, \`lib/\`, \`app/\`, \`tests/\`, \`docs/\`, etc.) + - Check for existing config files: \`.eslintrc\`, \`tsconfig.json\`, \`biome.json\`, \`oxlint.json\`, \`.prettierrc\`, CI configs (\`.github/workflows/\`, \`.gitlab-ci.yml\`), etc. + - Read \`README.md\` if it exists for project description and setup instructions + - Check for \`.env.example\`, \`.env.local\`, or similar environment files + - Identify the package manager (bun, npm, yarn, pnpm, cargo, go, pip, etc.) + +2. **Identify key project attributes:** + - **Project name**: From manifest file or directory name + - **Project purpose**: 1-2 sentence description from README or manifest + - **Project structure**: Key directories and their purposes + - **Tech stack**: Language, framework, runtime + - **Commands**: dev, build, test, lint, typecheck, format (from scripts in manifest) + - **Environment setup**: Required env vars, env example files + - **Verification command**: The command to run before commits (usually lint + typecheck + test) + - **Existing documentation**: Links to docs within the repo + +3. **Populate the template below** with discovered values. Replace every \`{{placeholder}}\` with actual values from the repo. Delete sections that don't apply (e.g., Environment if there are no env files). Remove the "How to Fill This Template" meta-section entirely. + +4. **Write the populated content** to both \`CLAUDE.md\` and \`AGENTS.md\` at the project root with identical content. + +## Template + +\`\`\`markdown +# {{PROJECT_NAME}} + +## Overview + +{{1-2 sentences describing the project purpose}} + +## Project Structure + +| Path | Type | Purpose | +| ---------- | -------- | ----------- | +| \\\`{{path}}\\\` | {{type}} | {{purpose}} | + +## Quick Reference + +### Commands + +\\\`\\\`\\\`bash +{{dev_command}} # Start dev server / all services +{{build_command}} # Build the project +{{test_command}} # Run tests +{{lint_command}} # Lint & format check +{{typecheck_command}} # Type-check (if applicable) +\\\`\\\`\\\` + +### Environment + +- Copy \\\`{{env_example_file}}\\\` → \\\`{{env_local_file}}\\\` for local development +- Required vars: {{comma-separated list of required env vars}} + +## Progressive Disclosure + +Read relevant docs before starting: +| Topic | Location | +| ----- | -------- | +| {{topic}} | \\\`{{path_to_doc}}\\\` | + +## Universal Rules + +1. Run \\\`{{verify_command}}\\\` before commits +2. Keep PRs focused on a single concern +3. {{Add any project-specific universal rules}} + +## Code Quality + +Formatting and linting are handled by automated tools: + +- \\\`{{lint_command}}\\\` — {{linter/formatter names}} +- \\\`{{format_command}}\\\` — Auto-fix formatting (if separate from lint) + +Run before committing. Don't manually check style—let tools do it. +\`\`\` + +## Important Notes + +- **Keep it under 100 lines** (ideally under 60) after populating +- **Every instruction must be universally applicable** to all tasks in the repo +- **No code style rules** — delegate to linters/formatters +- **No task-specific instructions** — use the progressive disclosure table +- **No code snippets** — use \`file:line\` pointers instead +- **Include verification commands** the agent can run to validate work +- Delete any section from the template that doesn't apply to this project +- Do NOT include the "How to Fill This Template" section in the output +- Write identical content to both \`CLAUDE.md\` and \`AGENTS.md\` at the project root`, + }, + { + name: "frontend-design", + description: + "Create distinctive, production-grade frontend interfaces with high design quality", + aliases: ["fd", "design"], + argumentHint: "", + prompt: `This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. The user provides frontend requirements: $ARGUMENTS @@ -1139,7 +1243,7 @@ Interpret creatively and make unexpected choices that feel genuinely designed fo **IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.`, - }, + }, ]; // ============================================================================ @@ -1153,27 +1257,29 @@ Remember: Claude is capable of extraordinary creative work. Don't hold back, sho * These are loaded from disk and are used as fallback when no built-in skill exists. */ export const SKILL_DEFINITIONS: SkillMetadata[] = [ - // Core skills - { - name: "research-codebase", - description: "Document codebase as-is with research directory for historical context", - aliases: ["research"], - }, - { - name: "create-spec", - description: "Create a detailed execution plan for implementing features or refactors in a codebase by leveraging existing research in the specified `research` directory.", - aliases: ["spec"], - }, - { - name: "explain-code", - description: "Explain code functionality in detail.", - aliases: ["explain"], - }, - - // Note: ralph:ralph-loop, ralph:cancel-ralph, and ralph:ralph-help replaced by SDK-native /ralph workflow - // Help for Ralph is now integrated into /help command, and /ralph description provides usage info - - // Note: prompt-engineer and testing-anti-patterns moved to BUILTIN_SKILLS + // Core skills + { + name: "research-codebase", + description: + "Document codebase as-is with research directory for historical context", + aliases: ["research"], + }, + { + name: "create-spec", + description: + "Create a detailed execution plan for implementing features or refactors in a codebase by leveraging existing research in the specified `research` directory.", + aliases: ["spec"], + }, + { + name: "explain-code", + description: "Explain code functionality in detail.", + aliases: ["explain"], + }, + + // Note: ralph:ralph-loop, ralph:cancel-ralph, and ralph:ralph-help replaced by SDK-native /ralph workflow + // Help for Ralph is now integrated into /help command, and /ralph description provides usage info + + // Note: prompt-engineer and testing-anti-patterns moved to BUILTIN_SKILLS ]; // ============================================================================ @@ -1184,7 +1290,7 @@ export const SKILL_DEFINITIONS: SkillMetadata[] = [ * Expand $ARGUMENTS placeholder in skill prompt with user arguments. */ function expandArguments(prompt: string, args: string): string { - return prompt.replace(/\$ARGUMENTS/g, args || "[no arguments provided]"); + return prompt.replace(/\$ARGUMENTS/g, args || "[no arguments provided]"); } /** @@ -1194,12 +1300,12 @@ function expandArguments(prompt: string, args: string): string { * @returns BuiltinSkill if found, undefined otherwise */ export function getBuiltinSkill(name: string): BuiltinSkill | undefined { - const lowerName = name.toLowerCase(); - return BUILTIN_SKILLS.find( - (s) => - s.name.toLowerCase() === lowerName || - s.aliases?.some((a) => a.toLowerCase() === lowerName) - ); + const lowerName = name.toLowerCase(); + return BUILTIN_SKILLS.find( + (s) => + s.name.toLowerCase() === lowerName || + s.aliases?.some((a) => a.toLowerCase() === lowerName), + ); } // ============================================================================ @@ -1213,48 +1319,53 @@ export function getBuiltinSkill(name: string): BuiltinSkill | undefined { * @returns Command definition for the skill */ function createSkillCommand(metadata: SkillMetadata): CommandDefinition { - return { - name: metadata.name, - description: metadata.description, - category: "skill", - aliases: metadata.aliases, - execute: (args: string, context: CommandContext): CommandResult => { - const skillArgs = args.trim(); - - // Check for builtin skill with embedded prompt - const builtinSkill = getBuiltinSkill(metadata.name); - if (builtinSkill) { - // Validate required arguments for builtin skills - if (builtinSkill.requiredArguments?.length && !skillArgs) { - const argList = builtinSkill.requiredArguments.map((a) => `<${a}>`).join(" "); - return { - success: false, - message: `Missing required argument.\nUsage: /${builtinSkill.name} ${argList}`, - }; - } - - // Use the embedded prompt directly - const expandedPrompt = expandArguments(builtinSkill.prompt, skillArgs); - context.sendSilentMessage(expandedPrompt); - return { - success: true, - }; - } - - // Fallback: send slash command to agent's native skill system - // This handles skills that aren't in BUILTIN_SKILLS (e.g., ralph:* skills) - // The agent SDK may process it internally. - const invocationMessage = skillArgs - ? `/${metadata.name} ${skillArgs}` - : `/${metadata.name}`; - context.sendSilentMessage(invocationMessage); - - return { - success: true, - // No message displayed - the agent will handle displaying the skill output - }; - }, - }; + return { + name: metadata.name, + description: metadata.description, + category: "skill", + aliases: metadata.aliases, + execute: (args: string, context: CommandContext): CommandResult => { + const skillArgs = args.trim(); + + // Check for builtin skill with embedded prompt + const builtinSkill = getBuiltinSkill(metadata.name); + if (builtinSkill) { + // Validate required arguments for builtin skills + if (builtinSkill.requiredArguments?.length && !skillArgs) { + const argList = builtinSkill.requiredArguments + .map((a) => `<${a}>`) + .join(" "); + return { + success: false, + message: `Missing required argument.\nUsage: /${builtinSkill.name} ${argList}`, + }; + } + + // Use the embedded prompt directly + const expandedPrompt = expandArguments( + builtinSkill.prompt, + skillArgs, + ); + context.sendSilentMessage(expandedPrompt); + return { + success: true, + }; + } + + // Fallback: send slash command to agent's native skill system + // This handles skills that aren't in BUILTIN_SKILLS (e.g., ralph:* skills) + // The agent SDK may process it internally. + const invocationMessage = skillArgs + ? `/${metadata.name} ${skillArgs}` + : `/${metadata.name}`; + context.sendSilentMessage(invocationMessage); + + return { + success: true, + // No message displayed - the agent will handle displaying the skill output + }; + }, + }; } // ============================================================================ @@ -1268,32 +1379,34 @@ function createSkillCommand(metadata: SkillMetadata): CommandDefinition { * @returns Command definition for the skill */ function createBuiltinSkillCommand(skill: BuiltinSkill): CommandDefinition { - return { - name: skill.name, - description: skill.description, - category: "skill", - aliases: skill.aliases, - argumentHint: skill.argumentHint, - execute: (args: string, context: CommandContext): CommandResult => { - const skillArgs = args.trim(); - - // Validate required arguments - if (skill.requiredArguments?.length && !skillArgs) { - const argList = skill.requiredArguments.map((a) => `<${a}>`).join(" "); - return { - success: false, - message: `Missing required argument.\nUsage: /${skill.name} ${argList}`, - }; - } - - // Use the embedded prompt directly and expand $ARGUMENTS - const expandedPrompt = expandArguments(skill.prompt, skillArgs); - context.sendSilentMessage(expandedPrompt); - return { - success: true, - }; - }, - }; + return { + name: skill.name, + description: skill.description, + category: "skill", + aliases: skill.aliases, + argumentHint: skill.argumentHint, + execute: (args: string, context: CommandContext): CommandResult => { + const skillArgs = args.trim(); + + // Validate required arguments + if (skill.requiredArguments?.length && !skillArgs) { + const argList = skill.requiredArguments + .map((a) => `<${a}>`) + .join(" "); + return { + success: false, + message: `Missing required argument.\nUsage: /${skill.name} ${argList}`, + }; + } + + // Use the embedded prompt directly and expand $ARGUMENTS + const expandedPrompt = expandArguments(skill.prompt, skillArgs); + context.sendSilentMessage(expandedPrompt); + return { + success: true, + }; + }, + }; } // ============================================================================ @@ -1303,15 +1416,14 @@ function createBuiltinSkillCommand(skill: BuiltinSkill): CommandDefinition { /** * Skill commands created from definitions (legacy disk-based fallback). */ -export const skillCommands: CommandDefinition[] = SKILL_DEFINITIONS.map( - createSkillCommand -); +export const skillCommands: CommandDefinition[] = + SKILL_DEFINITIONS.map(createSkillCommand); /** * Builtin skill commands created from BUILTIN_SKILLS array. */ export const builtinSkillCommands: CommandDefinition[] = BUILTIN_SKILLS.map( - createBuiltinSkillCommand + createBuiltinSkillCommand, ); /** @@ -1329,12 +1441,12 @@ export const builtinSkillCommands: CommandDefinition[] = BUILTIN_SKILLS.map( * ``` */ export function registerBuiltinSkills(): void { - for (const command of builtinSkillCommands) { - // Skip if already registered (idempotent) - if (!globalRegistry.has(command.name)) { - globalRegistry.register(command); + for (const command of builtinSkillCommands) { + // Skip if already registered (idempotent) + if (!globalRegistry.has(command.name)) { + globalRegistry.register(command); + } } - } } /** @@ -1352,16 +1464,16 @@ export function registerBuiltinSkills(): void { * ``` */ export function registerSkillCommands(): void { - // First register builtin skills (they take priority) - registerBuiltinSkills(); - - // Then register legacy skill definitions (for skills not in BUILTIN_SKILLS) - for (const command of skillCommands) { - // Skip if already registered (builtin skills take priority) - if (!globalRegistry.has(command.name)) { - globalRegistry.register(command); + // First register builtin skills (they take priority) + registerBuiltinSkills(); + + // Then register legacy skill definitions (for skills not in BUILTIN_SKILLS) + for (const command of skillCommands) { + // Skip if already registered (builtin skills take priority) + if (!globalRegistry.has(command.name)) { + globalRegistry.register(command); + } } - } } // ============================================================================ @@ -1371,255 +1483,285 @@ export function registerSkillCommands(): void { const HOME = homedir(); export const SKILL_DISCOVERY_PATHS = [ - join(".claude", "skills"), - join(".opencode", "skills"), - join(".github", "skills"), + join(".claude", "skills"), + join(".opencode", "skills"), + join(".github", "skills"), ] as const; export const GLOBAL_SKILL_PATHS = [ - join(HOME, ".claude", "skills"), - join(HOME, ".opencode", "skills"), - join(HOME, ".copilot", "skills"), + join(HOME, ".claude", "skills"), + join(HOME, ".opencode", "skills"), + join(HOME, ".copilot", "skills"), ] as const; export type SkillSource = "project" | "user" | "builtin"; export const PINNED_BUILTIN_SKILLS = new Set([ - "prompt-engineer", - "testing-anti-patterns", + "prompt-engineer", + "testing-anti-patterns", ]); export interface DiscoveredSkillFile { - path: string; - dirName: string; - source: SkillSource; + path: string; + dirName: string; + source: SkillSource; } export interface DiskSkillDefinition { - name: string; - description: string; - skillFilePath: string; - source: SkillSource; - aliases?: string[]; - argumentHint?: string; + name: string; + description: string; + skillFilePath: string; + source: SkillSource; + aliases?: string[]; + argumentHint?: string; } export function shouldSkillOverride( - newSource: SkillSource, - existingSource: SkillSource, - existingName: string + newSource: SkillSource, + existingSource: SkillSource, + existingName: string, ): boolean { - if (existingSource === "builtin" && PINNED_BUILTIN_SKILLS.has(existingName)) { - return false; - } - const priority: Record = { - project: 3, - user: 2, - builtin: 1, - }; - return priority[newSource] > priority[existingSource]; + if ( + existingSource === "builtin" && + PINNED_BUILTIN_SKILLS.has(existingName) + ) { + return false; + } + const priority: Record = { + project: 3, + user: 2, + builtin: 1, + }; + return priority[newSource] > priority[existingSource]; } export function discoverSkillFiles(): DiscoveredSkillFile[] { - const files: DiscoveredSkillFile[] = []; - const cwd = process.cwd(); - - for (const discoveryPath of SKILL_DISCOVERY_PATHS) { - const fullPath = join(cwd, discoveryPath); - if (!existsSync(fullPath)) continue; - - try { - const entries = readdirSync(fullPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const skillFile = join(fullPath, entry.name, "SKILL.md"); - if (existsSync(skillFile)) { - files.push({ path: skillFile, dirName: entry.name, source: "project" }); + const files: DiscoveredSkillFile[] = []; + const cwd = process.cwd(); + + for (const discoveryPath of SKILL_DISCOVERY_PATHS) { + const fullPath = join(cwd, discoveryPath); + if (!existsSync(fullPath)) continue; + + try { + const entries = readdirSync(fullPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillFile = join(fullPath, entry.name, "SKILL.md"); + if (existsSync(skillFile)) { + files.push({ + path: skillFile, + dirName: entry.name, + source: "project", + }); + } + } + } catch { + // Skip inaccessible directories } - } - } catch { - // Skip inaccessible directories } - } - - for (const globalPath of GLOBAL_SKILL_PATHS) { - if (!existsSync(globalPath)) continue; - try { - const entries = readdirSync(globalPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const skillFile = join(globalPath, entry.name, "SKILL.md"); - if (existsSync(skillFile)) { - files.push({ path: skillFile, dirName: entry.name, source: "user" }); + for (const globalPath of GLOBAL_SKILL_PATHS) { + if (!existsSync(globalPath)) continue; + + try { + const entries = readdirSync(globalPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillFile = join(globalPath, entry.name, "SKILL.md"); + if (existsSync(skillFile)) { + files.push({ + path: skillFile, + dirName: entry.name, + source: "user", + }); + } + } + } catch { + // Skip inaccessible directories } - } - } catch { - // Skip inaccessible directories } - } - return files; + return files; } -export function parseSkillFile(file: DiscoveredSkillFile): DiskSkillDefinition | null { - try { - const content = readFileSync(file.path, "utf-8"); - const parsed = parseMarkdownFrontmatter(content); - - if (!parsed) { - return { - name: file.dirName, - description: `Skill: ${file.dirName}`, - skillFilePath: file.path, - source: file.source, - }; - } - - const fm = parsed.frontmatter; - const name = typeof fm.name === "string" ? fm.name : file.dirName; - const description = - typeof fm.description === "string" ? fm.description : `Skill: ${name}`; +export function parseSkillFile( + file: DiscoveredSkillFile, +): DiskSkillDefinition | null { + try { + const content = readFileSync(file.path, "utf-8"); + const parsed = parseMarkdownFrontmatter(content); + + if (!parsed) { + return { + name: file.dirName, + description: `Skill: ${file.dirName}`, + skillFilePath: file.path, + source: file.source, + }; + } - let aliases: string[] | undefined; - if (Array.isArray(fm.aliases)) { - aliases = fm.aliases.filter((a): a is string => typeof a === "string"); - } + const fm = parsed.frontmatter; + const name = typeof fm.name === "string" ? fm.name : file.dirName; + const description = + typeof fm.description === "string" + ? fm.description + : `Skill: ${name}`; + + let aliases: string[] | undefined; + if (Array.isArray(fm.aliases)) { + aliases = fm.aliases.filter( + (a): a is string => typeof a === "string", + ); + } - const argumentHint = - typeof fm["argument-hint"] === "string" ? fm["argument-hint"] : undefined; + const argumentHint = + typeof fm["argument-hint"] === "string" + ? fm["argument-hint"] + : undefined; - return { - name, - description, - skillFilePath: file.path, - source: file.source, - aliases, - argumentHint, - }; - } catch { - return null; - } + return { + name, + description, + skillFilePath: file.path, + source: file.source, + aliases, + argumentHint, + }; + } catch { + return null; + } } export function loadSkillContent(skillFilePath: string): string | null { - try { - const content = readFileSync(skillFilePath, "utf-8"); - const parsed = parseMarkdownFrontmatter(content); - if (parsed) { - return parsed.body; + try { + const content = readFileSync(skillFilePath, "utf-8"); + const parsed = parseMarkdownFrontmatter(content); + if (parsed) { + return parsed.body; + } + // No frontmatter — return entire content as body + return content; + } catch { + return null; } - // No frontmatter — return entire content as body - return content; - } catch { - return null; - } } function createDiskSkillCommand(skill: DiskSkillDefinition): CommandDefinition { - return { - name: skill.name, - description: skill.description, - category: "skill", - aliases: skill.aliases, - argumentHint: skill.argumentHint, - execute: (args: string, context: CommandContext): CommandResult => { - const skillArgs = args.trim(); - - // Inherit requiredArguments validation from matching builtin skill - const builtinSkill = getBuiltinSkill(skill.name); - if (builtinSkill?.requiredArguments?.length && !skillArgs) { - const argList = builtinSkill.requiredArguments.map((a) => `<${a}>`).join(" "); - return { - success: false, - message: `Missing required argument.\nUsage: /${skill.name} ${argList}`, - }; - } - - const body = loadSkillContent(skill.skillFilePath); - if (!body) { - // Fallback to builtin prompt if disk file is empty/unreadable - if (builtinSkill) { - const expandedPrompt = expandArguments(builtinSkill.prompt, skillArgs); - context.sendSilentMessage(expandedPrompt); - return { success: true, skillLoaded: skill.name }; - } - // Delegate to the agent's native skill system (e.g. Copilot CLI - // loads skills itself via skillDirectories passed at session creation) - const invocationMessage = skillArgs - ? `/${skill.name} ${skillArgs}` - : `/${skill.name}`; - context.sendSilentMessage(invocationMessage); - return { success: true, skillLoaded: skill.name }; - } - const expandedPrompt = expandArguments(body, skillArgs); - context.sendSilentMessage(expandedPrompt); - return { success: true, skillLoaded: skill.name }; - }, - }; + return { + name: skill.name, + description: skill.description, + category: "skill", + aliases: skill.aliases, + argumentHint: skill.argumentHint, + execute: (args: string, context: CommandContext): CommandResult => { + const skillArgs = args.trim(); + + // Inherit requiredArguments validation from matching builtin skill + const builtinSkill = getBuiltinSkill(skill.name); + if (builtinSkill?.requiredArguments?.length && !skillArgs) { + const argList = builtinSkill.requiredArguments + .map((a) => `<${a}>`) + .join(" "); + return { + success: false, + message: `Missing required argument.\nUsage: /${skill.name} ${argList}`, + }; + } + + const body = loadSkillContent(skill.skillFilePath); + if (!body) { + // Fallback to builtin prompt if disk file is empty/unreadable + if (builtinSkill) { + const expandedPrompt = expandArguments( + builtinSkill.prompt, + skillArgs, + ); + context.sendSilentMessage(expandedPrompt); + return { success: true, skillLoaded: skill.name }; + } + // Delegate to the agent's native skill system (e.g. Copilot CLI + // loads skills itself via skillDirectories passed at session creation) + const invocationMessage = skillArgs + ? `/${skill.name} ${skillArgs}` + : `/${skill.name}`; + context.sendSilentMessage(invocationMessage); + return { success: true, skillLoaded: skill.name }; + } + const expandedPrompt = expandArguments(body, skillArgs); + context.sendSilentMessage(expandedPrompt); + return { success: true, skillLoaded: skill.name }; + }, + }; } let discoveredSkillDirectories: string[] = []; export function getDiscoveredSkillDirectories(): string[] { - return discoveredSkillDirectories; + return discoveredSkillDirectories; } export async function discoverAndRegisterDiskSkills(): Promise { - const files = discoverSkillFiles(); - - // Collect unique parent directories for SDK passthrough - const dirSet = new Set(); - for (const file of files) { - const parentDir = join(file.path, "..", ".."); - dirSet.add(parentDir); - } - discoveredSkillDirectories = [...dirSet]; - - // Build map with priority resolution - const resolved = new Map(); - for (const file of files) { - const skill = parseSkillFile(file); - if (!skill) continue; + const files = discoverSkillFiles(); - const existing = resolved.get(skill.name); - if (!existing || shouldSkillOverride(skill.source, existing.source, existing.name)) { - resolved.set(skill.name, skill); + // Collect unique parent directories for SDK passthrough + const dirSet = new Set(); + for (const file of files) { + const parentDir = join(file.path, "..", ".."); + dirSet.add(parentDir); } - } - - // Register resolved skills - for (const skill of resolved.values()) { - if (PINNED_BUILTIN_SKILLS.has(skill.name) && globalRegistry.has(skill.name)) { - continue; + discoveredSkillDirectories = [...dirSet]; + + // Build map with priority resolution + const resolved = new Map(); + for (const file of files) { + const skill = parseSkillFile(file); + if (!skill) continue; + + const existing = resolved.get(skill.name); + if ( + !existing || + shouldSkillOverride(skill.source, existing.source, existing.name) + ) { + resolved.set(skill.name, skill); + } } - // Inherit aliases from existing builtin if disk skill doesn't define its own - if (!skill.aliases && globalRegistry.has(skill.name)) { - const existing = globalRegistry.get(skill.name); - if (existing?.aliases) { - skill.aliases = existing.aliases; - } - } + // Register resolved skills + for (const skill of resolved.values()) { + if ( + PINNED_BUILTIN_SKILLS.has(skill.name) && + globalRegistry.has(skill.name) + ) { + continue; + } - // Inherit argumentHint from existing builtin if disk skill doesn't define its own - if (!skill.argumentHint && globalRegistry.has(skill.name)) { - const existing = globalRegistry.get(skill.name); - if (existing?.argumentHint) { - skill.argumentHint = existing.argumentHint; - } - } + // Inherit aliases from existing builtin if disk skill doesn't define its own + if (!skill.aliases && globalRegistry.has(skill.name)) { + const existing = globalRegistry.get(skill.name); + if (existing?.aliases) { + skill.aliases = existing.aliases; + } + } + + // Inherit argumentHint from existing builtin if disk skill doesn't define its own + if (!skill.argumentHint && globalRegistry.has(skill.name)) { + const existing = globalRegistry.get(skill.name); + if (existing?.argumentHint) { + skill.argumentHint = existing.argumentHint; + } + } - const command = createDiskSkillCommand(skill); - if (globalRegistry.has(skill.name)) { - if (shouldSkillOverride(skill.source, "builtin", skill.name)) { - globalRegistry.unregister(skill.name); - globalRegistry.register(command); - } - } else { - globalRegistry.register(command); + const command = createDiskSkillCommand(skill); + if (globalRegistry.has(skill.name)) { + if (shouldSkillOverride(skill.source, "builtin", skill.name)) { + globalRegistry.unregister(skill.name); + globalRegistry.register(command); + } + } else { + globalRegistry.register(command); + } } - } } /** @@ -1629,12 +1771,12 @@ export async function discoverAndRegisterDiskSkills(): Promise { * @returns SkillMetadata if found, undefined otherwise */ export function getSkillMetadata(name: string): SkillMetadata | undefined { - const lowerName = name.toLowerCase(); - return SKILL_DEFINITIONS.find( - (s) => - s.name.toLowerCase() === lowerName || - s.aliases?.some((a) => a.toLowerCase() === lowerName) - ); + const lowerName = name.toLowerCase(); + return SKILL_DEFINITIONS.find( + (s) => + s.name.toLowerCase() === lowerName || + s.aliases?.some((a) => a.toLowerCase() === lowerName), + ); } /** @@ -1644,7 +1786,7 @@ export function getSkillMetadata(name: string): SkillMetadata | undefined { * @returns True if this is a Ralph skill */ export function isRalphSkill(name: string): boolean { - return name.toLowerCase().startsWith("ralph:"); + return name.toLowerCase().startsWith("ralph:"); } /** @@ -1653,7 +1795,7 @@ export function isRalphSkill(name: string): boolean { * @returns Array of Ralph skill metadata */ export function getRalphSkills(): SkillMetadata[] { - return SKILL_DEFINITIONS.filter((s) => isRalphSkill(s.name)); + return SKILL_DEFINITIONS.filter((s) => isRalphSkill(s.name)); } /** @@ -1662,7 +1804,7 @@ export function getRalphSkills(): SkillMetadata[] { * @returns Array of core skill metadata */ export function getCoreSkills(): SkillMetadata[] { - return SKILL_DEFINITIONS.filter((s) => !isRalphSkill(s.name)); + return SKILL_DEFINITIONS.filter((s) => !isRalphSkill(s.name)); } // Export helper functions for testing and external use diff --git a/src/utils/copy.ts b/src/utils/copy.ts index f9afc0b2..1cb30b7d 100644 --- a/src/utils/copy.ts +++ b/src/utils/copy.ts @@ -167,7 +167,6 @@ export async function copyDir( copyPromises.push(copyFile(srcPath, destPath)); } else if (entry.isSymbolicLink()) { // Dereference symlinks: resolve target and copy as regular file - // This handles cases like AGENTS.md -> CLAUDE.md on Windows copyPromises.push(copySymlinkAsFile(srcPath, destPath)); } // Skip other special files (block devices, etc.) From 70db5d5552d7905ad3b9d46caa66cb87bc62d111 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 15 Feb 2026 06:20:07 +0000 Subject: [PATCH 2/2] fix(models): add live session model switching and normalize preferences Enable runtime model switching without requiring a new session for Claude and Copilot agents by adding setActiveSessionModel to the SDK client interface. - Add setActiveSessionModel to CodingAgentClient interface - Implement for ClaudeAgentClient (persists on session config for future turns) and CopilotClient (rebinds session with updated model) - Update UnifiedModelOperations to prefer SDK model setter over pendingModel/requiresNewSession fallback - Pass reasoning effort to Copilot during model switch - Fix model preference persistence to use effective model from modelOps rather than raw selected ID - Normalize Claude model preferences to canonical aliases (opus, sonnet, haiku) stripping provider prefixes Assistant-model: Claude Code --- src/models/model-operations.test.ts | 31 ++++++++++++++ src/models/model-operations.ts | 28 ++++++++---- src/sdk/claude-client.test.ts | 38 +++++++++++++++++ src/sdk/claude-client.ts | 28 ++++++++++++ src/sdk/copilot-client.ts | 54 ++++++++++++++++++++++++ src/sdk/types.ts | 9 ++++ src/ui/chat.tsx | 12 ++++-- src/ui/commands/builtin-commands.test.ts | 21 +++++++++ src/ui/commands/builtin-commands.ts | 18 +++++--- src/ui/index.ts | 12 ++++-- src/utils/settings.test.ts | 15 ++++++- src/utils/settings.ts | 36 +++++++++++----- 12 files changed, 268 insertions(+), 34 deletions(-) diff --git a/src/models/model-operations.test.ts b/src/models/model-operations.test.ts index 710687a1..cd6cb228 100644 --- a/src/models/model-operations.test.ts +++ b/src/models/model-operations.test.ts @@ -182,6 +182,37 @@ describe("UnifiedModelOperations - setModel", () => { expect(ops.getPendingModel()).toBe("gpt-4o"); }); + test("switches Copilot model immediately when SDK model setter is available", async () => { + let capturedModel: string | undefined; + let capturedReasoningEffort: string | undefined; + const mockSetModel = async ( + model: string, + options?: { reasoningEffort?: string } + ) => { + capturedModel = model; + capturedReasoningEffort = options?.reasoningEffort; + }; + const ops = new UnifiedModelOperations("copilot", mockSetModel); + const mockModels = [ + createMockModel({ + id: "github-copilot/gpt-4o", + providerID: "github-copilot", + modelID: "gpt-4o", + }), + ]; + spyOn(ops, "listAvailableModels").mockResolvedValue(mockModels); + ops.setPendingReasoningEffort("high"); + + const result = await ops.setModel("gpt-4o"); + + expect(result.success).toBe(true); + expect(result.requiresNewSession).toBeUndefined(); + expect(capturedModel).toBe("gpt-4o"); + expect(capturedReasoningEffort).toBe("high"); + expect(await ops.getCurrentModel()).toBe("gpt-4o"); + expect(ops.getPendingModel()).toBeUndefined(); + }); + test("validates model exists for Copilot before setting", async () => { const ops = new UnifiedModelOperations("copilot"); // Populate the cache through the public API by mocking listAvailableModels diff --git a/src/models/model-operations.ts b/src/models/model-operations.ts index 26f49de8..2724ff46 100644 --- a/src/models/model-operations.ts +++ b/src/models/model-operations.ts @@ -91,6 +91,11 @@ export interface ModelOperations { getPendingModel?(): string | undefined; } +type SdkSetModelFn = ( + model: string, + options?: { reasoningEffort?: string } +) => Promise; + /** * Unified implementation of model operations using SDKs as the source of truth * @@ -121,7 +126,7 @@ export class UnifiedModelOperations implements ModelOperations { */ constructor( private agentType: AgentType, - private sdkSetModel?: (model: string) => Promise, + private sdkSetModel?: SdkSetModelFn, private sdkListModels?: () => Promise>, initialModel?: string, ) { @@ -324,18 +329,25 @@ export class UnifiedModelOperations implements ModelOperations { await this.validateModelExists(resolvedModel); } - // Copilot limitation: model changes require a new session + // Prefer runtime SDK model switching when available. + if (this.sdkSetModel) { + await this.sdkSetModel( + resolvedModel, + this.agentType === "copilot" + ? { reasoningEffort: this.pendingReasoningEffort } + : undefined + ); + this.pendingModel = undefined; + this.currentModel = resolvedModel; + return { success: true }; + } + + // Fallback for SDKs that cannot switch the active session model. if (this.agentType === 'copilot') { this.pendingModel = resolvedModel; return { success: true, requiresNewSession: true }; } - // For other agents, call SDK if available - // SDK handles actual model validation and will throw with clear error if invalid - if (this.sdkSetModel) { - await this.sdkSetModel(resolvedModel); - } - this.currentModel = resolvedModel; return { success: true }; } diff --git a/src/sdk/claude-client.test.ts b/src/sdk/claude-client.test.ts index e5d99ae1..d7e3be54 100644 --- a/src/sdk/claude-client.test.ts +++ b/src/sdk/claude-client.test.ts @@ -50,3 +50,41 @@ describe("ClaudeAgentClient.getModelDisplayInfo", () => { expect(canonicalInfo.contextWindow).toBe(300_000); }); }); + +describe("ClaudeAgentClient.setActiveSessionModel", () => { + test("updates active session config without writing to the previous query transport", async () => { + const client = new ClaudeAgentClient(); + const setModelCalls: string[] = []; + + const sessions = (client as unknown as { sessions: Map }).sessions; + const state = { + query: { + setModel: async (model: string) => { + setModelCalls.push(model); + }, + }, + sessionId: "test-session", + sdkSessionId: null, + config: {}, + inputTokens: 0, + outputTokens: 0, + isClosed: false, + contextWindow: null, + systemToolsBaseline: null, + }; + sessions.set("test-session", state); + + await client.setActiveSessionModel("anthropic/sonnet"); + + expect((state.config as { model?: string }).model).toBe("sonnet"); + expect(setModelCalls).toHaveLength(0); + }); + + test("rejects default model alias", async () => { + const client = new ClaudeAgentClient(); + + await expect(client.setActiveSessionModel("default")).rejects.toThrow( + "Model 'default' is not supported for Claude. Use one of: opus, sonnet, haiku." + ); + }); +}); diff --git a/src/sdk/claude-client.ts b/src/sdk/claude-client.ts index 38f27c71..61df4919 100644 --- a/src/sdk/claude-client.ts +++ b/src/sdk/claude-client.ts @@ -1110,6 +1110,34 @@ export class ClaudeAgentClient implements CodingAgentClient { } } + /** + * Switch model for the active Claude session while preserving history. + * + * This client uses turn-scoped queries (send/stream each create a new Query), + * so persisting the model on session config is sufficient for future turns. + * Calling query.setModel() on the previous Query instance is unsafe because + * its underlying transport may already be closed between turns. + */ + async setActiveSessionModel(model: string): Promise { + const targetModel = stripProviderPrefix(model).trim(); + if (!targetModel) { + throw new Error("Model ID cannot be empty."); + } + if (targetModel.toLowerCase() === "default") { + throw new Error("Model 'default' is not supported for Claude. Use one of: opus, sonnet, haiku."); + } + + // Use the most recently created active session as the primary chat session. + const activeSessions = Array.from(this.sessions.values()).filter((state) => !state.isClosed); + const activeSession = activeSessions[activeSessions.length - 1]; + + if (!activeSession) { + return; + } + + activeSession.config.model = targetModel; + } + /** * Start the client */ diff --git a/src/sdk/copilot-client.ts b/src/sdk/copilot-client.ts index e5eef907..5f72f2c9 100644 --- a/src/sdk/copilot-client.ts +++ b/src/sdk/copilot-client.ts @@ -874,6 +874,60 @@ export class CopilotClient implements CodingAgentClient { } } + /** + * Switch model for the active Copilot session while preserving history. + * Rebinds the same session ID with updated model config. + */ + async setActiveSessionModel( + model: string, + options?: { reasoningEffort?: string } + ): Promise { + if (!this.isRunning || !this.sdkClient) { + throw new Error("Client not started. Call start() first."); + } + + const activeStates = Array.from(this.sessions.values()).filter((state) => !state.isClosed); + const activeState = activeStates[activeStates.length - 1]; + if (!activeState) { + return; + } + + const resolvedModel = stripProviderPrefix(model).trim(); + if (!resolvedModel) { + throw new Error("Model ID cannot be empty."); + } + + const defaultOptions = initCopilotSessionOptions(); + const permissionHandler = + this.permissionHandler + ?? defaultOptions.OnPermissionRequest + ?? this.createHITLPermissionHandler(activeState.sessionId); + + const resumeConfig: SdkResumeSessionConfig = { + model: resolvedModel, + ...(options?.reasoningEffort ? { reasoningEffort: options.reasoningEffort as SdkSessionConfig["reasoningEffort"] } : {}), + streaming: true, + tools: this.registeredTools.map((t) => this.convertTool(t)), + onPermissionRequest: permissionHandler, + onUserInputRequest: this.createUserInputHandler(activeState.sessionId), + }; + + const resumedSession = await this.sdkClient.resumeSession(activeState.sessionId, resumeConfig); + + activeState.unsubscribe(); + activeState.sdkSession = resumedSession; + activeState.config = { + ...activeState.config, + model: resolvedModel, + ...(options?.reasoningEffort !== undefined + ? { reasoningEffort: options.reasoningEffort } + : {}), + }; + activeState.unsubscribe = resumedSession.on((event: SdkSessionEvent) => { + this.handleSdkEvent(activeState.sessionId, event); + }); + } + /** * Register an event handler */ diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 978922b1..47487a90 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -616,6 +616,15 @@ export interface CodingAgentClient { */ getModelDisplayInfo(modelHint?: string): Promise; + /** + * Update the model for the currently active session, when the SDK supports it. + * Implementations should preserve existing conversation history. + */ + setActiveSessionModel?( + model: string, + options?: { reasoningEffort?: string } + ): Promise; + /** * Get the system tools token baseline at the client level (pre-session). * Available after start() for SDKs that support probing (e.g., Claude SDK diff --git a/src/ui/chat.tsx b/src/ui/chat.tsx index 51145481..c9b387ba 100644 --- a/src/ui/chat.tsx +++ b/src/ui/chat.tsx @@ -3046,22 +3046,26 @@ export function ChatApp({ setShowModelSelector(false); try { - const result = await modelOps?.setModel(selectedModel.id); if (modelOps && 'setPendingReasoningEffort' in modelOps) { (modelOps as { setPendingReasoningEffort: (e: string | undefined) => void }).setPendingReasoningEffort(reasoningEffort); } + const result = await modelOps?.setModel(selectedModel.id); + const effectiveModel = + modelOps?.getPendingModel?.() + ?? await modelOps?.getCurrentModel?.() + ?? selectedModel.id; const effortSuffix = reasoningEffort ? ` (${reasoningEffort})` : ""; if (result?.requiresNewSession) { addMessage("assistant", `Model **${selectedModel.modelID}**${effortSuffix} will be used for the next session.`); } else { addMessage("assistant", `Switched to model **${selectedModel.modelID}**${effortSuffix}`); } - setCurrentModelId(selectedModel.id); - onModelChange?.(selectedModel.id); + setCurrentModelId(effectiveModel); + onModelChange?.(effectiveModel); const displaySuffix = (agentType === "copilot" && reasoningEffort) ? ` (${reasoningEffort})` : ""; setCurrentModelDisplayName(`${selectedModel.modelID}${displaySuffix}`); if (agentType) { - saveModelPreference(agentType, selectedModel.id); + saveModelPreference(agentType, effectiveModel); if (reasoningEffort) { saveReasoningEffortPreference(agentType, reasoningEffort); } else { diff --git a/src/ui/commands/builtin-commands.test.ts b/src/ui/commands/builtin-commands.test.ts index 914d9cd1..4c55a395 100644 --- a/src/ui/commands/builtin-commands.test.ts +++ b/src/ui/commands/builtin-commands.test.ts @@ -349,6 +349,27 @@ describe("Built-in Commands", () => { expect(result.stateUpdate).toHaveProperty("model", "claude-sonnet-4"); }); + test("uses effective model from modelOps for state update", async () => { + const context = createMockContext({ + state: { + isStreaming: false, + messageCount: 1, + }, + agentType: "claude" as any, + modelOps: { + resolveAlias: (_model: string) => undefined, + setModel: async () => ({ requiresNewSession: false }), + getCurrentModel: async () => "opus", + } as any, + }); + + const result = await modelCommand.execute("anthropic/opus", context); + + expect(result.success).toBe(true); + expect(result.stateUpdate).toBeDefined(); + expect(result.stateUpdate).toHaveProperty("model", "opus"); + }); + test("handles model switch requiring new session", async () => { const context = createMockContext({ state: { diff --git a/src/ui/commands/builtin-commands.ts b/src/ui/commands/builtin-commands.ts index 7fc48d6c..252d1e9e 100644 --- a/src/ui/commands/builtin-commands.ts +++ b/src/ui/commands/builtin-commands.ts @@ -346,9 +346,17 @@ export const modelCommand: CommandDefinition = { try { const resolvedModel = modelOps?.resolveAlias(trimmed) ?? trimmed; + if (modelOps && "setPendingReasoningEffort" in modelOps) { + (modelOps as { setPendingReasoningEffort: (effort: string | undefined) => void }) + .setPendingReasoningEffort(undefined); + } const result = await modelOps?.setModel(resolvedModel); + const effectiveModel = + modelOps?.getPendingModel?.() + ?? await modelOps?.getCurrentModel?.() + ?? resolvedModel; if (agentType) { - saveModelPreference(agentType, resolvedModel); + saveModelPreference(agentType, effectiveModel); // Clear reasoning effort since the text command can't prompt for it; // user should use the interactive selector (/model select) to set effort clearReasoningEffortPreference(agentType); @@ -356,14 +364,14 @@ export const modelCommand: CommandDefinition = { if (result?.requiresNewSession) { return { success: true, - message: `Model **${resolvedModel}** will be used for the next session. (${agentType} requires a new session for model changes)`, - stateUpdate: { pendingModel: resolvedModel } as unknown as CommandResult["stateUpdate"], + message: `Model **${effectiveModel}** will be used for the next session. (${agentType} requires a new session for model changes)`, + stateUpdate: { pendingModel: effectiveModel } as unknown as CommandResult["stateUpdate"], }; } return { success: true, - message: `Model switched to **${resolvedModel}**`, - stateUpdate: { model: resolvedModel } as unknown as CommandResult["stateUpdate"], + message: `Model switched to **${effectiveModel}**`, + stateUpdate: { model: effectiveModel } as unknown as CommandResult["stateUpdate"], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/ui/index.ts b/src/ui/index.ts index f09c9a17..a4f3a34c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -289,11 +289,15 @@ export async function startChatUI( const sdkListModels = agentType === 'claude' && 'listSupportedModels' in client ? () => (client as import('../sdk/claude-client.ts').ClaudeAgentClient).listSupportedModels() : undefined; - const sdkSetModel = agentType === 'opencode' && 'setActivePromptModel' in client - ? async (model: string) => { - await (client as import('../sdk/opencode-client.ts').OpenCodeClient).setActivePromptModel(model); + const sdkSetModel = agentType === "opencode" && "setActivePromptModel" in client + ? async (selectedModel: string) => { + await (client as import("../sdk/opencode-client.ts").OpenCodeClient).setActivePromptModel(selectedModel); } - : undefined; + : agentType && "setActiveSessionModel" in client + ? async (selectedModel: string, options?: { reasoningEffort?: string }) => { + await client.setActiveSessionModel?.(selectedModel, options); + } + : undefined; const modelOps = agentType ? new UnifiedModelOperations(agentType, sdkSetModel, sdkListModels, sessionConfig?.model) : undefined; // Initialize state diff --git a/src/utils/settings.test.ts b/src/utils/settings.test.ts index b68b48ab..ef692260 100644 --- a/src/utils/settings.test.ts +++ b/src/utils/settings.test.ts @@ -55,7 +55,7 @@ describe("settings persistence", () => { const localPath = join(cwdDir, ".atomic", "settings.json"); writeJson(localPath, { model: { claude: "anthropic/default" } }); - expect(getModelPreference("claude")).toBe("anthropic/opus"); + expect(getModelPreference("claude")).toBe("opus"); }); test("writes model preferences to global only and normalizes claude default", () => { @@ -129,7 +129,18 @@ describe("settings persistence", () => { const globalPath = join(homeDir, ".atomic", "settings.json"); writeJson(globalPath, { model: { claude: "anthropic/default" } }); - expect(getModelPreference("claude")).toBe("anthropic/opus"); + expect(getModelPreference("claude")).toBe("opus"); + }); + + test("saveModelPreference strips Claude provider prefix and stores canonical alias", () => { + const globalPath = join(homeDir, ".atomic", "settings.json"); + + saveModelPreference("claude", "anthropic/sonnet"); + + const settings = JSON.parse(readFileSync(globalPath, "utf-8")) as { + model?: Record; + }; + expect(settings.model?.claude).toBe("sonnet"); }); test("getModelPreference trims whitespace from model ID", () => { diff --git a/src/utils/settings.ts b/src/utils/settings.ts index d0ba55fb..0329a709 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -18,24 +18,38 @@ interface AtomicSettings { reasoningEffort?: Record; // agentType -> effort level } -function normalizeModelPreference(agentType: string, modelId: string): string { - if (agentType !== "claude") { - return modelId; - } +const CLAUDE_CANONICAL_MODELS = ["opus", "sonnet", "haiku"] as const; +function extractClaudeModelId(modelId: string): string { const trimmed = modelId.trim(); - if (trimmed.toLowerCase() === "default") { + if (!trimmed) return trimmed; + if (!trimmed.includes("/")) return trimmed; + const parts = trimmed.split("/"); + return parts.length >= 2 ? parts.slice(1).join("/") : trimmed; +} + +function normalizeClaudeModelPreference(modelId: string): string { + const normalized = extractClaudeModelId(modelId).trim().toLowerCase(); + if (!normalized || normalized === "default") { return "opus"; } - if (trimmed.includes("/")) { - const parts = trimmed.split("/"); - if (parts.length === 2 && parts[1]?.toLowerCase() === "default") { - return `${parts[0]}/opus`; - } + const canonical = CLAUDE_CANONICAL_MODELS.find((name) => + normalized === name || normalized.includes(name) + ); + if (canonical) { + return canonical; + } + + return extractClaudeModelId(modelId).trim(); +} + +function normalizeModelPreference(agentType: string, modelId: string): string { + if (agentType !== "claude") { + return modelId; } - return trimmed; + return normalizeClaudeModelPreference(modelId); } /** Global settings path: ~/.atomic/settings.json */