@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,149 @@
1
+ /**
2
+ * Metadata commands: meta, tags.
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, resolveDocument } from '../resolve.ts'
9
+ import { printJson, printTable, getFormat } from '../output.ts'
10
+
11
+ /** Safely read a tree map value. */
12
+ function toPlain(val: any): any {
13
+ return val instanceof Y.Map ? val.toJSON() : val
14
+ }
15
+
16
+ registerCommand({
17
+ name: 'meta',
18
+ aliases: ['metadata'],
19
+ description: 'Get or set document metadata.',
20
+ usage: 'meta id=<docId> | name=<label> [key=value ...] [--format=json]',
21
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
22
+ if (!conn) return 'Not connected'
23
+
24
+ const docId = resolveDocument(conn, args.params, args.positional)
25
+ if (!docId) return 'Document not found.'
26
+
27
+ const treeMap = conn.getTreeMap()
28
+ if (!treeMap) return 'Not connected'
29
+
30
+ const raw = treeMap.get(docId)
31
+ if (!raw) return `Document ${docId} not found.`
32
+
33
+ const entry = toPlain(raw)
34
+
35
+ // Collect meta updates from remaining params
36
+ const metaKeys = new Set(['icon', 'color', 'priority', 'status', 'checked', 'rating',
37
+ 'tags', 'dateStart', 'dateEnd', 'datetimeStart', 'datetimeEnd', 'allDay',
38
+ 'url', 'email', 'phone', 'number', 'unit', 'subtitle', 'note',
39
+ 'taskProgress', 'coverUploadId', 'geoType', 'geoLat', 'geoLng',
40
+ 'deskX', 'deskY', 'deskMode', 'spShape', 'spColor', 'chartType',
41
+ 'kanbanColumnWidth', 'galleryColumns', 'calendarView', 'tableMode',
42
+ ])
43
+
44
+ const updates: Record<string, unknown> = {}
45
+ let hasUpdates = false
46
+
47
+ for (const [key, value] of Object.entries(args.params)) {
48
+ if (metaKeys.has(key)) {
49
+ // Parse special types
50
+ if (value === 'null') {
51
+ updates[key] = null
52
+ } else if (value === 'true') {
53
+ updates[key] = true
54
+ } else if (value === 'false') {
55
+ updates[key] = false
56
+ } else if (['priority', 'rating', 'number', 'taskProgress', 'geoLat', 'geoLng',
57
+ 'deskX', 'deskY', 'galleryColumns'].includes(key)) {
58
+ updates[key] = parseFloat(value)
59
+ } else if (key === 'tags') {
60
+ updates[key] = value.split(',').map(s => s.trim())
61
+ } else {
62
+ updates[key] = value
63
+ }
64
+ hasUpdates = true
65
+ }
66
+ }
67
+
68
+ if (hasUpdates) {
69
+ // Write mode: merge updates into existing meta
70
+ treeMap.set(docId, {
71
+ ...entry,
72
+ meta: { ...(entry.meta ?? {}), ...updates },
73
+ updatedAt: Date.now(),
74
+ })
75
+ return `Metadata updated for ${docId.slice(0, 8)}…`
76
+ }
77
+
78
+ // Read mode: display current metadata
79
+ const meta = entry.meta ?? {}
80
+ if (args.params['format'] === 'json') {
81
+ return printJson({ id: docId, label: entry.label, type: entry.type, meta })
82
+ }
83
+
84
+ if (Object.keys(meta).length === 0) {
85
+ return `Document "${entry.label}" has no metadata.`
86
+ }
87
+
88
+ const lines = Object.entries(meta).map(([k, v]) =>
89
+ `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`
90
+ )
91
+ return lines.join('\n')
92
+ },
93
+ })
94
+
95
+ registerCommand({
96
+ name: 'tags',
97
+ description: 'List tags aggregated from document metadata.',
98
+ usage: 'tags [id=<docId>] [--counts] [--total] [--format=json|tsv]',
99
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
100
+ if (!conn) return 'Not connected'
101
+ const treeMap = conn.getTreeMap()
102
+ if (!treeMap) return 'Not connected'
103
+
104
+ // If a document is specified, show its tags
105
+ const docId = resolveDocument(conn, args.params, args.positional)
106
+ if (docId) {
107
+ const raw = treeMap.get(docId)
108
+ if (!raw) return `Document ${docId} not found.`
109
+ const entry = toPlain(raw)
110
+ const tags: string[] = entry.meta?.tags ?? []
111
+ if (tags.length === 0) return 'No tags.'
112
+ return tags.join('\n')
113
+ }
114
+
115
+ // Aggregate tags from all documents
116
+ const entries = readEntries(treeMap)
117
+ const tagCounts = new Map<string, number>()
118
+
119
+ for (const entry of entries) {
120
+ const tags: string[] = (entry.meta as any)?.tags ?? []
121
+ for (const tag of tags) {
122
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
123
+ }
124
+ }
125
+
126
+ if (tagCounts.size === 0) return 'No tags found.'
127
+
128
+ if (args.flags.has('total')) {
129
+ return String(tagCounts.size)
130
+ }
131
+
132
+ const sorted = [...tagCounts.entries()].sort((a, b) => {
133
+ if (args.params['sort'] === 'count') return b[1] - a[1]
134
+ return a[0].localeCompare(b[0])
135
+ })
136
+
137
+ const format = getFormat(args, 'text')
138
+
139
+ if (format === 'json') {
140
+ return printJson(sorted.map(([tag, count]) => ({ tag, count })))
141
+ }
142
+
143
+ if (args.flags.has('counts')) {
144
+ return sorted.map(([tag, count]) => `${tag}\t${count}`).join('\n')
145
+ }
146
+
147
+ return sorted.map(([tag]) => tag).join('\n')
148
+ },
149
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Permission commands: permissions.
3
+ */
4
+ import { registerCommand } from '../command.ts'
5
+ import type { CLIConnection } from '../connection.ts'
6
+ import type { ParsedArgs } from '../parser.ts'
7
+ import { resolveDocument } from '../resolve.ts'
8
+ import { printJson, printTable, getFormat } from '../output.ts'
9
+
10
+ registerCommand({
11
+ name: 'permissions',
12
+ aliases: ['perms'],
13
+ description: 'List or set document permissions.',
14
+ usage: 'permissions id=<docId> [user=<userId> role=<role>] [--effective] [--format=json|tsv]',
15
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
16
+ if (!conn) return 'Not connected'
17
+
18
+ const docId = resolveDocument(conn, args.params, args.positional)
19
+ if (!docId) return 'Document not found.'
20
+
21
+ // Set permission mode
22
+ const targetUser = args.params['user'] || args.params['user_id']
23
+ const targetRole = args.params['role']
24
+
25
+ if (targetUser && targetRole) {
26
+ try {
27
+ await conn.client.setPermission(docId, {
28
+ user_id: targetUser,
29
+ role: targetRole as 'owner' | 'editor' | 'viewer' | 'observer',
30
+ })
31
+ return `Set ${targetUser.slice(0, 12)}… → ${targetRole} on ${docId.slice(0, 8)}…`
32
+ } catch (error: any) {
33
+ return `Error: ${error.message}`
34
+ }
35
+ }
36
+
37
+ // Remove permission mode
38
+ if (targetUser && args.flags.has('remove')) {
39
+ try {
40
+ await conn.client.removePermission(docId, { user_id: targetUser })
41
+ return `Removed permissions for ${targetUser.slice(0, 12)}… on ${docId.slice(0, 8)}…`
42
+ } catch (error: any) {
43
+ return `Error: ${error.message}`
44
+ }
45
+ }
46
+
47
+ // List permissions mode
48
+ try {
49
+ const format = getFormat(args, 'text')
50
+
51
+ if (args.flags.has('effective')) {
52
+ const result = await conn.client.listEffectivePermissions(docId)
53
+
54
+ if (format === 'json') {
55
+ return printJson(result)
56
+ }
57
+
58
+ if (result.permissions.length === 0) {
59
+ return `Default role: ${result.default_role}\nNo explicit permissions.`
60
+ }
61
+
62
+ const rows = result.permissions.map(p => [
63
+ p.display_name || p.username,
64
+ p.role,
65
+ p.source,
66
+ p.inherited_from_doc_id ? p.inherited_from_doc_id.slice(0, 8) + '…' : '—',
67
+ ])
68
+
69
+ return `Default role: ${result.default_role}\n\n` +
70
+ printTable(rows, ['USER', 'ROLE', 'SOURCE', 'INHERITED FROM'])
71
+ }
72
+
73
+ const perms = await conn.client.listPermissions(docId)
74
+
75
+ if (format === 'json') {
76
+ return printJson(perms)
77
+ }
78
+
79
+ if (perms.length === 0) {
80
+ return 'No explicit permissions set.'
81
+ }
82
+
83
+ const rows = perms.map(p => [
84
+ p.display_name || p.username,
85
+ p.role,
86
+ p.user_id.slice(0, 12) + '…',
87
+ ])
88
+
89
+ return printTable(rows, ['USER', 'ROLE', 'USER ID'])
90
+ } catch (error: any) {
91
+ return `Error: ${error.message}`
92
+ }
93
+ },
94
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * spaces, spaces:switch, info commands.
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, pad } from '../output.ts'
8
+ import { readEntries, childrenOf } from '../resolve.ts'
9
+
10
+ registerCommand({
11
+ name: 'info',
12
+ description: 'Show server and space info.',
13
+ async run(conn: CLIConnection | null): Promise<string> {
14
+ if (!conn) return 'Not connected'
15
+ const si = conn.serverInfo
16
+ if (!si) return 'No server info available'
17
+
18
+ const treeMap = conn.getTreeMap()
19
+ const docCount = treeMap ? readEntries(treeMap).length : 0
20
+
21
+ const rootProvider = conn.rootProvider
22
+ const onlineUsers = rootProvider
23
+ ? Array.from(rootProvider.awareness.getStates().values()).filter((s: any) => s.user).length
24
+ : 0
25
+
26
+ const lines = [
27
+ `Server: ${si.name ?? '—'}`,
28
+ `URL: ${conn.config.url}`,
29
+ `Version: ${si.version ?? '—'}`,
30
+ `Protocol: ${si.protocol_version ?? '—'}`,
31
+ `Hub Doc: ${conn.rootDocId ?? '—'}`,
32
+ `Auth: ${si.auth_methods?.join(', ') ?? '—'}`,
33
+ `Registration:${si.registration_allowed ? ' open' : ' closed'}${si.invite_only ? ' (invite only)' : ''}`,
34
+ `Documents: ${docCount}`,
35
+ `Users: ${onlineUsers} online`,
36
+ ]
37
+ return lines.join('\n')
38
+ },
39
+ })
40
+
41
+ registerCommand({
42
+ name: 'spaces',
43
+ description: 'List available spaces/root documents.',
44
+ usage: 'spaces [--format=json|tsv]',
45
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
46
+ if (!conn) return 'Not connected'
47
+ const spaces = conn.spaces
48
+ if (!spaces.length) return 'No spaces available.'
49
+
50
+ const format = getFormat(args, 'text')
51
+ const active = conn.rootDocId
52
+
53
+ if (format === 'json') {
54
+ return printJson(spaces.map(s => ({ ...s, active: s.doc_id === active })))
55
+ }
56
+
57
+ const rows = spaces.map(s => [
58
+ s.doc_id === active ? '▸' : ' ',
59
+ s.doc_id.slice(0, 8) + '…',
60
+ s.name,
61
+ s.is_hub ? 'hub' : '',
62
+ s.visibility,
63
+ ])
64
+
65
+ return printTable(rows, ['', 'ID', 'NAME', 'HUB', 'VISIBILITY'])
66
+ },
67
+ })
68
+
69
+ registerCommand({
70
+ name: 'spaces:switch',
71
+ aliases: ['switch'],
72
+ description: 'Switch the active space.',
73
+ usage: 'spaces:switch name=<name> | id=<docId>',
74
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
75
+ if (!conn) return 'Not connected'
76
+
77
+ const targetName = args.params['name'] || args.positional[0]
78
+ const targetId = args.params['id']
79
+
80
+ let docId: string | null = null
81
+
82
+ if (targetId) {
83
+ docId = targetId
84
+ } else if (targetName) {
85
+ const lower = targetName.toLowerCase()
86
+ const space = conn.spaces.find(s =>
87
+ s.name.toLowerCase() === lower || s.doc_id === targetName
88
+ )
89
+ if (space) docId = space.doc_id
90
+ }
91
+
92
+ if (!docId) {
93
+ return 'Space not found. Use "abracadabra spaces" to list available spaces.'
94
+ }
95
+
96
+ await conn.switchSpace(docId)
97
+ const space = conn.spaces.find(s => s.doc_id === docId)
98
+ return `Switched to space "${space?.name ?? docId}"`
99
+ },
100
+ })
@@ -0,0 +1,158 @@
1
+ /**
2
+ * tree, ls, search commands — navigate and explore the document tree.
3
+ */
4
+ import { registerCommand } from '../command.ts'
5
+ import type { CLIConnection } from '../connection.ts'
6
+ import type { ParsedArgs } from '../parser.ts'
7
+ import {
8
+ readEntries,
9
+ childrenOf,
10
+ buildTree,
11
+ normalizeRootId,
12
+ resolveDocument,
13
+ getAncestorPath,
14
+ type TreeDisplayNode,
15
+ } from '../resolve.ts'
16
+ import { printJson, printTable, printTree, getFormat, relativeTime, type TreeNode } from '../output.ts'
17
+
18
+ function toTreeNodes(nodes: TreeDisplayNode[]): TreeNode[] {
19
+ return nodes.map(n => ({
20
+ label: n.label,
21
+ type: n.type,
22
+ children: n.children.length > 0 ? toTreeNodes(n.children) : undefined,
23
+ }))
24
+ }
25
+
26
+ registerCommand({
27
+ name: 'tree',
28
+ aliases: ['t'],
29
+ description: 'Show the full document tree hierarchy.',
30
+ usage: 'tree [id=<docId>] [depth=<n>] [--format=json|tree]',
31
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
32
+ if (!conn) return 'Not connected'
33
+ const treeMap = conn.getTreeMap()
34
+ if (!treeMap) return 'Not connected'
35
+
36
+ const rootId = normalizeRootId(
37
+ args.params['id'] || args.positional[0] || conn.rootDocId,
38
+ conn
39
+ )
40
+ const maxDepth = args.params['depth'] ? parseInt(args.params['depth'], 10) : -1
41
+ const format = getFormat(args, 'tree')
42
+
43
+ const entries = readEntries(treeMap)
44
+ const tree = buildTree(entries, rootId, maxDepth)
45
+
46
+ if (format === 'json') {
47
+ return printJson(tree)
48
+ }
49
+
50
+ if (tree.length === 0) {
51
+ return '(empty tree)'
52
+ }
53
+
54
+ // Find root label for header
55
+ const hubSpace = conn.spaces.find(s => s.doc_id === conn.rootDocId)
56
+ const rootLabel = hubSpace?.name ?? conn.rootDocId ?? 'Workspace'
57
+
58
+ return rootLabel + '\n' + printTree(toTreeNodes(tree))
59
+ },
60
+ })
61
+
62
+ registerCommand({
63
+ name: 'ls',
64
+ aliases: ['list', 'l'],
65
+ description: 'List direct children of a document (defaults to root).',
66
+ usage: 'ls [id=<parentId>] [name=<parentName>] [--format=json|tsv] [--total]',
67
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
68
+ if (!conn) return 'Not connected'
69
+ const treeMap = conn.getTreeMap()
70
+ if (!treeMap) return 'Not connected'
71
+
72
+ let parentId: string | null = null
73
+ if (args.params['id'] || args.params['name'] || args.params['path'] || args.positional[0]) {
74
+ parentId = resolveDocument(conn, args.params, args.positional)
75
+ if (!parentId && (args.params['id'] || args.params['name'] || args.params['path'])) {
76
+ return 'Document not found.'
77
+ }
78
+ }
79
+
80
+ const targetId = normalizeRootId(parentId || conn.rootDocId, conn)
81
+ const entries = readEntries(treeMap)
82
+ const children = childrenOf(entries, targetId)
83
+
84
+ if (args.flags.has('total')) {
85
+ return String(children.length)
86
+ }
87
+
88
+ const format = getFormat(args, 'text')
89
+
90
+ if (format === 'json') {
91
+ return printJson(children)
92
+ }
93
+
94
+ if (children.length === 0) {
95
+ return '(no children)'
96
+ }
97
+
98
+ const rows = children.map(c => [
99
+ c.id.slice(0, 8) + '…',
100
+ c.label,
101
+ c.type ?? '—',
102
+ relativeTime(c.updatedAt),
103
+ ])
104
+
105
+ return printTable(rows, ['ID', 'LABEL', 'TYPE', 'UPDATED'])
106
+ },
107
+ })
108
+
109
+ registerCommand({
110
+ name: 'search',
111
+ aliases: ['find', 's'],
112
+ description: 'Search documents by label across the tree.',
113
+ usage: 'search query=<text> [id=<rootId>] [--format=json|tsv] [--total]',
114
+ async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
115
+ if (!conn) return 'Not connected'
116
+ const treeMap = conn.getTreeMap()
117
+ if (!treeMap) return 'Not connected'
118
+
119
+ const query = args.params['query'] || args.positional[0]
120
+ if (!query) return 'Missing required parameter: query=<text>'
121
+
122
+ const entries = readEntries(treeMap)
123
+ const lowerQuery = query.toLowerCase()
124
+
125
+ const matches = entries.filter(e =>
126
+ e.label.toLowerCase().includes(lowerQuery)
127
+ )
128
+
129
+ if (args.flags.has('total')) {
130
+ return String(matches.length)
131
+ }
132
+
133
+ const format = getFormat(args, 'text')
134
+
135
+ if (matches.length === 0) {
136
+ return `No documents found matching "${query}".`
137
+ }
138
+
139
+ const results = matches.map(entry => ({
140
+ id: entry.id,
141
+ label: entry.label,
142
+ type: entry.type,
143
+ path: getAncestorPath(entries, entry.id),
144
+ }))
145
+
146
+ if (format === 'json') {
147
+ return printJson(results)
148
+ }
149
+
150
+ const rows = results.map(r => [
151
+ r.path.length > 0 ? r.path.join(' / ') + ' / ' + r.label : r.label,
152
+ r.label,
153
+ r.type ?? '—',
154
+ ])
155
+
156
+ return printTable(rows, ['PATH', 'LABEL', 'TYPE'])
157
+ },
158
+ })