ocpipe 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center"><strong>ocpipe</strong></p>
2
- <p align="center">Build LLM pipelines with <a href="https://github.com/sst/opencode">OpenCode</a> and <a href="https://zod.dev">Zod</a>.</p>
2
+ <p align="center">Build LLM pipelines with <a href="https://github.com/sst/opencode">OpenCode</a>, <a href="https://github.com/anthropics/claude-code">Claude Code</a>, and <a href="https://zod.dev">Zod</a>.</p>
3
3
  <p align="center">Inspired by <a href="https://github.com/stanfordnlp/dspy">DSPy</a>.</p>
4
4
  <p align="center">
5
5
  <a href="https://www.npmjs.com/package/ocpipe"><img alt="npm" src="https://img.shields.io/npm/v/ocpipe?style=flat-square" /></a>
@@ -11,7 +11,7 @@
11
11
  - **Type-safe** Define inputs and outputs with Zod schemas
12
12
  - **Modular** Compose modules into complex pipelines
13
13
  - **Checkpoints** Resume from any step
14
- - **Multi-model** Works with 75+ providers through OpenCode
14
+ - **Multi-backend** Choose between OpenCode (75+ providers) or Claude Code SDK
15
15
  - **Auto-correction** Fixes schema mismatches automatically
16
16
 
17
17
  ### Quick Start
@@ -47,15 +47,43 @@ type GreetIn = InferInputs<typeof Greet> // { name: string }
47
47
  type GreetOut = InferOutputs<typeof Greet> // { greeting: string }
48
48
  ```
49
49
 
50
- OpenCode CLI is bundled — run `bun run opencode` or use your system `opencode` if installed.
50
+ ### Backends
51
+
52
+ ocpipe supports two backends for running LLM agents:
53
+
54
+ **OpenCode** (default) - Requires `opencode` CLI in your PATH. Supports 75+ providers.
55
+
56
+ ```typescript
57
+ const pipeline = new Pipeline({
58
+ name: 'my-pipeline',
59
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' },
60
+ defaultAgent: 'default',
61
+ }, createBaseState)
62
+ ```
63
+
64
+ **Claude Code** - Uses `@anthropic-ai/claude-agent-sdk`. Install as a peer dependency.
65
+
66
+ ```typescript
67
+ const pipeline = new Pipeline({
68
+ name: 'my-pipeline',
69
+ defaultModel: { backend: 'claude-code', providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514' },
70
+ defaultAgent: 'default',
71
+ }, createBaseState)
72
+ ```
51
73
 
52
74
  ### Requirements
53
75
 
54
- Currently requires [this OpenCode fork](https://github.com/paralin/opencode). Once the following PRs are merged, the official release will work:
76
+ **For OpenCode backend:** Currently requires [this OpenCode fork](https://github.com/paralin/opencode). Once the following PRs are merged, the official release will work:
55
77
 
56
78
  - [#5426](https://github.com/anomalyco/opencode/pull/5426) - Adds `--prompt-file` flag
57
79
  - [#5339](https://github.com/anomalyco/opencode/pull/5339) - Session export fixes
58
80
 
81
+ **For Claude Code backend:** Install the SDK as a peer dependency:
82
+
83
+ ```bash
84
+ bun add @anthropic-ai/claude-agent-sdk
85
+ ```
86
+
59
87
  ### Documentation
60
88
 
61
89
  - [Getting Started](./GETTING_STARTED.md) - Tutorial with examples
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocpipe",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
4
  "description": "SDK for LLM pipelines with OpenCode and Zod",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,13 +28,18 @@
28
28
  "engines": {
29
29
  "bun": ">=1.0.0"
30
30
  },
31
- "dependencies": {
32
- "opencode-ai": "1.1.25"
33
- },
31
+ "dependencies": {},
34
32
  "peerDependencies": {
35
- "zod": "4.3.5"
33
+ "zod": "4.3.6",
34
+ "@anthropic-ai/claude-agent-sdk": "0.2.19"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@anthropic-ai/claude-agent-sdk": {
38
+ "optional": true
39
+ }
36
40
  },
37
41
  "devDependencies": {
42
+ "@anthropic-ai/claude-agent-sdk": "^0.2.14",
38
43
  "@eslint/js": "^9.39.2",
39
44
  "bun-types": "^1.3.5",
40
45
  "eslint": "^9.39.2",
package/src/agent.ts CHANGED
@@ -1,53 +1,37 @@
1
1
  /**
2
- * ocpipe OpenCode agent integration.
2
+ * ocpipe agent integration.
3
3
  *
4
- * Wraps the OpenCode CLI for running LLM agents with session management.
4
+ * Dispatches to OpenCode CLI or Claude Code SDK based on backend configuration.
5
5
  */
6
6
 
7
7
  import { spawn } from 'child_process'
8
- import { existsSync } from 'fs'
9
8
  import { mkdir, writeFile, unlink } from 'fs/promises'
10
9
  import { join } from 'path'
11
10
  import { PROJECT_ROOT, TMP_DIR } from './paths.js'
12
11
  import type { RunAgentOptions, RunAgentResult } from './types.js'
13
12
 
14
- /** Find opencode binary from PATH, preferring non-node_modules locations */
15
- function findOpencode(): string | null {
16
- const pathDirs = (process.env.PATH || '').split(':')
13
+ /** Get command and args to invoke opencode from PATH */
14
+ function getOpencodeCommand(args: string[]): { cmd: string; args: string[] } {
15
+ return { cmd: 'opencode', args }
16
+ }
17
17
 
18
- // First pass: look for opencode in non-node_modules directories
19
- for (const dir of pathDirs) {
20
- if (dir.includes('node_modules')) continue
21
- const candidate = join(dir, 'opencode')
22
- if (existsSync(candidate)) {
23
- return candidate
24
- }
25
- }
18
+ /** runAgent dispatches to the appropriate backend based on model configuration. */
19
+ export async function runAgent(
20
+ options: RunAgentOptions,
21
+ ): Promise<RunAgentResult> {
22
+ const backend = options.model.backend ?? 'opencode'
26
23
 
27
- // Second pass: check node_modules/.bin as fallback
28
- for (const dir of pathDirs) {
29
- if (!dir.includes('node_modules')) continue
30
- const candidate = join(dir, 'opencode')
31
- if (existsSync(candidate)) {
32
- return candidate
33
- }
24
+ if (backend === 'claude-code') {
25
+ // Dynamic import to avoid requiring @anthropic-ai/claude-agent-sdk when using opencode
26
+ const { runClaudeCodeAgent } = await import('./claude-code.js')
27
+ return runClaudeCodeAgent(options)
34
28
  }
35
29
 
36
- return null
37
- }
38
-
39
- /** Get command and args to invoke opencode */
40
- function getOpencodeCommand(args: string[]): { cmd: string; args: string[] } {
41
- const opencode = findOpencode()
42
- if (opencode) {
43
- return { cmd: opencode, args }
44
- }
45
- // Fallback to bunx with ocpipe package (which has opencode-ai as dependency)
46
- return { cmd: 'bunx', args: ['-p', 'ocpipe', 'opencode', ...args] }
30
+ return runOpencodeAgent(options)
47
31
  }
48
32
 
49
- /** runAgent executes an OpenCode agent with a prompt, streaming output in real-time. */
50
- export async function runAgent(
33
+ /** runOpencodeAgent executes an OpenCode agent with a prompt, streaming output in real-time. */
34
+ async function runOpencodeAgent(
51
35
  options: RunAgentOptions,
52
36
  ): Promise<RunAgentResult> {
53
37
  const { prompt, agent, model, sessionId, timeoutSec = 300, workdir } = options
@@ -69,6 +53,8 @@ export async function runAgent(
69
53
 
70
54
  const args = [
71
55
  'run',
56
+ '--dir',
57
+ cwd,
72
58
  '--format',
73
59
  'default',
74
60
  '--agent',
@@ -85,6 +71,7 @@ export async function runAgent(
85
71
 
86
72
  return new Promise((resolve, reject) => {
87
73
  const opencodeCmd = getOpencodeCommand(args)
74
+ console.error(`[DEBUG] Running: ${opencodeCmd.cmd} ${opencodeCmd.args.join(' ')}`)
88
75
  const proc = spawn(opencodeCmd.cmd, opencodeCmd.args, {
89
76
  cwd,
90
77
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ocpipe Claude Code agent integration.
3
+ *
4
+ * Uses the Claude Agent SDK v2 for running LLM agents with session management.
5
+ */
6
+
7
+ import {
8
+ unstable_v2_createSession,
9
+ unstable_v2_resumeSession,
10
+ type SDKMessage,
11
+ } from '@anthropic-ai/claude-agent-sdk'
12
+ import type { RunAgentOptions, RunAgentResult } from './types.js'
13
+
14
+ /** Extract text from assistant messages. */
15
+ function getAssistantText(msg: SDKMessage): string | null {
16
+ if (msg.type !== 'assistant') return null
17
+ const textParts: string[] = []
18
+ for (const block of msg.message.content) {
19
+ if (block.type === 'text') {
20
+ textParts.push(block.text)
21
+ }
22
+ }
23
+ return textParts.join('')
24
+ }
25
+
26
+ /** runClaudeCodeAgent executes a Claude Code agent with a prompt. */
27
+ export async function runClaudeCodeAgent(
28
+ options: RunAgentOptions,
29
+ ): Promise<RunAgentResult> {
30
+ const { prompt, model, sessionId, timeoutSec = 300 } = options
31
+
32
+ // Claude Agent SDK only uses modelID, not providerID
33
+ const modelStr = model.modelID
34
+ const sessionInfo = sessionId ? `[session:${sessionId}]` : '[new session]'
35
+ const promptPreview = prompt.slice(0, 50).replace(/\n/g, ' ')
36
+
37
+ console.error(
38
+ `\n>>> Claude Code [${modelStr}] ${sessionInfo}: ${promptPreview}...`,
39
+ )
40
+
41
+ // Create or resume session
42
+ const session =
43
+ sessionId ?
44
+ unstable_v2_resumeSession(sessionId, { model: modelStr })
45
+ : unstable_v2_createSession({ model: modelStr })
46
+
47
+ try {
48
+ // Send the prompt
49
+ await session.send(prompt)
50
+
51
+ // Collect the response
52
+ const textParts: string[] = []
53
+ let newSessionId = sessionId || ''
54
+
55
+ // Set up timeout
56
+ const timeoutPromise =
57
+ timeoutSec > 0 ?
58
+ new Promise<never>((_, reject) => {
59
+ setTimeout(() => {
60
+ session.close()
61
+ reject(new Error(`Timeout after ${timeoutSec}s`))
62
+ }, timeoutSec * 1000)
63
+ })
64
+ : null
65
+
66
+ // Stream the response
67
+ const streamPromise = (async () => {
68
+ for await (const msg of session.stream()) {
69
+ // Capture session ID from any message
70
+ if (msg.session_id) {
71
+ newSessionId = msg.session_id
72
+ }
73
+
74
+ const text = getAssistantText(msg)
75
+ if (text) {
76
+ textParts.push(text)
77
+ process.stderr.write(text)
78
+ }
79
+ }
80
+ })()
81
+
82
+ // Race between stream and timeout
83
+ if (timeoutPromise) {
84
+ await Promise.race([streamPromise, timeoutPromise])
85
+ } else {
86
+ await streamPromise
87
+ }
88
+
89
+ const response = textParts.join('')
90
+ const sessionStr = newSessionId || 'none'
91
+ console.error(
92
+ `\n<<< Claude Code done (${response.length} chars) [session:${sessionStr}]`,
93
+ )
94
+
95
+ return {
96
+ text: response,
97
+ sessionId: newSessionId,
98
+ }
99
+ } finally {
100
+ session.close()
101
+ }
102
+ }
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export type { MockResponse } from './testing.js'
87
87
  // Types
88
88
  export type {
89
89
  // Core types
90
+ BackendType,
90
91
  ModelConfig,
91
92
  ExecutionContext,
92
93
  StepResult,
package/src/parsing.ts CHANGED
@@ -307,33 +307,69 @@ export function zodTypeToString(zodType: z.ZodType): string {
307
307
  return 'unknown'
308
308
  }
309
309
 
310
+ /**
311
+ * extractBalancedObject extracts a balanced JSON object starting at startIdx.
312
+ * Returns the object substring or null if unbalanced.
313
+ */
314
+ function extractBalancedObject(text: string, startIdx: number): string | null {
315
+ if (startIdx === -1 || startIdx >= text.length || text[startIdx] !== '{') {
316
+ return null
317
+ }
318
+
319
+ let braceCount = 0
320
+ let endIdx = startIdx
321
+ for (let i = startIdx; i < text.length; i++) {
322
+ if (text[i] === '{') braceCount++
323
+ else if (text[i] === '}') {
324
+ braceCount--
325
+ if (braceCount === 0) {
326
+ endIdx = i + 1
327
+ break
328
+ }
329
+ }
330
+ }
331
+
332
+ if (endIdx > startIdx && braceCount === 0) {
333
+ return text.slice(startIdx, endIdx)
334
+ }
335
+ return null
336
+ }
337
+
310
338
  /** extractJsonString finds and extracts JSON from a response string. */
311
339
  export function extractJsonString(response: string): string | null {
312
340
  // Try to find JSON in code blocks first
313
341
  const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?)```/)
314
342
  if (codeBlockMatch?.[1]) {
315
- return codeBlockMatch[1].trim()
343
+ const candidate = codeBlockMatch[1].trim()
344
+ // Validate it's actually parseable JSON
345
+ try {
346
+ JSON.parse(candidate)
347
+ return candidate
348
+ } catch {
349
+ // Code block content is malformed, try other methods
350
+ }
316
351
  }
317
352
 
318
- // Try to find raw JSON by counting braces
319
- const startIdx = response.indexOf('{')
320
- if (startIdx !== -1) {
321
- let braceCount = 0
322
- let endIdx = startIdx
323
- for (let i = startIdx; i < response.length; i++) {
324
- if (response[i] === '{') braceCount++
325
- else if (response[i] === '}') {
326
- braceCount--
327
- if (braceCount === 0) {
328
- endIdx = i + 1
329
- break
330
- }
353
+ // Try to find raw JSON by counting braces, starting from each { position
354
+ // This handles cases where the model started outputting JSON, then restarted
355
+ // (e.g., "{"response":{"embeds":[{"title":"Haze{"response":{"embeds":[...]}}}")
356
+ // We try each { position until we find one that produces valid JSON
357
+ let searchFrom = 0
358
+ while (searchFrom < response.length) {
359
+ const startIdx = response.indexOf('{', searchFrom)
360
+ if (startIdx === -1) break
361
+
362
+ const candidate = extractBalancedObject(response, startIdx)
363
+ if (candidate) {
364
+ try {
365
+ JSON.parse(candidate)
366
+ return candidate
367
+ } catch {
368
+ // This { position produced invalid JSON, try the next one
331
369
  }
332
370
  }
333
371
 
334
- if (endIdx > startIdx) {
335
- return response.slice(startIdx, endIdx)
336
- }
372
+ searchFrom = startIdx + 1
337
373
  }
338
374
 
339
375
  return null
package/src/types.ts CHANGED
@@ -8,8 +8,13 @@ import type { z } from 'zod/v4'
8
8
  // Model Configuration
9
9
  // ============================================================================
10
10
 
11
- /** Model configuration for OpenCode. */
11
+ /** Backend type for running agents. */
12
+ export type BackendType = 'opencode' | 'claude-code'
13
+
14
+ /** Model configuration for LLM backends. */
12
15
  export interface ModelConfig {
16
+ /** Backend to use (default: 'opencode'). */
17
+ backend?: BackendType
13
18
  providerID: string
14
19
  modelID: string
15
20
  }