@abraca/cli 1.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.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Document CRUD commands: read, create, rename, move, delete, type, doc.
3
+ */
4
+ import * as Y from 'yjs'
5
+ import { registerCommand } from '../command.ts'
6
+ import type { CLIConnection } from '../connection.ts'
7
+ import type { ParsedArgs } from '../parser.ts'
8
+ import { readEntries, childrenOf, normalizeRootId, resolveDocument, descendantsOf } from '../resolve.ts'
9
+ import { printJson, relativeTime } from '../output.ts'
10
+ import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
11
+ import { populateYDocFromMarkdown, parseFrontmatter } from '../converters/markdownToYjs.ts'
12
+
13
+ /** Safely read a tree map value, converting Y.Map to plain object if needed. */
14
+ function toPlain(val: any): any {
15
+ return val instanceof Y.Map ? val.toJSON() : val
16
+ }
17
+
18
+ registerCommand({
19
+ name: 'doc',
20
+ aliases: ['info:doc'],
21
+ description: 'Show document metadata (label, type, meta, dates).',
22
+ usage: 'doc id=<docId> | name=<label> | path=<a/b/c>',
23
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
24
+ if (!conn) return 'Not connected'
25
+
26
+ const docId = resolveDocument(conn, args.params, args.positional)
27
+ if (!docId) return 'Document not found. Specify id=, name=, or path= to identify the document.'
28
+
29
+ const treeMap = conn.getTreeMap()
30
+ if (!treeMap) return 'Not connected'
31
+
32
+ const raw = treeMap.get(docId)
33
+ if (!raw) return `Document ${docId} not found in tree.`
34
+
35
+ const entry = toPlain(raw)
36
+
37
+ const lines = [
38
+ `id: ${docId}`,
39
+ `label: ${entry.label || 'Untitled'}`,
40
+ `type: ${entry.type ?? '—'}`,
41
+ `parent: ${entry.parentId ?? '(root)'}`,
42
+ `order: ${entry.order ?? 0}`,
43
+ `created: ${entry.createdAt ? new Date(entry.createdAt).toISOString() : '—'}`,
44
+ `updated: ${entry.updatedAt ? new Date(entry.updatedAt).toISOString() : '—'} (${relativeTime(entry.updatedAt)})`,
45
+ ]
46
+
47
+ if (entry.meta && Object.keys(entry.meta).length > 0) {
48
+ lines.push(`meta:`)
49
+ for (const [k, v] of Object.entries(entry.meta)) {
50
+ lines.push(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
51
+ }
52
+ }
53
+
54
+ // Count children
55
+ const entries = readEntries(treeMap)
56
+ const children = childrenOf(entries, docId)
57
+ lines.push(`children: ${children.length}`)
58
+
59
+ return lines.join('\n')
60
+ },
61
+ })
62
+
63
+ registerCommand({
64
+ name: 'read',
65
+ aliases: ['cat', 'r'],
66
+ description: 'Read document content as markdown.',
67
+ usage: 'read id=<docId> | name=<label> | path=<a/b/c> [--format=json|md]',
68
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
69
+ if (!conn) return 'Not connected'
70
+
71
+ const docId = resolveDocument(conn, args.params, args.positional)
72
+ if (!docId) return 'Document not found. Specify id=, name=, or path= to identify the document.'
73
+
74
+ try {
75
+ const provider = await conn.getChildProvider(docId)
76
+ const fragment = provider.document.getXmlFragment('default')
77
+ const { title, markdown } = yjsToMarkdown(fragment)
78
+
79
+ if (args.flags.has('json') || args.params['format'] === 'json') {
80
+ // Include tree metadata + children
81
+ const treeMap = conn.getTreeMap()
82
+ let label = title
83
+ let type: string | undefined
84
+ let meta: Record<string, unknown> | undefined
85
+ let children: Array<{ id: string; label: string; type?: string }> = []
86
+ if (treeMap) {
87
+ const entry = treeMap.get(docId)
88
+ if (entry) {
89
+ label = entry.label || title
90
+ type = entry.type
91
+ meta = entry.meta
92
+ }
93
+ treeMap.forEach((value: any, id: string) => {
94
+ const v = toPlain(value)
95
+ if (v.parentId === docId) {
96
+ children.push({ id, label: v.label || 'Untitled', type: v.type })
97
+ }
98
+ })
99
+ children.sort((a: any, b: any) => {
100
+ const va = treeMap.get(a.id)
101
+ const vb = treeMap.get(b.id)
102
+ return ((va?.order ?? 0) - (vb?.order ?? 0))
103
+ })
104
+ }
105
+ return printJson({ label, type, meta, markdown, children })
106
+ }
107
+
108
+ return markdown || '(empty document)'
109
+ } catch (error: any) {
110
+ return `Error reading document: ${error.message}`
111
+ }
112
+ },
113
+ })
114
+
115
+ registerCommand({
116
+ name: 'create',
117
+ aliases: ['new'],
118
+ description: 'Create a new document in the tree.',
119
+ usage: 'create label=<name> [parent=<docId>] [type=<pageType>] [content=<markdown>] [--format=json]',
120
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
121
+ if (!conn) return 'Not connected'
122
+
123
+ const label = args.params['label'] || args.params['name'] || args.positional[0]
124
+ if (!label) return 'Missing required parameter: label=<name>'
125
+
126
+ const treeMap = conn.getTreeMap()
127
+ const rootDoc = conn.rootDoc
128
+ if (!treeMap || !rootDoc) return 'Not connected'
129
+
130
+ // Resolve parent
131
+ let parentId: string | null = null
132
+ if (args.params['parent']) {
133
+ parentId = resolveDocument(conn, { id: args.params['parent'], name: args.params['parent'] }, [])
134
+ if (!parentId) parentId = args.params['parent'] // Use raw value as ID
135
+ }
136
+
137
+ const normalizedParent = normalizeRootId(parentId || conn.rootDocId, conn)
138
+ const type = args.params['type']
139
+
140
+ const id = crypto.randomUUID()
141
+ const now = Date.now()
142
+
143
+ // Parse optional meta from params
144
+ const meta: Record<string, unknown> = {}
145
+ if (args.params['icon']) meta['icon'] = args.params['icon']
146
+ if (args.params['color']) meta['color'] = args.params['color']
147
+
148
+ rootDoc.transact(() => {
149
+ treeMap.set(id, {
150
+ label,
151
+ parentId: normalizedParent,
152
+ order: now,
153
+ type,
154
+ meta: Object.keys(meta).length > 0 ? meta : undefined,
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ })
158
+ })
159
+
160
+ // Write content if provided
161
+ const content = args.params['content']
162
+ if (content) {
163
+ try {
164
+ const provider = await conn.getChildProvider(id)
165
+ const fragment = provider.document.getXmlFragment('default')
166
+ const contentToWrite = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
167
+ populateYDocFromMarkdown(fragment, contentToWrite, label)
168
+ } catch (error: any) {
169
+ return `Created ${id} "${label}" but failed to write content: ${error.message}`
170
+ }
171
+ }
172
+
173
+ if (args.params['format'] === 'json') {
174
+ return printJson({ id, label, parentId: normalizedParent, type })
175
+ }
176
+
177
+ return `Created: ${id.slice(0, 8)}… "${label}"${type ? ` (${type})` : ''}`
178
+ },
179
+ })
180
+
181
+ registerCommand({
182
+ name: 'rename',
183
+ description: 'Rename a document.',
184
+ usage: 'rename id=<docId> | name=<oldLabel> label=<newLabel>',
185
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
186
+ if (!conn) return 'Not connected'
187
+
188
+ const docId = resolveDocument(conn, args.params, args.positional)
189
+ if (!docId) return 'Document not found.'
190
+
191
+ const newLabel = args.params['label'] || args.params['to']
192
+ if (!newLabel) return 'Missing required parameter: label=<newLabel>'
193
+
194
+ const treeMap = conn.getTreeMap()
195
+ if (!treeMap) return 'Not connected'
196
+
197
+ const raw = treeMap.get(docId)
198
+ if (!raw) return `Document ${docId} not found.`
199
+
200
+ const entry = toPlain(raw)
201
+ treeMap.set(docId, { ...entry, label: newLabel, updatedAt: Date.now() })
202
+
203
+ return `Renamed to "${newLabel}"`
204
+ },
205
+ })
206
+
207
+ registerCommand({
208
+ name: 'move',
209
+ aliases: ['mv'],
210
+ description: 'Move a document to a new parent.',
211
+ usage: 'move id=<docId> | name=<label> to=<parentId> [order=<n>]',
212
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
213
+ if (!conn) return 'Not connected'
214
+
215
+ const docId = resolveDocument(conn, args.params, args.positional)
216
+ if (!docId) return 'Document not found.'
217
+
218
+ const newParentId = args.params['to'] || args.params['parent']
219
+ if (!newParentId) return 'Missing required parameter: to=<parentId>'
220
+
221
+ const treeMap = conn.getTreeMap()
222
+ if (!treeMap) return 'Not connected'
223
+
224
+ const raw = treeMap.get(docId)
225
+ if (!raw) return `Document ${docId} not found.`
226
+
227
+ const entry = toPlain(raw)
228
+ const order = args.params['order'] ? parseInt(args.params['order'], 10) : Date.now()
229
+ treeMap.set(docId, {
230
+ ...entry,
231
+ parentId: normalizeRootId(newParentId, conn),
232
+ order,
233
+ updatedAt: Date.now(),
234
+ })
235
+
236
+ return `Moved ${docId.slice(0, 8)}… to parent ${newParentId.slice(0, 8)}…`
237
+ },
238
+ })
239
+
240
+ registerCommand({
241
+ name: 'delete',
242
+ aliases: ['rm', 'del'],
243
+ description: 'Soft-delete a document (moves to trash).',
244
+ usage: 'delete id=<docId> | name=<label>',
245
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
246
+ if (!conn) return 'Not connected'
247
+
248
+ const docId = resolveDocument(conn, args.params, args.positional)
249
+ if (!docId) return 'Document not found.'
250
+
251
+ const treeMap = conn.getTreeMap()
252
+ const trashMap = conn.getTrashMap()
253
+ const rootDoc = conn.rootDoc
254
+ if (!treeMap || !trashMap || !rootDoc) return 'Not connected'
255
+
256
+ const entries = readEntries(treeMap)
257
+ const toDelete = [docId, ...descendantsOf(entries, docId).map(e => e.id)]
258
+
259
+ const now = Date.now()
260
+ rootDoc.transact(() => {
261
+ for (const nid of toDelete) {
262
+ const raw = treeMap.get(nid)
263
+ if (!raw) continue
264
+ const entry = toPlain(raw)
265
+ trashMap.set(nid, {
266
+ label: entry.label || 'Untitled',
267
+ parentId: entry.parentId ?? null,
268
+ order: entry.order ?? 0,
269
+ type: entry.type,
270
+ meta: entry.meta,
271
+ deletedAt: now,
272
+ })
273
+ treeMap.delete(nid)
274
+ }
275
+ })
276
+
277
+ return `Deleted ${toDelete.length} document(s)`
278
+ },
279
+ })
280
+
281
+ registerCommand({
282
+ name: 'type',
283
+ description: 'Change the page type view of a document.',
284
+ usage: 'type id=<docId> | name=<label> type=<pageType>',
285
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
286
+ if (!conn) return 'Not connected'
287
+
288
+ const docId = resolveDocument(conn, args.params, args.positional)
289
+ if (!docId) return 'Document not found.'
290
+
291
+ const newType = args.params['type'] || args.positional[1]
292
+ if (!newType) return 'Missing required parameter: type=<pageType>'
293
+
294
+ const treeMap = conn.getTreeMap()
295
+ if (!treeMap) return 'Not connected'
296
+
297
+ const raw = treeMap.get(docId)
298
+ if (!raw) return `Document ${docId} not found.`
299
+
300
+ const entry = toPlain(raw)
301
+ treeMap.set(docId, { ...entry, type: newType, updatedAt: Date.now() })
302
+
303
+ return `Changed type to "${newType}"`
304
+ },
305
+ })
306
+
307
+ registerCommand({
308
+ name: 'write',
309
+ description: 'Write markdown content to a document (replace or append).',
310
+ usage: 'write id=<docId> | name=<label> content=<markdown> [mode=replace|append]',
311
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
312
+ if (!conn) return 'Not connected'
313
+
314
+ const docId = resolveDocument(conn, args.params, args.positional)
315
+ if (!docId) return 'Document not found.'
316
+
317
+ // Content from param, or read from stdin if piped
318
+ let content = args.params['content']
319
+
320
+ if (!content) {
321
+ // Check if stdin is piped (not a TTY)
322
+ if (!process.stdin.isTTY) {
323
+ content = await readStdin()
324
+ }
325
+ }
326
+
327
+ if (!content) return 'Missing required parameter: content=<markdown> (or pipe via stdin)'
328
+
329
+ // Unescape \n and \t
330
+ content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
331
+
332
+ try {
333
+ const writeMode = args.params['mode'] ?? 'replace'
334
+ const provider = await conn.getChildProvider(docId)
335
+ const doc = provider.document
336
+ const fragment = doc.getXmlFragment('default')
337
+
338
+ // Parse optional frontmatter
339
+ const { title, meta, body } = parseFrontmatter(content)
340
+
341
+ // Update tree metadata if frontmatter provided
342
+ if (title || Object.keys(meta).length > 0) {
343
+ const treeMap = conn.getTreeMap()
344
+ const rootDoc = conn.rootDoc
345
+ if (treeMap && rootDoc) {
346
+ const entry = treeMap.get(docId)
347
+ if (entry) {
348
+ const e = toPlain(entry)
349
+ rootDoc.transact(() => {
350
+ const updates: Record<string, unknown> = { ...e, updatedAt: Date.now() }
351
+ if (title) updates.label = title
352
+ if (Object.keys(meta).length > 0) {
353
+ updates.meta = { ...(e.meta ?? {}), ...meta }
354
+ }
355
+ treeMap.set(docId, updates)
356
+ })
357
+ }
358
+ }
359
+ }
360
+
361
+ if (writeMode === 'replace') {
362
+ doc.transact(() => {
363
+ while (fragment.length > 0) {
364
+ fragment.delete(0)
365
+ }
366
+ })
367
+ }
368
+
369
+ const contentToWrite = body || content
370
+ populateYDocFromMarkdown(fragment, contentToWrite, title || 'Untitled')
371
+
372
+ return `Document ${docId.slice(0, 8)}… updated (${writeMode} mode)`
373
+ } catch (error: any) {
374
+ return `Error writing document: ${error.message}`
375
+ }
376
+ },
377
+ })
378
+
379
+ registerCommand({
380
+ name: 'duplicate',
381
+ aliases: ['dup'],
382
+ description: 'Shallow-clone a document.',
383
+ usage: 'duplicate id=<docId> | name=<label>',
384
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
385
+ if (!conn) return 'Not connected'
386
+
387
+ const docId = resolveDocument(conn, args.params, args.positional)
388
+ if (!docId) return 'Document not found.'
389
+
390
+ const treeMap = conn.getTreeMap()
391
+ if (!treeMap) return 'Not connected'
392
+
393
+ const raw = treeMap.get(docId)
394
+ if (!raw) return `Document ${docId} not found.`
395
+
396
+ const entry = toPlain(raw)
397
+ const newId = crypto.randomUUID()
398
+ treeMap.set(newId, {
399
+ ...entry,
400
+ label: (entry.label || 'Untitled') + ' (copy)',
401
+ order: Date.now(),
402
+ })
403
+
404
+ return `Duplicated: ${newId.slice(0, 8)}… "${entry.label} (copy)"`
405
+ },
406
+ })
407
+
408
+ /** Helper to read all of stdin as a string. */
409
+ function readStdin(): Promise<string> {
410
+ return new Promise((resolve, reject) => {
411
+ let data = ''
412
+ process.stdin.setEncoding('utf-8')
413
+ process.stdin.on('data', (chunk: string) => { data += chunk })
414
+ process.stdin.on('end', () => resolve(data))
415
+ process.stdin.on('error', reject)
416
+ // Safety timeout — don't hang forever
417
+ setTimeout(() => resolve(data), 5000)
418
+ })
419
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * File attachment commands: upload, uploads.
3
+ */
4
+ import * as fs from 'node:fs'
5
+ import * as nodePath from 'node:path'
6
+ import { registerCommand } from '../command.ts'
7
+ import type { CLIConnection } from '../connection.ts'
8
+ import type { ParsedArgs } from '../parser.ts'
9
+ import { resolveDocument } from '../resolve.ts'
10
+ import { printJson, printTable, getFormat } from '../output.ts'
11
+
12
+ registerCommand({
13
+ name: 'uploads',
14
+ aliases: ['attachments'],
15
+ description: 'List file attachments for a document.',
16
+ usage: 'uploads id=<docId> | name=<label> [--format=json|tsv] [--total]',
17
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
18
+ if (!conn) return 'Not connected'
19
+
20
+ const docId = resolveDocument(conn, args.params, args.positional)
21
+ if (!docId) return 'Document not found.'
22
+
23
+ try {
24
+ const uploads = await conn.client.listUploads(docId)
25
+
26
+ if (args.flags.has('total')) {
27
+ return String(uploads.length)
28
+ }
29
+
30
+ const format = getFormat(args, 'text')
31
+
32
+ if (format === 'json') {
33
+ return printJson(uploads)
34
+ }
35
+
36
+ if (uploads.length === 0) {
37
+ return 'No attachments.'
38
+ }
39
+
40
+ const rows = uploads.map(u => [
41
+ u.id.slice(0, 8) + '…',
42
+ u.filename,
43
+ u.mime_type ?? '—',
44
+ u.size ? formatBytes(u.size) : '—',
45
+ ])
46
+
47
+ return printTable(rows, ['ID', 'FILENAME', 'TYPE', 'SIZE'])
48
+ } catch (error: any) {
49
+ return `Error: ${error.message}`
50
+ }
51
+ },
52
+ })
53
+
54
+ registerCommand({
55
+ name: 'upload',
56
+ description: 'Upload a local file to a document.',
57
+ usage: 'upload id=<docId> | name=<label> file=<localPath> [filename=<override>]',
58
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
59
+ if (!conn) return 'Not connected'
60
+
61
+ const docId = resolveDocument(conn, args.params, args.positional)
62
+ if (!docId) return 'Document not found.'
63
+
64
+ const filePath = args.params['file'] || args.params['path']
65
+ if (!filePath) return 'Missing required parameter: file=<localPath>'
66
+
67
+ try {
68
+ const resolvedPath = nodePath.resolve(filePath)
69
+ if (!fs.existsSync(resolvedPath)) {
70
+ return `File not found: ${resolvedPath}`
71
+ }
72
+ const data = fs.readFileSync(resolvedPath)
73
+ const filename = args.params['filename'] ?? nodePath.basename(resolvedPath)
74
+ const blob = new Blob([data])
75
+ const result = await conn.client.upload(docId, blob, filename)
76
+
77
+ return `Uploaded: ${filename} (${formatBytes(data.length)}) → ${result.id}`
78
+ } catch (error: any) {
79
+ return `Error: ${error.message}`
80
+ }
81
+ },
82
+ })
83
+
84
+ registerCommand({
85
+ name: 'download',
86
+ description: 'Download a file attachment from a document.',
87
+ usage: 'download id=<docId> | name=<label> upload=<uploadId> output=<localPath>',
88
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
89
+ if (!conn) return 'Not connected'
90
+
91
+ const docId = resolveDocument(conn, args.params, args.positional)
92
+ if (!docId) return 'Document not found.'
93
+
94
+ const uploadId = args.params['upload'] || args.params['uploadId']
95
+ if (!uploadId) return 'Missing required parameter: upload=<uploadId>'
96
+
97
+ const outputPath = args.params['output'] || args.params['to']
98
+ if (!outputPath) return 'Missing required parameter: output=<localPath>'
99
+
100
+ try {
101
+ const blob = await conn.client.getUpload(docId, uploadId)
102
+ const buffer = Buffer.from(await blob.arrayBuffer())
103
+ const resolvedPath = nodePath.resolve(outputPath)
104
+ fs.writeFileSync(resolvedPath, buffer)
105
+
106
+ return `Downloaded to ${resolvedPath} (${formatBytes(buffer.length)})`
107
+ } catch (error: any) {
108
+ return `Error: ${error.message}`
109
+ }
110
+ },
111
+ })
112
+
113
+ function formatBytes(bytes: number): string {
114
+ if (bytes < 1024) return `${bytes} B`
115
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
116
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
117
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * help + version commands.
3
+ */
4
+ import { registerCommand, getAllCommands } from '../command.ts'
5
+ import type { CLIConnection } from '../connection.ts'
6
+ import type { ParsedArgs } from '../parser.ts'
7
+ import { pad } from '../output.ts'
8
+
9
+ registerCommand({
10
+ name: 'help',
11
+ aliases: ['h', '?'],
12
+ description: 'Show list of all available commands.',
13
+ usage: 'help [<command>]',
14
+ async run(_conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
15
+ const target = args.positional[0]
16
+
17
+ if (target) {
18
+ const cmd = getAllCommands().find(c =>
19
+ c.name === target || c.aliases?.includes(target)
20
+ )
21
+ if (!cmd) {
22
+ return `Unknown command: ${target}\n\nRun 'abracadabra help' to see all commands.`
23
+ }
24
+ const aliasStr = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : ''
25
+ let text = `${cmd.name}${aliasStr}\n ${cmd.description}`
26
+ if (cmd.usage) text += `\n\n Usage: abracadabra ${cmd.usage}`
27
+ return text
28
+ }
29
+
30
+ const cmds = getAllCommands()
31
+ const maxLen = Math.max(...cmds.map(c => c.name.length))
32
+
33
+ const lines = [
34
+ 'Abracadabra CLI — interact with CRDT document workspaces from the terminal.',
35
+ '',
36
+ 'Usage: abracadabra <command> [key=value ...] [--flags]',
37
+ '',
38
+ 'Commands:',
39
+ ...cmds.map(c => {
40
+ const aliasStr = c.aliases?.length ? ` (${c.aliases.join(', ')})` : ''
41
+ return ` ${pad(c.name, maxLen + 2)}${c.description}${aliasStr}`
42
+ }),
43
+ '',
44
+ 'Environment:',
45
+ ' ABRA_URL Server URL (required)',
46
+ ' ABRA_KEY_FILE Path to Ed25519 key file',
47
+ ' ABRA_INVITE_CODE Invite code for first-run registration',
48
+ ' ABRA_NAME Display name',
49
+ ' ABRA_COLOR Presence color',
50
+ '',
51
+ 'Run "abracadabra help <command>" for command-specific help.',
52
+ ]
53
+ return lines.join('\n')
54
+ },
55
+ })
56
+
57
+ registerCommand({
58
+ name: 'version',
59
+ aliases: ['v'],
60
+ description: 'Show CLI and server version.',
61
+ async run(conn: CLIConnection | null): Promise<string> {
62
+ const cliVersion = '1.0.0'
63
+ const lines = [`Abracadabra CLI v${cliVersion}`]
64
+ if (conn?.serverInfo) {
65
+ const si = conn.serverInfo
66
+ if (si.version) lines.push(`Server v${si.version}`)
67
+ if (si.name) lines.push(`Server name ${si.name}`)
68
+ if (si.protocol_version) lines.push(`Protocol ${si.protocol_version}`)
69
+ }
70
+ return lines.join('\n')
71
+ },
72
+ })