@onmars/lunar-agent-claude 0.1.0

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/src/adapter.ts ADDED
@@ -0,0 +1,514 @@
1
+ import type { Agent, AgentEvent, AgentInput, AgentUsage } from '@onmars/lunar-core'
2
+ import { log } from '@onmars/lunar-core'
3
+ import type { SecurityConfig } from '@onmars/lunar-core/lib/config-loader'
4
+ import { buildSafeEnv } from '@onmars/lunar-core/lib/security'
5
+
6
+ export interface ClaudeAgentOptions {
7
+ /** Agent ID for routing — defaults to 'claude' */
8
+ id?: string
9
+ /** Display name — defaults to 'Claude Code (CLI)' */
10
+ name?: string
11
+ /** Working directory for Claude Code */
12
+ cwd: string
13
+ /** Path to claude binary (default: 'claude') */
14
+ binaryPath?: string
15
+ /** Model override (e.g., 'opus', 'sonnet') */
16
+ model?: string
17
+ /** Max turns for agentic loop */
18
+ maxTurns?: number
19
+ /** System prompt to prepend */
20
+ systemPrompt?: string
21
+ /**
22
+ * Auth mode:
23
+ * - 'stored' (default): use credentials from ~/.claude/ (claude login)
24
+ * - 'api-key': use ANTHROPIC_API_KEY env var
25
+ * - 'oauth-token': use ANTHROPIC_AUTH_TOKEN env var
26
+ */
27
+ authMode?: 'stored' | 'api-key' | 'oauth-token'
28
+ /** Environment variables to pass to the Claude process */
29
+ env?: Record<string, string>
30
+ /** Security config from workspace config.yaml */
31
+ security?: SecurityConfig
32
+ /**
33
+ * Enable 1M context window (extended context).
34
+ * Appends [1m] suffix to the model flag, which tells Claude Code CLI
35
+ * to send the anthropic-beta: context-1m-2025-08-07 header.
36
+ * Pricing: same up to 200K tokens, 2x input / 1.5x output above 200K.
37
+ */
38
+ context1m?: boolean
39
+ }
40
+
41
+ /**
42
+ * Model alias → full ID mapping for Claude Code CLI.
43
+ *
44
+ * When appending [1m] for extended context, the CLI requires the full
45
+ * model ID (e.g., "claude-opus-4-6[1m]"), not just the alias ("opus[1m]").
46
+ * This map resolves common aliases before appending the suffix.
47
+ */
48
+ export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
49
+ opus: 'claude-opus-4-6',
50
+ sonnet: 'claude-sonnet-4-6',
51
+ 'sonnet-4.5': 'claude-sonnet-4-5',
52
+ haiku: 'claude-haiku-4-5',
53
+ }
54
+
55
+ /** Resolve a model alias to its full ID. Returns the input if not an alias. */
56
+ export function resolveModelAlias(model: string): string {
57
+ return CLAUDE_MODEL_ALIASES[model] ?? model
58
+ }
59
+
60
+ interface ClaudeJsonMessage {
61
+ type: string
62
+ subtype?: string
63
+ session_id?: string
64
+ message?: {
65
+ role: string
66
+ content: Array<{
67
+ type: string
68
+ text?: string
69
+ thinking?: string
70
+ name?: string
71
+ input?: unknown
72
+ content?: unknown
73
+ }>
74
+ usage?: {
75
+ input_tokens?: number
76
+ output_tokens?: number
77
+ cache_read_input_tokens?: number
78
+ cache_creation_input_tokens?: number
79
+ }
80
+ model?: string
81
+ }
82
+ tool_use_id?: string
83
+ duration_ms?: number
84
+ duration_api_ms?: number
85
+ num_turns?: number
86
+ result?: string
87
+ }
88
+
89
+ /**
90
+ * Claude Agent — CLI spawn adapter.
91
+ *
92
+ * Spawns the official Claude Code CLI binary via Bun.spawn.
93
+ * Uses --output-format stream-json for real-time streaming.
94
+ * Auth: by default uses stored credentials from `claude login` (~/.claude/).
95
+ */
96
+ export class ClaudeAgent implements Agent {
97
+ readonly id: string
98
+ readonly name: string
99
+
100
+ private activeProcess: ReturnType<typeof Bun.spawn> | null = null
101
+
102
+ constructor(private options: ClaudeAgentOptions) {
103
+ this.id = options.id ?? 'claude'
104
+ this.name = options.name ?? 'Claude Code (CLI)'
105
+ }
106
+
107
+ async init(): Promise<void> {
108
+ const binary = this.options.binaryPath ?? 'claude'
109
+
110
+ // Check binary exists
111
+ const which = Bun.spawn(['which', binary], { stdout: 'pipe', stderr: 'pipe' })
112
+ const exitCode = await which.exited
113
+ if (exitCode !== 0) {
114
+ throw new Error(
115
+ `Claude CLI not found at '${binary}'. Install with: npm install -g @anthropic-ai/claude-code`,
116
+ )
117
+ }
118
+
119
+ // Verify auth works with a minimal test query
120
+ const env = this.buildEnv()
121
+ const testProc = Bun.spawn(
122
+ [
123
+ binary,
124
+ '-p',
125
+ 'Reply with OK',
126
+ '--output-format',
127
+ 'json',
128
+ '--max-turns',
129
+ '1',
130
+ '--permission-mode',
131
+ 'bypassPermissions',
132
+ ],
133
+ { cwd: this.options.cwd, env, stdout: 'pipe', stderr: 'pipe' },
134
+ )
135
+ const testExit = await testProc.exited
136
+ if (testExit !== 0) {
137
+ const stderr = await new Response(testProc.stderr).text()
138
+ throw new Error(
139
+ `Claude CLI auth check failed (exit ${testExit}). Run 'claude login' first.\nDetails: ${stderr.slice(0, 200)}`,
140
+ )
141
+ }
142
+
143
+ // Check response for auth errors
144
+ const testOutput = await new Response(testProc.stdout).text()
145
+ try {
146
+ const result = JSON.parse(testOutput)
147
+ if (result.is_error || result.result?.includes('Invalid API key')) {
148
+ throw new Error(
149
+ 'Claude CLI auth failed: Invalid API key detected. If you have ANTHROPIC_API_KEY set in your environment, remove it — Claude Code uses stored credentials from "claude login".',
150
+ )
151
+ }
152
+ } catch (err) {
153
+ if (err instanceof SyntaxError) {
154
+ log.warn('Could not parse auth check response, proceeding anyway')
155
+ } else {
156
+ throw err
157
+ }
158
+ }
159
+
160
+ log.info(
161
+ { cwd: this.options.cwd, binary, auth: this.options.authMode ?? 'stored' },
162
+ 'Claude CLI agent initialized (auth verified)',
163
+ )
164
+ }
165
+
166
+ async destroy(): Promise<void> {
167
+ if (this.activeProcess) {
168
+ this.activeProcess.kill()
169
+ this.activeProcess = null
170
+ }
171
+ }
172
+
173
+ abort(): void {
174
+ if (this.activeProcess) {
175
+ log.info('Aborting active Claude CLI process')
176
+ this.activeProcess.kill()
177
+ // Don't null activeProcess — the query() generator's finally block handles that
178
+ }
179
+ }
180
+
181
+ async *query(input: AgentInput): AsyncGenerator<AgentEvent> {
182
+ const startTime = Date.now()
183
+ const args = this.buildArgs(input)
184
+ const env = this.buildEnv()
185
+
186
+ log.debug(
187
+ { args: args.join(' ').slice(0, 200), sessionId: input.sessionId },
188
+ 'Spawning claude CLI',
189
+ )
190
+ log.debug(
191
+ {
192
+ hasApiKey: 'ANTHROPIC_API_KEY' in env,
193
+ hasAuthToken: 'ANTHROPIC_AUTH_TOKEN' in env,
194
+ authMode: this.options.authMode ?? 'stored',
195
+ },
196
+ 'Claude env check',
197
+ )
198
+
199
+ const proc = Bun.spawn(args, {
200
+ cwd: this.options.cwd,
201
+ env,
202
+ stdout: 'pipe',
203
+ stderr: 'pipe',
204
+ })
205
+
206
+ this.activeProcess = proc
207
+
208
+ try {
209
+ yield* this.processStream(proc, startTime, input)
210
+ } finally {
211
+ this.activeProcess = null
212
+ }
213
+ }
214
+
215
+ async health(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
216
+ const start = Date.now()
217
+ try {
218
+ const proc = Bun.spawn([this.options.binaryPath ?? 'claude', '--version'], {
219
+ stdout: 'pipe',
220
+ stderr: 'pipe',
221
+ })
222
+ const exitCode = await proc.exited
223
+ return {
224
+ ok: exitCode === 0,
225
+ latencyMs: Date.now() - start,
226
+ error: exitCode !== 0 ? `Exit code ${exitCode}` : undefined,
227
+ }
228
+ } catch (err) {
229
+ return {
230
+ ok: false,
231
+ latencyMs: Date.now() - start,
232
+ error: err instanceof Error ? err.message : String(err),
233
+ }
234
+ }
235
+ }
236
+
237
+ // --- Private ---
238
+
239
+ private buildArgs(input: AgentInput): string[] {
240
+ const binary = this.options.binaryPath ?? 'claude'
241
+ const args: string[] = [binary]
242
+
243
+ // Print mode with prompt
244
+ args.push('-p', input.prompt)
245
+
246
+ // Streaming JSON output + verbose (required by CLI)
247
+ args.push('--output-format', 'stream-json', '--verbose')
248
+
249
+ // Session resumption
250
+ if (input.sessionId) {
251
+ args.push('--resume', input.sessionId)
252
+ }
253
+
254
+ // System prompt
255
+ const systemPrompt = input.systemPrompt ?? this.options.systemPrompt
256
+ if (systemPrompt) {
257
+ args.push('--append-system-prompt', systemPrompt)
258
+ }
259
+
260
+ // Model override — per-query (moon) takes priority over agent-level (global)
261
+ const effectiveModel = input.model ?? this.options.model
262
+ const effectiveContext1m = input.context1m ?? this.options.context1m
263
+
264
+ if (effectiveModel) {
265
+ let model = effectiveModel
266
+ if (effectiveContext1m) {
267
+ // Resolve alias to full ID before appending [1m].
268
+ // CLI requires full ID format: "claude-opus-4-6[1m]", not "opus[1m]"
269
+ model = resolveModelAlias(model)
270
+ if (!model.includes('[1m]')) {
271
+ model = `${model}[1m]`
272
+ }
273
+ }
274
+ args.push('--model', model)
275
+ }
276
+
277
+ // Max turns
278
+ if (this.options.maxTurns) {
279
+ args.push('--max-turns', String(this.options.maxTurns))
280
+ }
281
+
282
+ // Permission mode
283
+ args.push('--permission-mode', 'bypassPermissions')
284
+
285
+ return args
286
+ }
287
+
288
+ private buildEnv(): Record<string, string> {
289
+ const authMode = this.options.authMode ?? 'stored'
290
+
291
+ // Auth-specific env overrides
292
+ const authEnv: Record<string, string> = {}
293
+
294
+ if (authMode === 'stored') {
295
+ // Explicitly clear auth env vars so CLI uses stored credentials (~/.claude/)
296
+ // These are set to empty string to override any inherited values
297
+ } else if (authMode === 'api-key') {
298
+ const apiKey = process.env.ANTHROPIC_API_KEY
299
+ if (apiKey) authEnv.ANTHROPIC_API_KEY = apiKey
300
+ } else if (authMode === 'oauth-token') {
301
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN
302
+ if (authToken) authEnv.ANTHROPIC_AUTH_TOKEN = authToken
303
+ }
304
+
305
+ // 1M context control via CLAUDE_CODE_DISABLE_1M_CONTEXT env var.
306
+ // Since Claude Code v2.1.50+, 1M context is enabled by default for Opus 4.6.
307
+ // We only set the disable var when context1m is explicitly false.
308
+ const context1mEnv: Record<string, string> = {}
309
+ if (this.options.context1m === false) {
310
+ context1mEnv.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1'
311
+ }
312
+
313
+ // If security config is provided, use allowlist filtering
314
+ if (this.options.security) {
315
+ const env = buildSafeEnv(
316
+ process.env as Record<string, string | undefined>,
317
+ this.options.security,
318
+ { ...authEnv, ...context1mEnv, ...(this.options.env ?? {}) },
319
+ )
320
+
321
+ // For stored auth, ensure auth env vars are NOT present
322
+ if (authMode === 'stored') {
323
+ delete env.ANTHROPIC_API_KEY
324
+ delete env.ANTHROPIC_AUTH_TOKEN
325
+ }
326
+
327
+ return env
328
+ }
329
+
330
+ // Fallback: legacy behavior (blocklist approach) when no security config
331
+ log.warn('No security config provided — using legacy blocklist env filtering')
332
+ const keysToRemove = new Set<string>()
333
+ if (authMode === 'stored') {
334
+ keysToRemove.add('ANTHROPIC_API_KEY')
335
+ keysToRemove.add('ANTHROPIC_AUTH_TOKEN')
336
+ } else if (authMode === 'api-key') {
337
+ keysToRemove.add('ANTHROPIC_AUTH_TOKEN')
338
+ } else if (authMode === 'oauth-token') {
339
+ keysToRemove.add('ANTHROPIC_API_KEY')
340
+ }
341
+
342
+ const env: Record<string, string> = {}
343
+ for (const [key, value] of Object.entries(process.env)) {
344
+ if (value !== undefined && !keysToRemove.has(key)) {
345
+ env[key] = value
346
+ }
347
+ }
348
+ Object.assign(env, context1mEnv)
349
+ if (this.options.env) Object.assign(env, this.options.env)
350
+ return env
351
+ }
352
+
353
+ private async *processStream(
354
+ proc: ReturnType<typeof Bun.spawn>,
355
+ startTime: number,
356
+ input: AgentInput,
357
+ ): AsyncGenerator<AgentEvent> {
358
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
359
+ const decoder = new TextDecoder()
360
+
361
+ let buffer = ''
362
+ let sessionId = ''
363
+ let totalInputTokens = 0
364
+ let totalOutputTokens = 0
365
+ let totalCacheReadTokens = 0
366
+ let totalCacheWriteTokens = 0
367
+ // Last API call values — for accurate context window calculation
368
+ let lastCallInputTokens = 0
369
+ let lastCallCacheReadTokens = 0
370
+ let lastCallCacheWriteTokens = 0
371
+ let model: string | undefined
372
+ let hasAssistantText = false
373
+
374
+ try {
375
+ while (true) {
376
+ const { done, value } = await reader.read()
377
+ if (done) break
378
+
379
+ buffer += decoder.decode(value, { stream: true })
380
+
381
+ // Process complete JSON lines
382
+ const lines = buffer.split('\n')
383
+ buffer = lines.pop() ?? ''
384
+
385
+ for (const line of lines) {
386
+ const trimmed = line.trim()
387
+ if (!trimmed) continue
388
+
389
+ try {
390
+ const msg: ClaudeJsonMessage = JSON.parse(trimmed)
391
+ for (const evt of this.processMessage(msg)) {
392
+ if (evt.type === 'text') hasAssistantText = true
393
+ yield evt
394
+ }
395
+
396
+ // Fallback: if result type has text and no assistant blocks emitted text
397
+ if (msg.type === 'result' && msg.result && !hasAssistantText) {
398
+ yield { type: 'text', content: msg.result }
399
+ }
400
+
401
+ if (msg.session_id) sessionId = msg.session_id
402
+ if (msg.message?.usage) {
403
+ totalInputTokens += msg.message.usage.input_tokens ?? 0
404
+ totalOutputTokens += msg.message.usage.output_tokens ?? 0
405
+ totalCacheReadTokens += msg.message.usage.cache_read_input_tokens ?? 0
406
+ totalCacheWriteTokens += msg.message.usage.cache_creation_input_tokens ?? 0
407
+ // Overwrite (not accumulate) — last API call = current context state
408
+ lastCallInputTokens = msg.message.usage.input_tokens ?? 0
409
+ lastCallCacheReadTokens = msg.message.usage.cache_read_input_tokens ?? 0
410
+ lastCallCacheWriteTokens = msg.message.usage.cache_creation_input_tokens ?? 0
411
+ }
412
+ if (msg.message?.model) model = msg.message.model
413
+ } catch {
414
+ log.debug({ line: trimmed.slice(0, 100) }, 'Skipping non-JSON line')
415
+ }
416
+ }
417
+ }
418
+ } finally {
419
+ reader.releaseLock()
420
+ }
421
+
422
+ const exitCode = await proc.exited
423
+
424
+ if (exitCode !== 0) {
425
+ const stderr = await new Response(proc.stderr as ReadableStream).text()
426
+ if (stderr.trim()) {
427
+ log.error({ exitCode, stderr: stderr.slice(0, 500) }, 'Claude CLI error')
428
+ if (stderr.includes('rate limit') || stderr.includes("you've hit your limit")) {
429
+ yield { type: 'error', error: 'Rate limited. Please wait a moment.', recoverable: true }
430
+ } else {
431
+ yield { type: 'error', error: stderr.trim(), recoverable: false }
432
+ }
433
+ }
434
+ }
435
+
436
+ // Only return sessionId if the query actually produced content
437
+ // This prevents saving corrupted sessions from failed auth/errors
438
+ const validSession = hasAssistantText || totalOutputTokens > 0
439
+
440
+ // Tag model with [1m] for internal catalog lookup when 1M context is active.
441
+ // Since Claude Code v2.1.50+, 1M is the default for Opus 4.6, so the CLI
442
+ // model name is unchanged. We tag internally for correct pricing/window size.
443
+ // Only tag real Claude models — skip synthetic/error responses.
444
+ const isContext1m = input.context1m ?? this.options.context1m
445
+ let reportedModel = model
446
+ if (
447
+ isContext1m &&
448
+ reportedModel &&
449
+ reportedModel.startsWith('claude-') &&
450
+ !reportedModel.includes('[1m]')
451
+ ) {
452
+ reportedModel = `${reportedModel}[1m]`
453
+ }
454
+
455
+ yield {
456
+ type: 'done',
457
+ sessionId: validSession ? sessionId : '',
458
+ usage: {
459
+ inputTokens: totalInputTokens,
460
+ outputTokens: totalOutputTokens,
461
+ cacheReadTokens: totalCacheReadTokens,
462
+ cacheWriteTokens: totalCacheWriteTokens,
463
+ // Context from last API call only (not accumulated across turns)
464
+ contextTokens: lastCallInputTokens + lastCallCacheReadTokens + lastCallCacheWriteTokens,
465
+ model: reportedModel,
466
+ durationMs: Date.now() - startTime,
467
+ },
468
+ }
469
+ }
470
+
471
+ private *processMessage(msg: ClaudeJsonMessage): Generator<AgentEvent> {
472
+ switch (msg.type) {
473
+ case 'assistant': {
474
+ if (!msg.message?.content) break
475
+ for (const block of msg.message.content) {
476
+ if (block.type === 'text' && block.text) {
477
+ yield { type: 'text', content: block.text }
478
+ } else if (block.type === 'thinking' && block.thinking) {
479
+ yield { type: 'thinking', content: block.thinking }
480
+ }
481
+ }
482
+ break
483
+ }
484
+
485
+ case 'tool_use': {
486
+ if (!msg.message?.content) break
487
+ for (const block of msg.message.content) {
488
+ if (block.type === 'tool_use' && block.name) {
489
+ yield { type: 'tool_use', tool: block.name, input: block.input }
490
+ }
491
+ }
492
+ break
493
+ }
494
+
495
+ case 'tool_result': {
496
+ if (!msg.message?.content) break
497
+ for (const block of msg.message.content) {
498
+ if (block.type === 'tool_result') {
499
+ yield { type: 'tool_result', tool: '', output: block.content }
500
+ }
501
+ }
502
+ break
503
+ }
504
+
505
+ case 'error': {
506
+ yield { type: 'error', error: msg.result ?? 'Unknown CLI error', recoverable: false }
507
+ break
508
+ }
509
+
510
+ case 'result':
511
+ break // Final result — already captured from assistant messages
512
+ }
513
+ }
514
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { ClaudeAgentOptions } from './adapter'
2
+ export { CLAUDE_MODEL_ALIASES, ClaudeAgent, resolveModelAlias } from './adapter'