@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,321 @@
1
+ /**
2
+ * CLIConnection — manages the CRDT connection lifecycle for the CLI.
3
+ *
4
+ * Handles: Ed25519 auth → space discovery → Y.Doc sync → awareness.
5
+ * Reuses the same patterns as AbracadabraMCPServer but without MCP SDK dependency.
6
+ */
7
+ import * as Y from 'yjs'
8
+ import { AbracadabraProvider, AbracadabraClient } from '@abraca/dabra'
9
+ import type { ServerInfo, DocumentMeta, SpaceMeta } from '@abraca/dabra'
10
+ import { loadOrCreateKeypair, signChallenge } from './crypto.ts'
11
+
12
+ export interface CLIConnectionConfig {
13
+ url: string
14
+ name?: string
15
+ color?: string
16
+ inviteCode?: string
17
+ keyFile?: string
18
+ /** If true, suppress all stderr logging */
19
+ quiet?: boolean
20
+ }
21
+
22
+ interface CachedProvider {
23
+ provider: AbracadabraProvider
24
+ lastAccessed: number
25
+ }
26
+
27
+ function waitForSync(
28
+ provider: { on(event: string, cb: () => void): void; off(event: string, cb: () => void): void },
29
+ timeoutMs = 15000
30
+ ): Promise<void> {
31
+ return new Promise<void>((resolve, reject) => {
32
+ const timer = setTimeout(() => {
33
+ provider.off('synced', handler)
34
+ reject(new Error(`Sync timed out after ${timeoutMs}ms`))
35
+ }, timeoutMs)
36
+
37
+ function handler() {
38
+ clearTimeout(timer)
39
+ resolve()
40
+ }
41
+
42
+ provider.on('synced', handler)
43
+ })
44
+ }
45
+
46
+ /** Map a DocumentMeta to SpaceMeta shape for display compatibility. */
47
+ function docToSpaceMeta(doc: DocumentMeta): SpaceMeta {
48
+ const publicAccess = doc.public_access
49
+ let visibility: SpaceMeta['visibility'] = 'private'
50
+ if (publicAccess && publicAccess !== 'none') visibility = 'public'
51
+
52
+ return {
53
+ id: doc.id,
54
+ doc_id: doc.id,
55
+ name: doc.label ?? doc.id,
56
+ description: doc.description ?? null,
57
+ visibility,
58
+ is_hub: doc.is_hub ?? false,
59
+ owner_id: doc.owner_id ?? null,
60
+ created_at: 0,
61
+ updated_at: doc.updated_at ?? 0,
62
+ public_access: publicAccess ?? null,
63
+ }
64
+ }
65
+
66
+ export class CLIConnection {
67
+ readonly config: CLIConnectionConfig
68
+ readonly client: AbracadabraClient
69
+
70
+ private _serverInfo: ServerInfo | null = null
71
+ private _rootDocId: string | null = null
72
+ private _spaces: SpaceMeta[] = []
73
+ private _rootDoc: Y.Doc | null = null
74
+ private _rootProvider: AbracadabraProvider | null = null
75
+ private _userId: string | null = null
76
+ private _signFn: ((challenge: string) => Promise<string>) | null = null
77
+ private childCache = new Map<string, CachedProvider>()
78
+
79
+ constructor(config: CLIConnectionConfig) {
80
+ this.config = config
81
+ this.client = new AbracadabraClient({
82
+ url: config.url,
83
+ persistAuth: false,
84
+ })
85
+ }
86
+
87
+ get displayName(): string {
88
+ return this.config.name || 'CLI User'
89
+ }
90
+
91
+ get displayColor(): string {
92
+ return this.config.color || 'hsl(45, 90%, 55%)'
93
+ }
94
+
95
+ get serverInfo(): ServerInfo | null {
96
+ return this._serverInfo
97
+ }
98
+
99
+ get rootDocId(): string | null {
100
+ return this._rootDocId
101
+ }
102
+
103
+ get spaces(): SpaceMeta[] {
104
+ return this._spaces
105
+ }
106
+
107
+ get rootDoc(): Y.Doc | null {
108
+ return this._rootDoc
109
+ }
110
+
111
+ get rootProvider(): AbracadabraProvider | null {
112
+ return this._rootProvider
113
+ }
114
+
115
+ get userId(): string | null {
116
+ return this._userId
117
+ }
118
+
119
+ private log(msg: string): void {
120
+ if (!this.config.quiet) {
121
+ console.error(`[abracadabra] ${msg}`)
122
+ }
123
+ }
124
+
125
+ /** Connect to the server: authenticate, discover spaces, sync root doc. */
126
+ async connect(): Promise<void> {
127
+ // Step 1: Load or generate Ed25519 keypair
128
+ const keypair = await loadOrCreateKeypair(this.config.keyFile)
129
+ this._userId = keypair.publicKeyB64
130
+ const signFn = (challenge: string) => Promise.resolve(signChallenge(challenge, keypair.privateKey))
131
+ this._signFn = signFn
132
+
133
+ // Step 2: Authenticate via challenge-response (register on first run)
134
+ try {
135
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn)
136
+ } catch (err: any) {
137
+ const status = err?.status ?? err?.response?.status
138
+ if (status === 404 || status === 422) {
139
+ this.log('Key not registered, creating new account...')
140
+ await this.client.registerWithKey({
141
+ publicKey: keypair.publicKeyB64,
142
+ username: this.displayName.replace(/\s+/g, '-').toLowerCase(),
143
+ displayName: this.displayName,
144
+ deviceName: 'CLI',
145
+ inviteCode: this.config.inviteCode,
146
+ })
147
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn)
148
+ } else {
149
+ throw err
150
+ }
151
+ }
152
+ this.log(`Authenticated as ${this.displayName} (${keypair.publicKeyB64.slice(0, 12)}...)`)
153
+
154
+ // Step 3: Discover server info
155
+ this._serverInfo = await this.client.serverInfo()
156
+
157
+ // Step 4: Discover root documents
158
+ let initialDocId: string | null = this._serverInfo.index_doc_id ?? null
159
+ try {
160
+ const roots = await this.client.listRootDocuments()
161
+ this._spaces = roots.map(docToSpaceMeta)
162
+ const hub = roots.find((d: any) => d.is_hub)
163
+ if (hub) {
164
+ initialDocId = hub.id
165
+ this.log(`Hub document: ${hub.label ?? hub.id} (${hub.id})`)
166
+ } else if (roots.length > 0) {
167
+ initialDocId = roots[0].id
168
+ this.log(`No hub, using first root doc: ${roots[0].label ?? roots[0].id}`)
169
+ }
170
+ } catch {
171
+ try {
172
+ this._spaces = await this.client.listSpaces()
173
+ const hub = this._spaces.find(s => s.is_hub)
174
+ if (hub) {
175
+ initialDocId = hub.doc_id
176
+ } else if (this._spaces.length > 0) {
177
+ initialDocId = this._spaces[0].doc_id
178
+ }
179
+ } catch {
180
+ this.log('Neither /docs?root=true nor /spaces available, using index_doc_id')
181
+ }
182
+ }
183
+
184
+ if (!initialDocId) {
185
+ throw new Error('No entry point found: server has neither spaces nor index_doc_id configured.')
186
+ }
187
+
188
+ this._rootDocId = initialDocId
189
+
190
+ // Step 5: Connect provider and sync
191
+ const doc = new Y.Doc({ guid: initialDocId })
192
+ const provider = new AbracadabraProvider({
193
+ name: initialDocId,
194
+ document: doc,
195
+ client: this.client,
196
+ disableOfflineStore: true,
197
+ subdocLoading: 'lazy',
198
+ })
199
+
200
+ await waitForSync(provider)
201
+
202
+ provider.awareness.setLocalStateField('user', {
203
+ name: this.displayName,
204
+ color: this.displayColor,
205
+ publicKey: this._userId,
206
+ isAgent: false,
207
+ })
208
+ provider.awareness.setLocalStateField('status', null)
209
+
210
+ this._rootDoc = doc
211
+ this._rootProvider = provider
212
+ this.log('Connected and synced')
213
+ }
214
+
215
+ /** Switch active space to a different root document. */
216
+ async switchSpace(docId: string): Promise<void> {
217
+ // Destroy existing child providers
218
+ for (const [, cached] of this.childCache) {
219
+ cached.provider.destroy()
220
+ }
221
+ this.childCache.clear()
222
+
223
+ // Destroy current root provider
224
+ if (this._rootProvider) {
225
+ this._rootProvider.destroy()
226
+ this._rootProvider = null
227
+ }
228
+ this._rootDoc = null
229
+
230
+ // Re-authenticate if JWT has expired
231
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
232
+ await this.client.loginWithKey(this._userId, this._signFn)
233
+ }
234
+
235
+ // Connect to new space
236
+ const doc = new Y.Doc({ guid: docId })
237
+ const provider = new AbracadabraProvider({
238
+ name: docId,
239
+ document: doc,
240
+ client: this.client,
241
+ disableOfflineStore: true,
242
+ subdocLoading: 'lazy',
243
+ })
244
+
245
+ await waitForSync(provider)
246
+
247
+ provider.awareness.setLocalStateField('user', {
248
+ name: this.displayName,
249
+ color: this.displayColor,
250
+ publicKey: this._userId,
251
+ isAgent: false,
252
+ })
253
+
254
+ this._rootDoc = doc
255
+ this._rootProvider = provider
256
+ this._rootDocId = docId
257
+ this.log(`Switched to space ${docId}`)
258
+ }
259
+
260
+ /** Get the root doc-tree Y.Map. */
261
+ getTreeMap(): Y.Map<any> | null {
262
+ return this._rootDoc?.getMap('doc-tree') ?? null
263
+ }
264
+
265
+ /** Get the root doc-trash Y.Map. */
266
+ getTrashMap(): Y.Map<any> | null {
267
+ return this._rootDoc?.getMap('doc-trash') ?? null
268
+ }
269
+
270
+ /** Get or create a child provider for a document. */
271
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
272
+ const cached = this.childCache.get(docId)
273
+ if (cached) {
274
+ cached.lastAccessed = Date.now()
275
+ return cached.provider
276
+ }
277
+
278
+ if (!this._rootProvider) {
279
+ throw new Error('Not connected. Call connect() first.')
280
+ }
281
+
282
+ // Re-authenticate if JWT has expired
283
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
284
+ await this.client.loginWithKey(this._userId, this._signFn)
285
+ }
286
+
287
+ const childProvider = await this._rootProvider.loadChild(docId)
288
+ await waitForSync(childProvider)
289
+
290
+ childProvider.awareness.setLocalStateField('user', {
291
+ name: this.displayName,
292
+ color: this.displayColor,
293
+ publicKey: this._userId,
294
+ isAgent: false,
295
+ })
296
+
297
+ this.childCache.set(docId, {
298
+ provider: childProvider,
299
+ lastAccessed: Date.now(),
300
+ })
301
+
302
+ return childProvider
303
+ }
304
+
305
+ /** Graceful shutdown. */
306
+ async destroy(): Promise<void> {
307
+ for (const [, cached] of this.childCache) {
308
+ cached.provider.destroy()
309
+ }
310
+ this.childCache.clear()
311
+
312
+ if (this._rootProvider) {
313
+ this._rootProvider.awareness.setLocalStateField('status', null)
314
+ this._rootProvider.destroy()
315
+ this._rootProvider = null
316
+ }
317
+ this._rootDoc = null
318
+
319
+ this.log('Disconnected')
320
+ }
321
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Re-export converters from @abraca/mcp for markdown ↔ Y.js conversion.
3
+ *
4
+ * These modules only depend on yjs (a peer dep of this package),
5
+ * not on the MCP SDK, so importing via the source export condition is safe.
6
+ */
7
+ // @ts-ignore — resolve via monorepo source condition
8
+ export { yjsToMarkdown } from '../../mcp/src/converters/yjsToMarkdown.ts'
9
+ // @ts-ignore — resolve via monorepo source condition
10
+ export { populateYDocFromMarkdown, parseFrontmatter } from '../../mcp/src/converters/markdownToYjs.ts'
@@ -0,0 +1,3 @@
1
+ // Re-export from @abraca/mcp converters (only depends on yjs, not MCP SDK)
2
+ // @ts-ignore — resolve via monorepo relative path
3
+ export { populateYDocFromMarkdown, parseFrontmatter } from '../../../mcp/src/converters/markdownToYjs.ts'
@@ -0,0 +1,3 @@
1
+ // Re-export converter types from @abraca/mcp
2
+ // @ts-ignore — resolve via monorepo relative path
3
+ export type { PageMeta, TreeEntry, UserMetaField } from '../../../mcp/src/converters/types.ts'
@@ -0,0 +1,3 @@
1
+ // Re-export from @abraca/mcp converters (only depends on yjs, not MCP SDK)
2
+ // @ts-ignore — resolve via monorepo relative path
3
+ export { yjsToMarkdown } from '../../../mcp/src/converters/yjsToMarkdown.ts'
package/src/crypto.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Ed25519 key generation, persistence, and challenge signing for CLI auth.
3
+ * Mirrors @abraca/mcp/src/crypto.ts — standalone to avoid MCP SDK dependency.
4
+ */
5
+ import * as ed from '@noble/ed25519'
6
+ import { sha512 } from '@noble/hashes/sha2'
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
8
+
9
+ // @noble/ed25519 v2+ requires explicit hash configuration
10
+ ed.etc.sha512Sync = (...msgs: Uint8Array[]) => sha512(ed.etc.concatBytes(...msgs))
11
+ import { existsSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join, dirname } from 'node:path'
14
+
15
+ const DEFAULT_KEY_PATH = join(homedir(), '.abracadabra', 'cli.key')
16
+
17
+ function toBase64url(bytes: Uint8Array): string {
18
+ return Buffer.from(bytes).toString('base64url')
19
+ }
20
+
21
+ function fromBase64url(b64: string): Uint8Array {
22
+ return new Uint8Array(Buffer.from(b64, 'base64url'))
23
+ }
24
+
25
+ export interface CLIKeypair {
26
+ privateKey: Uint8Array
27
+ publicKeyB64: string
28
+ }
29
+
30
+ /**
31
+ * Load an existing Ed25519 keypair from disk, or generate and persist a new one.
32
+ * The file stores the raw 32-byte private key seed.
33
+ */
34
+ export async function loadOrCreateKeypair(keyPath?: string): Promise<CLIKeypair> {
35
+ const path = keyPath || DEFAULT_KEY_PATH
36
+
37
+ if (existsSync(path)) {
38
+ const seed = await readFile(path)
39
+ if (seed.length !== 32) {
40
+ throw new Error(`Invalid key file at ${path}: expected 32 bytes, got ${seed.length}`)
41
+ }
42
+ const privateKey = new Uint8Array(seed)
43
+ const publicKey = ed.getPublicKey(privateKey)
44
+ return { privateKey, publicKeyB64: toBase64url(publicKey) }
45
+ }
46
+
47
+ // Generate new keypair
48
+ const privateKey = ed.utils.randomPrivateKey()
49
+ const publicKey = ed.getPublicKey(privateKey)
50
+
51
+ // Ensure directory exists and write seed with restricted permissions
52
+ const dir = dirname(path)
53
+ if (!existsSync(dir)) {
54
+ await mkdir(dir, { recursive: true, mode: 0o700 })
55
+ }
56
+ await writeFile(path, Buffer.from(privateKey), { mode: 0o600 })
57
+
58
+ console.error(`[abracadabra] Generated new keypair at ${path}`)
59
+ console.error(`[abracadabra] Public key: ${toBase64url(publicKey)}`)
60
+
61
+ return { privateKey, publicKeyB64: toBase64url(publicKey) }
62
+ }
63
+
64
+ /** Sign a base64url challenge with the private key; returns base64url signature. */
65
+ export function signChallenge(challengeB64: string, privateKey: Uint8Array): string {
66
+ const challenge = fromBase64url(challengeB64)
67
+ const sig = ed.sign(challenge, privateKey)
68
+ return toBase64url(sig)
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Abracadabra CLI — interact with CRDT document workspaces from the terminal.
4
+ *
5
+ * Usage:
6
+ * abracadabra <command> [key=value ...] [--flags]
7
+ *
8
+ * Environment:
9
+ * ABRA_URL Server URL (required)
10
+ * ABRA_KEY_FILE Path to Ed25519 key file (~/.abracadabra/cli.key)
11
+ * ABRA_INVITE_CODE Invite code for first-run registration
12
+ * ABRA_NAME Display name (default: CLI User)
13
+ * ABRA_COLOR Presence color (default: hsl(45, 90%, 55%))
14
+ */
15
+ import { parseArgs } from './parser.ts'
16
+ import { CLIConnection } from './connection.ts'
17
+ import { getCommand } from './command.ts'
18
+
19
+ // ── Register all commands ────────────────────────────────────────────────────
20
+ // Side-effect imports: each module calls registerCommand() at module scope.
21
+ import './commands/help.ts'
22
+ import './commands/spaces.ts'
23
+ import './commands/tree.ts'
24
+ import './commands/documents.ts'
25
+ import './commands/content.ts'
26
+ import './commands/meta.ts'
27
+ import './commands/awareness.ts'
28
+ import './commands/files.ts'
29
+ import './commands/permissions.ts'
30
+
31
+ // ── Commands that don't require a connection ─────────────────────────────────
32
+ const NO_CONNECT_COMMANDS = new Set(['help', 'h', '?', 'version', 'v'])
33
+
34
+ async function main() {
35
+ const args = parseArgs(process.argv)
36
+
37
+ // Resolve command
38
+ const cmd = getCommand(args.command)
39
+ if (!cmd) {
40
+ console.error(`Unknown command: "${args.command}"`)
41
+ console.error('Run "abracadabra help" to see all commands.')
42
+ process.exit(1)
43
+ }
44
+
45
+ // Commands that don't need a server connection
46
+ if (NO_CONNECT_COMMANDS.has(args.command)) {
47
+ // Try to connect for extra info (version), but don't fail
48
+ let conn: CLIConnection | null = null
49
+ const url = process.env['ABRA_URL']
50
+ if (url && args.command === 'version') {
51
+ try {
52
+ conn = new CLIConnection({
53
+ url,
54
+ name: process.env['ABRA_NAME'],
55
+ color: process.env['ABRA_COLOR'],
56
+ inviteCode: process.env['ABRA_INVITE_CODE'],
57
+ keyFile: process.env['ABRA_KEY_FILE'],
58
+ quiet: true,
59
+ })
60
+ await conn.connect()
61
+ } catch {
62
+ // Silently fall back to local-only version info
63
+ }
64
+ }
65
+
66
+ try {
67
+ const output = await cmd.run(conn, args)
68
+ if (output) console.log(output)
69
+ } finally {
70
+ if (conn) await conn.destroy().catch(() => {})
71
+ }
72
+ return
73
+ }
74
+
75
+ // All other commands require ABRA_URL
76
+ const url = process.env['ABRA_URL']
77
+ if (!url) {
78
+ console.error('Error: ABRA_URL environment variable is required.')
79
+ console.error('')
80
+ console.error('Set it to your Abracadabra server URL:')
81
+ console.error(' export ABRA_URL=https://your-server.example.com')
82
+ console.error('')
83
+ console.error('Run "abracadabra help" for more info.')
84
+ process.exit(1)
85
+ }
86
+
87
+ const conn = new CLIConnection({
88
+ url,
89
+ name: process.env['ABRA_NAME'],
90
+ color: process.env['ABRA_COLOR'],
91
+ inviteCode: process.env['ABRA_INVITE_CODE'],
92
+ keyFile: process.env['ABRA_KEY_FILE'],
93
+ quiet: args.flags.has('quiet') || args.flags.has('q'),
94
+ })
95
+
96
+ try {
97
+ await conn.connect()
98
+ const output = await cmd.run(conn, args)
99
+ if (output) console.log(output)
100
+ } catch (error: any) {
101
+ console.error(`Error: ${error.message}`)
102
+ if (args.flags.has('verbose')) {
103
+ console.error(error.stack)
104
+ }
105
+ process.exit(1)
106
+ } finally {
107
+ await conn.destroy().catch(() => {})
108
+ }
109
+ }
110
+
111
+ main().catch((err) => {
112
+ console.error(`Fatal: ${err.message ?? err}`)
113
+ process.exit(1)
114
+ })
package/src/output.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Output formatting utilities for Abracadabra CLI.
3
+ *
4
+ * Supports JSON, TSV, tree, and compact text output.
5
+ */
6
+
7
+ import type { ParsedArgs } from './parser.ts'
8
+
9
+ export type OutputFormat = 'json' | 'tsv' | 'tree' | 'text' | 'md'
10
+
11
+ export function getFormat(args: ParsedArgs, defaultFormat: OutputFormat = 'text'): OutputFormat {
12
+ const f = args.params['format']
13
+ if (f && ['json', 'tsv', 'tree', 'text', 'md'].includes(f)) {
14
+ return f as OutputFormat
15
+ }
16
+ return defaultFormat
17
+ }
18
+
19
+ /** Print a JSON-serializable value. */
20
+ export function printJson(data: unknown): string {
21
+ return JSON.stringify(data, null, 2)
22
+ }
23
+
24
+ /** Print a list of objects as TSV. */
25
+ export function printTsv(rows: Record<string, unknown>[], columns?: string[]): string {
26
+ if (rows.length === 0) return ''
27
+ const cols = columns ?? Object.keys(rows[0])
28
+ const header = cols.map(c => c.toUpperCase()).join('\t')
29
+ const body = rows.map(row =>
30
+ cols.map(c => {
31
+ const val = row[c]
32
+ if (val == null) return '—'
33
+ if (typeof val === 'object') return JSON.stringify(val)
34
+ return String(val)
35
+ }).join('\t')
36
+ ).join('\n')
37
+ return header + '\n' + body
38
+ }
39
+
40
+ /** Format a tree of nested items with box-drawing characters. */
41
+ export function printTree(
42
+ items: TreeNode[],
43
+ opts: { indent?: string, isLast?: boolean[] } = {}
44
+ ): string {
45
+ const lines: string[] = []
46
+ for (let i = 0; i < items.length; i++) {
47
+ const item = items[i]
48
+ const isLast = i === items.length - 1
49
+ const prefix = (opts.isLast ?? [])
50
+ .map(last => last ? ' ' : '│ ')
51
+ .join('')
52
+ const connector = isLast ? '└── ' : '├── '
53
+ const typeSuffix = item.type ? ` (${item.type})` : ''
54
+ lines.push(`${prefix}${connector}${item.label}${typeSuffix}`)
55
+ if (item.children && item.children.length > 0) {
56
+ lines.push(printTree(item.children, {
57
+ isLast: [...(opts.isLast ?? []), isLast],
58
+ }))
59
+ }
60
+ }
61
+ return lines.join('\n')
62
+ }
63
+
64
+ export interface TreeNode {
65
+ label: string
66
+ type?: string
67
+ children?: TreeNode[]
68
+ }
69
+
70
+ /** Format relative time (e.g. "2m ago", "1h ago"). */
71
+ export function relativeTime(timestamp: number | undefined | null): string {
72
+ if (!timestamp) return '—'
73
+ const diff = Date.now() - timestamp
74
+ if (diff < 0) return 'just now'
75
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`
76
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
77
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
78
+ return `${Math.floor(diff / 86_400_000)}d ago`
79
+ }
80
+
81
+ /** Truncate a string to a max length, appending '…' if truncated. */
82
+ export function truncate(str: string, max: number): string {
83
+ if (str.length <= max) return str
84
+ return str.slice(0, max - 1) + '…'
85
+ }
86
+
87
+ /** Pad a string to a fixed width. */
88
+ export function pad(str: string, width: number): string {
89
+ if (str.length >= width) return str.slice(0, width)
90
+ return str + ' '.repeat(width - str.length)
91
+ }
92
+
93
+ /** Format a table with auto-column widths. */
94
+ export function printTable(rows: string[][], headers?: string[]): string {
95
+ const allRows = headers ? [headers, ...rows] : rows
96
+ if (allRows.length === 0) return ''
97
+
98
+ const colCount = Math.max(...allRows.map(r => r.length))
99
+ const widths: number[] = []
100
+ for (let c = 0; c < colCount; c++) {
101
+ widths[c] = Math.max(...allRows.map(r => (r[c] ?? '').length))
102
+ }
103
+
104
+ const formatted = allRows.map(row =>
105
+ row.map((cell, i) => pad(cell, widths[i])).join(' ')
106
+ )
107
+
108
+ return formatted.join('\n')
109
+ }