@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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@abraca/cli",
3
+ "version": "1.5.0",
4
+ "description": "CLI for Abracadabra — interact with CRDT document workspaces from the terminal",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/abracadabra-cli.cjs",
8
+ "module": "dist/abracadabra-cli.esm.js",
9
+ "types": "dist/index.d.ts",
10
+ "bin": {
11
+ "abracadabra": "./dist/abracadabra-cli.esm.js"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ "source": {
18
+ "import": "./src/index.ts"
19
+ },
20
+ "default": {
21
+ "import": "./dist/abracadabra-cli.esm.js",
22
+ "require": "./dist/abracadabra-cli.cjs",
23
+ "types": "./dist/index.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "src",
28
+ "dist"
29
+ ],
30
+ "dependencies": {
31
+ "@noble/ed25519": "^2.3.0",
32
+ "@noble/hashes": "^1.8.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@abraca/dabra": ">=1.0.0",
36
+ "y-protocols": "^1.0.6",
37
+ "yjs": "^13.6.8"
38
+ }
39
+ }
package/src/command.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Command interface and registry.
3
+ */
4
+ import type { CLIConnection } from './connection.ts'
5
+ import type { ParsedArgs } from './parser.ts'
6
+
7
+ export interface Command {
8
+ name: string
9
+ aliases?: string[]
10
+ description: string
11
+ usage?: string
12
+ run(conn: CLIConnection | null, args: ParsedArgs): Promise<string>
13
+ }
14
+
15
+ const commands: Command[] = []
16
+
17
+ export function registerCommand(cmd: Command): void {
18
+ commands.push(cmd)
19
+ }
20
+
21
+ export function getCommand(name: string): Command | null {
22
+ return commands.find(c =>
23
+ c.name === name || c.aliases?.includes(name)
24
+ ) ?? null
25
+ }
26
+
27
+ export function getAllCommands(): Command[] {
28
+ return [...commands]
29
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Awareness commands: who, status, chat.
3
+ */
4
+ import { registerCommand } from '../command.ts'
5
+ import type { CLIConnection } from '../connection.ts'
6
+ import type { ParsedArgs } from '../parser.ts'
7
+ import { printJson, printTable, getFormat } from '../output.ts'
8
+
9
+ registerCommand({
10
+ name: 'who',
11
+ aliases: ['users', 'online'],
12
+ description: 'List connected users and their awareness state.',
13
+ usage: 'who [id=<docId>] [--format=json|tsv]',
14
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
15
+ if (!conn) return 'Not connected'
16
+
17
+ let awareness
18
+ if (args.params['id']) {
19
+ try {
20
+ const provider = await conn.getChildProvider(args.params['id'])
21
+ awareness = provider.awareness
22
+ } catch (error: any) {
23
+ return `Error: ${error.message}`
24
+ }
25
+ } else {
26
+ const rootProvider = conn.rootProvider
27
+ if (!rootProvider) return 'Not connected'
28
+ awareness = rootProvider.awareness
29
+ }
30
+
31
+ const states = awareness.getStates()
32
+ const selfId = awareness.clientID
33
+ const users: Array<{
34
+ name: string
35
+ status: string
36
+ docId: string
37
+ isYou: boolean
38
+ isAgent: boolean
39
+ }> = []
40
+
41
+ for (const [clientId, state] of states) {
42
+ const user = (state as Record<string, any>)['user']
43
+ if (!user) continue
44
+ users.push({
45
+ name: user.name ?? 'Unknown',
46
+ status: (state as Record<string, any>)['status'] ?? '—',
47
+ docId: (state as Record<string, any>)['docId'] ?? '—',
48
+ isYou: clientId === selfId,
49
+ isAgent: user.isAgent ?? false,
50
+ })
51
+ }
52
+
53
+ const format = getFormat(args, 'text')
54
+
55
+ if (format === 'json') {
56
+ return printJson(users)
57
+ }
58
+
59
+ if (users.length === 0) {
60
+ return 'No users connected.'
61
+ }
62
+
63
+ const rows = users.map(u => [
64
+ u.name + (u.isYou ? ' (you)' : '') + (u.isAgent ? ' 🤖' : ''),
65
+ u.status,
66
+ u.docId === '—' ? '—' : u.docId.slice(0, 8) + '…',
67
+ ])
68
+
69
+ return printTable(rows, ['NAME', 'STATUS', 'DOCUMENT'])
70
+ },
71
+ })
72
+
73
+ registerCommand({
74
+ name: 'status',
75
+ description: 'Set your presence status.',
76
+ usage: 'status <text> | status --clear',
77
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
78
+ if (!conn) return 'Not connected'
79
+
80
+ const rootProvider = conn.rootProvider
81
+ if (!rootProvider) return 'Not connected'
82
+
83
+ if (args.flags.has('clear')) {
84
+ rootProvider.awareness.setLocalStateField('status', null)
85
+ return 'Status cleared.'
86
+ }
87
+
88
+ const status = args.params['text'] || args.positional[0]
89
+ if (!status) return 'Missing status text. Use: status <text> or status --clear'
90
+
91
+ rootProvider.awareness.setLocalStateField('status', status)
92
+ return `Status set to "${status}"`
93
+ },
94
+ })
95
+
96
+ registerCommand({
97
+ name: 'chat',
98
+ aliases: ['send'],
99
+ description: 'Send a chat message to a channel.',
100
+ usage: 'chat channel=<group:docId> text=<message>',
101
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
102
+ if (!conn) return 'Not connected'
103
+
104
+ const rootProvider = conn.rootProvider
105
+ if (!rootProvider) return 'Not connected'
106
+
107
+ const channel = args.params['channel']
108
+ if (!channel) return 'Missing required parameter: channel=<group:docId>'
109
+
110
+ const text = args.params['text'] || args.params['content'] || args.positional[0]
111
+ if (!text) return 'Missing required parameter: text=<message>'
112
+
113
+ rootProvider.sendStateless(JSON.stringify({
114
+ type: 'chat:send',
115
+ channel,
116
+ content: text,
117
+ sender_name: conn.displayName,
118
+ }))
119
+
120
+ return `Sent to ${channel}`
121
+ },
122
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Content commands: append, prepend, wc, export.
3
+ */
4
+ import * as fs from 'node:fs'
5
+ import * as path 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 { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
11
+ import { populateYDocFromMarkdown } from '../converters/markdownToYjs.ts'
12
+
13
+ registerCommand({
14
+ name: 'append',
15
+ description: 'Append content to a document.',
16
+ usage: 'append id=<docId> | name=<label> content=<text> [--inline]',
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
+ const content = args.params['content']
24
+ if (!content) return 'Missing required parameter: content=<text>'
25
+
26
+ try {
27
+ const provider = await conn.getChildProvider(docId)
28
+ const fragment = provider.document.getXmlFragment('default')
29
+ const text = args.flags.has('inline')
30
+ ? content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
31
+ : '\n' + content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
32
+ populateYDocFromMarkdown(fragment, text, '')
33
+
34
+ return `Appended to ${docId.slice(0, 8)}…`
35
+ } catch (error: any) {
36
+ return `Error: ${error.message}`
37
+ }
38
+ },
39
+ })
40
+
41
+ registerCommand({
42
+ name: 'prepend',
43
+ description: 'Prepend content to a document (after frontmatter).',
44
+ usage: 'prepend id=<docId> | name=<label> content=<text> [--inline]',
45
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
46
+ if (!conn) return 'Not connected'
47
+
48
+ const docId = resolveDocument(conn, args.params, args.positional)
49
+ if (!docId) return 'Document not found.'
50
+
51
+ const content = args.params['content']
52
+ if (!content) return 'Missing required parameter: content=<text>'
53
+
54
+ try {
55
+ const provider = await conn.getChildProvider(docId)
56
+ const doc = provider.document
57
+ const fragment = doc.getXmlFragment('default')
58
+
59
+ // Read existing, prepend, rewrite
60
+ const { markdown: existing } = yjsToMarkdown(fragment)
61
+ const text = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
62
+ const combined = args.flags.has('inline')
63
+ ? text + existing
64
+ : text + '\n' + existing
65
+
66
+ doc.transact(() => {
67
+ while (fragment.length > 0) {
68
+ fragment.delete(0)
69
+ }
70
+ })
71
+ populateYDocFromMarkdown(fragment, combined, '')
72
+
73
+ return `Prepended to ${docId.slice(0, 8)}…`
74
+ } catch (error: any) {
75
+ return `Error: ${error.message}`
76
+ }
77
+ },
78
+ })
79
+
80
+ registerCommand({
81
+ name: 'wc',
82
+ aliases: ['wordcount'],
83
+ description: 'Count words and characters in a document.',
84
+ usage: 'wc id=<docId> | name=<label> [--words] [--characters]',
85
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
86
+ if (!conn) return 'Not connected'
87
+
88
+ const docId = resolveDocument(conn, args.params, args.positional)
89
+ if (!docId) return 'Document not found.'
90
+
91
+ try {
92
+ const provider = await conn.getChildProvider(docId)
93
+ const fragment = provider.document.getXmlFragment('default')
94
+ const { markdown } = yjsToMarkdown(fragment)
95
+
96
+ const words = markdown.split(/\s+/).filter(Boolean).length
97
+ const chars = markdown.length
98
+
99
+ if (args.flags.has('words')) return String(words)
100
+ if (args.flags.has('characters')) return String(chars)
101
+
102
+ return `Words: ${words}\nCharacters: ${chars}`
103
+ } catch (error: any) {
104
+ return `Error: ${error.message}`
105
+ }
106
+ },
107
+ })
108
+
109
+ registerCommand({
110
+ name: 'export',
111
+ description: 'Export a document as markdown to a local file.',
112
+ usage: 'export id=<docId> | name=<label> output=<filepath>',
113
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
114
+ if (!conn) return 'Not connected'
115
+
116
+ const docId = resolveDocument(conn, args.params, args.positional)
117
+ if (!docId) return 'Document not found.'
118
+
119
+ const outputPath = args.params['output'] || args.params['to'] || args.params['path']
120
+ if (!outputPath) return 'Missing required parameter: output=<filepath>'
121
+
122
+ try {
123
+ const provider = await conn.getChildProvider(docId)
124
+ const fragment = provider.document.getXmlFragment('default')
125
+ const { title, markdown } = yjsToMarkdown(fragment)
126
+
127
+ const resolvedPath = path.resolve(outputPath)
128
+ fs.writeFileSync(resolvedPath, markdown, 'utf-8')
129
+
130
+ return `Exported "${title || 'document'}" to ${resolvedPath} (${Buffer.byteLength(markdown)} bytes)`
131
+ } catch (error: any) {
132
+ return `Error: ${error.message}`
133
+ }
134
+ },
135
+ })