draco-cli 0.0.0-stage → 3.0.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.
@@ -0,0 +1,248 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const config = require('../config.js');
5
+ const ui = require('../ui/terminal.js');
6
+
7
+ const REQUEST_TIMEOUT_MS = 30000;
8
+ const MAX_TOOL_RESULT_CHARS = 32000;
9
+
10
+ class McpServer {
11
+ constructor(name, definition) {
12
+ this.name = name;
13
+ this.definition = definition;
14
+ this.proc = null;
15
+ this.nextId = 1;
16
+ this.pending = new Map();
17
+ this.buffer = '';
18
+ this.tools = [];
19
+ this.ready = false;
20
+ }
21
+
22
+ connect() {
23
+ const def = this.definition;
24
+ if (!def || typeof def.command !== 'string') {
25
+ throw new Error(`server "${this.name}" needs a "command" field`);
26
+ }
27
+ return new Promise((resolve, reject) => {
28
+ let settled = false;
29
+ try {
30
+ this.proc = spawn(def.command, Array.isArray(def.args) ? def.args : [], {
31
+ env: { ...process.env, ...(def.env || {}) },
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ });
34
+ } catch (e) {
35
+ return reject(e);
36
+ }
37
+ const failTimer = setTimeout(() => {
38
+ if (!settled) {
39
+ settled = true;
40
+ reject(new Error(`MCP server "${this.name}" did not respond in time`));
41
+ }
42
+ }, REQUEST_TIMEOUT_MS);
43
+
44
+ this.proc.stdout.on('data', (chunk) => this.onData(chunk));
45
+ this.proc.stderr.on('data', () => {});
46
+ this.proc.on('error', (e) => {
47
+ if (!settled) {
48
+ settled = true;
49
+ clearTimeout(failTimer);
50
+ reject(e);
51
+ }
52
+ });
53
+ this.proc.on('exit', () => {
54
+ for (const p of this.pending.values()) {
55
+ p.reject(new Error(`MCP server "${this.name}" exited`));
56
+ }
57
+ this.pending.clear();
58
+ this.ready = false;
59
+ });
60
+
61
+ this.request('initialize', {
62
+ protocolVersion: '2024-11-05',
63
+ capabilities: {},
64
+ clientInfo: { name: 'draco-cli', version: config.VERSION },
65
+ })
66
+ .then(async () => {
67
+ this.notify('notifications/initialized', {});
68
+ const res = await this.request('tools/list', {});
69
+ this.tools = Array.isArray(res.tools) ? res.tools : [];
70
+ this.ready = true;
71
+ settled = true;
72
+ clearTimeout(failTimer);
73
+ resolve(this.tools);
74
+ })
75
+ .catch((e) => {
76
+ if (!settled) {
77
+ settled = true;
78
+ clearTimeout(failTimer);
79
+ reject(e);
80
+ }
81
+ });
82
+ });
83
+ }
84
+
85
+ onData(chunk) {
86
+ this.buffer += chunk.toString('utf8');
87
+ let nl;
88
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
89
+ const line = this.buffer.slice(0, nl).trim();
90
+ this.buffer = this.buffer.slice(nl + 1);
91
+ if (!line) continue;
92
+ let msg;
93
+ try {
94
+ msg = JSON.parse(line);
95
+ } catch {
96
+ continue;
97
+ }
98
+ if (msg.id !== undefined && this.pending.has(msg.id)) {
99
+ const p = this.pending.get(msg.id);
100
+ this.pending.delete(msg.id);
101
+ if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
102
+ else p.resolve(msg.result);
103
+ }
104
+ }
105
+ }
106
+
107
+ send(obj) {
108
+ if (!this.proc || !this.proc.stdin.writable) throw new Error(`MCP server "${this.name}" is not running`);
109
+ this.proc.stdin.write(JSON.stringify(obj) + '\n');
110
+ }
111
+
112
+ request(method, params) {
113
+ const id = this.nextId++;
114
+ const payload = { jsonrpc: '2.0', id, method, params };
115
+ return new Promise((resolve, reject) => {
116
+ const timer = setTimeout(() => {
117
+ this.pending.delete(id);
118
+ reject(new Error(`MCP request "${method}" timed out`));
119
+ }, REQUEST_TIMEOUT_MS);
120
+ this.pending.set(id, {
121
+ resolve: (v) => {
122
+ clearTimeout(timer);
123
+ resolve(v);
124
+ },
125
+ reject: (e) => {
126
+ clearTimeout(timer);
127
+ reject(e);
128
+ },
129
+ });
130
+ try {
131
+ this.send(payload);
132
+ } catch (e) {
133
+ clearTimeout(timer);
134
+ this.pending.delete(id);
135
+ reject(e);
136
+ }
137
+ });
138
+ }
139
+
140
+ notify(method, params) {
141
+ try {
142
+ this.send({ jsonrpc: '2.0', method, params });
143
+ } catch {
144
+ // notifications are best-effort
145
+ }
146
+ }
147
+
148
+ async callTool(toolName, args) {
149
+ const result = await this.request('tools/call', { name: toolName, arguments: args || {} });
150
+ if (result && result.isError) {
151
+ const errText = extractText(result).slice(0, 500);
152
+ throw new Error(errText || 'tool reported an error');
153
+ }
154
+ return extractText(result);
155
+ }
156
+
157
+ stop() {
158
+ if (this.proc) {
159
+ try {
160
+ this.proc.kill();
161
+ } catch {}
162
+ this.proc = null;
163
+ this.ready = false;
164
+ }
165
+ }
166
+ }
167
+
168
+ function extractText(result) {
169
+ if (!result) return '';
170
+ const content = Array.isArray(result.content) ? result.content : [];
171
+ const parts = content
172
+ .filter((item) => item && item.type === 'text' && typeof item.text === 'string')
173
+ .map((item) => item.text);
174
+ const text = parts.join('\n').trim();
175
+ return truncate(text);
176
+ }
177
+
178
+ function truncate(text, max = MAX_TOOL_RESULT_CHARS) {
179
+ if (text.length <= max) return text;
180
+ return text.slice(0, max) + `\n[...truncated ${text.length - max} chars...]`;
181
+ }
182
+
183
+ class McpManager {
184
+ constructor(serverDefs) {
185
+ this.servers = [];
186
+ for (const [name, definition] of Object.entries(serverDefs || {})) {
187
+ this.servers.push(new McpServer(name, definition));
188
+ }
189
+ }
190
+
191
+ async startAll() {
192
+ const summaries = [];
193
+ for (const server of this.servers) {
194
+ try {
195
+ const tools = await server.connect();
196
+ summaries.push(`${server.name}: ${tools.length} tool(s)`);
197
+ } catch (e) {
198
+ ui.error(`MCP server "${server.name}" failed to start (${e.message}). Continuing without it.`);
199
+ }
200
+ }
201
+ return summaries;
202
+ }
203
+
204
+ listTools() {
205
+ const out = [];
206
+ for (const server of this.servers) {
207
+ if (!server.ready) continue;
208
+ for (const tool of server.tools) {
209
+ out.push({
210
+ qualifiedName: `mcp_${sanitize(server.name)}_${sanitize(tool.name)}`,
211
+ serverName: server.name,
212
+ toolName: tool.name,
213
+ description: tool.description || '',
214
+ inputSchema: tool.inputSchema || { type: 'object', properties: {} },
215
+ });
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ async call(qualifiedName, args) {
222
+ const tool = this.listTools().find((t) => t.qualifiedName === qualifiedName);
223
+ if (!tool) throw new Error(`Unknown MCP tool "${qualifiedName}".`);
224
+ const server = this.servers.find((s) => s.name === tool.serverName && s.ready);
225
+ if (!server) throw new Error(`MCP server "${tool.serverName}" is not running.`);
226
+ return server.callTool(tool.toolName, args);
227
+ }
228
+
229
+ stopAll() {
230
+ for (const server of this.servers) server.stop();
231
+ }
232
+ }
233
+
234
+ function sanitize(name) {
235
+ return String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 24);
236
+ }
237
+
238
+ async function createFromConfig() {
239
+ const defs = config.loadMcpConfig();
240
+ const manager = new McpManager(defs);
241
+ if (Object.keys(defs).length > 0) {
242
+ const summaries = await manager.startAll();
243
+ if (summaries.length) ui.info(`MCP servers: ${summaries.join(', ')}`);
244
+ }
245
+ return manager;
246
+ }
247
+
248
+ module.exports = { McpManager, McpServer, createFromConfig };
@@ -0,0 +1,288 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ CHAT_TIMEOUT_MS,
5
+ MODELS_TIMEOUT_MS,
6
+ } = require('../config.js');
7
+
8
+ const DEFAULT_PROBE_MODEL = 'nemotron-3-ultra-free';
9
+
10
+ class ApiError extends Error {
11
+ constructor(status, message, bodyText) {
12
+ super(message);
13
+ this.name = 'ApiError';
14
+ this.status = status || 0;
15
+ this.bodyText = bodyText || '';
16
+ }
17
+ }
18
+
19
+ function extractEmbeddedStatus(message) {
20
+ const m = /\[(\d{3})\]/.exec(String(message));
21
+ return m ? Number(m[1]) : undefined;
22
+ }
23
+
24
+ function isFallbackable(err) {
25
+ if (!(err instanceof ApiError)) return false;
26
+ if (err.status === 429) return true;
27
+ if ([400, 502, 503, 504, 529].includes(err.status)) {
28
+ return /unavailable|not supported|ModelError|overloaded|upstream|temporarily/i.test(
29
+ err.message + ' ' + err.bodyText
30
+ );
31
+ }
32
+ if (/overloaded|upstream error|temporarily unavailable/i.test(err.message + ' ' + err.bodyText)) {
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ async function safeBodyText(res, maxChars = 2000) {
39
+ try {
40
+ const text = await res.text();
41
+ return text.slice(0, maxChars);
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ function extractApiMessage(status, bodyText) {
48
+ try {
49
+ const json = JSON.parse(bodyText);
50
+ const msg =
51
+ (json.error && (json.error.message || json.error.code)) ||
52
+ json.message ||
53
+ '';
54
+ if (msg) return `${msg}`;
55
+ } catch {
56
+ // fall through to raw body
57
+ }
58
+ return bodyText ? bodyText.slice(0, 200) : `HTTP ${status}`;
59
+ }
60
+
61
+ async function listModels(cfg) {
62
+ const controller = new AbortController();
63
+ const timer = setTimeout(() => controller.abort(), MODELS_TIMEOUT_MS);
64
+ try {
65
+ let res;
66
+ try {
67
+ res = await fetch(`${cfg.apiBase}/zen/v1/models`, {
68
+ signal: controller.signal,
69
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
70
+ });
71
+ } catch (e) {
72
+ if (e.name === 'AbortError') {
73
+ throw new ApiError(0, 'Request timed out — connection may have dropped.');
74
+ }
75
+ throw new ApiError(0, `Network error: ${e.message}`);
76
+ }
77
+ if (!res.ok) {
78
+ const body = await safeBodyText(res);
79
+ throw new ApiError(res.status, extractApiMessage(res.status, body), body);
80
+ }
81
+ const text = await res.text();
82
+ let json;
83
+ try {
84
+ json = JSON.parse(text);
85
+ } catch {
86
+ throw new ApiError(res.status, 'Malformed JSON in models registry response.');
87
+ }
88
+ const rawList = Array.isArray(json.data)
89
+ ? json.data
90
+ : Array.isArray(json.models)
91
+ ? json.models
92
+ : Array.isArray(json)
93
+ ? json
94
+ : [];
95
+ const ids = rawList
96
+ .map((m) => (typeof m === 'string' ? m : m && (m.id || m.name)))
97
+ .filter((id) => typeof id === 'string' && id.length > 0);
98
+ if (!ids.length) {
99
+ throw new ApiError(res.status, 'Models registry returned no usable ids.');
100
+ }
101
+ return ids;
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+
107
+ class AbortedError extends Error {
108
+ constructor() {
109
+ super('Stopped by user.');
110
+ this.name = 'AbortedError';
111
+ this.userAbort = true;
112
+ }
113
+ }
114
+
115
+ async function streamChatCompletion(opts) {
116
+ const { cfg, model, messages, tools, onDelta, signal } = opts;
117
+ const controller = new AbortController();
118
+ const timer = setTimeout(() => controller.abort(), CHAT_TIMEOUT_MS);
119
+ let externalAborted = false;
120
+ const onExternalAbort = () => {
121
+ externalAborted = true;
122
+ controller.abort();
123
+ };
124
+ if (signal) {
125
+ if (signal.aborted) onExternalAbort();
126
+ else signal.addEventListener('abort', onExternalAbort);
127
+ }
128
+ const cleanup = () => {
129
+ clearTimeout(timer);
130
+ if (signal) signal.removeEventListener('abort', onExternalAbort);
131
+ };
132
+
133
+ let res;
134
+ try {
135
+ res = await fetch(`${cfg.apiBase}/zen/v1/chat/completions`, {
136
+ method: 'POST',
137
+ signal: controller.signal,
138
+ headers: {
139
+ Authorization: `Bearer ${cfg.apiKey}`,
140
+ 'Content-Type': 'application/json',
141
+ },
142
+ body: JSON.stringify({ model, messages, tools, stream: true }),
143
+ });
144
+ } catch (e) {
145
+ cleanup();
146
+ if (externalAborted) throw new AbortedError();
147
+ if (e.name === 'AbortError') {
148
+ throw new ApiError(0, 'Request timed out — connection may have dropped.');
149
+ }
150
+ throw new ApiError(0, `Network error: ${e.message}`);
151
+ }
152
+
153
+ if (!res.ok) {
154
+ cleanup();
155
+ const body = await safeBodyText(res);
156
+ throw new ApiError(res.status, extractApiMessage(res.status, body), body);
157
+ }
158
+ if (!res.body) {
159
+ cleanup();
160
+ throw new ApiError(res.status || 0, 'Server returned an empty response body.');
161
+ }
162
+
163
+ const decoder = new TextDecoder();
164
+ let buffer = '';
165
+ let finishReason = null;
166
+ const parts = new Map();
167
+ let done = false;
168
+
169
+ try {
170
+ outer: for await (const chunk of res.body) {
171
+ buffer += decoder.decode(chunk, { stream: true });
172
+ let nl;
173
+ while ((nl = buffer.indexOf('\n')) >= 0) {
174
+ const line = buffer.slice(0, nl).trim();
175
+ buffer = buffer.slice(nl + 1);
176
+ if (!line || line.startsWith(':')) continue;
177
+ if (!line.startsWith('data:')) continue;
178
+ const payload = line.slice(5).trim();
179
+ if (payload === '[DONE]') {
180
+ done = true;
181
+ break outer;
182
+ }
183
+ let evt;
184
+ try {
185
+ evt = JSON.parse(payload);
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (evt.error) {
190
+ const msg =
191
+ (evt.error.message || evt.error.code || JSON.stringify(evt.error)).toString();
192
+ const status =
193
+ evt.error.status ||
194
+ extractEmbeddedStatus(msg) ||
195
+ evt.status ||
196
+ res.status;
197
+ throw new ApiError(status, msg);
198
+ }
199
+ const choice = evt.choices && evt.choices[0];
200
+ if (!choice) continue;
201
+ if (choice.finish_reason) finishReason = choice.finish_reason;
202
+ const delta = choice.delta || {};
203
+ if (typeof delta.content === 'string' && delta.content.length > 0 && onDelta) {
204
+ onDelta(delta.content);
205
+ }
206
+ if (Array.isArray(delta.tool_calls)) {
207
+ for (const frag of delta.tool_calls) {
208
+ const idx = typeof frag.index === 'number' ? frag.index : 0;
209
+ let part = parts.get(idx);
210
+ if (!part) {
211
+ part = { id: '', name: '', arguments: '' };
212
+ parts.set(idx, part);
213
+ }
214
+ if (frag.id) part.id = frag.id;
215
+ if (frag.function) {
216
+ if (frag.function.name) part.name += frag.function.name;
217
+ if (frag.function.arguments) part.arguments += frag.function.arguments;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ } catch (e) {
224
+ cleanup();
225
+ if (externalAborted) throw new AbortedError();
226
+ if (done) {
227
+ // stream already completed; ignore trailing read errors
228
+ } else if (e instanceof ApiError) {
229
+ throw e;
230
+ } else if (e.name === 'AbortError') {
231
+ throw new ApiError(res.status, 'Stream timed out — connection may have dropped.');
232
+ } else {
233
+ throw new ApiError(res.status, `Connection dropped mid-stream: ${e.message}`);
234
+ }
235
+ } finally {
236
+ clearTimeout(timer);
237
+ }
238
+
239
+ const toolCalls = [...parts.entries()]
240
+ .sort((a, b) => a[0] - b[0])
241
+ .map(([i, p], n) => ({
242
+ id: p.id || `call_${n}`,
243
+ type: 'function',
244
+ function: { name: p.name, arguments: p.arguments || '{}' },
245
+ }));
246
+
247
+ return { toolCalls, finishReason };
248
+ }
249
+
250
+ async function probeApiKey(cfg, apiKey) {
251
+ const controller = new AbortController();
252
+ const timer = setTimeout(() => controller.abort(), MODELS_TIMEOUT_MS);
253
+ try {
254
+ let res;
255
+ try {
256
+ res = await fetch(`${cfg.apiBase}/zen/v1/chat/completions`, {
257
+ method: 'POST',
258
+ signal: controller.signal,
259
+ headers: {
260
+ Authorization: `Bearer ${apiKey}`,
261
+ 'Content-Type': 'application/json',
262
+ },
263
+ body: JSON.stringify({
264
+ model: DEFAULT_PROBE_MODEL,
265
+ messages: [{ role: 'user', content: 'ping' }],
266
+ max_tokens: 1,
267
+ }),
268
+ });
269
+ } catch (e) {
270
+ if (e.name === 'AbortError') {
271
+ return { verdict: 'unreachable', detail: 'request timed out' };
272
+ }
273
+ return { verdict: 'unreachable', detail: e.message };
274
+ }
275
+ if (res.ok || res.status === 429 || res.status === 400) {
276
+ // 429 = rate-limited but authenticated; 400 ModelError = auth fine, model busy
277
+ return { verdict: 'ok', status: res.status };
278
+ }
279
+ if (res.status === 401 || res.status === 403) {
280
+ return { verdict: 'invalid', status: res.status };
281
+ }
282
+ return { verdict: 'unknown', status: res.status };
283
+ } finally {
284
+ clearTimeout(timer);
285
+ }
286
+ }
287
+
288
+ module.exports = { ApiError, AbortedError, isFallbackable, listModels, streamChatCompletion, probeApiKey };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { READ_FILE_MAX_BYTES } = require('../config.js');
6
+ const { sandboxPath } = require('./sandbox.js');
7
+ const { approvalMode, gate } = require('../core/approvals.js');
8
+ const { diffLines, renderDiff } = require('../ui/diff.js');
9
+
10
+ function readFile(workspaceRoot, p) {
11
+ const abs = sandboxPath(workspaceRoot, p);
12
+ let stat;
13
+ try {
14
+ stat = fs.statSync(abs);
15
+ } catch (e) {
16
+ throw new Error(`Cannot stat "${p}": ${e.message}`);
17
+ }
18
+ if (stat.isDirectory()) {
19
+ throw new Error(`"${p}" is a directory, not a file.`);
20
+ }
21
+ if (stat.size > READ_FILE_MAX_BYTES) {
22
+ const fd = fs.openSync(abs, 'r');
23
+ const buf = Buffer.alloc(READ_FILE_MAX_BYTES);
24
+ fs.readSync(fd, buf, 0, READ_FILE_MAX_BYTES, 0);
25
+ fs.closeSync(fd);
26
+ return (
27
+ buf.toString('utf8') +
28
+ `\n\n[File truncated: showing the first ${Math.round(
29
+ READ_FILE_MAX_BYTES / 1024
30
+ )} KB of ${(stat.size / 1024).toFixed(1)} KB total.]`
31
+ );
32
+ }
33
+ return fs.readFileSync(abs, 'utf8');
34
+ }
35
+
36
+ function writeFileRaw(workspaceRoot, p, content) {
37
+ if (typeof content !== 'string') {
38
+ throw new Error('"content" must be a string.');
39
+ }
40
+ const abs = sandboxPath(workspaceRoot, p);
41
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
42
+ fs.writeFileSync(abs, content, 'utf8');
43
+ return `Wrote ${Buffer.byteLength(content, 'utf8')} bytes to ${abs}`;
44
+ }
45
+
46
+ async function writeFile(workspaceRoot, p, content, ctx) {
47
+ if (typeof content !== 'string') {
48
+ throw new Error('"content" must be a string.');
49
+ }
50
+ const abs = sandboxPath(workspaceRoot, p);
51
+ const exists = fs.existsSync(abs) && fs.statSync(abs).isFile();
52
+ if (exists && approvalMode(ctx) !== 'suggest' && process.stdout.isTTY) {
53
+ const rows = diffLines(fs.readFileSync(abs, 'utf8'), content);
54
+ process.stdout.write(renderDiff(rows) + '\n');
55
+ }
56
+ const allowed = await gate(ctx, `Allow ${exists ? 'edit of' : 'creation of'} "${p}" ? [y/N] `, [
57
+ 'auto-edit',
58
+ 'full-auto',
59
+ ]);
60
+ if (!allowed) {
61
+ return 'User declined this file write. Do not retry it; ask how they want to proceed.';
62
+ }
63
+ return writeFileRaw(workspaceRoot, p, content);
64
+ }
65
+
66
+ async function makeDirectory(workspaceRoot, p, ctx) {
67
+ const allowed = await gate(ctx, `Allow creating directory "${p}" ? [y/N] `, [
68
+ 'auto-edit',
69
+ 'full-auto',
70
+ ]);
71
+ if (!allowed) {
72
+ return 'User declined this directory creation. Do not retry it; ask how they want to proceed.';
73
+ }
74
+ const abs = sandboxPath(workspaceRoot, p);
75
+ fs.mkdirSync(abs, { recursive: true });
76
+ return `Created directory ${abs}`;
77
+ }
78
+
79
+ module.exports = { readFile, writeFile, makeDirectory };