quilltap 4.7.0-dev → 4.7.0-dev.117

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.
@@ -9,6 +9,7 @@ const {
9
9
  loadDbKey,
10
10
  openMountIndexDb,
11
11
  } = require('./db-helpers');
12
+ const { isQtapUri, parseQtapUri, formatDocStoreUri } = require('./qtap-uri');
12
13
 
13
14
  const RESET = '\x1b[0m';
14
15
  const BOLD = '\x1b[1m';
@@ -77,21 +78,35 @@ Server-required subcommands (background-job queue lives in the running server):
77
78
  Enqueue embedding jobs for un-embedded chunks
78
79
 
79
80
  Write subcommands (server required for database-backed mounts):
80
- write [--force] <mount> <path> [file] Write a file from <file> or stdin
81
- delete <mount> <path> Idempotent file delete
82
- mkdir <mount> <path> Idempotent folder create
83
- move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
84
- copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
81
+ write [--force] [--base64] <mount> <path> [file] Write a file from <file> or stdin
82
+ delete <mount> <path> Idempotent file delete
83
+ mkdir <mount> <path> Idempotent folder create
84
+ move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
85
+ copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
86
+ link <srcMount> <srcPath> <dstMount> <dstPath> Hard-link file (server-required)
87
+ rmdir <mount> <path> Delete an empty folder (server-required)
88
+ mvdir <mount> <fromPath> <toPath> Rename/move a folder (server-required)
85
89
 
86
90
  <mount> may be a mount name or UUID. Names are case-insensitive; ambiguous
87
91
  names print candidates and exit non-zero.
88
92
 
93
+ Addressing with qtap:// URIs: anywhere a <mount> <relativePath> pair is taken,
94
+ you may instead pass a single qtap:// URI in its place — e.g.
95
+ quilltap docs read qtap://notes/today.md
96
+ quilltap docs move qtap://drafts/foo.md qtap://notes/2026/foo.md
97
+ The CLI addresses document stores only: qtap://self/… needs a character context
98
+ and qtap://project/… and qtap://general/… are not CLI-addressable; pass a store
99
+ name or UUID instead. --mount on find/grep also accepts a qtap://store/ URI.
100
+
89
101
  Options:
90
102
  -d, --data-dir <path> Override data directory
91
103
  -i, --instance <name> Use a registered instance (see 'quilltap instances')
92
104
  --passphrase <pass> Decrypt .dbkey if peppered
93
105
  --port <number> Server port for API calls (default: 3000)
94
- --json Machine-readable output
106
+ --json Machine-readable output (find/grep/ls/files/tree rows
107
+ carry a 'uri' field)
108
+ --uri For find/grep/files: show the canonical qtap:// URI
109
+ as the locator instead of the mount/path columns
95
110
  --rendered For 'read': output extracted plaintext
96
111
  --folder <path> For 'files': narrow to a folder prefix
97
112
  --recursive, -R For 'ls': list all files recursively, grouped by folder
@@ -107,6 +122,11 @@ Options:
107
122
  For 'write': overwrite existing destination
108
123
  For 'copy': overwrite + force a real byte copy
109
124
  (skips the default hard-link path)
125
+ --base64 For 'write': send content as base64 JSON via PUT
126
+ .../files/{path} (portable path used
127
+ by the file browser; server-required)
128
+ For 'read': fetch raw bytes via GET .../files/{path}
129
+ ?encoding=base64 and emit to stdout
110
130
  -h, --help Show this help
111
131
 
112
132
  Read-only operations (list, show, files, read, export) open the mount-index
@@ -129,10 +149,17 @@ Examples:
129
149
  quilltap docs move drafts foo.md notes 2026/foo.md
130
150
  quilltap docs copy notes today.md archive 2026-05/today.md
131
151
  quilltap docs copy --force notes today.md archive copy.md
152
+ quilltap docs link notes today.md archive 2026-05/today.md
153
+ quilltap docs rmdir notes 2026/may
154
+ quilltap docs mvdir notes drafts/old drafts/archive
155
+ quilltap docs write --base64 notes image.png < image.png
156
+ quilltap docs read --base64 notes image.png > image.png
132
157
  quilltap docs find Manifesto
133
158
  quilltap docs find --mount notes --ext md Knowledge
134
159
  quilltap docs grep --mount notes --ignore-case "five-point Calvinist"
135
160
  quilltap docs grep --mount notes -l "TODO"
161
+ quilltap docs read qtap://notes/today.md
162
+ quilltap docs find --uri Manifesto
136
163
  quilltap docs status
137
164
  quilltap docs status --mount notes --top 10
138
165
  quilltap docs reindex notes Knowledge --force
@@ -174,6 +201,8 @@ function parseFlags(args) {
174
201
  // semantic search
175
202
  semantic: false,
176
203
  threshold: -1,
204
+ // base64 read/write flag
205
+ base64: false,
177
206
  };
178
207
  const positional = [];
179
208
  let i = 0;
@@ -193,6 +222,7 @@ function parseFlags(args) {
193
222
  break;
194
223
  }
195
224
  case '--json': flags.json = true; break;
225
+ case '--uri': flags.uri = true; break;
196
226
  case '--rendered': flags.rendered = true; break;
197
227
  case '--folder': flags.folder = args[++i]; break;
198
228
  case '--force': flags.force = true; break;
@@ -219,6 +249,7 @@ function parseFlags(args) {
219
249
  case '--long': flags.long = true; break;
220
250
  case '--names-only': flags.namesOnly = true; break;
221
251
  case '--semantic': flags.semantic = true; break;
252
+ case '--base64': flags.base64 = true; break;
222
253
  case '--threshold': {
223
254
  const v = parseFloat(args[++i]);
224
255
  if (isNaN(v) || v < 0 || v > 1) {
@@ -299,6 +330,44 @@ function assertDocsSchema(db, dataDir) {
299
330
 
300
331
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
301
332
 
333
+ /**
334
+ * Expand a single `qtap://…` argument into the CLI's `[mountSpec, relPath]`
335
+ * pair. The CLI addresses document stores only, so non-`document_store` scopes
336
+ * and the character-context-only `self` authority are rejected with guidance
337
+ * (throws; the docs dispatcher prints `Error: <message>`).
338
+ */
339
+ function expandQtapArg(arg) {
340
+ const p = parseQtapUri(arg);
341
+ if (p.scope !== 'document_store') {
342
+ throw new Error(
343
+ 'The CLI addresses document stores only; project/general scopes ' +
344
+ '(qtap://project/…, qtap://general/…) are not CLI-addressable. Pass a store name or UUID.'
345
+ );
346
+ }
347
+ if (p.mountPoint && p.mountPoint.toLowerCase() === 'self') {
348
+ throw new Error(
349
+ '"self" requires a character context and is not resolvable from the CLI; pass a store name or UUID.'
350
+ );
351
+ }
352
+ return [p.mountPoint, p.path];
353
+ }
354
+
355
+ /**
356
+ * Walk a subcommand's positional args, expanding any `qtap://…` argument into
357
+ * the `<mount> <relativePath>` pair the existing handlers consume. A bare
358
+ * `<mount> <path>` pair is left untouched, so both forms work. Two-target
359
+ * commands (move/copy/link) accept two `qtap://` args (→ four positionals) or
360
+ * the legacy four positionals.
361
+ */
362
+ function expandQtapPositionals(positional) {
363
+ const out = [];
364
+ for (const arg of positional) {
365
+ if (isQtapUri(arg)) out.push(...expandQtapArg(arg));
366
+ else out.push(arg);
367
+ }
368
+ return out;
369
+ }
370
+
302
371
  function requireMount(db, spec) {
303
372
  if (!spec) {
304
373
  console.error('Error: mount name or id is required');
@@ -332,6 +401,33 @@ function requireMount(db, spec) {
332
401
  return rows[0];
333
402
  }
334
403
 
404
+ /**
405
+ * Lower-cased names that map to more than one enabled mount — i.e. names that
406
+ * are ambiguous and must use the UUID form in a `qtap://` URI. Mirrors the
407
+ * server's `countByName` ambiguity check.
408
+ */
409
+ function loadAmbiguousMountNames(db) {
410
+ const rows = db.prepare('SELECT name FROM doc_mount_points WHERE enabled = 1').all();
411
+ const counts = new Map();
412
+ for (const r of rows) {
413
+ const key = String(r.name || '').trim().toLowerCase();
414
+ counts.set(key, (counts.get(key) || 0) + 1);
415
+ }
416
+ const ambiguous = new Set();
417
+ for (const [key, count] of counts) if (count > 1) ambiguous.add(key);
418
+ return ambiguous;
419
+ }
420
+
421
+ /**
422
+ * Canonical document-store `qtap://` URI for a CLI result row: the readable
423
+ * store name unless it is ambiguous (then the UUID), matching the resolver's
424
+ * name-first / id-as-fallback rule.
425
+ */
426
+ function rowQtapUri(mountName, mountId, relativePath, ambiguousNames) {
427
+ const useId = !mountName || ambiguousNames.has(String(mountName).trim().toLowerCase());
428
+ return formatDocStoreUri(useId ? mountId : mountName, relativePath || '');
429
+ }
430
+
335
431
  function formatBytes(n) {
336
432
  if (n < 1024) return `${n} B`;
337
433
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
@@ -477,16 +573,21 @@ async function handleFiles(flags, id) {
477
573
  ORDER BY l.relativePath
478
574
  `).all(mount.id);
479
575
  }
576
+ const ambiguousNames = loadAmbiguousMountNames(db);
577
+ const rowsWithUri = rows.map(r => ({
578
+ ...r,
579
+ uri: rowQtapUri(mount.name, mount.id, r.relativePath, ambiguousNames),
580
+ }));
480
581
  if (flags.json) {
481
- process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
582
+ process.stdout.write(JSON.stringify(rowsWithUri, null, 2) + '\n');
482
583
  return;
483
584
  }
484
585
  if (rows.length === 0) {
485
586
  console.log('(no files)');
486
587
  return;
487
588
  }
488
- const display = rows.map(r => ({
489
- relativePath: r.relativePath,
589
+ const display = rowsWithUri.map(r => ({
590
+ ...(flags.uri ? { uri: r.uri } : { relativePath: r.relativePath }),
490
591
  type: r.fileType,
491
592
  source: r.source,
492
593
  size: formatBytes(r.fileSizeBytes || 0),
@@ -726,6 +827,7 @@ async function handleLs(flags, mountSpec, rawPath) {
726
827
 
727
828
  // JSON output
728
829
  if (flags.json) {
830
+ const ambiguousNames = loadAmbiguousMountNames(db);
729
831
  const out = [];
730
832
  if (!flags.recursive) {
731
833
  for (const folder of folders) {
@@ -733,6 +835,7 @@ async function handleLs(flags, mountSpec, rawPath) {
733
835
  type: 'folder',
734
836
  name: folder.name,
735
837
  path: folder.path,
838
+ uri: rowQtapUri(mount.name, mount.id, folder.path, ambiguousNames),
736
839
  createdAt: folder.createdAt,
737
840
  updatedAt: folder.updatedAt,
738
841
  });
@@ -751,6 +854,7 @@ async function handleLs(flags, mountSpec, rawPath) {
751
854
  type: 'file',
752
855
  name: file.fileName,
753
856
  relativePath: file.relativePath,
857
+ uri: rowQtapUri(mount.name, mount.id, file.relativePath, ambiguousNames),
754
858
  fileType: file.fileType,
755
859
  source: file.source,
756
860
  fileSizeBytes: file.fileSizeBytes,
@@ -989,16 +1093,19 @@ function renderTreeAscii(node, prefix, isLast, isRoot, maxNodes, nodeCount) {
989
1093
  }
990
1094
  }
991
1095
 
992
- function treeToJson(node) {
1096
+ function treeToJson(node, ctx) {
993
1097
  return {
994
1098
  name: node.name,
995
1099
  type: node.type,
996
1100
  path: node.path || undefined,
997
1101
  relativePath: node.relativePath || undefined,
1102
+ uri: ctx
1103
+ ? rowQtapUri(ctx.mountName, ctx.mountId, node.relativePath || node.path || '', ctx.ambiguousNames)
1104
+ : undefined,
998
1105
  size: node.size !== undefined ? node.size : undefined,
999
1106
  chunkCount: node.chunkCount !== undefined ? node.chunkCount : undefined,
1000
1107
  children: node.children && node.children.length > 0
1001
- ? node.children.map(treeToJson)
1108
+ ? node.children.map((child) => treeToJson(child, ctx))
1002
1109
  : undefined,
1003
1110
  };
1004
1111
  }
@@ -1031,7 +1138,8 @@ async function handleTree(flags, mountSpec, rawPath) {
1031
1138
  const tree = buildFolderTree(db, mount.id, startPath, maxDepth);
1032
1139
 
1033
1140
  if (flags.json) {
1034
- const output = treeToJson(tree);
1141
+ const ambiguousNames = loadAmbiguousMountNames(db);
1142
+ const output = treeToJson(tree, { mountName: mount.name, mountId: mount.id, ambiguousNames });
1035
1143
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
1036
1144
  return;
1037
1145
  }
@@ -1078,10 +1186,50 @@ function ttyGuard(fileType, flags, label) {
1078
1186
 
1079
1187
  async function handleRead(flags, id, relativePath) {
1080
1188
  if (!id || !relativePath) {
1081
- console.error('Usage: quilltap docs read [--rendered] <mount-id> <relativePath>');
1189
+ console.error('Usage: quilltap docs read [--rendered] [--base64] <mount-id> <relativePath>');
1082
1190
  process.exit(1);
1083
1191
  }
1084
1192
 
1193
+ // --base64: fetch raw bytes via the item route (server-required).
1194
+ if (flags.base64) {
1195
+ const { db } = await openDb(flags);
1196
+ let mount;
1197
+ try {
1198
+ mount = requireMount(db, id);
1199
+ } finally {
1200
+ db.close();
1201
+ }
1202
+ const url = fileItemUrl(flags.port, mount.id, relativePath) + '?encoding=base64';
1203
+ let res;
1204
+ try {
1205
+ res = await fetch(url);
1206
+ } catch (err) {
1207
+ if (isConnectionRefused(err)) {
1208
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. --base64 read requires the running server.`);
1209
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
1210
+ process.exit(1);
1211
+ }
1212
+ throw err;
1213
+ }
1214
+ if (!res.ok) {
1215
+ let body = null;
1216
+ try { body = await res.json(); } catch { /* ignore */ }
1217
+ const msg = body && body.error ? body.error : `HTTP ${res.status}`;
1218
+ console.error(`Read failed: ${msg}`);
1219
+ process.exit(1);
1220
+ }
1221
+ let body = null;
1222
+ try { body = await res.json(); } catch { /* ignore */ }
1223
+ // A zero-byte file legitimately returns content: "" (falsy), so check the
1224
+ // type, not truthiness — otherwise empty files fail to round-trip.
1225
+ if (!body || typeof body.content !== 'string') {
1226
+ console.error('Read failed: server returned no content');
1227
+ process.exit(1);
1228
+ }
1229
+ process.stdout.write(Buffer.from(body.content, 'base64'));
1230
+ return;
1231
+ }
1232
+
1085
1233
  const { db } = await openDb(flags);
1086
1234
  try {
1087
1235
  const mount = requireMount(db, id);
@@ -1469,6 +1617,35 @@ async function writeViaHttp(port, mountId, relativePath, data, force) {
1469
1617
  return { reachable: true, ok: true, result: unwrap(body) };
1470
1618
  }
1471
1619
 
1620
+ // fileItemUrl — builds the item-route URL for the new PUT/GET .../files/{path}
1621
+ // Each path segment is independently URL-encoded to preserve the slash
1622
+ // structure of the route.
1623
+ function fileItemUrl(port, mountId, relativePath) {
1624
+ const encodedSegments = relativePath
1625
+ .split('/')
1626
+ .map(seg => encodeURIComponent(seg))
1627
+ .join('/');
1628
+ return `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}/files/${encodedSegments}`;
1629
+ }
1630
+
1631
+ async function writeViaHttpBase64(port, mountId, relativePath, data, force) {
1632
+ const content = data.toString('base64');
1633
+ const url = fileItemUrl(port, mountId, relativePath);
1634
+ const attempt = await tryFetch(url, {
1635
+ method: 'PUT',
1636
+ headers: { 'Content-Type': 'application/json' },
1637
+ body: JSON.stringify({ content, encoding: 'base64', force: !!force }),
1638
+ });
1639
+ if (!attempt.ok) return { reachable: false };
1640
+ const body = await readBodyJson(attempt.res);
1641
+ if (!attempt.res.ok) {
1642
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
1643
+ const code = body && body.code ? body.code : null;
1644
+ return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
1645
+ }
1646
+ return { reachable: true, ok: true, result: unwrap(body) };
1647
+ }
1648
+
1472
1649
  async function handleWrite(flags, positional) {
1473
1650
  const force = flags.force;
1474
1651
  const [mountSpec, relativePath, filename] = positional;
@@ -1488,6 +1665,31 @@ async function handleWrite(flags, positional) {
1488
1665
  db.close();
1489
1666
  }
1490
1667
 
1668
+ // --base64: use the new item-route PUT (server-required; no offline fallback).
1669
+ if (flags.base64) {
1670
+ const http = await writeViaHttpBase64(flags.port, mount.id, relativePath, data, force);
1671
+ if (!http.reachable) {
1672
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. --base64 write requires the running server.`);
1673
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
1674
+ process.exit(1);
1675
+ }
1676
+ if (!http.ok) {
1677
+ console.error(`Write failed: ${http.error}`);
1678
+ process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
1679
+ }
1680
+ const r = http.result;
1681
+ if (r.sha256 !== sourceSha) {
1682
+ console.error(`Checksum mismatch: source ${sourceSha} != dest ${r.sha256}`);
1683
+ process.exit(1);
1684
+ }
1685
+ if (flags.json) {
1686
+ process.stdout.write(JSON.stringify({ ...r, sourceSha256: sourceSha }, null, 2) + '\n');
1687
+ return;
1688
+ }
1689
+ console.log(`${GREEN}Wrote${RESET} ${mount.name}:${r.relativePath ?? relativePath} (${formatBytes(r.sizeBytes)}, sha=${r.sha256.slice(0, 12)}…)`);
1690
+ return;
1691
+ }
1692
+
1491
1693
  const http = await writeViaHttp(flags.port, mount.id, relativePath, data, force);
1492
1694
  if (http.reachable) {
1493
1695
  if (!http.ok) {
@@ -1868,6 +2070,169 @@ async function handleFileOp(flags, positional, action) {
1868
2070
  console.log(`${GREEN}${verb}${RESET} ${sourceMount.name}:${result.sourcePath} → ${destMount.name}:${result.destPath} ${DIM}(${result.strategy}, ${formatBytes(result.sizeBytes)}, sha=${result.destSha256.slice(0, 12)}…) [direct mode — run 'quilltap docs scan' once the server is back]${RESET}`);
1869
2071
  }
1870
2072
 
2073
+ // ----------------------------------------------------------------------------
2074
+ // link — hard-link via server (server-required; no offline fallback)
2075
+ // ----------------------------------------------------------------------------
2076
+
2077
+ async function handleLink(flags, positional) {
2078
+ const [srcMountSpec, srcPath, dstMountSpec, dstPath] = positional;
2079
+ if (!srcMountSpec || !srcPath || !dstMountSpec || !dstPath) {
2080
+ console.error('Usage: quilltap docs link <srcMount> <srcPath> <dstMount> <dstPath>');
2081
+ process.exit(1);
2082
+ }
2083
+
2084
+ const { db } = await openDb(flags);
2085
+ let sourceMount, destMount;
2086
+ try {
2087
+ sourceMount = requireMount(db, srcMountSpec);
2088
+ destMount = requireMount(db, dstMountSpec);
2089
+ } finally {
2090
+ db.close();
2091
+ }
2092
+
2093
+ const http = await fileOpViaHttp(flags.port, 'link-file', sourceMount.id, {
2094
+ sourcePath: srcPath,
2095
+ destMountPointId: destMount.id,
2096
+ destPath: dstPath,
2097
+ });
2098
+ if (!http.reachable) {
2099
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'link' requires the running server (hard-link logic lives there).`);
2100
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
2101
+ process.exit(1);
2102
+ }
2103
+ if (!http.ok) {
2104
+ const r = http;
2105
+ if (r.code === 'DEST_EXISTS') {
2106
+ console.error(`Link failed: destination already exists at ${dstPath}`);
2107
+ process.exit(2);
2108
+ }
2109
+ if (r.code === 'UNSUPPORTED') {
2110
+ console.error(`Link failed: hard links are not supported across storage types or devices for this mount pair`);
2111
+ process.exit(1);
2112
+ }
2113
+ if (r.code === 'SOURCE_NOT_FOUND') {
2114
+ console.error(`Link failed: source not found at ${srcPath} in mount ${sourceMount.name}`);
2115
+ process.exit(1);
2116
+ }
2117
+ console.error(`Link failed: ${r.error}`);
2118
+ process.exit(1);
2119
+ }
2120
+ const r = http.result;
2121
+ if (flags.json) {
2122
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
2123
+ return;
2124
+ }
2125
+ const strategy = r.strategy ? ` (${r.strategy})` : '';
2126
+ const size = r.sizeBytes != null ? `, ${formatBytes(r.sizeBytes)}` : '';
2127
+ const sha = r.destSha256 ? `, sha=${r.destSha256.slice(0, 12)}…` : '';
2128
+ console.log(`${GREEN}Linked${RESET} ${sourceMount.name}:${srcPath} → ${destMount.name}:${dstPath}${DIM}${strategy}${size}${sha}${RESET}`);
2129
+ }
2130
+
2131
+ // ----------------------------------------------------------------------------
2132
+ // rmdir — delete an empty folder (server-required)
2133
+ // ----------------------------------------------------------------------------
2134
+
2135
+ async function handleRmdir(flags, positional) {
2136
+ const [mountSpec, folderPath] = positional;
2137
+ if (!mountSpec || !folderPath) {
2138
+ console.error('Usage: quilltap docs rmdir <mount> <path>');
2139
+ process.exit(1);
2140
+ }
2141
+
2142
+ const { db } = await openDb(flags);
2143
+ let mount;
2144
+ try {
2145
+ mount = requireMount(db, mountSpec);
2146
+ } finally {
2147
+ db.close();
2148
+ }
2149
+
2150
+ const attempt = await tryFetch(actionUrl(flags.port, mount.id, 'delete-folder'), {
2151
+ method: 'POST',
2152
+ headers: { 'Content-Type': 'application/json' },
2153
+ body: JSON.stringify({ path: folderPath }),
2154
+ });
2155
+ if (!attempt.ok) {
2156
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'rmdir' requires the running server.`);
2157
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
2158
+ process.exit(1);
2159
+ }
2160
+ const body = await readBodyJson(attempt.res);
2161
+ if (!attempt.res.ok) {
2162
+ const code = body && body.code ? body.code : null;
2163
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
2164
+ if (code === 'NOT_EMPTY' || code === 'CONFLICT') {
2165
+ console.error(`rmdir failed: folder is not empty — ${folderPath}`);
2166
+ console.error('Remove all files inside it first, or use delete to remove individual files.');
2167
+ process.exit(1);
2168
+ }
2169
+ if (code === 'NOT_FOUND' || attempt.res.status === 404) {
2170
+ console.error(`rmdir failed: folder not found — ${folderPath}`);
2171
+ process.exit(1);
2172
+ }
2173
+ console.error(`rmdir failed: ${msg}`);
2174
+ process.exit(1);
2175
+ }
2176
+ const r = unwrap(body);
2177
+ if (flags.json) {
2178
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
2179
+ return;
2180
+ }
2181
+ console.log(`${GREEN}Removed${RESET} ${mount.name}:${folderPath}`);
2182
+ }
2183
+
2184
+ // ----------------------------------------------------------------------------
2185
+ // mvdir — rename/move a folder (server-required)
2186
+ // ----------------------------------------------------------------------------
2187
+
2188
+ async function handleMvdir(flags, positional) {
2189
+ const [mountSpec, fromPath, toPath] = positional;
2190
+ if (!mountSpec || !fromPath || !toPath) {
2191
+ console.error('Usage: quilltap docs mvdir <mount> <fromPath> <toPath>');
2192
+ process.exit(1);
2193
+ }
2194
+
2195
+ const { db } = await openDb(flags);
2196
+ let mount;
2197
+ try {
2198
+ mount = requireMount(db, mountSpec);
2199
+ } finally {
2200
+ db.close();
2201
+ }
2202
+
2203
+ const attempt = await tryFetch(actionUrl(flags.port, mount.id, 'move-folder'), {
2204
+ method: 'POST',
2205
+ headers: { 'Content-Type': 'application/json' },
2206
+ body: JSON.stringify({ fromPath, toPath }),
2207
+ });
2208
+ if (!attempt.ok) {
2209
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. 'mvdir' requires the running server.`);
2210
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
2211
+ process.exit(1);
2212
+ }
2213
+ const body = await readBodyJson(attempt.res);
2214
+ if (!attempt.res.ok) {
2215
+ const code = body && body.code ? body.code : null;
2216
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
2217
+ if (code === 'DEST_EXISTS') {
2218
+ console.error(`mvdir failed: destination already exists — ${toPath}`);
2219
+ process.exit(2);
2220
+ }
2221
+ if (code === 'SOURCE_NOT_FOUND' || attempt.res.status === 404) {
2222
+ console.error(`mvdir failed: source folder not found — ${fromPath}`);
2223
+ process.exit(1);
2224
+ }
2225
+ console.error(`mvdir failed: ${msg}`);
2226
+ process.exit(1);
2227
+ }
2228
+ const r = unwrap(body);
2229
+ if (flags.json) {
2230
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
2231
+ return;
2232
+ }
2233
+ console.log(`${GREEN}Moved${RESET} ${mount.name}:${fromPath} → ${mount.name}:${toPath}`);
2234
+ }
2235
+
1871
2236
  // ----------------------------------------------------------------------------
1872
2237
  // dispatch
1873
2238
  // ----------------------------------------------------------------------------
@@ -1892,6 +2257,8 @@ function resolveSearchMounts(db, mountSpec) {
1892
2257
  }
1893
2258
 
1894
2259
  async function handleFind(flags, positional) {
2260
+ // --mount may be given as a qtap://store/ URI; reduce it to the store spec.
2261
+ if (flags.mount && isQtapUri(flags.mount)) flags.mount = expandQtapArg(flags.mount)[0];
1895
2262
  const pattern = positional[0];
1896
2263
  if (!pattern) {
1897
2264
  console.error('Usage: quilltap docs find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] [--json] <pattern>');
@@ -1908,6 +2275,7 @@ async function handleFind(flags, positional) {
1908
2275
  try {
1909
2276
  const mounts = resolveSearchMounts(db, flags.mount);
1910
2277
  const showMountColumn = !flags.mount || flags.mount === 'all';
2278
+ const ambiguousNames = loadAmbiguousMountNames(db);
1911
2279
 
1912
2280
  const liked = `%${escapeLike(pattern)}%`;
1913
2281
 
@@ -1935,6 +2303,7 @@ async function handleFind(flags, positional) {
1935
2303
  mount: { id: mount.id, name: mount.name },
1936
2304
  kind: 'file',
1937
2305
  relativePath: r.relativePath,
2306
+ uri: rowQtapUri(mount.name, mount.id, r.relativePath, ambiguousNames),
1938
2307
  size: r.fileSizeBytes,
1939
2308
  fileType: r.fileType,
1940
2309
  lastModified: r.lastModified,
@@ -1962,6 +2331,7 @@ async function handleFind(flags, positional) {
1962
2331
  mount: { id: mount.id, name: mount.name },
1963
2332
  kind: 'folder',
1964
2333
  relativePath: r.path,
2334
+ uri: rowQtapUri(mount.name, mount.id, r.path, ambiguousNames),
1965
2335
  name: r.name,
1966
2336
  updatedAt: r.updatedAt,
1967
2337
  });
@@ -1979,13 +2349,17 @@ async function handleFind(flags, positional) {
1979
2349
  console.log('(no matches)');
1980
2350
  return;
1981
2351
  }
1982
- // Plain text columns.
1983
- const headers = showMountColumn
2352
+ // Plain text columns. `--uri` swaps the mount/path columns for the single
2353
+ // canonical qtap:// URI.
2354
+ const headers = flags.uri
2355
+ ? ['uri', 'size', 'modified']
2356
+ : showMountColumn
1984
2357
  ? ['mount', 'path', 'size', 'modified']
1985
2358
  : ['path', 'size', 'modified'];
1986
2359
  const rows = out.map(r => {
1987
2360
  const size = r.size != null ? formatBytes(r.size) : '-';
1988
2361
  const mod = r.lastModified || r.updatedAt || '';
2362
+ if (flags.uri) return [r.uri, size, mod];
1989
2363
  return showMountColumn
1990
2364
  ? [r.mount.name, r.relativePath, size, mod]
1991
2365
  : [r.relativePath, size, mod];
@@ -2091,6 +2465,8 @@ async function handleSemanticGrep(flags, query) {
2091
2465
  }
2092
2466
 
2093
2467
  async function handleGrep(flags, positional) {
2468
+ // --mount may be given as a qtap://store/ URI; reduce it to the store spec.
2469
+ if (flags.mount && isQtapUri(flags.mount)) flags.mount = expandQtapArg(flags.mount)[0];
2094
2470
  const pattern = positional[0];
2095
2471
  if (!pattern) {
2096
2472
  console.error('Usage: quilltap docs grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] [--json] <pattern>');
@@ -2107,6 +2483,7 @@ async function handleGrep(flags, positional) {
2107
2483
  try {
2108
2484
  const mounts = resolveSearchMounts(db, flags.mount);
2109
2485
  const showMountColumn = !flags.mount || flags.mount === 'all';
2486
+ const ambiguousNames = loadAmbiguousMountNames(db);
2110
2487
 
2111
2488
  const linksByMount = db.prepare(`
2112
2489
  SELECT l.id AS linkId, l.fileId, l.relativePath, l.extractedText,
@@ -2148,6 +2525,7 @@ async function handleGrep(flags, positional) {
2148
2525
  results.push({
2149
2526
  mount: { id: mount.id, name: mount.name },
2150
2527
  relativePath: link.relativePath,
2528
+ uri: rowQtapUri(mount.name, mount.id, link.relativePath, ambiguousNames),
2151
2529
  matches,
2152
2530
  });
2153
2531
  }
@@ -2163,14 +2541,16 @@ async function handleGrep(flags, positional) {
2163
2541
  }
2164
2542
 
2165
2543
  for (const r of results) {
2166
- const prefix = showMountColumn ? `${r.mount.name}:` : '';
2544
+ // `--uri` uses the canonical qtap:// URI as the locator; otherwise the
2545
+ // familiar [mount:]path form.
2546
+ const locator = flags.uri ? r.uri : `${showMountColumn ? `${r.mount.name}:` : ''}${r.relativePath}`;
2167
2547
  if (flags.pathsOnly) {
2168
- console.log(`${prefix}${r.relativePath}`);
2548
+ console.log(locator);
2169
2549
  continue;
2170
2550
  }
2171
2551
  for (const m of r.matches) {
2172
2552
  const oneLine = m.snippet.replace(/\n/g, ' ⏎ ');
2173
- console.log(`${prefix}${r.relativePath}:${m.line}: ${oneLine}`);
2553
+ console.log(`${locator}:${m.line}: ${oneLine}`);
2174
2554
  }
2175
2555
  }
2176
2556
  } finally {
@@ -2529,7 +2909,9 @@ async function docsCommand(args) {
2529
2909
  process.exit(1);
2530
2910
  }
2531
2911
 
2532
- const { flags, positional } = parseFlags(args);
2912
+ const parsed = parseFlags(args);
2913
+ const flags = parsed.flags;
2914
+ let positional = parsed.positional;
2533
2915
 
2534
2916
  if (flags.help) {
2535
2917
  printDocsHelp();
@@ -2543,7 +2925,20 @@ async function docsCommand(args) {
2543
2925
 
2544
2926
  const verb = positional.shift();
2545
2927
 
2928
+ // Verbs that take positional `<mount> <relativePath>` accept a single
2929
+ // `qtap://…` URI in its place; expand it before dispatch so every handler
2930
+ // keeps consuming the familiar mount/path positionals. (find/grep address a
2931
+ // mount via `--mount`, handled inside those handlers; export's second
2932
+ // positional is a local output dir, so it is excluded.)
2933
+ const QTAP_POSITIONAL_VERBS = new Set([
2934
+ 'read', 'write', 'delete', 'mkdir', 'ls', 'dir', 'tree', 'files',
2935
+ 'move', 'copy', 'link', 'rmdir', 'mvdir', 'show', 'scan',
2936
+ ]);
2937
+
2546
2938
  try {
2939
+ if (QTAP_POSITIONAL_VERBS.has(verb)) {
2940
+ positional = expandQtapPositionals(positional);
2941
+ }
2547
2942
  switch (verb) {
2548
2943
  case 'list':
2549
2944
  await handleList(flags);
@@ -2585,6 +2980,15 @@ async function docsCommand(args) {
2585
2980
  case 'copy':
2586
2981
  await handleFileOp(flags, positional, 'copy');
2587
2982
  break;
2983
+ case 'link':
2984
+ await handleLink(flags, positional);
2985
+ break;
2986
+ case 'rmdir':
2987
+ await handleRmdir(flags, positional);
2988
+ break;
2989
+ case 'mvdir':
2990
+ await handleMvdir(flags, positional);
2991
+ break;
2588
2992
  case 'status':
2589
2993
  await handleStatus(flags);
2590
2994
  break;