@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.
- package/dist/abracadabra-cli.cjs +3301 -0
- package/dist/abracadabra-cli.cjs.map +1 -0
- package/dist/abracadabra-cli.esm.js +3272 -0
- package/dist/abracadabra-cli.esm.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/package.json +39 -0
- package/src/command.ts +29 -0
- package/src/commands/awareness.ts +122 -0
- package/src/commands/content.ts +135 -0
- package/src/commands/documents.ts +419 -0
- package/src/commands/files.ts +117 -0
- package/src/commands/help.ts +72 -0
- package/src/commands/meta.ts +149 -0
- package/src/commands/permissions.ts +94 -0
- package/src/commands/spaces.ts +100 -0
- package/src/commands/tree.ts +158 -0
- package/src/connection.ts +321 -0
- package/src/converters/index.ts +10 -0
- package/src/converters/markdownToYjs.ts +3 -0
- package/src/converters/types.ts +3 -0
- package/src/converters/yjsToMarkdown.ts +3 -0
- package/src/crypto.ts +69 -0
- package/src/index.ts +114 -0
- package/src/output.ts +109 -0
- package/src/parser.ts +80 -0
- package/src/resolve.ts +174 -0
|
@@ -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'
|
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
|
+
}
|