lazyclaw 3.88.0 → 3.99.3
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 +26 -3
- package/cli.mjs +830 -38
- package/package.json +1 -1
- package/providers/claude_cli.mjs +221 -0
- package/providers/registry.mjs +72 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.99.3",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -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 };
|
package/providers/registry.mjs
CHANGED
|
@@ -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: [
|
|
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: [
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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-
|
|
102
|
-
suggestedModels: [
|
|
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
|
|