feat(get,multi-get): line-numbered + docid output, line ranges, --full-path

Redesign the get/multi-get retrieval surface so callers can cite what
they retrieved and request follow-up slices without piping through sed:

- Output is line-numbered by default; opt out with --no-line-numbers.
- Header always identifies the document by qmd:// path + #docid. The
  MCP get/multi_get tools default lineNumbers=true to match.
- qmd get and the MCP get tool accept a :from:count suffix on a path
  or docid (e.g. '#abc123:120:40' reads 40 lines from line 120).
  Explicit --from/-l flags still override the suffix.
- qmd multi-get now includes #docid in every output format (--md,
  --json, --csv, --xml, --files, default CLI), matching qmd search.
- New --full-path flag swaps the qmd:// + docid header for the
  document's on-disk path (handy for piping into Read/Edit/editors);
  falls back to the canonical header when the file no longer exists.
This commit is contained in:
Tobi Lutke 2026-05-28 10:55:55 -07:00
parent 443760f4d5
commit 41bc3a27d8
No known key found for this signature in database
3 changed files with 274 additions and 42 deletions

View File

@ -951,15 +951,26 @@ function contextRemove(pathArg: string): void {
console.log(`${c.green}${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
}
function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void {
// Parse :linenum suffix from filename (e.g., "file.md:100")
function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean, fullPath: boolean = false): void {
// Parse :line suffix from filename. Two forms:
// "file.md:100" -> start at line 100
// "file.md:100:40" -> start at line 100, read 40 lines
// The :// in virtual paths is never matched because we anchor digits to $.
// Explicit --from/-l flags always win over values parsed from the path.
let inputPath = filename;
const colonMatch = inputPath.match(/:(\d+)$/);
if (colonMatch && !fromLine) {
const matched = colonMatch[1];
if (matched) {
fromLine = parseInt(matched, 10);
inputPath = inputPath.slice(0, -colonMatch[0].length);
const rangeMatch = inputPath.match(/:(\d+):(\d+)$/);
if (rangeMatch) {
if (fromLine === undefined) fromLine = parseInt(rangeMatch[1]!, 10);
if (maxLines === undefined) maxLines = parseInt(rangeMatch[2]!, 10);
inputPath = inputPath.slice(0, -rangeMatch[0].length);
} else {
const colonMatch = inputPath.match(/:(\d+)$/);
if (colonMatch) {
const matched = colonMatch[1];
if (matched) {
if (fromLine === undefined) fromLine = parseInt(matched, 10);
inputPath = inputPath.slice(0, -colonMatch[0].length);
}
}
}
if (fromLine !== undefined) fromLine = Math.max(1, fromLine);
@ -1113,6 +1124,30 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
// Get context for this file
const context = getContextForPath(db, doc.collectionName, doc.path);
// Resolve the docid (first 6 chars of the content hash) so callers always
// know what they retrieved and can cite it back to `get`/`multi-get`.
const hashRow = db.prepare(`
SELECT d.hash as hash
FROM documents d
WHERE d.collection = ? AND d.path = ? AND d.active = 1
`).get(doc.collectionName, doc.path) as { hash: string } | null;
const docid = hashRow?.hash ? hashRow.hash.slice(0, 6) : undefined;
const canonicalPath = buildVirtualPath(doc.collectionName, doc.path);
// --full-path: show the on-disk path instead of the qmd:// URL + docid, when
// the file actually exists. Fall back to the canonical header otherwise.
let header: string;
if (fullPath) {
const fsPath = resolveVirtualPath(db, canonicalPath);
if (fsPath && existsSync(fsPath)) {
header = fsPath;
} else {
header = docid ? `${canonicalPath} #${docid}` : canonicalPath;
}
} else {
header = docid ? `${canonicalPath} #${docid}` : canonicalPath;
}
let output = doc.body;
const startLine = fromLine || 1;
@ -1124,21 +1159,25 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
output = lines.slice(start, end).join('\n');
}
// Add line numbers if requested
// Line numbers are on by default (disable with --no-line-numbers) so the
// model can cite exact lines and request follow-up ranges via path:from:count.
if (lineNumbers) {
output = addLineNumbers(output, startLine);
}
// Output context header if exists
// Header: identify the document (path + docid, or the on-disk path with
// --full-path), then optional context.
console.log(header);
if (context) {
console.log(`Folder Context: ${context}\n---\n`);
console.log(`Folder Context: ${context}`);
}
console.log("---\n");
console.log(output);
closeDb();
}
// Multi-get: fetch multiple documents by glob pattern or comma-separated list
function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli"): void {
function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli", lineNumbers: boolean = true, fullPath: boolean = false): void {
const db = getDb();
// Check if it's a comma-separated list or a glob pattern
@ -1226,7 +1265,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
}
// Collect results for structured output
const results: { file: string; displayPath: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = [];
const results: { file: string; displayPath: string; fsPath?: string; docid?: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = [];
for (const file of files) {
// Parse virtual path to get collection info if not already available
@ -1244,11 +1283,28 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
// Get context using collection-scoped function
const context = collection && path ? getContextForPath(db, collection, path) : null;
// Resolve docid (first 6 chars of content hash) so every entry can be cited.
const docidRow = collection && path ? db.prepare(`
SELECT d.hash as hash
FROM documents d
WHERE d.collection = ? AND d.path = ? AND d.active = 1
`).get(collection, path) as { hash: string } | null : null;
const docid = docidRow?.hash ? docidRow.hash.slice(0, 6) : undefined;
// --full-path: resolve the on-disk path when it exists (else fall back).
let fsPath: string | undefined;
if (fullPath) {
const resolved = resolveVirtualPath(db, file.filepath);
if (resolved && existsSync(resolved)) fsPath = resolved;
}
// Check size limit
if (file.bodyLength > maxBytes) {
results.push({
file: file.filepath,
displayPath: file.displayPath,
fsPath,
docid,
title: file.displayPath.split('/').pop() || file.displayPath,
body: "",
context,
@ -1281,9 +1337,16 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
}
}
// Line numbers on by default (disable with --no-line-numbers).
if (lineNumbers) {
body = addLineNumbers(body);
}
results.push({
file: file.filepath,
displayPath: file.displayPath,
fsPath,
docid,
title: doc.title || file.displayPath.split('/').pop() || file.displayPath,
body,
context,
@ -1293,14 +1356,23 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
closeDb();
// --full-path replaces the qmd:// path + docid with the on-disk path (when it
// resolved). Per result: pick the identifier and whether to show the docid.
const identOf = (r: typeof results[number]): string => (fullPath && r.fsPath) ? r.fsPath : r.displayPath;
const docidOf = (r: typeof results[number]): string | undefined => (fullPath && r.fsPath) ? undefined : r.docid;
// Output based on format
if (format === "json") {
const output = results.map(r => ({
file: r.displayPath,
title: r.title,
...(r.context && { context: r.context }),
...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
}));
const output = results.map(r => {
const docidVal = docidOf(r);
return {
file: identOf(r),
...(docidVal && { docid: `#${docidVal}` }),
title: r.title,
...(r.context && { context: r.context }),
...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
};
});
console.log(JSON.stringify(output, null, 2));
} else if (format === "csv") {
const escapeField = (val: string | null | undefined): string => {
@ -1311,19 +1383,24 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
}
return str;
};
console.log("file,title,context,skipped,body");
console.log("docid,file,title,context,skipped,body");
for (const r of results) {
console.log([r.displayPath, r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
const docidVal = docidOf(r);
console.log([docidVal ? `#${docidVal}` : "", identOf(r), r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
}
} else if (format === "files") {
for (const r of results) {
const docidVal = docidOf(r);
const id = docidVal ? `#${docidVal} ` : "";
const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
const status = r.skipped ? "[SKIPPED]" : "";
console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`);
console.log(`${id}${identOf(r)}${ctx}${status ? `,${status}` : ""}`);
}
} else if (format === "md") {
for (const r of results) {
console.log(`## ${r.displayPath}\n`);
const docidVal = docidOf(r);
console.log(`## ${identOf(r)}\n`);
if (docidVal) console.log(`**docid:** \`#${docidVal}\`\n`);
if (r.title && r.title !== r.displayPath) console.log(`**Title:** ${r.title}\n`);
if (r.context) console.log(`**Context:** ${r.context}\n`);
if (r.skipped) {
@ -1338,8 +1415,10 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
console.log('<?xml version="1.0" encoding="UTF-8"?>');
console.log("<documents>");
for (const r of results) {
console.log(" <document>");
console.log(` <file>${escapeXml(r.displayPath)}</file>`);
const docidVal = docidOf(r);
const docidAttr = docidVal ? ` docid="#${docidVal}"` : "";
console.log(` <document${docidAttr}>`);
console.log(` <file>${escapeXml(identOf(r))}</file>`);
console.log(` <title>${escapeXml(r.title)}</title>`);
if (r.context) console.log(` <context>${escapeXml(r.context)}</context>`);
if (r.skipped) {
@ -1354,8 +1433,10 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
} else {
// CLI format (default)
for (const r of results) {
const docidVal = docidOf(r);
const id = docidVal ? ` #${docidVal}` : "";
console.log(`\n${'='.repeat(60)}`);
console.log(`File: ${r.displayPath}`);
console.log(`File: ${identOf(r)}${id}`);
console.log(`${'='.repeat(60)}\n`);
if (r.skipped) {
@ -2726,7 +2807,9 @@ function parseCLI() {
l: { type: "string" }, // max lines
from: { type: "string" }, // start line
"max-bytes": { type: "string" }, // max bytes for multi-get
"line-numbers": { type: "boolean" }, // add line numbers to output
"line-numbers": { type: "boolean" }, // add line numbers to output (search; default on for get/multi-get)
"no-line-numbers": { type: "boolean" }, // disable line numbers for get/multi-get
"full-path": { type: "boolean" }, // get/multi-get: show on-disk path instead of qmd:// + docid
// Query options
"candidate-limit": { type: "string", short: "C" },
"no-rerank": { type: "boolean", default: false },
@ -3222,7 +3305,7 @@ function showHelp(): void {
console.log(" qmd query 'lex:..\\nvec:...' - Structured query document (you provide lex/vec/hyde lines)");
console.log(" qmd search <query> - Full-text BM25 keywords (no LLM)");
console.log(" qmd vsearch <query> - Vector similarity only");
console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
console.log(" qmd get <file>[:from[:count]] - Show a document (line-numbered; #docid in header)");
console.log(" qmd multi-get <pattern> - Batch fetch via glob or comma-separated list");
console.log(" qmd skills list/get/path - List and retrieve bundled runtime skills");
console.log(" qmd skill show/install - Show or install the QMD skill");
@ -3297,7 +3380,9 @@ function showHelp(): void {
console.log(" -C, --candidate-limit <n> - Max candidates to rerank (default 40, lower = faster)");
console.log(" --no-rerank - Skip LLM reranking (use RRF scores only, much faster on CPU)");
console.log(" --no-gpu - Force CPU mode for llama.cpp operations (same as QMD_FORCE_CPU=1)");
console.log(" --line-numbers - Include line numbers in output");
console.log(" --line-numbers - Include line numbers (search; get/multi-get are on by default)");
console.log(" --no-line-numbers - Disable line numbers for get/multi-get");
console.log(" --full-path - get/multi-get: show on-disk path instead of qmd:// + docid (if file exists)");
console.log(" --explain - Include retrieval score traces (query --json/CLI)");
console.log(" --files | --json | --csv | --md | --xml - Output format");
console.log(" -c, --collection <name> - Filter by one or more collections");
@ -4023,24 +4108,28 @@ if (isMain) {
case "get": {
if (!cli.args[0]) {
console.error("Usage: qmd get <filepath>[:line] [--from <line>] [-l <lines>] [--line-numbers]");
console.error("Usage: qmd get <filepath>[:from[:count]] [--from <line>] [-l <lines>] [--no-line-numbers] [--full-path]");
process.exit(1);
}
const fromLine = cli.values.from ? parseInt(cli.values.from as string, 10) : undefined;
const maxLines = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
getDocument(cli.args[0], fromLine, maxLines, cli.opts.lineNumbers);
// Line numbers default ON for get; opt out with --no-line-numbers.
const getLineNumbers = !cli.values["no-line-numbers"];
getDocument(cli.args[0], fromLine, maxLines, getLineNumbers, !!cli.values["full-path"]);
break;
}
case "multi-get": {
if (!cli.args[0]) {
console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--json|--csv|--md|--xml|--files]");
console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--no-line-numbers] [--full-path] [--json|--csv|--md|--xml|--files]");
console.error(" pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list");
process.exit(1);
}
const maxLinesMulti = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
const maxBytes = cli.values["max-bytes"] ? parseInt(cli.values["max-bytes"] as string, 10) : DEFAULT_MULTI_GET_MAX_BYTES;
multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format);
// Line numbers default ON for multi-get; opt out with --no-line-numbers.
const mgLineNumbers = !cli.values["no-line-numbers"];
multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format, mgLineNumbers, !!cli.values["full-path"]);
break;
}

View File

@ -372,20 +372,29 @@ Intent-aware lex (C++ performance, not sports):
description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
annotations: { readOnlyHint: true, openWorldHint: false },
inputSchema: {
file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"),
file: z.string().describe("File path or docid from search results. Supports a line-range suffix: 'pages/meeting.md:100' starts at line 100; 'pages/meeting.md:100:40' (or '#abc123:100:40') reads 40 lines from line 100."),
fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
maxLines: z.number().optional().describe("Maximum number of lines to return"),
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."),
},
},
async ({ file, fromLine, maxLines, lineNumbers }) => {
// Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
// Support :line and :from:count suffixes in `file` (e.g. "foo.md:120" or
// "foo.md:120:40"). Explicit fromLine/maxLines args take precedence.
let parsedFromLine = fromLine;
let parsedMaxLines = maxLines;
let lookup = file;
const colonMatch = lookup.match(/:(\d+)$/);
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
parsedFromLine = parseInt(colonMatch[1], 10);
lookup = lookup.slice(0, -colonMatch[0].length);
const rangeMatch = lookup.match(/:(\d+):(\d+)$/);
if (rangeMatch) {
if (parsedFromLine === undefined) parsedFromLine = parseInt(rangeMatch[1]!, 10);
if (parsedMaxLines === undefined) parsedMaxLines = parseInt(rangeMatch[2]!, 10);
lookup = lookup.slice(0, -rangeMatch[0].length);
} else {
const colonMatch = lookup.match(/:(\d+)$/);
if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
parsedFromLine = parseInt(colonMatch[1], 10);
lookup = lookup.slice(0, -colonMatch[0].length);
}
}
if (parsedFromLine !== undefined) parsedFromLine = Math.max(1, parsedFromLine);
@ -402,7 +411,7 @@ Intent-aware lex (C++ performance, not sports):
};
}
const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? "";
const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines: parsedMaxLines }) ?? "";
let text = body;
if (lineNumbers) {
const startLine = parsedFromLine || 1;
@ -441,7 +450,7 @@ Intent-aware lex (C++ performance, not sports):
pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
maxLines: z.number().optional().describe("Maximum lines per file"),
maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
lineNumbers: z.boolean().optional().default(true).describe("Add line numbers to output (format: 'N: content'). On by default; set false for raw content."),
},
},
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {

View File

@ -867,6 +867,54 @@ describe("CLI Multi-Get Command", () => {
expect(stdout).toContain("Test Project");
expect(stdout).toContain("Team Meeting");
});
test("--md output includes a #docid for each file", async () => {
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--md"], { dbPath: localDbPath });
expect(exitCode).toBe(0);
// Every result carries a docid line, consistent with `search --md`.
expect(stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/);
});
test("--json output includes a #docid for each file", async () => {
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--json"], { dbPath: localDbPath });
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout);
expect(parsed.length).toBeGreaterThan(0);
for (const entry of parsed) {
expect(entry.docid).toMatch(/^#[a-f0-9]{6}$/);
}
});
test("shows line numbers by default and --no-line-numbers disables them", async () => {
const withNums = await runQmd(["multi-get", "README.md"], { dbPath: localDbPath });
expect(withNums.exitCode).toBe(0);
expect(withNums.stdout).toMatch(/^1: /m);
const raw = await runQmd(["multi-get", "README.md", "--no-line-numbers"], { dbPath: localDbPath });
expect(raw.exitCode).toBe(0);
expect(raw.stdout).not.toMatch(/^1: /m);
});
test("--full-path --md shows on-disk paths and drops the docid", async () => {
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--md", "--full-path"], { dbPath: localDbPath });
expect(exitCode).toBe(0);
// Headings are absolute filesystem paths under the fixtures dir.
expect(stdout).toContain(`## ${fixturesDir}`);
expect(stdout).toContain("notes/meeting.md");
expect(stdout).not.toContain("qmd://");
expect(stdout).not.toMatch(/\*\*docid:\*\*/);
});
test("--full-path --json puts the fs path in `file` and omits docid", async () => {
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md", "--json", "--full-path"], { dbPath: localDbPath });
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout);
expect(parsed.length).toBeGreaterThan(0);
for (const entry of parsed) {
expect(entry.file.startsWith(fixturesDir)).toBe(true);
expect(entry.docid).toBeUndefined();
}
});
});
describe("CLI Update Command", () => {
@ -1719,6 +1767,92 @@ describe("get command path normalization", () => {
// Should start from line 3, not line 1
expect(stdout).not.toMatch(/^# Test Document 1$/m);
});
test("get with path:from:count format reads a bounded range", async () => {
// Lines: 1 "# Test Document 1", 5 "It has multiple lines...",
// 6 "Line 6 is here.", 7 "Line 7 is here."
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).toContain("It has multiple lines");
expect(stdout).toContain("Line 6 is here.");
// Bounded to 2 lines: must not include the start of the file or line 7
expect(stdout).not.toMatch(/^# Test Document 1$/m);
expect(stdout).not.toContain("Line 7 is here.");
});
test("get with qmd://path:from:count format reads a bounded range", async () => {
const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).toContain("It has multiple lines");
expect(stdout).toContain("Line 6 is here.");
expect(stdout).not.toMatch(/^# Test Document 1$/m);
expect(stdout).not.toContain("Line 7 is here.");
});
test("explicit -l overrides the :count in path:from:count", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`, "-l", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).toContain("It has multiple lines");
expect(stdout).not.toContain("Line 6 is here.");
});
test("get header includes canonical qmd:// path and a #docid", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
// First line of output identifies the document by path + docid.
expect(stdout).toMatch(new RegExp(`^qmd://${collName}/test1\\.md\\s+#[a-f0-9]{6}`, "m"));
});
test("get shows line numbers by default", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).toMatch(/^1: # Test Document 1$/m);
expect(stdout).toMatch(/^6: Line 6 is here\.$/m);
});
test("get --no-line-numbers returns raw content", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "--no-line-numbers"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
expect(stdout).not.toMatch(/^1: /m);
expect(stdout).toMatch(/^# Test Document 1$/m);
});
test("get line numbers reflect the start line of a range", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:5:2`], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
// Numbering starts at the requested line, not at 1.
expect(stdout).toMatch(/^5: It has multiple lines/m);
expect(stdout).not.toMatch(/^1: /m);
});
test("get --full-path shows the on-disk path instead of qmd:// + docid", async () => {
const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "--full-path"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
// Header is an absolute filesystem path ending in the file; no qmd:// URL, no docid.
// (Use a loose match so macOS /var → /private/var symlink normalization is fine.)
expect(stdout).toMatch(/^\/.+\/test1\.md$/m);
expect(stdout).toContain("test1.md");
expect(stdout).not.toContain("qmd://");
expect(stdout).not.toMatch(/#[a-f0-9]{6}/);
// Body still present and line-numbered.
expect(stdout).toMatch(/^1: # Test Document 1$/m);
});
test("get --full-path falls back to qmd:// + docid when the file is gone", async () => {
// Index a doc, then delete the underlying file so the fs path no longer exists.
const env = await createIsolatedTestEnv("full-path-fallback");
const collectionDir = join(testDir, `gone-fixtures-${Date.now()}`);
await mkdir(collectionDir, { recursive: true });
const gonePath = join(collectionDir, "gone.md");
await writeFile(gonePath, "# Gone\n\nbody line\n");
const add = await runQmd(["collection", "add", collectionDir, "--name", "gonecoll"], { dbPath: env.dbPath, configDir: env.configDir });
expect(add.exitCode).toBe(0);
await rm(gonePath);
const { stdout, exitCode } = await runQmd(["get", "gonecoll/gone.md", "--full-path"], { dbPath: env.dbPath, configDir: env.configDir });
expect(exitCode).toBe(0);
expect(stdout).toMatch(new RegExp(`^qmd://gonecoll/gone\\.md\\s+#[a-f0-9]{6}`, "m"));
});
});
// =============================================================================