lazyclaw 3.88.0 → 3.99.4

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.
@@ -0,0 +1,221 @@
1
+ // Claude subscription provider (no API key).
2
+ //
3
+ // Spawns the local `claude` CLI binary that ships with Claude Code and
4
+ // streams the JSON event format it emits with:
5
+ //
6
+ // claude -p "<prompt>" --output-format stream-json
7
+ // --include-partial-messages --verbose [--model opus|sonnet|haiku]
8
+ //
9
+ // The user's authentication is whatever `claude` is already logged in
10
+ // with — i.e. an Anthropic Pro / Max / Team subscription session — so
11
+ // no API key is needed and no key shows up in the lazyclaw config.
12
+ //
13
+ // Why this is a separate provider from ./anthropic.mjs:
14
+ // - anthropic.mjs talks to api.anthropic.com directly and requires
15
+ // `sk-ant-` keys (pay-per-token).
16
+ // - claude_cli.mjs delegates auth + billing entirely to the `claude`
17
+ // CLI's already-established session (Pro/Max subscription quota).
18
+ // Both can coexist; users pick at onboard time.
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { spawnSandboxed } from '../sandbox.mjs';
22
+
23
+ class AbortError extends Error {
24
+ constructor(message = 'aborted') {
25
+ super(message);
26
+ this.name = 'AbortError';
27
+ this.code = 'ABORT';
28
+ }
29
+ }
30
+
31
+ class CliMissingError extends Error {
32
+ constructor() {
33
+ super('claude CLI not found in PATH — install Claude Code or use the anthropic provider');
34
+ this.name = 'ClaudeCliMissingError';
35
+ this.code = 'CLI_MISSING';
36
+ }
37
+ }
38
+
39
+ class CliExitError extends Error {
40
+ constructor(code, signal, stderr) {
41
+ super(`claude CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
42
+ this.name = 'ClaudeCliExitError';
43
+ this.code = 'CLI_EXIT';
44
+ this.exitCode = code;
45
+ this.signal = signal;
46
+ this.stderr = stderr;
47
+ }
48
+ }
49
+
50
+ // Map canonical Anthropic model ids and friendly aliases to the short
51
+ // form `claude --model` actually accepts. The Python dashboard ran into
52
+ // the same issue (FF1) — passing the full id silently hangs the CLI.
53
+ const _CLI_MODEL_ALIASES = {
54
+ 'claude-opus-4-7': 'opus',
55
+ 'claude-opus-4-6': 'opus',
56
+ 'claude-sonnet-4-6': 'sonnet',
57
+ 'claude-sonnet-4-5': 'sonnet',
58
+ 'claude-haiku-4-5': 'haiku',
59
+ 'claude-haiku-4-5-20251001': 'haiku',
60
+ opus: 'opus',
61
+ sonnet: 'sonnet',
62
+ haiku: 'haiku',
63
+ };
64
+
65
+ function resolveModelAlias(model) {
66
+ if (!model) return '';
67
+ const lower = String(model).toLowerCase();
68
+ return _CLI_MODEL_ALIASES[lower] ?? '';
69
+ }
70
+
71
+ // Flatten the chat-style messages array into a single -p prompt the
72
+ // CLI accepts. Mirrors how the dashboard formats Claude turns when it
73
+ // has no native multi-turn channel.
74
+ function buildPrompt(messages, system) {
75
+ const parts = [];
76
+ if (system) parts.push(`[System instructions: ${system}]`);
77
+ for (const m of messages) {
78
+ if (!m || !m.content) continue;
79
+ if (m.role === 'system' && !system) parts.push(`[System instructions: ${m.content}]`);
80
+ else if (m.role === 'user') parts.push(`User: ${m.content}`);
81
+ else if (m.role === 'assistant') parts.push(`Assistant: ${m.content}`);
82
+ }
83
+ // Trailing "Assistant:" cue so the CLI continues the conversation.
84
+ return parts.length ? parts.join('\n') + '\n\nAssistant:' : '';
85
+ }
86
+
87
+ // Walk the partial-message JSON stream and pull text deltas out. The
88
+ // `claude` CLI emits one JSON object per line; the shapes we care about:
89
+ // { type: 'stream_event', event: { type: 'content_block_delta',
90
+ // delta: { type: 'text_delta', text: '...' } } }
91
+ // { type: 'result', usage: {...}, total_cost_usd: ... }
92
+ function extractTextDelta(obj) {
93
+ if (!obj || typeof obj !== 'object') return '';
94
+ if (obj.type !== 'stream_event') return '';
95
+ const ev = obj.event || {};
96
+ if (ev.type === 'content_block_delta') {
97
+ const d = ev.delta || {};
98
+ if (d.type === 'text_delta' && typeof d.text === 'string') return d.text;
99
+ }
100
+ return '';
101
+ }
102
+
103
+ export const claudeCliProvider = {
104
+ name: 'claude-cli',
105
+ /**
106
+ * @param {Array<{role:string,content:string}>} messages
107
+ * @param {{
108
+ * model?: string,
109
+ * system?: string,
110
+ * signal?: AbortSignal,
111
+ * bin?: string, // override the resolved binary (tests)
112
+ * cwd?: string, // working dir for the subprocess
113
+ * onUsage?: (u: object) => void,
114
+ * }} opts
115
+ */
116
+ async *sendMessage(messages, opts = {}) {
117
+ const bin = opts.bin || 'claude';
118
+ const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
119
+ if (!prompt) return;
120
+
121
+ const args = [
122
+ '-p', prompt,
123
+ '--output-format', 'stream-json',
124
+ '--include-partial-messages',
125
+ '--verbose',
126
+ ];
127
+ const modelAlias = resolveModelAlias(opts.model);
128
+ if (modelAlias) args.push('--model', modelAlias);
129
+
130
+ if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
131
+
132
+ let proc;
133
+ try {
134
+ // opts.sandbox (parsed by parseSandboxSpec) routes the spawn
135
+ // through `docker run` instead of running `claude` on the
136
+ // host. spawnSandboxed is a no-op when sandbox is null, so
137
+ // the un-sandboxed path stays bit-identical to before.
138
+ proc = spawnSandboxed(opts.sandbox || null, bin, args, {
139
+ cwd: opts.cwd || process.cwd(),
140
+ stdio: ['ignore', 'pipe', 'pipe'],
141
+ });
142
+ } catch (err) {
143
+ // ENOENT means the binary isn't on PATH. Surface a clearer error
144
+ // than the raw spawn failure so onboard / doctor can hint at
145
+ // "install Claude Code or pick a different provider".
146
+ if (err && err.code === 'ENOENT') throw new CliMissingError();
147
+ throw err;
148
+ }
149
+
150
+ const onAbort = () => {
151
+ try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
152
+ };
153
+ if (opts.signal) opts.signal.addEventListener('abort', onAbort);
154
+
155
+ let stderr = '';
156
+ proc.stderr.setEncoding('utf8');
157
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
158
+
159
+ // The stdout protocol is newline-delimited JSON. We buffer partial
160
+ // lines across chunks (shapes can straddle a single read).
161
+ proc.stdout.setEncoding('utf8');
162
+ let buffer = '';
163
+ let exitInfo = null;
164
+ const exitPromise = new Promise((resolve) => {
165
+ proc.on('close', (code, signal) => {
166
+ exitInfo = { code, signal };
167
+ resolve();
168
+ });
169
+ });
170
+
171
+ try {
172
+ for await (const chunk of proc.stdout) {
173
+ if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
174
+ buffer += chunk;
175
+ let nl;
176
+ while ((nl = buffer.indexOf('\n')) >= 0) {
177
+ const line = buffer.slice(0, nl).trim();
178
+ buffer = buffer.slice(nl + 1);
179
+ if (!line) continue;
180
+ let obj;
181
+ try { obj = JSON.parse(line); } catch { continue; }
182
+ const text = extractTextDelta(obj);
183
+ if (text) yield text;
184
+ if (obj?.type === 'result') {
185
+ // Last event of a successful run carries usage + cost.
186
+ if (typeof opts.onUsage === 'function') {
187
+ try {
188
+ opts.onUsage({
189
+ inputTokens: obj.usage?.input_tokens || 0,
190
+ outputTokens: obj.usage?.output_tokens || 0,
191
+ totalCostUsd: obj.total_cost_usd || 0,
192
+ });
193
+ } catch (_) { /* never fail the stream on usage callback */ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ // Drain trailing buffered line.
199
+ if (buffer.trim()) {
200
+ try {
201
+ const obj = JSON.parse(buffer.trim());
202
+ const text = extractTextDelta(obj);
203
+ if (text) yield text;
204
+ } catch (_) { /* incomplete tail — drop */ }
205
+ }
206
+ await exitPromise;
207
+ if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
208
+ throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
209
+ }
210
+ } finally {
211
+ if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
212
+ // Make sure we don't leave a runaway subprocess if the consumer
213
+ // bailed mid-iteration without explicit abort.
214
+ if (!proc.killed && exitInfo === null) {
215
+ try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
216
+ }
217
+ }
218
+ },
219
+ };
220
+
221
+ export { CliMissingError, CliExitError, AbortError, resolveModelAlias, buildPrompt };
@@ -11,6 +11,7 @@ import { anthropicProvider } from './anthropic.mjs';
11
11
  import { openaiProvider } from './openai.mjs';
12
12
  import { ollamaProvider } from './ollama.mjs';
13
13
  import { geminiProvider } from './gemini.mjs';
14
+ import { claudeCliProvider } from './claude_cli.mjs';
14
15
 
15
16
  /**
16
17
  * @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
@@ -47,14 +48,17 @@ export const mockProvider = {
47
48
  },
48
49
  };
49
50
 
50
- export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider };
51
+ export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
51
52
 
52
53
  export const PROVIDERS = {
53
54
  mock: mockProvider,
55
+ // claude-cli (subscription-backed, no API key) listed before
56
+ // anthropic so first-time onboarding surfaces it as the default.
57
+ 'claude-cli': claudeCliProvider,
54
58
  anthropic: anthropicProvider,
55
59
  openai: openaiProvider,
56
- ollama: ollamaProvider,
57
60
  gemini: geminiProvider,
61
+ ollama: ollamaProvider,
58
62
  };
59
63
 
60
64
  // Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
@@ -67,14 +71,37 @@ export const PROVIDER_INFO = {
67
71
  defaultModel: null,
68
72
  suggestedModels: [],
69
73
  },
74
+ 'claude-cli': {
75
+ name: 'claude-cli',
76
+ requiresApiKey: false,
77
+ docs: 'Anthropic via the local `claude` CLI (Pro / Max subscription). No API key — auth flows through whatever account `claude` is logged in with. Requires Claude Code installed.',
78
+ endpoint: 'subprocess: claude -p',
79
+ defaultModel: 'claude-opus-4-7',
80
+ suggestedModels: [
81
+ 'claude-opus-4-7',
82
+ 'claude-sonnet-4-6',
83
+ 'claude-haiku-4-5',
84
+ 'opus',
85
+ 'sonnet',
86
+ 'haiku',
87
+ ],
88
+ },
70
89
  anthropic: {
71
90
  name: 'anthropic',
72
91
  requiresApiKey: true,
73
92
  keyPrefix: 'sk-ant-',
74
- docs: 'Anthropic Messages API. Supports streaming + extended thinking.',
93
+ docs: 'Anthropic Messages API (pay-per-token, requires sk-ant- key). Supports streaming + extended thinking. For subscription billing, use the `claude-cli` provider instead.',
75
94
  endpoint: 'https://api.anthropic.com/v1/messages',
76
95
  defaultModel: 'claude-opus-4-7',
77
- suggestedModels: ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
96
+ suggestedModels: [
97
+ 'claude-opus-4-7',
98
+ 'claude-opus-4-6',
99
+ 'claude-sonnet-4-6',
100
+ 'claude-sonnet-4-5',
101
+ 'claude-haiku-4-5',
102
+ 'claude-3-5-sonnet-20241022',
103
+ 'claude-3-5-haiku-20241022',
104
+ ],
78
105
  },
79
106
  openai: {
80
107
  name: 'openai',
@@ -83,23 +110,53 @@ export const PROVIDER_INFO = {
83
110
  docs: 'OpenAI Chat Completions API. Streaming via SSE with [DONE] terminator.',
84
111
  endpoint: 'https://api.openai.com/v1/chat/completions',
85
112
  defaultModel: 'gpt-4.1',
86
- suggestedModels: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
87
- },
88
- ollama: {
89
- name: 'ollama',
90
- requiresApiKey: false,
91
- docs: 'Local Ollama daemon. Streams newline-delimited JSON from /api/chat. No auth — defaults to 127.0.0.1:11434, override via OLLAMA_HOST or opts.baseUrl.',
92
- endpoint: 'http://127.0.0.1:11434/api/chat',
93
- defaultModel: 'llama3.1',
94
- suggestedModels: ['llama3.1', 'llama3.2', 'mistral', 'qwen2.5-coder'],
113
+ suggestedModels: [
114
+ 'gpt-5',
115
+ 'gpt-5-codex',
116
+ 'gpt-4.1',
117
+ 'gpt-4.1-mini',
118
+ 'gpt-4o',
119
+ 'gpt-4o-mini',
120
+ 'o3-pro',
121
+ 'o4-mini',
122
+ 'o1',
123
+ 'o1-mini',
124
+ ],
95
125
  },
96
126
  gemini: {
97
127
  name: 'gemini',
98
128
  requiresApiKey: true,
99
129
  docs: 'Google Generative Language API (Gemini). SSE streaming via :streamGenerateContent?alt=sse. Auth via ?key= query param.',
100
130
  endpoint: 'https://generativelanguage.googleapis.com/v1/models/{model}:streamGenerateContent',
101
- defaultModel: 'gemini-1.5-pro',
102
- suggestedModels: ['gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-2.0-flash'],
131
+ defaultModel: 'gemini-2.5-pro',
132
+ suggestedModels: [
133
+ 'gemini-2.5-pro',
134
+ 'gemini-2.5-flash',
135
+ 'gemini-2.0-flash',
136
+ 'gemini-2.0-flash-thinking-exp',
137
+ 'gemini-1.5-pro',
138
+ 'gemini-1.5-flash',
139
+ ],
140
+ },
141
+ ollama: {
142
+ name: 'ollama',
143
+ requiresApiKey: false,
144
+ docs: 'Local Ollama daemon. Streams newline-delimited JSON from /api/chat. No auth — defaults to 127.0.0.1:11434, override via OLLAMA_HOST or opts.baseUrl. Available models depend on what you have pulled locally (`ollama list`).',
145
+ endpoint: 'http://127.0.0.1:11434/api/chat',
146
+ defaultModel: 'llama3.1',
147
+ suggestedModels: [
148
+ 'llama3.1',
149
+ 'llama3.2',
150
+ 'llama3.3',
151
+ 'qwen2.5-coder',
152
+ 'qwen3.5',
153
+ 'mistral',
154
+ 'mistral-nemo',
155
+ 'codellama',
156
+ 'deepseek-coder-v2',
157
+ 'phi3',
158
+ 'gemma2',
159
+ ],
103
160
  },
104
161
  };
105
162
 
package/sandbox.mjs ADDED
@@ -0,0 +1,127 @@
1
+ // Sandbox — wrap a child process in a Docker container.
2
+ //
3
+ // `lazyclaw chat --sandbox docker:<image>` (or the equivalent on
4
+ // `agent`) routes the underlying `claude` CLI invocation through
5
+ //
6
+ // docker run --rm -i --network=<net> \
7
+ // -v <cwd>:<cwd> -w <cwd> \
8
+ // -e <pass-through env vars> \
9
+ // <image> claude -p ...
10
+ //
11
+ // instead of running `claude` directly on the host. Two reasons:
12
+ //
13
+ // 1. Filesystem confinement. The default --workdir mount only
14
+ // exposes the current working directory; tools that try to
15
+ // chdir into $HOME or read /etc see an empty container fs.
16
+ // 2. Network policy. By default we set --network=none so the
17
+ // sandboxed agent cannot reach the public internet — useful
18
+ // when handing it untrusted prompts. Pass `--network host` /
19
+ // `bridge` via flags when the workflow needs outbound access
20
+ // (e.g. it has to call an API).
21
+ //
22
+ // Caveats — call out so users aren't surprised:
23
+ //
24
+ // - The user's `claude` login lives in $HOME/.claude/. The sandbox
25
+ // doesn't expose $HOME by default, so the wrapped CLI can't see
26
+ // that auth and will prompt for login. To run sandboxed under
27
+ // the user's existing subscription, mount $HOME/.claude:
28
+ //
29
+ // lazyclaw chat --sandbox docker:node:20 \
30
+ // --sandbox-mount "$HOME/.claude:/root/.claude:ro"
31
+ //
32
+ // - Sandboxing only applies when the picked provider goes through
33
+ // a subprocess (currently `claude-cli`). API providers
34
+ // (anthropic / openai / gemini) hit the network from
35
+ // *lazyclaw's* process, not a child — sandboxing them is a
36
+ // no-op and we surface a warning.
37
+
38
+ import { spawn } from 'node:child_process';
39
+
40
+ class SandboxError extends Error {
41
+ constructor(message, code) {
42
+ super(message);
43
+ this.name = 'SandboxError';
44
+ this.code = code || 'SANDBOX_ERR';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Parse a `--sandbox` flag. Accepts:
50
+ * docker:<image> — Docker with default policy
51
+ * docker:<image>?<args> — query-string-style overrides
52
+ * off | none | - — explicit "no sandbox"
53
+ *
54
+ * Returns null when sandboxing is off, or
55
+ * { kind: 'docker', image, network, mounts: string[], envPassthrough: string[] }.
56
+ */
57
+ export function parseSandboxSpec(spec, flags = {}) {
58
+ if (!spec || /^(off|none|-)$/i.test(String(spec))) return null;
59
+ const m = String(spec).match(/^([a-z]+):(.+)$/i);
60
+ if (!m) throw new SandboxError(`bad sandbox spec "${spec}" — expected "docker:<image>"`, 'SANDBOX_BAD_SPEC');
61
+ const [, kind, rest] = m;
62
+ if (kind.toLowerCase() !== 'docker') {
63
+ throw new SandboxError(`unsupported sandbox kind "${kind}" — only "docker" is implemented`, 'SANDBOX_UNSUPPORTED');
64
+ }
65
+ return {
66
+ kind: 'docker',
67
+ image: rest.trim(),
68
+ // Default to --network=none for safety. Override via:
69
+ // --sandbox-network host (or bridge / a named network)
70
+ network: flags['sandbox-network'] || 'none',
71
+ // --sandbox-mount can repeat; cli.parseArgs collects repeats
72
+ // into an array.
73
+ mounts: arrayify(flags['sandbox-mount']),
74
+ envPassthrough: arrayify(flags['sandbox-env']),
75
+ };
76
+ }
77
+
78
+ function arrayify(v) {
79
+ if (v === undefined || v === null) return [];
80
+ return Array.isArray(v) ? v : [String(v)];
81
+ }
82
+
83
+ /**
84
+ * Build the docker run argv that wraps a child invocation. The
85
+ * caller hands us the original [bin, ...args] they were going to
86
+ * spawn; we return [docker, ...dockerArgs] that puts the same
87
+ * thing inside the container.
88
+ */
89
+ export function buildDockerArgs(spec, [bin, ...binArgs], opts = {}) {
90
+ if (!spec || spec.kind !== 'docker') {
91
+ throw new SandboxError('buildDockerArgs requires a docker spec', 'SANDBOX_BAD_SPEC');
92
+ }
93
+ const cwd = opts.cwd || process.cwd();
94
+ const args = [
95
+ 'run', '--rm', '-i',
96
+ '--network', spec.network || 'none',
97
+ '-v', `${cwd}:${cwd}`,
98
+ '-w', cwd,
99
+ ];
100
+ for (const mount of spec.mounts) {
101
+ if (!mount.includes(':')) {
102
+ throw new SandboxError(`bad mount "${mount}" — expected host:container[:mode]`, 'SANDBOX_BAD_MOUNT');
103
+ }
104
+ args.push('-v', mount);
105
+ }
106
+ for (const envName of spec.envPassthrough) {
107
+ args.push('-e', envName);
108
+ }
109
+ args.push(spec.image, bin, ...binArgs);
110
+ return args;
111
+ }
112
+
113
+ /**
114
+ * Spawn `bin` with `args` either bare (no sandbox) or under the
115
+ * docker wrapper. Returns the child process; the caller drives
116
+ * stdio and handles exit. Mirrors `child_process.spawn`'s shape.
117
+ */
118
+ export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
119
+ if (!spec) return spawn(bin, args, spawnOpts);
120
+ if (spec.kind !== 'docker') {
121
+ throw new SandboxError(`unsupported kind "${spec.kind}"`, 'SANDBOX_UNSUPPORTED');
122
+ }
123
+ const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
124
+ return spawn('docker', dockerArgs, spawnOpts);
125
+ }
126
+
127
+ export { SandboxError };