@drael/code 0.3.0 → 0.5.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/package.json +1 -1
- package/src/code.js +159 -40
package/package.json
CHANGED
package/src/code.js
CHANGED
|
@@ -41,12 +41,15 @@ import { isCancel, PasswordPrompt, SelectPrompt } from '@clack/core'
|
|
|
41
41
|
|
|
42
42
|
/* ───────────────────────────────────────────────────────────── where things live ── */
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
// Home-relative on every platform, Windows included. The clients here document
|
|
45
|
+
// `~/.config/<them>` literally and read nowhere else, so the earlier `%APPDATA%\\Config`
|
|
46
|
+
// branch wrote a valid file into a directory nothing opens: the worst failure this tool
|
|
47
|
+
// has, because it reports success.
|
|
48
|
+
const CONFIG_HOME = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
|
|
49
|
+
|
|
50
|
+
// The backups do not follow, and that is deliberate: this directory is ours rather than a
|
|
51
|
+
// client's, so it was never in the wrong place, and moving it would strand the records
|
|
52
|
+
// written by an earlier version. An uninstall that cannot find them is not an uninstall.
|
|
50
53
|
const STATE_HOME =
|
|
51
54
|
process.env.XDG_STATE_HOME ??
|
|
52
55
|
(platform() === 'win32'
|
|
@@ -55,8 +58,8 @@ const STATE_HOME =
|
|
|
55
58
|
|
|
56
59
|
const BACKUPS = join(STATE_HOME, 'drael', 'installer')
|
|
57
60
|
|
|
58
|
-
// Kilo documents this literal path
|
|
59
|
-
//
|
|
61
|
+
// Kilo documents this literal path, so unlike the others it is not moved by
|
|
62
|
+
// XDG_CONFIG_HOME either.
|
|
60
63
|
const KILO_CONFIG = join(homedir(), '.config', 'kilo', 'kilo.jsonc')
|
|
61
64
|
|
|
62
65
|
// There is one origin (ADR 0017), and this package is published by the people who run
|
|
@@ -102,12 +105,12 @@ const clients = {
|
|
|
102
105
|
merges: true,
|
|
103
106
|
file: () => KILO_CONFIG,
|
|
104
107
|
contents: (host, key, model) => {
|
|
105
|
-
const
|
|
108
|
+
const kept = existing(KILO_CONFIG).value
|
|
106
109
|
return json({
|
|
107
|
-
...
|
|
110
|
+
...kept,
|
|
108
111
|
$schema: 'https://app.kilo.ai/config.json',
|
|
109
112
|
provider: {
|
|
110
|
-
...
|
|
113
|
+
...kept.provider,
|
|
111
114
|
drael: {
|
|
112
115
|
options: { baseURL: `${host}/v1`, apiKey: key },
|
|
113
116
|
models: { [model]: { name: 'Drael' } },
|
|
@@ -118,6 +121,40 @@ const clients = {
|
|
|
118
121
|
},
|
|
119
122
|
},
|
|
120
123
|
|
|
124
|
+
continue: {
|
|
125
|
+
label: 'Continue',
|
|
126
|
+
file: () => join(homedir(), '.continue', 'config.yaml'),
|
|
127
|
+
contents: (host, key, model) =>
|
|
128
|
+
[
|
|
129
|
+
'name: Drael',
|
|
130
|
+
'version: 1.0.0',
|
|
131
|
+
'schema: v1',
|
|
132
|
+
'models:',
|
|
133
|
+
' - name: Drael',
|
|
134
|
+
' provider: openai',
|
|
135
|
+
` model: ${scalar(model)}`,
|
|
136
|
+
` apiBase: ${scalar(`${host}/v1`)}`,
|
|
137
|
+
` apiKey: ${scalar(key)}`,
|
|
138
|
+
'',
|
|
139
|
+
].join('\n'),
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
aider: {
|
|
143
|
+
label: 'Aider',
|
|
144
|
+
// A dotfile at the root of home, so its own presence is the only sign of it: the
|
|
145
|
+
// directory the others are found by would be the home directory, which is always there.
|
|
146
|
+
at: () => join(homedir(), '.aider.conf.yml'),
|
|
147
|
+
file: () => join(homedir(), '.aider.conf.yml'),
|
|
148
|
+
contents: (host, key, model) =>
|
|
149
|
+
[
|
|
150
|
+
'# Drael.',
|
|
151
|
+
`model: ${scalar(`openai/${model}`)}`,
|
|
152
|
+
`openai-api-base: ${scalar(`${host}/v1`)}`,
|
|
153
|
+
`openai-api-key: ${scalar(key)}`,
|
|
154
|
+
'',
|
|
155
|
+
].join('\n'),
|
|
156
|
+
},
|
|
157
|
+
|
|
121
158
|
env: {
|
|
122
159
|
label: 'anything reading OPENAI_BASE_URL',
|
|
123
160
|
file: () => join(CONFIG_HOME, 'drael', 'env.sh'),
|
|
@@ -137,30 +174,80 @@ const clients = {
|
|
|
137
174
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`
|
|
138
175
|
|
|
139
176
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* mangled config. The copy under BACKUPS still holds the original, and `parses` below
|
|
144
|
-
* is what lets the frame say so before it writes.
|
|
177
|
+
* A YAML scalar, for the two files that are YAML. YAML 1.2 is a superset of JSON, so a
|
|
178
|
+
* JSON string is already a correctly quoted and escaped one, and quoting every value is
|
|
179
|
+
* what keeps the `https://` in a URL from reading as a key with a comment after it.
|
|
145
180
|
*/
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
return JSON.parse(readFileSync(file, 'utf8'))
|
|
149
|
-
} catch {
|
|
150
|
-
return {}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
181
|
+
const scalar = (value) => JSON.stringify(String(value))
|
|
153
182
|
|
|
154
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Kilo's file is the whole extension's configuration and not ours, so ours is merged into
|
|
185
|
+
* what is already there. It is a `.jsonc`, and a file that names itself that has comments
|
|
186
|
+
* in it, so reading it with `JSON.parse` alone meant every commented config was replaced
|
|
187
|
+
* whole: the other providers, the keys and the settings in it, gone but for the copy under
|
|
188
|
+
* BACKUPS.
|
|
189
|
+
*
|
|
190
|
+
* What cannot be kept is the comments themselves, because what goes back is JSON. The
|
|
191
|
+
* frame says so when that is what happened.
|
|
192
|
+
*/
|
|
193
|
+
function existing(file) {
|
|
155
194
|
if (!existsSync(file)) {
|
|
156
|
-
return
|
|
195
|
+
return { value: {} }
|
|
196
|
+
}
|
|
197
|
+
const text = readFileSync(file, 'utf8')
|
|
198
|
+
try {
|
|
199
|
+
return { value: JSON.parse(text) }
|
|
200
|
+
} catch {
|
|
201
|
+
// Not plain JSON, so it is either JSONC or damaged, and those are different answers.
|
|
157
202
|
}
|
|
158
203
|
try {
|
|
159
|
-
JSON.parse(
|
|
160
|
-
return true
|
|
204
|
+
return { value: JSON.parse(uncommented(text)), stripped: true }
|
|
161
205
|
} catch {
|
|
162
|
-
return
|
|
206
|
+
return { value: {}, replaced: true }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* JSON with the comments and the trailing commas taken out, which is what JSONC is. A
|
|
212
|
+
* scanner rather than a pattern, because a `//` inside a string value is not a comment and
|
|
213
|
+
* a pattern cannot tell the difference; the same goes for the comma in `"a, }"`, which a
|
|
214
|
+
* pattern for trailing commas would eat.
|
|
215
|
+
*/
|
|
216
|
+
function uncommented(text) {
|
|
217
|
+
let out = ''
|
|
218
|
+
let comma = -1
|
|
219
|
+
let i = 0
|
|
220
|
+
while (i < text.length) {
|
|
221
|
+
const here = text[i]
|
|
222
|
+
|
|
223
|
+
if (here === '"') {
|
|
224
|
+
let end = i + 1
|
|
225
|
+
while (end < text.length && text[end] !== '"') {
|
|
226
|
+
end += text[end] === '\\' ? 2 : 1
|
|
227
|
+
}
|
|
228
|
+
out += text.slice(i, end + 1)
|
|
229
|
+
i = end + 1
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
if (here === '/' && text[i + 1] === '/') {
|
|
233
|
+
while (i < text.length && text[i] !== '\n') i++
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (here === '/' && text[i + 1] === '*') {
|
|
237
|
+
const closed = text.indexOf('*/', i + 2)
|
|
238
|
+
i = closed === -1 ? text.length : closed + 2
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (here === ',') {
|
|
242
|
+
comma = out.length
|
|
243
|
+
} else if ((here === '}' || here === ']') && comma >= 0 && !out.slice(comma + 1).trim()) {
|
|
244
|
+
out = out.slice(0, comma) + out.slice(comma + 1)
|
|
245
|
+
comma = -1
|
|
246
|
+
}
|
|
247
|
+
out += here
|
|
248
|
+
i++
|
|
163
249
|
}
|
|
250
|
+
return out
|
|
164
251
|
}
|
|
165
252
|
|
|
166
253
|
/* ────────────────────────────────────────────────────────────────── the drawing ── */
|
|
@@ -279,17 +366,24 @@ const askClient = () =>
|
|
|
279
366
|
* product itself. The one call this makes, and the only one: it is to the host being
|
|
280
367
|
* configured, with the key being configured, and a host that does not answer is not a
|
|
281
368
|
* reason to refuse to write anything, so the published id stands in.
|
|
369
|
+
*
|
|
370
|
+
* It is also the only check the reader needs, which is why the frame reports what came
|
|
371
|
+
* back instead of handing out a curl line: that line carried the key in clear text, on
|
|
372
|
+
* screen and into the scrollback, one row under the prompt that had just masked it.
|
|
282
373
|
*/
|
|
283
|
-
async function
|
|
374
|
+
async function askHost(host, key) {
|
|
284
375
|
try {
|
|
285
376
|
const response = await fetch(`${host}/v1/models`, {
|
|
286
377
|
headers: { Authorization: `Bearer ${key}` },
|
|
287
378
|
signal: AbortSignal.timeout(4000),
|
|
288
379
|
})
|
|
380
|
+
if (response.status === 401 || response.status === 403) {
|
|
381
|
+
return { refused: true }
|
|
382
|
+
}
|
|
289
383
|
const served = (await response.json())?.data?.[0]?.id
|
|
290
|
-
return typeof served === 'string' && served ? served :
|
|
384
|
+
return { model: typeof served === 'string' && served ? served : null }
|
|
291
385
|
} catch {
|
|
292
|
-
return
|
|
386
|
+
return {}
|
|
293
387
|
}
|
|
294
388
|
}
|
|
295
389
|
|
|
@@ -299,7 +393,7 @@ async function servedModel(host, key) {
|
|
|
299
393
|
* list and detection only decides which one the cursor starts on.
|
|
300
394
|
*/
|
|
301
395
|
function found(client) {
|
|
302
|
-
return existsSync(dirname(client.file()))
|
|
396
|
+
return existsSync(client.at?.() ?? dirname(client.file()))
|
|
303
397
|
}
|
|
304
398
|
|
|
305
399
|
function detected() {
|
|
@@ -327,8 +421,9 @@ async function install(args) {
|
|
|
327
421
|
fail(`unknown client: ${name} (try --list)`)
|
|
328
422
|
}
|
|
329
423
|
|
|
424
|
+
const answer = await askHost(host, key)
|
|
330
425
|
const file = client.file()
|
|
331
|
-
const contents = client.contents(host, key,
|
|
426
|
+
const contents = client.contents(host, key, answer.model ?? MODEL)
|
|
332
427
|
|
|
333
428
|
// An answered prompt leaves its frame on screen and needs a gap after it. With every
|
|
334
429
|
// answer given as a flag there was no frame, and the masthead's own gap is already it.
|
|
@@ -346,8 +441,8 @@ async function install(args) {
|
|
|
346
441
|
return
|
|
347
442
|
}
|
|
348
443
|
|
|
349
|
-
// Asked before the write, or it inspects the
|
|
350
|
-
const
|
|
444
|
+
// Asked before the write, or it inspects the JSON we are about to put there.
|
|
445
|
+
const was = client.merges ? existing(file) : {}
|
|
351
446
|
|
|
352
447
|
const kept = backup(file)
|
|
353
448
|
mkdirSync(dirname(file), { recursive: true })
|
|
@@ -355,8 +450,11 @@ async function install(args) {
|
|
|
355
450
|
chmodSync(file, 0o600)
|
|
356
451
|
|
|
357
452
|
console.log(row(client.merges ? 'merged' : 'wrote', tilde(file)))
|
|
358
|
-
if (replaced) {
|
|
359
|
-
console.log(under(dim('it
|
|
453
|
+
if (was.replaced) {
|
|
454
|
+
console.log(under(dim('it did not parse at all, so it was replaced rather than merged')))
|
|
455
|
+
}
|
|
456
|
+
if (was.stripped) {
|
|
457
|
+
console.log(under(dim('everything in it was kept; its comments were not, because JSON')))
|
|
360
458
|
}
|
|
361
459
|
console.log(row(kept.label, dim(kept.detail)))
|
|
362
460
|
if (client.after) {
|
|
@@ -367,11 +465,25 @@ async function install(args) {
|
|
|
367
465
|
console.log()
|
|
368
466
|
console.log(rule())
|
|
369
467
|
console.log()
|
|
370
|
-
console.log(row('
|
|
468
|
+
console.log(row('the host', dim(`${host}/v1, ${said(answer)}`)))
|
|
469
|
+
if (answer.refused) {
|
|
470
|
+
console.log(under(dim(`create one at ${host}/developers, then run this again`)))
|
|
471
|
+
}
|
|
371
472
|
console.log(row('undo it', dim('npx @drael/code --uninstall')))
|
|
372
473
|
console.log()
|
|
373
474
|
}
|
|
374
475
|
|
|
476
|
+
/** What the one call came back with, in the frame's own voice. */
|
|
477
|
+
function said(answer) {
|
|
478
|
+
if (answer.refused) {
|
|
479
|
+
return 'which refused that key'
|
|
480
|
+
}
|
|
481
|
+
if (answer.model) {
|
|
482
|
+
return `serving ${answer.model}`
|
|
483
|
+
}
|
|
484
|
+
return `which did not answer, so ${MODEL} was written`
|
|
485
|
+
}
|
|
486
|
+
|
|
375
487
|
/**
|
|
376
488
|
* Every write is preceded by a copy. An uninstall that cannot put the file back is not
|
|
377
489
|
* an uninstall, it is a deletion with better manners. It reports what it found rather
|
|
@@ -466,7 +578,8 @@ function help() {
|
|
|
466
578
|
function list() {
|
|
467
579
|
console.log()
|
|
468
580
|
for (const [name, client] of Object.entries(clients)) {
|
|
469
|
-
|
|
581
|
+
const said = `${client.label}${found(client) ? ' · on this machine' : ''}`
|
|
582
|
+
console.log(row(name, `${dim(tilde(client.file()).padEnd(34))} ${said}`))
|
|
470
583
|
}
|
|
471
584
|
console.log()
|
|
472
585
|
}
|
|
@@ -497,7 +610,13 @@ function parse(argv) {
|
|
|
497
610
|
parsed[name] = true
|
|
498
611
|
continue
|
|
499
612
|
}
|
|
500
|
-
|
|
613
|
+
// `--client --key dk-…` used to swallow the next flag as the value and then report
|
|
614
|
+
// `unknown client: --key`, which names neither the mistake nor the fix.
|
|
615
|
+
const value = argv[++i]
|
|
616
|
+
if (value === undefined || value.startsWith('--')) {
|
|
617
|
+
fail(`--${name} needs a value`)
|
|
618
|
+
}
|
|
619
|
+
parsed[name] = value
|
|
501
620
|
}
|
|
502
621
|
return parsed
|
|
503
622
|
}
|