Architecture
Overview
claude-connector is a programmatic Node.js interface for the Claude Code CLI. It wraps the claude command-line tool (used via subscription) and exposes a clean TypeScript API for building integrations.
┌──────────────────────────────────────────────────────────────────┐
│ Consumer Code │
│ │
│ const claude = new Claude({ model: 'sonnet' }) │
│ const result = await claude.query('Fix the bug') │
└──────────────────┬───────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ Claude (Facade) │
│ │
│ Orchestrates all components. Validates input. Merges options. │
│ Delegates execution. │
│ Exposes: query, stream, chat, session, loop, parallel. │
└──────┬────────────────────┬──────────────────────┬───────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌────────────────────┐
│ ArgsBuilder │ │ IExecutor │ │ Session │
│ │ │ (interface) │ │ │
│ Converts │ │ │ │ Multi-turn state │
│ options → │ │ ┌────────┐ │ │ management via │
│ CLI args │ │ │ SDK │ │ │ --resume/--continue│
│ (constants) │ │ │Executor│ │ │ │
│ │ │ └────────┘ │ │ Returns StreamHandle│
└─────────────┘ │ ┌────────┐ │ └────────────────────┘
│ │ CLI │ │
│ │Executor│ │ ┌────────────────────┐
│ └────────┘ │ │ StreamHandle │
└──────┬───────┘ │ (Readable) │
│ │ │
│ │ .on() .done() │
▼ │ .text() .pipe() │
┌───────────────────┐ │ .toReadable() │
│ CLI Process │ └────────────────────┘
│ claude -p "..." │
│ --output-format │ ┌────────────────────┐
│ stream-json │ │ ChatHandle │
└───────────────────┘ │ (Duplex) │
│ │
│ .send() .pipe() │
│ .toReadable() │
│ .toDuplex() │
└────────────────────┘Design Principles
1. SOLID
Single Responsibility:
Claude— facade, delegates everythingArgsBuilder— only converts options to CLI args (using constants fromconstants.ts)SdkExecutor— manages persistent SDK sessions (default)CliExecutor— only spawns and manages CLI processesSession— only tracks session stateStreamHandle— fluent streaming API + Node.js Readable bridgeChatHandle— bidirectional streaming + Node.js Duplex bridgeScheduler— only manages recurring execution- Parsers — only parse CLI output
Open/Closed:
- New execution backends are added by implementing
IExecutor— no changes to existing code. - New CLI flags are added to
ArgsBuilder— parsers and executor remain unchanged. - New stream consumers are added via
StreamHandle.on()— no core changes needed.
Liskov Substitution:
- Any
IExecutorimplementation can replaceSdkExecutor/CliExecutorwithout breaking the client.
Interface Segregation:
IExecutorhas only 3 methods:execute,stream,abort.- Types are split into focused files:
client.ts,result.ts,session.ts.
Dependency Inversion:
Claudedepends onIExecutor(abstraction), notSdkExecutor(implementation).- Constructor injection:
new Claude(options, customExecutor).
2. No Magic Strings
All string literals (event types, CLI flags, permission modes, etc.) are centralized in constants.ts. Source files import named constants — no hardcoded strings anywhere in the codebase.
3. DRY
- Option merging logic is centralized in
mergeOptions(). - Validation is centralized in
utils/validation.ts, referencingVALID_PERMISSION_MODESandVALID_EFFORT_LEVELSfrom constants. - Error hierarchy has a single base class.
- Event dispatching logic is shared between
StreamHandleandChatHandle.
Layer Map
src/
├── constants.ts All string constants (events, flags, keys, modes)
├── index.ts Public API surface (re-exports)
├── types/ Type definitions (no runtime code)
│ ├── client.ts ClientOptions, QueryOptions, PermissionMode, EffortLevel
│ ├── result.ts QueryResult, StreamEvent, TokenUsage, Message
│ └── session.ts SessionOptions, SessionInfo
├── executor/ Execution abstraction
│ ├── interface.ts IExecutor, ExecuteOptions
│ ├── sdk-executor.ts SDK implementation (persistent session, default)
│ └── cli-executor.ts CLI implementation (spawn per query)
├── builder/ Options → CLI args
│ └── args-builder.ts buildArgs(), mergeOptions(), resolveEnv()
├── parser/ CLI output → typed objects
│ ├── json-parser.ts JSON mode parsing
│ └── stream-parser.ts NDJSON stream parsing
├── client/ High-level API
│ ├── claude.ts Claude class (facade)
│ ├── session.ts Session class (stateful wrapper)
│ ├── stream-handle.ts StreamHandle (fluent API + Node.js Readable)
│ └── chat-handle.ts ChatHandle (bidirectional + Node.js Duplex)
├── scheduler/ Recurring execution (/loop equivalent)
│ └── scheduler.ts Scheduler, ScheduledJob
├── errors/ Error hierarchy
│ └── errors.ts All error classes
└── utils/ Shared utilities
└── validation.ts Input validationKey Abstractions
IExecutor
The central abstraction that decouples the public API from the transport mechanism.
Why it exists: Today there are two executors — SdkExecutor (persistent SDK session, default) and CliExecutor (spawns claude -p per query). Tomorrow, Anthropic may ship an HTTP API or Unix socket interface. By coding against IExecutor, only a new implementation is needed.
Contract:
execute(args, options)→Promise<QueryResult>(run to completion)stream(args, options)→AsyncIterable<StreamEvent>(incremental)abort()→void(cancel running execution)
Invariants:
- Error conditions throw
ClaudeConnectorErrorsubclasses - Arguments are fully resolved (no option merging in the executor)
StreamHandle
Wraps an AsyncIterable<StreamEvent> with a fluent API and Node.js stream bridge.
Why it exists: Raw for await loops require boilerplate for common patterns (collect text, pipe to stdout, track progress). StreamHandle provides .on().done(), .text(), .pipe(), and .toReadable() for these cases, while preserving for await backward compatibility.
ChatHandle
Manages a persistent CLI process with --input-format stream-json for bidirectional streaming.
Why it exists: One-shot stream() spawns a process per query. For multi-turn conversations where latency matters, ChatHandle keeps one process alive and sends messages via stdin. It provides .send() (Promise-based), .toDuplex() (Node.js Duplex), and the same .on() fluent API as StreamHandle.
ArgsBuilder
Purely functional module that converts typed options into CLI argument arrays.
Why it's separate: Argument building is a distinct concern from execution. All CLI flag strings come from constants.ts — no hardcoded flags in the builder.
Constants
Single source of truth for all string literals: event types, CLI flags, JSON protocol keys, permission modes, effort levels, error names, etc. Every module imports from here — zero magic strings in the codebase.
Data Flow
query() — Synchronous Request
claude.query('Find bugs', { model: 'opus' })
│
├─ validate prompt & options
├─ mergeOptions(clientOpts, queryOpts, { outputFormat: FORMAT_JSON })
├─ buildArgs(resolvedOptions) → [FLAG_PRINT, FLAG_OUTPUT_FORMAT, FORMAT_JSON, ...]
├─ resolveEnv(clientOpts, queryOpts)
│
└─ executor.execute(args, { cwd, env, input, systemPrompt })
│
├─ spawn(DEFAULT_EXECUTABLE, args) or session.send(prompt)
├─ collect stdout
├─ wait for exit
│
└─ parseJsonResult(stdout) → QueryResultstream() — Streaming Request
claude.stream('Rewrite module')
│
├─ validate & merge (outputFormat: FORMAT_STREAM_JSON)
│
└─ new StreamHandle(() => executor.stream(args, options))
│
├─ .on(EVENT_TEXT, cb) → register callback
├─ .on(EVENT_TOOL_USE, cb) → register callback
├─ .done() → consume iterable, dispatch events
│ │
│ └─ for each NDJSON line:
│ parseStreamLine(line) → StreamEvent
│ dispatch to registered callbacks
│
├─ .text() → collect text, return string
├─ .pipe(writable) → pipe text, return result
└─ .toReadable() → Node.js Readable (text mode)chat() — Bidirectional Streaming
claude.chat()
│
├─ buildArgs({ inputFormat: FORMAT_STREAM_JSON, ... })
│
└─ new ChatHandle(executable, args, { cwd, env })
│
├─ spawn process with stdin open
├─ .send(prompt) → write JSON to stdin, await result
├─ stdout → parseStreamLine → dispatch to callbacks
├─ .toDuplex() → Node.js Duplex (write prompts, read text)
└─ .end() → close stdin → process exitsError Handling Strategy
ClaudeConnectorError Base class (catch-all)
├── CliNotFoundError Binary not found (ERR_ENOENT)
├── CliExecutionError Non-zero exit code
├── CliTimeoutError Process exceeded DEFAULT_TIMEOUT_MS
├── ParseError Unexpected CLI output format
└── ValidationError Invalid options/inputPhilosophy: Fail fast with descriptive messages. Each error class carries contextual data (exit code, stderr, raw output) for debugging. Error class names use constants from ERR_NAME_*.
Testing Strategy
- Unit tests: Every module is tested in isolation using mock executors.
- No real CLI calls in tests:
IExecutoris mocked, so tests run instantly. - Parser tests: Cover both happy paths and edge cases (missing fields, malformed JSON).
- Session tests: Verify state management (session ID tracking, query counting, flag selection).
- StreamHandle tests: Verify
.on(),.done(),.text(),.pipe(),.toReadable(), andfor await. - ChatHandle tests: Verify lifecycle (properties, close, abort, send-after-close).
- Scheduler tests: Use
vi.useFakeTimers()for deterministic timing. - 122 tests across 10 test files.
Future Extensibility
Adding New CLI Flags
- Add the constant to
constants.ts - Add the option to
ClientOptionsand/orQueryOptionsintypes/client.ts - Add merging logic in
mergeOptions()inbuilder/args-builder.ts - Add argument building in
buildArgs()using the constant - Add tests
- No changes needed in executor, parser, or client classes
Adding New Stream Event Types
- Add the constant to
constants.ts - Add the type to the
StreamEventunion intypes/result.ts - Add parsing logic in
stream-parser.ts - Add dispatch case in
StreamHandleandChatHandle - Unknown types are already forwarded as
EVENT_SYSTEMevents, so existing code won't break
Custom Executor
Inject a custom executor for testing or custom transport:
import {
Claude,
EVENT_TEXT,
EVENT_RESULT,
type IExecutor,
type ExecuteOptions,
type QueryResult,
type StreamEvent,
} from '@scottwalker/claude-connector'
const mockExecutor: IExecutor = {
async execute(args: readonly string[], options: ExecuteOptions): Promise<QueryResult> {
return {
text: 'Mocked response',
sessionId: 'mock-session',
usage: { inputTokens: 0, outputTokens: 0 },
cost: null,
durationMs: 0,
messages: [],
structured: null,
raw: {},
}
},
async *stream(args: readonly string[], options: ExecuteOptions): AsyncIterable<StreamEvent> {
yield { type: EVENT_TEXT, text: 'Mocked stream' }
yield {
type: EVENT_RESULT,
text: 'Mocked stream',
sessionId: 'mock-session',
usage: { inputTokens: 0, outputTokens: 0 },
cost: null,
durationMs: 0,
}
},
abort() {},
}
// Pass as second argument — bypasses SDK/CLI executor creation
const claude = new Claude({ model: 'sonnet' }, mockExecutor)
const result = await claude.query('Test')
console.log(result.text) // "Mocked response"