draco-cli 0.0.0-stage → 3.0.2
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/LICENSE +21 -0
- package/README.md +76 -3
- package/bin/draco.js +6 -0
- package/package.json +32 -6
- package/src/cli/args.js +28 -0
- package/src/cli/command-registry.js +130 -0
- package/src/cli/line-queue.js +35 -0
- package/src/cli/oneshot.js +17 -0
- package/src/cli/repl.js +46 -0
- package/src/cli/slash-commands.js +271 -0
- package/src/config.js +216 -0
- package/src/core/approvals.js +20 -0
- package/src/core/compaction.js +53 -0
- package/src/core/config-manager.js +174 -0
- package/src/core/fallback.js +11 -0
- package/src/core/images.js +40 -0
- package/src/core/logger.js +207 -0
- package/src/core/middleware.js +102 -0
- package/src/core/plugin-manager.js +149 -0
- package/src/core/prompt.js +45 -0
- package/src/core/session-history.js +161 -0
- package/src/core/session.js +307 -0
- package/src/core/sessions-store.js +56 -0
- package/src/index.js +255 -0
- package/src/integrations/mcp-manager.js +248 -0
- package/src/providers/openai-compatible.js +288 -0
- package/src/tools/fs-tools.js +79 -0
- package/src/tools/index.js +152 -0
- package/src/tools/sandbox.js +21 -0
- package/src/tools/shell-tool.js +43 -0
- package/src/tools/web-tools.js +322 -0
- package/src/ui/diff.js +53 -0
- package/src/ui/markdown.js +106 -0
- package/src/ui/terminal.js +114 -0
- package/src/ui/theme.js +167 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AbortedError, isFallbackable, streamChatCompletion } = require('../providers/openai-compatible.js');
|
|
4
|
+
const { buildToolDefs, executeTool } = require('../tools/index.js');
|
|
5
|
+
const { MarkdownStream } = require('../ui/markdown.js');
|
|
6
|
+
const { buildSystemPrompt, loadAgentsMd } = require('./prompt.js');
|
|
7
|
+
const { nextFallback } = require('./fallback.js');
|
|
8
|
+
const { extractImages } = require('./images.js');
|
|
9
|
+
const { compactConversation } = require('./compaction.js');
|
|
10
|
+
const { saveSession } = require('./sessions-store.js');
|
|
11
|
+
const ui = require('../ui/terminal.js');
|
|
12
|
+
|
|
13
|
+
const MAX_TOOL_ROUNDS = 12;
|
|
14
|
+
const MAX_TOOL_RESULT_CHARS = 16000;
|
|
15
|
+
|
|
16
|
+
function truncate(text, max) {
|
|
17
|
+
if (text.length <= max) return text;
|
|
18
|
+
return text.slice(0, max) + `\n[...truncated ${text.length - max} chars...]`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildUserMessage(workspaceRoot, text) {
|
|
22
|
+
const images = extractImages(workspaceRoot, text);
|
|
23
|
+
if (images.length === 0) return { role: 'user', content: text };
|
|
24
|
+
return { role: 'user', content: [{ type: 'text', text }, ...images] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createSession(cfg, ask, options) {
|
|
28
|
+
const opts = options || {};
|
|
29
|
+
const mcp = opts.mcp || null;
|
|
30
|
+
const agentsMd = loadAgentsMd(cfg.workspaceRoot);
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
cfg,
|
|
34
|
+
ask,
|
|
35
|
+
activeModel: cfg.model,
|
|
36
|
+
lastUserText: null,
|
|
37
|
+
messages: [],
|
|
38
|
+
activeController: null,
|
|
39
|
+
usage: { turns: 0, outputChars: 0 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function abortActive() {
|
|
43
|
+
if (state.activeController) {
|
|
44
|
+
state.activeController.abort();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reset() {
|
|
51
|
+
state.messages = [{ role: 'system', content: buildSystemPrompt(cfg, agentsMd) }];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolContext() {
|
|
55
|
+
return {
|
|
56
|
+
workspaceRoot: cfg.workspaceRoot,
|
|
57
|
+
yolo: cfg.yolo,
|
|
58
|
+
approvals: cfg.approvals,
|
|
59
|
+
ask: state.ask,
|
|
60
|
+
cfg,
|
|
61
|
+
mcp,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runTurn(userText) {
|
|
66
|
+
state.lastUserText = userText;
|
|
67
|
+
state.messages.push(buildUserMessage(cfg.workspaceRoot, userText));
|
|
68
|
+
await completeTurn();
|
|
69
|
+
state.usage.turns += 1;
|
|
70
|
+
persist();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function retry() {
|
|
74
|
+
if (!state.lastUserText) {
|
|
75
|
+
ui.warn('Nothing to retry yet.');
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const last = state.messages[state.messages.length - 1];
|
|
79
|
+
if (!last || last.role !== 'user') {
|
|
80
|
+
state.messages.push(buildUserMessage(cfg.workspaceRoot, state.lastUserText));
|
|
81
|
+
}
|
|
82
|
+
ui.info(`Retrying: "${truncate(String(state.lastUserText), 60)}"`);
|
|
83
|
+
await completeTurn();
|
|
84
|
+
persist();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function compact() {
|
|
89
|
+
const conversation = state.messages.filter((m) => m.role !== 'system');
|
|
90
|
+
if (conversation.length < 3) {
|
|
91
|
+
ui.warn('Conversation is too short to compact.');
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
let summary;
|
|
95
|
+
try {
|
|
96
|
+
summary = await compactConversation({
|
|
97
|
+
cfg,
|
|
98
|
+
model: state.activeModel,
|
|
99
|
+
messages: conversation,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
ui.error(`Compaction failed: ${err.message || err}`);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (!summary) {
|
|
106
|
+
ui.error('Compaction produced an empty summary; conversation left unchanged.');
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
state.messages = [
|
|
110
|
+
{ role: 'system', content: buildSystemPrompt(cfg, agentsMd) },
|
|
111
|
+
{
|
|
112
|
+
role: 'user',
|
|
113
|
+
content: `[Earlier conversation was summarized to stay within context limits. Summary:]\n${summary}\n\n[End of summary. Continue helping from here.]`,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
persist();
|
|
117
|
+
ui.success(`Conversation compacted to ~${Math.round(summary.length / 4)} tokens of summary.`);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function persist() {
|
|
122
|
+
saveSession(cfg.workspaceRoot, exportSession());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function exportSession() {
|
|
126
|
+
return {
|
|
127
|
+
version: 2,
|
|
128
|
+
cwd: cfg.workspaceRoot,
|
|
129
|
+
model: state.activeModel,
|
|
130
|
+
updatedAt: new Date().toISOString(),
|
|
131
|
+
lastUserText: state.lastUserText,
|
|
132
|
+
messages: state.messages,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function importSession(data) {
|
|
137
|
+
if (
|
|
138
|
+
!data ||
|
|
139
|
+
!Array.isArray(data.messages) ||
|
|
140
|
+
data.messages.length === 0 ||
|
|
141
|
+
data.messages[0].role !== 'system'
|
|
142
|
+
) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
state.messages = data.messages;
|
|
146
|
+
if (typeof data.model === 'string' && data.model) {
|
|
147
|
+
state.activeModel = data.model;
|
|
148
|
+
cfg.model = data.model;
|
|
149
|
+
}
|
|
150
|
+
state.lastUserText = typeof data.lastUserText === 'string' ? data.lastUserText : null;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getUsage() {
|
|
155
|
+
return {
|
|
156
|
+
...state.usage,
|
|
157
|
+
messages: state.messages.filter((m) => m.role !== 'system').length,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function completeTurn() {
|
|
162
|
+
const md = new MarkdownStream();
|
|
163
|
+
for (let round = 1; ; round++) {
|
|
164
|
+
if (round > MAX_TOOL_ROUNDS) {
|
|
165
|
+
ui.error(`Stopped after ${MAX_TOOL_ROUNDS} tool rounds in a single turn.`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const model = state.activeModel;
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
state.activeController = controller;
|
|
171
|
+
const spinner = new ui.Spinner(`Thinking (${model})…`);
|
|
172
|
+
spinner.start();
|
|
173
|
+
let acc = '';
|
|
174
|
+
let emitted = false;
|
|
175
|
+
let res;
|
|
176
|
+
try {
|
|
177
|
+
res = await streamChatCompletion({
|
|
178
|
+
cfg,
|
|
179
|
+
model,
|
|
180
|
+
messages: state.messages,
|
|
181
|
+
tools: buildToolDefs(mcp ? mcp.listTools() : []),
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
onDelta(t) {
|
|
184
|
+
acc += t;
|
|
185
|
+
if (!emitted) {
|
|
186
|
+
emitted = true;
|
|
187
|
+
spinner.stop();
|
|
188
|
+
}
|
|
189
|
+
md.write(t);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
state.activeController = null;
|
|
194
|
+
spinner.stop();
|
|
195
|
+
if (err instanceof AbortedError || controller.signal.aborted) {
|
|
196
|
+
md.end();
|
|
197
|
+
if (emitted) process.stdout.write('\n');
|
|
198
|
+
ui.warn('⏹ Response stopped. Type /retry to resend your last message.');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (isFallbackable(err) && !emitted) {
|
|
202
|
+
const nextModel = nextFallback(cfg.fallbackChain, model);
|
|
203
|
+
if (nextModel) {
|
|
204
|
+
ui.warn(
|
|
205
|
+
`Switching to backup model: ${nextModel} — ${model} hit ${
|
|
206
|
+
err.status === 429 ? 'a rate limit' : 'an availability error'
|
|
207
|
+
}.`
|
|
208
|
+
);
|
|
209
|
+
state.activeModel = nextModel;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
ui.error(
|
|
213
|
+
`All models in the fallback chain failed. Use /models to pick another manually.`
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (emitted) {
|
|
218
|
+
md.end();
|
|
219
|
+
process.stdout.write('\n');
|
|
220
|
+
ui.error('Connection dropped mid-stream. Type /retry to resend your last message.');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (err instanceof AbortedError) {
|
|
224
|
+
ui.warn(err.message);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
228
|
+
ui.error('Request timed out — connection may have dropped.');
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
ui.error(`${err.status ? `API error HTTP ${err.status}: ` : ''}${err.message || err}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (emitted) {
|
|
235
|
+
md.end();
|
|
236
|
+
process.stdout.write('\n');
|
|
237
|
+
}
|
|
238
|
+
state.usage.outputChars += acc.length;
|
|
239
|
+
|
|
240
|
+
const toolCalls = res.toolCalls || [];
|
|
241
|
+
if (toolCalls.length > 0) {
|
|
242
|
+
state.messages.push({
|
|
243
|
+
role: 'assistant',
|
|
244
|
+
content: acc || null,
|
|
245
|
+
tool_calls: toolCalls.map((tc) => ({
|
|
246
|
+
id: tc.id,
|
|
247
|
+
type: 'function',
|
|
248
|
+
function: { name: tc.function.name, arguments: tc.function.arguments },
|
|
249
|
+
})),
|
|
250
|
+
});
|
|
251
|
+
for (const tc of toolCalls) {
|
|
252
|
+
if (controller.signal.aborted) {
|
|
253
|
+
state.activeController = null;
|
|
254
|
+
ui.warn('⏹ Response stopped. Type /retry to resend your last message.');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let args = {};
|
|
258
|
+
try {
|
|
259
|
+
args = JSON.parse(tc.function.arguments || '{}');
|
|
260
|
+
} catch {
|
|
261
|
+
args = { _raw: tc.function.arguments };
|
|
262
|
+
}
|
|
263
|
+
ui.toolStart(tc.function.name, args);
|
|
264
|
+
const result = await executeTool(tc.function.name, args, toolContext());
|
|
265
|
+
state.messages.push({
|
|
266
|
+
role: 'tool',
|
|
267
|
+
tool_call_id: tc.id,
|
|
268
|
+
content: truncate(String(result), MAX_TOOL_RESULT_CHARS),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
state.activeController = null;
|
|
275
|
+
state.messages.push({ role: 'assistant', content: acc });
|
|
276
|
+
if (!acc && res.finishReason !== 'stop') {
|
|
277
|
+
ui.error('(empty response from model)');
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function setModel(id) {
|
|
284
|
+
state.activeModel = id;
|
|
285
|
+
cfg.model = id;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
reset();
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
get activeModel() {
|
|
292
|
+
return state.activeModel;
|
|
293
|
+
},
|
|
294
|
+
setModel,
|
|
295
|
+
reset,
|
|
296
|
+
retry,
|
|
297
|
+
compact,
|
|
298
|
+
runTurn,
|
|
299
|
+
abortActive,
|
|
300
|
+
exportSession,
|
|
301
|
+
importSession,
|
|
302
|
+
clearSavedSession: () => require('./sessions-store.js').clearSavedSession(cfg.workspaceRoot),
|
|
303
|
+
getUsage,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = { createSession };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const config = require('../config.js');
|
|
6
|
+
|
|
7
|
+
function saveSession(workspaceRoot, data) {
|
|
8
|
+
try {
|
|
9
|
+
const file = config.sessionFileFor(workspaceRoot);
|
|
10
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
11
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
12
|
+
return file;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadLatestSession(workspaceRoot) {
|
|
19
|
+
try {
|
|
20
|
+
const file = config.sessionFileFor(workspaceRoot);
|
|
21
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clearSavedSession(workspaceRoot) {
|
|
28
|
+
try {
|
|
29
|
+
fs.unlinkSync(config.sessionFileFor(workspaceRoot));
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function listSessions() {
|
|
34
|
+
const dir = config.sessionsDir();
|
|
35
|
+
let files = [];
|
|
36
|
+
try {
|
|
37
|
+
files = fs
|
|
38
|
+
.readdirSync(dir)
|
|
39
|
+
.filter((f) => f.endsWith('.json'))
|
|
40
|
+
.map((f) => path.join(dir, f));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const sessions = [];
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
48
|
+
if (!data || !Array.isArray(data.messages)) continue;
|
|
49
|
+
sessions.push({ file, data });
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
sessions.sort((a, b) => String(b.data.updatedAt).localeCompare(String(a.data.updatedAt)));
|
|
53
|
+
return sessions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { saveSession, loadLatestSession, clearSavedSession, listSessions };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
const config = require('./config.js');
|
|
6
|
+
const ui = require('./ui/terminal.js');
|
|
7
|
+
const mcpClient = require('./integrations/mcp-manager.js');
|
|
8
|
+
const { createSession } = require('./core/session.js');
|
|
9
|
+
const { loadLatestSession } = require('./core/sessions-store.js');
|
|
10
|
+
const { parseArgs } = require('./cli/args.js');
|
|
11
|
+
const { createLineQueue } = require('./cli/line-queue.js');
|
|
12
|
+
const { runRepl } = require('./cli/repl.js');
|
|
13
|
+
const { runOneShot } = require('./cli/oneshot.js');
|
|
14
|
+
|
|
15
|
+
function printUsage() {
|
|
16
|
+
process.stdout.write(
|
|
17
|
+
[
|
|
18
|
+
`Draco CLI v${config.VERSION}`,
|
|
19
|
+
'',
|
|
20
|
+
'Usage: draco [prompt] [options]',
|
|
21
|
+
'',
|
|
22
|
+
'Options:',
|
|
23
|
+
' --model=<id> Start with a specific model id',
|
|
24
|
+
' --suggest Ask before every file write and shell command',
|
|
25
|
+
' --auto-edit Auto-apply file edits, still ask before shell commands',
|
|
26
|
+
' --full-auto Run everything without asking (same as --yolo)',
|
|
27
|
+
' --yolo Alias of --full-auto',
|
|
28
|
+
' --continue, -c Resume the last session for this directory',
|
|
29
|
+
' --version, -v Print version and exit',
|
|
30
|
+
' --help, -h Show this help',
|
|
31
|
+
' --check-update Check for available updates',
|
|
32
|
+
'',
|
|
33
|
+
'One-shot mode: draco "your prompt here" — answer once, then exit',
|
|
34
|
+
'',
|
|
35
|
+
'Environment:',
|
|
36
|
+
' OPENCODE_ZEN_KEY Optional. Skips the first-run key prompt.',
|
|
37
|
+
' DRACO_MODEL Default model override (default: nemotron-3-ultra-free)',
|
|
38
|
+
' DRACO_APPROVALS suggest | auto-edit | full-auto',
|
|
39
|
+
' DRACO_YOLO "true" to auto-approve shell commands',
|
|
40
|
+
' DRACO_API_BASE Override the API endpoint',
|
|
41
|
+
' DRACO_SEARCH_PROVIDER Search backend when a key is set (default: brave)',
|
|
42
|
+
' DRACO_SEARCH_API_KEY Optional. Use Brave instead of built-in DuckDuckGo',
|
|
43
|
+
].join('\n') + '\n'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function installGlobalHandlers() {
|
|
48
|
+
process.on('uncaughtException', (err) => {
|
|
49
|
+
ui.error(`Uncaught exception: ${err && err.stack ? err.stack : err}`);
|
|
50
|
+
});
|
|
51
|
+
process.on('unhandledRejection', (reason) => {
|
|
52
|
+
ui.error(`Unhandled rejection: ${reason instanceof Error ? reason.stack : String(reason)}`);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ensureApiKey(cfg, nextLine) {
|
|
57
|
+
if (cfg.apiKey) return true;
|
|
58
|
+
ui.info('No API key found. Get a free one at https://opencode.ai');
|
|
59
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
60
|
+
process.stdout.write(`${ui.c.yellow}?${ui.c.reset} Enter your OpenCode Zen API key: `);
|
|
61
|
+
const line = await nextLine();
|
|
62
|
+
if (line === null) return false;
|
|
63
|
+
const key = line.trim();
|
|
64
|
+
if (!key) {
|
|
65
|
+
ui.error('Key cannot be empty.');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const spinner = new ui.Spinner('Validating key…');
|
|
69
|
+
spinner.start();
|
|
70
|
+
const probe = await api_probe(cfg, key);
|
|
71
|
+
spinner.stop();
|
|
72
|
+
if (probe.verdict === 'invalid') {
|
|
73
|
+
ui.error(`Key rejected (HTTP ${probe.status}). Check it and try again.`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (probe.verdict === 'unreachable') {
|
|
77
|
+
ui.error(`Could not reach the API to verify (${probe.detail}). Using the key anyway.`);
|
|
78
|
+
}
|
|
79
|
+
cfg.apiKey = key;
|
|
80
|
+
cfg.keySource = 'session';
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (!cfg.apiKey) return false;
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
`${ui.c.yellow}?${ui.c.reset} Save key to ${cfg.configFile} for future sessions? [Y/n] `
|
|
86
|
+
);
|
|
87
|
+
const saveAnswer = await nextLine();
|
|
88
|
+
const declined =
|
|
89
|
+
saveAnswer !== null && ['n', 'no'].includes(saveAnswer.trim().toLowerCase());
|
|
90
|
+
if (declined) {
|
|
91
|
+
ui.info('Key kept for this session only.');
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
config.persistConfig({ apiKey: cfg.apiKey });
|
|
95
|
+
cfg.keySource = 'saved';
|
|
96
|
+
ui.success(`Key saved to ${cfg.configFile}.`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
ui.error(`Could not save key (${e.message}). It will only last this session.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function checkForUpdate() {
|
|
105
|
+
const https = require('https');
|
|
106
|
+
const currentVersion = config.VERSION;
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const options = {
|
|
110
|
+
hostname: 'registry.npmjs.org',
|
|
111
|
+
path: '/draco-cli/latest',
|
|
112
|
+
method: 'GET',
|
|
113
|
+
timeout: 5000,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const req = https.request(options, (res) => {
|
|
117
|
+
let data = '';
|
|
118
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
119
|
+
res.on('end', () => {
|
|
120
|
+
try {
|
|
121
|
+
const pkg = JSON.parse(data);
|
|
122
|
+
const latestVersion = pkg.version;
|
|
123
|
+
if (latestVersion !== currentVersion) {
|
|
124
|
+
resolve({
|
|
125
|
+
updateAvailable: true,
|
|
126
|
+
current: currentVersion,
|
|
127
|
+
latest: latestVersion,
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
resolve({ updateAvailable: false, current: currentVersion });
|
|
131
|
+
}
|
|
132
|
+
} catch (e) {
|
|
133
|
+
resolve({ updateAvailable: false, current: currentVersion, error: e.message });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
req.on('error', () => {
|
|
139
|
+
resolve({ updateAvailable: false, current: currentVersion, error: 'Could not check for updates' });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
req.on('timeout', () => {
|
|
143
|
+
req.destroy();
|
|
144
|
+
resolve({ updateAvailable: false, current: currentVersion, error: 'Timeout checking for updates' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
req.end();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function api_probe(cfg, key) {
|
|
152
|
+
const { probeApiKey } = require('./providers/openai-compatible.js');
|
|
153
|
+
return probeApiKey(cfg, key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function main(argv) {
|
|
157
|
+
let flags;
|
|
158
|
+
try {
|
|
159
|
+
flags = parseArgs(argv || []);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
ui.error(e.message);
|
|
162
|
+
printUsage();
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
config.loadEnvFile();
|
|
167
|
+
if (flags.version) {
|
|
168
|
+
process.stdout.write(`Draco CLI v${config.VERSION}\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (flags.help) {
|
|
172
|
+
printUsage();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (flags.checkUpdate) {
|
|
177
|
+
const result = await checkForUpdate();
|
|
178
|
+
if (result.updateAvailable) {
|
|
179
|
+
ui.info(`Update available: ${result.current} -> ${result.latest}`);
|
|
180
|
+
ui.info('Run "npm update -g draco-cli" to update.');
|
|
181
|
+
} else {
|
|
182
|
+
ui.success(`You are running the latest version (${result.current}).`);
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const cfg = config.resolveConfig(flags);
|
|
188
|
+
ui.splash(cfg);
|
|
189
|
+
|
|
190
|
+
installGlobalHandlers();
|
|
191
|
+
|
|
192
|
+
const rl = readline.createInterface({
|
|
193
|
+
input: process.stdin,
|
|
194
|
+
output: process.stdout,
|
|
195
|
+
terminal: Boolean(process.stdin.isTTY),
|
|
196
|
+
});
|
|
197
|
+
const lineQueue = createLineQueue(rl);
|
|
198
|
+
const nextLine = lineQueue.nextLine;
|
|
199
|
+
|
|
200
|
+
rl.on('SIGINT', () => {
|
|
201
|
+
process.stdout.write('\n');
|
|
202
|
+
ui.info('Goodbye.');
|
|
203
|
+
process.exit(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const hasKey = await ensureApiKey(cfg, nextLine);
|
|
207
|
+
if (!hasKey) {
|
|
208
|
+
ui.error('No API key provided. Set OPENCODE_ZEN_KEY or enter it when prompted.');
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const mcp = await mcpClient.createFromConfig();
|
|
213
|
+
|
|
214
|
+
const agent = createSession(
|
|
215
|
+
cfg,
|
|
216
|
+
async (question) => {
|
|
217
|
+
process.stdout.write(`${ui.c.yellow}?${ui.c.reset} ${question}`);
|
|
218
|
+
const answer = await nextLine();
|
|
219
|
+
return answer === null ? '' : answer;
|
|
220
|
+
},
|
|
221
|
+
{ mcp }
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
rl.on('keypress', (_ch, key) => {
|
|
225
|
+
if (key && key.name === 'escape') {
|
|
226
|
+
agent.abortActive();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (flags.continueSession) {
|
|
231
|
+
const data = loadLatestSession(cfg.workspaceRoot);
|
|
232
|
+
if (data && agent.importSession(data)) ui.success(`Resumed last session (${data.model}).`);
|
|
233
|
+
else ui.info('No previous session for this directory — starting fresh.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (flags.promptArgs && flags.promptArgs.length > 0) {
|
|
237
|
+
const prompt = flags.promptArgs.join(' ').trim();
|
|
238
|
+
const code = await runOneShot({ prompt, agent });
|
|
239
|
+
try {
|
|
240
|
+
rl.close();
|
|
241
|
+
} catch {}
|
|
242
|
+
mcp.stopAll();
|
|
243
|
+
setTimeout(() => process.exit(code), 300);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await runRepl({ cfg, rl, lineQueue, agent, mcp });
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
mcp.stopAll();
|
|
251
|
+
} catch {}
|
|
252
|
+
setTimeout(() => process.exit(0), 250).unref();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { main };
|