This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Run all tests:
bun test
Run a single test file:
bun test src/commands/ask.test.ts
Type check (no emit):
npx tsc --noEmit
Run the CLI directly:
bun run src/main.ts <command> [args]
The project has no build scripts in package.json — it runs directly via Bun which handles TypeScript natively. dist/ is gitignored.
my-cli is a Bun + TypeScript CLI AI assistant. It provides multi-turn LLM conversations with function calling (tool use), session management, MCP tool integration, and file attachments.
- Uses
Bun.file(),Bun.write(),Bun.spawn()— native Bun APIs - Tests use
bun:test(describe,test,expect,beforeEach,afterEach) - No
package.jsonscripts — run viabun run src/main.tsorbun test
The askCommand exports factory objects (not bare functions) for all external dependencies. Tests override properties on these factories:
// In ask.ts
export const streamChatFactory = { call: streamChat };
export const storeFactory = { getSession, getOrCreateActiveSession, updateSession, setActiveSessionId };
export const chatWithToolsFactory = { call: streamChatWithTools };
export const executorFactory = { execute: executeUnifiedTool };
export const toolsStoreFactory = { loadTools: getUnifiedToolDefs };Test example: streamChatFactory.call = async () => ({ reply: 'mock', thinking: '' })
- Entry:
src/main.ts— parses argv, loads config, resolves command via Registry - Registry:
src/registry.ts— tree-based command router (supports nested subcommands likesession new) - Args:
src/args.ts— parses--flag value,--boolean, positional args;fileflag aggregates into arrays;--btwskips session save - Config:
src/config/loader.tsmerges 3 layers: file (~/.config/my-cli/config.json) → env vars (MY_CLI_*) → CLI overrides - Ask flow (
src/commands/ask.ts): loads agent.md as system prompt → loads session → appliestrimMessages→ calls LLM viastreamChat(no tools) orstreamChatWithToolsloop (up to 10 tool call iterations) → renders markdown → saves session
| Module | Purpose |
|---|---|
src/llm/client.ts |
streamChat() returns {reply, thinking}; streamChatWithTools() returns {reply, thinking, toolCalls}; both use SSE streaming internally via streamChatInternal() |
src/tools/store.ts |
Unified tool system: getUnifiedToolDefs() merges builtins + MCP tools; executeUnifiedTool() routes by source field |
src/tools/base.ts |
ToolExecutor interface: { execute(args): Promise<string> } |
src/tools/builtin/ |
Built-in tools: weather (Open-Meteo API), file (read/write/append) |
src/mcp/client.ts |
Lazy singleton mcporter runtime; discovers tools from enabled servers; naming: servername__toolname |
src/session/store.ts |
Session CRUD; ID format YYYYMMDD-HHmmss-xxxx; files in ~/.config/my-cli/sessions/ |
src/utils/context.ts |
trimMessages() uses group-based token counting (preserves assistant+tool pairs); triggers at 80% context, trims to 50% |
src/utils/tokenizer.ts |
tiktoken cl100k_base singleton; must call freeEncoder() after each command |
src/utils/file.ts |
File attachments: images → base64 data URL; text files → inline content |
| File | Schema |
|---|---|
config.json |
zod-validated: model, contextWindow, activeSessionId, chatMode, builtinTools record |
llm-providers.json |
LLMConfig: { providers: LLMProvider[], defaultProvider: string }; each provider has models: ModelMap with context limits |
agent.md |
System prompt, generated by my-cli init |
mcp-servers.json |
{ mcpServers: Record<string, MCPServerConfig> }; discriminated union: { type: 'local', command: string[] } or { type: 'remote', url: string } |
ChatRole:'system' | 'user' | 'assistant' | 'tool'Message.role: adds'thinking'for reasoning content storageUnifiedTool.source:'builtin' | 'mcp'— used to route tool execution- MCP tool names use
servername__toolnameformat (double underscore separator)
streamChatWithToolsreturns empty reply on tool_calls (line 271 inllm/client.ts): whenfinishReason === 'tool_calls',fullReplyis reset to''andreply: ''is returnedtrimMessagespreserves tool call groups:groupMessages()groups assistant messages with their following tool messages, so trimming never breaks a call/result pair--btwflag: messages are NOT saved to session when this flag is setchatMode: 'lite' | 'normal': defined in schema but not actively used in code paths yetloadConfig()is synchronous (usesreadFileSync), whilesaveConfig()is async (useswriteFileSyncwrapped in Promise)- MCP runtime is a lazy singleton in
mcp/client.ts(_runtime), closed viacloseRuntime()in ask'sfinallyblock