@drael/code 0.1.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.
Files changed (2) hide show
  1. package/package.json +25 -0
  2. package/src/code.js +469 -0
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@drael/code",
3
+ "version": "0.1.0",
4
+ "description": "Point your coding client at Drael.",
5
+ "type": "module",
6
+ "bin": {
7
+ "drael": "src/code.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "ci": "node --check src/code.js"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@clack/core": "^1.0.0"
24
+ }
25
+ }
package/src/code.js ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Drael, into the editor you already use.
4
+ *
5
+ * npx @drael/code interactive
6
+ * npx @drael/code --key … --client … answered up front
7
+ * npx @drael/code --list what it knows how to configure
8
+ * npx @drael/code --dry-run … print what it would write, change nothing
9
+ * npx @drael/code --uninstall restore every file it changed
10
+ *
11
+ * It runs through npx rather than `curl | sh` deliberately. Piping a URL into a
12
+ * shell is exactly the pattern this product's own users are right to refuse, and
13
+ * npx is already on the machine of anybody with an editor extension. It also makes
14
+ * Windows work without a second implementation.
15
+ *
16
+ * It writes nothing but the target client's own configuration file, copies what it
17
+ * touches before touching it, and asks for no privilege. The docs link to this file
18
+ * on purpose, because an installer earns trust by being short enough to read.
19
+ */
20
+ import {
21
+ chmodSync,
22
+ copyFileSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ } from 'node:fs'
30
+ import { homedir, platform } from 'node:os'
31
+ import { dirname, join } from 'node:path'
32
+ import process from 'node:process'
33
+
34
+ /**
35
+ * `@clack/core` rather than `@clack/prompts`: the latter draws its own rail and
36
+ * colours its own symbols, and both are its identity rather than ours. Core is the
37
+ * half underneath, which reads raw mode, tracks the keys, validates and carries the
38
+ * cancel symbol, and asks each prompt to draw itself. The drawing here is Drael's.
39
+ */
40
+ import { isCancel, PasswordPrompt, SelectPrompt } from '@clack/core'
41
+
42
+ /* ───────────────────────────────────────────────────────────── where things live ── */
43
+
44
+ const CONFIG_HOME =
45
+ process.env.XDG_CONFIG_HOME ??
46
+ (platform() === 'win32'
47
+ ? join(process.env.APPDATA ?? homedir(), 'Config')
48
+ : join(homedir(), '.config'))
49
+
50
+ const STATE_HOME =
51
+ process.env.XDG_STATE_HOME ??
52
+ (platform() === 'win32'
53
+ ? join(process.env.LOCALAPPDATA ?? homedir(), 'State')
54
+ : join(homedir(), '.local', 'state'))
55
+
56
+ const BACKUPS = join(STATE_HOME, 'drael', 'installer')
57
+
58
+ // Kilo documents this literal path on every platform, Windows included, so this one
59
+ // does not go through CONFIG_HOME the way the others do.
60
+ const KILO_CONFIG = join(homedir(), '.config', 'kilo', 'kilo.jsonc')
61
+
62
+ // There is one origin (ADR 0017), and this package is published by the people who run
63
+ // it. Asking for the address was the installer failing to know its own address.
64
+ const DRAEL = 'https://drael.sh'
65
+
66
+ /* ────────────────────────────────────────────────────────────────── the clients ── */
67
+
68
+ /**
69
+ * What it knows how to configure. A client is added when real usage justifies it, not
70
+ * pre-emptively: each one is a format that can change under us, and a client nobody
71
+ * here runs is a client we would break silently.
72
+ *
73
+ * `merges` is the difference between a file that is ours and a file we are a guest in.
74
+ */
75
+ const clients = {
76
+ opencode: {
77
+ label: 'OpenCode',
78
+ file: () => join(CONFIG_HOME, 'opencode', 'opencode.json'),
79
+ contents: (host, key) =>
80
+ json({
81
+ $schema: 'https://opencode.ai/config.json',
82
+ provider: {
83
+ drael: {
84
+ npm: '@ai-sdk/openai-compatible',
85
+ name: 'Drael',
86
+ options: { baseURL: `${host}/v1`, apiKey: key },
87
+ models: { drael: { name: 'Drael' } },
88
+ },
89
+ },
90
+ model: 'drael/drael',
91
+ }),
92
+ },
93
+
94
+ kilo: {
95
+ label: 'Kilo Code',
96
+ merges: true,
97
+ file: () => KILO_CONFIG,
98
+ contents: (host, key) => {
99
+ const existing = readJson(KILO_CONFIG)
100
+ return json({
101
+ ...existing,
102
+ $schema: 'https://app.kilo.ai/config.json',
103
+ provider: {
104
+ ...existing.provider,
105
+ drael: {
106
+ options: { baseURL: `${host}/v1`, apiKey: key },
107
+ models: { drael: { name: 'Drael' } },
108
+ },
109
+ },
110
+ model: 'drael/drael',
111
+ })
112
+ },
113
+ },
114
+
115
+ env: {
116
+ label: 'anything reading OPENAI_BASE_URL',
117
+ file: () => join(CONFIG_HOME, 'drael', 'env.sh'),
118
+ contents: (host, key) =>
119
+ [
120
+ '# Drael. Source this, or add it to your shell profile.',
121
+ `export OPENAI_BASE_URL="${host}/v1"`,
122
+ `export OPENAI_API_KEY="${key}"`,
123
+ '# Some clients read the older name.',
124
+ `export OPENAI_API_BASE="${host}/v1"`,
125
+ '',
126
+ ].join('\n'),
127
+ after: (file) => `add this to your shell profile\n${dim(`. ${sourceable(file)}`)}`,
128
+ },
129
+ }
130
+
131
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`
132
+
133
+ /**
134
+ * Kilo's file is the whole extension's configuration and not ours, so ours is merged
135
+ * into what is already there. A file that does not parse is treated as absent rather
136
+ * than guessed at: JSONC allows comments, and a parser guessing at them writes back a
137
+ * mangled config. The copy under BACKUPS still holds the original, and `parses` below
138
+ * is what lets the frame say so before it writes.
139
+ */
140
+ function readJson(file) {
141
+ try {
142
+ return JSON.parse(readFileSync(file, 'utf8'))
143
+ } catch {
144
+ return {}
145
+ }
146
+ }
147
+
148
+ function parses(file) {
149
+ if (!existsSync(file)) {
150
+ return true
151
+ }
152
+ try {
153
+ JSON.parse(readFileSync(file, 'utf8'))
154
+ return true
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
160
+ /* ────────────────────────────────────────────────────────────────── the drawing ── */
161
+
162
+ /**
163
+ * The system is monochrome and its structure is a ruled grid with markers at the
164
+ * crossings, never a card and never a fill (`packages/ui/DESIGN.md`). A page has three
165
+ * values for that, `--figure`, `--secondary` and `--tertiary`; a terminal has weight
166
+ * instead, so these three stand in for them in the same order.
167
+ *
168
+ * All of it goes plain when stdout is not a terminal or NO_COLOR is set, because a
169
+ * piped log is read by something that does not draw.
170
+ */
171
+ const styled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
172
+ const bold = (text) => (styled ? `\u001b[1m${text}\u001b[22m` : text)
173
+ const dim = (text) => (styled ? `\u001b[2m${text}\u001b[22m` : text)
174
+
175
+ // The label column, wide enough for the longest label with a gutter after it.
176
+ const LABEL = 13
177
+ const WIDTH = Math.max(48, Math.min(process.stdout.columns || 80, 78))
178
+
179
+ /**
180
+ * A row of the grid: the label on the left rail, what it names on the right. Content
181
+ * that runs to a second line is indented into the same column, so the rail stays a rail.
182
+ */
183
+ function row(label, content = '') {
184
+ const gutter = ' '.repeat(LABEL + 2)
185
+ return ` ${dim(label.padEnd(LABEL))}${String(content).replaceAll('\n', `\n${gutter}`)}`
186
+ }
187
+
188
+ /** A row with no label, sitting under the one above it. */
189
+ const under = (content) => row('', content)
190
+
191
+ /** A hairline, marked where the label column crosses it. */
192
+ const rule = () => dim(` ${'─'.repeat(LABEL - 1)}┬${'─'.repeat(WIDTH - LABEL - 3)}`)
193
+
194
+ /** A home-relative path is what the reader recognises; the absolute one is a wall. */
195
+ const tilde = (path) => (path.startsWith(homedir()) ? `~${path.slice(homedir().length)}` : path)
196
+
197
+ /**
198
+ * For a line that gets pasted into a shell. A `~` inside quotes is not expanded and an
199
+ * unquoted path breaks on a space; `$HOME` survives both.
200
+ */
201
+ const sourceable = (path) =>
202
+ path.startsWith(homedir()) ? `"$HOME${path.slice(homedir().length)}"` : `"${path}"`
203
+
204
+ function masthead(caption) {
205
+ console.log()
206
+ console.log(` ${bold('Drael.sh'.padEnd(LABEL))}${dim(caption)}`)
207
+ console.log(rule())
208
+ console.log()
209
+ }
210
+
211
+ /* ────────────────────────────────────────────────────────────────── the prompts ── */
212
+
213
+ /**
214
+ * Each prompt draws its own block and core redraws it on every key. `state` is what
215
+ * the prompt is doing, and the only two frames that differ are the one being answered
216
+ * and the one already answered, which collapses to the answer alone.
217
+ */
218
+ async function ask(prompt) {
219
+ if (!process.stdin.isTTY) {
220
+ // Without a terminal a prompt waits forever, node exits on the unsettled await,
221
+ // and nothing is said about the flags that would have avoided the question.
222
+ fail('there is no terminal to ask on: pass --key and --client')
223
+ }
224
+ const answer = await prompt.prompt()
225
+ if (isCancel(answer)) {
226
+ console.log(row('', dim('cancelled, nothing was written')))
227
+ console.log()
228
+ process.exit(1)
229
+ }
230
+ return answer
231
+ }
232
+
233
+ const askKey = (host) =>
234
+ ask(
235
+ new PasswordPrompt({
236
+ mask: '▪',
237
+ validate: (value) => (value.startsWith('dk-') ? undefined : 'a Drael key starts with dk-'),
238
+ render() {
239
+ if (this.state === 'submit') {
240
+ return row('api key', dim(this.masked))
241
+ }
242
+ return [
243
+ row('api key', this.userInputWithCursor),
244
+ under(dim(this.error || `create one at ${host}/developers`)),
245
+ ].join('\n')
246
+ },
247
+ }),
248
+ )
249
+
250
+ const askClient = () =>
251
+ ask(
252
+ new SelectPrompt({
253
+ options: Object.entries(clients).map(([value, client]) => ({ value, client })),
254
+ initialValue: detected(),
255
+ render() {
256
+ if (this.state === 'submit') {
257
+ return row('client', dim(this.value))
258
+ }
259
+ return this.options
260
+ .map(({ value, client }, index) => {
261
+ const chosen = index === this.cursor
262
+ const name = `${chosen ? '▸ ' : ' '}${value.padEnd(9)} `
263
+ const said = `${client.label}${found(client) ? ' · on this machine' : ''}`
264
+ return row(index === 0 ? 'client' : '', chosen ? bold(name) + said : dim(name + said))
265
+ })
266
+ .join('\n')
267
+ },
268
+ }),
269
+ )
270
+
271
+ /**
272
+ * Whether the client's own directory is on this machine. It is a hint and never a gate:
273
+ * a fresh install may not have written its directory yet, so every client stays on the
274
+ * list and detection only decides which one the cursor starts on.
275
+ */
276
+ function found(client) {
277
+ return existsSync(dirname(client.file()))
278
+ }
279
+
280
+ function detected() {
281
+ return Object.entries(clients).find(([, client]) => found(client))?.[0] ?? 'opencode'
282
+ }
283
+
284
+ /* ──────────────────────────────────────────────────────────────────── installing ── */
285
+
286
+ async function install(args) {
287
+ masthead('into the editor you already use')
288
+
289
+ const host = (args.host ?? process.env.DRAEL_HOST ?? DRAEL).replace(/\/+$/, '')
290
+ if (!/^https?:\/\//.test(host)) {
291
+ fail('the host must start with https:// or http://')
292
+ }
293
+
294
+ const key = (args.key ?? process.env.DRAEL_KEY ?? (await askKey(host))).trim()
295
+ if (!key.startsWith('dk-')) {
296
+ fail('that does not look like a Drael key; they start with dk-')
297
+ }
298
+
299
+ const name = (args.client ?? (await askClient())).trim()
300
+ const client = clients[name]
301
+ if (!client) {
302
+ fail(`unknown client: ${name} (try --list)`)
303
+ }
304
+
305
+ const file = client.file()
306
+ const contents = client.contents(host, key)
307
+
308
+ // An answered prompt leaves its frame on screen and needs a gap after it. With every
309
+ // answer given as a flag there was no frame, and the masthead's own gap is already it.
310
+ if (!(args.key ?? process.env.DRAEL_KEY) || !args.client) {
311
+ console.log()
312
+ }
313
+
314
+ if (args['dry-run']) {
315
+ console.log(row(client.merges ? 'would merge' : 'would write', tilde(file)))
316
+ console.log()
317
+ console.log(contents.trimEnd().replace(/^/gm, ' '.repeat(LABEL + 2)))
318
+ console.log()
319
+ console.log(row('', dim('nothing was written')))
320
+ console.log()
321
+ return
322
+ }
323
+
324
+ const kept = backup(file)
325
+ mkdirSync(dirname(file), { recursive: true })
326
+ writeFileSync(file, contents, { mode: 0o600 })
327
+ chmodSync(file, 0o600)
328
+
329
+ console.log(row(client.merges ? 'merged' : 'wrote', tilde(file)))
330
+ if (client.merges && !parses(file)) {
331
+ console.log(under(dim('it was not plain JSON, so it was replaced rather than merged')))
332
+ }
333
+ console.log(row(kept.label, dim(kept.detail)))
334
+ if (client.after) {
335
+ console.log()
336
+ console.log(row('and then', client.after(file)))
337
+ }
338
+
339
+ console.log()
340
+ console.log(rule())
341
+ console.log()
342
+ console.log(row('check it', dim(`curl ${host}/v1/models -H "Authorization: Bearer ${key}"`)))
343
+ console.log(row('undo it', dim('npx @drael/code --uninstall')))
344
+ console.log()
345
+ }
346
+
347
+ /**
348
+ * Every write is preceded by a copy. An uninstall that cannot put the file back is not
349
+ * an uninstall, it is a deletion with better manners. It reports what it found rather
350
+ * than printing it, so the frame says the path once.
351
+ */
352
+ function backup(file) {
353
+ mkdirSync(BACKUPS, { recursive: true })
354
+ const slug = file.replace(/[\\/:]/g, '_')
355
+
356
+ // Only the FIRST record is kept. What an uninstall restores is the state before this
357
+ // installer ever touched the file, so running it twice must not overwrite the
358
+ // original with our own output, and must not leave two records that disagree about
359
+ // whether the file existed.
360
+ if (existsSync(join(BACKUPS, slug)) || existsSync(join(BACKUPS, `${slug}.absent`))) {
361
+ return { label: 'kept', detail: 'the original was already recorded, from an earlier run' }
362
+ }
363
+
364
+ if (existsSync(file)) {
365
+ copyFileSync(file, join(BACKUPS, slug))
366
+ // The original path beside the copy, because a slug is not reversible into a path
367
+ // and guessing one is how an uninstall writes to the wrong place.
368
+ writeFileSync(join(BACKUPS, `${slug}.path`), file)
369
+ return { label: 'copied', detail: `the original, to ${tilde(BACKUPS)}` }
370
+ }
371
+
372
+ // The file did not exist, so restoring means removing what we created.
373
+ writeFileSync(join(BACKUPS, `${slug}.absent`), file)
374
+ return { label: 'new', detail: 'it did not exist before, and uninstall removes it' }
375
+ }
376
+
377
+ function uninstall(args) {
378
+ masthead('putting back what it changed')
379
+
380
+ if (!existsSync(BACKUPS)) {
381
+ fail(`nothing to restore: no record at ${tilde(BACKUPS)}`)
382
+ }
383
+
384
+ let restored = 0
385
+ for (const entry of readdirSync(BACKUPS)) {
386
+ if (entry.endsWith('.path')) continue
387
+ const saved = join(BACKUPS, entry)
388
+
389
+ if (entry.endsWith('.absent')) {
390
+ const file = read(saved)
391
+ if (existsSync(file)) {
392
+ if (!args['dry-run']) rmSync(file)
393
+ console.log(row('removed', tilde(file)))
394
+ restored++
395
+ }
396
+ if (!args['dry-run']) rmSync(saved)
397
+ continue
398
+ }
399
+
400
+ // The slug is not reversible into a path on its own, so the original path is stored
401
+ // beside the copy rather than guessed from the name.
402
+ const file = read(`${saved}.path`)
403
+ if (!file) continue
404
+ if (!args['dry-run']) copyFileSync(saved, file)
405
+ console.log(row('restored', tilde(file)))
406
+ restored++
407
+ if (!args['dry-run']) {
408
+ rmSync(saved)
409
+ rmSync(`${saved}.path`)
410
+ }
411
+ }
412
+
413
+ console.log()
414
+ console.log(row('', dim(`${restored} ${restored === 1 ? 'file' : 'files'} put back`)))
415
+ console.log()
416
+ }
417
+
418
+ function list() {
419
+ console.log()
420
+ for (const [name, client] of Object.entries(clients)) {
421
+ console.log(row(name, `${dim(tilde(client.file()).padEnd(34))} ${client.label}`))
422
+ }
423
+ console.log()
424
+ }
425
+
426
+ /* ─────────────────────────────────────────────────────────────────────── running ── */
427
+
428
+ function read(path) {
429
+ try {
430
+ return readFileSync(path, 'utf8').trim()
431
+ } catch {
432
+ return ''
433
+ }
434
+ }
435
+
436
+ function fail(message) {
437
+ console.log(row('', bold(message)))
438
+ console.log()
439
+ process.exit(1)
440
+ }
441
+
442
+ function parse(argv) {
443
+ const parsed = {}
444
+ for (let i = 0; i < argv.length; i++) {
445
+ const arg = argv[i]
446
+ if (!arg.startsWith('--')) continue
447
+ const name = arg.slice(2)
448
+ if (['list', 'uninstall', 'dry-run'].includes(name)) {
449
+ parsed[name] = true
450
+ continue
451
+ }
452
+ parsed[name] = argv[++i]
453
+ }
454
+ return parsed
455
+ }
456
+
457
+ // A prompt hides the cursor while it is up, and an exit that is not the prompt's own
458
+ // leaves it hidden in the terminal the reader goes back to.
459
+ process.on('exit', () => process.stdout.write('\u001b[?25h'))
460
+
461
+ const args = parse(process.argv.slice(2))
462
+
463
+ if (args.list) {
464
+ list()
465
+ } else if (args.uninstall) {
466
+ uninstall(args)
467
+ } else {
468
+ await install(args)
469
+ }