diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 29f78f4..927fa46 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -66,39 +66,67 @@ Every session has three phases: start, work, end. ## Current State -Branch: `feature/sdk-message-channel` -In-progress: PR shellicar/claude-cli#174 open, auto-merge enabled. SDK bidirectional communication feature. +Branch: `feature/sdk-tooling` — pushed, clean working tree. + +Active development is in **`apps/claude-sdk-cli/`** — a TUI terminal app built on `@shellicar/claude-sdk`. + +**Completed:** +- Full cursor-aware multi-line editor (`AppLayout.ts`) +- Clipboard text attachments via command mode (`ctrl+/` → `t` paste, `d` delete, chips in status bar) +- `ConversationHistory.push(msg, {id?})` + `remove(id)` for tagged message pruning +- `IAnthropicAgent.injectContext/removeContext` public API +- `RunAgentQuery.thinking` + `pauseAfterCompact` options; `AnthropicBeta` enum cleanup +- `BetaMessageParam` used directly in public interface (no more `JsonObject` casts) +- Ref tool + RefStore for large output ref-swapping +- Tool approval flow (auto-approve/deny/prompt) +- Compaction display with context high-water mark +- File attachments via `f` command: three-stage clipboard path reading (pbpaste / VS Code code/file-list JXA / osascript furl), stat-first handler, `file`/`dir`/`missing` chips + +**In-progress / next:** +- Skills system (`ActivateSkill`/`DeactivateSkill`) — primitives in place; timing design issue unresolved (see `docs/skills-design.md`) +- Image attachments — `pngpaste` + clipboard image detection (deferred) ## Architecture -**Stack**: TypeScript, esbuild (bundler), `@anthropic-ai/claude-agent-sdk`. pnpm monorepo workspace with turbo. CLI package lives at `packages/claude-cli/`. SDK package lives at `packages/claude-sdk/` (see `packages/claude-sdk/CLAUDE.md` for architecture and known issues). +**Stack**: TypeScript, esbuild (bundler), `@anthropic-ai/sdk` (direct). pnpm monorepo with turbo. Two apps: active (`apps/claude-sdk-cli/`) and legacy (`apps/claude-cli/`). -**Entry point**: `packages/claude-cli/src/main.ts` parses CLI flags, creates `ClaudeCli`, calls `start()` +### Packages -**Key source files** (all under `packages/claude-cli/`): +| Package | Role | +|---------|------| +| `apps/claude-sdk-cli/` | **Active TUI CLI** — talks directly to `@shellicar/claude-sdk` | +| `apps/claude-cli/` | Legacy CLI using a different SDK path (not actively developed) | +| `packages/claude-sdk/` | Anthropic SDK wrapper: `IAnthropicAgent`, `AnthropicAgent`, `AgentRun`, `ConversationHistory`, `MessageStream` | +| `packages/claude-sdk-tools/` | Tool definitions: `Find`, `ReadFile`, `Grep`, `Head`, `Tail`, `Range`, `SearchFiles`, `Pipe`, `EditFile`, `PreviewEdit`, `CreateFile`, `DeleteFile`, `DeleteDirectory`, `Exec`, `Ref` | +| `packages/claude-core/` | Shared ANSI/terminal utilities: `sanitise`, `reflow`, `screen`, `status-line`, `viewport`, `renderer` | +| `packages/typescript-config/` | Shared tsconfig base | + +### Key files in `apps/claude-sdk-cli/src/` + +| File | Role | +|------|------| +| `entry/main.ts` | Entry point: creates agent, layout, starts readline loop | +| `AppLayout.ts` | TUI: full cursor editor, streaming display, compaction blocks, tool approval, command mode, attachment chips | +| `AttachmentStore.ts` | `TextAttachment \| FileAttachment` union; SHA-256 dedup; 10 KB text cap; `addFile(path, kind, size?)` | +| `clipboard.ts` | `readClipboardText()`; three-stage `readClipboardPath()` (pbpaste → VS Code code/file-list JXA → osascript furl); `looksLikePath`; `sanitiseFurlResult` | +| `runAgent.ts` | Wires agent to layout: sets up tools, beta flags, event handlers | +| `permissions.ts` | Tool auto-approve/deny rules | +| `redact.ts` | Strips sensitive values from tool inputs before display | +| `logger.ts` | Winston file logger (`claude-sdk-cli.log`) | + +### Key files in `packages/claude-sdk/src/` | File | Role | |------|------| -| `src/ClaudeCli.ts` | Orchestrator, startup sequence, event loop, query cycle | -| `src/session.ts` | `QuerySession`, SDK wrapper, session/resume lifecycle | -| `src/AppState.ts` | Phase state machine (`idle`, `sending`, `thinking`, `idle`) | -| `src/terminal.ts` | ANSI terminal rendering, three-zone layout | -| `src/renderer.ts` | Pure editor content preparation (cursor math) | -| `src/StatusLineBuilder.ts` | Fluent builder for width-accurate ANSI status lines | -| `src/SessionManager.ts` | Session file I/O (`.claude/cli-session`) | -| `src/AuditWriter.ts` | JSONL event logger (`~/.claude/audit/.jsonl`) | -| `src/files.ts` | `initFiles()` creates `.claude/` dir, returns `CliPaths` | -| `src/cli-config/` | Config subsystem, schema, loading, diffing, hot reload | -| `src/providers/` | `GitProvider`, `UsageProvider`, system prompt data sources | -| `src/PermissionManager.ts` | Tool approval queue and permission prompt UI | -| `src/PromptManager.ts` | `AskUserQuestion` dialog, single/multi-select + free text | -| `src/CommandMode.ts` | Ctrl+/ state machine for attachment and session operations | -| `src/SdkResult.ts` | Parses `SDKResultSuccess`, extracts errors, rate limits, token counts | -| `src/UsageTracker.ts` | Context usage and session cost tracking interface | -| `src/mcp/shellicar/autoApprove.ts` | Glob-based auto-approve for exec commands (`execAutoApprove` config) | -| `docs/sdk-findings.md` | SDK behaviour discoveries (session semantics, tool options, etc.) | +| `public/interfaces.ts` | `IAnthropicAgent` abstract class (public contract) | +| `public/types.ts` | `RunAgentQuery`, `SdkMessage` union, tool types | +| `public/enums.ts` | `AnthropicBeta` enum | +| `private/AgentRun.ts` | Single agent turn loop: streaming, tool dispatch, history management | +| `private/ConversationHistory.ts` | Persistent JSONL history with ID-tagged push/remove | +| `private/MessageStream.ts` | Stream event parser and emitter | +| `private/pricing.ts` | Token cost calculation | @@ -178,6 +206,12 @@ Opt-in via `shellicarMcp: true` config. Registers an in-process MCP server (`she +- **f command clipboard system** (2026-04-05): Three-stage `readClipboardPath()` — (1) pbpaste filtered by `looksLikePath`, (2) VS Code `code/file-list` JXA probe (file:// URI → POSIX path), (3) osascript `furl` filtered by `sanitiseFurlResult`. Injectable `readClipboardPathCore` for tests. `looksLikePath` is permissive (accepts bare-relative like `apps/foo/bar.ts`); `isLikelyPath` in AppLayout is strict (explicit prefixes only) and only used for the missing-chip case. `sanitiseFurlResult` rejects paths containing `:` (HFS artifacts). `f` handler is stat-first: if the file exists attach it directly; only apply `isLikelyPath` if stat fails. +- **Clipboard text attachments** (2026-04-06): `ctrl+/` enters command mode; `t` reads clipboard via `pbpaste` and adds a `` block attachment; `d` removes selected chip; `← →` select chips. On `ctrl+enter` submit, attachments are folded into the prompt as `` XML blocks and cleared. +- **ConversationHistory ID tagging** (2026-04-06): `push(msg, { id? })` tags messages for later removal. `remove(id)` splices the last item with matching ID. IDs are session-scoped (not persisted). Used by `IAnthropicAgent.injectContext/removeContext` for skills context management. +- **IAnthropicAgent uses BetaMessageParam** (2026-04-06): `getHistory/loadHistory/injectContext` now use `BetaMessageParam` directly instead of `JsonObject` casts. `JsonObject`, `JsonValue`, `ContextMessage` types removed. `BetaMessageParam` re-exported from package index. +- **thinking/pauseAfterCompact as RunAgentQuery options** (2026-04-06): Both default off. `thinking: true` adds `{ type: 'adaptive' }` to the API body. `pauseAfterCompact: true` wires into `compact_20260112.pause_after_compaction`. When `pauseAfterCompact: true` and compaction fires, the agent sends `done` with `stopReason: 'pause_turn'` — user sees the summary and resumes manually (intentional UX). +- **Skills timing design issue** (2026-04-06): Documented in `docs/skills-design.md`. Calling `agent.injectContext()` from inside a tool handler merges the injected user message with the pending tool-results user message (consecutive merge policy). Resolution options documented; implementation deferred. ## Recent Decisions - **Structured command execution via in-process MCP** (#99) — replaced freeform Bash with a structured Exec tool served by an in-process MCP server. Glob-based auto-approve (`execAutoApprove`) with custom zero-dep glob matcher (no minimatch dependency). @@ -185,6 +219,7 @@ Opt-in via `shellicarMcp: true` config. Registers an in-process MCP server (`she - **ZWJ sanitisation in layout pipeline**: `sanitiseZwj` strips U+200D before `wrapLine` measures width. Terminals render ZWJ sequences as individual emojis; `string-width` assumes composed form. Stripping at the layout boundary removes the mismatch. - **Monorepo workspace conversion**: CLI source moved to `packages/claude-cli/`. Root package is private workspace with turbo, syncpack, biome, lefthook. Turbo orchestrates build/test/type-check. syncpack enforces version consistency. `.packagename` file at root holds the active package name for scripts and pre-push hooks. - **SDK bidirectional channel** (`packages/claude-sdk/`): New package wrapping the Anthropic API. Uses `MessagePort` for bidirectional consumer/SDK communication. Tool validation (existence + input schema) happens before approval requests are sent. Approval requests are sent in bulk; tools execute in approval-arrival order. +- **Screen utilities extracted to `claude-core`**: `sanitise`, `reflow` (wrapLine/rewrapFromSegments/computeLineSegments), `screen` (Screen interface + StdoutScreen), `status-line` (StatusLineBuilder), `viewport` (Viewport), `renderer` (Renderer) all moved from `claude-cli` to `claude-core`. `claude-cli` now imports from `@shellicar/claude-core/*`. `tsconfig.json` in claude-core requires `"types": ["node"]` for process globals with moduleResolution bundler. diff --git a/.claude/sessions/2026-04-05.md b/.claude/sessions/2026-04-05.md new file mode 100644 index 0000000..6b81063 --- /dev/null +++ b/.claude/sessions/2026-04-05.md @@ -0,0 +1,325 @@ +# Session 2026-04-05 + +## What was done + +Extracted display/terminal utilities from `claude-cli` into `claude-core` so they can be reused by `claude-sdk-cli` and other consumers. + +### New files in `packages/claude-core/src/` + +- `sanitise.ts` - `sanitiseLoneSurrogates`, `sanitiseZwj` +- `reflow.ts` - `LineSegment`, `computeLineSegments`, `rewrapFromSegments`, `wrapLine` +- `screen.ts` - `Screen` interface, `StdoutScreen` implementation +- `status-line.ts` - `StatusLineBuilder` +- `viewport.ts` - `Viewport`, `ViewportResult` +- `renderer.ts` - `Renderer` (ANSI alt-buffer diff renderer) + +### Changes to `packages/claude-cli/` + +- `src/Layout.ts` - stripped reflow functions, now imports `wrapLine` from `@shellicar/claude-core/reflow` +- `src/terminal.ts` - imports `Screen`, `StdoutScreen`, `StatusLineBuilder`, `Renderer`, `Viewport`, `LineSegment`, `computeLineSegments`, `rewrapFromSegments`, `wrapLine` from `@shellicar/claude-core/*` +- `src/ClaudeCli.ts` - imports `sanitiseLoneSurrogates` from `@shellicar/claude-core/sanitise` +- `test/TerminalRenderer.spec.ts` - updated imports +- `test/sanitise.spec.ts` - updated imports +- `test/viewport.spec.ts` - updated imports +- `test/terminal-integration.spec.ts` - updated imports +- `test/terminal-perf.spec.ts` - updated `Screen` type import +- `tsconfig.json` (claude-core) - added `"types": ["node"]` (required for `process` globals with moduleResolution: bundler) + +### Removed from `packages/claude-cli/src/` (dead code after extraction) + +- `sanitise.ts`, `Screen.ts`, `StatusLineBuilder.ts`, `Viewport.ts`, `TerminalRenderer.ts` + +### Dependencies added + +- `string-width` to `claude-core` (runtime dep for reflow and status-line) +- `@shellicar/claude-core@workspace:*` to `claude-cli` + +## Current state + +All tests pass (238 tests). One performance test ("resize re-wrap at 10K history lines under 2ms") is borderline: values around 2.1-3.5ms on this machine. CI threshold is 15ms so CI is unaffected. Left as-is. + +## What's next + +`claude-sdk-cli` can now use `@shellicar/claude-core` display primitives (`Screen`, `StatusLineBuilder`, `Renderer`, `Viewport`, `wrapLine`) to improve its output rendering. + + +--- + +# Session 2026-04-05 (feature/sdk-tooling) + +## What was done + +This session focused on getting the Ref system working live: wiring it into the CLI app, fixing bugs discovered during testing, and tightening up several adjacent rough edges. + +### Ref system — bug fixes and wiring + +The Ref tool's own output was being processed by `transformToolResult`, causing infinite ref chains (a Ref result getting ref-swapped into another Ref). Fixed by skipping `walkAndRef` when `toolName === 'Ref'` (`packages/claude-sdk-tools/src/Ref/Ref.ts`). + +Wired the ref system into `apps/claude-sdk-cli`: +- `RefStore` created once in `main.ts`, shared across all turns +- `createRef(store, 2_000)` (threshold bumped 1k → 2k after live testing) +- `transformToolResult` passed into `runAgent()` +- `Ref` tool added to the tools list + +### walkAndRef — uniform string-array handling + +`ReadFile` returns a line array (`string[]`). Previously `walkAndRef` would recurse into it element-by-element; individual lines are never large enough to trigger the threshold, so ReadFile output never got ref-swapped. Fixed: if all elements in an array are strings and their joined length exceeds the threshold, the whole array is stored as a single `\n`-joined ref. Mixed arrays (non-strings) still recurse element-wise. + +### ReadFile — file size guard + +Added a 500 KB size check before reading. Files above the limit return an error pointing Claude at Head/Tail/Grep instead. Required adding `stat(path): Promise<{ size: number }>` to `IFileSystem` (implemented in both `NodeFileSystem` and `MemoryFileSystem`). + +### ConversationHistory — consecutive user message fix + +The Anthropic API requires strict role alternation. A timing race (user typing while a tool result is being processed) could produce two consecutive `user` messages, causing API failures. Fixed in `push()`: when the incoming message has the same role as the last message and both are `user`, the content arrays are merged rather than appending a second message. + +### Compaction high-water mark + +After compaction, the context% display showed the post-compaction (small) value. There was no way to see what triggered it. Fixed by tracking `lastUsage` in `runAgent.ts` and appending a footer to the compaction block: + +``` +[compacted at 124.1k / 200.0k (62.1%)] +``` + +### Documentation + +Added `packages/claude-sdk-tools/CLAUDE.md` covering: architecture, `IFileSystem` pattern, Ref system wiring, ReadFile size limit, and the planned `IRefStore` interface (parked, not yet implemented). + +## Design discussions + +**IRefStore interface** — `RefStore` is currently concrete. The planned path: extract `IRefStore`, rename to `MemoryRefStore`, make `createRef` accept the interface. Same pattern as `IFileSystem`. In-memory is the right default. Documented in CLAUDE.md, not implemented yet. + +**Join/flatten tool** — `ReadFile` output is a structured line array, useful for piping into Grep/Head. When Claude reads it directly (ReadFile as sink), the structure is an implementation detail. A `Join` step could flatten it. The `walkAndRef` string-array fix is an implicit join at the ref boundary. Whether to make it explicit depends on a prior question: **if refs become queryable JSON (jq/JSONPath), keeping structure all the way to the ref is more useful than flattening.** If refs stay as opaque blobs, flattening at the Pipe boundary makes more sense. One design decision flips the answer — held open deliberately. + +**Pipe sink type** — related: should `Pipe` auto-join its output (always produce a flat string, never `PipeContent`)? This would make the line array purely an internal pipe mechanism. Breaking change; held open pending the queryable-refs question. + +## Commits + +- `5885a36` fix: exempt Ref tool from transformToolResult to prevent infinite ref chains +- `a0aa494` feat: wire ref system into claude-sdk-cli app +- `5e461ea` fix: merge consecutive user messages in ConversationHistory +- `2bb2185` chore: increase ref threshold to 2k +- `48ecd7e` feat: ReadFile size guard + walkAndRef large string-array handling +- `9c48b72` docs: add CLAUDE.md for claude-sdk-tools +- `d85062c` feat: show context high-water mark at end of compaction block +- `22b07c3` linting + +## Current state + +227/227 tests passing. Clean working tree. Several commits ahead of `origin/feature/sdk-tooling` (not yet pushed). + +## What's next + +- **Push** — commits are local only +- **`.sdk-history.jsonl.bak` cleanup** — accidentally committed; needs `git rm --cached .sdk-history.jsonl.bak` (lefthook blocks plain `git rm`). Also verify `*.bak` and `.sdk-history.jsonl` are in `.gitignore`. +- **`IRefStore` interface extraction** — documented in CLAUDE.md; straightforward to implement +- **`ConversationHistory.remove(id)`** — foundational primitive needed for skills deactivation and deferred ref pruning +- **Skills system** — design complete in `docs/skills-design.md`; needs `remove(id)` first +- **Join/flatten decision** — blocked on the queryable-refs direction + + +--- + +# Session 2026-04-05 (feature/sdk-tooling, continued) + +## What was done + +### Bug fix: stop_reason tool_use with no tool uses + +Fixed a crash (`[error: stop_reason was tool_use but no tool uses found]`) that occurred when the API returned `stop_reason: "tool_use"` but streamed no tool use blocks — a transient API glitch. The old code had a commented-out retry tied to `contextManagementOccurred` (which is never true since that beta is disabled), causing it to always error. + +Fix: unconditional retry counter (`emptyToolUseRetries`, max 2) declared outside the while loop. On the bad condition, retry if `< 2` attempts used; after 2 consecutive failures, send the error and break. Counter resets to 0 on every successful tool-use turn. History is unchanged at that point so retrying is safe. + +### Cursor-aware multiline editor with full keyboard navigation + +The prompt editor previously had only 4 cases (enter, ctrl+enter, backspace, char) and always appended at the end of the last line. Replaced with a full cursor-aware implementation. + +**`packages/claude-core/src/input.ts`** — new key mappings confirmed via `scripts/keydump.ts` (see below): + +| Key | Sequence | Action | +|-----|----------|--------| +| `ctrl+a` | `0x01` | `home` (beginning of line) | +| `ctrl+e` | `0x05` | `end` (end of line) | +| `ctrl+b` | `0x02` | `left` | +| `ctrl+f` | `0x06` | `right` | +| `ctrl+k` | `0x0b` | `ctrl+k` (kill to EOL) | +| `ctrl+u` | `0x15` | `ctrl+u` (kill to start of line) | +| `option+left` | `meta+left` (iTerm2) or `meta+b` (tmux) | `ctrl+left` (word left) | +| `option+right` | `meta+right` (iTerm2) or `meta+f` (tmux) | `ctrl+right` (word right) | +| `option+d` | `∂` (U+2202) | `ctrl+delete` (delete word right) | + +Notes: +- `ctrl+left/right` unavailable (macOS Mission Control grabs them) +- `ctrl+a` unavailable inside tmux (tmux prefix key) +- `option+backspace` was already handled as `ctrl+backspace` via `meta+backspace` +- `option+left/right` send different sequences through tmux (`meta+b/f`) vs. iTerm2 direct (`meta+left/right`) — both mapped + +**`apps/claude-sdk-cli/src/AppLayout.ts`** — editor rewrite: + +- Added `#cursorLine`, `#cursorCol` (logical cursor position in `#editorLines`) +- Added `#renderPending` + `#scheduleRender()` — `setImmediate` debounce so rapid paste events batch into one repaint +- Added `#wordStartLeft(line, col)` and `#wordEndRight(line, col)` helpers +- Full 18-case switch replacing the old 4-case version: + - `enter` — splits line at cursor + - `backspace/delete` — delete char at cursor or join lines at boundary + - `ctrl+backspace/delete` — delete word; crosses newline boundary when at start/end of line + - `ctrl+k` — kill to EOL, or join next line if at EOL + - `ctrl+u` — kill from start of line to cursor + - `left/right/up/down` — cursor movement with line-wrap crossing + - `home/end/ctrl+home/ctrl+end` — jump to line/document boundaries + - `ctrl+left/right` — word jump + - `char` — insert at cursor position +- **Virtual block cursor**: the character *under* the cursor rendered in reverse-video (`\x1b[7m{char}\x1b[27m`), replacing the old approach of moving the terminal cursor to end-of-last-line. No text displacement — at EOL a reverse-video space is shown. +- `showCursor` import removed (terminal cursor stays hidden throughout editing). + +**`scripts/keydump.ts`** — standalone key inspector. Run with `tsx scripts/keydump.ts` in a raw terminal (no tmux, no VS Code) to see hex/name/ctrl/meta/shift for any keypress. Used to confirm actual sequences sent by iTerm2. + +## Commits + +- `70eeeff` fix: retry up to 2x when stop_reason is tool_use but no tool uses streamed +- `a781aae` feat: cursor-aware editor with full keyboard navigation +- `48a7c1c` fix: render char under cursor in reverse-video instead of inserting space +- `bfe5a5f` fix: ctrl+backspace/delete cross newline boundaries when at start/end of line + +## Current state + +All tests passing. Clean working tree. 12+ commits ahead of `origin/feature/sdk-tooling` (not pushed). + +## What's next + +- **Push** — still not pushed +- **`.sdk-history.jsonl.bak` cleanup** — accidentally committed; needs `git rm --cached` +- **`IRefStore` interface extraction** — documented in CLAUDE.md; straightforward +- **`ConversationHistory.remove(id)`** — foundational for skills/ref pruning +- **Skills system** — design in `docs/skills-design.md`; needs `remove(id)` first + + + +--- + +# Session 2026-04-05 (feature/sdk-tooling — f command + clipboard system) + +## What was done + +Built and completed the `f` command: reads a file path from the clipboard and attaches the file as a chip in the TUI status bar. This took several iterative commits to get right, ending with a clean stat-first architecture. + +--- + +### 1. `f` command — initial implementation + +`AppLayout.ts` command mode gains a `f` key: +- Reads path from clipboard via `readClipboardPath()` +- Resolves `~` expansion and calls `path.resolve()` +- Calls `this.#attachments.addFile(resolved, kind, size?)` + +`AttachmentStore.ts` extended with a `FileAttachment` type alongside the existing `TextAttachment`: + +```typescript +type FileAttachment = { + kind: 'file' | 'dir' | 'missing'; + path: string; // absolute resolved path + sizeBytes?: number; // present for 'file' only +}; +export type Attachment = TextAttachment | FileAttachment; +``` + +`addFile(path, kind, size?)` deduplicates by path (last-write-wins). Chips render as `[file basename]`, `[dir basename/]`, or `[? basename]` for missing. At submit, `FileAttachment` items serialise as `[attachment #N]\npath: {path}\ntype: {kind}\nsize: {human}\n[/attachment]` blocks injected into the prompt. + +**10 KB text cap**: `addText()` enforces a 10 KB limit on text attachments. Oversized content is silently truncated with a note. + +**`p` key** toggles a preview panel for the selected attachment (text content or file path details). + +--- + +### 2. `readClipboardPath` — three-stage architecture + +`clipboard.ts` gained a full three-stage path-reading system: + +**Stage 1 — `pbpaste`**: reads plain-text clipboard. Accepted only if `looksLikePath()` returns true. + +**Stage 2 — VS Code `code/file-list`**: a JXA (JavaScript for Automation) ObjC snippet reads the VS Code proprietary pasteboard type, decodes it as UTF-8, extracts the first `file://` URI, and converts it to a POSIX path via `fileURLToPath()`. This handles VS Code Explorer → right-click → Copy (which neither `pbpaste` nor osascript can see). + +**Stage 3 — `osascript furl`**: `POSIX path of (the clipboard as «class furl»)` reads the Finder file-reference clipboard type (set when you ⌘C a file in Finder). Only the filename shows in `pbpaste`; this probe gets the full path. + +**`readClipboardPathCore(pbpaste, ...fileProbes)`** — injectable core function for testing. Rest-params accept any number of file probes tried in order; first non-null wins. Used directly in tests with mock functions; only `readClipboardPath()` uses the real system probes. + +--- + +### 3. `looksLikePath()` — permissive, for clipboard stage 1 + +Accepts: +- `/absolute`, `~/home`, `./relative`, `../parent` — explicit prefix forms +- `apps/foo/bar.ts` — bare relative (contains `/`, no whitespace) — needed for VS Code "Copy Relative Path" + +Rejects: empty, >1 KB, multi-line, whitespace-containing strings, bare filenames. + +--- + +### 4. `sanitiseFurlResult()` — HFS artifact rejection + +Root-cause: when the clipboard contains a bare relative path like `apps/foo/bar.ts` and `the clipboard as «class furl»` is evaluated, AppleScript converts `/` to `:` (HFS separator), producing `/apps:foo:bar.ts`. This is not a real path. + +`sanitiseFurlResult(path)` returns `null` if the path contains `:`. A real `POSIX path of` result from an actual file reference always uses `/` and never contains `:`. + +Exported as a pure function for unit testing. + +--- + +### 5. Trace logging via `logged()` wrapper + +`logged(label, fn)` wraps a probe function: logs the result at trace level on success; logs the error and re-throws on failure. Wrapping happens only in `readClipboardPath()` — `readClipboardPathCore` stays clean and logger-free for tests. + +Also logs `pbpaste looksLikePath` decision (trimmed text + accepted boolean). + +All trace output goes to `claude-sdk-cli.log`. + +--- + +### 6. Vitest test suite — `test/clipboard.spec.ts` (52 tests) + +`apps/claude-sdk-cli/` gained a `vitest` dependency (`^4.1.2`) and `"test": "vitest run"` script. + +Tests cover: +- `looksLikePath` — accepts/rejects matrix (absolute, home-relative, explicit-relative, bare-relative, bare filename, whitespace, multi-line, empty, long strings) +- `sanitiseFurlResult` — colon rejection, passthrough for valid paths +- `readClipboardPathCore` — stage 1 accepted, stage 1 rejected (falls to probe), probe returns path, probe throws (falls to next), all null → null + +--- + +### 7. Stat-first `f` handler + strict `isLikelyPath` + +The bug that prompted all the probe work: VS Code "Copy Relative Path" puts `apps/foo/bar.ts` on the clipboard. The old `looksLikePath` rejected it → fell through to osascript furl → returned `/apps:foo:bar.ts` (HFS artifact). + +Final architecture: + +- `looksLikePath` in `clipboard.ts`: permissive (accepts bare-relative). Catches VS Code "Copy Relative Path" at stage 1. +- `f` handler in `AppLayout.ts`: **stat-first** — resolves the path, calls `stat()`. If the file exists, attach it directly (no heuristic needed). If stat throws (file not found), only create a missing chip if `isLikelyPath()` passes. +- `isLikelyPath` in `AppLayout.ts`: **strict** (explicit prefixes only: `/`, `~/`, `./`, `../`). Only used for the missing-file case — if the file doesn't exist, we need a stronger signal that this was intentional. + +The previous edit session had left `AppLayout.ts` in a broken state (parse error at line 791 — duplicate `.then()` block). This session diagnosed and fixed it by replacing lines 785–814 with the correct single-chain implementation. + +--- + +## Commits + +- `fb53d1a` feat: f command reads Finder-copied files via osascript furl fallback +- `c346268` fix: trailing newline in clipboard.ts +- `f0ebefb` refactor: f command inserts resolved path into editor instead of loading file contents +- `72840db` refactor: f attaches file/dir/missing as metadata object; [attachment #N] serialisation format; TextAttachment/FileAttachment union type +- `77727cf` feat: f path-validation; 10KB text cap; p preview mode for attachments +- `ba216fe` fix: readClipboardPath falls through to osascript when pbpaste gives a bare filename +- `8c0ea02` test: add vitest + clipboard unit tests; fix looksLikePath to accept ./ and ../ +- `c574ded` feat: read VS Code 'Copy' clipboard via code/file-list JXA probe +- `4e0762d` debug: trace-level logging for each clipboard probe +- `a23dc53` fix: reject HFS artifacts from osascript furl (colons in path) +- `df01721` fix: stat-first in f handler; bare-relative looksLikePath; strict isLikelyPath + +## Current state + +52/52 tests passing. Clean working tree. Pushed to `origin/feature/sdk-tooling`. + +## What's next + +- **Skills system** — design in `docs/skills-design.md`; `ConversationHistory.remove(id)` primitive is in place +- **Image attachments** — `pngpaste` + clipboard image detection (text-only first was the stated goal; now met) +- **`IRefStore` interface extraction** — documented in CLAUDE.md, straightforward diff --git a/.claude/sessions/2026-04-06.md b/.claude/sessions/2026-04-06.md new file mode 100644 index 0000000..c08efb4 --- /dev/null +++ b/.claude/sessions/2026-04-06.md @@ -0,0 +1,102 @@ +# Session 2026-04-06 + +## What was done + +This session continued `feature/sdk-tooling` development. Five commits on top of the previous 14. + +--- + +### 1. Clipboard attachments via command mode — `apps/claude-sdk-cli` + +Introduced a full clipboard-attachment system in the TUI editor: + +**New files:** +- `src/AttachmentStore.ts` — stores text attachments as `{ kind: 'text', hash, text, sizeBytes }`. SHA-256 deduplication. `addText()`, `removeSelected()`, `selectLeft/Right()`, `clear()`, `takeAttachments()`. +- `src/clipboard.ts` — `readClipboardText()` using `pbpaste`. + +**`AppLayout.ts` changes:** +- `ctrl+enter` folds attachments into prompt as `` XML blocks before sending. +- `completeStreaming()` clears attachments at end of turn. +- `#handleCommandKey`: + - `t` → reads clipboard via `readClipboardText()`, calls `addText`, exits command mode + - `d` → deletes selected attachment, exits command mode if store empty + - `left/right` → `selectLeft/Right()` within command mode +- `#buildCommandRow`: + - Shows `[txt 2.4KB]` chips (dimmed normally, reverse-video when selected in command mode) + - In command mode: shows `cmd` label + hints (`← → select d del · t paste · ESC cancel`) +- Fixed `statusBarHeight` bug: was not counting the command row, causing height miscalculation +- Replaced hardcoded ANSI cursor escape codes with `INVERSE_ON`/`INVERSE_OFF` constants from `@shellicar/claude-core/ansi` + +--- + +### 2. `ConversationHistory.push(msg, {id?}) + remove(id)` — `packages/claude-sdk` + +**`src/private/ConversationHistory.ts`:** +- Internal storage changed from `BetaMessageParam[]` to `HistoryItem[] = { id?: string; msg: BetaMessageParam }[]` +- `push(msg, opts?: { id?: string })` — single-message push with optional tag; consecutive user messages still merge (merged message loses ID); compaction block still clears all history +- `remove(id: string): boolean` — finds last item with matching ID, splices it, returns success +- `#save()` extracted as private method (persists only `msg` fields; IDs are session-scoped and ephemeral) +- `messages` getter maps items to raw `msg` array + +**`src/private/AgentRun.ts`**: Updated to use `for (const content of this.#options.messages) { this.#history.push(...) }` (no longer spread-variadic). + +**`src/private/AnthropicAgent.ts`**: `loadHistory` uses loop instead of spread. + +--- + +### 3. `IAnthropicAgent.injectContext/removeContext` public API + +**`src/public/interfaces.ts`:** +```typescript +public abstract injectContext(msg: BetaMessageParam, opts?: { id?: string }): void; +public abstract removeContext(id: string): boolean; +``` + +**`src/private/AnthropicAgent.ts`:** Both methods delegate to `this.#history.push/remove`. + +Created `docs/skills-design.md` (117 lines) documenting the full skills system design — primitives, proposed tool names (`ActivateSkill`/`DeactivateSkill`), timing constraints, and open design questions around tool-handler injection. + +**Unresolved design issue**: Calling `agent.injectContext()` from within a tool handler during `AgentRun.execute()` causes a timing conflict — the injected user message merges with the pending tool-results user message. Documented options; implementation deferred. + +--- + +### 4. SDK API refactor — `packages/claude-sdk` + +**`IAnthropicAgent` now uses `BetaMessageParam` directly** (from `@anthropic-ai/sdk/resources/beta.js`) instead of the `JsonObject` type cast: +- `getHistory(): BetaMessageParam[]` +- `loadHistory(messages: BetaMessageParam[]): void` +- `injectContext(msg: BetaMessageParam, opts?): void` + +**Types removed**: `JsonObject`, `JsonValue`, `ContextMessage` — all deleted from `types.ts` and `index.ts`. `BetaMessageParam` is now re-exported from the package index for consumer convenience. + +**New `RunAgentQuery` options:** +- `thinking?: boolean` — when `true`, adds `{ type: 'adaptive' }` thinking block to the API request body; off by default +- `pauseAfterCompact?: boolean` — wired into `compact_20260112.pause_after_compaction`; off by default + +`cache_control` (prompt-caching scope) is now conditionally applied only when `AnthropicBeta.PromptCachingScope` is enabled. + +**`AnthropicBeta` enum cleanup**: Added JSDoc links; removed `Effort` and `TokenEfficientTools` (deprecated/superseded). + +**`runAgent.ts` updated** to pass `thinking: true`, `pauseAfterCompact: true`, and the corrected beta set. + +## What's next + +- **`f` command** (command mode) — reads clipboard text as a file path, reads the file, adds its content as an attachment chip +- **Attachment count** in the fixed status area even outside command mode (currently chips only show in the command row, which is correct — but worth revisiting visibility) +- **Image attachments** — `pngpaste` + clipboard image detection (deferred, text-only first) +- **Skills system** — resolve the tool-handler injection timing design issue; implement `ActivateSkill`/`DeactivateSkill` in a new package +- **`ConversationHistory.remove(id)` integration test** — no tests yet for the remove path +- **Push** — 18 commits ahead of `origin/feature/sdk-tooling` + +## Current architecture (as of this session) + +Active development is in **`apps/claude-sdk-cli/`** — a TUI terminal app built directly on `@shellicar/claude-sdk`. + +Key packages: +| Package | Role | +|---------|------| +| `apps/claude-sdk-cli/` | Active TUI CLI: `AppLayout.ts` (TUI editor, 870 lines), `runAgent.ts`, `AttachmentStore.ts`, `clipboard.ts` | +| `packages/claude-sdk/` | SDK: `AnthropicAgent`, `AgentRun`, `ConversationHistory`, `MessageStream`, `IAnthropicAgent` | +| `packages/claude-sdk-tools/` | Tool definitions: Find, ReadFile, Grep, Head, Tail, Range, SearchFiles, Pipe, EditFile, Exec, Ref, etc. | +| `packages/claude-core/` | Shared ANSI/terminal utilities: sanitise, reflow, screen, status-line, viewport, renderer | +| `apps/claude-cli/` | Legacy Claude CLI (different SDK path, not actively modified) | diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index bff8c92..0bed39c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -30,7 +30,7 @@ jobs: - run: pnpm run ci - name: Verify release tag matches package.json run: | - cd "packages/$(cat .packagename)" + cd "apps/$(cat .packagename)" VERSION=$(node -p "require('./package.json').version") RELEASE_TAG="${{ github.event.release.tag_name }}" echo "package.json version: $VERSION" @@ -42,7 +42,7 @@ jobs: echo "✅ Versions match" - run: | . ./scripts/get-npm-tag.sh - cd "packages/$(cat .packagename)" + cd "apps/$(cat .packagename)" VERSION=$(node -p "require('./package.json').version") echo "Package version: $VERSION" diff --git a/.gitignore b/.gitignore index 7d72834..f243835 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ coverage/ # Claude harness — Stage 2 !.claude/*/ !.claude/**/*.md +.sdk-history.jsonl +*.log +*.bak diff --git a/.lefthook/pre-push/verify-version-functions.sh b/.lefthook/pre-push/verify-version-functions.sh index 7b9ecd6..c89c963 100644 --- a/.lefthook/pre-push/verify-version-functions.sh +++ b/.lefthook/pre-push/verify-version-functions.sh @@ -8,7 +8,7 @@ get_package_name() { # Extract version field from package.json staged version get_full_version() { local package_name="$1" - git show :packages/$package_name/package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version" + git show :apps/$package_name/package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version" } # Read CHANGELOG.md content from staged version diff --git a/packages/claude-cli/README.md b/apps/claude-cli/README.md similarity index 100% rename from packages/claude-cli/README.md rename to apps/claude-cli/README.md diff --git a/packages/claude-cli/build.ts b/apps/claude-cli/build.ts similarity index 95% rename from packages/claude-cli/build.ts rename to apps/claude-cli/build.ts index 98093cf..0c37036 100644 --- a/packages/claude-cli/build.ts +++ b/apps/claude-cli/build.ts @@ -25,9 +25,9 @@ const ctx = await esbuild.context({ platform: 'node', plugins, sourcemap: true, - target: 'node22', + target: 'node24', treeShaking: true, - dropLabels: ['DEBUG'], + // dropLabels: watch ? [] : ['DEBUG'], tsconfig: 'tsconfig.json', external: ['@anthropic-ai/claude-agent-sdk', 'sharp'], }); diff --git a/packages/claude-cli/inject/cjs-shim.ts b/apps/claude-cli/inject/cjs-shim.ts similarity index 100% rename from packages/claude-cli/inject/cjs-shim.ts rename to apps/claude-cli/inject/cjs-shim.ts diff --git a/packages/claude-cli/package.json b/apps/claude-cli/package.json similarity index 91% rename from packages/claude-cli/package.json rename to apps/claude-cli/package.json index 39cc98f..a971f33 100644 --- a/packages/claude-cli/package.json +++ b/apps/claude-cli/package.json @@ -34,11 +34,13 @@ "start": "node dist/main.js", "test": "vitest run", "type-check": "tsc -p tsconfig.check.json", - "knip": "knip" + "knip": "knip", + "watch": "tsx build.ts --watch" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.90", "@js-joda/core": "^5.7.0", + "@shellicar/claude-core": "workspace:*", "@shellicar/mcp-exec": "1.0.0-preview.6", "sharp": "^0.34.5", "string-width": "^8.2.0", @@ -49,6 +51,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@shellicar/build-clean": "^1.3.2", "@shellicar/build-version": "^1.3.6", + "@shellicar/typescript-config": "workspace:*", "@tsconfig/node24": "^24.0.4", "@types/node": "^25.5.0", "@vitest/coverage-v8": "^4.1.2", diff --git a/packages/claude-cli/src/AppState.ts b/apps/claude-cli/src/AppState.ts similarity index 100% rename from packages/claude-cli/src/AppState.ts rename to apps/claude-cli/src/AppState.ts diff --git a/packages/claude-cli/src/AttachmentStore.ts b/apps/claude-cli/src/AttachmentStore.ts similarity index 100% rename from packages/claude-cli/src/AttachmentStore.ts rename to apps/claude-cli/src/AttachmentStore.ts diff --git a/packages/claude-cli/src/AuditWriter.ts b/apps/claude-cli/src/AuditWriter.ts similarity index 100% rename from packages/claude-cli/src/AuditWriter.ts rename to apps/claude-cli/src/AuditWriter.ts diff --git a/packages/claude-cli/src/ClaudeCli.ts b/apps/claude-cli/src/ClaudeCli.ts similarity index 98% rename from packages/claude-cli/src/ClaudeCli.ts rename to apps/claude-cli/src/ClaudeCli.ts index 21d11ea..1ffe01c 100644 --- a/packages/claude-cli/src/ClaudeCli.ts +++ b/apps/claude-cli/src/ClaudeCli.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { resolve } from 'node:path'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import type { DocumentBlockParam, ImageBlockParam, SearchResultBlockParam, TextBlockParam, ToolReferenceBlockParam } from '@anthropic-ai/sdk/resources'; +import { sanitiseLoneSurrogates } from '@shellicar/claude-core/sanitise'; import { ExecInputSchema } from '@shellicar/mcp-exec'; import stringWidth from 'string-width'; import { AppState } from './AppState.js'; @@ -32,7 +33,6 @@ import { UsageProvider } from './providers/UsageProvider.js'; import { SdkResult } from './SdkResult.js'; import { SessionManager } from './SessionManager.js'; import { SystemPromptBuilder } from './SystemPromptBuilder.js'; -import { sanitiseLoneSurrogates } from './sanitise.js'; import { QuerySession } from './session.js'; import { Terminal } from './terminal.js'; import { type ContextUsage, readLastTodoWrite, type TodoItem, UsageTracker } from './UsageTracker.js'; @@ -67,6 +67,8 @@ const toolResultToString = (content: string | Array 80 ? '\x1b[31m' : percent > 50 ? '\x1b[33m' : '\x1b[32m'; } + private transitionBlock(type: BlockType): void { + if (this.currentBlock === type) { + return; + } + this.currentBlock = type; + this.term.openBlock(type); + } + private checkConfigReload(): void { if (this.appState.phase !== 'idle') { this.pendingConfigReload = true; @@ -314,6 +325,7 @@ export class ClaudeCli { } const attachments = this.attachmentStore.takeAttachments(); + this.transitionBlock('prompt'); if (attachments) { this.term.log(`> ${text} [${attachments.length} attachment${attachments.length === 1 ? '' : 's'}]`); } else { @@ -354,9 +366,14 @@ export class ClaudeCli { const pctSuffix = ctx ? ` ${this.contextColor(ctx.percent)}(${ctx.percent.toFixed(1)}%)\x1b[0m` : ''; this.term.log(`\x1b[2mmessageId: ${msg.uuid}\x1b[0m${pctSuffix}`); for (const block of msg.message.content) { - if (block.type === 'text') { + if (block.type === 'thinking') { + this.transitionBlock('thinking'); + this.term.log(`thinking: ${block.thinking}`); + } else if (block.type === 'text') { + this.transitionBlock('response'); this.term.log(`\x1b[1;97massistant: ${block.text}\x1b[0m`); } else if (block.type === 'tool_use') { + this.transitionBlock('tools'); if (block.name === 'Edit') { const input = block.input as { file_path?: string; old_string?: string; new_string?: string }; this.term.log(`tool_use: Edit ${input.file_path ?? 'unknown'}`); @@ -472,6 +489,7 @@ export class ClaudeCli { this.term.log(`Error: ${err}`); } } finally { + this.currentBlock = null; this.appState.idle(); if (this.session.currentSessionId) { this.sessions.save(this.session.currentSessionId); diff --git a/packages/claude-cli/src/CommandMode.ts b/apps/claude-cli/src/CommandMode.ts similarity index 100% rename from packages/claude-cli/src/CommandMode.ts rename to apps/claude-cli/src/CommandMode.ts diff --git a/packages/claude-cli/src/HistoryViewport.ts b/apps/claude-cli/src/HistoryViewport.ts similarity index 100% rename from packages/claude-cli/src/HistoryViewport.ts rename to apps/claude-cli/src/HistoryViewport.ts diff --git a/packages/claude-cli/src/ImageStore.ts b/apps/claude-cli/src/ImageStore.ts similarity index 100% rename from packages/claude-cli/src/ImageStore.ts rename to apps/claude-cli/src/ImageStore.ts diff --git a/apps/claude-cli/src/Layout.ts b/apps/claude-cli/src/Layout.ts new file mode 100644 index 0000000..dbae065 --- /dev/null +++ b/apps/claude-cli/src/Layout.ts @@ -0,0 +1,70 @@ +import { wrapLine } from '@shellicar/claude-core/reflow'; +import type { EditorRender } from './renderer.js'; + +/** + * Output from an existing builder (status, attachment, preview). + * `rows` are logical lines as the builder produces them (may be wider than columns). + * `height` is the visual height in terminal rows (accounts for wrapping). + */ +export interface BuiltComponent { + rows: string[]; + height: number; +} + +export interface LayoutInput { + editor: EditorRender; + status: BuiltComponent | null; + attachments: BuiltComponent | null; + preview: BuiltComponent | null; + question: BuiltComponent | null; + promptDivider: BuiltComponent; + columns: number; +} + +/** + * Layout output. `buffer` contains one entry per visual (terminal) row. + * Layout is responsible for wrapping: a logical line wider than `columns` + * becomes multiple buffer entries. + */ +export interface LayoutResult { + buffer: string[]; + cursorRow: number; + cursorCol: number; + editorStartRow: number; +} + +/** + * Pure layout function. Takes all UI components and returns an unbounded + * buffer of visual rows with cursor position metadata. + * + * Buffer order (top to bottom): question, status, attachments, preview, editor. + */ +export function layout(input: LayoutInput): LayoutResult { + const { editor, status, attachments, preview, question, promptDivider, columns } = input; + const buffer: string[] = []; + + for (const component of [question, status, attachments, preview]) { + if (component !== null) { + for (const row of component.rows) { + buffer.push(...wrapLine(row, columns)); + } + } + } + + for (const row of promptDivider.rows) { + buffer.push(...wrapLine(row, columns)); + } + + const editorStartRow = buffer.length; + + for (const line of editor.lines) { + buffer.push(...wrapLine(line, columns)); + } + + return { + buffer, + cursorRow: editorStartRow + editor.cursorRow, + cursorCol: editor.cursorCol, + editorStartRow, + }; +} diff --git a/packages/claude-cli/src/PermissionManager.ts b/apps/claude-cli/src/PermissionManager.ts similarity index 100% rename from packages/claude-cli/src/PermissionManager.ts rename to apps/claude-cli/src/PermissionManager.ts diff --git a/packages/claude-cli/src/PromptManager.ts b/apps/claude-cli/src/PromptManager.ts similarity index 100% rename from packages/claude-cli/src/PromptManager.ts rename to apps/claude-cli/src/PromptManager.ts diff --git a/packages/claude-cli/src/SdkResult.ts b/apps/claude-cli/src/SdkResult.ts similarity index 100% rename from packages/claude-cli/src/SdkResult.ts rename to apps/claude-cli/src/SdkResult.ts diff --git a/packages/claude-cli/src/SessionManager.ts b/apps/claude-cli/src/SessionManager.ts similarity index 100% rename from packages/claude-cli/src/SessionManager.ts rename to apps/claude-cli/src/SessionManager.ts diff --git a/packages/claude-cli/src/SystemPromptBuilder.ts b/apps/claude-cli/src/SystemPromptBuilder.ts similarity index 100% rename from packages/claude-cli/src/SystemPromptBuilder.ts rename to apps/claude-cli/src/SystemPromptBuilder.ts diff --git a/packages/claude-cli/src/UsageTracker.ts b/apps/claude-cli/src/UsageTracker.ts similarity index 100% rename from packages/claude-cli/src/UsageTracker.ts rename to apps/claude-cli/src/UsageTracker.ts diff --git a/packages/claude-cli/src/cli-config/cleanSchema.ts b/apps/claude-cli/src/cli-config/cleanSchema.ts similarity index 100% rename from packages/claude-cli/src/cli-config/cleanSchema.ts rename to apps/claude-cli/src/cli-config/cleanSchema.ts diff --git a/packages/claude-cli/src/cli-config/consts.ts b/apps/claude-cli/src/cli-config/consts.ts similarity index 100% rename from packages/claude-cli/src/cli-config/consts.ts rename to apps/claude-cli/src/cli-config/consts.ts diff --git a/packages/claude-cli/src/cli-config/diffConfig.ts b/apps/claude-cli/src/cli-config/diffConfig.ts similarity index 100% rename from packages/claude-cli/src/cli-config/diffConfig.ts rename to apps/claude-cli/src/cli-config/diffConfig.ts diff --git a/packages/claude-cli/src/cli-config/generateJsonSchema.ts b/apps/claude-cli/src/cli-config/generateJsonSchema.ts similarity index 100% rename from packages/claude-cli/src/cli-config/generateJsonSchema.ts rename to apps/claude-cli/src/cli-config/generateJsonSchema.ts diff --git a/packages/claude-cli/src/cli-config/initConfig.ts b/apps/claude-cli/src/cli-config/initConfig.ts similarity index 100% rename from packages/claude-cli/src/cli-config/initConfig.ts rename to apps/claude-cli/src/cli-config/initConfig.ts diff --git a/packages/claude-cli/src/cli-config/loadCliConfig.ts b/apps/claude-cli/src/cli-config/loadCliConfig.ts similarity index 100% rename from packages/claude-cli/src/cli-config/loadCliConfig.ts rename to apps/claude-cli/src/cli-config/loadCliConfig.ts diff --git a/packages/claude-cli/src/cli-config/parseCliConfig.ts b/apps/claude-cli/src/cli-config/parseCliConfig.ts similarity index 100% rename from packages/claude-cli/src/cli-config/parseCliConfig.ts rename to apps/claude-cli/src/cli-config/parseCliConfig.ts diff --git a/packages/claude-cli/src/cli-config/schema.ts b/apps/claude-cli/src/cli-config/schema.ts similarity index 100% rename from packages/claude-cli/src/cli-config/schema.ts rename to apps/claude-cli/src/cli-config/schema.ts diff --git a/packages/claude-cli/src/cli-config/types.ts b/apps/claude-cli/src/cli-config/types.ts similarity index 100% rename from packages/claude-cli/src/cli-config/types.ts rename to apps/claude-cli/src/cli-config/types.ts diff --git a/packages/claude-cli/src/cli-config/validateRawConfig.ts b/apps/claude-cli/src/cli-config/validateRawConfig.ts similarity index 100% rename from packages/claude-cli/src/cli-config/validateRawConfig.ts rename to apps/claude-cli/src/cli-config/validateRawConfig.ts diff --git a/packages/claude-cli/src/clipboard.ts b/apps/claude-cli/src/clipboard.ts similarity index 100% rename from packages/claude-cli/src/clipboard.ts rename to apps/claude-cli/src/clipboard.ts diff --git a/packages/claude-cli/src/config.ts b/apps/claude-cli/src/config.ts similarity index 100% rename from packages/claude-cli/src/config.ts rename to apps/claude-cli/src/config.ts diff --git a/packages/claude-cli/src/diff.ts b/apps/claude-cli/src/diff.ts similarity index 100% rename from packages/claude-cli/src/diff.ts rename to apps/claude-cli/src/diff.ts diff --git a/packages/claude-cli/src/editor.ts b/apps/claude-cli/src/editor.ts similarity index 100% rename from packages/claude-cli/src/editor.ts rename to apps/claude-cli/src/editor.ts diff --git a/packages/claude-cli/src/files.ts b/apps/claude-cli/src/files.ts similarity index 100% rename from packages/claude-cli/src/files.ts rename to apps/claude-cli/src/files.ts diff --git a/packages/claude-cli/src/help.ts b/apps/claude-cli/src/help.ts similarity index 100% rename from packages/claude-cli/src/help.ts rename to apps/claude-cli/src/help.ts diff --git a/packages/claude-cli/src/input.ts b/apps/claude-cli/src/input.ts similarity index 100% rename from packages/claude-cli/src/input.ts rename to apps/claude-cli/src/input.ts diff --git a/packages/claude-cli/src/main.ts b/apps/claude-cli/src/main.ts similarity index 100% rename from packages/claude-cli/src/main.ts rename to apps/claude-cli/src/main.ts diff --git a/packages/claude-cli/src/mcp/shellicar/collectRules.ts b/apps/claude-cli/src/mcp/shellicar/collectRules.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/collectRules.ts rename to apps/claude-cli/src/mcp/shellicar/collectRules.ts diff --git a/packages/claude-cli/src/mcp/shellicar/consts.ts b/apps/claude-cli/src/mcp/shellicar/consts.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/consts.ts rename to apps/claude-cli/src/mcp/shellicar/consts.ts diff --git a/packages/claude-cli/src/mcp/shellicar/escapeRegex.ts b/apps/claude-cli/src/mcp/shellicar/escapeRegex.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/escapeRegex.ts rename to apps/claude-cli/src/mcp/shellicar/escapeRegex.ts diff --git a/packages/claude-cli/src/mcp/shellicar/globMatch.ts b/apps/claude-cli/src/mcp/shellicar/globMatch.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/globMatch.ts rename to apps/claude-cli/src/mcp/shellicar/globMatch.ts diff --git a/packages/claude-cli/src/mcp/shellicar/isExecAutoApproved.ts b/apps/claude-cli/src/mcp/shellicar/isExecAutoApproved.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/isExecAutoApproved.ts rename to apps/claude-cli/src/mcp/shellicar/isExecAutoApproved.ts diff --git a/packages/claude-cli/src/mcp/shellicar/isExecPermitted.ts b/apps/claude-cli/src/mcp/shellicar/isExecPermitted.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/isExecPermitted.ts rename to apps/claude-cli/src/mcp/shellicar/isExecPermitted.ts diff --git a/packages/claude-cli/src/mcp/shellicar/match.ts b/apps/claude-cli/src/mcp/shellicar/match.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/match.ts rename to apps/claude-cli/src/mcp/shellicar/match.ts diff --git a/packages/claude-cli/src/mcp/shellicar/matchRules.ts b/apps/claude-cli/src/mcp/shellicar/matchRules.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/matchRules.ts rename to apps/claude-cli/src/mcp/shellicar/matchRules.ts diff --git a/packages/claude-cli/src/mcp/shellicar/ruleMatchesArgs.ts b/apps/claude-cli/src/mcp/shellicar/ruleMatchesArgs.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/ruleMatchesArgs.ts rename to apps/claude-cli/src/mcp/shellicar/ruleMatchesArgs.ts diff --git a/packages/claude-cli/src/mcp/shellicar/ruleMatchesProgram.ts b/apps/claude-cli/src/mcp/shellicar/ruleMatchesProgram.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/ruleMatchesProgram.ts rename to apps/claude-cli/src/mcp/shellicar/ruleMatchesProgram.ts diff --git a/packages/claude-cli/src/mcp/shellicar/segmentMatch.ts b/apps/claude-cli/src/mcp/shellicar/segmentMatch.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/segmentMatch.ts rename to apps/claude-cli/src/mcp/shellicar/segmentMatch.ts diff --git a/packages/claude-cli/src/mcp/shellicar/types.ts b/apps/claude-cli/src/mcp/shellicar/types.ts similarity index 100% rename from packages/claude-cli/src/mcp/shellicar/types.ts rename to apps/claude-cli/src/mcp/shellicar/types.ts diff --git a/packages/claude-cli/src/platform.ts b/apps/claude-cli/src/platform.ts similarity index 100% rename from packages/claude-cli/src/platform.ts rename to apps/claude-cli/src/platform.ts diff --git a/packages/claude-cli/src/providers/GitProvider.ts b/apps/claude-cli/src/providers/GitProvider.ts similarity index 100% rename from packages/claude-cli/src/providers/GitProvider.ts rename to apps/claude-cli/src/providers/GitProvider.ts diff --git a/packages/claude-cli/src/providers/UsageProvider.ts b/apps/claude-cli/src/providers/UsageProvider.ts similarity index 100% rename from packages/claude-cli/src/providers/UsageProvider.ts rename to apps/claude-cli/src/providers/UsageProvider.ts diff --git a/packages/claude-cli/src/providers/consts.ts b/apps/claude-cli/src/providers/consts.ts similarity index 100% rename from packages/claude-cli/src/providers/consts.ts rename to apps/claude-cli/src/providers/consts.ts diff --git a/packages/claude-cli/src/providers/execFileAsync.ts b/apps/claude-cli/src/providers/execFileAsync.ts similarity index 100% rename from packages/claude-cli/src/providers/execFileAsync.ts rename to apps/claude-cli/src/providers/execFileAsync.ts diff --git a/packages/claude-cli/src/providers/types.ts b/apps/claude-cli/src/providers/types.ts similarity index 100% rename from packages/claude-cli/src/providers/types.ts rename to apps/claude-cli/src/providers/types.ts diff --git a/packages/claude-cli/src/renderer.ts b/apps/claude-cli/src/renderer.ts similarity index 100% rename from packages/claude-cli/src/renderer.ts rename to apps/claude-cli/src/renderer.ts diff --git a/packages/claude-cli/src/session.ts b/apps/claude-cli/src/session.ts similarity index 100% rename from packages/claude-cli/src/session.ts rename to apps/claude-cli/src/session.ts diff --git a/packages/claude-cli/src/terminal.ts b/apps/claude-cli/src/terminal.ts similarity index 88% rename from packages/claude-cli/src/terminal.ts rename to apps/claude-cli/src/terminal.ts index c486a43..07a8659 100644 --- a/packages/claude-cli/src/terminal.ts +++ b/apps/claude-cli/src/terminal.ts @@ -1,28 +1,43 @@ import { inspect } from 'node:util'; import { DateTimeFormatter, LocalTime } from '@js-joda/core'; +import { BEL, DIM, hideCursor, INVERSE_OFF, INVERSE_ON, RESET } from '@shellicar/claude-core/ansi'; +import { computeLineSegments, type LineSegment, rewrapFromSegments, wrapLine } from '@shellicar/claude-core/reflow'; +import { Renderer } from '@shellicar/claude-core/renderer'; +import type { Screen } from '@shellicar/claude-core/screen'; +import { StdoutScreen } from '@shellicar/claude-core/screen'; +import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; +import { Viewport } from '@shellicar/claude-core/viewport'; import stringWidth from 'string-width'; import type { AppState } from './AppState.js'; import type { AttachmentStore } from './AttachmentStore.js'; import type { CommandMode } from './CommandMode.js'; import type { EditorState } from './editor.js'; import { type HistoryFrame, HistoryViewport } from './HistoryViewport.js'; -import type { BuiltComponent, LayoutInput, LineSegment } from './Layout.js'; -import { computeLineSegments, layout, rewrapFromSegments, wrapLine } from './Layout.js'; +import type { BuiltComponent, LayoutInput } from './Layout.js'; +import { layout } from './Layout.js'; import { type EditorRender, prepareEditor } from './renderer.js'; -import type { Screen } from './Screen.js'; -import { StdoutScreen } from './Screen.js'; -import { StatusLineBuilder } from './StatusLineBuilder.js'; -import { Renderer } from './TerminalRenderer.js'; -import { Viewport } from './Viewport.js'; const TIME_FORMAT = DateTimeFormatter.ofPattern('HH:mm:ss.SSS'); -const ESC = '\x1B['; -const hideCursorSeq = `${ESC}?25l`; -const resetStyle = `${ESC}0m`; -const inverseOn = `${ESC}7m`; -const inverseOff = `${ESC}27m`; -const bel = '\x07'; +const FILL = '\u2500'; + +const BLOCK_LABELS: Record = { + prompt: 'prompt', + thinking: '💭 thinking', + response: '💬 response', + tools: '🔧 tools', +}; + +function buildDivider(label: string | null, columns: number): string { + const displayLabel = label !== null ? (BLOCK_LABELS[label] ?? label) : null; + if (!displayLabel) { + return DIM + FILL.repeat(columns) + RESET; + } + const prefix = `${FILL}${FILL} ${displayLabel} `; + const prefixWidth = stringWidth(prefix); + const remaining = Math.max(0, columns - prefixWidth); + return DIM + prefix + FILL.repeat(remaining) + RESET; +} export class Terminal { private editorContent: EditorRender = { lines: [], cursorRow: 0, cursorCol: 0 }; @@ -126,7 +141,7 @@ export class Terminal { } private formatLogLine(message: string, ...args: unknown[]): string { - let line = `${resetStyle}[${this.timestamp()}] ${message}`; + let line = `${RESET}[${this.timestamp()}] ${message}`; for (const a of args) { line += ' '; line += typeof a === 'string' ? a : inspect(a, { depth: null, colors: true, breakLength: Infinity, compact: true }); @@ -135,20 +150,20 @@ export class Terminal { } private buildLogLine(b: StatusLineBuilder, message: string): void { - b.ansi(resetStyle); + b.ansi(RESET); const ts = this.timestamp(); b.text(`[${ts}] ${message}`); } private buildInverseLine(b: StatusLineBuilder, message: string, inverse: boolean): void { - b.ansi(resetStyle); + b.ansi(RESET); if (inverse) { - b.ansi(inverseOn); + b.ansi(INVERSE_ON); } const ts = this.timestamp(); b.text(`[${ts}] ${message}`); if (inverse) { - b.ansi(inverseOff); + b.ansi(INVERSE_OFF); } } @@ -204,7 +219,7 @@ export class Terminal { const start = this.lastHistoryFrame.visibleStart + 1; const end = Math.min(this.lastHistoryFrame.visibleStart + this.lastHistoryFrame.rows.length, this.lastHistoryFrame.totalLines); const total = this.lastHistoryFrame.totalLines; - b.ansi(resetStyle); + b.ansi(RESET); b.text(` [\u2191 ${start}-${end}/${total}]`); } @@ -230,11 +245,11 @@ export class Terminal { const label = att.kind === 'image' ? 'img' : 'txt'; const isSelected = commandModeActive && i === store.selectedIndex; if (isSelected) { - b.ansi(inverseOn); + b.ansi(INVERSE_ON); } b.text(`[${i + 1}:${label}:${sizeKB}KB]`); if (isSelected) { - b.ansi(inverseOff); + b.ansi(INVERSE_OFF); } if (i < store.attachments.length - 1) { b.text(' '); @@ -252,11 +267,11 @@ export class Terminal { } else { b.text('i=image t=text d=delete '); if (this.commandMode.previewActive) { - b.ansi(inverseOn); + b.ansi(INVERSE_ON); } b.text('p=preview'); if (this.commandMode.previewActive) { - b.ansi(inverseOff); + b.ansi(INVERSE_OFF); } b.text(' \u2190\u2192=select s=session ESC=exit'); } @@ -338,12 +353,16 @@ export class Terminal { questionComp = null; } + const dividerLine = buildDivider('prompt', columns); + const promptDividerComp: BuiltComponent = { rows: ['', dividerLine, '', ''], height: 4 }; + return { editor: this.editorContent, status: statusComp, attachments: attachComp, preview: previewComp, question: questionComp, + promptDivider: promptDividerComp, columns, }; } @@ -387,7 +406,7 @@ export class Terminal { const frame = this.viewport.resolve(zoneBuffer, zoneRows, 0, 0); this.renderer.render(historyFrame.rows, frame); if (this.cursorHidden) { - this.screen.write(hideCursorSeq); + this.screen.write(hideCursor); } return; } @@ -412,7 +431,7 @@ export class Terminal { // 5. Renderer receives both this.renderer.render(historyFrame.rows, frame); if (this.cursorHidden) { - this.screen.write(hideCursorSeq); + this.screen.write(hideCursor); } } @@ -469,6 +488,13 @@ export class Terminal { this.renderZone(); } + public openBlock(label: string | null): void { + const columns = this.screen.columns; + this.writeHistory(''); + this.writeHistory(buildDivider(label, columns)); + this.writeHistory(''); + } + public log(message: string, ...args: unknown[]): void { const line = this.formatLogLine(message, ...args); this.writeHistory(line); @@ -495,7 +521,7 @@ export class Terminal { } public beep(): void { - this.screen.write(bel); + this.screen.write(BEL); } public error(message: string): void { diff --git a/packages/claude-cli/test/HistoryViewport.spec.ts b/apps/claude-cli/test/HistoryViewport.spec.ts similarity index 100% rename from packages/claude-cli/test/HistoryViewport.spec.ts rename to apps/claude-cli/test/HistoryViewport.spec.ts diff --git a/packages/claude-cli/test/MockScreen.ts b/apps/claude-cli/test/MockScreen.ts similarity index 98% rename from packages/claude-cli/test/MockScreen.ts rename to apps/claude-cli/test/MockScreen.ts index 55f7c84..7845048 100644 --- a/packages/claude-cli/test/MockScreen.ts +++ b/apps/claude-cli/test/MockScreen.ts @@ -1,4 +1,4 @@ -import type { Screen } from '../src/Screen.js'; +import type { Screen } from '@shellicar/claude-core/screen'; export class MockScreen implements Screen { public cells: string[][]; diff --git a/packages/claude-cli/test/TerminalRenderer.spec.ts b/apps/claude-cli/test/TerminalRenderer.spec.ts similarity index 96% rename from packages/claude-cli/test/TerminalRenderer.spec.ts rename to apps/claude-cli/test/TerminalRenderer.spec.ts index 592196b..7c58ed5 100644 --- a/packages/claude-cli/test/TerminalRenderer.spec.ts +++ b/apps/claude-cli/test/TerminalRenderer.spec.ts @@ -1,7 +1,7 @@ +import { Renderer } from '@shellicar/claude-core/renderer'; +import type { Screen } from '@shellicar/claude-core/screen'; +import type { ViewportResult } from '@shellicar/claude-core/viewport'; import { describe, expect, it } from 'vitest'; -import type { Screen } from '../src/Screen.js'; -import { Renderer } from '../src/TerminalRenderer.js'; -import type { ViewportResult } from '../src/Viewport.js'; import { MockScreen } from './MockScreen.js'; function makeScreen(columns: number) { diff --git a/packages/claude-cli/test/autoApprove.spec.ts b/apps/claude-cli/test/autoApprove.spec.ts similarity index 100% rename from packages/claude-cli/test/autoApprove.spec.ts rename to apps/claude-cli/test/autoApprove.spec.ts diff --git a/packages/claude-cli/test/cli-config.spec.ts b/apps/claude-cli/test/cli-config.spec.ts similarity index 100% rename from packages/claude-cli/test/cli-config.spec.ts rename to apps/claude-cli/test/cli-config.spec.ts diff --git a/packages/claude-cli/test/execPermissions.spec.ts b/apps/claude-cli/test/execPermissions.spec.ts similarity index 100% rename from packages/claude-cli/test/execPermissions.spec.ts rename to apps/claude-cli/test/execPermissions.spec.ts diff --git a/packages/claude-cli/test/input.spec.ts b/apps/claude-cli/test/input.spec.ts similarity index 100% rename from packages/claude-cli/test/input.spec.ts rename to apps/claude-cli/test/input.spec.ts diff --git a/packages/claude-cli/test/layout.spec.ts b/apps/claude-cli/test/layout.spec.ts similarity index 93% rename from packages/claude-cli/test/layout.spec.ts rename to apps/claude-cli/test/layout.spec.ts index a355f34..ab2fccf 100644 --- a/packages/claude-cli/test/layout.spec.ts +++ b/apps/claude-cli/test/layout.spec.ts @@ -10,6 +10,8 @@ function component(rows: string[], height: number): BuiltComponent { return { rows, height }; } +const noPromptDivider: BuiltComponent = { rows: [], height: 0 }; + describe('layout', () => { it('editor only: 5 single-row lines produce buffer of 5 rows', () => { const input: LayoutInput = { @@ -18,6 +20,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -34,6 +37,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -50,6 +54,7 @@ describe('layout', () => { attachments: component(['attachment'], 1), preview: component(['preview'], 1), question: component(['question'], 1), + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -69,6 +74,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -84,6 +90,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -105,6 +112,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 4, }; @@ -125,6 +133,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -140,6 +149,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -153,6 +163,7 @@ describe('layout', () => { attachments: component(['a'], 1), preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -166,6 +177,7 @@ describe('layout', () => { attachments: null, preview: component(['p'], 1), question: component(['q'], 1), + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -181,6 +193,7 @@ describe('layout', () => { attachments: null, preview: component(['preview line 1', 'preview line 2', 'preview line 3'], 3), question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); @@ -204,6 +217,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; @@ -224,6 +238,7 @@ describe('layout', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, }; const result = layout(input); diff --git a/packages/claude-cli/test/mock-screen.spec.ts b/apps/claude-cli/test/mock-screen.spec.ts similarity index 100% rename from packages/claude-cli/test/mock-screen.spec.ts rename to apps/claude-cli/test/mock-screen.spec.ts diff --git a/packages/claude-cli/test/prepareEditor.spec.ts b/apps/claude-cli/test/prepareEditor.spec.ts similarity index 100% rename from packages/claude-cli/test/prepareEditor.spec.ts rename to apps/claude-cli/test/prepareEditor.spec.ts diff --git a/packages/claude-cli/test/sanitise.spec.ts b/apps/claude-cli/test/sanitise.spec.ts similarity index 96% rename from packages/claude-cli/test/sanitise.spec.ts rename to apps/claude-cli/test/sanitise.spec.ts index ec89a54..9a5d674 100644 --- a/packages/claude-cli/test/sanitise.spec.ts +++ b/apps/claude-cli/test/sanitise.spec.ts @@ -1,6 +1,6 @@ +import { sanitiseLoneSurrogates, sanitiseZwj } from '@shellicar/claude-core/sanitise'; import stringWidth from 'string-width'; import { describe, expect, it } from 'vitest'; -import { sanitiseLoneSurrogates, sanitiseZwj } from '../src/sanitise.js'; describe('sanitiseLoneSurrogates', () => { it('replaces lone high surrogate', () => { diff --git a/packages/claude-cli/test/terminal-functional.spec.ts b/apps/claude-cli/test/terminal-functional.spec.ts similarity index 98% rename from packages/claude-cli/test/terminal-functional.spec.ts rename to apps/claude-cli/test/terminal-functional.spec.ts index 67369ed..a587fc7 100644 --- a/packages/claude-cli/test/terminal-functional.spec.ts +++ b/apps/claude-cli/test/terminal-functional.spec.ts @@ -1,8 +1,8 @@ +import type { Screen } from '@shellicar/claude-core/screen'; import { describe, expect, it } from 'vitest'; import { AppState } from '../src/AppState.js'; import { AttachmentStore } from '../src/AttachmentStore.js'; import { CommandMode } from '../src/CommandMode.js'; -import type { Screen } from '../src/Screen.js'; import { Terminal } from '../src/terminal.js'; import { MockScreen } from './MockScreen.js'; diff --git a/packages/claude-cli/test/terminal-integration.spec.ts b/apps/claude-cli/test/terminal-integration.spec.ts similarity index 95% rename from packages/claude-cli/test/terminal-integration.spec.ts rename to apps/claude-cli/test/terminal-integration.spec.ts index 1c09521..b51298e 100644 --- a/packages/claude-cli/test/terminal-integration.spec.ts +++ b/apps/claude-cli/test/terminal-integration.spec.ts @@ -1,10 +1,11 @@ +import { wrapLine } from '@shellicar/claude-core/reflow'; +import { Renderer } from '@shellicar/claude-core/renderer'; +import { Viewport } from '@shellicar/claude-core/viewport'; import { describe, expect, it } from 'vitest'; import { HistoryViewport } from '../src/HistoryViewport.js'; import type { BuiltComponent, LayoutInput } from '../src/Layout.js'; -import { layout, wrapLine } from '../src/Layout.js'; +import { layout } from '../src/Layout.js'; import type { EditorRender } from '../src/renderer.js'; -import { Renderer } from '../src/TerminalRenderer.js'; -import { Viewport } from '../src/Viewport.js'; import { MockScreen } from './MockScreen.js'; function makeEditorRender(lineCount: number, cursorRow = 0, cursorCol = 0): EditorRender { @@ -16,6 +17,8 @@ function makeComponent(rows: string[]): BuiltComponent { return { rows, height: rows.length }; } +const noPromptDivider: BuiltComponent = { rows: [], height: 0 }; + function runPipeline(screen: MockScreen, viewport: Viewport, renderer: Renderer, input: LayoutInput): void { const result = layout(input); const frame = viewport.resolve(result.buffer, screen.rows, result.cursorRow, result.cursorCol); @@ -34,6 +37,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -53,6 +57,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -69,6 +74,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -102,6 +108,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -123,6 +130,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -142,6 +150,7 @@ describe('Terminal integration', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -197,6 +206,7 @@ describe('History flush', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -224,6 +234,7 @@ describe('Two-region rendering', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -260,6 +271,7 @@ describe('Two-region rendering', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -294,6 +306,7 @@ describe('Two-region rendering', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; @@ -427,6 +440,7 @@ describe('Two-region rendering', () => { attachments: null, preview: null, question: null, + promptDivider: noPromptDivider, columns: 80, } satisfies LayoutInput; const result = layout(input); diff --git a/packages/claude-cli/test/terminal-perf.spec.ts b/apps/claude-cli/test/terminal-perf.spec.ts similarity index 96% rename from packages/claude-cli/test/terminal-perf.spec.ts rename to apps/claude-cli/test/terminal-perf.spec.ts index e3f8ffa..f495655 100644 --- a/packages/claude-cli/test/terminal-perf.spec.ts +++ b/apps/claude-cli/test/terminal-perf.spec.ts @@ -1,9 +1,9 @@ +import type { Screen } from '@shellicar/claude-core/screen'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AppState } from '../src/AppState.js'; import { AttachmentStore } from '../src/AttachmentStore.js'; import { CommandMode } from '../src/CommandMode.js'; import { createEditor, insertChar } from '../src/editor.js'; -import type { Screen } from '../src/Screen.js'; import { Terminal } from '../src/terminal.js'; function makeTerminal(): Terminal { @@ -82,7 +82,7 @@ describe('Terminal wrapping cache', () => { const end = process.hrtime.bigint(); const actual = Number(end - start) / 1_000_000; - const expected = process.env.CI ? 15 : 2; + const expected = process.env.CI ? 20 : 2; expect(actual).toBeLessThan(expected); }); diff --git a/packages/claude-cli/test/terminal.spec.ts b/apps/claude-cli/test/terminal.spec.ts similarity index 100% rename from packages/claude-cli/test/terminal.spec.ts rename to apps/claude-cli/test/terminal.spec.ts diff --git a/packages/claude-cli/test/viewport.spec.ts b/apps/claude-cli/test/viewport.spec.ts similarity index 98% rename from packages/claude-cli/test/viewport.spec.ts rename to apps/claude-cli/test/viewport.spec.ts index 1250934..bb5fd30 100644 --- a/packages/claude-cli/test/viewport.spec.ts +++ b/apps/claude-cli/test/viewport.spec.ts @@ -1,5 +1,5 @@ +import { Viewport } from '@shellicar/claude-core/viewport'; import { describe, expect, it } from 'vitest'; -import { Viewport } from '../src/Viewport.js'; describe('Viewport', () => { it('buffer shorter than screen: returns screenRows entries (content + padding)', () => { diff --git a/packages/claude-cli/tsconfig.check.json b/apps/claude-cli/tsconfig.check.json similarity index 100% rename from packages/claude-cli/tsconfig.check.json rename to apps/claude-cli/tsconfig.check.json diff --git a/apps/claude-cli/tsconfig.json b/apps/claude-cli/tsconfig.json new file mode 100644 index 0000000..f41a1dc --- /dev/null +++ b/apps/claude-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@shellicar/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["**/*.ts"] +} diff --git a/apps/claude-cli/vitest.config.ts b/apps/claude-cli/vitest.config.ts new file mode 100644 index 0000000..ae8680e --- /dev/null +++ b/apps/claude-cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/apps/claude-sdk-cli/.gitignore b/apps/claude-sdk-cli/.gitignore index a547bf3..f80d99c 100644 --- a/apps/claude-sdk-cli/.gitignore +++ b/apps/claude-sdk-cli/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.sdk-history.jsonl diff --git a/apps/claude-sdk-cli/CLAUDE.md b/apps/claude-sdk-cli/CLAUDE.md new file mode 100644 index 0000000..5fa9d1f --- /dev/null +++ b/apps/claude-sdk-cli/CLAUDE.md @@ -0,0 +1,118 @@ +# claude-sdk-cli — App Notes + +## Overview + +`apps/claude-sdk-cli` is the **new** CLI, intentionally lightweight, built on `packages/claude-sdk` (the custom agent SDK). It is the active development target. `apps/claude-cli` is the older CLI built on `@anthropic-ai/claude-agent-sdk` and is kept as a reference. + +The distinction matters: `claude-sdk-cli` owns the agentic loop directly — `AgentRun.execute()`, `MessageStream`, `ConversationHistory`, cache control, cost calculation. The Anthropic SDK is just an HTTP client. This is what makes cost tracking, context management, orchestration, and observability possible. + +## Design Principles + +- **Lightweight by design** — no session management, no built-in permissions, no built-in tools (tools live in `packages/claude-sdk-tools`) +- **Own the loop** — full control over the message cycle, caching, token tracking +- **Explicit over magic** — tool results, refs, approvals are all visible and deliberate + +## Planned Features + +### Command Mode (not yet implemented) +Ctrl+/ enters command mode, single-key commands inside (like roguelikes/Dwarf Fortress). +Ref: see `apps/claude-cli/src/CommandMode.ts` for the existing implementation to port. + +Planned bindings: +- `i` — paste image from clipboard +- `t` — paste text as attachment (large text block, labelled, not inline) +- More TBD + +### Attachments / Paste (not yet implemented) +Ref: `apps/claude-cli/src/AttachmentStore.ts`, `apps/claude-cli/src/clipboard.ts`, `apps/claude-cli/src/ImageStore.ts`. + +`runAgent.ts` currently passes `messages: [prompt]` — needs to become `messages: [...attachments, prompt]`. + +Note: direct terminal paste is extremely slow for large content. Clipboard read via command mode is the correct approach. + +### Input +The current input method (readline) is slow for large pastes. Needs investigation — may require a different approach for the alt buffer. + +--- + +## Tool Result Size & Context Protection + +This is the most urgent infrastructure gap. Without it, a single `ReadFile` on a large file can exhaust the entire context and cause an API error. + +### The Problem + +Currently all tool results go directly into the conversation with no size limit. The official `@anthropic-ai/claude-agent-sdk` handles this by redirecting large outputs to a temp file and returning the path instead — but this is a blunt instrument (the whole result is replaced, losing structured fields like `exitCode`). + +### The Four Layers (distinct problems, distinct owners) + +**1. Ref-swapping large output** *(most urgent — serialisation layer)* + +One intervention point, before the tool result enters the conversation. Walk the JSON tree, find string fields over a threshold (e.g. 10k chars), swap them with `{ ref: 'uuid', size: N }`. The tool itself doesn't need to change at all. + +Example — without ref-swapping: +```json +{ "exitCode": 0, "stdout": "...500kb of build output..." } +``` +With ref-swapping: +```json +{ "exitCode": 0, "stdout": { "ref": "abc123", "size": 512000 } } +``` +The model still sees `exitCode`, still knows stdout was large, can choose to query the ref or not. + +**2. Ref store / query tool** *(prerequisite for #1)* + +Every tool result gets stored, regardless of size (write-always). A `Ref` tool lets the model retrieve stored content with optional paging/slicing. The EditFile patch store is the same pattern — just global scope. + +Tool probably looks like: +``` +Ref(id, offset?, length?) → { content: string, size: N, truncated: bool } +``` + +**3. Culling old tool results from history** *(history / message loop)* + +After N turns, old tool results get summarised or dropped from `ConversationHistory`. Owned by the message loop, not the tools. Less urgent than #1 and #2. + +**4. Context search / RAG** *(separate problem entirely)* + +If context becomes unwieldy, semantic search over it. Different infrastructure, different owner. Lowest priority. + +### Implementation Order +1. Ref store (simple in-memory store, keyed by UUID) +2. Ref-swapping at the serialisation layer (walk JSON, swap large strings) +3. `Ref` query tool +4. Culling policy in `ConversationHistory` +5. RAG (future, separate concern) + +### Design Note on Ref Store +The ref store uses `hash(prev.newContent)` style per-step hashing (not inherited root hash) — i.e. each ref is self-contained. Unlike the EditFile patch chain where `originalHash` is inherited from the root, refs don't need to chain. + +--- + +## Token / Cost Display + +Status line format (implemented): +``` +in: 7 ↑138.0k ↓65.7k out: 610 $0.5465 ctx: 72.3k/200.0k (36.1%) +``` +- `in:` — uncached input tokens (small when cache is hot) +- `↑` — cache creation tokens (written, expensive) +- `↓` — cache read tokens (read, cheap) — shown only when > 0 +- `out:` — output tokens +- `$` — total cost this turn (cumulative across turns) +- `ctx:` — per-turn context usage vs model context window + +Previous bug: `↑` (cache creation) was invisible in the display but was included in cost, making the cost appear wildly wrong (e.g. `in: 3 $5.21`). + +--- + +## Key Files + +| File | Role | +|------|------| +| `src/entry/main.ts` | Entry point | +| `src/AppLayout.ts` | TUI layout, streaming, tool display, status line | +| `src/runAgent.ts` | Agent loop wiring — tools, approval, message dispatch | +| `src/ReadLine.ts` | Terminal input | +| `src/permissions.ts` | Tool auto-approve/deny rules | +| `src/logger.ts` | Structured logging with truncation | +| `src/redact.ts` | Redaction for audit/log output | diff --git a/apps/claude-sdk-cli/build.ts b/apps/claude-sdk-cli/build.ts index 350222a..2671602 100644 --- a/apps/claude-sdk-cli/build.ts +++ b/apps/claude-sdk-cli/build.ts @@ -10,19 +10,22 @@ const inject = await Array.fromAsync(glob('./inject/*.ts')); const ctx = await esbuild.context({ bundle: true, - entryPoints: ['src/**/*.ts'], + entryPoints: ['src/entry/*.ts'], inject, - entryNames: '[name]', + entryNames: 'entry/[name]', + chunkNames: 'chunks/[name]-[hash]', keepNames: true, format: 'esm', minify: false, outdir: 'dist', platform: 'node', plugins, + splitting: true, + external: ['@anthropic-ai/sdk'], sourcemap: true, - target: 'node22', + target: 'node24', treeShaking: true, - dropLabels: ['DEBUG'], + // dropLabels: ['DEBUG'], tsconfig: 'tsconfig.json', }); diff --git a/apps/claude-sdk-cli/package.json b/apps/claude-sdk-cli/package.json index 38a6658..bae00b4 100644 --- a/apps/claude-sdk-cli/package.json +++ b/apps/claude-sdk-cli/package.json @@ -1,21 +1,55 @@ { - "name": "claude-sdk-cli", + "name": "@shellicar/claude-sdk-cli", "version": "0.0.0", - "private": true, + "private": false, + "description": "Interactive CLI for Claude AI built on the Anthropic SDK", + "license": "MIT", + "author": "Stephen Hellicar", + "contributors": [ + "BananaBot9000 ", + "Claude (Anthropic) " + ], + "repository": { + "type": "git", + "url": "git+https://github.com/shellicar/claude-cli.git" + }, + "bugs": { + "url": "https://github.com/shellicar/claude-cli/issues" + }, + "homepage": "https://github.com/shellicar/claude-cli#readme", + "publishConfig": { + "access": "public" + }, + "bin": { + "claude-sdk-cli": "dist/entry/main.js" + }, + "files": [ + "dist" + ], "type": "module", "scripts": { + "dev": "node --inspect dist/entry/main.js", "build": "tsx build.ts", - "start": "node dist/main.js" + "start": "node dist/entry/main.js", + "watch": "tsx build.ts --watch", + "test": "vitest run" }, "devDependencies": { "@shellicar/build-clean": "^1.3.2", "@shellicar/build-version": "^1.3.6", + "@shellicar/typescript-config": "workspace:*", "@tsconfig/node24": "^24.0.4", + "@types/node": "^25.5.0", "esbuild": "^0.27.5", - "tsx": "^4.21.0" + "tsx": "^4.21.0", + "vitest": "^4.1.2" }, "dependencies": { + "@anthropic-ai/sdk": "^0.82.0", + "@shellicar/claude-core": "workspace:^", "@shellicar/claude-sdk": "workspace:^", + "@shellicar/claude-sdk-tools": "workspace:^", + "cli-highlight": "^2.1.11", "winston": "^3.19.0", "zod": "^4.3.6" } diff --git a/apps/claude-sdk-cli/src/AppLayout.ts b/apps/claude-sdk-cli/src/AppLayout.ts new file mode 100644 index 0000000..c8f24aa --- /dev/null +++ b/apps/claude-sdk-cli/src/AppLayout.ts @@ -0,0 +1,995 @@ +import { stat } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { clearDown, clearLine, cursorAt, DIM, hideCursor, INVERSE_OFF, INVERSE_ON, RESET, syncEnd, syncStart } from '@shellicar/claude-core/ansi'; +import type { KeyAction } from '@shellicar/claude-core/input'; +import { wrapLine } from '@shellicar/claude-core/reflow'; +import { sanitiseLoneSurrogates } from '@shellicar/claude-core/sanitise'; +import type { Screen } from '@shellicar/claude-core/screen'; +import { StdoutScreen } from '@shellicar/claude-core/screen'; +import { StatusLineBuilder } from '@shellicar/claude-core/status-line'; +import type { SdkMessageUsage } from '@shellicar/claude-sdk'; +import { highlight } from 'cli-highlight'; +import { AttachmentStore } from './AttachmentStore.js'; +import { readClipboardPath, readClipboardText } from './clipboard.js'; +import { logger } from './logger.js'; + +export type PendingTool = { + requestId: string; + name: string; + input: Record; +}; + +type Mode = 'editor' | 'streaming'; + +type BlockType = 'prompt' | 'thinking' | 'response' | 'tools' | 'compaction'; + +type Block = { + type: BlockType; + content: string; +}; + +const FILL = '\u2500'; + +const BLOCK_PLAIN: Record = { + prompt: 'prompt', + thinking: 'thinking', + response: 'response', + tools: 'tools', + compaction: 'compaction', +}; + +const BLOCK_EMOJI: Record = { + prompt: '💬 ', + thinking: '💭 ', + response: '📝 ', + tools: '🔧 ', + compaction: '🗜 ', +}; + +const EDITOR_PROMPT = '💬 '; +const CONTENT_INDENT = ' '; + +const CODE_FENCE_RE = /```(\w*)\n([\s\S]*?)```/g; + +function renderBlockContent(content: string, cols: number): string[] { + const result: string[] = []; + let lastIndex = 0; + + const addText = (text: string) => { + const lines = text.split('\n'); + const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines; + for (const line of trimmed) { + result.push(...wrapLine(CONTENT_INDENT + line, cols)); + } + }; + + for (const match of content.matchAll(CODE_FENCE_RE)) { + if (match.index > lastIndex) { + addText(content.slice(lastIndex, match.index)); + } + const lang = match[1] || 'plaintext'; + const code = (match[2] ?? '').trimEnd(); + result.push(`${CONTENT_INDENT}\`\`\`${lang}`); + try { + const highlighted = highlight(code, { language: lang, ignoreIllegals: true }); + for (const line of highlighted.split('\n')) { + result.push(CONTENT_INDENT + line); + } + } catch { + for (const line of code.split('\n')) { + result.push(CONTENT_INDENT + line); + } + } + result.push(`${CONTENT_INDENT}\`\`\``); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < content.length) { + addText(content.slice(lastIndex)); + } else if (lastIndex === 0) { + addText(content); + } + + return result; +} + +function formatTokens(n: number): string { + if (n >= 1000) { + return `${(n / 1000).toFixed(1)}k`; + } + return String(n); +} + +function buildDivider(displayLabel: string | null, cols: number): string { + if (!displayLabel) { + return DIM + FILL.repeat(cols) + RESET; + } + const prefix = `${FILL}${FILL} ${displayLabel} `; + const remaining = Math.max(0, cols - prefix.length); + return DIM + prefix + FILL.repeat(remaining) + RESET; +} + +/** Returns true if the string looks like a deliberate filesystem path (for missing-file chips). */ +function isLikelyPath(s: string): boolean { + if (!s || s.length > 1024) { + return false; + } + if (/[\n\r]/.test(s)) { + return false; + } + return s.startsWith('/') || s.startsWith('~/') || s === '~' || s.startsWith('./') || s.startsWith('../'); +} + +export class AppLayout implements Disposable { + readonly #screen: Screen; + readonly #cleanupResize: () => void; + + #mode: Mode = 'editor'; + #sealedBlocks: Block[] = []; + #flushedCount = 0; + #activeBlock: Block | null = null; + #editorLines: string[] = ['']; + #cursorLine = 0; + #cursorCol = 0; + #renderPending = false; + + #pendingTools: PendingTool[] = []; + #selectedTool = 0; + #toolExpanded = false; + + #commandMode = false; + #previewMode = false; + #attachments = new AttachmentStore(); + + #editorResolve: ((value: string) => void) | null = null; + #pendingApprovals: Array<(approved: boolean) => void> = []; + #cancelFn: (() => void) | null = null; + + #totalInputTokens = 0; + #totalCacheCreationTokens = 0; + #totalCacheReadTokens = 0; + #totalOutputTokens = 0; + #totalCostUsd = 0; + #lastContextUsed = 0; + #contextWindow = 0; + + public constructor() { + this.#screen = new StdoutScreen(); + this.#cleanupResize = this.#screen.onResize(() => this.render()); + } + + public [Symbol.dispose](): void { + this.exit(); + } + + public enter(): void { + this.#screen.enterAltBuffer(); + this.render(); + } + + public exit(): void { + this.#cleanupResize(); + this.#screen.exitAltBuffer(); + } + + /** Transition to streaming mode. Seals the prompt as a block; active block is created on first content. */ + public startStreaming(prompt: string): void { + this.#sealedBlocks.push({ type: 'prompt', content: prompt }); + this.#activeBlock = null; + this.#mode = 'streaming'; + this.#flushToScroll(); + this.render(); + } + + /** Transition to a new block type. Seals the current block (if it has content) and opens a fresh one. + * Consecutive same-type blocks are merged visually by the renderer (no header or gap between them), + * so there is nothing special to do here — every call produces its own block. */ + public transitionBlock(type: BlockType): void { + if (this.#activeBlock?.type === type) { + logger.debug('transitionBlock_noop', { type, totalSealed: this.#sealedBlocks.length }); + return; + } + const from = this.#activeBlock?.type ?? null; + const sealed = !!this.#activeBlock?.content.trim(); + if (this.#activeBlock?.content.trim()) { + this.#sealedBlocks.push(this.#activeBlock); + } + logger.debug('transitionBlock', { from, to: type, sealed, totalSealed: this.#sealedBlocks.length }); + this.#activeBlock = { type, content: '' }; + this.render(); + } + + /** Append a chunk of text to the active block. */ + public appendStreaming(text: string): void { + if (this.#activeBlock) { + this.#activeBlock.content += sanitiseLoneSurrogates(text); + this.render(); + } + } + + /** Seal the completed response block and return to editor mode. */ + public completeStreaming(): void { + if (this.#activeBlock?.content.trim()) { + this.#sealedBlocks.push(this.#activeBlock); + } + this.#activeBlock = null; + this.#pendingTools = []; + this.#mode = 'editor'; + this.#commandMode = false; + this.#previewMode = false; + this.#attachments.clear(); + this.#editorLines = ['']; + this.#cursorLine = 0; + this.#cursorCol = 0; + this.#flushToScroll(); + this.render(); + } + + public addPendingTool(tool: PendingTool): void { + this.#pendingTools.push(tool); + if (this.#pendingTools.length === 1) { + this.#selectedTool = 0; + } + this.render(); + } + + public removePendingTool(requestId: string): void { + const idx = this.#pendingTools.findIndex((t) => t.requestId === requestId); + if (idx < 0) { + return; + } + this.#pendingTools.splice(idx, 1); + this.#selectedTool = Math.min(this.#selectedTool, Math.max(0, this.#pendingTools.length - 1)); + this.render(); + } + + public setCancelFn(fn: (() => void) | null): void { + this.#cancelFn = fn; + } + + /** + * Append text to the most recent sealed block of the given type. + * Used for retroactive annotations (e.g. adding turn cost to the tools block after + * the next message_usage arrives). Has no effect if no matching block exists. + */ + public appendToLastSealed(type: BlockType, text: string): void { + const activeType = this.#activeBlock?.type ?? null; + logger.debug('appendToLastSealed', { type, activeType, totalSealed: this.#sealedBlocks.length }); + // When tool batches run back-to-back (no thinking/text between them), transitionBlock + // is a no-op so the tools block stays *active* when message_usage fires. Check active first. + if (this.#activeBlock?.type === type) { + logger.debug('appendToLastSealed_found', { target: 'active' }); + this.#activeBlock.content += text; + this.render(); + return; + } + for (let i = this.#sealedBlocks.length - 1; i >= 0; i--) { + if (this.#sealedBlocks[i]?.type === type) { + logger.debug('appendToLastSealed_found', { index: i, totalSealed: this.#sealedBlocks.length }); + // biome-ignore lint/style/noNonNullAssertion: checked above + this.#sealedBlocks[i]!.content += text; + this.render(); + return; + } + } + logger.warn('appendToLastSealed_miss', { type, activeType }); + } + + public updateUsage(msg: SdkMessageUsage): void { + this.#totalInputTokens += msg.inputTokens; + this.#totalCacheCreationTokens += msg.cacheCreationTokens; + this.#totalCacheReadTokens += msg.cacheReadTokens; + this.#totalOutputTokens += msg.outputTokens; + this.#totalCostUsd += msg.costUsd; + this.#lastContextUsed = msg.inputTokens + msg.cacheCreationTokens + msg.cacheReadTokens; + this.#contextWindow = msg.contextWindow; + this.render(); + } + + /** Enter editor mode and wait for the user to submit input via Ctrl+Enter. */ + public waitForInput(): Promise { + this.#mode = 'editor'; + this.#editorLines = ['']; + this.#cursorLine = 0; + this.#cursorCol = 0; + this.#toolExpanded = false; + this.render(); + return new Promise((resolve) => { + this.#editorResolve = resolve; + }); + } + + /** + * Wait for the user to approve or deny a tool via Y/N. + * The tool must already be added via addPendingTool before calling this. + * Multiple calls queue up; Y/N resolves them in FIFO order. + */ + public requestApproval(): Promise { + return new Promise((resolve) => { + this.#pendingApprovals.push(resolve); + this.render(); + }); + } + + /** Debounced render for key events — batches rapid input (paste) into one repaint. */ + #scheduleRender(): void { + if (!this.#renderPending) { + this.#renderPending = true; + setImmediate(() => { + this.#renderPending = false; + this.render(); + }); + } + } + + /** Returns the column index of the start of the word to the left of col. */ + #wordStartLeft(line: string, col: number): number { + let c = col; + while (c > 0 && line[c - 1] === ' ') { + c--; + } + while (c > 0 && line[c - 1] !== ' ') { + c--; + } + return c; + } + + /** Returns the column index of the end of the word to the right of col. */ + #wordEndRight(line: string, col: number): number { + let c = col; + while (c < line.length && line[c] === ' ') { + c++; + } + while (c < line.length && line[c] !== ' ') { + c++; + } + return c; + } + + public handleKey(key: KeyAction): void { + if (key.type === 'ctrl+c') { + this.exit(); + process.exit(0); + } + + if (key.type === 'ctrl+/') { + if (this.#mode === 'editor') { + this.#commandMode = !this.#commandMode; + this.render(); + } + return; + } + + if (key.type === 'escape') { + if (this.#commandMode) { + this.#commandMode = false; + this.#previewMode = false; + this.render(); + return; + } + this.#cancelFn?.(); + return; + } + + // Y/N resolves the first queued approval + if (this.#pendingApprovals.length > 0 && key.type === 'char') { + const ch = key.value.toUpperCase(); + if (ch === 'Y' || ch === 'N') { + const resolve = this.#pendingApprovals.shift(); + resolve?.(ch === 'Y'); + this.render(); + return; + } + } + + // Tool navigation: left/right to cycle, space to expand/collapse + if (this.#pendingTools.length > 0) { + if (key.type === 'char' && key.value === ' ') { + this.#toolExpanded = !this.#toolExpanded; + this.render(); + return; + } + if (key.type === 'left') { + this.#selectedTool = Math.max(0, this.#selectedTool - 1); + this.#toolExpanded = false; + this.render(); + return; + } + if (key.type === 'right') { + this.#selectedTool = Math.min(this.#pendingTools.length - 1, this.#selectedTool + 1); + this.#toolExpanded = false; + this.render(); + return; + } + } + + if (this.#mode !== 'editor') { + return; + } + + // Command mode: consume all keys, dispatch actions immediately + if (this.#commandMode) { + this.#handleCommandKey(key); + return; + } + + switch (key.type) { + case 'enter': { + // Split current line at cursor + const cur = this.#editorLines[this.#cursorLine] ?? ''; + const before = cur.slice(0, this.#cursorCol); + const after = cur.slice(this.#cursorCol); + this.#editorLines[this.#cursorLine] = before; + this.#editorLines.splice(this.#cursorLine + 1, 0, after); + this.#cursorLine++; + this.#cursorCol = 0; + this.#scheduleRender(); + break; + } + case 'ctrl+enter': { + const text = this.#editorLines.join('\n').trim(); + if (!text && !this.#attachments.hasAttachments) { + break; + } + if (!this.#editorResolve) { + break; + } + const attachments = this.#attachments.takeAttachments(); + const parts: string[] = [text]; + if (attachments) { + for (let n = 0; n < attachments.length; n++) { + const att = attachments[n]; + if (!att) { + continue; + } + if (att.kind === 'text') { + const showSize = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + const fullSize = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; + const truncPrefix = att.truncated ? `// showing ${showSize} of ${fullSize} (truncated)\n` : ''; + parts.push(`\n\n[attachment #${n + 1}]\n${truncPrefix}${att.text}\n[/attachment]`); + } else { + const lines: string[] = [`path: ${att.path}`]; + if (att.fileType === 'missing') { + lines.push('// not found'); + } else { + lines.push(`type: ${att.fileType}`); + if (att.fileType === 'file' && att.sizeBytes !== undefined) { + const sz = att.sizeBytes; + const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; + lines.push(`size: ${sizeStr}`); + } + } + parts.push(`\n\n[attachment #${n + 1}]\n${lines.join('\n')}\n[/attachment]`); + } + } + } + const resolveInput = this.#editorResolve; + this.#editorResolve = null; + resolveInput(parts.join('')); + break; + } + case 'backspace': { + if (this.#cursorCol > 0) { + const line = this.#editorLines[this.#cursorLine] ?? ''; + this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol - 1) + line.slice(this.#cursorCol); + this.#cursorCol--; + } else if (this.#cursorLine > 0) { + // Join with previous line + const prev = this.#editorLines[this.#cursorLine - 1] ?? ''; + const curr = this.#editorLines[this.#cursorLine] ?? ''; + this.#editorLines.splice(this.#cursorLine, 1); + this.#cursorLine--; + this.#cursorCol = prev.length; + this.#editorLines[this.#cursorLine] = prev + curr; + } + this.#scheduleRender(); + break; + } + case 'delete': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + if (this.#cursorCol < line.length) { + this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + line.slice(this.#cursorCol + 1); + } else if (this.#cursorLine < this.#editorLines.length - 1) { + // Join with next line + const next = this.#editorLines[this.#cursorLine + 1] ?? ''; + this.#editorLines.splice(this.#cursorLine + 1, 1); + this.#editorLines[this.#cursorLine] = line + next; + } + this.#scheduleRender(); + break; + } + case 'ctrl+backspace': { + if (this.#cursorCol === 0) { + // At start of line: cross the newline boundary, same as plain backspace + if (this.#cursorLine > 0) { + const prev = this.#editorLines[this.#cursorLine - 1] ?? ''; + const curr = this.#editorLines[this.#cursorLine] ?? ''; + this.#editorLines.splice(this.#cursorLine, 1); + this.#cursorLine--; + this.#cursorCol = prev.length; + this.#editorLines[this.#cursorLine] = prev + curr; + } + } else { + const line = this.#editorLines[this.#cursorLine] ?? ''; + const newCol = this.#wordStartLeft(line, this.#cursorCol); + this.#editorLines[this.#cursorLine] = line.slice(0, newCol) + line.slice(this.#cursorCol); + this.#cursorCol = newCol; + } + this.#scheduleRender(); + break; + } + case 'ctrl+delete': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + if (this.#cursorCol === line.length) { + // At EOL: cross the newline boundary, same as plain delete + if (this.#cursorLine < this.#editorLines.length - 1) { + const next = this.#editorLines[this.#cursorLine + 1] ?? ''; + this.#editorLines.splice(this.#cursorLine + 1, 1); + this.#editorLines[this.#cursorLine] = line + next; + } + } else { + const newCol = this.#wordEndRight(line, this.#cursorCol); + this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + line.slice(newCol); + } + this.#scheduleRender(); + break; + } + case 'ctrl+k': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + if (this.#cursorCol < line.length) { + // Kill to end of line + this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol); + } else if (this.#cursorLine < this.#editorLines.length - 1) { + // At EOL: join with next line + const next = this.#editorLines[this.#cursorLine + 1] ?? ''; + this.#editorLines.splice(this.#cursorLine + 1, 1); + this.#editorLines[this.#cursorLine] = line + next; + } + this.#scheduleRender(); + break; + } + case 'ctrl+u': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + this.#editorLines[this.#cursorLine] = line.slice(this.#cursorCol); + this.#cursorCol = 0; + this.#scheduleRender(); + break; + } + case 'left': { + if (this.#cursorCol > 0) { + this.#cursorCol--; + } else if (this.#cursorLine > 0) { + this.#cursorLine--; + this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + } + this.#scheduleRender(); + break; + } + case 'right': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + if (this.#cursorCol < line.length) { + this.#cursorCol++; + } else if (this.#cursorLine < this.#editorLines.length - 1) { + this.#cursorLine++; + this.#cursorCol = 0; + } + this.#scheduleRender(); + break; + } + case 'up': { + if (this.#cursorLine > 0) { + this.#cursorLine--; + const newLine = this.#editorLines[this.#cursorLine] ?? ''; + this.#cursorCol = Math.min(this.#cursorCol, newLine.length); + } + this.#scheduleRender(); + break; + } + case 'down': { + if (this.#cursorLine < this.#editorLines.length - 1) { + this.#cursorLine++; + const newLine = this.#editorLines[this.#cursorLine] ?? ''; + this.#cursorCol = Math.min(this.#cursorCol, newLine.length); + } + this.#scheduleRender(); + break; + } + case 'home': { + this.#cursorCol = 0; + this.#scheduleRender(); + break; + } + case 'end': { + this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + this.#scheduleRender(); + break; + } + case 'ctrl+home': { + this.#cursorLine = 0; + this.#cursorCol = 0; + this.#scheduleRender(); + break; + } + case 'ctrl+end': { + this.#cursorLine = this.#editorLines.length - 1; + this.#cursorCol = (this.#editorLines[this.#cursorLine] ?? '').length; + this.#scheduleRender(); + break; + } + case 'ctrl+left': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + this.#cursorCol = this.#wordStartLeft(line, this.#cursorCol); + this.#scheduleRender(); + break; + } + case 'ctrl+right': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + this.#cursorCol = this.#wordEndRight(line, this.#cursorCol); + this.#scheduleRender(); + break; + } + case 'char': { + const line = this.#editorLines[this.#cursorLine] ?? ''; + this.#editorLines[this.#cursorLine] = line.slice(0, this.#cursorCol) + key.value + line.slice(this.#cursorCol); + this.#cursorCol += key.value.length; + this.#scheduleRender(); + break; + } + } + } + + #flushToScroll(): void { + if (this.#flushedCount >= this.#sealedBlocks.length) { + return; + } + const cols = this.#screen.columns; + let out = ''; + for (let i = this.#flushedCount; i < this.#sealedBlocks.length; i++) { + const block = this.#sealedBlocks[i]; + if (!block) { + continue; + } + // Consecutive blocks of the same type are shown without a header or gap between them. + const isContinuation = this.#sealedBlocks[i - 1]?.type === block.type; + const hasNextContinuation = this.#sealedBlocks[i + 1]?.type === block.type; + if (!isContinuation) { + const emoji = BLOCK_EMOJI[block.type] ?? ''; + const plain = BLOCK_PLAIN[block.type] ?? block.type; + out += `${buildDivider(`${emoji}${plain}`, cols)}\n\n`; + } + for (const line of renderBlockContent(block.content, cols)) { + out += `${line}\n`; + } + if (!hasNextContinuation) { + out += '\n'; + } + } + this.#flushedCount = this.#sealedBlocks.length; + this.#screen.exitAltBuffer(); + this.#screen.write(out); + this.#screen.enterAltBuffer(); + } + + public render(): void { + const cols = this.#screen.columns; + const totalRows = this.#screen.rows; + + const expandedRows = this.#buildExpandedRows(cols); + const commandRow = this.#buildCommandRow(cols); + // Fixed status bar: separator (1) + status line (1) + approval row (1) + command row (always 1) + optional expanded rows + const statusBarHeight = 4 + expandedRows.length; + const contentRows = Math.max(2, totalRows - statusBarHeight); + + // Build all content rows from sealed blocks, active block, and editor + const allContent: string[] = []; + + for (let i = 0; i < this.#sealedBlocks.length; i++) { + const block = this.#sealedBlocks[i]; + if (!block) { + continue; + } + // Consecutive blocks of the same type flow as one: skip header and gap for continuations, + // and suppress the trailing blank when the next block will continue the sequence. + const isContinuation = this.#sealedBlocks[i - 1]?.type === block.type; + const nextBlock = this.#sealedBlocks[i + 1] ?? (i === this.#sealedBlocks.length - 1 ? this.#activeBlock : undefined); + const hasNextContinuation = nextBlock?.type === block.type; + if (!isContinuation) { + const emoji = BLOCK_EMOJI[block.type] ?? ''; + const plain = BLOCK_PLAIN[block.type] ?? block.type; + allContent.push(buildDivider(`${emoji}${plain}`, cols)); + allContent.push(''); + } + allContent.push(...renderBlockContent(block.content, cols)); + if (!hasNextContinuation) { + allContent.push(''); + } + } + + if (this.#activeBlock) { + const lastSealed = this.#sealedBlocks[this.#sealedBlocks.length - 1]; + const isContinuation = lastSealed?.type === this.#activeBlock.type; + if (!isContinuation) { + const activeEmoji = BLOCK_EMOJI[this.#activeBlock.type] ?? ''; + const activePlain = BLOCK_PLAIN[this.#activeBlock.type] ?? this.#activeBlock.type; + allContent.push(buildDivider(`${activeEmoji}${activePlain}`, cols)); + allContent.push(''); + } + const activeEmoji = BLOCK_EMOJI[this.#activeBlock.type] ?? ''; + const activeLines = this.#activeBlock.content.split('\n'); + for (let i = 0; i < activeLines.length; i++) { + const pfx = i === 0 ? activeEmoji : CONTENT_INDENT; + allContent.push(...wrapLine(pfx + (activeLines[i] ?? ''), cols)); + } + } + + if (this.#mode === 'editor') { + allContent.push(buildDivider(BLOCK_PLAIN.prompt ?? 'prompt', cols)); + allContent.push(''); + for (let i = 0; i < this.#editorLines.length; i++) { + const pfx = i === 0 ? EDITOR_PROMPT : CONTENT_INDENT; + const line = this.#editorLines[i] ?? ''; + if (i === this.#cursorLine) { + // Render the character *under* the cursor in reverse-video (no text displacement). + // At EOL there is no character, so use a space as the cursor block. + const charUnder = line[this.#cursorCol] ?? ' '; + const withCursor = `${line.slice(0, this.#cursorCol)}${INVERSE_ON}${charUnder}${INVERSE_OFF}${line.slice(this.#cursorCol + 1)}`; + allContent.push(...wrapLine(pfx + withCursor, cols)); + } else { + allContent.push(...wrapLine(pfx + line, cols)); + } + } + } + + // Fit to contentRows: take last N rows, pad from top if short + const overflow = allContent.length - contentRows; + const visibleRows = overflow > 0 ? allContent.slice(overflow) : [...new Array(contentRows - allContent.length).fill(''), ...allContent]; + + const separator = DIM + FILL.repeat(cols) + RESET; + const statusLine = this.#buildStatusLine(cols); + const approvalRow = this.#buildApprovalRow(cols); + const allRows = [...visibleRows, separator, statusLine, approvalRow, commandRow, ...expandedRows]; + + let out = syncStart + hideCursor; + out += cursorAt(1, 1); + for (let i = 0; i < allRows.length - 1; i++) { + out += `\r${clearLine}${allRows[i] ?? ''}\n`; + } + out += clearDown; + const lastRow = allRows[allRows.length - 1]; + if (lastRow !== undefined) { + out += `\r${clearLine}${lastRow}`; + } + + // Virtual cursor is rendered inline in the editor lines above; keep terminal cursor hidden. + + out += syncEnd; + this.#screen.write(out); + } + + #handleCommandKey(key: KeyAction): void { + if (key.type === 'char') { + switch (key.value) { + case 't': { + readClipboardText() + .then((text) => { + if (text) { + this.#attachments.addText(text); + } + this.render(); + }) + .catch(() => { + this.render(); + }); + return; + } + case 'f': { + readClipboardPath() + .then(async (pathText) => { + const filePath = pathText?.trim(); + if (filePath) { + const expanded = filePath.replace(/^~(?=\/|$)/, process.env.HOME ?? ''); + const resolved = resolve(expanded); + try { + const info = await stat(resolved); + // File exists — attach it directly, no further heuristic needed. + if (info.isDirectory()) { + this.#attachments.addFile(resolved, 'dir'); + } else { + this.#attachments.addFile(resolved, 'file', info.size); + } + } catch { + // File not found — only create a missing chip if the text + // looks like a deliberate path (explicit prefix). + if (isLikelyPath(filePath)) { + this.#attachments.addFile(resolved, 'missing'); + } + } + } + this.render(); + }) + .catch(() => { + this.render(); + }); + return; + } + case 'd': + this.#attachments.removeSelected(); + this.render(); + return; + case 'p': + if (this.#attachments.selectedIndex >= 0) { + this.#previewMode = !this.#previewMode; + } + this.render(); + return; + } + } + if (key.type === 'left') { + this.#attachments.selectLeft(); + this.render(); + return; + } + if (key.type === 'right') { + this.#attachments.selectRight(); + this.render(); + return; + } + // All other keys silently consumed + } + + #buildCommandRow(_cols: number): string { + const hasAttachments = this.#attachments.hasAttachments; + if (!this.#commandMode && !hasAttachments) { + return ''; + } + const b = new StatusLineBuilder(); + b.text(' '); + const atts = this.#attachments.attachments; + for (let i = 0; i < atts.length; i++) { + const att = atts[i]; + if (!att) { + continue; + } + let chip: string; + if (att.kind === 'text') { + if (att.truncated) { + const fullStr = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; + chip = `[txt ${fullStr}!]`; + } else { + const sizeStr = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + chip = `[txt ${sizeStr}]`; + } + } else { + const name = basename(att.path); + if (att.fileType === 'missing') { + chip = `[${name} ?]`; + } else if (att.fileType === 'dir') { + chip = `[${name}/]`; + } else { + const sz = att.sizeBytes ?? 0; + const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; + chip = `[${name} ${sizeStr}]`; + } + } + if (this.#commandMode && i === this.#attachments.selectedIndex) { + b.ansi(INVERSE_ON); + b.text(chip); + b.ansi(INVERSE_OFF); + } else { + b.ansi(DIM); + b.text(chip); + b.ansi(RESET); + } + b.text(' '); + } + if (this.#commandMode) { + b.ansi(DIM); + b.text('cmd'); + b.ansi(RESET); + if (hasAttachments) { + b.text(' \u2190 \u2192 select d del p prev \u00b7 t paste \u00b7 f file \u00b7 ESC cancel'); + } else { + b.text(' t paste · f file · ESC cancel'); + } + } + return b.output; + } + + #buildStatusLine(_cols: number): string { + if (this.#totalInputTokens === 0 && this.#totalOutputTokens === 0 && this.#totalCacheCreationTokens === 0) { + return ''; + } + const b = new StatusLineBuilder(); + b.text(` in: ${formatTokens(this.#totalInputTokens)}`); + if (this.#totalCacheCreationTokens > 0) { + b.text(` ↑${formatTokens(this.#totalCacheCreationTokens)}`); + } + if (this.#totalCacheReadTokens > 0) { + b.text(` ↓${formatTokens(this.#totalCacheReadTokens)}`); + } + b.text(` out: ${formatTokens(this.#totalOutputTokens)}`); + b.text(` $${this.#totalCostUsd.toFixed(4)}`); + if (this.#contextWindow > 0) { + const pct = ((this.#lastContextUsed / this.#contextWindow) * 100).toFixed(1); + b.text(` ctx: ${formatTokens(this.#lastContextUsed)}/${formatTokens(this.#contextWindow)} (${pct}%)`); + } + return b.output; + } + + #buildApprovalRow(_cols: number): string { + if (this.#pendingTools.length === 0) { + return ''; + } + const tool = this.#pendingTools[this.#selectedTool]; + if (!tool) { + return ''; + } + + const idx = this.#selectedTool + 1; + const total = this.#pendingTools.length; + const nav = total > 1 ? ` \u2190 ${idx}/${total} \u2192` : ''; + const needsApproval = this.#pendingApprovals.length > 0; + const prefix = needsApproval ? 'Allow ' : ''; + const approval = needsApproval ? ' [Y/N]' : ''; + const expand = this.#toolExpanded ? ' [space: collapse]' : ' [space: expand]'; + return ` ${prefix}Tool: ${tool.name}${nav}${approval}${expand}`; + } + + #buildExpandedRows(cols: number): string[] { + if (this.#toolExpanded && this.#pendingTools.length > 0) { + const tool = this.#pendingTools[this.#selectedTool]; + if (tool) { + const rows: string[] = []; + for (const line of JSON.stringify(tool.input, null, 2).split('\n')) { + rows.push(...wrapLine(CONTENT_INDENT + line, cols)); + } + // Cap at half the screen height to leave room for content + return rows.slice(0, Math.floor(this.#screen.rows / 2)); + } + } + if (this.#previewMode && this.#commandMode) { + return this.#buildPreviewRows(cols); + } + return []; + } + + #buildPreviewRows(cols: number): string[] { + const idx = this.#attachments.selectedIndex; + if (idx < 0) { + return []; + } + const att = this.#attachments.attachments[idx]; + if (!att) { + return []; + } + + const rows: string[] = []; + if (att.kind === 'text') { + if (att.truncated) { + const showSize = att.sizeBytes >= 1024 ? `${(att.sizeBytes / 1024).toFixed(1)}KB` : `${att.sizeBytes}B`; + const fullSize = att.fullSizeBytes >= 1024 ? `${(att.fullSizeBytes / 1024).toFixed(1)}KB` : `${att.fullSizeBytes}B`; + rows.push(DIM + ` showing ${showSize} of ${fullSize} (truncated)` + RESET); + } + const lines = att.text.split('\n'); + const maxPreviewLines = Math.max(1, Math.floor(this.#screen.rows / 3)); + for (const line of lines.slice(0, maxPreviewLines)) { + rows.push(...wrapLine(CONTENT_INDENT + line, cols)); + } + if (lines.length > maxPreviewLines) { + rows.push(DIM + ` \u2026 ${lines.length - maxPreviewLines} more lines` + RESET); + } + } else { + rows.push(` path: ${att.path}`); + if (att.fileType === 'file') { + const sz = att.sizeBytes ?? 0; + const sizeStr = sz >= 1024 ? `${(sz / 1024).toFixed(1)}KB` : `${sz}B`; + rows.push(` type: file size: ${sizeStr}`); + } else if (att.fileType === 'dir') { + rows.push(' type: dir'); + } else { + rows.push(' // not found'); + } + } + return rows.slice(0, Math.floor(this.#screen.rows / 2)); + } +} diff --git a/apps/claude-sdk-cli/src/AttachmentStore.ts b/apps/claude-sdk-cli/src/AttachmentStore.ts new file mode 100644 index 0000000..a3e256c --- /dev/null +++ b/apps/claude-sdk-cli/src/AttachmentStore.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; + +export type TextAttachment = { + readonly kind: 'text'; + readonly hash: string; + readonly text: string; + readonly sizeBytes: number; // stored bytes (≤ 10 KB cap) + readonly fullSizeBytes: number; // original byte length before any cap + readonly truncated: boolean; +}; + +export type FileAttachment = { + readonly kind: 'file'; + readonly path: string; + readonly fileType: 'file' | 'dir' | 'missing'; + readonly sizeBytes?: number; // only when fileType === 'file' +}; + +export type Attachment = TextAttachment | FileAttachment; + +export class AttachmentStore { + readonly #attachments: Attachment[] = []; + #selectedIndex = -1; + + public get attachments(): readonly Attachment[] { + return this.#attachments; + } + + public get selectedIndex(): number { + return this.#selectedIndex; + } + + public get hasAttachments(): boolean { + return this.#attachments.length > 0; + } + + /** Add plain-text content. Returns 'duplicate' if already present (by SHA-256). */ + public addText(text: string): 'added' | 'duplicate' { + const hash = createHash('sha256').update(text).digest('hex'); + if (this.#attachments.some((a) => a.kind === 'text' && a.hash === hash)) { + return 'duplicate'; + } + const TEXT_CAP = 10 * 1024; // 10 KB + const fullBytes = Buffer.from(text, 'utf8'); + const fullSizeBytes = fullBytes.length; + const truncated = fullSizeBytes > TEXT_CAP; + // Slice at a UTF-8 byte boundary to avoid splitting a multi-byte character + const storedText = truncated ? fullBytes.subarray(0, TEXT_CAP).toString('utf8') : text; + const sizeBytes = truncated ? Buffer.byteLength(storedText, 'utf8') : fullSizeBytes; + this.#attachments.push({ kind: 'text', hash, text: storedText, sizeBytes, fullSizeBytes, truncated }); + this.#selectedIndex = this.#attachments.length - 1; + return 'added'; + } + + /** Add a file/dir/missing path reference. Returns 'duplicate' if the same path is already attached. */ + public addFile(path: string, fileType: 'file' | 'dir' | 'missing', sizeBytes?: number): 'added' | 'duplicate' { + if (this.#attachments.some((a) => a.kind === 'file' && a.path === path)) { + return 'duplicate'; + } + this.#attachments.push({ kind: 'file', path, fileType, sizeBytes }); + this.#selectedIndex = this.#attachments.length - 1; + return 'added'; + } + + public removeSelected(): void { + if (this.#selectedIndex < 0 || this.#selectedIndex >= this.#attachments.length) { + return; + } + this.#attachments.splice(this.#selectedIndex, 1); + this.#selectedIndex = this.#attachments.length === 0 ? -1 : Math.min(this.#selectedIndex, this.#attachments.length - 1); + } + + public selectLeft(): void { + if (this.#attachments.length === 0) { + return; + } + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + } + + public selectRight(): void { + if (this.#attachments.length === 0) { + return; + } + this.#selectedIndex = Math.min(this.#attachments.length - 1, this.#selectedIndex + 1); + } + + public clear(): void { + this.#attachments.length = 0; + this.#selectedIndex = -1; + } + + /** Returns all attachments and clears the store. Returns null if empty. */ + public takeAttachments(): readonly Attachment[] | null { + if (this.#attachments.length === 0) { + return null; + } + const copy = [...this.#attachments]; + this.clear(); + return copy; + } +} diff --git a/apps/claude-sdk-cli/src/ReadLine.ts b/apps/claude-sdk-cli/src/ReadLine.ts new file mode 100644 index 0000000..baab7ef --- /dev/null +++ b/apps/claude-sdk-cli/src/ReadLine.ts @@ -0,0 +1,37 @@ +import { type KeyAction, setupKeypressHandler } from '@shellicar/claude-core/input'; +import type { AppLayout } from './AppLayout.js'; + +export class ReadLine implements Disposable { + readonly #cleanup: () => void; + #layout: AppLayout | null = null; + + public constructor() { + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + this.#cleanup = setupKeypressHandler((key) => this.#handleKey(key)); + } + + public [Symbol.dispose](): void { + this.#cleanup(); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + } + + public setLayout(layout: AppLayout): void { + this.#layout = layout; + } + + #handleKey(key: KeyAction): void { + if (this.#layout !== null) { + this.#layout.handleKey(key); + return; + } + if (key.type === 'ctrl+c') { + process.exit(0); + } + } +} diff --git a/apps/claude-sdk-cli/src/clipboard.ts b/apps/claude-sdk-cli/src/clipboard.ts new file mode 100644 index 0000000..b00559d --- /dev/null +++ b/apps/claude-sdk-cli/src/clipboard.ts @@ -0,0 +1,181 @@ +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { logger } from './logger.js'; + +function execText(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf8', timeout: 5000 }, (error, stdout) => { + if (error) { + reject(new Error(`${command} failed: ${error.message}`)); + return; + } + const text = stdout.trim(); + resolve(text.length > 0 ? text : null); + }); + }); +} + +/** Read plain text from the system clipboard. Returns null if empty or unavailable. */ +export async function readClipboardText(): Promise { + return execText('pbpaste', []); +} + +/** + * Return true if the string looks like an absolute, home-relative, + * explicitly-relative, or bare-relative filesystem path. + * + * Accepts: + * /absolute/path + * ~/home/relative + * ./explicitly/relative (explicit ./ prefix) + * ../parent/relative (explicit ../ prefix) + * apps/foo/bar.ts (bare relative — contains '/' and no whitespace) + * + * Rejects multi-line strings, bare filenames (no '/'), whitespace-containing + * strings, and anything longer than 1 KB. + */ +export function looksLikePath(s: string): boolean { + if (!s || s.length > 1024) { + return false; + } + if (/[\n\r]/.test(s)) { + return false; + } + // Explicit prefix forms + if (s.startsWith('/') || s.startsWith('~/') || s === '~' || s.startsWith('./') || s.startsWith('../')) { + return true; + } + // Bare relative path (e.g. VS Code ‘Copy Relative Path’ without a ./ prefix): + // must contain at least one '/' and no whitespace. + return s.includes('/') && !/\s/.test(s); +} + +// JXA snippet that reads the first file URI from the VS Code "code/file-list" pasteboard type. +// Throws if the type is absent so that execText rejects and the caller can fall through. +const VSCODE_FILE_LIST_JXA = ["ObjC.import('AppKit');", 'var pb = $.NSPasteboard.generalPasteboard;', "var d = pb.dataForType($('code/file-list'));", "if (!d || !d.length) throw 'no code/file-list data';", '$.NSString.alloc.initWithDataEncoding(d, $.NSUTF8StringEncoding).js'].join(' '); + +/** + * Read a file path from VS Code's proprietary "code/file-list" pasteboard type. + * + * VS Code places a `file://` URI (or newline-separated list for multi-select) on + * the clipboard when you right-click a file in the Explorer and choose Copy. + * Neither `pbpaste` nor the AppleScript `furl` type can see it. + * + * Returns the POSIX path of the first file, or null if the type is absent / undecodable. + */ +async function readVSCodeFileList(): Promise { + const raw = await execText('osascript', ['-l', 'JavaScript', '-e', VSCODE_FILE_LIST_JXA]); + if (!raw) { + return null; + } + // code/file-list may contain multiple file: URIs (one per line); take the first. + const firstUri = raw + .trim() + .split(/[\r\n]/)[0] + .trim(); + if (!firstUri) { + return null; + } + try { + return fileURLToPath(firstUri); + } catch { + return null; + } +} + +/** + * Core two-stage path resolution logic, with injectable callables for testing. + * + * Stage 1 (`pbpaste`): plain-text clipboard, accepted only if it `looksLikePath`. + * Stages 2+ (`fileProbes`): file-format–specific probes tried in order; the + * first non-null result wins. Errors are caught and treated as "no result". + * + * Returns null if no stage yields a path. + */ +export async function readClipboardPathCore(pbpaste: () => Promise, ...fileProbes: Array<() => Promise>): Promise { + const text = await pbpaste().catch(() => null); + const trimmed = text?.trim() ?? null; + const pathLike = trimmed !== null && looksLikePath(trimmed); + logger.trace('clipboard: pbpaste looksLikePath', { trimmed, accepted: pathLike }); + if (pathLike && trimmed) { + return trimmed; + } + for (const probe of fileProbes) { + const path = await probe().catch(() => null); + if (path) { + return path; + } + } + return null; +} + +/** + * Wrap a probe function with trace-level logging. + * On success the raw result is logged before being returned. + * On failure the error is logged and re-thrown so readClipboardPathCore can + * catch it and continue to the next probe. + */ +function logged(label: string, fn: () => Promise): () => Promise { + return async () => { + try { + const result = await fn(); + logger.trace(`clipboard: ${label}`, { result }); + return result; + } catch (err) { + logger.trace(`clipboard: ${label} failed`, { error: String(err) }); + throw err; + } + }; +} + +/** + * Return null if `path` looks like an HFS artifact from AppleScript coercing + * plain text as a file reference. + * + * When the clipboard contains plain text (e.g. a bare relative path like + * `apps/foo/bar.ts`) and `the clipboard as «class furl»` is evaluated, + * AppleScript treats `/` in the text as the HFS path separator `:`, producing + * a path like `/apps:foo:bar.ts`. A genuine `POSIX path of` result from a real + * file reference always uses `/` as separator and never contains `:`. + */ +export function sanitiseFurlResult(path: string | null): string | null { + if (!path || path.includes(':')) { + return null; + } + return path; +} + +/** + * Read a file path from the osascript `furl` clipboard type. + * Rejects results that contain `:` (HFS artifacts from plain-text coercion). + */ +async function readOsascriptFurl(): Promise { + const raw = await execText('osascript', ['-e', 'POSIX path of (the clipboard as «class furl»)']); + const sanitised = sanitiseFurlResult(raw); + if (raw !== null && sanitised === null) { + logger.trace('clipboard: osascript:furl rejecting HFS artifact', { raw }); + } + return sanitised; +} + +/** + * Read a file path from the clipboard. + * + * Three-stage: + * 1. pbpaste — if the plain-text content looks like a path, use it. + * (Terminal copy, VS Code “Copy Path” / “Copy Relative Path”.) + * 2. code/file-list — VS Code Explorer “Copy”; contains a file:// URI. + * 3. osascript furl — Finder ⌘C; pbpaste only gives the bare filename. + * HFS artifacts (colons) are rejected. + * + * Returns null if no stage yields a path. + */ +export async function readClipboardPath(): Promise { + const result = await readClipboardPathCore( + logged('pbpaste', () => execText('pbpaste', [])), + logged('vscode:code/file-list', () => readVSCodeFileList()), + logged('osascript:furl', readOsascriptFurl), + ); + logger.trace('clipboard: readClipboardPath', { result }); + return result; +} diff --git a/apps/claude-sdk-cli/src/entry/main.ts b/apps/claude-sdk-cli/src/entry/main.ts new file mode 100644 index 0000000..fad7534 --- /dev/null +++ b/apps/claude-sdk-cli/src/entry/main.ts @@ -0,0 +1,71 @@ +import { parseArgs } from 'node:util'; +import { createAnthropicAgent } from '@shellicar/claude-sdk'; +import { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; +import { AppLayout } from '../AppLayout.js'; +import { printUsage, printVersion, printVersionInfo } from '../help.js'; +import { logger } from '../logger.js'; +import { ReadLine } from '../ReadLine.js'; +import { runAgent } from '../runAgent.js'; + +const { values } = parseArgs({ + options: { + version: { type: 'boolean', short: 'v', default: false }, + 'version-info': { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + strict: false, +}); + +if (values.version) { + // biome-ignore lint/suspicious/noConsole: CLI --version output before app starts + printVersion(console.log); + process.exit(0); +} + +if (values['version-info']) { + // biome-ignore lint/suspicious/noConsole: CLI --version-info output before app starts + printVersionInfo(console.log); + process.exit(0); +} + +if (values.help || process.argv.includes('-?')) { + // biome-ignore lint/suspicious/noConsole: CLI --help output before app starts + printUsage(console.log); + process.exit(0); +} + +if (!process.stdin.isTTY) { + process.stderr.write('stdin is not a terminal. Run interactively.\n'); + process.exit(1); +} + +const HISTORY_FILE = '.sdk-history.jsonl'; + +const main = async () => { + const apiKey = process.env.CLAUDE_CODE_API_KEY; + if (!apiKey) { + logger.error('CLAUDE_CODE_API_KEY is not set'); + process.exit(1); + } + + using rl = new ReadLine(); + const layout = new AppLayout(); + + const cleanup = () => { + layout.exit(); + process.exit(0); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + rl.setLayout(layout); + layout.enter(); + + const agent = createAnthropicAgent({ apiKey, logger, historyFile: HISTORY_FILE }); + const store = new RefStore(); + while (true) { + const prompt = await layout.waitForInput(); + await runAgent(agent, prompt, layout, store); + } +}; +await main(); diff --git a/apps/claude-sdk-cli/src/help.ts b/apps/claude-sdk-cli/src/help.ts new file mode 100644 index 0000000..00656df --- /dev/null +++ b/apps/claude-sdk-cli/src/help.ts @@ -0,0 +1,27 @@ +import versionInfo from '@shellicar/build-version/version'; + +type Log = (msg: string) => void; + +export function printVersion(log: Log): void { + log(versionInfo.version); +} + +export function printVersionInfo(log: Log): void { + log(`claude-sdk-cli ${versionInfo.version}`); + log(` branch: ${versionInfo.branch}`); + log(` sha: ${versionInfo.sha}`); + log(` shortSha: ${versionInfo.shortSha}`); + log(` commitDate: ${versionInfo.commitDate}`); + log(` buildDate: ${versionInfo.buildDate}`); +} + +export function printUsage(log: Log): void { + log(`claude-sdk-cli ${versionInfo.version}`); + log(''); + log('Usage: claude-sdk-cli [options]'); + log(''); + log('Options:'); + log(' -v, --version Show version'); + log(' --version-info Show detailed version information'); + log(' -h, --help, -? Show this help message'); +} diff --git a/apps/claude-sdk-cli/src/logger.ts b/apps/claude-sdk-cli/src/logger.ts index d86976a..c4dc97f 100644 --- a/apps/claude-sdk-cli/src/logger.ts +++ b/apps/claude-sdk-cli/src/logger.ts @@ -1,49 +1,69 @@ -import type winston from 'winston'; -import { addColors, createLogger, format, transports } from 'winston'; +import winston from 'winston'; +import { redact } from './redact'; const levels = { error: 0, warn: 1, info: 2, debug: 3, trace: 4 }; const colors = { error: 'red', warn: 'yellow', info: 'green', debug: 'blue', trace: 'gray' }; -addColors(colors); +winston.addColors(colors); -const MAX_LENGTH = 512; - -function truncate(value: T): T { +const truncateStrings = (value: unknown, max: number): unknown => { if (typeof value === 'string') { - if (value.length <= MAX_LENGTH) { - return value; - } - return `${value.slice(0, MAX_LENGTH)}...` as T; + return value.length > max ? `${value.slice(0, max)}...` : value; } if (Array.isArray(value)) { - return value.map(truncate) as T; + return value.map((item) => truncateStrings(item, max)); } if (value !== null && typeof value === 'object') { - return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, truncate(v)])) as T; + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, truncateStrings(v, max)])); } return value; -} +}; -const truncateFormat = format((info) => { - const { level, message, timestamp, ...meta } = info; - const truncated = truncate(meta); - for (const [key, value] of Object.entries(truncated)) { - info[key] = value; +const summariseLarge = (value: unknown, max: number): unknown => { + const s = JSON.stringify(value); + if (s.length <= max) { + return value; + } + if (Array.isArray(value)) { + return { '[truncated]': true, bytes: s.length, length: value.length }; + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as object).map(([k, v]) => [k, summariseLarge(v, max)])); + } + if (typeof value === 'string') { + return `${value.slice(0, max)}...`; } - return info; -}); + return value; +}; + +const fileFormat = (max: number) => + winston.format.printf((info) => { + const parsed = JSON.parse(JSON.stringify(info)); + if (parsed.data !== undefined) { + parsed.data = summariseLarge(parsed.data, max); + } + return JSON.stringify(truncateStrings(parsed, max)); + }); -export const logger = createLogger({ +const transports: winston.transport[] = []; +transports.push(new winston.transports.File({ filename: 'claude-sdk-cli.log', format: fileFormat(200) })); + +const winstonLogger = winston.createLogger({ levels, - level: 'debug', - format: format.combine( - format.timestamp({ format: 'HH:mm:ss' }), - truncateFormat(MAX_LENGTH), - format.colorize(), - format.printf(({ level, message, timestamp, ...meta }) => { - const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : ''; - return `${timestamp} ${level}: ${message}${metaStr}`; - }), - ), - transports: [new transports.Console()], + level: 'trace', + format: winston.format.combine(winston.format.timestamp({ format: 'HH:mm:ss' })), + transports, }) as winston.Logger & { trace: winston.LeveledLogMethod }; + +const wrapMeta = (meta: unknown[]): object => { + const wrapped = meta.length === 0 ? {} : meta.length === 1 ? { data: meta[0] } : { data: meta }; + return redact(wrapped) as object; +}; + +export const logger = { + trace: (message: string, ...meta: unknown[]) => winstonLogger.trace(message, wrapMeta(meta)), + debug: (message: string, ...meta: unknown[]) => winstonLogger.debug(message, wrapMeta(meta)), + info: (message: string, ...meta: unknown[]) => winstonLogger.info(message, wrapMeta(meta)), + warn: (message: string, ...meta: unknown[]) => winstonLogger.warn(message, wrapMeta(meta)), + error: (message: string, ...meta: unknown[]) => winstonLogger.error(message, wrapMeta(meta)), +}; diff --git a/apps/claude-sdk-cli/src/main.ts b/apps/claude-sdk-cli/src/main.ts deleted file mode 100644 index c0049db..0000000 --- a/apps/claude-sdk-cli/src/main.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { AnthropicBeta, createAnthropicAgent, type SdkMessage } from '@shellicar/claude-sdk'; -import { logger } from './logger'; -import { editConfirmTool } from './tools/edit/editConfirmTool'; -import { editTool } from './tools/edit/editTool'; - -const main = async () => { - const apiKey = process.env.CLAUDE_CODE_API_KEY; - if (!apiKey) { - logger.error('CLAUDE_CODE_API_KEY is not set'); - process.exit(1); - } - - const agent = createAnthropicAgent({ - apiKey, - logger, - }); - - const { port, done } = agent.runAgent({ - model: 'claude-sonnet-4-6', - maxTokens: 8096, - messages: ['Please add a comment "// hello world" on line 1344 of the file /Users/stephen/repos/@shellicar/claude-cli/node_modules/.pnpm/@anthropic-ai+sdk@0.80.0_zod@4.3.6/node_modules/@anthropic-ai/sdk/src/resources/messages/messages.ts'], - tools: [editTool, editConfirmTool], - requireToolApproval: true, - betas: { - [AnthropicBeta.InterleavedThinking]: true, - [AnthropicBeta.ContextManagement]: true, - [AnthropicBeta.PromptCachingScope]: true, - [AnthropicBeta.Effort]: true, - [AnthropicBeta.AdvancedToolUse]: true, - [AnthropicBeta.TokenEfficientTools]: true, - }, - }); - - port.on('message', (msg: SdkMessage) => { - switch (msg.type) { - case 'message_start': - process.stdout.write('> '); - break; - case 'message_text': - process.stdout.write(msg.text); - break; - case 'message_end': - process.stdout.write('\n'); - break; - case 'tool_approval_request': - logger.info('tool_approval_request', { name: msg.name, input: msg.input }); - port.postMessage({ type: 'tool_approval_response', requestId: msg.requestId, approved: true }); - break; - case 'done': - logger.info('done', { stopReason: msg.stopReason }); - break; - case 'error': - logger.error('error', { message: msg.message }); - break; - } - }); - - await done; -}; -main(); diff --git a/apps/claude-sdk-cli/src/permissions.ts b/apps/claude-sdk-cli/src/permissions.ts new file mode 100644 index 0000000..e2382bc --- /dev/null +++ b/apps/claude-sdk-cli/src/permissions.ts @@ -0,0 +1,58 @@ +import { resolve, sep } from 'node:path'; +import type { AnyToolDefinition } from '@shellicar/claude-sdk'; + +export enum PermissionAction { + Approve = 0, + Ask = 1, + Deny = 2, +} + +export type ToolCall = { name: string; input: Record }; + +type PipeStep = { tool: string; input: Record }; +type PipeInput = { steps: PipeStep[] }; +type PipeToolCall = { name: 'Pipe'; input: PipeInput }; + +function isPipeTool(tool: ToolCall): tool is PipeToolCall { + return tool.name === 'Pipe'; +} + +type ZonePermissions = { read: PermissionAction; write: PermissionAction; delete: PermissionAction }; +type PermissionConfig = { default: ZonePermissions; outside: ZonePermissions }; + +const permissions: PermissionConfig = { + default: { read: PermissionAction.Approve, write: PermissionAction.Approve, delete: PermissionAction.Ask }, + outside: { read: PermissionAction.Approve, write: PermissionAction.Ask, delete: PermissionAction.Deny }, +}; + +function getPathFromInput(tool: ToolCall): string | undefined { + if (tool.name === 'PreviewEdit' || tool.name === 'EditFile') { + return typeof tool.input.file === 'string' ? tool.input.file : undefined; + } + return typeof tool.input.path === 'string' ? tool.input.path : undefined; +} + +function isInsideCwd(filePath: string, cwd: string): boolean { + const resolved = resolve(filePath); + return resolved === cwd || resolved.startsWith(cwd + sep); +} + +export function getPermission(tool: ToolCall, allTools: AnyToolDefinition[], cwd: string): PermissionAction { + if (isPipeTool(tool)) { + if (tool.input.steps.length === 0) { + return PermissionAction.Ask; + } + return Math.max(...tool.input.steps.map((s) => getPermission({ name: s.tool, input: s.input }, allTools, cwd))) as PermissionAction; + } + + const definition = allTools.find((t) => t.name === tool.name); + if (!definition) { + return PermissionAction.Deny; + } + + const operation = definition.operation ?? 'read'; + const filePath = getPathFromInput(tool); + const zone: keyof PermissionConfig = filePath != null && !isInsideCwd(filePath, cwd) ? 'outside' : 'default'; + + return permissions[zone][operation]; +} diff --git a/apps/claude-sdk-cli/src/redact.ts b/apps/claude-sdk-cli/src/redact.ts new file mode 100644 index 0000000..cb2f5c7 --- /dev/null +++ b/apps/claude-sdk-cli/src/redact.ts @@ -0,0 +1,19 @@ +const SENSITIVE_KEYS = new Set(['authorization', 'x-api-key', 'api-key', 'api_key', 'apikey', 'password', 'secret', 'token']); + +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +}; + +export const redact = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redact); + } + if (isPlainObject(value)) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v)])); + } + return value; +}; diff --git a/apps/claude-sdk-cli/src/runAgent.ts b/apps/claude-sdk-cli/src/runAgent.ts new file mode 100644 index 0000000..aa2dddb --- /dev/null +++ b/apps/claude-sdk-cli/src/runAgent.ts @@ -0,0 +1,257 @@ +import { relative } from 'node:path'; +import { AnthropicBeta, type AnyToolDefinition, type CacheTtl, calculateCost, type IAnthropicAgent, type SdkMessage, type SdkMessageUsage, type SdkToolApprovalRequest } from '@shellicar/claude-sdk'; +import { CreateFile } from '@shellicar/claude-sdk-tools/CreateFile'; +import { DeleteDirectory } from '@shellicar/claude-sdk-tools/DeleteDirectory'; +import { DeleteFile } from '@shellicar/claude-sdk-tools/DeleteFile'; +import { EditFile } from '@shellicar/claude-sdk-tools/EditFile'; +import { Exec } from '@shellicar/claude-sdk-tools/Exec'; +import { Find } from '@shellicar/claude-sdk-tools/Find'; +import { Grep } from '@shellicar/claude-sdk-tools/Grep'; +import { Head } from '@shellicar/claude-sdk-tools/Head'; +import { createPipe } from '@shellicar/claude-sdk-tools/Pipe'; +import { PreviewEdit } from '@shellicar/claude-sdk-tools/PreviewEdit'; +import { Range } from '@shellicar/claude-sdk-tools/Range'; +import { ReadFile } from '@shellicar/claude-sdk-tools/ReadFile'; +import { createRef } from '@shellicar/claude-sdk-tools/Ref'; +import type { RefStore } from '@shellicar/claude-sdk-tools/RefStore'; +import { SearchFiles } from '@shellicar/claude-sdk-tools/SearchFiles'; +import { Tail } from '@shellicar/claude-sdk-tools/Tail'; +import type { AppLayout, PendingTool } from './AppLayout.js'; +import { logger } from './logger.js'; +import { getPermission, PermissionAction } from './permissions.js'; + +function fmtBytes(n: number): string { + if (n >= 1024 * 1024) { + return `${(n / 1024 / 1024).toFixed(1)}mb`; + } + if (n >= 1024) { + return `${(n / 1024).toFixed(1)}kb`; + } + return `${n}b`; +} + +function primaryArg(input: Record, cwd: string): string | null { + for (const key of ['path', 'file']) { + if (typeof input[key] === 'string') { + return relative(cwd, input[key] as string) || (input[key] as string); + } + } + if (typeof input.pattern === 'string') { + return input.pattern; + } + if (typeof input.description === 'string') { + return input.description; + } + return null; +} + +function formatRefSummary(input: Record, store: RefStore): string { + const id = typeof input.id === 'string' ? input.id : ''; + if (!id) { + return 'Ref(?)'; + } + const hint = store.getHint(id) ?? id.slice(0, 8); + const content = store.get(id); + if (content === undefined) { + return `Ref(${id.slice(0, 8)}…)`; + } + const sizeStr = fmtBytes(content.length); + // start and limit always have defaults now (0 and 1000) so always show the range + const start = typeof input.start === 'number' ? input.start : 0; + const limit = typeof input.limit === 'number' ? input.limit : 1000; + const end = Math.min(start + limit, content.length); + return `Ref ← ${hint} [${start}–${end} / ${sizeStr}]`; +} + +function formatToolSummary(name: string, input: Record, cwd: string, store: RefStore): string { + if (name === 'Ref') { + return formatRefSummary(input, store); + } + if (name === 'Pipe' && Array.isArray(input.steps)) { + const steps = (input.steps as Array<{ tool?: unknown; input?: unknown }>) + .map((s) => { + const tool = typeof s.tool === 'string' ? s.tool : '?'; + const stepInput = s.input != null && typeof s.input === 'object' ? (s.input as Record) : {}; + const arg = primaryArg(stepInput, cwd); + return arg ? `${tool}(${arg})` : tool; + }) + .join(' | '); + return steps; + } + const arg = primaryArg(input, cwd); + return arg ? `${name}(${arg})` : name; +} + +export async function runAgent(agent: IAnthropicAgent, prompt: string, layout: AppLayout, store: RefStore): Promise { + const pipeSource = [Find, ReadFile, Grep, Head, Tail, Range, SearchFiles]; + const { tool: Ref, transformToolResult: refTransform } = createRef(store, 2_000); + const otherTools = [PreviewEdit, EditFile, CreateFile, DeleteFile, DeleteDirectory, Exec, Ref]; + const pipe = createPipe(pipeSource); + const tools: AnyToolDefinition[] = [pipe, ...pipeSource, ...otherTools]; + + const cwd = process.cwd(); + let lastUsage: SdkMessageUsage | null = null; + /** Snapshot of usage at the start of the current tool batch; used to compute the token delta + * when the next message_usage arrives. Non-null while a batch is in-flight. */ + let usageBeforeTools: SdkMessageUsage | null = null; + + const transformToolResult = (toolName: string, output: unknown): unknown => { + const result = refTransform(toolName, output); + if (toolName !== 'Ref') { + const bytes = (typeof result === 'string' ? result : JSON.stringify(result)).length; + logger.debug('tool_result_size', { name: toolName, bytes }); + } + return result; + }; + + layout.startStreaming(prompt); + + const model = 'claude-sonnet-4-6'; + const cacheTtl: CacheTtl = '5m'; + + const { port, done } = agent.runAgent({ + model, + maxTokens: 32768, + messages: [prompt], + transformToolResult, + pauseAfterCompact: true, + tools, + requireToolApproval: true, + thinking: true, + betas: { + [AnthropicBeta.Compact]: true, + [AnthropicBeta.ClaudeCodeAuth]: true, + // [AnthropicBeta.InterleavedThinking]: true, + [AnthropicBeta.ContextManagement]: false, + [AnthropicBeta.PromptCachingScope]: true, + // [AnthropicBeta.Effort]: true, + [AnthropicBeta.AdvancedToolUse]: true, + // [AnthropicBeta.TokenEfficientTools]: true, + }, + }); + + const toolApprovalRequest = async (msg: SdkToolApprovalRequest) => { + try { + logger.info('tool_approval_request', { name: msg.name, input: msg.input }); + + const pendingTool: PendingTool = { requestId: msg.requestId, name: msg.name, input: msg.input }; + layout.addPendingTool(pendingTool); + + const perm = getPermission({ name: msg.name, input: msg.input }, tools, cwd); + let approved: boolean; + if (perm === PermissionAction.Approve) { + logger.info('Auto approving', { name: msg.name }); + approved = true; + } else if (perm === PermissionAction.Deny) { + logger.info('Auto denying', { name: msg.name }); + approved = false; + } else { + approved = await layout.requestApproval(); + } + + port.postMessage({ type: 'tool_approval_response', requestId: msg.requestId, approved }); + layout.removePendingTool(msg.requestId); + } catch (err) { + logger.error('Error', err); + port.postMessage({ type: 'tool_approval_response', requestId: msg.requestId, approved: false }); + layout.removePendingTool(msg.requestId); + } + }; + + port.on('message', (msg: SdkMessage) => { + switch (msg.type) { + case 'message_thinking': + layout.transitionBlock('thinking'); + layout.appendStreaming(msg.text); + break; + case 'message_text': + layout.transitionBlock('response'); + layout.appendStreaming(msg.text); + break; + case 'tool_approval_request': + layout.transitionBlock('tools'); + layout.appendStreaming(`${formatToolSummary(msg.name, msg.input, cwd, store)}\n`); + // Snapshot usage at the start of the first tool in this batch so we can + // compute the per-batch turn cost when the next message_usage arrives. + if (!usageBeforeTools) { + usageBeforeTools = lastUsage; + } + toolApprovalRequest(msg); + break; + case 'tool_error': + layout.transitionBlock('tools'); + layout.appendStreaming(`${msg.name} error\n\`\`\`json\n${JSON.stringify(msg.input, null, 2)}\n\`\`\`\n\n${msg.error}\n`); + break; + case 'message_compaction_start': + layout.transitionBlock('compaction'); + break; + case 'message_compaction': + layout.transitionBlock('compaction'); + layout.appendStreaming(msg.summary); + if (lastUsage) { + const used = lastUsage.inputTokens + lastUsage.cacheCreationTokens + lastUsage.cacheReadTokens; + const pct = ((used / lastUsage.contextWindow) * 100).toFixed(1); + const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); + layout.appendStreaming(`\n\n[compacted at ${fmt(used)} / ${fmt(lastUsage.contextWindow)} (${pct}%)]`); + } + break; + case 'message_usage': { + // Annotate the (now-sealed) tools block with how many tokens this batch added to the + // context window: delta = (input+cacheCreate+cacheRead at N+1) - (same at N). + // This captures tool-result tokens + the assistant tool-call tokens that moved into + // the cache between turns. The running cost total is in the status bar. + logger.debug('message_usage', { hasUsageBeforeTools: usageBeforeTools !== null }); + if (usageBeforeTools !== null) { + const prevCtx = usageBeforeTools.inputTokens + usageBeforeTools.cacheCreationTokens + usageBeforeTools.cacheReadTokens; + const currCtx = msg.inputTokens + msg.cacheCreationTokens + msg.cacheReadTokens; + const delta = currCtx - prevCtx; + const sign = delta >= 0 ? '+' : ''; + // Marginal cost: price only the net-new tokens this batch added (delta per category) + // plus the output tokens Claude generated in response to those results. + const marginalCost = calculateCost( + { + inputTokens: Math.max(0, msg.inputTokens - usageBeforeTools.inputTokens), + cacheCreationTokens: Math.max(0, msg.cacheCreationTokens - usageBeforeTools.cacheCreationTokens), + cacheReadTokens: Math.max(0, msg.cacheReadTokens - usageBeforeTools.cacheReadTokens), + outputTokens: msg.outputTokens, + }, + model, + cacheTtl, + ); + const costStr = `$${marginalCost.toFixed(4)}`; + logger.debug('tool_batch_tokens', { prevCtx, currCtx, delta, marginalCost }); + layout.appendToLastSealed('tools', `[\u2191 ${sign}${delta.toLocaleString()} tokens \u00b7 ${costStr}]\n`); + usageBeforeTools = null; + } + lastUsage = msg; + layout.updateUsage(msg); + break; + } + case 'done': + logger.info('done', { stopReason: msg.stopReason }); + if (msg.stopReason !== 'end_turn') { + layout.appendStreaming(`\n\n[stop: ${msg.stopReason}]`); + } + break; + case 'error': + layout.transitionBlock('response'); + layout.appendStreaming(`\n\n[error: ${msg.message}]`); + logger.error('error', { message: msg.message }); + break; + } + }); + + layout.setCancelFn(() => port.postMessage({ type: 'cancel' })); + + try { + await done; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + layout.transitionBlock('response'); + layout.appendStreaming(`\n\n[error: ${message}]`); + logger.error('runAgent error', { message }); + } finally { + layout.setCancelFn(null); + layout.completeStreaming(); + } +} diff --git a/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts b/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts deleted file mode 100644 index 408ec3d..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/editConfirmTool.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createHash } from 'node:crypto'; -import { closeSync, fstatSync, ftruncateSync, openSync, readSync, writeSync } from 'node:fs'; -import type { ToolDefinition } from '@shellicar/claude-sdk'; -import { EditConfirmInputSchema, EditConfirmOutputSchema, EditOutputSchema } from './schema'; -import type { EditConfirmInputType, EditConfirmOutputType } from './types'; - -export const editConfirmTool: ToolDefinition = { - name: 'edit_confirm', - description: 'Apply a staged edit after reviewing the diff.', - input_schema: EditConfirmInputSchema, - input_examples: [ - { - patchId: '2b9cfd39-7f29-4911-8cb2-ef4454635e51', - }, - ], - handler: async ({ patchId }, store) => { - const input = store.get(patchId); - if (input == null) { - throw new Error('edit_confirm requires a staged edit from the edit tool'); - } - const chained = EditOutputSchema.parse(input); - const fd = openSync(chained.file, 'r+'); - try { - const { size } = fstatSync(fd); - const buffer = Buffer.alloc(size); - readSync(fd, buffer, 0, size, 0); - const currentContent = buffer.toString('utf-8'); - const currentHash = createHash('sha256').update(currentContent).digest('hex'); - if (currentHash !== chained.originalHash) { - throw new Error(`File ${chained.file} has been modified since the edit was staged`); - } - const newBuffer = Buffer.from(chained.newContent, 'utf-8'); - ftruncateSync(fd, 0); - writeSync(fd, newBuffer, 0, newBuffer.length, 0); - const linesChanged = Math.abs(chained.newContent.split('\n').length - currentContent.split('\n').length); - return EditConfirmOutputSchema.parse({ linesChanged }); - } finally { - closeSync(fd); - } - }, -}; diff --git a/apps/claude-sdk-cli/src/tools/edit/editTool.ts b/apps/claude-sdk-cli/src/tools/edit/editTool.ts deleted file mode 100644 index aecb7ab..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/editTool.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import type { ToolDefinition } from '@shellicar/claude-sdk'; -import { applyEdits } from './applyEdits'; -import { generateDiff } from './generateDiff'; -import { EditInputSchema, EditOutputSchema } from './schema'; -import type { EditInputType, EditOutputType } from './types'; -import { validateEdits } from './validateEdits'; - -export const editTool: ToolDefinition = { - name: 'edit', - description: 'Stage edits to a file. Returns a diff for review before confirming.', - input_schema: EditInputSchema, - input_examples: [ - { - file: '/path/to/file.ts', - edits: [{ action: 'insert', after_line: 0, content: '// hello world' }], - }, - { - file: '/path/to/file.ts', - edits: [{ action: 'replace', startLine: 5, endLine: 7, content: 'const x = 1;' }], - }, - { - file: '/path/to/file.ts', - edits: [{ action: 'delete', startLine: 10, endLine: 12 }], - }, - { - file: '/path/to/file.ts', - edits: [ - { action: 'delete', startLine: 3, endLine: 3 }, - { action: 'replace', startLine: 8, endLine: 9, content: 'export default foo;' }, - ], - }, - ], - handler: async (input, store) => { - const originalContent = readFileSync(input.file, 'utf-8'); - const originalHash = createHash('sha256').update(originalContent).digest('hex'); - const originalLines = originalContent.split('\n'); - validateEdits(originalLines, input.edits); - const newLines = applyEdits(originalLines, input.edits); - const newContent = newLines.join('\n'); - const diff = generateDiff(input.file, originalLines, input.edits); - const output = EditOutputSchema.parse({ - patchId: randomUUID(), - diff, - file: input.file, - newContent, - originalHash, - }); - store.set(output.patchId, output); - return output; - }, -}; diff --git a/apps/claude-sdk-cli/src/tools/edit/generateDiff.ts b/apps/claude-sdk-cli/src/tools/edit/generateDiff.ts deleted file mode 100644 index a878963..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/generateDiff.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { EditOperationType } from './types'; - -export function generateDiff(filePath: string, originalLines: string[], edits: EditOperationType[]): string { - const sorted = [...edits].sort((a, b) => { - const aLine = a.action === 'insert' ? a.after_line : a.startLine; - const bLine = b.action === 'insert' ? b.after_line : b.startLine; - return aLine - bLine; - }); - - const hunks: string[] = [`--- a/${filePath}`, `+++ b/${filePath}`]; - - for (const edit of sorted) { - if (edit.action === 'replace') { - const oldLines = originalLines.slice(edit.startLine - 1, edit.endLine); - const newLines = edit.content.split('\n'); - hunks.push(`@@ -${edit.startLine},${oldLines.length} +${edit.startLine},${newLines.length} @@`); - hunks.push(...oldLines.map((l) => `-${l}`)); - hunks.push(...newLines.map((l) => `+${l}`)); - } else if (edit.action === 'delete') { - const oldLines = originalLines.slice(edit.startLine - 1, edit.endLine); - hunks.push(`@@ -${edit.startLine},${oldLines.length} +${edit.startLine},0 @@`); - hunks.push(...oldLines.map((l) => `-${l}`)); - } else { - const newLines = edit.content.split('\n'); - hunks.push(`@@ -${edit.after_line},0 +${edit.after_line + 1},${newLines.length} @@`); - hunks.push(...newLines.map((l) => `+${l}`)); - } - } - - return hunks.join('\n'); -} diff --git a/apps/claude-sdk-cli/src/tools/edit/schema.ts b/apps/claude-sdk-cli/src/tools/edit/schema.ts deleted file mode 100644 index 3cca7e6..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/schema.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from 'zod'; - -const ReplaceOperationSchema = z.object({ - action: z.literal('replace'), - startLine: z.number().int().positive(), - endLine: z.number().int().positive(), - content: z.string(), -}); - -const DeleteOperationSchema = z.object({ - action: z.literal('delete'), - startLine: z.number().int().positive(), - endLine: z.number().int().positive(), -}); - -const InsertOperationSchema = z.object({ - action: z.literal('insert'), - after_line: z.number().int().min(0), - content: z.string(), -}); - -export const EditOperationSchema = z.discriminatedUnion('action', [ReplaceOperationSchema, DeleteOperationSchema, InsertOperationSchema]); - -export const EditInputSchema = z.object({ - file: z.string(), - edits: z.array(EditOperationSchema).min(1), -}); - -export const EditOutputSchema = z.object({ - patchId: z.string(), - diff: z.string(), - file: z.string(), - newContent: z.string(), - originalHash: z.string(), -}); - -export const EditConfirmInputSchema = z.object({ - patchId: z.string(), -}); - -export const EditConfirmOutputSchema = z.object({ - linesChanged: z.number().int().nonnegative(), -}); diff --git a/apps/claude-sdk-cli/src/tools/edit/types.ts b/apps/claude-sdk-cli/src/tools/edit/types.ts deleted file mode 100644 index e5d1b2c..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { z } from 'zod'; -import type { EditConfirmInputSchema, EditConfirmOutputSchema, EditInputSchema, EditOperationSchema, EditOutputSchema } from './schema'; - -export type EditInputType = z.infer; -export type EditOutputType = z.infer; -export type EditConfirmInputType = z.infer; -export type EditConfirmOutputType = z.infer; -export type EditOperationType = z.infer; diff --git a/apps/claude-sdk-cli/src/tools/edit/validateEdits.ts b/apps/claude-sdk-cli/src/tools/edit/validateEdits.ts deleted file mode 100644 index f1466de..0000000 --- a/apps/claude-sdk-cli/src/tools/edit/validateEdits.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EditOperationType } from './types'; - -export function validateEdits(lines: string[], edits: EditOperationType[]): void { - for (const edit of edits) { - if (edit.action === 'insert') { - if (edit.after_line > lines.length) { - throw new Error(`insert after_line ${edit.after_line} out of bounds (file has ${lines.length} lines)`); - } - } else { - if (edit.startLine > lines.length) { - throw new Error(`${edit.action} startLine ${edit.startLine} out of bounds (file has ${lines.length} lines)`); - } - if (edit.endLine > lines.length) { - throw new Error(`${edit.action} endLine ${edit.endLine} out of bounds (file has ${lines.length} lines)`); - } - if (edit.startLine > edit.endLine) { - throw new Error(`${edit.action} startLine ${edit.startLine} is greater than endLine ${edit.endLine}`); - } - } - } -} diff --git a/apps/claude-sdk-cli/test/clipboard.spec.ts b/apps/claude-sdk-cli/test/clipboard.spec.ts new file mode 100644 index 0000000..a364a89 --- /dev/null +++ b/apps/claude-sdk-cli/test/clipboard.spec.ts @@ -0,0 +1,241 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { looksLikePath, readClipboardPathCore, sanitiseFurlResult } from '../src/clipboard.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns a callable that resolves to `value`. */ +const ok = (value: string | null) => () => Promise.resolve(value); + +/** Returns a callable that rejects with an error. */ +const fail = + (msg = 'exec failed') => + () => + Promise.reject(new Error(msg)); + +// --------------------------------------------------------------------------- +// looksLikePath +// --------------------------------------------------------------------------- + +describe('looksLikePath', () => { + it.each([ + ['/absolute/path', true], + ['/single', true], + ['/', true], + ['~/home/relative', true], + ['~', true], + ['./explicitly/relative', true], + ['../parent/relative', true], + ['./', true], + ['../', true], + // bare relative paths (VS Code 'Copy Relative Path' — no ./ prefix) + ['apps/claude-sdk-cli/src/clipboard.ts', true], + ['relative/no-dot-prefix', true], + ['src/index.ts', true], + ])('accepts %s → %s', (input, expected) => { + expect(looksLikePath(input)).toBe(expected); + }); + + it.each([ + ['hello world', false], // no slash + ['file.ts', false], // no slash + ['', false], // empty + ['hello/world message', false], // slash but whitespace present + ['apps/foo bar/baz', false], // slash but whitespace present + ['C:\\Windows\\Path', false], // no forward slash + // multi-line strings are rejected + ['/valid/path\nwith newline', false], + ['/valid/path\rwith cr', false], + // strings over 1 KB are rejected + [`/${'a'.repeat(1024)}`, false], + ])('rejects %s → %s', (input, expected) => { + expect(looksLikePath(input)).toBe(expected); + }); + + it('accepts a string exactly 1 KB long', () => { + // 1024 chars total: '/' + 1023 'a's + const s = `/${'a'.repeat(1023)}`; + expect(s.length).toBe(1024); + expect(looksLikePath(s)).toBe(true); + }); + + it('rejects a string of exactly 1025 chars', () => { + const s = `/${'a'.repeat(1024)}`; + expect(s.length).toBe(1025); + expect(looksLikePath(s)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// readClipboardPathCore — stage-1 (pbpaste) wins +// --------------------------------------------------------------------------- + +describe('readClipboardPathCore — pbpaste returns a path', () => { + it('returns an absolute path directly from pbpaste', async () => { + const result = await readClipboardPathCore(ok('/Users/stephen/file.ts'), fail()); + expect(result).toBe('/Users/stephen/file.ts'); + }); + + it('trims surrounding whitespace from pbpaste output', async () => { + const result = await readClipboardPathCore(ok(' /Users/stephen/file.ts '), fail()); + expect(result).toBe('/Users/stephen/file.ts'); + }); + + it('returns a home-relative path from pbpaste', async () => { + const result = await readClipboardPathCore(ok('~/projects/my-app'), fail()); + expect(result).toBe('~/projects/my-app'); + }); + + it('returns a ./-relative path from pbpaste (VS Code "Copy Relative Path")', async () => { + const result = await readClipboardPathCore(ok('./apps/claude-sdk-cli/src/clipboard.ts'), fail()); + expect(result).toBe('./apps/claude-sdk-cli/src/clipboard.ts'); + }); + + it('returns a ../-relative path from pbpaste', async () => { + const result = await readClipboardPathCore(ok('../sibling/file.ts'), fail()); + expect(result).toBe('../sibling/file.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// readClipboardPathCore — stage-2 (osascript) fallback +// --------------------------------------------------------------------------- + +describe('readClipboardPathCore — pbpaste does not give a path (Finder ⌘C fallback)', () => { + it('falls through to osascript when pbpaste returns a bare filename', async () => { + // Finder ⌘C: pbpaste gives just "file.ts", osascript gives the full POSIX path + const result = await readClipboardPathCore(ok('file.ts'), ok('/Users/stephen/Desktop/file.ts')); + expect(result).toBe('/Users/stephen/Desktop/file.ts'); + }); + + it('falls through to osascript when pbpaste returns non-path text', async () => { + const result = await readClipboardPathCore(ok('hello world'), ok('/Users/stephen/Desktop/file.ts')); + expect(result).toBe('/Users/stephen/Desktop/file.ts'); + }); + + it('falls through to osascript when pbpaste returns null (empty clipboard)', async () => { + const result = await readClipboardPathCore(ok(null), ok('/Users/stephen/Desktop/file.ts')); + expect(result).toBe('/Users/stephen/Desktop/file.ts'); + }); + + it('falls through to osascript when pbpaste rejects', async () => { + const result = await readClipboardPathCore(fail('pbpaste not found'), ok('/Users/stephen/Desktop/file.ts')); + expect(result).toBe('/Users/stephen/Desktop/file.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// readClipboardPathCore — VS Code code/file-list probe (second file probe) +// --------------------------------------------------------------------------- + +describe('readClipboardPathCore — VS Code code/file-list probe', () => { + it('returns decoded POSIX path when VS Code probe resolves and pbpaste is empty', async () => { + const result = await readClipboardPathCore( + ok(null), // pbpaste: empty clipboard + ok('/Users/stephen/projects/file.ts'), // vscode probe: already decoded POSIX path + fail(), // osascript: should not be reached + ); + expect(result).toBe('/Users/stephen/projects/file.ts'); + }); + + it('skips a failing VS Code probe and falls through to the next probe', async () => { + const result = await readClipboardPathCore( + ok(null), // pbpaste: empty + fail(), // vscode probe: type absent (rejects) + ok('/Users/stephen/Desktop/file.ts'), // osascript: succeeds + ); + expect(result).toBe('/Users/stephen/Desktop/file.ts'); + }); + + it('uses the first succeeding file probe (VS Code wins over osascript)', async () => { + const result = await readClipboardPathCore(ok(null), ok('/Users/stephen/projects/vscode.ts'), ok('/Users/stephen/projects/osascript.ts')); + expect(result).toBe('/Users/stephen/projects/vscode.ts'); + }); + + it('returns null when pbpaste gives non-path text and all file probes fail', async () => { + const result = await readClipboardPathCore(ok('hello world'), fail(), fail()); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// readClipboardPathCore — both stages fail → null +// --------------------------------------------------------------------------- + +describe('readClipboardPathCore — nothing yields a path', () => { + it('returns null when pbpaste returns non-path text and osascript returns null', async () => { + const result = await readClipboardPathCore(ok('hello world'), ok(null)); + expect(result).toBeNull(); + }); + + it('returns null when pbpaste returns non-path text and osascript rejects', async () => { + // e.g. clipboard contains plain text — osascript -1700 error + const result = await readClipboardPathCore(ok('hello world'), fail('osascript: -1700')); + expect(result).toBeNull(); + }); + + it('returns null when both pbpaste and osascript reject', async () => { + const result = await readClipboardPathCore(fail(), fail()); + expect(result).toBeNull(); + }); + + it('returns null when both return null', async () => { + const result = await readClipboardPathCore(ok(null), ok(null)); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// fileURLToPath — verify file URI → POSIX path decoding +// (documents what readVSCodeFileList relies on when the clipboard has %XX chars) +// --------------------------------------------------------------------------- + +describe('fileURLToPath — file URI decoding for VS Code clipboard URIs', () => { + it('converts a plain file URI to a POSIX path', () => { + expect(fileURLToPath('file:///Users/stephen/projects/file.ts')).toBe('/Users/stephen/projects/file.ts'); + }); + + it('percent-decodes %40 (@) in path segments — the real case from this repo', () => { + // VS Code puts: file:///Users/stephen/repos/%40shellicar/claude-cli/apps/... + expect(fileURLToPath('file:///Users/stephen/repos/%40shellicar/claude-cli/apps/claude-sdk-cli/build.ts')).toBe('/Users/stephen/repos/@shellicar/claude-cli/apps/claude-sdk-cli/build.ts'); + }); + + it('percent-decodes spaces (%20) in path segments', () => { + expect(fileURLToPath('file:///Users/stephen/My%20Projects/file.ts')).toBe('/Users/stephen/My Projects/file.ts'); + }); +}); + +// --------------------------------------------------------------------------- +// sanitiseFurlResult — HFS artifact rejection +// --------------------------------------------------------------------------- + +describe('sanitiseFurlResult', () => { + it.each([ + // Genuine POSIX paths pass through unchanged + ['/Users/stephen/file.ts', '/Users/stephen/file.ts'], + ['/Users/stephen/repos/@shellicar/claude-cli/apps/build.ts', '/Users/stephen/repos/@shellicar/claude-cli/apps/build.ts'], + ['/Applications/VS Code.app/', '/Applications/VS Code.app/'], + ])('passes genuine POSIX path %s → %s', (input, expected) => { + expect(sanitiseFurlResult(input)).toBe(expected); + }); + + it.each([ + // HFS artifacts from AppleScript coercing plain text (/ → :) + ['/apps:claude-sdk-cli:src:clipboard.ts', null], // confirmed from real log output + ['/apps:claude-sdk-cli:src:AppLayout.ts', null], + ['Macintosh HD:Users:stephen:file.ts', null], // full HFS path without leading / + ['/Users:stephen:file.ts', null], // partial HFS coercion + ])('rejects HFS artifact %s → null', (input, expected) => { + expect(sanitiseFurlResult(input)).toBe(expected); + }); + + it('returns null for null input', () => { + expect(sanitiseFurlResult(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(sanitiseFurlResult('')).toBeNull(); + }); +}); diff --git a/apps/claude-sdk-cli/tsconfig.json b/apps/claude-sdk-cli/tsconfig.json index 3bb5eb4..f41a1dc 100644 --- a/apps/claude-sdk-cli/tsconfig.json +++ b/apps/claude-sdk-cli/tsconfig.json @@ -1,13 +1,8 @@ { - "extends": "@tsconfig/node24/tsconfig.json", + "extends": "@shellicar/typescript-config/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", - "moduleResolution": "bundler", - "module": "es2022", - "target": "es2024", - "strictNullChecks": true + "rootDir": "." }, - "include": ["**/*.ts"], - "exclude": ["dist", "node_modules"] + "include": ["**/*.ts"] } diff --git a/apps/claude-sdk-cli/vitest.config.ts b/apps/claude-sdk-cli/vitest.config.ts new file mode 100644 index 0000000..ae8680e --- /dev/null +++ b/apps/claude-sdk-cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/docs/skills-design.md b/docs/skills-design.md new file mode 100644 index 0000000..ced68f1 --- /dev/null +++ b/docs/skills-design.md @@ -0,0 +1,116 @@ +# Skills Design + +## What Skills Are + +A skill is a named, toggleable capability package. Each skill has three parts: + +1. **Content** — a `SKILL.md` body injected into context when the skill is active +2. **Gates** — optional: tools or permissions the skill enables (soft-locked otherwise) +3. **Lifecycle** — `alwaysOn`, or activated/deactivated by Claude via meta-tools + +Skills are not passive documentation. They are active context with a managed lifecycle. + +--- + +## The Two Meta-Tools + +Claude has two special tools that are not gated by any skill: + +- **`ActivateSkill(name)`** — injects the skill's `SKILL.md` as a tagged context message; lifts any gates the skill enables +- **`DeactivateSkill(name)`** — prunes the injected context message; re-applies gates + +Claude invokes these autonomously. The user can also trigger them by instruction. The consumer can also manage lifecycle directly (always-on skills, phased workflows). + +--- + +## Gating + +Gated tools are not removed from the tool list — Claude can still see they exist. Attempting to call a gated tool without the required skill returns a soft decline: + +> `git-commit skill must be active to use git. Call ActivateSkill("git-commit") first.` + +This is intentional. Claude needs to know the tool exists in order to plan around it. The message guides Claude to activate the right skill before proceeding. + +When a skill is activated, its `SKILL.md` enters context as the tool result — meaning the workflow guidance is guaranteed to be in context at the exact moment the gated tools become available. This is the key reliability property: the workflow cannot be bypassed because the tools aren't accessible without it. + +Gating is optional. A skill that has no gates still has value as managed context — it's injected when relevant and pruned when done, rather than loading everything upfront. + +--- + +## Why This Looks Like Restriction But Isn't + +The surface reading: tools are locked behind skills, Claude has to ask permission to use them. + +The actual effect: **Claude is given structured autonomy over its own capability set.** + +Today, Claude has access to every tool at all times. It has to make do with a static toolset and a static context regardless of what it's actually doing. A session doing exploration and a session doing git commits look identical to the model. + +With skills, Claude can: +- Recognise what phase of work it's in +- Self-activate the relevant capability package +- Work within a well-defined scope for that phase +- Deactivate when done, keeping context clean + +The skills system doesn't restrict what Claude can ultimately do — it gives Claude the ability to shape its own working environment. That's more autonomy, not less. The structure is what makes it trustworthy. + +The analogy: a contractor who scopes their own work, requests the right tools for each job, and puts them away when done is more capable than one who shows up with every tool they own and leaves them all out. The discipline is what enables the trust. + +--- + +## The Compliance Problem This Solves + +The problem with pure prompt-based skills: Claude can decide a skill isn't necessary and skip it. No matter how forceful the language, it's a suggestion. This means skills cannot enforce workflow or policy — they can only recommend it. + +The root cause isn't Claude being uncooperative. Claude doesn't have a compliance problem. It has a context problem: without a structural signal, it can't always know that following a workflow is more important than any given shortcut it might take. + +Gating provides that structural signal. When a skill must be active for a tool to be usable, the workflow isn't a recommendation — it's the path. The guidance in `SKILL.md` is loaded at the moment it's most relevant, not beforehand as ambient context that might or might not be attended to. + +--- + +## Phased Workflows + +Different phases of a job need different capability sets: + +| Phase | Likely active skills | +|---|---| +| Exploration / design | (minimal — maybe detection or style skills) | +| Development | `tdd`, `typescript-standards` | +| Commit / push / PR | `git-commit`, `github-pr`, `writing-style` | +| Azure ADO work item | `azure-devops-pr`, `work-item-hygiene` | + +These aren't hardcoded. The consumer decides which skills exist and how they're structured. A skill that's always-on for one workflow is optional in another. + +--- + +## SDK Boundaries + +The SDK provides primitives. It does not provide opinions about which skills exist or how workflows are structured. + +**SDK provides:** +- `ConversationHistory.push(msg, { id? })` — tagged message injection +- `ConversationHistory.remove(id)` — surgical message pruning (enables deactivation) +- Skill activation/deactivation event types on the message channel +- Enough scaffolding for `ActivateSkill`/`DeactivateSkill` to be buildable as regular tools + +**Consumer / package provides:** +- The actual skill definitions (`SKILL.md` content, gate declarations) +- The `ActivateSkill`/`DeactivateSkill` tool handlers +- Which tools require which skills +- Workflow phase configuration, always-on policy + +A reference implementation will live in `packages/claude-sdk-tools` or a dedicated `packages/claude-sdk-skills`. The consumer can use it as-is, adapt it, or replace it entirely. + +The Ref system follows the same boundary: the SDK owns the history primitive that makes it possible, the consumer decides what to store and when to retrieve it. + +--- + +## Minimum SDK Work Required + +The only SDK change needed to support this fully: + +``` +ConversationHistory.push(msg, { id? }) → tagged injection +ConversationHistory.remove(id) → pruning on deactivate +``` + +Tool gating requires no SDK changes — it's plain handler logic that returns a guidance message. The meta-tools themselves are plain `defineTool()` calls. The skill content entering context on activation is the tool result of `ActivateSkill` — no special injection mechanism needed for that direction. diff --git a/knip.json b/knip.json index 7a75f52..774510d 100644 --- a/knip.json +++ b/knip.json @@ -6,9 +6,17 @@ "entry": ["src/*.ts", "inject/*.ts"], "ignoreDependencies": [] }, - "packages/claude-cli": { + "apps/claude-cli": { "entry": ["src/*.ts", "inject/*.ts"], "ignoreDependencies": [] + }, + "packages/claude-sdk": { + "entry": ["src/*.ts"], + "ignoreDependencies": [] + }, + "packages/claude-cli-tools": { + "entry": ["src/*.ts"], + "ignoreDependencies": [] } } } diff --git a/package.json b/package.json index fdf21d4..0d100a6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "build": "turbo run build", "dev": "turbo run dev", "start": "turbo run start", + "watch": "turbo run watch", "type-check": "turbo run type-check", "lint": "biome lint", "format": "biome format", @@ -19,10 +20,12 @@ "private": true, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@vitest/coverage-v8": "^4.1.2", "knip": "^5.88.1", "lefthook": "^2.1.4", "npm-check-updates": "^19.6.6", "syncpack": "^14.3.0", - "turbo": "^2.9.3" + "turbo": "^2.9.3", + "vitest": "^4.1.2" } } diff --git a/packages/claude-cli/vitest.config.ts b/packages/claude-cli/vitest.config.ts deleted file mode 100644 index c106e67..0000000 --- a/packages/claude-cli/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - coverage: { - provider: 'v8', - }, - include: ['test/**/*.spec.ts', 'src/**/*.test.ts'], - }, -}); diff --git a/packages/claude-core/build.ts b/packages/claude-core/build.ts new file mode 100644 index 0000000..c07e902 --- /dev/null +++ b/packages/claude-core/build.ts @@ -0,0 +1,37 @@ +import { glob } from 'node:fs/promises'; +import cleanPlugin from '@shellicar/build-clean/esbuild'; +import versionPlugin from '@shellicar/build-version/esbuild'; +import * as esbuild from 'esbuild'; + +const watch = process.argv.some((x) => x === '--watch'); + +const plugins = [cleanPlugin({ destructive: true }), versionPlugin({ versionCalculator: 'gitversion' })]; + +const inject = await Array.fromAsync(glob('./inject/*.ts')); + +const ctx = await esbuild.context({ + bundle: true, + entryPoints: ['src/*.ts'], + inject, + entryNames: '[name]', + chunkNames: 'chunks/[name]-[hash]', + keepNames: true, + format: 'esm', + minify: false, + splitting: true, + outdir: 'dist', + platform: 'node', + plugins, + sourcemap: true, + target: 'node24', + treeShaking: false, + tsconfig: 'tsconfig.json', +}); + +if (watch) { + await ctx.watch(); + console.log('watching...'); +} else { + await ctx.rebuild(); + ctx.dispose(); +} diff --git a/packages/claude-core/package.json b/packages/claude-core/package.json new file mode 100644 index 0000000..235ce2b --- /dev/null +++ b/packages/claude-core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@shellicar/claude-core", + "version": "0.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsx build.ts", + "dev": "tsx build.ts --watch", + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsx build.ts --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.33.0", + "type": "module", + "files": [ + "dist" + ], + "exports": { + "./*": { + "import": "./dist/*.js", + "types": "./src/*.ts" + } + }, + "devDependencies": { + "@shellicar/build-clean": "^1.3.2", + "@shellicar/build-version": "^1.3.6", + "@shellicar/typescript-config": "workspace:*", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^25.5.0", + "esbuild": "^0.27.5", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "dependencies": { + "string-width": "^8.2.0" + } +} diff --git a/packages/claude-core/src/ansi.ts b/packages/claude-core/src/ansi.ts new file mode 100644 index 0000000..ee81f97 --- /dev/null +++ b/packages/claude-core/src/ansi.ts @@ -0,0 +1,29 @@ +export const ESC = '\x1B['; + +// Cursor +export const cursorAt = (row: number, col: number) => `${ESC}${row};${col}H`; +export const clearLine = `${ESC}2K`; +export const clearDown = `${ESC}J`; +export const showCursor = `${ESC}?25h`; +export const hideCursor = `${ESC}?25l`; + +// Synchronized output (DECSET 2026) +export const syncStart = '\x1B[?2026h'; +export const syncEnd = '\x1B[?2026l'; + +// Styles +export const RESET = '\x1B[0m'; +export const DIM = '\x1B[2m'; +export const BOLD = '\x1B[1m'; +export const INVERSE_ON = '\x1B[7m'; +export const INVERSE_OFF = '\x1B[27m'; + +// Colors (foreground) +export const RED = '\x1B[31m'; +export const GREEN = '\x1B[32m'; +export const YELLOW = '\x1B[33m'; +export const CYAN = '\x1B[36m'; +export const BOLD_WHITE = '\x1B[1;97m'; + +// Misc +export const BEL = '\x07'; diff --git a/packages/claude-core/src/input.ts b/packages/claude-core/src/input.ts new file mode 100644 index 0000000..3d0f3a2 --- /dev/null +++ b/packages/claude-core/src/input.ts @@ -0,0 +1,255 @@ +/** + * Keyboard input handler using Node's readline keypress parser. + * Translates Node keypress events into named KeyAction types. + * + * Uses readline.emitKeypressEvents() which handles: + * - CSI sequences (\x1b[A) and SS3/application mode (\x1bOA) + * - Modifier keys (Ctrl, Alt, Shift) with proper detection + * - Partial escape sequence buffering with timeout + * - F-keys, Home, End, Delete, Insert, PageUp, PageDown + * - Kitty keyboard protocol (CSI u format) + * - Paste bracket mode + */ + +import { appendFileSync } from 'node:fs'; +import readline from 'node:readline'; + +export type KeyAction = + | { type: 'char'; value: string } + | { type: 'enter' } + | { type: 'ctrl+enter' } + | { type: 'backspace' } + | { type: 'delete' } + | { type: 'ctrl+delete' } + | { type: 'ctrl+backspace' } + | { type: 'left' } + | { type: 'right' } + | { type: 'up' } + | { type: 'down' } + | { type: 'home' } + | { type: 'end' } + | { type: 'ctrl+home' } + | { type: 'ctrl+end' } + | { type: 'ctrl+left' } + | { type: 'ctrl+right' } + | { type: 'ctrl+c' } + | { type: 'ctrl+d' } + | { type: 'ctrl+k' } + | { type: 'ctrl+u' } + | { type: 'ctrl+/' } + | { type: 'escape' } + | { type: 'page_up' } + | { type: 'page_down' } + | { type: 'shift+up' } + | { type: 'shift+down' } + | { type: 'unknown'; raw: string }; + +export interface NodeKey { + sequence: string; + name: string | undefined; + ctrl: boolean; + meta: boolean; + shift: boolean; +} + +/** + * Translate a Node readline keypress event into our KeyAction type. + */ +export function translateKey(ch: string | undefined, key: NodeKey | undefined): KeyAction | null { + // biome-ignore lint/suspicious/noConfusingLabels: esbuild dropLabels strips DEBUG blocks in production + // biome-ignore lint/correctness/noUnusedLabels: esbuild dropLabels strips DEBUG blocks in production + DEBUG: { + const raw = key?.sequence ?? ch ?? ''; + const hex = [...raw].map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' '); + const ts = new Date().toISOString(); + appendFileSync('/tmp/claude-core-keys.log', `${ts} | ${hex} | ${JSON.stringify(raw)} | name=${key?.name} ctrl=${key?.ctrl} meta=${key?.meta} shift=${key?.shift}\n`); + } + + const name = key?.name; + const ctrl = key?.ctrl ?? false; + const meta = key?.meta ?? false; + const sequence = key?.sequence ?? ch ?? ''; + + // Ctrl combinations + if (ctrl) { + switch (name) { + case 'c': + return { type: 'ctrl+c' }; + case 'd': + return { type: 'ctrl+d' }; + case 'left': + return { type: 'ctrl+left' }; + case 'right': + return { type: 'ctrl+right' }; + case 'home': + return { type: 'ctrl+home' }; + case 'end': + return { type: 'ctrl+end' }; + case 'delete': + return { type: 'ctrl+delete' }; + case 'backspace': + return { type: 'ctrl+backspace' }; + case 'return': + return { type: 'ctrl+enter' }; + // Emacs navigation + case 'a': + return { type: 'home' }; + case 'e': + return { type: 'end' }; + case 'b': + return { type: 'left' }; + case 'f': + return { type: 'right' }; + case 'k': + return { type: 'ctrl+k' }; + case 'u': + return { type: 'ctrl+u' }; + } + } + + // Ctrl+Backspace: tmux sends Ctrl+W (\x17) + if (ctrl && name === 'w') { + return { type: 'ctrl+backspace' }; + } + + // Ctrl+Delete: tmux sends ESC+d (\x1Bd), readline reports meta+d + if (meta && name === 'd') { + return { type: 'ctrl+delete' }; + } + + // Ctrl+Backspace: ESC+DEL (\x1B\x7F), readline may report meta+backspace + if (meta && name === 'backspace') { + return { type: 'ctrl+backspace' }; + } + + // option+left: iTerm2 direct sends meta+left; tmux translates to meta+b + if (meta && (name === 'left' || name === 'b')) { + return { type: 'ctrl+left' }; + } + + // option+right: iTerm2 direct sends meta+right; tmux translates to meta+f + if (meta && (name === 'right' || name === 'f')) { + return { type: 'ctrl+right' }; + } + + // option+d on macOS (iTerm2 without "alt sends escape") sends ∂ (U+2202) + if (ch === '∂') { + return { type: 'ctrl+delete' }; + } + + // CSI u format (Kitty keyboard protocol): ESC [ keycode ; modifier u + // readline doesn't parse these, so handle them from the raw sequence + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape sequences requires \x1b + const csiU = sequence.match(/^\x1b\[(\d+);(\d+)u$/); + if (csiU) { + const keycode = Number(csiU[1]); + const modifier = Number(csiU[2]); + // modifier 5 = Ctrl, modifier 2 = Shift (both submit) + if (keycode === 13 && (modifier === 5 || modifier === 2)) { + return { type: 'ctrl+enter' }; + } + // Ctrl+C / Ctrl+D: tmux with extended-keys csi-u sends these + // as CSI u instead of the traditional 0x03 / 0x04 bytes + if (keycode === 99 && modifier === 5) { + return { type: 'ctrl+c' }; + } + if (keycode === 100 && modifier === 5) { + return { type: 'ctrl+d' }; + } + if (keycode === 127 && modifier === 5) { + return { type: 'ctrl+backspace' }; + } + if (keycode === 47 && modifier === 5) { + return { type: 'ctrl+/' }; + } + } + + // xterm modifyOtherKeys format: ESC [ 27 ; modifier ; keycode ~ + // iTerm2 and other terminals use this when modifyOtherKeys is enabled + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape sequences requires \x1b + const modifyOtherKeys = sequence.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (modifyOtherKeys) { + const modifier = Number(modifyOtherKeys[1]); + const keycode = Number(modifyOtherKeys[2]); + // modifier 5 = Ctrl, modifier 2 = Shift (both submit) + if (keycode === 13 && (modifier === 5 || modifier === 2)) { + return { type: 'ctrl+enter' }; + } + } + + // Shift modifier handling (before named keys switch) + if (key?.shift && !ctrl) { + switch (name) { + case 'up': + return { type: 'shift+up' }; + case 'down': + return { type: 'shift+down' }; + } + } + + // Named keys (without modifiers) + switch (name) { + case 'return': + return { type: 'enter' }; + case 'backspace': + return { type: 'backspace' }; + case 'delete': + return { type: 'delete' }; + case 'left': + return { type: 'left' }; + case 'right': + return { type: 'right' }; + case 'up': + return { type: 'up' }; + case 'down': + return { type: 'down' }; + case 'home': + return { type: 'home' }; + case 'end': + return { type: 'end' }; + case 'escape': + return { type: 'escape' }; + case 'pageup': + return { type: 'page_up' }; + case 'pagedown': + return { type: 'page_down' }; + } + + // Ctrl+/: most terminals send \x1f (ASCII Unit Separator) + if (sequence === '\x1f') { + return { type: 'ctrl+/' }; + } + + // Regular printable character (supports multi-byte Unicode like emoji) + if (ch && [...ch].length === 1 && ch >= ' ') { + return { type: 'char', value: ch }; + } + + // Unknown: only emit if we got some input we couldn't translate + if (sequence) { + return { type: 'unknown', raw: JSON.stringify(sequence) }; + } + + return null; +} + +/** + * Set up readline keypress events on stdin and call the handler for each translated KeyAction. + * Returns a cleanup function to remove the listener. + */ +export function setupKeypressHandler(handler: (key: KeyAction) => void): () => void { + readline.emitKeypressEvents(process.stdin); + + const onKeypress = (ch: string | undefined, key: NodeKey | undefined): void => { + const action = translateKey(ch, key); + if (action) { + handler(action); + } + }; + + process.stdin.on('keypress', onKeypress); + + return () => { + process.stdin.removeListener('keypress', onKeypress); + }; +} diff --git a/packages/claude-cli/src/Layout.ts b/packages/claude-core/src/reflow.ts similarity index 70% rename from packages/claude-cli/src/Layout.ts rename to packages/claude-core/src/reflow.ts index 493c304..a42565f 100644 --- a/packages/claude-cli/src/Layout.ts +++ b/packages/claude-core/src/reflow.ts @@ -1,40 +1,8 @@ import stringWidth from 'string-width'; -import type { EditorRender } from './renderer.js'; import { sanitiseZwj } from './sanitise.js'; const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); -/** - * Output from an existing builder (status, attachment, preview). - * `rows` are logical lines as the builder produces them (may be wider than columns). - * `height` is the visual height in terminal rows (accounts for wrapping). - */ -export interface BuiltComponent { - rows: string[]; - height: number; -} - -export interface LayoutInput { - editor: EditorRender; - status: BuiltComponent | null; - attachments: BuiltComponent | null; - preview: BuiltComponent | null; - question: BuiltComponent | null; - columns: number; -} - -/** - * Layout output. `buffer` contains one entry per visual (terminal) row. - * Layout is responsible for wrapping: a logical line wider than `columns` - * becomes multiple buffer entries. - */ -export interface LayoutResult { - buffer: string[]; - cursorRow: number; - cursorCol: number; - editorStartRow: number; -} - /** * A run of consecutive graphemes with the same character width. * Pre-computed on append to enable arithmetic-based re-wrapping on resize @@ -174,35 +142,3 @@ export function wrapLine(line: string, columns: number): string[] { } return segments; } - -/** - * Pure layout function. Takes all UI components and returns an unbounded - * buffer of visual rows with cursor position metadata. - * - * Buffer order (top to bottom): question, status, attachments, preview, editor. - */ -export function layout(input: LayoutInput): LayoutResult { - const { editor, status, attachments, preview, question, columns } = input; - const buffer: string[] = []; - - for (const component of [question, status, attachments, preview]) { - if (component !== null) { - for (const row of component.rows) { - buffer.push(...wrapLine(row, columns)); - } - } - } - - const editorStartRow = buffer.length; - - for (const line of editor.lines) { - buffer.push(...wrapLine(line, columns)); - } - - return { - buffer, - cursorRow: editorStartRow + editor.cursorRow, - cursorCol: editor.cursorCol, - editorStartRow, - }; -} diff --git a/packages/claude-cli/src/TerminalRenderer.ts b/packages/claude-core/src/renderer.ts similarity index 77% rename from packages/claude-cli/src/TerminalRenderer.ts rename to packages/claude-core/src/renderer.ts index 9b12862..6b78a14 100644 --- a/packages/claude-cli/src/TerminalRenderer.ts +++ b/packages/claude-core/src/renderer.ts @@ -1,14 +1,6 @@ -import type { Screen } from './Screen.js'; -import type { ViewportResult } from './Viewport.js'; - -const ESC = '\x1B['; -const cursorAt = (row: number, col: number) => `${ESC}${row};${col}H`; // 1-based -const clearLine = `${ESC}2K`; -const clearDown = `${ESC}J`; -const showCursor = `${ESC}?25h`; -const hideCursor = `${ESC}?25l`; -const syncStart = '\x1B[?2026h'; -const syncEnd = '\x1B[?2026l'; +import { clearDown, clearLine, cursorAt, hideCursor, showCursor, syncEnd, syncStart } from './ansi.js'; +import type { Screen } from './screen.js'; +import type { ViewportResult } from './viewport.js'; export class Renderer { public constructor(private readonly screen: Screen) {} diff --git a/packages/claude-cli/src/sanitise.ts b/packages/claude-core/src/sanitise.ts similarity index 100% rename from packages/claude-cli/src/sanitise.ts rename to packages/claude-core/src/sanitise.ts diff --git a/packages/claude-cli/src/Screen.ts b/packages/claude-core/src/screen.ts similarity index 100% rename from packages/claude-cli/src/Screen.ts rename to packages/claude-core/src/screen.ts diff --git a/packages/claude-cli/src/StatusLineBuilder.ts b/packages/claude-core/src/status-line.ts similarity index 100% rename from packages/claude-cli/src/StatusLineBuilder.ts rename to packages/claude-core/src/status-line.ts diff --git a/packages/claude-cli/src/Viewport.ts b/packages/claude-core/src/viewport.ts similarity index 100% rename from packages/claude-cli/src/Viewport.ts rename to packages/claude-core/src/viewport.ts diff --git a/packages/claude-core/tsconfig.check.json b/packages/claude-core/tsconfig.check.json new file mode 100644 index 0000000..bfed23d --- /dev/null +++ b/packages/claude-core/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true, + "composite": false, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + } +} diff --git a/packages/claude-core/tsconfig.json b/packages/claude-core/tsconfig.json new file mode 100644 index 0000000..a4e1379 --- /dev/null +++ b/packages/claude-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@shellicar/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/packages/claude-sdk-tools/.gitignore b/packages/claude-sdk-tools/.gitignore new file mode 100644 index 0000000..7535211 --- /dev/null +++ b/packages/claude-sdk-tools/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/packages/claude-sdk-tools/CLAUDE.md b/packages/claude-sdk-tools/CLAUDE.md new file mode 100644 index 0000000..4fb4c13 --- /dev/null +++ b/packages/claude-sdk-tools/CLAUDE.md @@ -0,0 +1,53 @@ +# @shellicar/claude-sdk-tools + +## Architecture + +Tool implementations for use with `@shellicar/claude-sdk`. Each tool is a standalone module under `src//` with its own schema, types, and handler. Tools are exported via named entry points in `package.json`. + +## Filesystem abstraction + +Tools that touch the filesystem take an `IFileSystem` dependency (see `src/fs/IFileSystem.ts`). This keeps tools testable without touching disk. + +| Implementation | Used in | +|---|---| +| `NodeFileSystem` | Production (default export from entry points) | +| `MemoryFileSystem` | Tests | + +## Ref system + +The Ref system reduces context window pressure by replacing large tool outputs with compact `{ ref, size, hint }` tokens that Claude can fetch on demand. + +### Components + +- **`RefStore`** — in-memory store. `store(content, hint?)` → UUID. `walkAndRef(value, threshold, hint?)` recursively walks a JSON-compatible tree and ref-swaps any string exceeding the threshold. Uniform string arrays (e.g. ReadFile `values`) are joined with `\n` and stored as a single ref, enabling natural char-offset pagination. +- **`createRef(store, threshold)`** — returns `{ tool, transformToolResult }`. Wire `transformToolResult` into `runAgent()` and add `tool` to the tool list. The Ref tool itself is exempt from `transformToolResult` to prevent infinite ref chains. + +### Wiring (consumer) + +```typescript +const store = new RefStore(); +const { tool: Ref, transformToolResult } = createRef(store, 2_000); + +runAgent({ transformToolResult, tools: [...tools, Ref] }); +``` + +### Future: `IRefStore` interface + +The current `RefStore` is in-memory only — refs are lost on process restart. The planned extensibility path is: + +```typescript +export interface IRefStore { + store(content: string, hint?: string): string; // returns id + get(id: string): string | undefined; + has(id: string): boolean; + delete(id: string): void; +} +``` + +`createRef` would take `IRefStore` instead of the concrete class. Consumers who want persistence implement the interface against whatever backend they choose (file, SQLite, etc.). The in-memory `RefStore` remains the default and is the right starting point — easy to implement, easy to test. + +Same pattern as `IFileSystem`: SDK provides the interface and a default, consumer provides opinions. + +## ReadFile size limit + +`ReadFile` rejects files over 500KB before reading (checked via `IFileSystem.stat`). For larger files use `Head`, `Tail`, `Range`, `Grep`, or `SearchFiles`. diff --git a/packages/claude-sdk-tools/build.ts b/packages/claude-sdk-tools/build.ts new file mode 100644 index 0000000..d82e2fa --- /dev/null +++ b/packages/claude-sdk-tools/build.ts @@ -0,0 +1,38 @@ +import { glob } from 'node:fs/promises'; +import cleanPlugin from '@shellicar/build-clean/esbuild'; +import versionPlugin from '@shellicar/build-version/esbuild'; +import * as esbuild from 'esbuild'; + +const watch = process.argv.some((x) => x === '--watch'); +const _minify = !watch; + +const plugins = [cleanPlugin({ destructive: true }), versionPlugin({ versionCalculator: 'gitversion' })]; + +const inject = await Array.fromAsync(glob('./inject/*.ts')); + +const ctx = await esbuild.context({ + bundle: true, + entryPoints: ['src/entry/*.ts'], + inject, + entryNames: 'entry/[name]', + chunkNames: 'chunks/[name]-[hash]', + keepNames: true, + format: 'esm', + minify: false, + splitting: true, + outdir: 'dist', + platform: 'node', + plugins, + sourcemap: true, + target: 'node24', + treeShaking: false, + tsconfig: 'tsconfig.json', +}); + +if (watch) { + await ctx.watch(); + console.log('watching...'); +} else { + await ctx.rebuild(); + ctx.dispose(); +} diff --git a/packages/claude-sdk-tools/package.json b/packages/claude-sdk-tools/package.json new file mode 100644 index 0000000..555e67e --- /dev/null +++ b/packages/claude-sdk-tools/package.json @@ -0,0 +1,101 @@ +{ + "name": "@shellicar/claude-sdk-tools", + "version": "0.0.0", + "files": [ + "dist" + ], + "type": "module", + "exports": { + "./EditFile": { + "import": "./dist/entry/EditFile.js", + "types": "./src/entry/EditFile.ts" + }, + "./PreviewEdit": { + "import": "./dist/entry/PreviewEdit.js", + "types": "./src/entry/PreviewEdit.ts" + }, + "./ReadFile": { + "import": "./dist/entry/ReadFile.js", + "types": "./src/entry/ReadFile.ts" + }, + "./CreateFile": { + "import": "./dist/entry/CreateFile.js", + "types": "./src/entry/CreateFile.ts" + }, + "./Find": { + "import": "./dist/entry/Find.js", + "types": "./src/entry/Find.ts" + }, + "./Grep": { + "import": "./dist/entry/Grep.js", + "types": "./src/entry/Grep.ts" + }, + "./Head": { + "import": "./dist/entry/Head.js", + "types": "./src/entry/Head.ts" + }, + "./Tail": { + "import": "./dist/entry/Tail.js", + "types": "./src/entry/Tail.ts" + }, + "./Range": { + "import": "./dist/entry/Range.js", + "types": "./src/entry/Range.ts" + }, + "./DeleteFile": { + "import": "./dist/entry/DeleteFile.js", + "types": "./src/entry/DeleteFile.ts" + }, + "./DeleteDirectory": { + "import": "./dist/entry/DeleteDirectory.js", + "types": "./src/entry/DeleteDirectory.ts" + }, + "./Pipe": { + "import": "./dist/entry/Pipe.js", + "types": "./src/entry/Pipe.ts" + }, + "./SearchFiles": { + "import": "./dist/entry/SearchFiles.js", + "types": "./src/entry/SearchFiles.ts" + }, + "./Exec": { + "import": "./dist/entry/Exec.js", + "types": "./src/entry/Exec.ts" + }, + "./Ref": { + "import": "./dist/entry/Ref.js", + "types": "./src/entry/Ref.ts" + }, + "./RefStore": { + "import": "./dist/entry/RefStore.js", + "types": "./src/entry/RefStore.ts" + } + }, + "scripts": { + "dev": "tsx build.ts --watch", + "build": "tsx build.ts", + "build:watch": "tsx build.ts --watch", + "start": "node dist/main.js", + "test": "vitest run", + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsx build.ts --watch" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.82.0", + "diff": "^8.0.4", + "file-type": "^22.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@shellicar/build-clean": "^1.3.2", + "@shellicar/build-version": "^1.3.6", + "@shellicar/claude-sdk": "workspace:^", + "@shellicar/typescript-config": "workspace:*", + "@tsconfig/node24": "^24.0.4", + "@types/node": "^25.5.0", + "esbuild": "^0.27.5", + "tsx": "^4.21.0", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} diff --git a/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts b/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts new file mode 100644 index 0000000..b096d1f --- /dev/null +++ b/packages/claude-sdk-tools/src/CreateFile/CreateFile.ts @@ -0,0 +1,30 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { expandPath } from '../expandPath'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { CreateFileInputSchema } from './schema'; +import type { CreateFileOutput } from './types'; + +export function createCreateFile(fs: IFileSystem) { + return defineTool({ + name: 'CreateFile', + description: 'Create a new file with optional content. Creates parent directories automatically. By default errors if the file already exists. Set overwrite: true to replace an existing file (errors if file does not exist).', + operation: 'write', + input_schema: CreateFileInputSchema, + input_examples: [{ path: './src/NewFile.ts' }, { path: './src/NewFile.ts', content: 'export const foo = 1;\n' }, { path: './src/NewFile.ts', content: 'export const foo = 1;\n', overwrite: true }], + handler: async (input): Promise => { + const filePath = expandPath(input.path, fs); + const { overwrite = false, content = '' } = input; + const exists = await fs.exists(filePath); + + if (!overwrite && exists) { + return { error: true, message: 'File already exists. Set overwrite: true to replace it.', path: filePath }; + } + if (overwrite && !exists) { + return { error: true, message: 'File does not exist. Set overwrite: false to create it.', path: filePath }; + } + + await fs.writeFile(filePath, content); + return { error: false, path: filePath }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/CreateFile/schema.ts b/packages/claude-sdk-tools/src/CreateFile/schema.ts new file mode 100644 index 0000000..0144e43 --- /dev/null +++ b/packages/claude-sdk-tools/src/CreateFile/schema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const CreateFileInputSchema = z.object({ + path: z.string().describe('Path to the file to create. Supports absolute, relative, ~ and $HOME.'), + content: z.string().optional().describe('Initial file content. Defaults to empty.'), + overwrite: z.boolean().optional().describe('If false (default), error if file already exists. If true, error if file does not exist.'), +}); + +export const CreateFileOutputSchema = z.discriminatedUnion('error', [ + z.object({ + error: z.literal(false), + path: z.string(), + }), + z.object({ + error: z.literal(true), + message: z.string(), + path: z.string(), + }), +]); diff --git a/packages/claude-sdk-tools/src/CreateFile/types.ts b/packages/claude-sdk-tools/src/CreateFile/types.ts new file mode 100644 index 0000000..85d725a --- /dev/null +++ b/packages/claude-sdk-tools/src/CreateFile/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { CreateFileInputSchema, CreateFileOutputSchema } from './schema'; + +export type CreateFileInput = z.output; +export type CreateFileOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/DeleteDirectory/DeleteDirectory.ts b/packages/claude-sdk-tools/src/DeleteDirectory/DeleteDirectory.ts new file mode 100644 index 0000000..3a9aae0 --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteDirectory/DeleteDirectory.ts @@ -0,0 +1,32 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { deleteBatch } from '../deleteBatch'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { isNodeError } from '../isNodeError'; +import { DeleteDirectoryInputSchema } from './schema'; +import type { DeleteDirectoryOutput } from './types'; + +export function createDeleteDirectory(fs: IFileSystem) { + return defineTool({ + name: 'DeleteDirectory', + description: 'Delete empty directories by path. Pass paths directly as { content: { type: "files", values: ["./path"] } } or pipe Find output into this tool. Directories must be empty — delete files first.', + operation: 'delete', + input_schema: DeleteDirectoryInputSchema, + input_examples: [{ content: { type: 'files', values: ['./src/OldDir'] } }], + handler: async (input): Promise => + deleteBatch( + input.content.values, + (path) => fs.deleteDirectory(path), + (err) => { + if (isNodeError(err, 'ENOENT')) { + return 'Directory not found'; + } + if (isNodeError(err, 'ENOTDIR')) { + return 'Path is not a directory \u2014 use DeleteFile instead'; + } + if (isNodeError(err, 'ENOTEMPTY')) { + return 'Directory is not empty. Delete the files inside first.'; + } + }, + ), + }); +} diff --git a/packages/claude-sdk-tools/src/DeleteDirectory/schema.ts b/packages/claude-sdk-tools/src/DeleteDirectory/schema.ts new file mode 100644 index 0000000..4368976 --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteDirectory/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { DeleteOutputSchema, DeleteResultSchema } from '../deleteBatch'; +import { PipeFilesSchema } from '../pipe'; + +export const DeleteDirectoryInputSchema = z.object({ + content: PipeFilesSchema.describe('Pipe input. Directory paths to delete, typically piped from Find. Directories must be empty.'), +}); + +export { DeleteOutputSchema as DeleteDirectoryOutputSchema, DeleteResultSchema as DeleteDirectoryResultSchema }; diff --git a/packages/claude-sdk-tools/src/DeleteDirectory/types.ts b/packages/claude-sdk-tools/src/DeleteDirectory/types.ts new file mode 100644 index 0000000..8beee2e --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteDirectory/types.ts @@ -0,0 +1,6 @@ +import type { z } from 'zod'; +import type { DeleteDirectoryInputSchema, DeleteDirectoryOutputSchema, DeleteDirectoryResultSchema } from './schema'; + +export type DeleteDirectoryInput = z.output; +export type DeleteDirectoryOutput = z.infer; +export type DeleteDirectoryResult = z.infer; diff --git a/packages/claude-sdk-tools/src/DeleteFile/DeleteFile.ts b/packages/claude-sdk-tools/src/DeleteFile/DeleteFile.ts new file mode 100644 index 0000000..85bea0a --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteFile/DeleteFile.ts @@ -0,0 +1,29 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { deleteBatch } from '../deleteBatch'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { isNodeError } from '../isNodeError'; +import { DeleteFileInputSchema } from './schema'; +import type { DeleteFileOutput } from './types'; + +export function createDeleteFile(fs: IFileSystem) { + return defineTool({ + name: 'DeleteFile', + operation: 'delete', + description: 'Delete files by path. Pass paths directly as { content: { type: "files", values: ["./path"] } } or pipe Find output into this tool.', + input_schema: DeleteFileInputSchema, + input_examples: [{ content: { type: 'files', values: ['./src/OldFile.ts'] } }], + handler: async (input): Promise => + deleteBatch( + input.content.values, + (path) => fs.deleteFile(path), + (err) => { + if (isNodeError(err, 'ENOENT')) { + return 'File not found'; + } + if (isNodeError(err, 'EISDIR')) { + return 'Path is a directory \u2014 use DeleteDirectory instead'; + } + }, + ), + }); +} diff --git a/packages/claude-sdk-tools/src/DeleteFile/schema.ts b/packages/claude-sdk-tools/src/DeleteFile/schema.ts new file mode 100644 index 0000000..5e34c4e --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteFile/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { DeleteOutputSchema, DeleteResultSchema } from '../deleteBatch'; +import { PipeFilesSchema } from '../pipe'; + +export const DeleteFileInputSchema = z.object({ + content: PipeFilesSchema.describe('Pipe input. Paths to delete, typically piped from Find.'), +}); + +export { DeleteOutputSchema as DeleteFileOutputSchema, DeleteResultSchema as DeleteFileResultSchema }; diff --git a/packages/claude-sdk-tools/src/DeleteFile/types.ts b/packages/claude-sdk-tools/src/DeleteFile/types.ts new file mode 100644 index 0000000..854396f --- /dev/null +++ b/packages/claude-sdk-tools/src/DeleteFile/types.ts @@ -0,0 +1,6 @@ +import type { z } from 'zod'; +import type { DeleteFileInputSchema, DeleteFileOutputSchema, DeleteFileResultSchema } from './schema'; + +export type DeleteFileInput = z.output; +export type DeleteFileOutput = z.infer; +export type DeleteFileResult = z.infer; diff --git a/packages/claude-sdk-tools/src/EditFile/ConfirmEditFile.ts b/packages/claude-sdk-tools/src/EditFile/ConfirmEditFile.ts new file mode 100644 index 0000000..6ced45e --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/ConfirmEditFile.ts @@ -0,0 +1,39 @@ +import { createHash } from 'node:crypto'; +import { defineTool } from '@shellicar/claude-sdk'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { EditFileInputSchema, EditFileOutputSchema } from './schema'; +import type { PreviewEditOutputType } from './types'; + +export function createEditFile(fs: IFileSystem, store: Map) { + return defineTool({ + name: 'EditFile', + description: 'Apply a staged edit after reviewing the diff.', + operation: 'write', + input_schema: EditFileInputSchema, + input_examples: [ + { + patchId: '2b9cfd39-7f29-4911-8cb2-ef4454635e51', + file: '/path/to/file.ts', + }, + ], + handler: async ({ patchId, file }) => { + const chained = store.get(patchId); + if (chained == null) { + throw new Error('Staged preview not found. The patch store is in-memory — please run PreviewEdit again.'); + } + if (file !== chained.file) { + throw new Error(`File mismatch: input has "${file}" but patch is for "${chained.file}"`); + } + const currentContent = await fs.readFile(chained.file); + const currentHash = createHash('sha256').update(currentContent).digest('hex'); + if (currentHash !== chained.originalHash) { + throw new Error(`File ${chained.file} has been modified since the edit was staged`); + } + await fs.writeFile(chained.file, chained.newContent); + const diffLines = chained.diff.split('\n'); + const linesAdded = diffLines.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length; + const linesRemoved = diffLines.filter((l) => l.startsWith('-') && !l.startsWith('---')).length; + return EditFileOutputSchema.parse({ linesAdded, linesRemoved }); + }, + }); +} diff --git a/packages/claude-sdk-tools/src/EditFile/EditFile.ts b/packages/claude-sdk-tools/src/EditFile/EditFile.ts new file mode 100644 index 0000000..45fad1f --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/EditFile.ts @@ -0,0 +1,185 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { relative, resolve, sep } from 'node:path'; +import { defineTool } from '@shellicar/claude-sdk'; +import { expandPath } from '../expandPath'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { applyEdits } from './applyEdits'; +import { generateDiff } from './generateDiff'; +import { PreviewEditInputSchema, PreviewEditOutputSchema } from './schema'; +import type { EditOperationType, PreviewEditOutputType, ResolvedEditOperationType } from './types'; +import { validateEdits } from './validateEdits'; + +/** + * Given two versions of a file split into lines, return a minimal set of + * line-based operations (replace / delete / insert) that transforms the + * original into the new content. The algorithm finds the longest common + * prefix and suffix and emits a single operation for the changed middle + * region, which is sufficient for all replace_text use-cases. + */ +function findChangedRegions(originalLines: string[], newLines: string[]): ResolvedEditOperationType[] { + if (originalLines.join('\n') === newLines.join('\n')) { + return []; + } + + let start = 0; + while (start < originalLines.length && start < newLines.length && originalLines[start] === newLines[start]) { + start++; + } + + let endOrig = originalLines.length - 1; + let endNew = newLines.length - 1; + while (endOrig > start && endNew > start && originalLines[endOrig] === newLines[endNew]) { + endOrig--; + endNew--; + } + + if (endOrig < start) { + // Pure insertion between lines + return [{ action: 'insert', after_line: start, content: newLines.slice(start, endNew + 1).join('\n') }]; + } + if (endNew < start) { + // Pure deletion + return [{ action: 'delete', startLine: start + 1, endLine: endOrig + 1 }]; + } + // Replace (covers single-line changes, line-count-changing replacements, etc.) + return [{ action: 'replace', startLine: start + 1, endLine: endOrig + 1, content: newLines.slice(start, endNew + 1).join('\n') }]; +} + +/** + * Convert an absolute file path to a display-friendly path relative to cwd + * when it falls under the current working directory, otherwise return as-is. + * This avoids the double-slash issue when passing absolute paths to + * `createTwoFilesPatch` which prepends "a/" and "b/". + */ +function toDisplayPath(absolutePath: string): string { + const cwd = process.cwd(); + const resolved = resolve(absolutePath); + if (resolved === cwd || resolved.startsWith(cwd + sep)) { + return relative(cwd, resolved); + } + return resolved; +} + +/** + * Resolve any `replace_text` or `regex_text` operations in `edits` into equivalent + * line-based operations. All other operation types are passed through + * unchanged. Each replace_text edit is applied against the accumulated + * result of all previous replace_text edits so that multiple ops on the + * same file chain correctly. + */ +function resolveReplaceText(originalContent: string, edits: EditOperationType[]): ResolvedEditOperationType[] { + const explicitOps: ResolvedEditOperationType[] = []; + let currentContent = originalContent; + + for (const edit of edits) { + if (edit.action !== 'replace_text' && edit.action !== 'regex_text') { + explicitOps.push(edit); + continue; + } + + const pattern = edit.action === 'regex_text' ? edit.pattern : edit.oldString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const matches = [...currentContent.matchAll(new RegExp(pattern, 'g'))]; + + if (matches.length === 0) { + throw new Error(`${edit.action}: pattern "${pattern}" not found in file`); + } + if (matches.length > 1 && !edit.replaceMultiple) { + throw new Error(`${edit.action}: pattern "${pattern}" matched ${matches.length} times — set replaceMultiple: true to replace all`); + } + + // replace_text: use a replacer function so $ in the replacement is never interpreted + // specially by String.prototype.replace (which treats $$ $& $1 etc. as special patterns). + // regex_text keeps the string form so $1, $&, $$ etc. work as documented. + const replacer = edit.action === 'replace_text' ? () => edit.replacement : edit.replacement; + currentContent = currentContent.replace(new RegExp(pattern, edit.replaceMultiple ? 'g' : ''), replacer as string); + } + + if (currentContent !== originalContent) { + explicitOps.push(...findChangedRegions(originalContent.split('\n'), currentContent.split('\n'))); + } + return explicitOps; +} + +export function createPreviewEdit(fs: IFileSystem, store: Map) { + return defineTool({ + name: 'PreviewEdit', + description: 'Preview edits to a file. Returns a diff for review — does not write to disk.', + operation: 'read', + input_schema: PreviewEditInputSchema, + input_examples: [ + { + file: '/path/to/file.ts', + edits: [{ action: 'insert', after_line: 0, content: '// hello world' }], + }, + { + file: '/path/to/file.ts', + edits: [{ action: 'replace', startLine: 5, endLine: 7, content: 'const x = 1;' }], + }, + { + file: '/path/to/file.ts', + edits: [{ action: 'delete', startLine: 10, endLine: 12 }], + }, + { + file: '/path/to/file.ts', + edits: [ + { action: 'delete', startLine: 3, endLine: 3 }, + { action: 'replace', startLine: 8, endLine: 9, content: 'export default foo;' }, + ], + }, + { + file: '/path/to/file.ts', + edits: [{ action: 'regex_text', pattern: 'import type \\{ (\\w+) \\}', replacement: 'import { $1 }' }], + }, + { + file: '/path/to/file.ts', + edits: [{ action: 'replace_text', oldString: 'import type { MyClass }', replacement: 'import { MyClass }' }], + }, + ], + handler: async (input) => { + const filePath = expandPath(input.file, fs); + + let baseContent: string; + let originalHash: string; + if (input.previousPatchId != null) { + const prev = store.get(input.previousPatchId); + if (!prev) { + throw new Error('Previous patch not found. The patch store is in-memory — please run PreviewEdit again.'); + } + if (prev.file !== filePath) { + throw new Error(`File mismatch: previousPatchId is for "${prev.file}" but this edit targets "${filePath}"`); + } + baseContent = prev.newContent; + // Inherit the root originalHash rather than hashing prev.newContent. + // This means any patch in the chain can be applied directly to the original + // disk file (EditFile always checks disk == originalHash before writing). + // The trade-off: patches cannot be applied incrementally in sequence — + // EditFile(patch1) then EditFile(patch2) will fail because after patch1 + // is written, disk no longer matches the inherited hash. + // If incremental sequential application were needed, each patch would + // store hash(prev.newContent) instead, but the practical use case + // is unclear given that EditFile(finalPatch) already writes everything. + originalHash = prev.originalHash; + } else { + baseContent = await fs.readFile(filePath); + originalHash = createHash('sha256').update(baseContent).digest('hex'); + } + + const baseLines = baseContent.split('\n'); + const resolvedEdits = resolveReplaceText(baseContent, input.edits); + validateEdits(baseLines, resolvedEdits); + const newLines = applyEdits(baseLines, resolvedEdits); + const newContent = newLines.join('\n'); + const diff = generateDiff(toDisplayPath(filePath), baseContent, newContent); + const output = PreviewEditOutputSchema.parse({ + patchId: randomUUID(), + diff, + file: filePath, + newContent, + originalHash, + }); + store.set(output.patchId, output); + return output; + }, + }); +} diff --git a/apps/claude-sdk-cli/src/tools/edit/applyEdits.ts b/packages/claude-sdk-tools/src/EditFile/applyEdits.ts similarity index 51% rename from apps/claude-sdk-cli/src/tools/edit/applyEdits.ts rename to packages/claude-sdk-tools/src/EditFile/applyEdits.ts index 955c4c0..e1207c1 100644 --- a/apps/claude-sdk-cli/src/tools/edit/applyEdits.ts +++ b/packages/claude-sdk-tools/src/EditFile/applyEdits.ts @@ -1,15 +1,9 @@ -import type { EditOperationType } from './types'; - -export function applyEdits(lines: string[], edits: EditOperationType[]): string[] { - const sorted = [...edits].sort((a, b) => { - const aLine = a.action === 'insert' ? a.after_line : a.startLine; - const bLine = b.action === 'insert' ? b.after_line : b.startLine; - return bLine - aLine; - }); +import type { ResolvedEditOperationType } from './types'; +export function applyEdits(lines: string[], edits: ResolvedEditOperationType[]): string[] { const result = [...lines]; - for (const edit of sorted) { + for (const edit of edits) { if (edit.action === 'replace') { result.splice(edit.startLine - 1, edit.endLine - edit.startLine + 1, ...edit.content.split('\n')); } else if (edit.action === 'delete') { diff --git a/packages/claude-sdk-tools/src/EditFile/createEditFilePair.ts b/packages/claude-sdk-tools/src/EditFile/createEditFilePair.ts new file mode 100644 index 0000000..4989336 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/createEditFilePair.ts @@ -0,0 +1,12 @@ +import type { IFileSystem } from '../fs/IFileSystem'; +import { createEditFile } from './ConfirmEditFile'; +import { createPreviewEdit } from './EditFile'; +import type { PreviewEditOutputType } from './types'; + +export function createEditFilePair(fs: IFileSystem) { + const store = new Map(); + return { + previewEdit: createPreviewEdit(fs, store), + editFile: createEditFile(fs, store), + }; +} diff --git a/packages/claude-sdk-tools/src/EditFile/generateDiff.ts b/packages/claude-sdk-tools/src/EditFile/generateDiff.ts new file mode 100644 index 0000000..6fab353 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/generateDiff.ts @@ -0,0 +1,5 @@ +import { createTwoFilesPatch } from 'diff'; + +export function generateDiff(displayPath: string, originalContent: string, newContent: string): string { + return createTwoFilesPatch(`a/${displayPath}`, `b/${displayPath}`, originalContent, newContent, '', '', { context: 3 }); +} diff --git a/packages/claude-sdk-tools/src/EditFile/schema.ts b/packages/claude-sdk-tools/src/EditFile/schema.ts new file mode 100644 index 0000000..7b15559 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/schema.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +const EditFileReplaceOperationSchema = z.object({ + action: z.literal('replace'), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + content: z.string(), +}); + +const EditFileDeleteOperationSchema = z.object({ + action: z.literal('delete'), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), +}); + +const EditFileInsertOperationSchema = z.object({ + action: z.literal('insert'), + after_line: z.number().int().min(0), + content: z.string(), +}); + +const EditFileReplaceStringOperationSchema = z.object({ + action: z.literal('replace_text'), + oldString: z.string().min(1).describe('String to search for'), + replacement: z.string().describe('Replacement string.'), + replaceMultiple: z.boolean().optional().default(false).describe('If true, replace all matches. If false (default), error if more than one match is found.'), +}); + +const EditFileReplaceRegexOperationSchema = z.object({ + action: z.literal('regex_text'), + pattern: z.string().min(1).describe('Regex pattern to search for'), + replacement: z.string().describe('Replacement string. Supports capture groups ($1, $2), $& (matched text), $$ (literal $).'), + replaceMultiple: z.boolean().optional().default(false).describe('If true, replace all matches. If false (default), error if more than one match is found.'), +}); + +export const EditFileResolvedOperationSchema = z.discriminatedUnion('action', [EditFileReplaceOperationSchema, EditFileDeleteOperationSchema, EditFileInsertOperationSchema]); + +export const EditFileOperationSchema = z.discriminatedUnion('action', [EditFileReplaceOperationSchema, EditFileDeleteOperationSchema, EditFileInsertOperationSchema, EditFileReplaceStringOperationSchema, EditFileReplaceRegexOperationSchema]); + +export const PreviewEditInputSchema = z.object({ + file: z.string(), + edits: z.array(EditFileOperationSchema).min(1), + previousPatchId: z + .uuid() + .optional() + .describe( + 'If provided, chain this preview onto a previous staged patch. The previous patch\u2019s result is used as the base instead of reading from disk, and the diff shown is incremental (only the changes introduced by this preview). To apply the full accumulated result, call EditFile with the final patchId in the chain — do not call EditFile on intermediate patches before the final one, as each patch validates against the original disk state rather than the previous patch’s result.', + ), +}); + +export const PreviewEditOutputSchema = z.object({ + patchId: z.uuid(), + diff: z.string(), + file: z.string(), + newContent: z.string(), + originalHash: z.string(), +}); + +export const EditFileInputSchema = z.object({ + patchId: z.uuid(), + file: z.string().describe('Path of the file being edited. Must match the file from the corresponding PreviewEdit call.'), +}); + +export const EditFileOutputSchema = z.object({ + linesAdded: z.number().int().nonnegative(), + linesRemoved: z.number().int().nonnegative(), +}); diff --git a/packages/claude-sdk-tools/src/EditFile/types.ts b/packages/claude-sdk-tools/src/EditFile/types.ts new file mode 100644 index 0000000..9fc2041 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/types.ts @@ -0,0 +1,9 @@ +import type { z } from 'zod'; +import type { EditFileInputSchema, EditFileOperationSchema, EditFileOutputSchema, EditFileResolvedOperationSchema, PreviewEditInputSchema, PreviewEditOutputSchema } from './schema'; + +export type PreviewEditInputType = z.infer; +export type PreviewEditOutputType = z.infer; +export type EditFileInputType = z.infer; +export type EditFileOutputType = z.infer; +export type EditOperationType = z.infer; +export type ResolvedEditOperationType = z.infer; diff --git a/packages/claude-sdk-tools/src/EditFile/validateEdits.ts b/packages/claude-sdk-tools/src/EditFile/validateEdits.ts new file mode 100644 index 0000000..3839353 --- /dev/null +++ b/packages/claude-sdk-tools/src/EditFile/validateEdits.ts @@ -0,0 +1,39 @@ +import type { ResolvedEditOperationType } from './types'; + +export function validateEdits(lines: string[], edits: ResolvedEditOperationType[]): void { + const getLines = (edit: ResolvedEditOperationType) => { + switch (edit.action) { + case 'insert': + case 'replace': { + return edit.content.split('\n').length; + } + case 'delete': { + return 0; + } + } + }; + + let currentLintCount = lines.length; + + for (const edit of edits) { + const lines = getLines(edit); + currentLintCount += lines; + if (edit.action === 'insert') { + if (edit.after_line > currentLintCount) { + throw new Error(`insert after_line ${edit.after_line} out of bounds (file has ${currentLintCount} lines)`); + } + } else { + if (edit.startLine > currentLintCount) { + throw new Error(`${edit.action} startLine ${edit.startLine} out of bounds (file has ${currentLintCount} lines)`); + } + if (edit.endLine > currentLintCount) { + throw new Error(`${edit.action} endLine ${edit.endLine} out of bounds (file has ${currentLintCount} lines)`); + } + if (edit.startLine > edit.endLine) { + throw new Error(`${edit.action} startLine ${edit.startLine} is greater than endLine ${edit.endLine}`); + } + const removed = edit.endLine - edit.startLine + 1; + currentLintCount -= removed; + } + } +} diff --git a/packages/claude-sdk-tools/src/Exec/Exec.ts b/packages/claude-sdk-tools/src/Exec/Exec.ts new file mode 100644 index 0000000..d44489b --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/Exec.ts @@ -0,0 +1,57 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { builtinRules } from './builtinRules'; +import { execute } from './execute'; +import { normaliseInput } from './normaliseInput'; +import { ExecInputSchema, ExecToolDescription } from './schema'; +import { stripAnsi } from './stripAnsi'; +import type { ExecOutput } from './types'; +import { validate } from './validate'; + +export function createExec(fs: IFileSystem) { + return defineTool({ + name: 'Exec', + operation: 'write', + description: ExecToolDescription, + input_schema: ExecInputSchema, + input_examples: [ + { + description: 'Run tests', + steps: [{ commands: [{ program: 'pnpm', args: ['test'] }] }], + }, + { + description: 'Check git status', + steps: [{ commands: [{ program: 'git', args: ['status'] }] }], + }, + { + description: 'Run tests in a specific package', + steps: [{ commands: [{ program: 'pnpm', args: ['test'], cwd: '~/repos/my-project/packages/my-pkg' }] }], + }, + ], + handler: async (input): Promise => { + const cwd = process.cwd(); + const normalised = normaliseInput(input, fs); + const allCommands = normalised.steps.flatMap((s) => s.commands); + const { allowed, errors } = validate(allCommands, builtinRules); + + if (!allowed) { + return { + results: [{ stdout: '', stderr: `BLOCKED:\n${errors.join('\n')}`, exitCode: 1, signal: null }], + success: false, + }; + } + + const result = await execute(normalised, cwd); + const clean = input.stripAnsi ? stripAnsi : (s: string) => s; + + return { + results: result.results.map((r) => ({ + ...r, + stdout: clean(r.stdout).trimEnd(), + stderr: clean(r.stderr).trimEnd(), + })), + success: result.success, + }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Exec/builtinRules.ts b/packages/claude-sdk-tools/src/Exec/builtinRules.ts new file mode 100644 index 0000000..8dc9548 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/builtinRules.ts @@ -0,0 +1,154 @@ +import { hasShortFlag } from './hasShortFlag'; +import type { ExecRule } from './types'; + +export const builtinRules: ExecRule[] = [ + { + name: 'no-destructive-commands', + check: (commands) => { + const blocked = new Set(['rm', 'rmdir', 'mkfs', 'dd', 'shred']); + for (const cmd of commands) { + if (blocked.has(cmd.program)) { + return `'${cmd.program}' is destructive and irreversible. Ask the user to run it directly.`; + } + } + return undefined; + }, + }, + { + name: 'no-xargs', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'xargs') { + return 'xargs can execute arbitrary commands on piped input. Write commands explicitly, or use Glob/Grep tools.'; + } + } + return undefined; + }, + }, + { + name: 'no-sed-in-place', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'sed') { + if (cmd.args.includes('--in-place') || hasShortFlag(cmd.args, 'i')) { + return 'sed -i modifies files in-place with no undo. Use the redirect option to write to a new file, or use the Edit tool.'; + } + } + } + return undefined; + }, + }, + { + name: 'no-git-rm', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && cmd.args.includes('rm')) { + return 'git rm is destructive and irreversible. Ask the user to run it directly.'; + } + } + return undefined; + }, + }, + { + name: 'no-git-checkout', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && cmd.args.includes('checkout')) { + return 'git checkout can discard uncommitted changes with no undo. Use "git switch" for branches, or ask the user to run it directly.'; + } + } + return undefined; + }, + }, + { + name: 'no-git-reset', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && cmd.args.includes('reset')) { + return 'git reset is destructive and irreversible. Ask the user to run it directly.'; + } + } + return undefined; + }, + }, + { + name: 'no-force-push', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && cmd.args.includes('push')) { + if (cmd.args.some((a) => a === '-f' || a.startsWith('--force'))) { + return 'Force push overwrites remote history with no undo. Use regular "git push", or ask the user to run it directly.'; + } + } + } + return undefined; + }, + }, + { + name: 'no-exe', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program.endsWith('.exe')) { + return `'${cmd.program}' — there is no reason to call .exe. Run equivalent commands natively.`; + } + } + return undefined; + }, + }, + { + name: 'no-sudo', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'sudo') { + return 'sudo is not permitted. Run commands directly.'; + } + } + return undefined; + }, + }, + { + name: 'no-git-C', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && hasShortFlag(cmd.args, 'C')) { + return 'git -C changes the working directory and bypasses auto-approve path checks. Use cwd instead.'; + } + } + return undefined; + }, + }, + { + name: 'no-pnpm-C', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'pnpm' && hasShortFlag(cmd.args, 'C')) { + return 'pnpm -C changes the working directory and bypasses auto-approve path checks. Use cwd instead.'; + } + } + return undefined; + }, + }, + { + name: 'no-env-dump', + check: (commands) => { + const blocked = new Set(['env', 'printenv']); + for (const cmd of commands) { + if (blocked.has(cmd.program) && cmd.args.length === 0) { + return `'${cmd.program}' without arguments would dump all environment variables. Specify which variable to read.`; + } + } + return undefined; + }, + }, + { + name: 'no-git-clean', + check: (commands) => { + for (const cmd of commands) { + if (cmd.program === 'git' && cmd.args.includes('clean')) { + return 'git clean deletes untracked files with no undo. Ask the user to run it directly.'; + } + } + return undefined; + }, + }, +]; diff --git a/packages/claude-sdk-tools/src/Exec/execCommand.ts b/packages/claude-sdk-tools/src/Exec/execCommand.ts new file mode 100644 index 0000000..8251b1d --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/execCommand.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; +import { createWriteStream, existsSync } from 'node:fs'; +import type { Command, StepResult } from './types'; + +/** Execute a single command via child_process.spawn (no shell). */ +export function execCommand(cmd: Command, cwd: string, timeoutMs?: number): Promise { + const resolvedCwd = cmd.cwd ?? cwd; + + if (!existsSync(resolvedCwd)) { + return Promise.resolve({ + stdout: '', + stderr: `Working directory not found: ${resolvedCwd}`, + exitCode: 126, + signal: null, + }); + } + + return new Promise((resolve) => { + const env = { ...process.env, ...cmd.env } satisfies NodeJS.ProcessEnv; + const child = spawn(cmd.program, cmd.args ?? [], { + cwd: resolvedCwd, + env, + stdio: 'pipe', + timeout: timeoutMs, + }); + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + const redirectingStdout = cmd.redirect && (cmd.redirect.stream === 'stdout' || cmd.redirect.stream === 'both'); + const redirectingStderr = cmd.redirect && (cmd.redirect.stream === 'stderr' || cmd.redirect.stream === 'both'); + + if (!redirectingStdout) { + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + } + if (!redirectingStderr) { + child.stderr.on('data', (chunk: Buffer) => (cmd.merge_stderr ? stdout : stderr).push(chunk)); + } + + if (cmd.stdin !== undefined) { + child.stdin.write(cmd.stdin); + child.stdin.end(); + } else { + child.stdin.end(); + } + + if (cmd.redirect) { + const flags = cmd.redirect.append ? 'a' : 'w'; + const stream = createWriteStream(cmd.redirect.path, { flags }); + const target = cmd.redirect.stream; + if (target === 'stdout' || target === 'both') { + child.stdout.pipe(stream); + } + if (target === 'stderr' || target === 'both') { + child.stderr.pipe(stream); + } + } + + child.on('close', (code, signal) => { + resolve({ + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: Buffer.concat(stderr).toString('utf-8'), + exitCode: code, + signal: signal ?? null, + }); + }); + + child.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + resolve({ + stdout: '', + stderr: `Command not found: ${cmd.program}`, + exitCode: 127, + signal: null, + }); + } else { + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + signal: null, + }); + } + }); + }); +} diff --git a/packages/claude-sdk-tools/src/Exec/execPipeline.ts b/packages/claude-sdk-tools/src/Exec/execPipeline.ts new file mode 100644 index 0000000..c310d51 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/execPipeline.ts @@ -0,0 +1,130 @@ +import { spawn } from 'node:child_process'; +import { createWriteStream, existsSync } from 'node:fs'; +import { PassThrough } from 'node:stream'; +import type { PipelineCommands, StepResult } from './types'; + +/** Execute a pipeline of commands with stdout→stdin piping. */ +export async function execPipeline(commands: PipelineCommands, cwd: string, timeoutMs?: number): Promise { + if (commands.length === 0) { + return { stdout: '', stderr: '', exitCode: 0, signal: null }; + } + + if (!existsSync(cwd)) { + return { stdout: '', stderr: `Working directory not found: ${cwd}`, exitCode: 126, signal: null }; + } + + for (const cmd of commands) { + const cmdCwd = cmd.cwd ?? cwd; + if (!existsSync(cmdCwd)) { + return { stdout: '', stderr: `Working directory not found: ${cmdCwd}`, exitCode: 126, signal: null }; + } + } + + return new Promise((resolve) => { + const children = commands.map((cmd, i) => { + const cmdCwd = cmd.cwd ?? cwd; + const child = spawn(cmd.program, cmd.args ?? [], { + cwd: cmdCwd, + env: cmd.env ? { ...process.env, ...cmd.env } : process.env, + stdio: 'pipe', + timeout: timeoutMs, + }); + + if (i === 0 && cmd.stdin !== undefined) { + child.stdin.write(cmd.stdin); + child.stdin.end(); + } else if (i === 0) { + child.stdin.end(); + } + + return child; + }); + + // Connect pipes: stdout (and optionally stderr) of each → stdin of next + for (let i = 0; i < children.length - 1; i++) { + const curr = children[i]; + const currCmd = commands[i]; + const next = children[i + 1]; + if (curr !== undefined && next !== undefined) { + if (currCmd?.merge_stderr) { + const merged = new PassThrough(); + curr.stdout.pipe(merged); + curr.stderr.pipe(merged); + merged.pipe(next.stdin); + } else { + curr.stdout.pipe(next.stdin); + } + } + } + + const lastChild = children[children.length - 1]; + const lastCmd = commands[commands.length - 1]; + if (lastChild === undefined || lastCmd === undefined) { + resolve({ stdout: '', stderr: '', exitCode: 0, signal: null }); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + lastChild.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + + for (let i = 0; i < children.length; i++) { + const isMerged = commands[i]?.merge_stderr && i < children.length - 1; + if (!isMerged) { + children[i]?.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + } + } + + const intermediateErrors: string[] = []; + for (let i = 0; i < children.length - 1; i++) { + const childIdx = i; + children[i]?.on('error', (err: NodeJS.ErrnoException) => { + const program = commands[childIdx]?.program ?? ''; + const msg = err.code === 'ENOENT' ? `Command not found: ${program}` : err.message; + intermediateErrors.push(msg); + children[childIdx + 1]?.stdin.end(); + }); + } + if (lastCmd.redirect) { + const flags = lastCmd.redirect.append ? 'a' : 'w'; + const stream = createWriteStream(lastCmd.redirect.path, { flags }); + const target = lastCmd.redirect.stream; + if (target === 'stdout' || target === 'both') { + lastChild.stdout.pipe(stream); + } + if (target === 'stderr' || target === 'both') { + lastChild.stderr.pipe(stream); + } + } + + lastChild.on('close', (code, signal) => { + const lastStderr = Buffer.concat(stderr).toString('utf-8'); + const combinedStderr = [lastStderr, ...intermediateErrors].filter(Boolean).join('\n'); + resolve({ + stdout: Buffer.concat(stdout).toString('utf-8'), + stderr: combinedStderr, + exitCode: intermediateErrors.length > 0 ? 127 : code, + signal: signal ?? null, + }); + }); + + lastChild.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + resolve({ + stdout: '', + stderr: `Command not found: ${lastCmd.program}`, + exitCode: 127, + signal: null, + }); + } else { + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + signal: null, + }); + } + }); + }); +} diff --git a/packages/claude-sdk-tools/src/Exec/execStep.ts b/packages/claude-sdk-tools/src/Exec/execStep.ts new file mode 100644 index 0000000..5109291 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/execStep.ts @@ -0,0 +1,12 @@ +import { execCommand } from './execCommand'; +import { execPipeline } from './execPipeline'; +import type { Step, StepResult } from './types'; + +/** Execute a single step: one command runs directly, two or more form a pipeline. */ +export async function execStep(step: Step, cwd: string, timeoutMs?: number): Promise { + const [first, second, ...rest] = step.commands; + if (second == null) { + return execCommand(first, cwd, timeoutMs); + } + return execPipeline([first, second, ...rest], cwd, timeoutMs); +} diff --git a/packages/claude-sdk-tools/src/Exec/execute.ts b/packages/claude-sdk-tools/src/Exec/execute.ts new file mode 100644 index 0000000..a5062cd --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/execute.ts @@ -0,0 +1,26 @@ +import { execStep } from './execStep'; +import type { ExecInput, ExecOutput } from './types'; + +/** Execute all steps according to the chaining strategy. */ +export async function execute(input: ExecInput, cwd: string): Promise { + // independent: all steps run concurrently — no step waits for another + if (input.chaining === 'independent') { + const results = await Promise.all(input.steps.map((step) => execStep(step, cwd, input.timeout))); + const success = results.every((r) => r.exitCode === 0); + return { results, success }; + } + + // sequential / bail_on_error: steps run one at a time + const results = []; + for (const step of input.steps) { + const result = await execStep(step, cwd, input.timeout); + results.push(result); + + if (input.chaining === 'bail_on_error' && result.exitCode !== 0) { + return { results, success: false }; + } + } + + const success = results.every((r) => r.exitCode === 0); + return { results, success }; +} diff --git a/packages/claude-sdk-tools/src/Exec/hasShortFlag.ts b/packages/claude-sdk-tools/src/Exec/hasShortFlag.ts new file mode 100644 index 0000000..7b4e64a --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/hasShortFlag.ts @@ -0,0 +1,4 @@ +/** Check if a short flag character appears in any arg (handles combined flags like -ni, -Ei). */ +export function hasShortFlag(args: string[], flag: string): boolean { + return args.some((a) => a === `-${flag}` || (a.startsWith('-') && !a.startsWith('--') && a.includes(flag))); +} diff --git a/packages/claude-sdk-tools/src/Exec/normaliseCommand.ts b/packages/claude-sdk-tools/src/Exec/normaliseCommand.ts new file mode 100644 index 0000000..67d32c2 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/normaliseCommand.ts @@ -0,0 +1,13 @@ +import { expandPath } from '../expandPath'; +import type { IFileSystem } from '../fs/IFileSystem'; +import type { Command } from './types'; + +export function normaliseCommand(cmd: Command, fs: IFileSystem): Command { + const { program, cwd, redirect, ...rest } = cmd; + return { + ...rest, + program: expandPath(program, fs), + cwd: expandPath(cwd, fs), + redirect: redirect && { ...redirect, path: expandPath(redirect.path, fs) }, + }; +} diff --git a/packages/claude-sdk-tools/src/Exec/normaliseInput.ts b/packages/claude-sdk-tools/src/Exec/normaliseInput.ts new file mode 100644 index 0000000..393220e --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/normaliseInput.ts @@ -0,0 +1,14 @@ +import type { IFileSystem } from '../fs/IFileSystem'; +import { normaliseCommand } from './normaliseCommand'; +import type { Command, ExecInput } from './types'; + +/** Expand ~ and $VAR in path-like fields (program, cwd, redirect.path) before validation and execution. */ +export function normaliseInput(input: ExecInput, fs: IFileSystem): ExecInput { + return { + ...input, + steps: input.steps.map((step) => ({ + ...step, + commands: step.commands.map((cmd) => normaliseCommand(cmd, fs)) as [Command, ...Command[]], + })), + }; +} diff --git a/packages/claude-sdk-tools/src/Exec/schema.ts b/packages/claude-sdk-tools/src/Exec/schema.ts new file mode 100644 index 0000000..6480879 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/schema.ts @@ -0,0 +1,111 @@ +import { z } from 'zod'; +import type { Command } from './types'; + +// --- Redirect: structured output redirection --- +export const RedirectSchema = z + .object({ + path: z + .string() + .describe('File path to redirect output to. Supports ~ and $VAR expansion.') + .meta({ examples: ['/tmp/output.txt', '~/build.log'] }), + stream: z.enum(['stdout', 'stderr', 'both']).default('stdout').describe('Which output stream to redirect'), + append: z.boolean().default(false).describe('Append to file instead of overwriting'), + }) + .strict(); + +// --- Atomic command: one program invocation --- +export const CommandSchema = z + .object({ + program: z + .string() + .describe('The program, binary, or script path to execute. Supports ~ and $VAR expansion. Must be on $PATH or an absolute path — no shell expansion of globs or operators.') + .meta({ examples: ['git', 'node', '~/.local/bin/script.sh'] }), + args: z + .array(z.string()) + .default([]) + .describe('Arguments to the program. Each argument is a separate string — no shell quoting or escaping needed. Note: ~ and $VAR are NOT expanded in args. Use absolute paths or let the program resolve them.') + .meta({ examples: [['status'], ['commit', '-m', 'Fix bug'], ['--filter', 'mcp-exec', 'build']] }), + stdin: z + .string() + .optional() + .describe('Content to pipe to stdin. Use instead of heredocs.') + .meta({ examples: ['console.log(process.version)', '{"key":"value"}'] }), + redirect: RedirectSchema.optional().describe('Redirect output to a file'), + cwd: z + .string() + .optional() + .describe('Working directory for this command. Supports ~ and $VAR expansion.') + .meta({ examples: ['~/projects/my-app', '/home/user/repos/api', '$HOME/workspace'] }), + env: z + .record(z.string(), z.string()) + .optional() + .describe('Environment variables to set for this command.') + .meta({ examples: [{ NODE_ENV: 'production' }, { NO_COLOR: '1', FORCE_COLOR: '0' }] }), + merge_stderr: z.boolean().default(false).describe('Merge stderr into stdout (equivalent to 2>&1). Combined output appears in stdout; stderr will be empty.'), + }) + .strict(); + +// --- Step: one or more commands (1 = single command, 2+ = pipeline) --- +export const StepSchema = z + .object({ + commands: z + .array(CommandSchema) + .min(1) + .transform((x) => x as [Command, ...Command[]]) + .describe('Commands to execute. A single command runs directly; two or more commands are connected as a pipeline (stdout → stdin).') + .meta({ + examples: [ + [{ program: 'git', args: ['status'] }], + [ + { program: 'echo', args: ['hello'] }, + { program: 'wc', args: ['-w'] }, + ], + ], + }), + }) + .strict(); + +// --- Tool-level description (passed to registerTool, not embedded in inputSchema) --- +export const ExecToolDescription = `Use this instead of the \`Bash\` tool. +Execute commands with structured input. No shell syntax needed. +Example: {"description": "Human readable description","steps": [{"commands": [{ "program": "git", "args": ["status"], "cwd": "/path" }]}]}`; + +// --- The full tool input schema --- +export const ExecInputSchema = z + .object({ + description: z + .string() + .describe('Human-readable summary of what these commands do, so the user can understand the intent at a glance.') + .meta({ examples: ['Check git status', 'Build and run tests', 'Find all TypeScript errors'] }), + steps: z.array(StepSchema).min(1).describe('Commands to execute in order'), + chaining: z.enum(['sequential', 'independent', 'bail_on_error']).default('bail_on_error').describe('sequential: run all (;). bail_on_error: stop on first failure (&&). independent: run all, report each.'), + timeout: z + .number() + .max(600000) + .optional() + .describe('Timeout in ms (max 600000)') + .meta({ examples: [30000, 120000, 300000] }), + stripAnsi: z.boolean().default(true).describe('Strip ANSI escape codes from output (default: true). Set false to preserve raw color/formatting codes.'), + }) + .strict(); + +export const StepResultSchema = z.object({ + stdout: z.string(), + stderr: z.string(), + exitCode: z.number().int().nullable(), + signal: z.string().nullable(), +}); + +export const ExecuteResultSchema = z.object({ + step: z.number().int(), + command: z.string(), + exitCode: z.number().int().optional(), + stdout: z.string().optional(), + stderr: z.string().optional(), + signal: z.string().optional(), +}); + +export const ExecOutputSchema = z.object({ + results: StepResultSchema.array(), + success: z.boolean(), +}); diff --git a/packages/claude-sdk-tools/src/Exec/stripAnsi.ts b/packages/claude-sdk-tools/src/Exec/stripAnsi.ts new file mode 100644 index 0000000..3adb72a --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/stripAnsi.ts @@ -0,0 +1,10 @@ +/** + * Strip ANSI escape sequences from a string. + * Handles: SGR (colors/styles), cursor movement, erase, OSC, and other CSI sequences. + */ +// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI regex intentionally matches escape sequences +const ANSI_PATTERN = /[\u001B\u009B][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ''); +} diff --git a/packages/claude-sdk-tools/src/Exec/types.ts b/packages/claude-sdk-tools/src/Exec/types.ts new file mode 100644 index 0000000..2de6142 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/types.ts @@ -0,0 +1,33 @@ +import type { z } from 'zod'; +import type { CommandSchema, ExecInputSchema, ExecOutputSchema, ExecuteResultSchema, RedirectSchema, StepResultSchema, StepSchema } from './schema'; + +// --- Internal types --- +export type StepResult = z.infer; +export type ExecuteResult = z.infer; + +export type Redirect = z.infer; +export type Command = z.output; +export type Step = z.output; +export type PipelineCommands = [Command, Command, ...Command[]]; + +// --- Public API types --- + +/** The parsed input to the exec tool. */ +export type ExecInput = z.output; +export type ExecOutput = z.infer; + +/** A validation rule applied to each command before execution. */ +export interface ExecRule { + /** Rule name for error messages */ + name: string; + /** Return error message if blocked, undefined if allowed */ + check: (commands: Command[]) => string | undefined; +} + +/** Configuration for the exec tool and server. */ +export interface ExecConfig { + /** Working directory for command execution. Defaults to process.cwd(). */ + cwd?: string; + /** Validation rules applied before each execution. Defaults to builtinRules. */ + rules?: ExecRule[]; +} diff --git a/packages/claude-sdk-tools/src/Exec/validate.ts b/packages/claude-sdk-tools/src/Exec/validate.ts new file mode 100644 index 0000000..8fdb044 --- /dev/null +++ b/packages/claude-sdk-tools/src/Exec/validate.ts @@ -0,0 +1,13 @@ +import type { Command, ExecRule } from './types'; + +/** Validate commands against a set of rules. */ +export function validate(commands: Command[], rules: ExecRule[]): { allowed: boolean; errors: string[] } { + const errors: string[] = []; + for (const rule of rules) { + const error = rule.check(commands); + if (error) { + errors.push(`[${rule.name}] ${error}`); + } + } + return { allowed: errors.length === 0, errors }; +} diff --git a/packages/claude-sdk-tools/src/Find/Find.ts b/packages/claude-sdk-tools/src/Find/Find.ts new file mode 100644 index 0000000..f67a5c0 --- /dev/null +++ b/packages/claude-sdk-tools/src/Find/Find.ts @@ -0,0 +1,38 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { expandPath } from '../expandPath'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { isNodeError } from '../isNodeError'; +import { FindInputSchema } from './schema'; +import type { FindOutput, FindOutputSuccess } from './types'; + +export function createFind(fs: IFileSystem) { + return defineTool({ + operation: 'read', + name: 'Find', + description: 'Find files or directories. Excludes node_modules and dist by default. Output can be piped into Grep.', + input_schema: FindInputSchema, + input_examples: [{ path: '.' }, { path: './src', pattern: '*.ts' }, { path: '.', type: 'directory' }, { path: '.', pattern: '*.ts', exclude: ['dist', 'node_modules', '.git'] }], + handler: async (input) => { + const dir = expandPath(input.path, fs); + let paths: string[]; + try { + paths = await fs.find(dir, { + pattern: input.pattern, + type: input.type, + exclude: input.exclude, + maxDepth: input.maxDepth, + }); + } catch (err) { + if (isNodeError(err, 'ENOENT')) { + return { error: true, message: 'Directory not found', path: dir } satisfies FindOutput; + } + if (isNodeError(err, 'ENOTDIR')) { + return { error: true, message: 'Path is not a directory', path: dir } satisfies FindOutput; + } + throw err; + } + + return { type: 'files', values: paths } satisfies FindOutputSuccess; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Find/schema.ts b/packages/claude-sdk-tools/src/Find/schema.ts new file mode 100644 index 0000000..ca4d2c3 --- /dev/null +++ b/packages/claude-sdk-tools/src/Find/schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { PipeFilesSchema } from '../pipe'; + +export const FindOutputSuccessSchema = PipeFilesSchema; + +export const FindOutputFailureSchema = z.object({ + error: z.literal(true), + message: z.string(), + path: z.string(), +}); + +export const FindOutputSchema = z.union([FindOutputSuccessSchema, FindOutputFailureSchema]); + +export const FindInputSchema = z.object({ + path: z.string().describe('Directory to search. Supports absolute, relative, ~ and $HOME.'), + pattern: z.string().optional().describe('Glob pattern to match filenames, e.g. *.ts, *.{ts,js}'), + type: z.enum(['file', 'directory', 'both']).default('file').describe('Whether to find files, directories, or both'), + exclude: z.array(z.string()).default(['dist', 'node_modules']).describe('Directory names to exclude from search'), + maxDepth: z.number().int().min(1).optional().describe('Maximum directory depth to search'), +}); diff --git a/packages/claude-sdk-tools/src/Find/types.ts b/packages/claude-sdk-tools/src/Find/types.ts new file mode 100644 index 0000000..4af638b --- /dev/null +++ b/packages/claude-sdk-tools/src/Find/types.ts @@ -0,0 +1,7 @@ +import type { z } from 'zod'; +import type { FindInputSchema, FindOutputFailureSchema, FindOutputSchema, FindOutputSuccessSchema } from './schema'; + +export type FindInput = z.output; +export type FindOutput = z.infer; +export type FindOutputSuccess = z.infer; +export type FindOutputFailure = z.infer; diff --git a/packages/claude-sdk-tools/src/Grep/Grep.ts b/packages/claude-sdk-tools/src/Grep/Grep.ts new file mode 100644 index 0000000..6819354 --- /dev/null +++ b/packages/claude-sdk-tools/src/Grep/Grep.ts @@ -0,0 +1,41 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { collectMatchedIndices } from '../collectMatchedIndices'; +import { GrepInputSchema } from './schema'; + +export const Grep = defineTool({ + name: 'Grep', + description: 'Filter lines matching a pattern from piped content. Works on output from ReadFile (lines) or Find (file list).', + operation: 'read', + input_schema: GrepInputSchema, + input_examples: [{ pattern: 'export' }, { pattern: 'TODO', caseInsensitive: true }, { pattern: 'error', context: 2 }], + handler: async (input) => { + const flags = input.caseInsensitive ? 'i' : ''; + const regex = new RegExp(input.pattern, flags); + + if (input.content == null) { + return { type: 'content', values: [], totalLines: 0 }; + } + + if (input.content.type === 'files') { + return { + type: 'files', + values: input.content.values.filter((v) => regex.test(v)), + }; + } + + // PipeContent — filter with optional context + const values = input.content.values; + const incomingLineNumbers = input.content.lineNumbers; + const indices = collectMatchedIndices(values, regex, input.context); + const filtered = indices.map((i) => values[i]); + const lineNumbers = indices.map((i) => (incomingLineNumbers != null ? (incomingLineNumbers[i] ?? i + 1) : i + 1)); + + return { + type: 'content', + values: filtered, + totalLines: input.content.totalLines, + path: input.content.path, + lineNumbers, + }; + }, +}); diff --git a/packages/claude-sdk-tools/src/Grep/schema.ts b/packages/claude-sdk-tools/src/Grep/schema.ts new file mode 100644 index 0000000..609f629 --- /dev/null +++ b/packages/claude-sdk-tools/src/Grep/schema.ts @@ -0,0 +1,7 @@ +import { PipeInputSchema, RegexSearchOptionsSchema } from '../pipe'; + +export const GrepInputSchema = RegexSearchOptionsSchema.extend({ + content: PipeInputSchema.optional().describe('Pipe input. Provided by composition layer, not needed for standalone use.'), +}); + +export const GrepOutputSchema = PipeInputSchema; diff --git a/packages/claude-sdk-tools/src/Grep/types.ts b/packages/claude-sdk-tools/src/Grep/types.ts new file mode 100644 index 0000000..8ce9b7b --- /dev/null +++ b/packages/claude-sdk-tools/src/Grep/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { GrepInputSchema, GrepOutputSchema } from './schema'; + +export type GrepInput = z.output; +export type GrepOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/Head/Head.ts b/packages/claude-sdk-tools/src/Head/Head.ts new file mode 100644 index 0000000..2ee2a79 --- /dev/null +++ b/packages/claude-sdk-tools/src/Head/Head.ts @@ -0,0 +1,26 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { HeadInputSchema } from './schema'; + +export const Head = defineTool({ + name: 'Head', + description: 'Return the first N lines of piped content.', + operation: 'read', + input_schema: HeadInputSchema, + input_examples: [{ count: 10 }, { count: 50 }], + handler: async (input) => { + if (input.content == null) { + return { type: 'content', values: [], totalLines: 0 }; + } + if (input.content.type === 'files') { + return { type: 'files', values: input.content.values.slice(0, input.count) }; + } + const sliced = input.content.values.slice(0, input.count); + return { + type: 'content', + values: sliced, + totalLines: input.content.totalLines, + path: input.content.path, + lineNumbers: input.content.lineNumbers?.slice(0, input.count), + }; + }, +}); diff --git a/packages/claude-sdk-tools/src/Head/schema.ts b/packages/claude-sdk-tools/src/Head/schema.ts new file mode 100644 index 0000000..67cba04 --- /dev/null +++ b/packages/claude-sdk-tools/src/Head/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { PipeInputSchema } from '../pipe'; + +export const HeadInputSchema = z.object({ + count: z.number().int().min(1).default(10).describe('Number of lines to return from the start'), + content: PipeInputSchema.optional().describe('Pipe input. Provided by composition layer, not needed for standalone use.'), +}); + +export const HeadOutputSchema = PipeInputSchema; diff --git a/packages/claude-sdk-tools/src/Head/types.ts b/packages/claude-sdk-tools/src/Head/types.ts new file mode 100644 index 0000000..1dc5e3b --- /dev/null +++ b/packages/claude-sdk-tools/src/Head/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { HeadInputSchema, HeadOutputSchema } from './schema'; + +export type HeadInput = z.output; +export type HeadOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/Pipe/Pipe.ts b/packages/claude-sdk-tools/src/Pipe/Pipe.ts new file mode 100644 index 0000000..430a1c6 --- /dev/null +++ b/packages/claude-sdk-tools/src/Pipe/Pipe.ts @@ -0,0 +1,52 @@ +import { type AnyToolDefinition, defineTool } from '@shellicar/claude-sdk'; +import { PipeToolInputSchema } from './schema'; + +export function createPipe(tools: AnyToolDefinition[]) { + const registry = new Map(tools.map((t) => [t.name, t])); + + return defineTool({ + name: 'Pipe', + description: 'Execute a sequence of read tools in order, threading the output of each step into the content field of the next. Use to chain Find or ReadFile with Grep, Head, Tail, and Range in a single tool call instead of multiple round-trips. Write tools (EditFile, CreateFile, DeleteFile etc.) are not allowed.', + operation: 'read', + input_schema: PipeToolInputSchema, + input_examples: [ + { + steps: [ + { tool: 'Find', input: { path: '.' } }, + { tool: 'Grep', input: { pattern: '\\.ts$' } }, + { tool: 'Head', input: { count: 10 } }, + ], + }, + { + steps: [ + { tool: 'ReadFile', input: { path: './src/index.ts' } }, + { tool: 'Grep', input: { pattern: 'export', context: 2 } }, + ], + }, + ], + handler: async (input) => { + let pipeValue: unknown; + + for (const step of input.steps) { + const tool = registry.get(step.tool); + if (!tool) { + throw new Error(`Pipe: unknown tool "${step.tool}". Available: ${[...registry.keys()].join(', ')}`); + } + if (tool.operation !== 'read') { + throw new Error(`Pipe: tool "${step.tool}" has operation "${tool.operation ?? 'unknown'}" — only read tools may be used in a pipe`); + } + + const toolInput = pipeValue !== undefined ? { ...step.input, content: pipeValue } : step.input; + + const parseResult = tool.input_schema.safeParse(toolInput); + if (!parseResult.success) { + throw new Error(`Pipe: step "${step.tool}" input validation failed: ${parseResult.error.message}`); + } + const handler = tool.handler as (input: unknown) => Promise; + pipeValue = await handler(parseResult.data); + } + + return pipeValue; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/Pipe/schema.ts b/packages/claude-sdk-tools/src/Pipe/schema.ts new file mode 100644 index 0000000..046bdd2 --- /dev/null +++ b/packages/claude-sdk-tools/src/Pipe/schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const PipeStepSchema = z.object({ + tool: z.string().describe('Name of the tool to invoke'), + input: z.record(z.string(), z.unknown()).describe('Input for the tool. Do not include a content field — it is injected automatically from the previous step.'), +}); + +export const PipeToolInputSchema = z.object({ + steps: z.array(PipeStepSchema).min(1).describe('Sequence of tools to execute in order. The first step must be a source (Find or ReadFile). Subsequent steps are transformers (Grep, Head, Tail, Range). The content field is injected between steps automatically — do not include it in the step inputs.'), +}); diff --git a/packages/claude-sdk-tools/src/Pipe/types.ts b/packages/claude-sdk-tools/src/Pipe/types.ts new file mode 100644 index 0000000..21f46a0 --- /dev/null +++ b/packages/claude-sdk-tools/src/Pipe/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { PipeStepSchema, PipeToolInputSchema } from './schema'; + +export type PipeStep = z.infer; +export type PipeToolInput = z.infer; diff --git a/packages/claude-sdk-tools/src/Range/Range.ts b/packages/claude-sdk-tools/src/Range/Range.ts new file mode 100644 index 0000000..0a81fa0 --- /dev/null +++ b/packages/claude-sdk-tools/src/Range/Range.ts @@ -0,0 +1,29 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { RangeInputSchema } from './schema'; + +export const Range = defineTool({ + name: 'Range', + description: 'Return lines between start and end (inclusive) from piped content.', + operation: 'read', + input_schema: RangeInputSchema, + input_examples: [ + { start: 1, end: 50 }, + { start: 100, end: 200 }, + ], + handler: async (input) => { + if (input.content == null) { + return { type: 'content', values: [], totalLines: 0 }; + } + const sliced = input.content.values.slice(input.start - 1, input.end); + if (input.content.type === 'files') { + return { type: 'files', values: sliced }; + } + return { + type: 'content', + values: sliced, + totalLines: input.content.totalLines, + path: input.content.path, + lineNumbers: input.content.lineNumbers?.slice(input.start - 1, input.end), + }; + }, +}); diff --git a/packages/claude-sdk-tools/src/Range/schema.ts b/packages/claude-sdk-tools/src/Range/schema.ts new file mode 100644 index 0000000..1f8afcc --- /dev/null +++ b/packages/claude-sdk-tools/src/Range/schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { PipeInputSchema } from '../pipe'; + +export const RangeInputSchema = z.object({ + start: z.number().int().min(1).describe('1-based start position in piped values (inclusive)'), + end: z.number().int().min(1).describe('1-based end position in piped values (inclusive)'), + content: PipeInputSchema.optional().describe('Pipe input. Provided by composition layer, not needed for standalone use.'), +}); + +export const RangeOutputSchema = PipeInputSchema; diff --git a/packages/claude-sdk-tools/src/Range/types.ts b/packages/claude-sdk-tools/src/Range/types.ts new file mode 100644 index 0000000..9b47782 --- /dev/null +++ b/packages/claude-sdk-tools/src/Range/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { RangeInputSchema, RangeOutputSchema } from './schema'; + +export type RangeInput = z.output; +export type RangeOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts b/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts new file mode 100644 index 0000000..536f8b3 --- /dev/null +++ b/packages/claude-sdk-tools/src/ReadFile/ReadFile.ts @@ -0,0 +1,47 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { expandPath } from '../expandPath'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { isNodeError } from '../isNodeError'; +import { ReadFileInputSchema } from './schema'; +import type { ReadFileOutput } from './types'; + +const MAX_FILE_BYTES = 500_000; + +export function createReadFile(fs: IFileSystem) { + return defineTool({ + name: 'ReadFile', + description: 'Read a text file. Returns all lines as structured content for piping into Head, Tail, Range or Grep.', + operation: 'read', + input_schema: ReadFileInputSchema, + input_examples: [{ path: '/path/to/file.ts' }, { path: '~/file.ts' }, { path: '$HOME/file.ts' }], + handler: async (input) => { + const filePath = expandPath(input.path, fs); + let text: string; + try { + const { size } = await fs.stat(filePath); + if (size > MAX_FILE_BYTES) { + const kb = Math.round(size / 1024); + return { + error: true, + message: `File is too large to read (${kb}KB, max ${MAX_FILE_BYTES / 1000}KB). Use Head/Tail/Range for specific lines, or Grep/SearchFiles to locate content.`, + path: filePath, + } satisfies ReadFileOutput; + } + text = await fs.readFile(filePath); + } catch (err) { + if (isNodeError(err, 'ENOENT')) { + return { error: true, message: 'File not found', path: filePath } satisfies ReadFileOutput; + } + throw err; + } + + const allLines = text.split('\n'); + return { + type: 'content', + values: allLines, + totalLines: allLines.length, + path: filePath, + } satisfies ReadFileOutput; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/ReadFile/schema.ts b/packages/claude-sdk-tools/src/ReadFile/schema.ts new file mode 100644 index 0000000..f7dfd21 --- /dev/null +++ b/packages/claude-sdk-tools/src/ReadFile/schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { PipeContentSchema } from '../pipe'; + +export const ReadFileInputSchema = z.object({ + path: z.string().describe('Path to the file. Supports absolute, relative, ~ and $HOME.'), +}); + +export const ReadFileOutputSuccessSchema = PipeContentSchema; + +export const ReadFileOutputFailureSchema = z.object({ + error: z.literal(true), + message: z.string(), + path: z.string(), +}); + +export const ReadFileOutputSchema = z.union([ReadFileOutputSuccessSchema, ReadFileOutputFailureSchema]); diff --git a/packages/claude-sdk-tools/src/ReadFile/types.ts b/packages/claude-sdk-tools/src/ReadFile/types.ts new file mode 100644 index 0000000..48636af --- /dev/null +++ b/packages/claude-sdk-tools/src/ReadFile/types.ts @@ -0,0 +1,7 @@ +import type { z } from 'zod'; +import type { ReadFileInputSchema, ReadFileOutputFailureSchema, ReadFileOutputSchema, ReadFileOutputSuccessSchema } from './schema'; + +export type ReadFileInput = z.output; +export type ReadFileOutput = z.infer; +export type ReadFileOutputSuccess = z.infer; +export type ReadFileOutputFailure = z.infer; diff --git a/packages/claude-sdk-tools/src/Ref/Ref.ts b/packages/claude-sdk-tools/src/Ref/Ref.ts new file mode 100644 index 0000000..f2354b3 --- /dev/null +++ b/packages/claude-sdk-tools/src/Ref/Ref.ts @@ -0,0 +1,49 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import type { RefStore } from '../RefStore/RefStore'; +import { RefInputSchema } from './schema'; +import type { RefOutput } from './types'; + +export type CreateRefResult = { + /** The Ref query tool — add to the agent's tool list. */ + tool: ReturnType>; + /** Pass as transformToolResult on RunAgentQuery. Walks the output tree and ref-swaps any string exceeding the threshold. */ + transformToolResult: (toolName: string, output: unknown) => unknown; +}; + +export function createRef(store: RefStore, threshold: number): CreateRefResult { + const tool = defineTool({ + name: 'Ref', + description: `Fetch the content of a stored ref. When a tool result contains { ref, size, hint } instead of the full value, use this tool to retrieve it. Returns at most \`limit\` characters starting at \`start\`. Both default (start=0, limit=1000) so a bare { id } call gives the first 1000 chars — safe for arbitrarily large refs. The response includes \`hint\` (what produced the ref), \`totalSize\`, and the slice bounds so you know whether to page further.`, + input_schema: RefInputSchema, + input_examples: [{ id: 'uuid-...' }, { id: 'uuid-...', start: 1000, limit: 1000 }], + handler: async (input): Promise => { + const content = store.get(input.id); + if (content === undefined) { + return { found: false, id: input.id }; + } + + const start = input.start; + const end = Math.min(start + input.limit, content.length); + const slice = content.slice(start, end); + + return { + found: true, + hint: store.getHint(input.id), + content: slice, + totalSize: content.length, + start, + end, + } satisfies RefOutput; + }, + }); + + const transformToolResult = (toolName: string, output: unknown): unknown => { + // Never ref-swap the Ref tool's own output — Claude needs the content directly. + if (toolName === 'Ref') { + return output; + } + return store.walkAndRef(output, threshold, toolName); + }; + + return { tool, transformToolResult }; +} diff --git a/packages/claude-sdk-tools/src/Ref/schema.ts b/packages/claude-sdk-tools/src/Ref/schema.ts new file mode 100644 index 0000000..5f176db --- /dev/null +++ b/packages/claude-sdk-tools/src/Ref/schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const RefInputSchema = z.object({ + id: z.string().describe('The ref ID returned in a { ref, size, hint } token.'), + start: z.number().int().min(0).default(0).describe('Start character offset (inclusive). Default 0.'), + limit: z.number().int().min(1).max(2000).default(1000).describe('Maximum number of characters to return. Max 2000, default 1000. Use start+limit to page through large refs.'), +}); diff --git a/packages/claude-sdk-tools/src/Ref/types.ts b/packages/claude-sdk-tools/src/Ref/types.ts new file mode 100644 index 0000000..0e0b9f5 --- /dev/null +++ b/packages/claude-sdk-tools/src/Ref/types.ts @@ -0,0 +1 @@ +export type RefOutput = { found: true; hint: string | undefined; content: string; totalSize: number; start: number; end: number } | { found: false; id: string }; diff --git a/packages/claude-sdk-tools/src/RefStore/RefStore.ts b/packages/claude-sdk-tools/src/RefStore/RefStore.ts new file mode 100644 index 0000000..43ec040 --- /dev/null +++ b/packages/claude-sdk-tools/src/RefStore/RefStore.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto'; + +export type RefToken = { + ref: string; + size: number; + hint: string; +}; + +export class RefStore { + readonly #store = new Map(); + readonly #hints = new Map(); + + public store(content: string, hint = ''): string { + const id = randomUUID(); + this.#store.set(id, content); + this.#hints.set(id, hint); + return id; + } + + public get(id: string): string | undefined { + return this.#store.get(id); + } + + public getHint(id: string): string | undefined { + return this.#hints.get(id); + } + + public has(id: string): boolean { + return this.#store.has(id); + } + + public delete(id: string): void { + this.#store.delete(id); + this.#hints.delete(id); + } + + public get count(): number { + return this.#store.size; + } + + public get bytes(): number { + let total = 0; + for (const v of this.#store.values()) { + total += v.length; + } + return total; + } + + /** + * Walk a JSON-compatible value tree. Any string value whose length exceeds + * `threshold` chars is stored in the ref store and replaced with a RefToken. + * Numbers, booleans, null, and short strings pass through unchanged. + * Objects and arrays are recursed into. + */ + public walkAndRef(value: unknown, threshold: number, hint = ''): unknown { + if (typeof value === 'string') { + if (value.length > threshold) { + const id = this.store(value, hint); + return { ref: id, size: value.length, hint } satisfies RefToken; + } + return value; + } + + if (Array.isArray(value)) { + // For uniform string arrays, check total joined length — individual lines may each be + // short but the array as a whole (e.g. ReadFile values) can be enormous. + if (value.length > 0 && value.every((x) => typeof x === 'string')) { + const joined = (value as string[]).join('\n'); + if (joined.length > threshold) { + const id = this.store(joined, hint); + return { ref: id, size: joined.length, hint } satisfies RefToken; + } + } + return value.map((item, i) => this.walkAndRef(item, threshold, hint ? `${hint}[${i}]` : `[${i}]`)); + } + + if (value !== null && typeof value === 'object') { + const result: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + result[k] = this.walkAndRef(v, threshold, hint ? `${hint}.${k}` : k); + } + return result; + } + + return value; + } +} diff --git a/packages/claude-sdk-tools/src/SearchFiles/SearchFiles.ts b/packages/claude-sdk-tools/src/SearchFiles/SearchFiles.ts new file mode 100644 index 0000000..dd3a2fe --- /dev/null +++ b/packages/claude-sdk-tools/src/SearchFiles/SearchFiles.ts @@ -0,0 +1,42 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { collectMatchedIndices } from '../collectMatchedIndices'; +import type { IFileSystem } from '../fs/IFileSystem'; +import { SearchFilesInputSchema } from './schema'; + +export function createSearchFiles(fs: IFileSystem) { + return defineTool({ + name: 'SearchFiles', + description: 'Search file contents by pattern across a list of files piped from Find. Emits matching lines in path:line:content format. Works on output from Find (file list).', + operation: 'read', + input_schema: SearchFilesInputSchema, + input_examples: [{ pattern: 'export' }, { pattern: 'TODO', caseInsensitive: true }, { pattern: 'operation', context: 1 }], + handler: async (input) => { + if (input.content == null) { + return { type: 'content', values: [], totalLines: 0 }; + } + + const flags = input.caseInsensitive ? 'i' : ''; + const regex = new RegExp(input.pattern, flags); + const results: string[] = []; + + for (const filePath of input.content.values) { + let text: string; + try { + text = await fs.readFile(filePath); + } catch { + continue; + } + + const lines = text.split('\n'); + for (const i of collectMatchedIndices(lines, regex, input.context ?? 0)) { + results.push(`${filePath}:${i + 1}:${lines[i]}`); + } + } + return { + type: 'content', + values: results, + totalLines: results.length, + }; + }, + }); +} diff --git a/packages/claude-sdk-tools/src/SearchFiles/schema.ts b/packages/claude-sdk-tools/src/SearchFiles/schema.ts new file mode 100644 index 0000000..2178e52 --- /dev/null +++ b/packages/claude-sdk-tools/src/SearchFiles/schema.ts @@ -0,0 +1,7 @@ +import { PipeContentSchema, PipeFilesSchema, RegexSearchOptionsSchema } from '../pipe'; + +export const SearchFilesInputSchema = RegexSearchOptionsSchema.extend({ + content: PipeFilesSchema.optional().describe('Pipe input. Provided by composition layer, not needed for standalone use.'), +}); + +export const SearchFilesOutputSchema = PipeContentSchema; diff --git a/packages/claude-sdk-tools/src/SearchFiles/types.ts b/packages/claude-sdk-tools/src/SearchFiles/types.ts new file mode 100644 index 0000000..19d574b --- /dev/null +++ b/packages/claude-sdk-tools/src/SearchFiles/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { SearchFilesInputSchema, SearchFilesOutputSchema } from './schema'; + +export type SearchFilesInput = z.output; +export type SearchFilesOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/Tail/Tail.ts b/packages/claude-sdk-tools/src/Tail/Tail.ts new file mode 100644 index 0000000..9957170 --- /dev/null +++ b/packages/claude-sdk-tools/src/Tail/Tail.ts @@ -0,0 +1,26 @@ +import { defineTool } from '@shellicar/claude-sdk'; +import { TailInputSchema } from './schema'; + +export const Tail = defineTool({ + name: 'Tail', + description: 'Return the last N lines of piped content.', + operation: 'read', + input_schema: TailInputSchema, + input_examples: [{ count: 10 }, { count: 50 }], + handler: async (input) => { + if (input.content == null) { + return { type: 'content', values: [], totalLines: 0 }; + } + if (input.content.type === 'files') { + return { type: 'files', values: input.content.values.slice(-input.count) }; + } + const sliced = input.content.values.slice(-input.count); + return { + type: 'content', + values: sliced, + totalLines: input.content.totalLines, + path: input.content.path, + lineNumbers: input.content.lineNumbers?.slice(-input.count), + }; + }, +}); diff --git a/packages/claude-sdk-tools/src/Tail/schema.ts b/packages/claude-sdk-tools/src/Tail/schema.ts new file mode 100644 index 0000000..df89177 --- /dev/null +++ b/packages/claude-sdk-tools/src/Tail/schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; +import { PipeInputSchema } from '../pipe'; + +export const TailInputSchema = z.object({ + count: z.number().int().min(1).default(10).describe('Number of lines to return from the end'), + content: PipeInputSchema.optional().describe('Pipe input. Provided by composition layer, not needed for standalone use.'), +}); + +export const TailOutputSchema = PipeInputSchema; diff --git a/packages/claude-sdk-tools/src/Tail/types.ts b/packages/claude-sdk-tools/src/Tail/types.ts new file mode 100644 index 0000000..01de75b --- /dev/null +++ b/packages/claude-sdk-tools/src/Tail/types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; +import type { TailInputSchema, TailOutputSchema } from './schema'; + +export type TailInput = z.output; +export type TailOutput = z.infer; diff --git a/packages/claude-sdk-tools/src/collectMatchedIndices.ts b/packages/claude-sdk-tools/src/collectMatchedIndices.ts new file mode 100644 index 0000000..6c77eee --- /dev/null +++ b/packages/claude-sdk-tools/src/collectMatchedIndices.ts @@ -0,0 +1,17 @@ +/** + * Returns a sorted array of line indices that match the regex, expanded by + * `context` lines on either side. + */ +export function collectMatchedIndices(lines: string[], regex: RegExp, context: number): number[] { + const matchedIndices = new Set(); + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + const start = Math.max(0, i - context); + const end = Math.min(lines.length - 1, i + context); + for (let j = start; j <= end; j++) { + matchedIndices.add(j); + } + } + } + return [...matchedIndices].sort((a, b) => a - b); +} diff --git a/packages/claude-sdk-tools/src/deleteBatch.ts b/packages/claude-sdk-tools/src/deleteBatch.ts new file mode 100644 index 0000000..2ac18a4 --- /dev/null +++ b/packages/claude-sdk-tools/src/deleteBatch.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const DeleteResultSchema = z.object({ + path: z.string(), + error: z.string().optional(), +}); + +export const DeleteOutputSchema = z.object({ + deleted: z.array(z.string()), + errors: z.array(DeleteResultSchema), + totalDeleted: z.number().int(), + totalErrors: z.number().int(), +}); + +export type DeleteResult = z.infer; +export type DeleteOutput = z.infer; + +type ErrorMapper = (err: unknown) => string | undefined; + +export async function deleteBatch(paths: string[], op: (path: string) => Promise, mapError: ErrorMapper): Promise { + const deleted: string[] = []; + const errors: DeleteResult[] = []; + + for (const path of paths) { + try { + await op(path); + deleted.push(path); + } catch (err) { + const message = mapError(err); + if (message !== undefined) { + errors.push({ path, error: message }); + } else { + throw err; + } + } + } + + return { deleted, errors, totalDeleted: deleted.length, totalErrors: errors.length }; +} diff --git a/packages/claude-sdk-tools/src/entry/CreateFile.ts b/packages/claude-sdk-tools/src/entry/CreateFile.ts new file mode 100644 index 0000000..7d077b9 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/CreateFile.ts @@ -0,0 +1,4 @@ +import { createCreateFile } from '../CreateFile/CreateFile'; +import { nodeFs } from './nodeFs'; + +export const CreateFile = createCreateFile(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/DeleteDirectory.ts b/packages/claude-sdk-tools/src/entry/DeleteDirectory.ts new file mode 100644 index 0000000..7d971ce --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/DeleteDirectory.ts @@ -0,0 +1,4 @@ +import { createDeleteDirectory } from '../DeleteDirectory/DeleteDirectory'; +import { nodeFs } from './nodeFs'; + +export const DeleteDirectory = createDeleteDirectory(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/DeleteFile.ts b/packages/claude-sdk-tools/src/entry/DeleteFile.ts new file mode 100644 index 0000000..95c5051 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/DeleteFile.ts @@ -0,0 +1,4 @@ +import { createDeleteFile } from '../DeleteFile/DeleteFile'; +import { nodeFs } from './nodeFs'; + +export const DeleteFile = createDeleteFile(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/EditFile.ts b/packages/claude-sdk-tools/src/entry/EditFile.ts new file mode 100644 index 0000000..23d7ade --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/EditFile.ts @@ -0,0 +1,3 @@ +import { EditFile } from './editFilePair'; + +export { EditFile }; diff --git a/packages/claude-sdk-tools/src/entry/Exec.ts b/packages/claude-sdk-tools/src/entry/Exec.ts new file mode 100644 index 0000000..9674dce --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Exec.ts @@ -0,0 +1,4 @@ +import { createExec } from '../Exec/Exec'; +import { nodeFs } from './nodeFs'; + +export const Exec = createExec(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/Find.ts b/packages/claude-sdk-tools/src/entry/Find.ts new file mode 100644 index 0000000..3eb8874 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Find.ts @@ -0,0 +1,4 @@ +import { createFind } from '../Find/Find'; +import { nodeFs } from './nodeFs'; + +export const Find = createFind(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/Grep.ts b/packages/claude-sdk-tools/src/entry/Grep.ts new file mode 100644 index 0000000..7cd6571 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Grep.ts @@ -0,0 +1,3 @@ +import { Grep } from '../Grep/Grep'; + +export { Grep }; diff --git a/packages/claude-sdk-tools/src/entry/Head.ts b/packages/claude-sdk-tools/src/entry/Head.ts new file mode 100644 index 0000000..4981e61 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Head.ts @@ -0,0 +1,3 @@ +import { Head } from '../Head/Head'; + +export { Head }; diff --git a/packages/claude-sdk-tools/src/entry/Pipe.ts b/packages/claude-sdk-tools/src/entry/Pipe.ts new file mode 100644 index 0000000..27f6f7f --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Pipe.ts @@ -0,0 +1,3 @@ +import { createPipe } from '../Pipe/Pipe'; + +export { createPipe }; diff --git a/packages/claude-sdk-tools/src/entry/PreviewEdit.ts b/packages/claude-sdk-tools/src/entry/PreviewEdit.ts new file mode 100644 index 0000000..88e2894 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/PreviewEdit.ts @@ -0,0 +1,3 @@ +import { PreviewEdit } from './editFilePair'; + +export { PreviewEdit }; diff --git a/packages/claude-sdk-tools/src/entry/Range.ts b/packages/claude-sdk-tools/src/entry/Range.ts new file mode 100644 index 0000000..45606c1 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Range.ts @@ -0,0 +1,3 @@ +import { Range } from '../Range/Range'; + +export { Range }; diff --git a/packages/claude-sdk-tools/src/entry/ReadFile.ts b/packages/claude-sdk-tools/src/entry/ReadFile.ts new file mode 100644 index 0000000..872fdda --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/ReadFile.ts @@ -0,0 +1,4 @@ +import { createReadFile } from '../ReadFile/ReadFile'; +import { nodeFs } from './nodeFs'; + +export const ReadFile = createReadFile(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/Ref.ts b/packages/claude-sdk-tools/src/entry/Ref.ts new file mode 100644 index 0000000..11d7acb --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Ref.ts @@ -0,0 +1,5 @@ +import type { CreateRefResult } from '../Ref/Ref'; +import { createRef } from '../Ref/Ref'; + +export type { CreateRefResult }; +export { createRef }; diff --git a/packages/claude-sdk-tools/src/entry/RefStore.ts b/packages/claude-sdk-tools/src/entry/RefStore.ts new file mode 100644 index 0000000..8019411 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/RefStore.ts @@ -0,0 +1,5 @@ +import type { RefToken } from '../RefStore/RefStore'; +import { RefStore } from '../RefStore/RefStore'; + +export type { RefToken }; +export { RefStore }; diff --git a/packages/claude-sdk-tools/src/entry/SearchFiles.ts b/packages/claude-sdk-tools/src/entry/SearchFiles.ts new file mode 100644 index 0000000..86f8951 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/SearchFiles.ts @@ -0,0 +1,4 @@ +import { createSearchFiles } from '../SearchFiles/SearchFiles'; +import { nodeFs } from './nodeFs'; + +export const SearchFiles = createSearchFiles(nodeFs); diff --git a/packages/claude-sdk-tools/src/entry/Tail.ts b/packages/claude-sdk-tools/src/entry/Tail.ts new file mode 100644 index 0000000..94014cd --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/Tail.ts @@ -0,0 +1,3 @@ +import { Tail } from '../Tail/Tail'; + +export { Tail }; diff --git a/packages/claude-sdk-tools/src/entry/editFilePair.ts b/packages/claude-sdk-tools/src/entry/editFilePair.ts new file mode 100644 index 0000000..c177ac6 --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/editFilePair.ts @@ -0,0 +1,6 @@ +import { createEditFilePair } from '../EditFile/createEditFilePair'; +import { nodeFs } from './nodeFs'; + +const { previewEdit, editFile } = createEditFilePair(nodeFs); + +export { editFile as EditFile, previewEdit as PreviewEdit }; diff --git a/packages/claude-sdk-tools/src/entry/nodeFs.ts b/packages/claude-sdk-tools/src/entry/nodeFs.ts new file mode 100644 index 0000000..a5b3f0a --- /dev/null +++ b/packages/claude-sdk-tools/src/entry/nodeFs.ts @@ -0,0 +1,3 @@ +import { NodeFileSystem } from '../fs/NodeFileSystem'; + +export const nodeFs = new NodeFileSystem(); diff --git a/packages/claude-sdk-tools/src/expandPath.ts b/packages/claude-sdk-tools/src/expandPath.ts new file mode 100644 index 0000000..7c35a7e --- /dev/null +++ b/packages/claude-sdk-tools/src/expandPath.ts @@ -0,0 +1,11 @@ +import type { IFileSystem } from './fs/IFileSystem'; + +/** Expand ~ and $VAR / ${VAR} in a path string. */ +export function expandPath(value: string, fs: IFileSystem): string; +export function expandPath(value: string | undefined, fs: IFileSystem): string | undefined; +export function expandPath(value: string | undefined, fs: IFileSystem): string | undefined { + if (value == null) { + return undefined; + } + return value.replace(/^~(?=\/|$)/, fs.homedir()).replace(/\$\{(\w+)\}|\$(\w+)/g, (_, braced: string, bare: string) => process.env[braced ?? bare] ?? ''); +} diff --git a/packages/claude-sdk-tools/src/fs/IFileSystem.ts b/packages/claude-sdk-tools/src/fs/IFileSystem.ts new file mode 100644 index 0000000..6c2b638 --- /dev/null +++ b/packages/claude-sdk-tools/src/fs/IFileSystem.ts @@ -0,0 +1,21 @@ +export interface FindOptions { + pattern?: string; + type?: 'file' | 'directory' | 'both'; + exclude?: string[]; + maxDepth?: number; +} + +export interface StatResult { + size: number; +} + +export interface IFileSystem { + homedir(): string; + exists(path: string): Promise; + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + deleteFile(path: string): Promise; + deleteDirectory(path: string): Promise; + find(path: string, options?: FindOptions): Promise; + stat(path: string): Promise; +} diff --git a/packages/claude-sdk-tools/src/fs/MemoryFileSystem.ts b/packages/claude-sdk-tools/src/fs/MemoryFileSystem.ts new file mode 100644 index 0000000..a26c938 --- /dev/null +++ b/packages/claude-sdk-tools/src/fs/MemoryFileSystem.ts @@ -0,0 +1,147 @@ +import type { FindOptions, IFileSystem, StatResult } from './IFileSystem'; +import { matchGlob } from './matchGlob'; + +/** + * In-memory filesystem implementation for testing. + * + * Files are stored in a Map keyed by absolute path. + * Directories are implicit: a file at /a/b/c implies a directory at /a/b. + * Note: empty directories cannot be represented without explicit tracking. + */ +export class MemoryFileSystem implements IFileSystem { + private readonly files = new Map(); + private readonly home: string; + + public constructor(initial?: Record, home = '/home/user') { + this.home = home; + if (initial) { + for (const [path, content] of Object.entries(initial)) { + this.files.set(path, content); + } + } + } + + public homedir(): string { + return this.home; + } + + public async exists(path: string): Promise { + return this.files.has(path); + } + + public async readFile(path: string): Promise { + const content = this.files.get(path); + if (content === undefined) { + const err = new Error(`ENOENT: no such file or directory, open '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return content; + } + + public async writeFile(path: string, content: string): Promise { + this.files.set(path, content); + } + + public async deleteFile(path: string): Promise { + if (!this.files.has(path)) { + const err = new Error(`ENOENT: no such file or directory, unlink '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + this.files.delete(path); + } + + public async deleteDirectory(path: string): Promise { + const prefix = path.endsWith('/') ? path : `${path}/`; + const directContents = [...this.files.keys()].filter((p) => { + if (!p.startsWith(prefix)) { + return false; + } + const relative = p.slice(prefix.length); + return !relative.includes('/'); + }); + if (directContents.length > 0) { + const err = new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOTEMPTY'; + throw err; + } + // Directories are implicit \u2014 nothing to remove when empty + } + + public async stat(path: string): Promise { + const content = this.files.get(path); + if (content === undefined) { + const err = new Error(`ENOENT: no such file or directory, stat '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return { size: content.length }; + } + + public async find(path: string, options?: FindOptions): Promise { + const prefix = path.endsWith('/') ? path : `${path}/`; + const type = options?.type ?? 'file'; + const exclude = options?.exclude ?? []; + const maxDepth = options?.maxDepth; + const pattern = options?.pattern; + + // Check that the directory exists (at least one file lives under it). + // Empty directories cannot be represented in MemoryFileSystem. + const dirExists = [...this.files.keys()].some((p) => p.startsWith(prefix)); + if (!dirExists) { + const err = new Error(`ENOENT: no such file or directory, scandir '${path}'`) as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + + const results: string[] = []; + const dirs = new Set(); + + for (const filePath of this.files.keys()) { + if (!filePath.startsWith(prefix)) { + continue; + } + + const relative = filePath.slice(prefix.length); + const parts = relative.split('/'); + + if (maxDepth !== undefined && parts.length > maxDepth) { + continue; + } + if (parts.some((p) => exclude.includes(p))) { + continue; + } + + if (type === 'directory' || type === 'both') { + for (let i = 1; i < parts.length; i++) { + const dirPath = prefix + parts.slice(0, i).join('/'); + if (!dirs.has(dirPath)) { + const dirName = parts[i - 1]; + if (!exclude.includes(dirName) && (maxDepth === undefined || i <= maxDepth)) { + dirs.add(dirPath); + } + } + } + } + + if (type === 'file' || type === 'both') { + const fileName = parts[parts.length - 1]; + if (!pattern || matchGlob(pattern, fileName)) { + results.push(filePath); + } + } + } + + if (type === 'directory' || type === 'both') { + for (const dir of dirs) { + const dirName = dir.split('/').pop() ?? ''; + if (!pattern || matchGlob(pattern, dirName)) { + results.push(dir); + } + } + } + + return results.sort(); + } +} diff --git a/packages/claude-sdk-tools/src/fs/NodeFileSystem.ts b/packages/claude-sdk-tools/src/fs/NodeFileSystem.ts new file mode 100644 index 0000000..abc8d1d --- /dev/null +++ b/packages/claude-sdk-tools/src/fs/NodeFileSystem.ts @@ -0,0 +1,81 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises'; +import { homedir as osHomedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { FindOptions, IFileSystem, StatResult } from './IFileSystem'; +import { matchGlob } from './matchGlob'; + +/** + * Production filesystem implementation using Node.js fs APIs. + */ +export class NodeFileSystem implements IFileSystem { + public homedir(): string { + return osHomedir(); + } + + public async exists(path: string): Promise { + return existsSync(path); + } + + public async readFile(path: string): Promise { + return readFile(path, 'utf-8'); + } + + public async writeFile(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, 'utf-8'); + } + + public async deleteFile(path: string): Promise { + await rm(path); + } + + public async deleteDirectory(path: string): Promise { + await rmdir(path); + } + + public async find(path: string, options?: FindOptions): Promise { + return walk(path, options ?? {}, 1); + } + + public async stat(path: string): Promise { + const s = await stat(path); + return { size: s.size }; + } +} + +async function walk(dir: string, options: FindOptions, depth: number): Promise { + const { maxDepth, exclude = [], pattern, type = 'file' } = options; + + if (maxDepth !== undefined && depth > maxDepth) { + return []; + } + + const results: string[] = []; + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (exclude.includes(entry.name)) { + continue; + } + + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (type === 'directory' || type === 'both') { + if (!pattern || matchGlob(pattern, entry.name)) { + results.push(fullPath); + } + } + results.push(...(await walk(fullPath, options, depth + 1))); + } else if (entry.isFile()) { + if (type === 'file' || type === 'both') { + if (!pattern || matchGlob(pattern, entry.name)) { + results.push(fullPath); + } + } + } + } + + return results; +} diff --git a/packages/claude-sdk-tools/src/fs/matchGlob.ts b/packages/claude-sdk-tools/src/fs/matchGlob.ts new file mode 100644 index 0000000..d1c7c75 --- /dev/null +++ b/packages/claude-sdk-tools/src/fs/matchGlob.ts @@ -0,0 +1,9 @@ +export function matchGlob(pattern: string, name: string): boolean { + // Strip leading **/ prefixes — directory traversal is handled by recursion + const normalised = pattern.replace(/^(\*\*\/)+/, ''); + const escaped = normalised + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`).test(name); +} diff --git a/packages/claude-sdk-tools/src/isNodeError.ts b/packages/claude-sdk-tools/src/isNodeError.ts new file mode 100644 index 0000000..e71d2fc --- /dev/null +++ b/packages/claude-sdk-tools/src/isNodeError.ts @@ -0,0 +1,3 @@ +export const isNodeError = (err: unknown, code: string): err is NodeJS.ErrnoException => { + return err instanceof Error && 'code' in err && err.code === code; +}; diff --git a/packages/claude-sdk-tools/src/pipe.ts b/packages/claude-sdk-tools/src/pipe.ts new file mode 100644 index 0000000..aacc7c5 --- /dev/null +++ b/packages/claude-sdk-tools/src/pipe.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +// The pipe contract — what flows between tools + +export const PipeFilesSchema = z.object({ + type: z.literal('files'), + values: z.array(z.string()), +}); + +export const PipeContentSchema = z.object({ + type: z.literal('content'), + values: z.array(z.string()), + totalLines: z.number().int(), + path: z.string().optional().describe('Source file path, present when piped from ReadFile'), + lineNumbers: z.array(z.number().int()).optional().describe('1-based line numbers corresponding to each value, present when content is a non-contiguous subset (e.g. from Grep)'), +}); + +export const PipeInputSchema = z.discriminatedUnion('type', [PipeFilesSchema, PipeContentSchema]); + +export type PipeFiles = z.infer; +export type PipeContent = z.infer; +export type PipeInput = z.infer; + +/** Shared fields for tools that search using a regex pattern. */ +export const RegexSearchOptionsSchema = z.object({ + pattern: z.string().describe('Regular expression pattern to search for'), + caseInsensitive: z.boolean().default(false).describe('Case insensitive matching'), + context: z.number().int().min(0).default(0).describe('Number of lines of context before and after each match'), +}); diff --git a/packages/claude-sdk-tools/test/CreateFile.spec.ts b/packages/claude-sdk-tools/test/CreateFile.spec.ts new file mode 100644 index 0000000..b763a3a --- /dev/null +++ b/packages/claude-sdk-tools/test/CreateFile.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { createCreateFile } from '../src/CreateFile/CreateFile'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { call } from './helpers'; + +describe('createCreateFile \u2014 creating new files', () => { + it('creates a file that did not exist', async () => { + const fs = new MemoryFileSystem(); + const CreateFile = createCreateFile(fs); + const result = await call(CreateFile, { path: '/new.ts', content: 'hello' }); + expect(result).toMatchObject({ error: false, path: '/new.ts' }); + expect(await fs.readFile('/new.ts')).toBe('hello'); + }); + + it('expands ~ in path', async () => { + const fs = new MemoryFileSystem({}, '/home/testuser'); + const CreateFile = createCreateFile(fs); + const result = await call(CreateFile, { path: '~/newfile.ts', content: 'hello' }); + expect(result).toMatchObject({ error: false, path: '/home/testuser/newfile.ts' }); + expect(await fs.readFile('/home/testuser/newfile.ts')).toBe('hello'); + }); + + it('creates a file with empty content when content is omitted', async () => { + const fs = new MemoryFileSystem(); + const CreateFile = createCreateFile(fs); + await call(CreateFile, { path: '/empty.ts' }); + expect(await fs.readFile('/empty.ts')).toBe(''); + }); + + it('errors when file already exists and overwrite is false (default)', async () => { + const fs = new MemoryFileSystem({ '/existing.ts': 'original' }); + const CreateFile = createCreateFile(fs); + const result = await call(CreateFile, { path: '/existing.ts', content: 'new' }); + expect(result).toMatchObject({ error: true, path: '/existing.ts' }); + expect(await fs.readFile('/existing.ts')).toBe('original'); + }); +}); + +describe('createCreateFile \u2014 overwriting existing files', () => { + it('overwrites a file when overwrite is true', async () => { + const fs = new MemoryFileSystem({ '/existing.ts': 'original' }); + const CreateFile = createCreateFile(fs); + const result = await call(CreateFile, { path: '/existing.ts', content: 'updated', overwrite: true }); + expect(result).toMatchObject({ error: false, path: '/existing.ts' }); + expect(await fs.readFile('/existing.ts')).toBe('updated'); + }); + + it('errors when overwrite is true but file does not exist', async () => { + const fs = new MemoryFileSystem(); + const CreateFile = createCreateFile(fs); + const result = await call(CreateFile, { path: '/missing.ts', content: 'data', overwrite: true }); + expect(result).toMatchObject({ error: true, path: '/missing.ts' }); + }); +}); diff --git a/packages/claude-sdk-tools/test/DeleteDirectory.spec.ts b/packages/claude-sdk-tools/test/DeleteDirectory.spec.ts new file mode 100644 index 0000000..7d9b735 --- /dev/null +++ b/packages/claude-sdk-tools/test/DeleteDirectory.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createDeleteDirectory } from '../src/DeleteDirectory/DeleteDirectory'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { call } from './helpers'; + +const files = (values: string[]) => ({ type: 'files' as const, values }); + +describe('createDeleteDirectory \u2014 success', () => { + it('deletes an empty directory (implicit, no files inside)', async () => { + const fs = new MemoryFileSystem({ '/other/file.ts': 'content' }); + const DeleteDirectory = createDeleteDirectory(fs); + const result = await call(DeleteDirectory, { content: files(['/empty-dir']) }); + expect(result).toMatchObject({ deleted: ['/empty-dir'], errors: [], totalDeleted: 1, totalErrors: 0 }); + }); +}); + +describe('createDeleteDirectory \u2014 error handling', () => { + it('reports ENOTEMPTY when directory has direct children', async () => { + const fs = new MemoryFileSystem({ '/dir/file.ts': 'content' }); + const DeleteDirectory = createDeleteDirectory(fs); + const result = await call(DeleteDirectory, { content: files(['/dir']) }); + expect(result).toMatchObject({ deleted: [], totalDeleted: 0, totalErrors: 1 }); + expect(result.errors[0]).toMatchObject({ + path: '/dir', + error: 'Directory is not empty. Delete the files inside first.', + }); + }); + + it('processes multiple paths and reports each outcome', async () => { + const fs = new MemoryFileSystem({ '/full/file.ts': 'data' }); + const DeleteDirectory = createDeleteDirectory(fs); + const result = await call(DeleteDirectory, { content: files(['/empty', '/full']) }); + expect(result).toMatchObject({ totalDeleted: 1, totalErrors: 1 }); + expect(result.deleted).toContain('/empty'); + expect(result.errors[0]).toMatchObject({ path: '/full' }); + }); +}); diff --git a/packages/claude-sdk-tools/test/DeleteFile.spec.ts b/packages/claude-sdk-tools/test/DeleteFile.spec.ts new file mode 100644 index 0000000..e98fd2a --- /dev/null +++ b/packages/claude-sdk-tools/test/DeleteFile.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { createDeleteFile } from '../src/DeleteFile/DeleteFile'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { call } from './helpers'; + +const files = (values: string[]) => ({ type: 'files' as const, values }); + +describe('createDeleteFile \u2014 success', () => { + it('deletes an existing file', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'content', '/b.ts': 'other' }); + const DeleteFile = createDeleteFile(fs); + const result = await call(DeleteFile, { content: files(['/a.ts']) }); + expect(result).toMatchObject({ deleted: ['/a.ts'], errors: [], totalDeleted: 1, totalErrors: 0 }); + expect(await fs.exists('/a.ts')).toBe(false); + expect(await fs.exists('/b.ts')).toBe(true); + }); + + it('deletes multiple files', async () => { + const fs = new MemoryFileSystem({ '/a.ts': '', '/b.ts': '', '/c.ts': '' }); + const DeleteFile = createDeleteFile(fs); + const result = await call(DeleteFile, { content: files(['/a.ts', '/b.ts']) }); + expect(result).toMatchObject({ totalDeleted: 2, totalErrors: 0 }); + expect(await fs.exists('/a.ts')).toBe(false); + expect(await fs.exists('/b.ts')).toBe(false); + expect(await fs.exists('/c.ts')).toBe(true); + }); +}); + +describe('createDeleteFile \u2014 error handling', () => { + it('reports an error for a missing file without throwing', async () => { + const fs = new MemoryFileSystem(); + const DeleteFile = createDeleteFile(fs); + const result = await call(DeleteFile, { content: files(['/missing.ts']) }); + expect(result).toMatchObject({ deleted: [], totalDeleted: 0, totalErrors: 1 }); + expect(result.errors[0]).toMatchObject({ path: '/missing.ts', error: 'File not found' }); + }); + + it('reports errors and successes in the same pass', async () => { + const fs = new MemoryFileSystem({ '/exists.ts': 'data' }); + const DeleteFile = createDeleteFile(fs); + const result = await call(DeleteFile, { content: files(['/exists.ts', '/missing.ts']) }); + expect(result).toMatchObject({ totalDeleted: 1, totalErrors: 1 }); + expect(result.deleted).toContain('/exists.ts'); + expect(result.errors[0]).toMatchObject({ path: '/missing.ts' }); + }); +}); diff --git a/packages/claude-sdk-tools/test/EditFile.spec.ts b/packages/claude-sdk-tools/test/EditFile.spec.ts new file mode 100644 index 0000000..83b94aa --- /dev/null +++ b/packages/claude-sdk-tools/test/EditFile.spec.ts @@ -0,0 +1,462 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { createEditFilePair } from '../src/EditFile/createEditFilePair'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { call } from './helpers'; + +const originalContent = 'line one\nline two\nline three'; + +describe('createPreviewEdit \u2014 staging', () => { + it('stores a patch in the store and returns a patchId', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'insert', after_line: 0, content: '// header' }] }); + expect(result).toMatchObject({ file: '/file.ts' }); + expect(typeof result.patchId).toBe('string'); + }); + + it('computes the correct originalHash', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'delete', startLine: 1, endLine: 1 }] }); + const expected = createHash('sha256').update(originalContent).digest('hex'); + expect(result.originalHash).toBe(expected); + }); + + it('includes a unified diff', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace', startLine: 2, endLine: 2, content: 'line TWO' }] }); + expect(result.diff).toContain('line two'); + expect(result.diff).toContain('line TWO'); + }); + + it('diff includes context lines around the change', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit } = createEditFilePair(fs); + // originalContent = 'line one\nline two\nline three'; edit middle line only + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace', startLine: 2, endLine: 2, content: 'line TWO' }] }); + expect(result.diff).toContain(' line one'); // unchanged line before — space-prefixed context + expect(result.diff).toContain(' line three'); // unchanged line after — space-prefixed context + }); + + it('diff contains a standard @@ hunk header', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace', startLine: 2, endLine: 2, content: 'line TWO' }] }); + expect(result.diff).toMatch(/@@ -\d+,\d+ \+\d+,\d+ @@/); + }); + + it('expands ~ in file path', async () => { + const fs = new MemoryFileSystem({ '/home/testuser/file.ts': originalContent }, '/home/testuser'); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '~/file.ts', edits: [{ action: 'delete', startLine: 1, endLine: 1 }] }); + expect(result.file).toBe('/home/testuser/file.ts'); + }); +}); + +describe('createEditFile \u2014 applying', () => { + it('applies the patch and writes the new content', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit, editFile } = createEditFilePair(fs); + const staged = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace', startLine: 1, endLine: 1, content: 'line ONE' }] }); + const confirmed = await call(editFile, { patchId: staged.patchId, file: staged.file }); + expect(confirmed).toMatchObject({ linesAdded: 1, linesRemoved: 1 }); + expect(await fs.readFile('/file.ts')).toBe('line ONE\nline two\nline three'); + }); + + it('throws when the file was modified after staging', async () => { + const fs = new MemoryFileSystem({ '/file.ts': originalContent }); + const { previewEdit, editFile } = createEditFilePair(fs); + const staged = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'delete', startLine: 1, endLine: 1 }] }); + await fs.writeFile('/file.ts', 'completely different content'); + await expect(call(editFile, { patchId: staged.patchId, file: staged.file })).rejects.toThrow('has been modified since the edit was staged'); + }); + + it('throws when patchId is unknown', async () => { + const fs = new MemoryFileSystem(); + const { editFile } = createEditFilePair(fs); + await expect(call(editFile, { patchId: '00000000-0000-4000-8000-000000000000', file: '/any.ts' })).rejects.toThrow('Staged preview not found'); + }); +}); + +describe('regex_text action', () => { + it('replaces a unique match', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'line two', replacement: 'line TWO' }] }); + expect(result.newContent).toBe('line one\nline TWO\nline three'); + }); + + it('replaces a substring within a line, not the whole line', async () => { + const fs = new MemoryFileSystem({ '/file.ts': "const x: string = 'hello';" }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: ': string', replacement: '' }] }); + expect(result.newContent).toBe("const x = 'hello';"); + }); + + it('find is treated as a regex pattern', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'version: 42' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: '\\d+', replacement: '99' }] }); + expect(result.newContent).toBe('version: 99'); + }); + + it('supports capture groups in replacement', async () => { + const fs = new MemoryFileSystem({ '/file.ts': "import type { MyType } from 'types';" }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'import type \\{ (\\w+) \\}', replacement: 'import { $1 }' }] }); + expect(result.newContent).toBe("import { MyType } from 'types';"); + }); + + it('$& in replacement inserts the matched text', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'hello world' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'world', replacement: '[$&]' }] }); + expect(result.newContent).toBe('hello [world]'); + }); + + it('$$ in replacement produces a literal dollar sign', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'cost is 100' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: '100', replacement: '$$100' }] }); + expect(result.newContent).toBe('cost is $100'); + }); + + it('matches across multiple lines', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'line one\\nline two', replacement: 'LINES ONE AND TWO' }] }); + expect(result.newContent).toBe('LINES ONE AND TWO\nline three'); + }); + + it('includes the old and new text in the diff', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'line two', replacement: 'line TWO' }] }); + expect(result.diff).toContain('line two'); + expect(result.diff).toContain('line TWO'); + }); + + it('confirmed edit writes the correct content to disk', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit, editFile } = createEditFilePair(fs); + const staged = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'line two', replacement: 'line TWO' }] }); + await call(editFile, { patchId: staged.patchId, file: staged.file }); + expect(await fs.readFile('/file.ts')).toBe('line one\nline TWO\nline three'); + }); + + it('throws when the pattern matches nothing', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + await expect(call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'not in file', replacement: 'x' }] })).rejects.toThrow(); + }); + + it('throws when the pattern matches multiple times and replaceMultiple is not set', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'foo\nfoo\nbar' }); + const { previewEdit } = createEditFilePair(fs); + await expect(call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'foo', replacement: 'baz' }] })).rejects.toThrow('2'); + }); + + it('replaces all occurrences across lines when replaceMultiple is true', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'foo\nfoo\nbar' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'foo', replacement: 'baz', replaceMultiple: true }] }); + expect(result.newContent).toBe('baz\nbaz\nbar'); + }); + + it('replaces all occurrences on the same line when replaceMultiple is true', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'foo foo\nbar' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'regex_text', pattern: 'foo', replacement: 'baz', replaceMultiple: true }] }); + expect(result.newContent).toBe('baz baz\nbar'); + }); +}); + +describe('replace_text action', () => { + it('replaces a unique literal string match', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: 'line two', replacement: 'line TWO' }] }); + expect(result.newContent).toBe('line one\nline TWO\nline three'); + }); + + it('treats special regex chars in oldString as literals', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'price: (100)' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: '(100)', replacement: '(200)' }] }); + expect(result.newContent).toBe('price: (200)'); + }); + + it('treats $ in replacement as a literal dollar sign, not a special pattern', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'cost: 100' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: '100', replacement: '$100' }] }); + expect(result.newContent).toBe('cost: $100'); + }); + + it('$$ in replacement produces two dollar signs, not one', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'x' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: 'x', replacement: '$$' }] }); + expect(result.newContent).toBe('$$'); + }); + + it('$& in replacement is literal, not the matched text', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'hello world' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: 'world', replacement: '$&' }] }); + expect(result.newContent).toBe('hello $&'); + }); + + it('throws when oldString is not found', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one' }); + const { previewEdit } = createEditFilePair(fs); + await expect(call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: 'not here', replacement: 'x' }] })).rejects.toThrow(); + }); + + it('replaces all occurrences when replaceMultiple is true', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'foo\nfoo\nbar' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { file: '/file.ts', edits: [{ action: 'replace_text', oldString: 'foo', replacement: 'baz', replaceMultiple: true }] }); + expect(result.newContent).toBe('baz\nbaz\nbar'); + }); +}); + +describe('multiple edits — sequential semantics', () => { + // Edits are applied in order, top-to-bottom. + // Each edit's line numbers reference the file *as it looks after all previous edits*, + // not the original file. + + it('delete then replace: second edit uses post-delete line numbers', async () => { + // The user's example: delete lines 5–7 from a 10-line file, + // then the original lines 9–10 are now at positions 6–7. + const content = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const fs = new MemoryFileSystem({ '/file.ts': content }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'delete', startLine: 5, endLine: 7 }, // removes 5,6,7 → [1,2,3,4,8,9,10] + { action: 'replace', startLine: 6, endLine: 7, content: 'nine\nten' }, // 9,10 are now at 6,7 + ], + }); + expect(result.newContent).toBe('1\n2\n3\n4\n8\nnine\nten'); + }); + + it('insert shifts subsequent edits: second edit uses post-insert line numbers', async () => { + // Insert a line after line 1 → original line 2 is now at line 3. + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'insert', after_line: 1, content: 'inserted' }, // → [line one, inserted, line two, line three] + { action: 'replace', startLine: 3, endLine: 3, content: 'line TWO' }, // line two is now at 3 + ], + }); + expect(result.newContent).toBe('line one\ninserted\nline TWO\nline three'); + }); + + it('two consecutive deletes at the same position both use current state', async () => { + // Delete line 2 twice: first removes B (line 2), second removes C (now line 2). + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC\nD\nE' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'delete', startLine: 2, endLine: 2 }, // removes B → [A,C,D,E] + { action: 'delete', startLine: 2, endLine: 2 }, // removes C (now line 2) → [A,D,E] + ], + }); + expect(result.newContent).toBe('A\nD\nE'); + }); + + it('two inserts in sequence: second insert references post-first-insert line numbers', async () => { + // Insert X after line 1, then insert Y after line 2 (where X now is). + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'insert', after_line: 1, content: 'X' }, // → [A, X, B, C] + { action: 'insert', after_line: 2, content: 'Y' }, // after X (now line 2) → [A, X, Y, B, C] + ], + }); + expect(result.newContent).toBe('A\nX\nY\nB\nC'); + }); + + it('replace expanding lines shifts subsequent edits down', async () => { + // Replace B (line 2) with 3 lines → C shifts from line 3 to line 5. + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC\nD\nE' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'replace', startLine: 2, endLine: 2, content: 'B1\nB2\nB3' }, // → [A,B1,B2,B3,C,D,E] + { action: 'replace', startLine: 5, endLine: 5, content: 'X' }, // C is now at line 5 + ], + }); + expect(result.newContent).toBe('A\nB1\nB2\nB3\nX\nD\nE'); + }); + + it('replace shrinking lines shifts subsequent edits up', async () => { + // Replace lines 1–3 with a single line → D shifts from line 4 to line 2. + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC\nD\nE' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'replace', startLine: 1, endLine: 3, content: 'ABC' }, // → [ABC, D, E] + { action: 'replace', startLine: 2, endLine: 2, content: 'X' }, // D is now at line 2 + ], + }); + expect(result.newContent).toBe('ABC\nX\nE'); + }); + + it('can reference a line that was added by a previous insert', async () => { + // insert expands the file beyond its original length; the second edit must be valid + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC' }); + const { previewEdit } = createEditFilePair(fs); + const result = await call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'insert', after_line: 3, content: 'D\nE' }, // → [A,B,C,D,E] + { action: 'replace', startLine: 4, endLine: 5, content: 'X\nY' }, // line 4,5 only exist post-insert + ], + }); + expect(result.newContent).toBe('A\nB\nC\nX\nY'); + }); + + it('throws when a subsequent edit references a line removed by a previous delete', async () => { + // delete shrinks the file; the second edit references a line that no longer exists + const fs = new MemoryFileSystem({ '/file.ts': 'A\nB\nC\nD\nE' }); + const { previewEdit } = createEditFilePair(fs); + await expect( + call(previewEdit, { + file: '/file.ts', + edits: [ + { action: 'delete', startLine: 1, endLine: 4 }, // → [E] (1 line left) + { action: 'replace', startLine: 3, endLine: 3, content: 'X' }, // line 3 no longer exists + ], + }), + ).rejects.toThrow('out of bounds'); + }); +}); + +describe('chained previews — previousPatchId', () => { + it('uses the previous patch newContent as the base', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line two', replacement: 'LINE TWO' }], + }); + const patch2 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line three', replacement: 'LINE THREE' }], + previousPatchId: patch1.patchId, + }); + expect(patch2.newContent).toBe('line one\nLINE TWO\nLINE THREE'); + }); + + it('inherits originalHash from the first patch so EditFile can validate the disk', async () => { + const content = 'line one\nline two\nline three'; + const fs = new MemoryFileSystem({ '/file.ts': content }); + const { previewEdit } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line one', replacement: 'LINE ONE' }], + }); + const patch2 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line two', replacement: 'LINE TWO' }], + previousPatchId: patch1.patchId, + }); + expect(patch2.originalHash).toBe(patch1.originalHash); + }); + + it('diff is incremental — only shows the delta introduced by this patch', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line one', replacement: 'LINE ONE' }], + }); + const patch2 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line three', replacement: 'LINE THREE' }], + previousPatchId: patch1.patchId, + }); + // patch2 diff should not show line one as a *changed* line (it's already settled in patch1) + // It may appear as context (space-prefixed), but must not appear as + or - lines + const changedLines = patch2.diff.split('\n').filter((l) => l.startsWith('+') || l.startsWith('-')); + expect(changedLines.join('\n')).not.toContain('line one'); + expect(changedLines.join('\n')).not.toContain('LINE ONE'); + // but should show the line three change + expect(patch2.diff).toContain('line three'); + expect(patch2.diff).toContain('LINE THREE'); + }); + + it('EditFile applies the fully accumulated result when given the final patch', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit, editFile } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line one', replacement: 'LINE ONE' }], + }); + const patch2 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line two', replacement: 'LINE TWO' }], + previousPatchId: patch1.patchId, + }); + await call(editFile, { patchId: patch2.patchId, file: patch2.file }); + expect(await fs.readFile('/file.ts')).toBe('LINE ONE\nLINE TWO\nline three'); + }); + + it('can also EditFile at an intermediate patch (rollback point)', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'line one\nline two\nline three' }); + const { previewEdit, editFile } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line one', replacement: 'LINE ONE' }], + }); + // build patch2 but don't apply it + await call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'line two', replacement: 'LINE TWO' }], + previousPatchId: patch1.patchId, + }); + // apply only patch1 + await call(editFile, { patchId: patch1.patchId, file: patch1.file }); + expect(await fs.readFile('/file.ts')).toBe('LINE ONE\nline two\nline three'); + }); + + it('throws when previousPatchId does not exist in store', async () => { + const fs = new MemoryFileSystem({ '/file.ts': 'hello' }); + const { previewEdit } = createEditFilePair(fs); + await expect( + call(previewEdit, { + file: '/file.ts', + edits: [{ action: 'replace_text', oldString: 'hello', replacement: 'world' }], + previousPatchId: '00000000-0000-4000-8000-000000000000', + }), + ).rejects.toThrow('Previous patch not found'); + }); + + it('throws when previousPatchId is for a different file', async () => { + const fs = new MemoryFileSystem({ '/a.ts': 'hello', '/b.ts': 'world' }); + const { previewEdit } = createEditFilePair(fs); + const patch1 = await call(previewEdit, { + file: '/a.ts', + edits: [{ action: 'replace_text', oldString: 'hello', replacement: 'HELLO' }], + }); + await expect( + call(previewEdit, { + file: '/b.ts', + edits: [{ action: 'replace_text', oldString: 'world', replacement: 'WORLD' }], + previousPatchId: patch1.patchId, + }), + ).rejects.toThrow('File mismatch'); + }); +}); diff --git a/packages/claude-sdk-tools/test/Exec.spec.ts b/packages/claude-sdk-tools/test/Exec.spec.ts new file mode 100644 index 0000000..ca49429 --- /dev/null +++ b/packages/claude-sdk-tools/test/Exec.spec.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest'; +import { Exec } from '../src/entry/Exec'; +import { call } from './helpers'; + +describe('Exec \u2014 basic execution', () => { + it('runs a command and captures stdout', async () => { + const result = await call(Exec, { + description: 'echo hello', + steps: [{ commands: [{ program: 'echo', args: ['hello'] }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('hello'); + }); + + it('trims trailing whitespace from stdout', async () => { + // echo appends a newline; the handler calls trimEnd() + const result = await call(Exec, { + description: 'echo with trailing newline', + steps: [{ commands: [{ program: 'echo', args: ['hello'] }] }], + }); + expect(result.results[0].stdout).not.toMatch(/\n$/); + }); + + it('returns exitCode 0 on success', async () => { + const result = await call(Exec, { + description: 'true', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'exit 0'] }] }], + }); + expect(result.results[0].exitCode).toBe(0); + }); + + it('captures a non-zero exit code', async () => { + const result = await call(Exec, { + description: 'exit 42', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'exit 42'] }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].exitCode).toBe(42); + }); + + it('captures stderr separately from stdout', async () => { + const result = await call(Exec, { + description: 'write to stderr', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'echo error >&2'] }] }], + }); + expect(result.results[0].stderr).toBe('error'); + expect(result.results[0].stdout).toBe(''); + }); +}); + +describe('Exec \u2014 blocked commands', () => { + it('blocks rm', async () => { + const result = await call(Exec, { + description: 'try rm', + steps: [{ commands: [{ program: 'rm', args: ['-rf', '/tmp/safe'] }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('BLOCKED'); + }); + + it('blocks sudo', async () => { + const result = await call(Exec, { + description: 'try sudo', + steps: [{ commands: [{ program: 'sudo', args: ['ls'] }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('BLOCKED'); + }); + + it('blocks xargs', async () => { + const result = await call(Exec, { + description: 'try xargs', + steps: [{ commands: [{ program: 'xargs', args: ['echo'] }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('BLOCKED'); + }); + + it('includes the rule name in the error message', async () => { + const result = await call(Exec, { + description: 'try sudo', + steps: [{ commands: [{ program: 'sudo', args: ['ls'] }] }], + }); + expect(result.results[0].stderr).toContain('no-sudo'); + }); + + it('blocks all commands in a step — not just the first', async () => { + const result = await call(Exec, { + description: 'rm and sudo in same step', + steps: [ + { + commands: [ + { program: 'rm', args: ['/tmp/x'] }, + { program: 'sudo', args: ['ls'] }, + ], + }, + ], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('no-destructive-commands'); + expect(result.results[0].stderr).toContain('no-sudo'); + }); +}); + +describe('Exec \u2014 chaining', () => { + it('returns one result per completed step', async () => { + const result = await call(Exec, { + description: 'two steps', + steps: [{ commands: [{ program: 'echo', args: ['a'] }] }, { commands: [{ program: 'echo', args: ['b'] }] }], + }); + expect(result.success).toBe(true); + expect(result.results).toHaveLength(2); + expect(result.results[0].stdout).toBe('a'); + expect(result.results[1].stdout).toBe('b'); + }); + + it('stops at the first failure with bail_on_error (default)', async () => { + const result = await call(Exec, { + description: 'fail then echo', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'exit 1'] }] }, { commands: [{ program: 'echo', args: ['should not run'] }] }], + }); + expect(result.success).toBe(false); + expect(result.results).toHaveLength(1); + }); + + it('runs all steps with sequential chaining even after a failure', async () => { + const result = await call(Exec, { + description: 'sequential despite failure', + chaining: 'sequential', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'exit 1'] }] }, { commands: [{ program: 'echo', args: ['still runs'] }] }], + }); + expect(result.results).toHaveLength(2); + expect(result.results[1].stdout).toBe('still runs'); + }); + + it('reports overall success: false when any step fails', async () => { + const result = await call(Exec, { + description: 'mixed results', + chaining: 'sequential', + steps: [{ commands: [{ program: 'echo', args: ['ok'] }] }, { commands: [{ program: 'sh', args: ['-c', 'exit 1'] }] }], + }); + expect(result.success).toBe(false); + }); +}); + +describe('Exec \u2014 pipeline', () => { + it('pipes stdout of the first command into stdin of the second', async () => { + const result = await call(Exec, { + description: 'echo piped to grep', + steps: [ + { + commands: [ + { program: 'echo', args: ['hello'] }, + { program: 'grep', args: ['hello'] }, + ], + }, + ], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('hello'); + }); + + it('returns an error when a non-final pipeline command is not found', async () => { + const result = await call(Exec, { + description: 'bad first pipeline command', + steps: [{ commands: [{ program: 'definitely-not-a-real-command-xyz' }, { program: 'cat' }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('Command not found'); + }); +}); + +describe('Exec — redirect', () => { + it('does not capture redirected stdout in returned results', async () => { + const result = await call(Exec, { + description: 'redirect stdout', + steps: [{ commands: [{ program: 'echo', args: ['hello'], redirect: { path: '/dev/null', stream: 'stdout' } }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe(''); + }); + + it('does not capture redirected stderr in returned results', async () => { + const result = await call(Exec, { + description: 'redirect stderr', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'echo error >&2'], redirect: { path: '/dev/null', stream: 'stderr' } }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stderr).toBe(''); + }); +}); + +describe('Exec \u2014 stripAnsi', () => { + it('strips ANSI codes from stdout by default', async () => { + const result = await call(Exec, { + description: 'ansi output', + steps: [{ commands: [{ program: 'node', args: ['-e', "process.stdout.write('\\x1b[31mred\\x1b[0m')"] }] }], + }); + expect(result.results[0].stdout).toBe('red'); + }); + + it('preserves ANSI codes when stripAnsi is false', async () => { + const result = await call(Exec, { + description: 'ansi output preserved', + stripAnsi: false, + steps: [{ commands: [{ program: 'node', args: ['-e', "process.stdout.write('\\x1b[31mred\\x1b[0m')"] }] }], + }); + expect(result.results[0].stdout).toContain('\x1b['); + }); +}); + +describe('Exec — command features', () => { + it('respects cwd per command', async () => { + const result = await call(Exec, { + description: 'cwd test', + steps: [{ commands: [{ program: 'node', args: ['-e', 'process.stdout.write(process.cwd())'], cwd: '/' }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('/'); + }); + + it('merges custom env vars with the process environment', async () => { + const result = await call(Exec, { + description: 'env test', + steps: [{ commands: [{ program: 'node', args: ['-e', 'process.stdout.write(process.env.EXEC_TEST_VAR ?? "missing")'], env: { EXEC_TEST_VAR: 'hello' } }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('hello'); + }); + + it('pipes stdin content to the command', async () => { + const result = await call(Exec, { + description: 'stdin test', + steps: [{ commands: [{ program: 'cat', stdin: 'hello world' }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('hello world'); + }); + + it('merge_stderr routes stderr output into stdout', async () => { + const result = await call(Exec, { + description: 'merge_stderr test', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'echo from_stderr >&2'], merge_stderr: true }] }], + }); + expect(result.success).toBe(true); + expect(result.results[0].stdout).toBe('from_stderr'); + expect(result.results[0].stderr).toBe(''); + }); +}); + +describe('Exec — error handling', () => { + it('returns exitCode 127 and an error message when the command is not found', async () => { + const result = await call(Exec, { + description: 'unknown command', + steps: [{ commands: [{ program: 'definitely-not-a-real-command-xyzzy-abc' }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].exitCode).toBe(127); + expect(result.results[0].stderr).toContain('Command not found'); + }); + + it('returns exitCode 126 and an error message when the cwd does not exist', async () => { + const result = await call(Exec, { + description: 'bad cwd', + steps: [{ commands: [{ program: 'echo', args: ['hello'], cwd: '/nonexistent/path/xyz123abc' }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].exitCode).toBe(126); + expect(result.results[0].stderr).toContain('Working directory not found'); + }); +}); + +describe('Exec — blocked rules (extended)', () => { + // Helper: generates a blocked-rule test inline + const expectBlocked = (label: string, program: string, args: string[]) => + it(label, async () => { + const result = await call(Exec, { + description: label, + steps: [{ commands: [{ program, args }] }], + }); + expect(result.success).toBe(false); + expect(result.results[0].stderr).toContain('BLOCKED'); + }); + + expectBlocked('blocks rmdir (no-destructive-commands)', 'rmdir', ['/tmp/x']); + expectBlocked('blocks sed -i (no-sed-in-place)', 'sed', ['-i', 's/a/b/', '/tmp/test.txt']); + expectBlocked('blocks sed --in-place (no-sed-in-place)', 'sed', ['--in-place', 's/a/b/', '/tmp/test.txt']); + expectBlocked('blocks git rm (no-git-rm)', 'git', ['rm', 'file.ts']); + expectBlocked('blocks git checkout (no-git-checkout)', 'git', ['checkout', 'main']); + expectBlocked('blocks git reset (no-git-reset)', 'git', ['reset', 'HEAD~1']); + expectBlocked('blocks git push -f (no-force-push)', 'git', ['push', '-f']); + expectBlocked('blocks git push --force (no-force-push)', 'git', ['push', '--force']); + expectBlocked('blocks .exe programs (no-exe)', 'program.exe', []); + expectBlocked('blocks env without arguments (no-env-dump)', 'env', []); + expectBlocked('blocks printenv without arguments (no-env-dump)', 'printenv', []); + expectBlocked('blocks git -C (no-git-C)', 'git', ['-C', '/some/path', 'status']); + expectBlocked('blocks pnpm -C (no-pnpm-C)', 'pnpm', ['-C', '/some/path', 'install']); + expectBlocked('blocks git clean (no-git-clean)', 'git', ['clean', '-fd']); +}); + +describe('Exec — validation is upfront', () => { + it('a blocked command in any step prevents all steps from running', async () => { + const result = await call(Exec, { + description: 'echo then rm', + steps: [{ commands: [{ program: 'echo', args: ['should not run'] }] }, { commands: [{ program: 'rm', args: ['/tmp/x'] }] }], + }); + expect(result.success).toBe(false); + // Only one synthetic blocked result — the echo step never ran + expect(result.results).toHaveLength(1); + expect(result.results[0].stderr).toContain('BLOCKED'); + expect(result.results[0].stdout).toBe(''); + }); +}); + +describe('Exec — chaining: independent', () => { + it('runs all steps and reports each even after a failure', async () => { + const result = await call(Exec, { + description: 'independent chaining', + chaining: 'independent', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'exit 1'] }] }, { commands: [{ program: 'echo', args: ['still runs'] }] }], + }); + expect(result.results).toHaveLength(2); + expect(result.results[1].stdout).toBe('still runs'); + }); + + it('runs steps concurrently, not sequentially', async () => { + // Two steps that each sleep 200ms. Sequential = ~400ms, parallel = ~200ms. + const start = Date.now(); + const result = await call(Exec, { + description: 'parallel timing', + chaining: 'independent', + steps: [{ commands: [{ program: 'sh', args: ['-c', 'sleep 0.2 && echo step1'] }] }, { commands: [{ program: 'sh', args: ['-c', 'sleep 0.2 && echo step2'] }] }], + }); + const elapsed = Date.now() - start; + expect(result.results[0].stdout).toBe('step1'); + expect(result.results[1].stdout).toBe('step2'); + // If truly parallel both 200ms sleeps overlap — total ~200ms, not ~400ms. + expect(elapsed).toBeLessThan(350); + }); +}); diff --git a/packages/claude-sdk-tools/test/Find.spec.ts b/packages/claude-sdk-tools/test/Find.spec.ts new file mode 100644 index 0000000..27f6a8c --- /dev/null +++ b/packages/claude-sdk-tools/test/Find.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { createFind } from '../src/Find/Find'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { call } from './helpers'; + +const makeFs = () => + new MemoryFileSystem({ + '/src/index.ts': 'export const x = 1;', + '/src/utils.ts': 'export function util() {}', + '/src/components/Button.tsx': 'export const Button = () => null;', + '/test/index.spec.ts': 'describe("suite", () => {});', + '/README.md': '# Project', + }); + +describe('createFind u2014 file results', () => { + it('returns all files under a directory', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src' }); + expect(result).toMatchObject({ type: 'files' }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/index.ts'); + expect(values).toContain('/src/utils.ts'); + expect(values).toContain('/src/components/Button.tsx'); + }); + + it('filters by glob pattern', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src', pattern: '*.ts' }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/index.ts'); + expect(values).toContain('/src/utils.ts'); + expect(values).not.toContain('/src/components/Button.tsx'); + }); + + it('respects maxDepth', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src', maxDepth: 1 }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/index.ts'); + expect(values).toContain('/src/utils.ts'); + expect(values).not.toContain('/src/components/Button.tsx'); + }); + + it('excludes specified directory names', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src', exclude: ['components'] }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).not.toContain('/src/components/Button.tsx'); + expect(values).toContain('/src/index.ts'); + }); + + it('** glob pattern matches files in subdirectories', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/', pattern: '**/*.ts' }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/index.ts'); + expect(values).toContain('/src/utils.ts'); + expect(values).not.toContain('/src/components/Button.tsx'); + }); +}); + +describe('createFind u2014 directory results', () => { + it('returns directories when type is directory', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src', type: 'directory' }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/components'); + expect(values).not.toContain('/src/index.ts'); + }); + + it('returns both files and directories when type is both', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/src', type: 'both' }); + const { values } = result as { type: 'files'; values: string[] }; + expect(values).toContain('/src/index.ts'); + expect(values).toContain('/src/components'); + }); +}); + +describe('createFind u2014 error handling', () => { + it('returns an error object for a non-existent directory', async () => { + const Find = createFind(makeFs()); + const result = await call(Find, { path: '/nonexistent' }); + expect(result).toMatchObject({ + error: true, + message: 'Directory not found', + path: '/nonexistent', + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/Grep.spec.ts b/packages/claude-sdk-tools/test/Grep.spec.ts new file mode 100644 index 0000000..084bebd --- /dev/null +++ b/packages/claude-sdk-tools/test/Grep.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { Grep } from '../src/Grep/Grep'; +import { call } from './helpers'; + +describe('Grep u2014 PipeFiles', () => { + it('filters file paths matching the pattern', async () => { + const result = (await call(Grep, { pattern: '.ts$', content: { type: 'files', values: ['src/foo.ts', 'src/bar.ts', 'src/readme.md'] } })) as { type: 'files'; values: string[] }; + expect(result.values).toEqual(['src/foo.ts', 'src/bar.ts']); + }); + + it('returns empty values when no paths match', async () => { + const result = (await call(Grep, { pattern: '.ts$', content: { type: 'files', values: ['src/readme.md'] } })) as { type: 'files'; values: string[] }; + expect(result.values).toEqual([]); + }); + + it('emits PipeFiles type', async () => { + const result = (await call(Grep, { pattern: 'foo', content: { type: 'files', values: ['foo.ts'] } })) as { type: string }; + expect(result.type).toEqual('files'); + }); + + it('matches case insensitively when flag is set', async () => { + const result = (await call(Grep, { pattern: '.ts$', caseInsensitive: true, content: { type: 'files', values: ['SRC/FOO.TS', 'SRC/README.MD'] } })) as { type: 'files'; values: string[] }; + expect(result.values).toEqual(['SRC/FOO.TS']); + }); +}); + +describe('Grep u2014 PipeContent', () => { + it('filters lines matching the pattern', async () => { + const result = (await call(Grep, { pattern: '^export', content: { type: 'content', values: ['export const x = 1;', 'const y = 2;'], totalLines: 2 } })) as { type: 'content'; values: string[] }; + expect(result.values).toEqual(['export const x = 1;']); + }); + + it('emits PipeContent type', async () => { + const result = (await call(Grep, { pattern: 'foo', content: { type: 'content', values: ['foo'], totalLines: 1 } })) as { type: string }; + expect(result.type).toEqual('content'); + }); + + it('passes totalLines through unchanged', async () => { + const result = (await call(Grep, { pattern: 'foo', content: { type: 'content', values: ['foo', 'bar'], totalLines: 10 } })) as { totalLines: number }; + expect(result.totalLines).toEqual(10); + }); + + it('passes path through unchanged', async () => { + const result = (await call(Grep, { pattern: 'foo', content: { type: 'content', values: ['foo'], totalLines: 1, path: '/src/foo.ts' } })) as { path?: string }; + expect(result.path).toEqual('/src/foo.ts'); + }); + + it('includes context lines around a match', async () => { + const result = (await call(Grep, { pattern: 'match', context: 1, content: { type: 'content', values: ['before', 'match', 'after'], totalLines: 3 } })) as { values: string[] }; + expect(result.values).toEqual(['before', 'match', 'after']); + }); + + it('does not include lines outside the context window', async () => { + const result = (await call(Grep, { pattern: 'match', context: 1, content: { type: 'content', values: ['a', 'b', 'match', 'c', 'd'], totalLines: 5 } })) as { values: string[] }; + expect(result.values).toEqual(['b', 'match', 'c']); + }); + + it('returns empty values when no lines match', async () => { + const result = (await call(Grep, { pattern: 'xyz', content: { type: 'content', values: ['foo', 'bar'], totalLines: 2 } })) as { values: string[] }; + expect(result.values).toEqual([]); + }); + + it('returns empty content when content is null', async () => { + const result = (await call(Grep, { pattern: 'foo', content: undefined })) as { values: string[] }; + expect(result.values).toEqual([]); + }); + + it('emits 1-based lineNumbers for matched lines', async () => { + const result = (await call(Grep, { pattern: 'match', content: { type: 'content', values: ['a', 'match', 'b', 'match'], totalLines: 4 } })) as { lineNumbers: number[] }; + expect(result.lineNumbers).toEqual([2, 4]); + }); + + it('lineNumbers include context lines with correct original positions', async () => { + const result = (await call(Grep, { pattern: 'match', context: 1, content: { type: 'content', values: ['a', 'b', 'match', 'c', 'd'], totalLines: 5 } })) as { lineNumbers: number[] }; + expect(result.lineNumbers).toEqual([2, 3, 4]); + }); + + it('lineNumbers thread through when input already has lineNumbers (chained Grep)', async () => { + // First grep: lines 2 and 4 of 6 match 'keep' + const first = (await call(Grep, { pattern: 'keep', content: { type: 'content', values: ['a', 'keep', 'b', 'keep', 'c', 'd'], totalLines: 6 } })) as { type: 'content'; values: string[]; lineNumbers: number[] }; + expect(first.lineNumbers).toEqual([2, 4]); + // Second grep on first result: only line 4 ('keep2') matches + const second = (await call(Grep, { pattern: 'keep2', content: { ...first, totalLines: first.values.length, values: ['keep1', 'keep2'] } })) as { lineNumbers: number[] }; + expect(second.lineNumbers).toEqual([4]); + }); +}); diff --git a/packages/claude-sdk-tools/test/Head.spec.ts b/packages/claude-sdk-tools/test/Head.spec.ts new file mode 100644 index 0000000..61a0aa7 --- /dev/null +++ b/packages/claude-sdk-tools/test/Head.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { Head } from '../src/Head/Head'; +import { call } from './helpers'; + +describe('Head — PipeFiles', () => { + it('returns the first N file paths', async () => { + const result = (await call(Head, { count: 2, content: { type: 'files', values: ['a.ts', 'b.ts', 'c.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['a.ts', 'b.ts']); + }); + + it('returns all paths when count exceeds length', async () => { + const result = (await call(Head, { count: 10, content: { type: 'files', values: ['a.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['a.ts']); + }); + + it('emits PipeFiles type', async () => { + const result = (await call(Head, { count: 1, content: { type: 'files', values: ['a.ts'] } })) as { type: string }; + expect(result.type).toEqual('files'); + }); +}); + +describe('Head — PipeContent', () => { + it('returns the first N lines', async () => { + const result = (await call(Head, { count: 2, content: { type: 'content', values: ['line1', 'line2', 'line3'], totalLines: 3 } })) as { values: string[] }; + expect(result.values).toEqual(['line1', 'line2']); + }); + + it('returns all lines when count exceeds length', async () => { + const result = (await call(Head, { count: 10, content: { type: 'content', values: ['line1'], totalLines: 1 } })) as { values: string[] }; + expect(result.values).toEqual(['line1']); + }); + + it('passes totalLines through unchanged', async () => { + const result = (await call(Head, { count: 5, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 100 } })) as { totalLines: number }; + expect(result.totalLines).toEqual(100); + }); + + it('passes path through unchanged', async () => { + const result = (await call(Head, { count: 1, content: { type: 'content', values: ['x'], totalLines: 1, path: '/src/foo.ts' } })) as { path?: string }; + expect(result.path).toEqual('/src/foo.ts'); + }); + + it('returns empty content when content is null', async () => { + const result = (await call(Head, { count: 10, content: undefined })) as { values: string[] }; + expect(result.values).toEqual([]); + }); +}); diff --git a/packages/claude-sdk-tools/test/Pipe.spec.ts b/packages/claude-sdk-tools/test/Pipe.spec.ts new file mode 100644 index 0000000..bc77239 --- /dev/null +++ b/packages/claude-sdk-tools/test/Pipe.spec.ts @@ -0,0 +1,166 @@ +import type { AnyToolDefinition } from '@shellicar/claude-sdk'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { Grep } from '../src/Grep/Grep'; +import { Head } from '../src/Head/Head'; +import { createPipe } from '../src/Pipe/Pipe'; +import { Range } from '../src/Range/Range'; +import { call } from './helpers'; + +/** Build a minimal read tool that passes its input straight through as its output. */ +function passthrough(name: string, schema: z.ZodType = z.unknown()): AnyToolDefinition { + return { + name, + description: name, + operation: 'read', + input_schema: schema, + input_examples: [], + handler: async (input) => input, + }; +} + +describe('Pipe', () => { + describe('basic chaining', () => { + it('calls the single step tool and returns its result', async () => { + const pipe = createPipe([Head]); + const result = await call(pipe, { + steps: [{ tool: 'Head', input: { count: 2, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 3 } } }], + }); + expect(result).toEqual({ type: 'content', values: ['a', 'b'], totalLines: 3, path: undefined }); + }); + + it('threads the output of one step into the content of the next', async () => { + const pipe = createPipe([Head, Grep]); + const result = await call(pipe, { + steps: [ + { tool: 'Head', input: { count: 2, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 3 } } }, + { tool: 'Grep', input: { pattern: '^a$' } }, + ], + }); + expect(result).toEqual({ type: 'content', values: ['a'], totalLines: 3, path: undefined, lineNumbers: [1] }); + }); + + it('threads an empty intermediate result through the chain', async () => { + // Grep that matches nothing → empty content → Range gets nothing + const pipe = createPipe([Head, Grep, Range]); + const result = await call(pipe, { + steps: [ + { tool: 'Head', input: { count: 3, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 3 } } }, + { tool: 'Grep', input: { pattern: 'NOMATCH' } }, + { tool: 'Range', input: { start: 1, end: 5 } }, + ], + }); + // Grep returns empty values; Range of an empty array is still empty + expect(result).toMatchObject({ type: 'content', values: [] }); + }); + + it('returns the last step result when chain has three steps', async () => { + const pipe = createPipe([Head, Grep]); + const result = await call(pipe, { + steps: [ + { tool: 'Head', input: { count: 3, content: { type: 'content', values: ['foo', 'bar', 'baz'], totalLines: 3 } } }, + { tool: 'Grep', input: { pattern: 'ba' } }, + ], + }); + expect(result).toMatchObject({ values: ['bar', 'baz'] }); + }); + }); + + describe('error handling', () => { + it('throws when a tool name is not registered', async () => { + const pipe = createPipe([]); + const promise = call(pipe, { steps: [{ tool: 'Unknown', input: {} }] }); + await expect(promise).rejects.toThrow('Pipe: unknown tool "Unknown"'); + }); + + it('throws when a write tool is used in a pipe', async () => { + const writeTool: AnyToolDefinition = { + name: 'WriteOp', + description: 'A write operation', + operation: 'write', + input_schema: z.object({}), + input_examples: [], + handler: async () => 'done', + }; + const pipe = createPipe([writeTool]); + const promise = call(pipe, { steps: [{ tool: 'WriteOp', input: {} }] }); + await expect(promise).rejects.toThrow('only read tools may be used in a pipe'); + }); + + it('throws when a step input fails schema validation', async () => { + const strictTool: AnyToolDefinition = { + name: 'StrictTool', + description: 'Requires specific input', + operation: 'read', + input_schema: z.object({ required: z.string() }), + input_examples: [], + handler: async () => 'done', + }; + const pipe = createPipe([strictTool]); + const promise = call(pipe, { steps: [{ tool: 'StrictTool', input: {} }] }); + await expect(promise).rejects.toThrow('Pipe: step "StrictTool" input validation failed'); + }); + + it('propagates an exception thrown by a mid-chain handler', async () => { + const boom: AnyToolDefinition = { + name: 'Boom', + description: 'Always throws', + operation: 'read', + input_schema: z.object({}).passthrough(), + input_examples: [], + handler: async () => { + throw new Error('mid-chain boom'); + }, + }; + const after = passthrough('After'); + + const pipe = createPipe([passthrough('Before'), boom, after]); + const promise = call(pipe, { + steps: [ + { tool: 'Before', input: {} }, + { tool: 'Boom', input: {} }, + { tool: 'After', input: {} }, + ], + }); + await expect(promise).rejects.toThrow('mid-chain boom'); + }); + + it('stops after a mid-chain handler throws — subsequent steps are not called', async () => { + let afterCalled = false; + const boom: AnyToolDefinition = { + name: 'Boom', + description: 'Always throws', + operation: 'read', + input_schema: z.object({}).passthrough(), + input_examples: [], + handler: async () => { + throw new Error('abort'); + }, + }; + const after: AnyToolDefinition = { + name: 'After', + description: 'Should not run', + operation: 'read', + input_schema: z.object({}).passthrough(), + input_examples: [], + handler: async () => { + afterCalled = true; + return 'ran'; + }, + }; + + const pipe = createPipe([passthrough('Before'), boom, after]); + await expect( + call(pipe, { + steps: [ + { tool: 'Before', input: {} }, + { tool: 'Boom', input: {} }, + { tool: 'After', input: {} }, + ], + }), + ).rejects.toThrow('abort'); + + expect(afterCalled).toBe(false); + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/Range.spec.ts b/packages/claude-sdk-tools/test/Range.spec.ts new file mode 100644 index 0000000..60a0ee0 --- /dev/null +++ b/packages/claude-sdk-tools/test/Range.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { Range } from '../src/Range/Range'; +import { call } from './helpers'; + +describe('Range u2014 PipeFiles', () => { + it('returns file paths at the given 1-based inclusive positions', async () => { + const result = (await call(Range, { start: 2, end: 3, content: { type: 'files', values: ['a.ts', 'b.ts', 'c.ts', 'd.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['b.ts', 'c.ts']); + }); + + it('emits PipeFiles type', async () => { + const result = (await call(Range, { start: 1, end: 1, content: { type: 'files', values: ['a.ts'] } })) as { type: string }; + expect(result.type).toEqual('files'); + }); + + it('clamps to the end of the list when end exceeds the length', async () => { + const result = (await call(Range, { start: 2, end: 100, content: { type: 'files', values: ['a.ts', 'b.ts', 'c.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['b.ts', 'c.ts']); + }); +}); + +describe('Range u2014 PipeContent', () => { + it('returns lines at the given 1-based inclusive positions', async () => { + const result = (await call(Range, { start: 2, end: 3, content: { type: 'content', values: ['line1', 'line2', 'line3', 'line4'], totalLines: 4 } })) as { values: string[] }; + expect(result.values).toEqual(['line2', 'line3']); + }); + + it('emits PipeContent type', async () => { + const result = (await call(Range, { start: 1, end: 1, content: { type: 'content', values: ['a'], totalLines: 1 } })) as { type: string }; + expect(result.type).toEqual('content'); + }); + + it('passes totalLines through unchanged', async () => { + const result = (await call(Range, { start: 1, end: 2, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 100 } })) as { totalLines: number }; + expect(result.totalLines).toEqual(100); + }); + + it('passes path through unchanged', async () => { + const result = (await call(Range, { start: 1, end: 1, content: { type: 'content', values: ['x'], totalLines: 1, path: '/src/foo.ts' } })) as { path?: string }; + expect(result.path).toEqual('/src/foo.ts'); + }); + + it('returns empty content when content is null', async () => { + const result = (await call(Range, { start: 1, end: 10, content: undefined })) as { values: string[] }; + expect(result.values).toEqual([]); + }); +}); diff --git a/packages/claude-sdk-tools/test/ReadFile.spec.ts b/packages/claude-sdk-tools/test/ReadFile.spec.ts new file mode 100644 index 0000000..d0ab7a8 --- /dev/null +++ b/packages/claude-sdk-tools/test/ReadFile.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { createReadFile } from '../src/ReadFile/ReadFile'; +import { call } from './helpers'; + +const makeFs = () => + new MemoryFileSystem({ + '/src/hello.ts': 'const a = 1;\nconst b = 2;\nconst c = 3;', + '/src/single.ts': 'single line', + }); + +describe('createReadFile \u2014 success', () => { + it('returns lines as content output', async () => { + const ReadFile = createReadFile(makeFs()); + const result = await call(ReadFile, { path: '/src/hello.ts' }); + expect(result).toMatchObject({ + type: 'content', + values: ['const a = 1;', 'const b = 2;', 'const c = 3;'], + totalLines: 3, + path: '/src/hello.ts', + }); + }); + + it('returns a single-element array for a single-line file', async () => { + const ReadFile = createReadFile(makeFs()); + const result = await call(ReadFile, { path: '/src/single.ts' }); + expect(result).toMatchObject({ type: 'content', values: ['single line'], totalLines: 1 }); + }); + + it('returns correct totalLines matching values length', async () => { + const ReadFile = createReadFile(makeFs()); + const result = await call(ReadFile, { path: '/src/hello.ts' }); + const content = result as { values: string[]; totalLines: number }; + expect(content.totalLines).toBe(content.values.length); + }); + + it('echoes the resolved path in the output', async () => { + const ReadFile = createReadFile(makeFs()); + const result = await call(ReadFile, { path: '/src/hello.ts' }); + expect((result as { path: string }).path).toBe('/src/hello.ts'); + }); +}); + +describe('createReadFile \u2014 error handling', () => { + it('returns an error object for a missing file', async () => { + const ReadFile = createReadFile(makeFs()); + const result = await call(ReadFile, { path: '/src/missing.ts' }); + expect(result).toMatchObject({ error: true, message: 'File not found', path: '/src/missing.ts' }); + }); +}); + +describe('createReadFile — size limit', () => { + it('returns an error for files exceeding the size limit', async () => { + const bigContent = 'x'.repeat(501_000); + const fs = new MemoryFileSystem({ '/logs/huge.log': bigContent }); + const ReadFile = createReadFile(fs); + const result = await call(ReadFile, { path: '/logs/huge.log' }); + expect(result).toMatchObject({ + error: true, + message: expect.stringContaining('too large'), + path: '/logs/huge.log', + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/Ref.spec.ts b/packages/claude-sdk-tools/test/Ref.spec.ts new file mode 100644 index 0000000..1149846 --- /dev/null +++ b/packages/claude-sdk-tools/test/Ref.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { createRef } from '../src/Ref/Ref'; +import { RefStore } from '../src/RefStore/RefStore'; +import { call } from './helpers'; + +const makeStore = (entries: Record = {}) => { + const store = new RefStore(); + const ids: Record = {}; + for (const [key, value] of Object.entries(entries)) { + ids[key] = store.store(value, key); + } + return { store, ids }; +}; + +describe('createRef — full fetch', () => { + it('returns full content for a known ref', async () => { + const { store, ids } = makeStore({ a: 'hello world' }); + const { tool: Ref } = createRef(store, 1000); + const result = await call(Ref, { id: ids.a }); + // default start=0, limit=1000 — content is 11 chars so end clamps to 11 + expect(result).toMatchObject({ found: true, content: 'hello world', totalSize: 11, start: 0, end: 11 }); + }); + + it('includes the hint in the response', async () => { + const { store, ids } = makeStore({ mykey: 'some content' }); + const { tool: Ref } = createRef(store, 1000); + const result = (await call(Ref, { id: ids.mykey })) as { found: boolean; hint: string }; + expect(result.found).toBe(true); + expect(result.hint).toBe('mykey'); + }); + + it('returns found: false for an unknown id', async () => { + const { store } = makeStore(); + const { tool: Ref } = createRef(store, 1000); + const result = await call(Ref, { id: 'no-such-id' }); + expect(result).toMatchObject({ found: false, id: 'no-such-id' }); + }); +}); + +describe('createRef — slicing', () => { + it('returns a slice when start and limit are given', async () => { + const { store, ids } = makeStore({ a: 'abcdefghij' }); + const { tool: Ref } = createRef(store, 1000); + const result = await call(Ref, { id: ids.a, start: 2, limit: 3 }); + expect(result).toMatchObject({ found: true, content: 'cde', totalSize: 10, start: 2, end: 5 }); + }); + + it('clamps start+limit to totalSize', async () => { + const { store, ids } = makeStore({ a: 'hello' }); + const { tool: Ref } = createRef(store, 1000); + const result = await call(Ref, { id: ids.a, start: 0, limit: 2000 }); + expect(result).toMatchObject({ found: true, content: 'hello', totalSize: 5, end: 5 }); + }); + + it('pages from a non-zero start using default limit', async () => { + const { store, ids } = makeStore({ a: 'abcdef' }); + const { tool: Ref } = createRef(store, 1000); + const result = await call(Ref, { id: ids.a, start: 3 }); + // limit defaults to 1000; content is 6 chars so end clamps to 6 + expect(result).toMatchObject({ found: true, content: 'def', totalSize: 6, start: 3, end: 6 }); + }); + + it('default start=0, limit=1000 never dumps the whole ref for large content', async () => { + const bigContent = 'x'.repeat(5000); + const store = new RefStore(); + const id = store.store(bigContent); + const { tool: Ref } = createRef(store, 10); + const result = (await call(Ref, { id })) as { found: boolean; content: string; end: number }; + expect(result.found).toBe(true); + expect(result.content.length).toBe(1000); + expect(result.end).toBe(1000); + }); +}); + +describe('createRef — transformToolResult', () => { + it('ref-swaps large strings from other tools', () => { + const store = new RefStore(); + const { transformToolResult } = createRef(store, 10); + const output = { exitCode: 0, stdout: 'x'.repeat(20) }; + const result = transformToolResult('Exec', output) as any; + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatchObject({ ref: expect.any(String), size: 20 }); + expect(store.count).toBe(1); + }); + + it('does not ref-swap the Ref tool\u2019s own output', () => { + const store = new RefStore(); + const { transformToolResult } = createRef(store, 10); + const output = { found: true, content: 'x'.repeat(20), totalSize: 20, start: 0, end: 20 }; + const result = transformToolResult('Ref', output) as any; + // content passes through unchanged — no ref token, nothing stored + expect(result.content).toBe('x'.repeat(20)); + expect(store.count).toBe(0); + }); + + it('passes small strings through without storing', () => { + const store = new RefStore(); + const { transformToolResult } = createRef(store, 100); + const output = { exitCode: 0, stdout: 'short' }; + const result = transformToolResult('Exec', output) as any; + expect(result.stdout).toBe('short'); + expect(store.count).toBe(0); + }); +}); diff --git a/packages/claude-sdk-tools/test/RefStore.spec.ts b/packages/claude-sdk-tools/test/RefStore.spec.ts new file mode 100644 index 0000000..795ea73 --- /dev/null +++ b/packages/claude-sdk-tools/test/RefStore.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { RefStore } from '../src/RefStore/RefStore'; + +describe('RefStore — store and retrieve', () => { + it('stores content and returns a uuid', () => { + const store = new RefStore(); + const id = store.store('hello world'); + expect(id).toMatch(/^[0-9a-f-]{36}$/); + expect(store.get(id)).toBe('hello world'); + }); + + it('returns undefined for unknown id', () => { + const store = new RefStore(); + expect(store.get('does-not-exist')).toBeUndefined(); + }); + + it('tracks count and bytes', () => { + const store = new RefStore(); + store.store('abc'); + store.store('defgh'); + expect(store.count).toBe(2); + expect(store.bytes).toBe(8); + }); + + it('deletes entries', () => { + const store = new RefStore(); + const id = store.store('hello'); + store.delete(id); + expect(store.get(id)).toBeUndefined(); + expect(store.count).toBe(0); + }); +}); + +describe('RefStore.walkAndRef — passthrough', () => { + it('passes through short strings unchanged', () => { + const store = new RefStore(); + expect(store.walkAndRef('short', 100)).toBe('short'); + }); + + it('passes through numbers unchanged', () => { + const store = new RefStore(); + expect(store.walkAndRef(42, 10)).toBe(42); + }); + + it('passes through booleans unchanged', () => { + const store = new RefStore(); + expect(store.walkAndRef(true, 10)).toBe(true); + }); + + it('passes through null unchanged', () => { + const store = new RefStore(); + expect(store.walkAndRef(null, 10)).toBeNull(); + }); +}); + +describe('RefStore.walkAndRef — string replacement', () => { + it('replaces a string exceeding threshold with a ref token', () => { + const store = new RefStore(); + const large = 'x'.repeat(101); + const result = store.walkAndRef(large, 100) as { ref: string; size: number }; + expect(result).toMatchObject({ ref: expect.any(String), size: 101 }); + expect(store.get(result.ref)).toBe(large); + }); + + it('does not replace a string exactly at the threshold', () => { + const store = new RefStore(); + const exact = 'x'.repeat(100); + expect(store.walkAndRef(exact, 100)).toBe(exact); + }); +}); + +describe('RefStore.walkAndRef — object tree', () => { + it('replaces only the large string field, leaving small fields intact', () => { + const store = new RefStore(); + const large = 'y'.repeat(200); + const input = { exitCode: 0, stdout: large, stderr: '' }; + const result = store.walkAndRef(input, 100) as { exitCode: number; stdout: { ref: string; size: number }; stderr: string }; + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toMatchObject({ ref: expect.any(String), size: 200 }); + expect(store.get(result.stdout.ref)).toBe(large); + expect(store.count).toBe(1); + }); + + it('handles nested objects', () => { + const store = new RefStore(); + const large = 'z'.repeat(200); + const input = { outer: { inner: large, small: 'ok' } }; + const result = store.walkAndRef(input, 100) as { outer: { inner: { ref: string }; small: string } }; + + expect(result.outer.small).toBe('ok'); + expect(result.outer.inner).toMatchObject({ ref: expect.any(String), size: 200 }); + }); + + it('leaves an object with all-small values completely unchanged in shape', () => { + const store = new RefStore(); + const input = { a: 'hello', b: 42, c: true }; + const result = store.walkAndRef(input, 100); + expect(result).toEqual({ a: 'hello', b: 42, c: true }); + expect(store.count).toBe(0); + }); +}); + +describe('RefStore.walkAndRef — arrays', () => { + it('recurses into arrays, replacing large string elements', () => { + const store = new RefStore(); + const large = 'a'.repeat(200); + const result = store.walkAndRef(['small', large, 42], 100) as unknown[]; + + expect(result[0]).toBe('small'); + expect(result[1]).toMatchObject({ ref: expect.any(String), size: 200 }); + expect(result[2]).toBe(42); + }); + + it('does not ref small array elements', () => { + const store = new RefStore(); + const result = store.walkAndRef(['a', 'b', 'c'], 100); + expect(result).toEqual(['a', 'b', 'c']); + expect(store.count).toBe(0); + }); + + it('refs a large uniform string array as a single newline-joined ref', () => { + const store = new RefStore(); + // 100 lines of 20 chars each = 2000 chars joined, exceeds threshold of 100 + const lines = Array.from({ length: 100 }, (_, i) => `line ${String(i).padStart(3, '0')}: ${'x'.repeat(10)}`); + const result = store.walkAndRef(lines, 100) as { ref: string; size: number; hint: string }; + expect(result).toMatchObject({ ref: expect.any(String), size: expect.any(Number) }); + // Stored content is newline-joined — supports char-offset pagination + const stored = store.get(result.ref); + expect(stored).toBe(lines.join('\n')); + expect(store.count).toBe(1); + }); + + it('falls through to element-wise for mixed arrays (not all strings)', () => { + const store = new RefStore(); + // Contains a number — not a uniform string array + const large = 'a'.repeat(200); + const result = store.walkAndRef(['small', large, 42], 100) as unknown[]; + expect(result[0]).toBe('small'); + expect(result[1]).toMatchObject({ ref: expect.any(String), size: 200 }); + expect(result[2]).toBe(42); + expect(store.count).toBe(1); // only the large string, not the whole array + }); +}); diff --git a/packages/claude-sdk-tools/test/SearchFiles.spec.ts b/packages/claude-sdk-tools/test/SearchFiles.spec.ts new file mode 100644 index 0000000..b14f10a --- /dev/null +++ b/packages/claude-sdk-tools/test/SearchFiles.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; +import { createSearchFiles } from '../src/SearchFiles/SearchFiles'; +import { call } from './helpers'; + +const makeFs = () => + new MemoryFileSystem({ + '/src/a.ts': 'export const x = 1;\n// TODO: remove this\nexport const y = 2;', + '/src/b.ts': 'import { x } from "./a";\nconst z = x + 1;', + '/src/c.ts': 'no matches here', + }); + +const files = (values: string[]) => ({ type: 'files' as const, values }); + +describe('createSearchFiles \u2014 basic matching', () => { + it('returns lines matching the pattern', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'export', content: files(['/src/a.ts', '/src/b.ts']) }); + expect(result).toMatchObject({ type: 'content' }); + const { values } = result as { values: string[] }; + expect(values.some((v) => v.includes('export const x'))).toBe(true); + expect(values.some((v) => v.includes('export const y'))).toBe(true); + }); + + it('only includes files that have matches', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'export', content: files(['/src/a.ts', '/src/c.ts']) }); + const { values } = result as { values: string[] }; + expect(values.filter((v) => v.startsWith('/src/c.ts'))).toHaveLength(0); + }); + + it('formats results as path:line:content', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'TODO', content: files(['/src/a.ts']) }); + const { values } = result as { values: string[] }; + expect(values).toHaveLength(1); + expect(values[0]).toBe('/src/a.ts:2:// TODO: remove this'); + }); + + it('returns empty content when no matches', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'NOMATCHWHATSOEVER', content: files(['/src/a.ts', '/src/b.ts']) }); + expect(result).toMatchObject({ type: 'content', values: [], totalLines: 0 }); + }); + + it('returns empty content when content is null/undefined', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'export' }); + expect(result).toMatchObject({ type: 'content', values: [], totalLines: 0 }); + }); +}); + +describe('createSearchFiles \u2014 case insensitive', () => { + it('matches case-insensitively when flag is set', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'todo', caseInsensitive: true, content: files(['/src/a.ts']) }); + const { values } = result as { values: string[] }; + expect(values).toHaveLength(1); + expect(values[0]).toContain('TODO'); + }); + + it('does not match case-insensitively when flag is unset', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'todo', content: files(['/src/a.ts']) }); + const { values } = result as { values: string[] }; + expect(values).toHaveLength(0); + }); +}); + +describe('createSearchFiles \u2014 context lines', () => { + it('includes surrounding lines when context > 0', async () => { + const SearchFiles = createSearchFiles(makeFs()); + const result = await call(SearchFiles, { pattern: 'TODO', context: 1, content: files(['/src/a.ts']) }); + const { values } = result as { values: string[] }; + expect(values.length).toBe(3); + expect(values.some((v) => v.includes('export const x'))).toBe(true); + expect(values.some((v) => v.includes('TODO'))).toBe(true); + expect(values.some((v) => v.includes('export const y'))).toBe(true); + }); +}); diff --git a/packages/claude-sdk-tools/test/Tail.spec.ts b/packages/claude-sdk-tools/test/Tail.spec.ts new file mode 100644 index 0000000..0d06a8e --- /dev/null +++ b/packages/claude-sdk-tools/test/Tail.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { Tail } from '../src/Tail/Tail'; +import { call } from './helpers'; + +describe('Tail u2014 PipeFiles', () => { + it('returns the last N file paths', async () => { + const result = (await call(Tail, { count: 2, content: { type: 'files', values: ['a.ts', 'b.ts', 'c.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['b.ts', 'c.ts']); + }); + + it('returns all paths when count exceeds length', async () => { + const result = (await call(Tail, { count: 10, content: { type: 'files', values: ['a.ts'] } })) as { values: string[] }; + expect(result.values).toEqual(['a.ts']); + }); + + it('emits PipeFiles type', async () => { + const result = (await call(Tail, { count: 1, content: { type: 'files', values: ['a.ts'] } })) as { type: string }; + expect(result.type).toEqual('files'); + }); +}); + +describe('Tail u2014 PipeContent', () => { + it('returns the last N lines', async () => { + const result = (await call(Tail, { count: 2, content: { type: 'content', values: ['line1', 'line2', 'line3'], totalLines: 3 } })) as { values: string[] }; + expect(result.values).toEqual(['line2', 'line3']); + }); + + it('returns all lines when count exceeds length', async () => { + const result = (await call(Tail, { count: 10, content: { type: 'content', values: ['line1'], totalLines: 1 } })) as { values: string[] }; + expect(result.values).toEqual(['line1']); + }); + + it('passes totalLines through unchanged', async () => { + const result = (await call(Tail, { count: 5, content: { type: 'content', values: ['a', 'b', 'c'], totalLines: 100 } })) as { totalLines: number }; + expect(result.totalLines).toEqual(100); + }); + + it('passes path through unchanged', async () => { + const result = (await call(Tail, { count: 1, content: { type: 'content', values: ['x'], totalLines: 1, path: '/src/foo.ts' } })) as { path?: string }; + expect(result.path).toEqual('/src/foo.ts'); + }); + + it('returns empty content when content is null', async () => { + const result = (await call(Tail, { count: 10, content: undefined })) as { values: string[] }; + expect(result.values).toEqual([]); + }); +}); diff --git a/packages/claude-sdk-tools/test/expandPath.spec.ts b/packages/claude-sdk-tools/test/expandPath.spec.ts new file mode 100644 index 0000000..351083a --- /dev/null +++ b/packages/claude-sdk-tools/test/expandPath.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { expandPath } from '../src/expandPath'; +import { MemoryFileSystem } from '../src/fs/MemoryFileSystem'; + +describe('expandPath', () => { + const fs = new MemoryFileSystem({}, '/home/test'); + + describe('tilde expansion', () => { + it('expands ~ to home directory', () => { + expect(expandPath('~', fs)).toBe('/home/test'); + }); + + it('expands ~/path', () => { + expect(expandPath('~/projects', fs)).toBe('/home/test/projects'); + }); + + it('does not expand ~ in the middle of a string', () => { + expect(expandPath('/foo/~/bar', fs)).toBe('/foo/~/bar'); + }); + + it('does not expand ~username', () => { + expect(expandPath('~root/bin', fs)).toBe('~root/bin'); + }); + + it('uses fs.homedir() for ~ expansion', () => { + const customFs = new MemoryFileSystem({}, '/custom/home'); + expect(expandPath('~/projects', customFs)).toBe('/custom/home/projects'); + }); + + it('expands bare ~ using fs.homedir()', () => { + const overrideFs = new MemoryFileSystem({}, '/override'); + expect(expandPath('~', overrideFs)).toBe('/override'); + }); + }); + + describe('env var expansion', () => { + it('expands $VAR', () => { + process.env['TEST_EXPAND_VAR'] = '/test/value'; + expect(expandPath('$TEST_EXPAND_VAR', fs)).toBe('/test/value'); + delete process.env['TEST_EXPAND_VAR']; + }); + + it('expands ${VAR}', () => { + process.env['TEST_EXPAND_VAR'] = '/test/value'; + expect(expandPath('${TEST_EXPAND_VAR}/sub', fs)).toBe('/test/value/sub'); + delete process.env['TEST_EXPAND_VAR']; + }); + + it('expands $HOME', () => { + expect(expandPath('$HOME', fs)).toBe(process.env['HOME']); + }); + + it('expands ${HOME}/path', () => { + expect(expandPath('${HOME}/foo', fs)).toBe(`${process.env['HOME']}/foo`); + }); + + it('expands multiple vars in one string', () => { + process.env['TEST_A'] = 'foo'; + process.env['TEST_B'] = 'bar'; + expect(expandPath('$TEST_A/$TEST_B', fs)).toBe('foo/bar'); + delete process.env['TEST_A']; + delete process.env['TEST_B']; + }); + + it('replaces undefined var with empty string', () => { + expect(expandPath('$THIS_VAR_DOES_NOT_EXIST_XYZ', fs)).toBe(''); + }); + }); + + describe('plain paths', () => { + it('returns absolute paths unchanged', () => { + expect(expandPath('/usr/local/bin', fs)).toBe('/usr/local/bin'); + }); + + it('returns plain program names unchanged', () => { + expect(expandPath('git', fs)).toBe('git'); + }); + }); + + describe('undefined handling', () => { + it('returns undefined for undefined input', () => { + expect(expandPath(undefined, fs)).toBeUndefined(); + }); + + it('returns undefined for undefined input when fs is provided', () => { + const otherFs = new MemoryFileSystem({}, '/custom'); + expect(expandPath(undefined, otherFs)).toBeUndefined(); + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/hasShortFlag.spec.ts b/packages/claude-sdk-tools/test/hasShortFlag.spec.ts new file mode 100644 index 0000000..7a94a19 --- /dev/null +++ b/packages/claude-sdk-tools/test/hasShortFlag.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { hasShortFlag } from '../src/Exec/hasShortFlag'; + +describe('hasShortFlag', () => { + describe('exact match', () => { + it('matches -i exactly', () => { + expect(hasShortFlag(['-i'], 'i')).toBe(true); + }); + + it('matches -n exactly', () => { + expect(hasShortFlag(['-n'], 'n')).toBe(true); + }); + + it('returns false when exact flag absent', () => { + expect(hasShortFlag(['-n'], 'i')).toBe(false); + }); + }); + + describe('combined short flags', () => { + it('detects i inside -ni', () => { + expect(hasShortFlag(['-ni'], 'i')).toBe(true); + }); + + it('detects n inside -ni', () => { + expect(hasShortFlag(['-ni'], 'n')).toBe(true); + }); + + it('detects i inside -Ei', () => { + expect(hasShortFlag(['-Ei'], 'i')).toBe(true); + }); + + it('detects E inside -Ei', () => { + expect(hasShortFlag(['-Ei'], 'E')).toBe(true); + }); + + it('does not detect absent flag in combined group', () => { + expect(hasShortFlag(['-ni'], 'E')).toBe(false); + }); + }); + + describe('long flags are ignored', () => { + it('does not match --in-place for i', () => { + expect(hasShortFlag(['--in-place'], 'i')).toBe(false); + }); + + it('does not match --ignore for i', () => { + expect(hasShortFlag(['--ignore'], 'i')).toBe(false); + }); + + it('does not match --interactive for i', () => { + expect(hasShortFlag(['--interactive'], 'i')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for empty args array', () => { + expect(hasShortFlag([], 'i')).toBe(false); + }); + + it('returns false for arg without leading dash', () => { + expect(hasShortFlag(['i'], 'i')).toBe(false); + }); + + it('returns false when no args contain the flag', () => { + expect(hasShortFlag(['-a', '-b', '-c'], 'i')).toBe(false); + }); + + it('returns true when flag appears in one of several args', () => { + expect(hasShortFlag(['-a', '-bi', '-c'], 'i')).toBe(true); + }); + + it('returns true when flag is in last arg', () => { + expect(hasShortFlag(['-a', '-b', '-ci'], 'i')).toBe(true); + }); + }); +}); diff --git a/packages/claude-sdk-tools/test/helpers.ts b/packages/claude-sdk-tools/test/helpers.ts new file mode 100644 index 0000000..6d2b9fa --- /dev/null +++ b/packages/claude-sdk-tools/test/helpers.ts @@ -0,0 +1,6 @@ +import type { ToolDefinition } from '@shellicar/claude-sdk'; +import type { z } from 'zod'; + +export async function call(tool: ToolDefinition, input: z.input): Promise { + return tool.handler(tool.input_schema.parse(input)); +} diff --git a/packages/claude-sdk-tools/tsconfig.check.json b/packages/claude-sdk-tools/tsconfig.check.json new file mode 100644 index 0000000..bfed23d --- /dev/null +++ b/packages/claude-sdk-tools/tsconfig.check.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "skipLibCheck": true, + "composite": false, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + } +} diff --git a/packages/claude-sdk-tools/tsconfig.json b/packages/claude-sdk-tools/tsconfig.json new file mode 100644 index 0000000..f41a1dc --- /dev/null +++ b/packages/claude-sdk-tools/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@shellicar/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["**/*.ts"] +} diff --git a/packages/claude-sdk-tools/vitest.config.ts b/packages/claude-sdk-tools/vitest.config.ts new file mode 100644 index 0000000..ae8680e --- /dev/null +++ b/packages/claude-sdk-tools/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/claude-sdk/CLAUDE.md b/packages/claude-sdk/CLAUDE.md index 444cad4..568a826 100644 --- a/packages/claude-sdk/CLAUDE.md +++ b/packages/claude-sdk/CLAUDE.md @@ -52,14 +52,3 @@ For each set of tool uses returned by the model: Steps 1 and 2 happen before any approval requests are sent, so the consumer is never asked about a tool that would fail anyway. -## Known Issues - -### Cancel while awaiting approval - -**Location**: `ApprovalState.handle()` / `AgentRun.#handleTools()` - -When a `cancel` message arrives, `ApprovalState` sets `#cancelled = true` but does not resolve pending approval promises. If `AgentRun.#handleTools` is currently blocked on `Promise.race(pending.map(...))`, it will never unblock to check `#cancelled`. - -**Fix needed**: On cancel, resolve all pending approval promises (e.g. `{ approved: false }`) so the while loop can unblock and exit cleanly. - -**Tests needed**: Cancellation during the tool approval wait should cause the run to terminate without hanging. diff --git a/packages/claude-sdk/build.ts b/packages/claude-sdk/build.ts index aa5a9c9..a102b19 100644 --- a/packages/claude-sdk/build.ts +++ b/packages/claude-sdk/build.ts @@ -22,7 +22,8 @@ const ctx = await esbuild.context({ platform: 'node', plugins, sourcemap: true, - target: 'node22', + external: ['@anthropic-ai/sdk'], + target: 'node24', treeShaking: false, tsconfig: 'tsconfig.json', }); diff --git a/packages/claude-sdk/package.json b/packages/claude-sdk/package.json index dfb5af4..7dc0b63 100644 --- a/packages/claude-sdk/package.json +++ b/packages/claude-sdk/package.json @@ -16,7 +16,9 @@ "build": "tsx build.ts", "build:watch": "tsx build.ts --watch", "start": "node dist/main.js", - "type-check": "tsc -p tsconfig.check.json" + "test": "vitest run", + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsx build.ts --watch" }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", @@ -25,10 +27,12 @@ "devDependencies": { "@shellicar/build-clean": "^1.3.2", "@shellicar/build-version": "^1.3.6", + "@shellicar/typescript-config": "workspace:*", "@tsconfig/node24": "^24.0.4", "@types/node": "^25.5.0", "esbuild": "^0.27.5", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.2" } } diff --git a/packages/claude-sdk/src/index.ts b/packages/claude-sdk/src/index.ts index bd08e1d..191658a 100644 --- a/packages/claude-sdk/src/index.ts +++ b/packages/claude-sdk/src/index.ts @@ -1,7 +1,11 @@ +import { calculateCost } from './private/pricing'; import { createAnthropicAgent } from './public/createAnthropicAgent'; +import { defineTool } from './public/defineTool'; import { AnthropicBeta } from './public/enums'; import { IAnthropicAgent } from './public/interfaces'; -import type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition } from './public/types'; +import type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, CacheTtl, ConsumerMessage, ILogger, RunAgentQuery, RunAgentResult, SdkDone, SdkError, SdkMessage, SdkMessageEnd, SdkMessageStart, SdkMessageText, SdkMessageUsage, SdkToolApprovalRequest, ToolDefinition, ToolOperation } from './public/types'; -export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, ChainedToolStore, ConsumerMessage, ILogger, JsonObject, JsonValue, RunAgentQuery, RunAgentResult, SdkMessage, ToolDefinition }; -export { AnthropicBeta, createAnthropicAgent, IAnthropicAgent }; +export type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; + +export type { AnthropicAgentOptions, AnthropicBetaFlags, AnyToolDefinition, CacheTtl, ConsumerMessage, ILogger, RunAgentQuery, RunAgentResult, SdkDone, SdkError, SdkMessage, SdkMessageEnd, SdkMessageStart, SdkMessageText, SdkMessageUsage, SdkToolApprovalRequest, ToolDefinition, ToolOperation }; +export { AnthropicBeta, calculateCost, createAnthropicAgent, defineTool, IAnthropicAgent }; diff --git a/packages/claude-sdk/src/private/AgentRun.ts b/packages/claude-sdk/src/private/AgentRun.ts index 72332dc..d759edb 100644 --- a/packages/claude-sdk/src/private/AgentRun.ts +++ b/packages/claude-sdk/src/private/AgentRun.ts @@ -1,30 +1,40 @@ import { randomUUID } from 'node:crypto'; -import type { RequestOptions } from 'node:http'; import type { MessagePort } from 'node:worker_threads'; import type { Anthropic } from '@anthropic-ai/sdk'; import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages.js'; -import type { BetaCacheControlEphemeral } from '@anthropic-ai/sdk/resources/beta.mjs'; -import { z } from 'zod'; -import type { AnyToolDefinition, ChainedToolStore, ILogger, RunAgentQuery, SdkMessage } from '../public/types'; +import type { BetaCacheControlEphemeral, BetaClearThinking20251015Edit, BetaClearToolUses20250919Edit, BetaCompact20260112Edit, BetaCompactionBlockParam, BetaContextManagementConfig, BetaTextBlockParam, BetaThinkingBlockParam, BetaToolUnion, BetaToolUseBlockParam } from '@anthropic-ai/sdk/resources/beta.mjs'; +import { AnthropicBeta } from '../public/enums'; +import type { AnyToolDefinition, ILogger, RunAgentQuery, SdkMessage } from '../public/types'; import { AgentChannel } from './AgentChannel'; import { ApprovalState } from './ApprovalState'; +import type { ConversationHistory } from './ConversationHistory'; import { AGENT_SDK_PREFIX } from './consts'; import { MessageStream } from './MessageStream'; -import type { ToolUseResult } from './types'; +import { calculateCost, getContextWindow } from './pricing'; +import type { ContentBlock, MessageStreamResult, ToolUseResult } from './types'; export class AgentRun { readonly #client: Anthropic; readonly #logger: ILogger | undefined; readonly #options: RunAgentQuery; + readonly #history: ConversationHistory; readonly #channel: AgentChannel; readonly #approval: ApprovalState; + readonly #abortController: AbortController; - public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery) { + public constructor(client: Anthropic, logger: ILogger | undefined, options: RunAgentQuery, history: ConversationHistory) { this.#client = client; this.#logger = logger; this.#options = options; + this.#history = history; + this.#abortController = new AbortController(); this.#approval = new ApprovalState(); - this.#channel = new AgentChannel((msg) => this.#approval.handle(msg)); + this.#channel = new AgentChannel((msg) => { + if (msg.type === 'cancel') { + this.#abortController.abort(); + } + this.#approval.handle(msg); + }); } public get port(): MessagePort { @@ -32,22 +42,24 @@ export class AgentRun { } public async execute(): Promise { - const messages: Anthropic.Beta.Messages.BetaMessageParam[] = this.#options.messages.map((content) => ({ - role: 'user', - content, - })); - const store: ChainedToolStore = new Map(); + for (const content of this.#options.messages) { + this.#history.push({ role: 'user', content }); + } try { + let emptyToolUseRetries = 0; while (!this.#approval.cancelled) { - this.#logger?.debug('messages', { messages }); - const stream = this.#getMessageStream(messages); + this.#logger?.debug('messages', { messages: this.#history.messages.length }); + const stream = this.#getMessageStream(this.#history.messages); this.#logger?.info('Processing messages'); const messageStream = new MessageStream(this.#logger); messageStream.on('message_start', () => this.#channel.send({ type: 'message_start' })); messageStream.on('message_text', (text) => this.#channel.send({ type: 'message_text', text })); + messageStream.on('thinking_text', (text) => this.#channel.send({ type: 'message_thinking', text })); messageStream.on('message_stop', () => this.#channel.send({ type: 'message_end' })); + messageStream.on('compaction_start', () => this.#channel.send({ type: 'message_compaction_start' })); + messageStream.on('compaction_complete', (summary) => this.#channel.send({ type: 'message_compaction', summary })); let result: Awaited>; try { @@ -59,72 +71,122 @@ export class AgentRun { return; } - if (result.stopReason !== 'tool_use' || result.toolUses.length === 0) { + const cacheTtl = this.#options.cacheTtl ?? '5m'; + const costUsd = calculateCost(result.usage, this.#options.model, cacheTtl); + const contextWindow = getContextWindow(this.#options.model); + this.#channel.send({ type: 'message_usage', ...result.usage, costUsd, contextWindow } satisfies SdkMessage); + + const toolUses = result.blocks.filter((b): b is Extract => b.type === 'tool_use'); + + if (result.stopReason !== 'tool_use') { + this.handleAssistantMessages(result); this.#channel.send({ type: 'done', stopReason: result.stopReason ?? 'end_turn' }); break; } - const toolResults = await this.#handleTools(result.toolUses, store); - - messages.push({ - role: 'assistant', - content: [ - ...(result.text.length > 0 ? [{ type: 'text' as const, text: result.text }] : []), - ...result.toolUses.map((t) => ({ - type: 'tool_use' as const, - id: t.id, - name: t.name, - input: t.input, - })), - ], - }); - messages.push({ role: 'user', content: toolResults }); + if (toolUses.length === 0) { + if (emptyToolUseRetries < 2) { + emptyToolUseRetries++; + this.#logger?.warn('stop_reason was tool_use but no tool uses accumulated — retrying', { attempt: emptyToolUseRetries }); + continue; + } + this.#logger?.warn('stop_reason was tool_use but no tool uses accumulated — giving up after retries'); + this.#channel.send({ type: 'error', message: 'stop_reason was tool_use but no tool uses found' }); + break; + } + + emptyToolUseRetries = 0; + this.handleAssistantMessages(result); + const toolResults = await this.#handleTools(toolUses); + this.#history.push({ role: 'user', content: toolResults }); } } finally { this.#channel.close(); } } + private handleAssistantMessages(result: MessageStreamResult) { + const mapBlock = (b: ContentBlock): Anthropic.Beta.Messages.BetaContentBlockParam => { + switch (b.type) { + case 'text': { + return { type: 'text' as const, text: b.text } satisfies BetaTextBlockParam; + } + case 'thinking': { + return { type: 'thinking' as const, thinking: b.thinking, signature: b.signature } satisfies BetaThinkingBlockParam; + } + case 'tool_use': { + return { type: 'tool_use' as const, id: b.id, name: b.name, input: b.input } satisfies BetaToolUseBlockParam; + } + case 'compaction': { + return { type: 'compaction' as const, content: b.content } satisfies BetaCompactionBlockParam; + } + } + }; + + const assistantContent = result.blocks.map(mapBlock); + if (assistantContent.length > 0) { + this.#history.push({ role: 'assistant', content: assistantContent }); + } + } + #getMessageStream(messages: Anthropic.Beta.Messages.BetaMessageParam[]) { - const body = { + const tools: BetaToolUnion[] = this.#options.tools.map( + (t) => + ({ + name: t.name, + description: t.description, + input_schema: t.input_schema.toJSONSchema({ target: 'draft-07', io: 'input' }) as Anthropic.Tool['input_schema'], + input_examples: t.input_examples, + }) satisfies BetaToolUnion, + ); + + const betas = resolveCapabilities(this.#options.betas, AnthropicBeta); + + const context_management: BetaContextManagementConfig = { + edits: [], + }; + if (betas[AnthropicBeta.ContextManagement]) { + context_management.edits?.push({ type: 'clear_thinking_20251015' } satisfies BetaClearThinking20251015Edit); + context_management.edits?.push({ type: 'clear_tool_uses_20250919' } satisfies BetaClearToolUses20250919Edit); + } + if (betas[AnthropicBeta.Compact]) { + context_management.edits?.push({ type: 'compact_20260112', pause_after_compaction: this.#options.pauseAfterCompact ?? false, trigger: { type: 'input_tokens', value: 125000 } } satisfies BetaCompact20260112Edit); + } + + const body: BetaMessageStreamParams = { model: this.#options.model, max_tokens: this.#options.maxTokens, - tools: this.#options.tools.map((t) => ({ - name: t.name, - description: t.description, - input_schema: z.toJSONSchema(t.input_schema) as Anthropic.Tool['input_schema'], - input_examples: t.input_examples, - })), - cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, + tools, + context_management, system: [{ type: 'text', text: AGENT_SDK_PREFIX }], messages, - thinking: { type: 'adaptive' }, + // thinking: { type: 'adaptive' }, stream: true, } satisfies BetaMessageStreamParams; - const betas = Object.entries(this.#options.betas ?? {}) + if (betas[AnthropicBeta.PromptCachingScope]) { + body.cache_control = { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral; + } + if (this.#options.thinking === true) { + body.thinking = { type: 'adaptive' }; + } + + const anthropicBetas = Object.entries(betas) .filter(([, enabled]) => enabled) .map(([beta]) => beta) .join(','); const requestOptions = { - headers: { 'anthropic-beta': betas }, - } satisfies RequestOptions; + headers: { 'anthropic-beta': anthropicBetas }, + signal: this.#abortController.signal, + } satisfies Anthropic.RequestOptions; - this.#logger?.info('Sending request', { - model: this.#options.model, - max_tokens: this.#options.maxTokens, - tools: this.#options.tools.map((t) => ({ name: t.name, description: t.description })), - cache_control: { type: 'ephemeral', scope: 'global' } as BetaCacheControlEphemeral, - thinking: { type: 'adaptive' }, - stream: true, - headers: requestOptions.headers, - }); + this.#logger?.info('Sending request', body); return this.#client.beta.messages.stream(body, requestOptions); } - async #handleTools(toolUses: ToolUseResult[], store: ChainedToolStore): Promise { + async #handleTools(toolUses: ToolUseResult[]): Promise { const requireApproval = this.#options.requireToolApproval ?? false; const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = []; @@ -140,8 +202,10 @@ export class AgentRun { } const parseResult = tool.input_schema.safeParse(toolUse.input); if (!parseResult.success) { + const error = parseResult.error.message; this.#logger?.debug('tool_parse_error', { name: toolUse.name, error: parseResult.error }); - toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${parseResult.error.message}` }); + this.#channel.send({ type: 'tool_error', name: toolUse.name, input: toolUse.input, error }); + toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: `Invalid input: ${error}` }); continue; } resolved.push({ toolUse, tool, input: parseResult.data }); @@ -176,35 +240,45 @@ export class AgentRun { continue; } - toolResults.push(await this.#executeTool(toolUse, tool, input, store)); + toolResults.push(await this.#executeTool(toolUse, tool, input)); } } else { for (const { toolUse, tool, input } of resolved) { if (this.#approval.cancelled) { break; } - toolResults.push(await this.#executeTool(toolUse, tool, input, store)); + toolResults.push(await this.#executeTool(toolUse, tool, input)); } } return toolResults; } - async #executeTool(toolUse: ToolUseResult, tool: AnyToolDefinition, input: unknown, store: ChainedToolStore): Promise { + async #executeTool(toolUse: ToolUseResult, tool: AnyToolDefinition, input: unknown): Promise { this.#logger?.debug('tool_call', { name: toolUse.name, input: toolUse.input }); - const handler = tool.handler as (input: unknown, store: Map) => Promise; + const handler = tool.handler as (input: unknown) => Promise; try { - const toolOutput = await handler(input, store); + const toolOutput = await handler(input); this.#logger?.debug('tool_result', { name: toolUse.name, output: toolOutput }); + const transformed = this.#options.transformToolResult ? this.#options.transformToolResult(toolUse.name, toolOutput) : toolOutput; return { type: 'tool_result', tool_use_id: toolUse.id, - content: typeof toolOutput === 'string' ? toolOutput : JSON.stringify(toolOutput), + content: typeof transformed === 'string' ? transformed : JSON.stringify(transformed), }; } catch (err) { const message = err instanceof Error ? err.message : String(err); this.#logger?.debug('tool_handler_error', { name: toolUse.name, error: message }); + this.#channel.send({ type: 'tool_error', name: toolUse.name, input: toolUse.input, error: message }); return { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: message }; } } } + +function resolveCapabilities(partial: Partial> | undefined, enumObj: Record): Record { + const result = {} as Record; + for (const key of Object.values(enumObj)) { + result[key] = partial?.[key] ?? false; + } + return result; +} diff --git a/packages/claude-sdk/src/private/AnthropicAgent.ts b/packages/claude-sdk/src/private/AnthropicAgent.ts index 4a73a12..6254e2f 100644 --- a/packages/claude-sdk/src/private/AnthropicAgent.ts +++ b/packages/claude-sdk/src/private/AnthropicAgent.ts @@ -1,20 +1,53 @@ -import { Anthropic } from '@anthropic-ai/sdk'; +import { Anthropic, type ClientOptions } from '@anthropic-ai/sdk'; +import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; +import versionJson from '@shellicar/build-version/version'; import { IAnthropicAgent } from '../public/interfaces'; import type { AnthropicAgentOptions, ILogger, RunAgentQuery, RunAgentResult } from '../public/types'; import { AgentRun } from './AgentRun'; +import { ConversationHistory } from './ConversationHistory'; +import { customFetch } from './http/customFetch'; export class AnthropicAgent extends IAnthropicAgent { readonly #client: Anthropic; readonly #logger: ILogger | undefined; + readonly #history: ConversationHistory; public constructor(options: AnthropicAgentOptions) { super(); this.#logger = options.logger; - this.#client = new Anthropic({ apiKey: options.apiKey }); + const defaultHeaders = { + 'user-agent': `@shellicar/claude-sdk/${versionJson.version}`, + }; + const clientOptions = { + authToken: `${options.apiKey}`, + fetch: customFetch(options.logger), + logger: options.logger, + defaultHeaders, + } satisfies ClientOptions; + this.#client = new Anthropic(clientOptions); + this.#history = new ConversationHistory(options.historyFile); } public runAgent(options: RunAgentQuery): RunAgentResult { - const run = new AgentRun(this.#client, this.#logger, options); + const run = new AgentRun(this.#client, this.#logger, options, this.#history); return { port: run.port, done: run.execute() }; } + + public getHistory(): BetaMessageParam[] { + return this.#history.messages; + } + + public loadHistory(messages: BetaMessageParam[]): void { + for (const msg of messages) { + this.#history.push(msg); + } + } + + public injectContext(msg: BetaMessageParam, opts?: { id?: string }): void { + this.#history.push(msg, opts); + } + + public removeContext(id: string): boolean { + return this.#history.remove(id); + } } diff --git a/packages/claude-sdk/src/private/ApprovalState.ts b/packages/claude-sdk/src/private/ApprovalState.ts index b856f19..51d8093 100644 --- a/packages/claude-sdk/src/private/ApprovalState.ts +++ b/packages/claude-sdk/src/private/ApprovalState.ts @@ -18,6 +18,10 @@ export class ApprovalState { } } else if (msg.type === 'cancel') { this.#cancelled = true; + for (const resolve of this.#pending.values()) { + resolve({ approved: false, reason: 'cancelled' }); + } + this.#pending.clear(); } } diff --git a/packages/claude-sdk/src/private/ConversationHistory.ts b/packages/claude-sdk/src/private/ConversationHistory.ts new file mode 100644 index 0000000..8630a47 --- /dev/null +++ b/packages/claude-sdk/src/private/ConversationHistory.ts @@ -0,0 +1,92 @@ +import { readFileSync, renameSync, writeFileSync } from 'node:fs'; +import type { Anthropic } from '@anthropic-ai/sdk'; + +type HistoryItem = { + id?: string; + msg: Anthropic.Beta.Messages.BetaMessageParam; +}; + +function hasCompactionBlock(msg: Anthropic.Beta.Messages.BetaMessageParam): boolean { + return Array.isArray(msg.content) && msg.content.some((b) => b.type === 'compaction'); +} + +function trimToLastCompaction(items: HistoryItem[]): HistoryItem[] { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item && hasCompactionBlock(item.msg)) { + return items.slice(i); + } + } + return items; +} + +export class ConversationHistory { + readonly #items: HistoryItem[] = []; + readonly #historyFile: string | undefined; + + public constructor(historyFile?: string) { + this.#historyFile = historyFile; + if (historyFile) { + try { + const raw = readFileSync(historyFile, 'utf-8'); + const msgs = raw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Anthropic.Beta.Messages.BetaMessageParam); + this.#items.push(...trimToLastCompaction(msgs.map((msg) => ({ msg })))); + } catch { + // No history file yet + } + } + } + + public get messages(): Anthropic.Beta.Messages.BetaMessageParam[] { + return this.#items.map((item) => item.msg); + } + + /** + * Append a message to the conversation history. + * @param msg The message to append. + * @param opts Optional. `id` tags the message for later removal via `remove(id)`. + */ + public push(msg: Anthropic.Beta.Messages.BetaMessageParam, opts?: { id?: string }): void { + if (hasCompactionBlock(msg)) { + this.#items.length = 0; + } + const last = this.#items.at(-1); + if (last?.msg.role === 'user' && msg.role === 'user') { + // Merge consecutive user messages — the API requires strict role alternation. + // On merge the tag is dropped (the merged message is no longer a single addressable unit). + const lastContent = Array.isArray(last.msg.content) ? last.msg.content : [{ type: 'text' as const, text: last.msg.content as string }]; + const newContent = Array.isArray(msg.content) ? msg.content : [{ type: 'text' as const, text: msg.content as string }]; + last.msg = { ...last.msg, content: [...lastContent, ...newContent] }; + last.id = undefined; + } else { + this.#items.push({ id: opts?.id, msg }); + } + this.#save(); + } + + /** + * Remove a previously pushed message by its tag. + * Returns `true` if found and removed, `false` if no message with that id exists. + */ + public remove(id: string): boolean { + const idx = this.#items.findLastIndex((item) => item.id === id); + if (idx < 0) { + return false; + } + this.#items.splice(idx, 1); + this.#save(); + return true; + } + + #save(): void { + if (!this.#historyFile) { + return; + } + const tmp = `${this.#historyFile}.tmp`; + writeFileSync(tmp, this.#items.map((item) => JSON.stringify(item.msg)).join('\n')); + renameSync(tmp, this.#historyFile); + } +} diff --git a/packages/claude-sdk/src/private/MessageStream.ts b/packages/claude-sdk/src/private/MessageStream.ts index 7e34faf..5fea9b3 100644 --- a/packages/claude-sdk/src/private/MessageStream.ts +++ b/packages/claude-sdk/src/private/MessageStream.ts @@ -1,13 +1,20 @@ import EventEmitter from 'node:events'; import type { Anthropic } from '@anthropic-ai/sdk'; import type { ILogger } from '../public/types'; -import type { MessageStreamEvents, MessageStreamResult, ToolUseAccumulator } from './types'; +import type { ContentBlock, MessageStreamEvents, MessageStreamResult } from './types'; + +type BlockAccumulator = { type: 'thinking'; thinking: string; signature: string } | { type: 'text'; text: string } | { type: 'tool_use'; id: string; name: string; partialJson: string } | { type: 'compaction'; content: string }; export class MessageStream extends EventEmitter { readonly #logger: ILogger | undefined; - #text = ''; - #accumulating = new Map(); + #current: BlockAccumulator | null = null; + #completed: ContentBlock[] = []; #stopReason: string | null = null; + #contextManagementOccurred = false; + #inputTokens = 0; + #cacheCreationTokens = 0; + #cacheReadTokens = 0; + #outputTokens = 0; public constructor(logger?: ILogger) { super(); @@ -19,13 +26,15 @@ export class MessageStream extends EventEmitter { this.#handleEvent(event); } return { - text: this.#text, - toolUses: [...this.#accumulating.values()].map((acc) => ({ - id: acc.id, - name: acc.name, - input: acc.partialJson.length > 0 ? JSON.parse(acc.partialJson) : {}, - })), + blocks: this.#completed, stopReason: this.#stopReason, + contextManagementOccurred: this.#contextManagementOccurred, + usage: { + inputTokens: this.#inputTokens, + cacheCreationTokens: this.#cacheCreationTokens, + cacheReadTokens: this.#cacheReadTokens, + outputTokens: this.#outputTokens, + }, }; } @@ -34,6 +43,9 @@ export class MessageStream extends EventEmitter { switch (event.type) { case 'message_start': this.#logger?.debug('message_start'); + this.#inputTokens = event.message.usage.input_tokens; + this.#cacheCreationTokens = event.message.usage.cache_creation_input_tokens ?? 0; + this.#cacheReadTokens = event.message.usage.cache_read_input_tokens ?? 0; this.emit('message_start'); break; case 'message_stop': @@ -45,26 +57,92 @@ export class MessageStream extends EventEmitter { this.#stopReason = event.delta.stop_reason; this.#logger?.debug('stop_reason', { reason: event.delta.stop_reason }); } + this.#outputTokens = event.usage.output_tokens; + if (event.context_management != null) { + this.#contextManagementOccurred = true; + this.#logger?.info('context_management', { context_management: event.context_management }); + } break; case 'content_block_start': - if (event.content_block.type === 'tool_use') { - this.#logger?.info('tool_use_start', { name: event.content_block.name }); - this.#accumulating.set(event.index, { - id: event.content_block.id, - name: event.content_block.name, - partialJson: '', - }); + this.#logger?.debug('content_block_start', { index: event.index, type: event.content_block.type }); + if (this.#current != null) { + this.#logger?.warn('content_block_start with existing current block', { existing: this.#current.type, incoming: event.content_block.type }); + } + switch (event.content_block.type) { + case 'tool_use': + this.#logger?.info('tool_use_start', { name: event.content_block.name }); + this.#current = { type: 'tool_use', id: event.content_block.id, name: event.content_block.name, partialJson: '' }; + break; + case 'thinking': + this.#current = { type: 'thinking', thinking: '', signature: '' }; + this.emit('thinking_start'); + break; + case 'text': + this.#current = { type: 'text', text: '' }; + break; + case 'compaction': + this.#current = { type: 'compaction', content: '' }; + this.emit('compaction_start'); + break; + } + break; + case 'content_block_stop': { + this.#logger?.debug('content_block_stop', { type: this.#current?.type }); + const acc = this.#current; + this.#current = null; + if (acc == null) { + this.#logger?.warn('content_block_stop with no current block'); + break; + } + switch (acc.type) { + case 'thinking': + this.#completed.push({ type: 'thinking', thinking: acc.thinking, signature: acc.signature }); + this.emit('thinking_stop'); + break; + case 'text': + this.#completed.push({ type: 'text', text: acc.text }); + break; + case 'tool_use': + this.#completed.push({ type: 'tool_use', id: acc.id, name: acc.name, input: acc.partialJson.length > 0 ? JSON.parse(acc.partialJson) : {} }); + break; + case 'compaction': + this.#completed.push({ type: 'compaction', content: acc.content }); + this.emit('compaction_complete', acc.content); + break; } break; + } case 'content_block_delta': - if (event.delta.type === 'text_delta') { - this.#text += event.delta.text; - this.emit('message_text', event.delta.text); - } else if (event.delta.type === 'input_json_delta') { - const acc = this.#accumulating.get(event.index); - if (acc != null) { - acc.partialJson += event.delta.partial_json; - } + switch (event.delta.type) { + case 'text_delta': + if (this.#current?.type === 'text') { + this.#current.text += event.delta.text; + this.emit('message_text', event.delta.text); + } + break; + case 'input_json_delta': + if (this.#current?.type === 'tool_use') { + this.#current.partialJson += event.delta.partial_json; + } + break; + case 'thinking_delta': + if (this.#current?.type === 'thinking') { + this.#current.thinking += event.delta.thinking; + this.emit('thinking_text', event.delta.thinking); + } + break; + case 'signature_delta': + if (this.#current?.type === 'thinking') { + this.#current.signature += event.delta.signature; + } + break; + case 'compaction_delta': + if (this.#current?.type === 'compaction') { + this.#current.content += event.delta.content; + } + break; + case 'citations_delta': + break; } break; } diff --git a/packages/claude-sdk/src/private/http/customFetch.ts b/packages/claude-sdk/src/private/http/customFetch.ts new file mode 100644 index 0000000..bb7d279 --- /dev/null +++ b/packages/claude-sdk/src/private/http/customFetch.ts @@ -0,0 +1,40 @@ +import type { ILogger } from '../../public/types'; +import { getBody } from './getBody'; +import { getHeaders } from './getHeaders'; + +export const customFetch = (logger: ILogger | undefined) => { + return async (input: string | URL | Request, init?: RequestInit) => { + const headers = getHeaders(init?.headers); + const body = getBody(init?.body, headers); + + logger?.info('HTTP Request', { + headers, + method: init?.method, + body, + }); + const response = await fetch(input, init); + const isStream = response.headers.get('content-type')?.includes('text/event-stream') ?? false; + if (!isStream) { + const text = await response.clone().text(); + let responseBody: unknown = text; + try { + responseBody = JSON.parse(text); + } catch { + // keep as text + } + logger?.info('HTTP Response', { + headers: getHeaders(response.headers), + status: response.status, + statusText: response.statusText, + body: responseBody, + }); + } else { + logger?.info('HTTP Response', { + headers: getHeaders(response.headers), + status: response.status, + statusText: response.statusText, + }); + } + return response; + }; +}; diff --git a/packages/claude-sdk/src/private/http/getBody.ts b/packages/claude-sdk/src/private/http/getBody.ts new file mode 100644 index 0000000..f12a0f0 --- /dev/null +++ b/packages/claude-sdk/src/private/http/getBody.ts @@ -0,0 +1,10 @@ +export const getBody = (body: RequestInit['body'] | undefined, headers: Record) => { + try { + if (typeof body === 'string' && headers['content-type'] === 'application/json') { + return JSON.parse(body); + } + } catch { + // ignore + } + return body; +}; diff --git a/packages/claude-sdk/src/private/http/getHeaders.ts b/packages/claude-sdk/src/private/http/getHeaders.ts new file mode 100644 index 0000000..74f2cad --- /dev/null +++ b/packages/claude-sdk/src/private/http/getHeaders.ts @@ -0,0 +1,6 @@ +export const getHeaders = (headers: RequestInit['headers'] | undefined): Record => { + if (headers == null) { + return {}; + } + return Object.fromEntries(new Headers(headers).entries()); +}; diff --git a/packages/claude-sdk/src/private/pricing.ts b/packages/claude-sdk/src/private/pricing.ts new file mode 100644 index 0000000..970ce78 --- /dev/null +++ b/packages/claude-sdk/src/private/pricing.ts @@ -0,0 +1,60 @@ +export type CacheTtl = '5m' | '1h'; + +type ModelRates = { + input: number; + cacheWrite5m: number; + cacheWrite1h: number; + cacheRead: number; + output: number; +}; + +const M = 1_000_000; + +const PRICING: Record = { + 'claude-opus-4-6': { input: 5 / M, cacheWrite5m: 6.25 / M, cacheWrite1h: 10 / M, cacheRead: 0.5 / M, output: 25 / M }, + 'claude-opus-4-5': { input: 5 / M, cacheWrite5m: 6.25 / M, cacheWrite1h: 10 / M, cacheRead: 0.5 / M, output: 25 / M }, + 'claude-opus-4-1': { input: 15 / M, cacheWrite5m: 18.75 / M, cacheWrite1h: 30 / M, cacheRead: 1.5 / M, output: 75 / M }, + 'claude-opus-4': { input: 15 / M, cacheWrite5m: 18.75 / M, cacheWrite1h: 30 / M, cacheRead: 1.5 / M, output: 75 / M }, + 'claude-sonnet-4-6': { input: 3 / M, cacheWrite5m: 3.75 / M, cacheWrite1h: 6 / M, cacheRead: 0.3 / M, output: 15 / M }, + 'claude-sonnet-4-5': { input: 3 / M, cacheWrite5m: 3.75 / M, cacheWrite1h: 6 / M, cacheRead: 0.3 / M, output: 15 / M }, + 'claude-sonnet-4': { input: 3 / M, cacheWrite5m: 3.75 / M, cacheWrite1h: 6 / M, cacheRead: 0.3 / M, output: 15 / M }, + 'claude-sonnet-3-7': { input: 3 / M, cacheWrite5m: 3.75 / M, cacheWrite1h: 6 / M, cacheRead: 0.3 / M, output: 15 / M }, + 'claude-haiku-4-5': { input: 1 / M, cacheWrite5m: 1.25 / M, cacheWrite1h: 2 / M, cacheRead: 0.1 / M, output: 5 / M }, + 'claude-haiku-3-5': { input: 0.8 / M, cacheWrite5m: 1 / M, cacheWrite1h: 1.6 / M, cacheRead: 0.08 / M, output: 4 / M }, + 'claude-opus-3': { input: 15 / M, cacheWrite5m: 18.75 / M, cacheWrite1h: 30 / M, cacheRead: 1.5 / M, output: 75 / M }, + 'claude-haiku-3': { input: 0.25 / M, cacheWrite5m: 0.3 / M, cacheWrite1h: 0.5 / M, cacheRead: 0.03 / M, output: 1.25 / M }, +}; + +const CONTEXT_WINDOW: Record = { + 'claude-opus-4': 200_000, + 'claude-sonnet-4': 200_000, + 'claude-haiku-4-5': 200_000, + 'claude-haiku-3-5': 200_000, + 'claude-sonnet-3-7': 200_000, + 'claude-opus-3': 200_000, + 'claude-haiku-3': 200_000, +}; + +export function getContextWindow(modelId: string): number { + return CONTEXT_WINDOW[modelId] ?? CONTEXT_WINDOW[stripDateSuffix(modelId)] ?? 200_000; +} + +function stripDateSuffix(modelId: string): string { + return modelId.replace(/-\d{8}$/, ''); +} + +export type MessageTokens = { + inputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + outputTokens: number; +}; + +export function calculateCost(tokens: MessageTokens, modelId: string, cacheTtl: CacheTtl): number { + const rates = PRICING[modelId] ?? PRICING[stripDateSuffix(modelId)]; + if (!rates) { + return 0; + } + const cacheWriteRate = cacheTtl === '1h' ? rates.cacheWrite1h : rates.cacheWrite5m; + return tokens.inputTokens * rates.input + tokens.cacheCreationTokens * cacheWriteRate + tokens.cacheReadTokens * rates.cacheRead + tokens.outputTokens * rates.output; +} diff --git a/packages/claude-sdk/src/private/types.ts b/packages/claude-sdk/src/private/types.ts index 9fe58a5..e89705e 100644 --- a/packages/claude-sdk/src/private/types.ts +++ b/packages/claude-sdk/src/private/types.ts @@ -1,9 +1,3 @@ -export type ToolUseAccumulator = { - id: string; - name: string; - partialJson: string; -}; - export type ApprovalResponse = { approved: boolean; reason?: string; @@ -15,14 +9,29 @@ export type ToolUseResult = { input: Record; }; +export type ContentBlock = { type: 'thinking'; thinking: string; signature: string } | { type: 'text'; text: string } | { type: 'tool_use'; id: string; name: string; input: Record } | { type: 'compaction'; content: string }; + +export type MessageUsage = { + inputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + outputTokens: number; +}; + export type MessageStreamResult = { - text: string; - toolUses: ToolUseResult[]; + blocks: ContentBlock[]; stopReason: string | null; + contextManagementOccurred: boolean; + usage: MessageUsage; }; export type MessageStreamEvents = { message_start: []; message_text: [text: string]; message_stop: []; + thinking_start: []; + thinking_text: [text: string]; + thinking_stop: []; + compaction_start: []; + compaction_complete: [summary: string]; }; diff --git a/packages/claude-sdk/src/public/defineTool.ts b/packages/claude-sdk/src/public/defineTool.ts new file mode 100644 index 0000000..1c5412c --- /dev/null +++ b/packages/claude-sdk/src/public/defineTool.ts @@ -0,0 +1,6 @@ +import type { z } from 'zod'; +import type { ToolDefinition } from './types'; + +export function defineTool(def: ToolDefinition): ToolDefinition { + return def; +} diff --git a/packages/claude-sdk/src/public/enums.ts b/packages/claude-sdk/src/public/enums.ts index 27c2ad1..c29ea9a 100644 --- a/packages/claude-sdk/src/public/enums.ts +++ b/packages/claude-sdk/src/public/enums.ts @@ -1,9 +1,23 @@ export enum AnthropicBeta { + /** + * @see https://platform.claude.com/docs/en/build-with-claude/compaction + */ + Compact = 'compact-2026-01-12', + ClaudeCodeAuth = 'oauth-2025-04-20', + /** + * @see https://platform.claude.com/docs/en/build-with-claude/extended-thinking#interleaved-thinking + * @deprecated + */ InterleavedThinking = 'interleaved-thinking-2025-05-14', + + /** + * @see https://platform.claude.com/docs/en/build-with-claude/context-editing#server-side-strategies + */ ContextManagement = 'context-management-2025-06-27', + PromptCachingScope = 'prompt-caching-scope-2026-01-05', - Effort = 'effort-2025-11-24', + /** + * @see https://www.anthropic.com/engineering/advanced-tool-use + */ AdvancedToolUse = 'advanced-tool-use-2025-11-20', - ToolSearchTool = 'tool-search-tool-2025-10-19', - TokenEfficientTools = 'token-efficient-tools-2026-03-28', } diff --git a/packages/claude-sdk/src/public/interfaces.ts b/packages/claude-sdk/src/public/interfaces.ts index 5604c5a..1ae6541 100644 --- a/packages/claude-sdk/src/public/interfaces.ts +++ b/packages/claude-sdk/src/public/interfaces.ts @@ -1,5 +1,19 @@ +import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta.js'; import type { RunAgentQuery, RunAgentResult } from './types'; export abstract class IAnthropicAgent { public abstract runAgent(options: RunAgentQuery): RunAgentResult; + public abstract getHistory(): BetaMessageParam[]; + public abstract loadHistory(messages: BetaMessageParam[]): void; + /** + * Inject a message into the conversation history with an optional tag. + * Use `removeContext(id)` to prune it later (e.g. on skill deactivation). + * Call between runs only — injecting during an active run is undefined behaviour. + */ + public abstract injectContext(msg: BetaMessageParam, opts?: { id?: string }): void; + /** + * Remove a previously injected message by its tag. + * Returns `true` if found and removed, `false` if no message with that id exists. + */ + public abstract removeContext(id: string): boolean; } diff --git a/packages/claude-sdk/src/public/types.ts b/packages/claude-sdk/src/public/types.ts index 1417097..5e7bfdd 100644 --- a/packages/claude-sdk/src/public/types.ts +++ b/packages/claude-sdk/src/public/types.ts @@ -3,42 +3,59 @@ import type { Model } from '@anthropic-ai/sdk/resources/messages'; import type { z } from 'zod'; import type { AnthropicBeta } from './enums'; -export type ChainedToolStore = Map; +export type ToolOperation = 'read' | 'write' | 'delete'; -export type ToolDefinition = { +export type ToolDefinition = { name: string; description: string; - input_schema: z.ZodType; - input_examples: TInput[]; - handler: (input: TInput, store: ChainedToolStore) => Promise; -}; - -export type JsonValue = string | number | boolean | JsonObject | JsonValue[]; -export type JsonObject = { - [key: string]: JsonValue; + operation?: ToolOperation; + input_schema: TSchema; + input_examples: z.input[]; + handler: (input: z.output) => Promise; }; export type AnyToolDefinition = { name: string; description: string; + operation?: ToolOperation; input_schema: z.ZodType; - input_examples: JsonObject[]; - handler: (input: never, store: ChainedToolStore) => Promise; + input_examples: Record[]; + handler: (input: never) => Promise; }; export type AnthropicBetaFlags = Partial>; +export type CacheTtl = '5m' | '1h'; + export type RunAgentQuery = { model: Model; + thinking?: boolean; maxTokens: number; messages: string[]; tools: AnyToolDefinition[]; betas?: AnthropicBetaFlags; requireToolApproval?: boolean; + pauseAfterCompact?: boolean; + cacheTtl?: CacheTtl; + /** Called with the raw tool output (pre-serialisation). Return value is serialised and stored in history. Use to ref-swap large values before they enter the context window. */ + transformToolResult?: (toolName: string, output: unknown) => unknown; }; /** Messages sent from the SDK to the consumer via the MessagePort. */ -export type SdkMessage = { type: 'message_start' } | { type: 'message_text'; text: string } | { type: 'message_end' } | { type: 'tool_approval_request'; requestId: string; name: string; input: Record } | { type: 'done'; stopReason: string } | { type: 'error'; message: string }; + +export type SdkMessageStart = { type: 'message_start' }; +export type SdkMessageText = { type: 'message_text'; text: string }; +export type SdkMessageThinking = { type: 'message_thinking'; text: string }; +export type SdkMessageCompactionStart = { type: 'message_compaction_start' }; +export type SdkMessageCompaction = { type: 'message_compaction'; summary: string }; +export type SdkMessageEnd = { type: 'message_end' }; +export type SdkToolApprovalRequest = { type: 'tool_approval_request'; requestId: string; name: string; input: Record }; +export type SdkToolError = { type: 'tool_error'; name: string; input: Record; error: string }; +export type SdkDone = { type: 'done'; stopReason: string }; +export type SdkError = { type: 'error'; message: string }; +export type SdkMessageUsage = { type: 'message_usage'; inputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; outputTokens: number; costUsd: number; contextWindow: number }; + +export type SdkMessage = SdkMessageStart | SdkMessageText | SdkMessageThinking | SdkMessageCompactionStart | SdkMessageCompaction | SdkMessageEnd | SdkToolApprovalRequest | SdkToolError | SdkDone | SdkError | SdkMessageUsage; /** Messages sent from the consumer to the SDK via the MessagePort. */ export type ConsumerMessage = { type: 'tool_approval_response'; requestId: string; approved: boolean; reason?: string } | { type: 'cancel' }; @@ -60,4 +77,5 @@ export type ILogger = { export type AnthropicAgentOptions = { apiKey: string; logger?: ILogger; + historyFile?: string; }; diff --git a/packages/claude-sdk/test/ConversationHistory.spec.ts b/packages/claude-sdk/test/ConversationHistory.spec.ts new file mode 100644 index 0000000..0d960fc --- /dev/null +++ b/packages/claude-sdk/test/ConversationHistory.spec.ts @@ -0,0 +1,150 @@ +import type { Anthropic } from '@anthropic-ai/sdk'; +import { describe, expect, it } from 'vitest'; +import { ConversationHistory } from '../src/private/ConversationHistory.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type Role = Anthropic.Beta.Messages.BetaMessageParam['role']; + +function msg(role: Role, text: string): Anthropic.Beta.Messages.BetaMessageParam { + return { role, content: [{ type: 'text', text }] }; +} + +function compactionMsg(): Anthropic.Beta.Messages.BetaMessageParam { + return { + role: 'user', + content: [{ type: 'compaction', summary: 'summary', llm_identifier: 'claude-3-5-sonnet-20241022' }], + } as unknown as Anthropic.Beta.Messages.BetaMessageParam; +} + +// --------------------------------------------------------------------------- +// push + messages +// --------------------------------------------------------------------------- + +describe('ConversationHistory.push / messages', () => { + it('appends messages in order', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'hello')); + h.push(msg('assistant', 'hi')); + h.push(msg('user', 'bye')); + + const msgs = h.messages; + expect(msgs).toHaveLength(3); + expect((msgs[0]?.content as { text: string }[])[0]?.text).toBe('hello'); + expect((msgs[1]?.content as { text: string }[])[0]?.text).toBe('hi'); + expect((msgs[2]?.content as { text: string }[])[0]?.text).toBe('bye'); + }); + + it('merges consecutive user messages into one', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'part one')); + h.push(msg('user', 'part two')); + + const msgs = h.messages; + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe('user'); + const content = msgs[0]?.content as { text: string }[]; + expect(content).toHaveLength(2); + expect(content[0]?.text).toBe('part one'); + expect(content[1]?.text).toBe('part two'); + }); + + it('does NOT merge consecutive assistant messages', () => { + // assistant→assistant is not typical but the class should not merge them + const h = new ConversationHistory(); + h.push(msg('assistant', 'first')); + h.push(msg('assistant', 'second')); + + expect(h.messages).toHaveLength(2); + }); + + it('clears history when a compaction block is pushed', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'old message 1')); + h.push(msg('assistant', 'old reply')); + expect(h.messages).toHaveLength(2); + + h.push(compactionMsg()); + + // Only the compaction message should remain + const msgs = h.messages; + expect(msgs).toHaveLength(1); + expect((msgs[0]?.content as { type: string }[])[0]?.type).toBe('compaction'); + }); + + it('starts empty with no history file', () => { + const h = new ConversationHistory(); + expect(h.messages).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// push with id / remove +// --------------------------------------------------------------------------- + +describe('ConversationHistory id tagging + remove', () => { + it('tags a message and remove() finds it', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'hello')); + h.push(msg('assistant', 'context injection'), { id: 'ctx-1' }); + h.push(msg('user', 'follow up')); + + expect(h.messages).toHaveLength(3); + const removed = h.remove('ctx-1'); + expect(removed).toBe(true); + expect(h.messages).toHaveLength(2); + expect((h.messages[0]?.content as { text: string }[])[0]?.text).toBe('hello'); + expect((h.messages[1]?.content as { text: string }[])[0]?.text).toBe('follow up'); + }); + + it('remove() returns false when id is not found', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'hello')); + expect(h.remove('nonexistent')).toBe(false); + expect(h.messages).toHaveLength(1); + }); + + it('remove() targets the LAST message with the given id', () => { + const h = new ConversationHistory(); + h.push(msg('assistant', 'first tagged'), { id: 'dup' }); + // A non-user message in between so there's no merge issue + h.push(msg('user', 'separator')); + h.push(msg('assistant', 'second tagged'), { id: 'dup' }); + + // Should remove the last one + expect(h.remove('dup')).toBe(true); + const msgs = h.messages; + expect(msgs).toHaveLength(2); + expect((msgs[0]?.content as { text: string }[])[0]?.text).toBe('first tagged'); + expect((msgs[1]?.content as { text: string }[])[0]?.text).toBe('separator'); + }); + + it('merging consecutive user messages drops the id tag', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'first'), { id: 'tagged' }); + h.push(msg('user', 'second')); // triggers merge — tag on 'first' is dropped + + // The merged message should NOT be findable by the old id + expect(h.remove('tagged')).toBe(false); + // But content is merged + expect(h.messages).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// compaction interaction with id/remove +// --------------------------------------------------------------------------- + +describe('ConversationHistory compaction edge cases', () => { + it('compaction clears tagged messages too', () => { + const h = new ConversationHistory(); + h.push(msg('user', 'old'), { id: 'old-ctx' }); + h.push(compactionMsg()); + + // Everything before compaction is gone + expect(h.remove('old-ctx')).toBe(false); + expect(h.messages).toHaveLength(1); + }); +}); diff --git a/packages/claude-sdk/tsconfig.json b/packages/claude-sdk/tsconfig.json index 3bb5eb4..f41a1dc 100644 --- a/packages/claude-sdk/tsconfig.json +++ b/packages/claude-sdk/tsconfig.json @@ -1,13 +1,8 @@ { - "extends": "@tsconfig/node24/tsconfig.json", + "extends": "@shellicar/typescript-config/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": ".", - "moduleResolution": "bundler", - "module": "es2022", - "target": "es2024", - "strictNullChecks": true + "rootDir": "." }, - "include": ["**/*.ts"], - "exclude": ["dist", "node_modules"] + "include": ["**/*.ts"] } diff --git a/packages/claude-sdk/vitest.config.ts b/packages/claude-sdk/vitest.config.ts new file mode 100644 index 0000000..ae8680e --- /dev/null +++ b/packages/claude-sdk/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/claude-cli/tsconfig.json b/packages/typescript-config/base.json similarity index 61% rename from packages/claude-cli/tsconfig.json rename to packages/typescript-config/base.json index 3bb5eb4..d60ec70 100644 --- a/packages/claude-cli/tsconfig.json +++ b/packages/typescript-config/base.json @@ -1,13 +1,13 @@ { "extends": "@tsconfig/node24/tsconfig.json", "compilerOptions": { - "outDir": "dist", - "rootDir": ".", "moduleResolution": "bundler", "module": "es2022", "target": "es2024", - "strictNullChecks": true + "strictNullChecks": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true }, - "include": ["**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json new file mode 100644 index 0000000..2f2c126 --- /dev/null +++ b/packages/typescript-config/package.json @@ -0,0 +1,11 @@ +{ + "name": "@shellicar/typescript-config", + "version": "0.0.0", + "private": "true", + "exports": { + "./base.json": "./base.json" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 779987e..b09de43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@biomejs/biome': specifier: ^2.4.10 version: 2.4.10 + '@vitest/coverage-v8': + specifier: ^4.1.2 + version: 4.1.2(vitest@4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))) knip: specifier: ^5.88.1 version: 5.88.1(@types/node@25.5.0)(typescript@6.0.2) @@ -33,12 +36,88 @@ importers: turbo: specifier: ^2.9.3 version: 2.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + + apps/claude-cli: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.90 + version: 0.2.90(zod@4.3.6) + '@js-joda/core': + specifier: ^5.7.0 + version: 5.7.0 + '@shellicar/claude-core': + specifier: workspace:* + version: link:../../packages/claude-core + '@shellicar/mcp-exec': + specifier: 1.0.0-preview.6 + version: 1.0.0-preview.6 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + string-width: + specifier: ^8.2.0 + version: 8.2.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@anthropic-ai/sdk': + specifier: ^0.82.0 + version: 0.82.0(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) + '@shellicar/build-clean': + specifier: ^1.3.2 + version: 1.3.2(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/build-version': + specifier: ^1.3.6 + version: 1.3.6(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@tsconfig/node24': + specifier: ^24.0.4 + version: 24.0.4 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + '@vitest/coverage-v8': + specifier: ^4.1.2 + version: 4.1.2(vitest@4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))) + esbuild: + specifier: ^0.27.5 + version: 0.27.5 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) apps/claude-sdk-cli: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.82.0 + version: 0.82.0(zod@4.3.6) + '@shellicar/claude-core': + specifier: workspace:^ + version: link:../../packages/claude-core '@shellicar/claude-sdk': specifier: workspace:^ version: link:../../packages/claude-sdk + '@shellicar/claude-sdk-tools': + specifier: workspace:^ + version: link:../../packages/claude-sdk-tools + cli-highlight: + specifier: ^2.1.11 + version: 2.1.11 winston: specifier: ^3.19.0 version: 3.19.0 @@ -52,58 +131,80 @@ importers: '@shellicar/build-version': specifier: ^1.3.6 version: 1.3.6(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 esbuild: specifier: ^0.27.5 version: 0.27.5 tsx: specifier: ^4.21.0 version: 4.21.0 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) - packages/claude-cli: + packages/claude-core: dependencies: - '@anthropic-ai/claude-agent-sdk': - specifier: ^0.2.90 - version: 0.2.90(zod@4.3.6) - '@js-joda/core': - specifier: ^5.7.0 - version: 5.7.0 - '@shellicar/mcp-exec': - specifier: 1.0.0-preview.6 - version: 1.0.0-preview.6 - sharp: - specifier: ^0.34.5 - version: 0.34.5 string-width: specifier: ^8.2.0 version: 8.2.0 - zod: - specifier: ^4.3.6 - version: 4.3.6 devDependencies: + '@shellicar/build-clean': + specifier: ^1.3.2 + version: 1.3.2(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/build-version': + specifier: ^1.3.6 + version: 1.3.6(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@tsconfig/node24': + specifier: ^24.0.4 + version: 24.0.4 + '@types/node': + specifier: ^25.5.0 + version: 25.5.0 + esbuild: + specifier: ^0.27.5 + version: 0.27.5 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + + packages/claude-sdk: + dependencies: '@anthropic-ai/sdk': specifier: ^0.82.0 version: 0.82.0(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: '@shellicar/build-clean': specifier: ^1.3.2 version: 1.3.2(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@shellicar/build-version': specifier: ^1.3.6 version: 1.3.6(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/typescript-config': + specifier: workspace:* + version: link:../typescript-config '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 '@types/node': specifier: ^25.5.0 version: 25.5.0 - '@vitest/coverage-v8': - specifier: ^4.1.2 - version: 4.1.2(vitest@4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))) esbuild: specifier: ^0.27.5 version: 0.27.5 @@ -111,17 +212,23 @@ importers: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.2 + version: 6.0.2 vitest: specifier: ^4.1.2 version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) - packages/claude-sdk: + packages/claude-sdk-tools: dependencies: '@anthropic-ai/sdk': specifier: ^0.82.0 version: 0.82.0(zod@4.3.6) + diff: + specifier: ^8.0.4 + version: 8.0.4 + file-type: + specifier: ^22.0.0 + version: 22.0.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -132,6 +239,12 @@ importers: '@shellicar/build-version': specifier: ^1.3.6 version: 1.3.6(esbuild@0.27.5)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@shellicar/claude-sdk': + specifier: workspace:^ + version: link:../claude-sdk + '@shellicar/typescript-config': + specifier: workspace:* + version: link:../typescript-config '@tsconfig/node24': specifier: ^24.0.4 version: 24.0.4 @@ -147,6 +260,15 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + vitest: + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/typescript-config: + devDependencies: + '@tsconfig/node24': + specifier: ^24.0.4 + version: 24.0.4 packages: @@ -256,6 +378,9 @@ packages: cpu: [x64] os: [win32] + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -1098,6 +1223,13 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tsconfig/node24@24.0.4': resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==} @@ -1207,10 +1339,21 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1245,10 +1388,29 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} engines: {node: '>=12.20'} @@ -1305,6 +1467,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1312,6 +1478,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} @@ -1344,6 +1513,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -1404,6 +1577,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + file-type@22.0.0: + resolution: {integrity: sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw==} + engines: {node: '>=22'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1436,6 +1613,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -1471,6 +1652,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.9: resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} engines: {node: '>=16.9.0'} @@ -1486,6 +1670,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1501,6 +1688,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1738,6 +1929,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1776,6 +1970,15 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1828,6 +2031,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1929,6 +2136,10 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string-width@8.2.0: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} @@ -1936,6 +2147,10 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.2.0: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} @@ -1944,6 +2159,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1996,6 +2215,13 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2019,6 +2245,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -2052,6 +2282,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbash@2.2.0: resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} engines: {node: '>=14'} @@ -2174,14 +2408,30 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -2275,6 +2525,8 @@ snapshots: '@biomejs/cli-win32-x64@2.4.10': optional: true + '@borewit/text-codec@0.2.2': {} + '@colors/colors@1.6.0': {} '@dabh/diagnostics@2.0.8': @@ -2783,6 +3035,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tsconfig/node24@24.0.4': {} '@turbo/darwin-64@2.9.3': @@ -2896,8 +3157,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.0: @@ -2940,10 +3209,36 @@ snapshots: chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + color-convert@3.1.3: dependencies: color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} color-string@2.1.4: @@ -2984,6 +3279,8 @@ snapshots: detect-libc@2.1.2: {} + diff@8.0.4: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2992,6 +3289,8 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@8.0.0: {} + enabled@2.0.0: {} encodeurl@2.0.0: {} @@ -3064,6 +3363,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.5 '@esbuild/win32-x64': 0.27.5 + escalade@3.2.0: {} + escape-html@1.0.3: {} estree-walker@3.0.3: @@ -3144,6 +3445,15 @@ snapshots: fecha@4.2.3: {} + file-type@22.0.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3174,6 +3484,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -3212,6 +3524,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.12.9: {} html-escaper@2.0.2: {} @@ -3228,6 +3542,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + inherits@2.0.4: {} ip-address@10.1.0: {} @@ -3236,6 +3552,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3435,6 +3753,12 @@ snapshots: ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} negotiator@1.0.0: {} @@ -3482,6 +3806,14 @@ snapshots: '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + parseurl@1.3.3: {} path-key@3.1.1: {} @@ -3528,6 +3860,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -3693,6 +4027,12 @@ snapshots: std-env@4.0.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string-width@8.2.0: dependencies: get-east-asian-width: 1.5.0 @@ -3702,12 +4042,20 @@ snapshots: dependencies: safe-buffer: 5.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 strip-json-comments@5.0.3: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3749,6 +4097,14 @@ snapshots: text-hex@1.0.0: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tinybench@2.9.0: {} tinyexec@1.0.4: {} @@ -3766,6 +4122,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + triple-beam@1.4.1: {} ts-algebra@2.0.0: {} @@ -3799,6 +4161,8 @@ snapshots: typescript@6.0.2: {} + uint8array-extras@1.5.0: {} + unbash@2.2.0: {} undici-types@7.18.2: {} @@ -3892,10 +4256,30 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} + y18n@5.0.8: {} + yaml@2.8.3: {} + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/scripts/tag-latest.sh b/scripts/tag-latest.sh index cb2be5d..2e0ecac 100755 --- a/scripts/tag-latest.sh +++ b/scripts/tag-latest.sh @@ -10,7 +10,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$REPO_ROOT/packages/$(cat "$REPO_ROOT/.packagename")" +cd "$REPO_ROOT/apps/$(cat "$REPO_ROOT/.packagename")" pkg=$(node -e "const p=$(pnpm pkg get name version);process.stdout.write(p.name+'@'+p.version)") echo "Tagging $pkg as latest..." diff --git a/scripts/verify-version.sh b/scripts/verify-version.sh index deab774..e85bdfc 100755 --- a/scripts/verify-version.sh +++ b/scripts/verify-version.sh @@ -7,7 +7,7 @@ set -e # Get full version from package.json -PACKAGE_DIR="packages/$(cat .packagename)" +PACKAGE_DIR="apps/$(cat .packagename)" full_version=$(node -p "JSON.parse(require('fs').readFileSync('$PACKAGE_DIR/package.json')).version") # Extract base version (x.y.z) stripping any prerelease suffix diff --git a/turbo.json b/turbo.json index e417eec..1ae7593 100644 --- a/turbo.json +++ b/turbo.json @@ -25,6 +25,11 @@ "dependsOn": ["^build"], "inputs": ["tsconfig.check.json"], "outputs": ["**/node_modules/.cache/tsbuildinfo.json"] + }, + "watch": { + "dependsOn": ["^build"], + "cache": false, + "persistent": true } } } diff --git a/vitest.config.ts b/vitest.config.ts index f041fa8..3f0e012 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - projects: ['packages/*'], + coverage: { + provider: 'v8', + }, + projects: ['apps/*', 'packages/*'], }, });