This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Build and run
pnpm run build # Compile TypeScript to dist/ with executable permissions
pnpm run build:credits # Regenerate contributors.json from git history (CI/release only)
pnpm run start # Run the compiled application
pnpm run dev # Watch mode compilation (tsc --watch)
# Testing (run before committing)
pnpm run test:all # Full suite: format, lint, types, AVA tests, knip, audit, security
# Individual test commands
pnpm run test:ava source/path/to/file.spec.ts # Run single test file
pnpm run test:ava:coverage # Tests with coverage
pnpm run test:types # TypeScript checking only
pnpm run test:format # Biome format check
pnpm run test:lint # Biome lint check
pnpm run test:lint:fix # Auto-fix lint/format issues
pnpm run test:knip # Unused code detection
pnpm run test:benchmark # Run model benchmarks
# VS Code extension
pnpm run build:vscode # Build extension to assets/nanocoder-vscode.vsixNanocoder is a React-based CLI coding agent built with Ink.js that provides local-first AI assistance with multiple provider support (Ollama, OpenRouter, any OpenAI-compatible API).
Entry point: source/cli.tsx → dynamic import of App from source/app/App.tsx (re-exported via source/app/index.ts). cli.tsx has fast paths for --help/--version (no app import), copilot/codex device-flow login, and a --plain non-Ink shell (source/plain/shell.ts) for CI / non-TTY environments.
- Directory Trust Check (
useDirectoryTrust) - First-run security disclaimer for new directories - App Initialization (
useAppInitialization) - Creates LLM client, loads MCP servers, loads custom commands - Central State (
useAppState) - Single source of truth for 50+ state variables - Chat/Tool Flow - User input → LLM → tool confirmation → execution → response
source/hooks/- React hooks:useAppState(central state),useToolHandler,useModeHandlers,useAppHandlers(orchestrator), pluschat-handler/useChatHandler(LLM interaction, in subdir)source/app/-App.tsxplus app-internal helpers (utils/app-util.ts,utils/conversation-state.ts, prompt sections, orchestration hooks inapp/hooks/)source/ai-sdk-client/- Wrapper over Vercel AI SDK: chat handler, providers, converters, tool helpers, error handlingsource/tools/- Built-in tools (file ops, bash, search, web fetch). Registered insource/tools/tool-manager.ts; main file editors arestring_replaceandwrite_filesource/components/- Ink UI componentssource/config/- Configuration loading and preferencessource/commands/- Built-in slash commands (/model,/provider,/clear, etc.)source/custom-commands/- User-defined markdown commands from.nanocoder/commands/source/mcp/- Model Context Protocol server integrationsource/tool-calling/- XML/text tool-call parsers for the fallback path (non-native-tool models)source/services/- Checkpoint manager, bash executor, file snapshots, lifecycle hooks (user shell commands run at fixed points in the agent loop)source/usage/- Token/cost usage: breakdown calculator, per-response usage + cost builder (provider-reported tokens priced via models.dev), compact formatters for the per-response indicator, session usage storagesource/session/- Chat session persistence (autosave / resume)source/schedule/- Cron-based scheduled agent runs (schedulermode)source/subagents/- Subagent executor (delegated agent runs)source/auth/- Copilot / Codex device-flow loginsource/lsp/- Language server client integrationsource/wizards/- Interactive setup flows (config, MCP, providers)source/plain/- Non-Ink CLI shell used by--plainsource/skills/- Unified skill primitive: loaders (bundle + flat-dir adapter), manifest parser, frontmattersubscribe:parser, registrar that fans members into the existing command / subagent / tool registries, dispatcher for triggered runssource/events/- Event router, file watcher source (chokidar), cron source (croner), backpressure (per-subscription concurrency cap + 500ms trailing debounce onfile.changed)source/daemon/- Per-project daemon process: lockfile, Unix-socket IPC server / TUI client, CLI surface (nanocoder daemon <start|stop|status|logs>), launchd plist + systemd user unit installers
All state lives in source/hooks/useAppState.tsx. Other hooks (useChatHandler, useToolHandler, useModeHandlers) receive state and setters from it. source/app/App.tsx orchestrates them via useAppHandlers. Global source/utils/message-queue.tsx lets deep components push chat messages without prop-drilling.
Tools are registered in source/tools/tool-manager.ts with:
- handler: Executes the tool
- nativeTool: AI SDK tool definition
- formatter: Formats output for display
- validator: Pre-execution validation (optional)
File editing uses a content-based approach:
string_replace: Primary edit tool — replaces exact contentwrite_file: Whole file overwrites
Two execution paths exist: native tool calling (preferred, via AI SDK) and an XML fallback for models that don't support tools. LLMChatResponse.toolsDisabled signals which path produced the response. The conversation loop runs parseToolCalls() (in source/tool-calling/) whenever the response has no native tool calls — always on the fallback path, and on the native path too, since models marketed as native-tool-capable sometimes regress to emitting tool-call text. Malformed text there feeds the self-correction retry loop capped by nanocoder.retries.maxMalformedRetries.
Slash commands live in source/commands/ and are lazy-loaded via source/commands/lazy-registry.ts. To add a new command: create the command file exporting a Command object (name, description, handler), then add an entry to lazyCommands in the registry. Commands return React elements for Ink rendering. Some commands (clear, model, provider, etc.) need app state and are intercepted as "special commands" in source/app/utils/app-util.ts.
A command that does slow work before returning (LLM round-trip, network) should declare progressLabel. handleBuiltInCommand then holds a CommandProgress spinner in the live slot for the duration, so the UI is not silent while the handler runs. Set it on both the module's Command object and its lazyCommands entry - the spinner has to render before load() resolves, so it cannot be read off the lazily-imported module.
File-based tools live in source/custom-tools/. CustomToolLoader discovers .md files in .nanocoder/tools/ (project) and ~/.config/nanocoder/tools/ (personal, via getConfigPath()); project tools shadow personal ones by name. Each file's YAML frontmatter declares the schema; schema-builder.ts synthesizes both the AI SDK inputSchema and a ToolValidator. template.ts renders the script body with {{ name }} and {{# section }} placeholders, shell-quoting all substitutions. handler.ts spawns the shell with timeout + env/cwd resolution. build-tool.ts glues everything into a ToolEntry, then ToolManager.initializeCustomTools() registers them into the same registry as built-ins and MCP tools — downstream code (/tools, subagents, mode filtering) sees them through the unified registry. Mode policy for custom tools lives in ToolManager.getAvailableToolNames: plan mode requires approval=never && read_only=true, scheduler mode requires approval=never.
agents.config.jsonin working directory (project-level)- Platform config dir:
~/.config/nanocoder/agents.config.json(Linux),~/Library/Preferences/nanocoder/(macOS) ~/.agents.config.json(legacy fallback)
If NANOCODER_CONFIG_DIR is set, the platform/legacy lookups are skipped and that directory is used directly.
Environment variable substitution in config values: $VAR, ${VAR}, ${VAR:-default}
source/client-factory.ts creates clients via createLLMClient(provider?). Uses Vercel AI SDK (ai v6) with @ai-sdk/openai-compatible for any OpenAI-compatible API, plus dedicated @ai-sdk/anthropic and @ai-sdk/google providers. The wrapper logic (streaming, tool calls, error handling, prepareStep, retries) lives in source/ai-sdk-client/.
A skill is the unit of extension. Two ergonomic forms over one primitive:
- Single-file: a
.mdin.nanocoder/commands|agents|tools/. Filename is the skill name. Optionalsubscribe:block in frontmatter declares event triggers. Backwards-compatible with the legacy flat dirs. - Bundle: a directory under
.nanocoder/skills/<name>/containingskill.yamlplus optionalcommands/,agents/,tools/subdirs. Multi-piece features (e.g. apr-reviewerskill with a subagent + tool + command) live in a bundle.
Loaders (source/skills/bundle-loader.ts, source/skills/flat-loader.ts) read project / personal / built-in locations in priority order. The registrar (source/skills/registrar.ts) fans each skill's members into the existing CustomCommandLoader, SubagentLoader, and ToolManager.registry - downstream consumers (/tools, agent tool, mode filters) keep using their familiar registries.
Bundle tools default to tools_visibility: scoped: hidden from the global tool list, visible only to the bundle's own subagent. Single-file tools default to global.
Event triggers: subscriptions on a member's frontmatter or a manifest's subscribe: block. The per-project daemon (source/daemon/, started by nanocoder daemon start) owns file-watch and cron sources. The interactive TUI never starts event sources. confirm: true on a subscription dispatches the triggered run in plan mode instead of headless.
- TypeScript strict mode with
@/*path alias mapping tosource/* - Biome for formatting (tabs, single quotes, semicolons, trailing commas)
- Key lint rules:
useExhaustiveDependencies: error,noUnusedVariables: error,noUnusedImports: error - React 19 with Ink.js for CLI rendering
- Framework: AVA with tsx loader
- Location:
source/**/*.spec.tsfiles alongside source - Serial execution: Tests run one at a time
- Run single test:
pnpm run test:ava source/path/to/file.spec.ts
Four user-facing modes (toggle with Shift+Tab during chat):
- normal: Confirm each tool before execution
- auto-accept: Automatically execute most tools (bash and destructive git still prompt)
- yolo: Automatically execute every tool without exception
- plan: Show tool calls but don't execute
There is also an internal headless mode used by the daemon for every triggered skill run (file.changed, schedule.cron, future kinds). Same posture as the legacy scheduler mode it supersedes: no ask_user, no foreground confirmations. Per-subscription confirm: true opts into plan mode instead.