@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/src/parser.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal argument parser for Abracadabra CLI.
3
+ *
4
+ * Syntax:
5
+ * abracadabra [--flag] <command>[:<subcommand>] [key=value ...] [--flag2]
6
+ * abracadabra [--flag] <command>[:<subcommand>] [key="value with spaces"] [--format=json]
7
+ */
8
+
9
+ export interface ParsedArgs {
10
+ /** The command name, e.g. "tree", "spaces:switch", "read" */
11
+ command: string
12
+ /** Key-value parameters, e.g. { name: "My Doc", type: "doc" } */
13
+ params: Record<string, string>
14
+ /** Boolean flags, e.g. { total: true, verbose: true } */
15
+ flags: Set<string>
16
+ /** Positional arguments (non-flag, non-key=value, after the command) */
17
+ positional: string[]
18
+ }
19
+
20
+ /**
21
+ * Parse CLI arguments into a structured object.
22
+ * @param argv Raw process.argv (includes node path and script path)
23
+ */
24
+ export function parseArgs(argv: string[]): ParsedArgs {
25
+ const args = argv.slice(2) // skip node + script
26
+
27
+ const result: ParsedArgs = {
28
+ command: 'help',
29
+ params: {},
30
+ flags: new Set(),
31
+ positional: [],
32
+ }
33
+
34
+ let commandFound = false
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ const arg = args[i]
38
+
39
+ // --flag or --key=value
40
+ if (arg.startsWith('--')) {
41
+ const stripped = arg.slice(2)
42
+ const eqIdx = stripped.indexOf('=')
43
+ if (eqIdx !== -1) {
44
+ // --format=json style
45
+ const key = stripped.slice(0, eqIdx)
46
+ const value = stripped.slice(eqIdx + 1)
47
+ result.params[key] = value
48
+ } else {
49
+ result.flags.add(stripped)
50
+ }
51
+ continue
52
+ }
53
+
54
+ // key=value pair
55
+ const eqIdx = arg.indexOf('=')
56
+ if (eqIdx !== -1 && eqIdx > 0) {
57
+ const key = arg.slice(0, eqIdx)
58
+ let value = arg.slice(eqIdx + 1)
59
+ // Strip surrounding quotes if present
60
+ if ((value.startsWith('"') && value.endsWith('"')) ||
61
+ (value.startsWith("'") && value.endsWith("'"))) {
62
+ value = value.slice(1, -1)
63
+ }
64
+ result.params[key] = value
65
+ continue
66
+ }
67
+
68
+ // First bare word is the command
69
+ if (!commandFound) {
70
+ result.command = arg
71
+ commandFound = true
72
+ continue
73
+ }
74
+
75
+ // Subsequent bare words are positional
76
+ result.positional.push(arg)
77
+ }
78
+
79
+ return result
80
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Document resolution utilities for the CLI.
3
+ *
4
+ * Resolves documents by id, name (label), or path (label chain from root).
5
+ * Similar to Obsidian's file= / path= resolution.
6
+ */
7
+ import * as Y from 'yjs'
8
+ import type { CLIConnection } from './connection.ts'
9
+
10
+ export interface TreeEntry {
11
+ id: string
12
+ label: string
13
+ parentId: string | null
14
+ order: number
15
+ type?: string
16
+ meta?: Record<string, unknown>
17
+ createdAt?: number
18
+ updatedAt?: number
19
+ }
20
+
21
+ /** Safely read a tree map value, converting Y.Map to plain object if needed. */
22
+ function toPlain(val: any): any {
23
+ return val instanceof Y.Map ? val.toJSON() : val
24
+ }
25
+
26
+ /** Read all tree entries from the Y.Map. */
27
+ export function readEntries(treeMap: Y.Map<any>): TreeEntry[] {
28
+ const entries: TreeEntry[] = []
29
+ treeMap.forEach((raw: any, id: string) => {
30
+ const value = toPlain(raw)
31
+ if (typeof value !== 'object' || value === null) return
32
+ entries.push({
33
+ id,
34
+ label: value.label || 'Untitled',
35
+ parentId: value.parentId ?? null,
36
+ order: value.order ?? 0,
37
+ type: value.type,
38
+ meta: value.meta,
39
+ createdAt: value.createdAt,
40
+ updatedAt: value.updatedAt,
41
+ })
42
+ })
43
+ return entries
44
+ }
45
+
46
+ /** Get direct children of a parent, sorted by order. */
47
+ export function childrenOf(entries: TreeEntry[], parentId: string | null): TreeEntry[] {
48
+ return entries
49
+ .filter(e => e.parentId === parentId)
50
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
51
+ }
52
+
53
+ /** Get all descendants of an entry. */
54
+ export function descendantsOf(entries: TreeEntry[], id: string | null): TreeEntry[] {
55
+ const result: TreeEntry[] = []
56
+ const visited = new Set<string>()
57
+ function collect(pid: string) {
58
+ if (visited.has(pid)) return
59
+ visited.add(pid)
60
+ for (const child of childrenOf(entries, pid)) {
61
+ result.push(child)
62
+ collect(child.id)
63
+ }
64
+ }
65
+ collect(id!)
66
+ return result
67
+ }
68
+
69
+ /** Build a nested tree structure for display. */
70
+ export function buildTree(entries: TreeEntry[], rootId: string | null, maxDepth: number, currentDepth = 0, visited = new Set<string>()): TreeDisplayNode[] {
71
+ if (maxDepth >= 0 && currentDepth >= maxDepth) return []
72
+ const children = childrenOf(entries, rootId)
73
+ return children.filter(e => !visited.has(e.id)).map(entry => {
74
+ const next = new Set(visited)
75
+ next.add(entry.id)
76
+ return {
77
+ id: entry.id,
78
+ label: entry.label,
79
+ type: entry.type,
80
+ meta: entry.meta,
81
+ children: buildTree(entries, entry.id, maxDepth, currentDepth + 1, next),
82
+ }
83
+ })
84
+ }
85
+
86
+ export interface TreeDisplayNode {
87
+ id: string
88
+ label: string
89
+ type?: string
90
+ meta?: Record<string, unknown>
91
+ children: TreeDisplayNode[]
92
+ }
93
+
94
+ /**
95
+ * Normalize a document ID so the hub/root doc ID is treated as the tree root (null).
96
+ */
97
+ export function normalizeRootId(id: string | null | undefined, conn: CLIConnection): string | null {
98
+ if (id == null) return null
99
+ return id === conn.rootDocId ? null : id
100
+ }
101
+
102
+ /**
103
+ * Resolve a document from parsed args.
104
+ *
105
+ * Resolution order:
106
+ * 1. id=<uuid> — Direct document ID
107
+ * 2. name=<label> — Case-insensitive label match (first match wins)
108
+ * 3. path=<a/b/c> — Resolve by label path from root
109
+ * 4. First positional argument — tries name resolution
110
+ *
111
+ * Returns the document ID or null if not found.
112
+ */
113
+ export function resolveDocument(
114
+ conn: CLIConnection,
115
+ params: Record<string, string>,
116
+ positional: string[],
117
+ ): string | null {
118
+ const treeMap = conn.getTreeMap()
119
+ if (!treeMap) return null
120
+
121
+ const entries = readEntries(treeMap)
122
+
123
+ // 1. Direct ID
124
+ if (params['id']) {
125
+ const entry = entries.find(e => e.id === params['id'])
126
+ return entry ? entry.id : null
127
+ }
128
+
129
+ // 2. Name resolution (case-insensitive label match)
130
+ const name = params['name'] || params['file'] || positional[0]
131
+ if (name) {
132
+ const lower = name.toLowerCase()
133
+ const match = entries.find(e => e.label.toLowerCase() === lower)
134
+ if (match) return match.id
135
+
136
+ // Substring match as fallback
137
+ const substring = entries.find(e => e.label.toLowerCase().includes(lower))
138
+ if (substring) return substring.id
139
+ }
140
+
141
+ // 3. Path resolution (slash-separated label chain)
142
+ if (params['path']) {
143
+ const segments = params['path'].split('/').map(s => s.trim()).filter(Boolean)
144
+ let currentParent: string | null = normalizeRootId(conn.rootDocId, conn)
145
+
146
+ for (const segment of segments) {
147
+ const lower = segment.toLowerCase()
148
+ const children = childrenOf(entries, currentParent)
149
+ const match = children.find(c => c.label.toLowerCase() === lower)
150
+ if (!match) return null
151
+ currentParent = match.id
152
+ }
153
+
154
+ return currentParent
155
+ }
156
+
157
+ return null
158
+ }
159
+
160
+ /** Get the ancestor path labels for a document. */
161
+ export function getAncestorPath(entries: TreeEntry[], docId: string): string[] {
162
+ const byId = new Map(entries.map(e => [e.id, e]))
163
+ const path: string[] = []
164
+ let current = byId.get(docId)?.parentId
165
+ const visited = new Set<string>()
166
+ while (current && !visited.has(current)) {
167
+ visited.add(current)
168
+ const parent = byId.get(current)
169
+ if (!parent) break
170
+ path.unshift(parent.label)
171
+ current = parent.parentId
172
+ }
173
+ return path
174
+ }