feat(cli): ./-prefix $PWD-relative --full-path; add --format <kind>

--full-path now ./-prefixes any path that resolves under $PWD, both for
search/query results and for get/multi-get headers. This makes the
output unambiguously a filesystem path — a bare 'notes/foo.md' could be
misread as a collection-relative qmd:// fragment, but './notes/foo.md'
cannot. Absolute realpaths (when the file is outside $PWD) are
unchanged. Extracted as renderFullPath() and reused across the three
call sites so the policy stays consistent.

New --format <kind> flag selects output format for search/query and
multi-get (cli|json|csv|md|xml|files). The legacy boolean aliases
(--json/--csv/--md/--xml/--files) still work for back-compat but are
removed from --help; the skill is updated to use --format.

ANSI colors and OSC 8 hyperlinks are already gated on process.stdout
.isTTY, so piped/agentic invocations get clean plain-text output with
no escape sequences. Verified via od -c on a piped 'qmd search' run.
This commit is contained in:
Tobi Lutke 2026-05-28 11:35:21 -07:00
parent 436420e927
commit 3de3162e1a
No known key found for this signature in database
4 changed files with 144 additions and 54 deletions

View File

@ -23,11 +23,17 @@
view (and the new `**file:**` line in `--md` output) always prints the full
`qmd://collection/path` URI so you can pipe it straight back into `qmd get`.
- `qmd search` / `qmd query` accept `--full-path` with the same semantics as
`qmd get`: the result label becomes the file's on-disk path — relative to
`$PWD` when the file lives in a subfolder of the current directory, absolute
realpath otherwise — and the per-result `#docid` is dropped because the path
is the identifier. Applies to all output formats (`cli`, `--json`, `--md`,
`--csv`, `--xml`, `--files`).
`qmd get`: the result label becomes the file's on-disk path — `./`-prefixed
relative path when the file lives in a subfolder of `$PWD`, absolute realpath
otherwise — and the per-result `#docid` is dropped because the path is the
identifier. The leading `./` is intentional so the output is unambiguously a
filesystem path. Applies to all output formats.
- `qmd get` and `qmd multi-get` now also use the `./`-prefixed convention when
`--full-path` renders a path under `$PWD`, matching `search`/`query`.
- New `--format <kind>` flag selects the output format (`cli` | `json` | `csv` |
`md` | `xml` | `files`) for `search`, `query`, and `multi-get`. The legacy
boolean aliases (`--json`/`--csv`/`--md`/`--xml`/`--files`) still work but are
no longer in `--help`; prefer `--format`.
### Docs

View File

@ -31,7 +31,7 @@ Typical loop:
```bash
qmd search "merchant reality support interviews" -n 5
# leads: #abc123 concepts/customer-proximity.md; #def432 sources/merchant-call.md
qmd multi-get "#abc123,#def432" --md
qmd multi-get "#abc123,#def432" --format md
```
**Default to structured `qmd query` with `intent:`, `lex:`, `vec:`, and `hyde:`
@ -89,7 +89,7 @@ If you genuinely have nothing to expand (a single rare token, a verbatim phrase)
that is a job for `qmd search`, not bare `qmd query`:
```bash
qmd query --json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking
qmd query --format json --explain $'intent: ...\nlex: ...\nvec: ...' # inspect ranking
```
If `qmd query` is slow or model/GPU setup fails, fall back to `qmd search` with
@ -102,8 +102,8 @@ Search results include docids like `#abc123` and `qmd://...` paths. Fetch them:
```bash
qmd get "#abc123"
qmd get qmd://concepts/ai-before-headcount.md
qmd multi-get "#abc123,#def432" --md
qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --md
qmd multi-get "#abc123,#def432" --format md
qmd multi-get 'concepts/{ai-before-headcount.md,data-informed-not-metric-driven.md}' --format md
qmd multi-get 'sources/podcast-2025-*.md' -l 80
```
@ -141,11 +141,13 @@ $ qmd get "#abc123" --full-path
```
`--full-path` works the same way on `qmd search` and `qmd query`: result paths
become the file's on-disk path — relative to `$PWD` when the file is inside the
current directory, absolute otherwise — and the per-result `#docid` is dropped
because the path is the identifier. Default search/query output still uses
`qmd://` URIs; only opt into `--full-path` when you specifically need a path you
can hand to a non-QMD tool.
become the file's on-disk path — `./`-prefixed relative path when the file is
inside `$PWD`, absolute realpath otherwise — and the per-result `#docid` is
dropped because the path is the identifier. The leading `./` is intentional so
the output is unambiguously a filesystem path and cannot be mistaken for a bare
collection-relative string. Default search/query output still uses `qmd://`
URIs; only opt into `--full-path` when you specifically need a path you can hand
to a non-QMD tool.
### Read line ranges with the `:from:count` suffix — never pipe through `sed`/`head`/`tail`

View File

@ -951,6 +951,28 @@ function contextRemove(pathArg: string): void {
console.log(`${c.green}${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
}
/**
* Render an absolute filesystem path for human display under --full-path.
*
* If the path is the current working directory or a subpath of it, return a
* "./"-prefixed relative path so it is unambiguously a filesystem path (not a
* bare collection-relative string that could be confused for a `qmd://`
* fragment). Otherwise return the absolute realpath so symlinks resolve
* consistently. Returns `null` if the path could not be normalized callers
* fall back to whatever they had before.
*/
function renderFullPath(absolutePath: string, cwd: string = process.cwd()): string {
let real: string;
try { real = realpathSync(absolutePath); } catch { real = absolutePath; }
const cwdReal = (() => { try { return realpathSync(cwd); } catch { return cwd; } })();
if (real === cwdReal) return "./";
if (real.startsWith(cwdReal + "/")) {
const rel = relativePath(cwdReal, real);
if (rel && !rel.startsWith("..")) return `./${rel}`;
}
return real;
}
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
@ -1140,7 +1162,7 @@ function getDocument(filename: string, fromLine?: number, maxLines?: number, lin
if (fullPath) {
const fsPath = resolveVirtualPath(db, canonicalPath);
if (fsPath && existsSync(fsPath)) {
header = fsPath;
header = renderFullPath(fsPath);
} else {
header = docid ? `${canonicalPath} #${docid}` : canonicalPath;
}
@ -1292,10 +1314,12 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
const docid = docidRow?.hash ? docidRow.hash.slice(0, 6) : undefined;
// --full-path: resolve the on-disk path when it exists (else fall back).
// Display as ./-prefixed relative path when under $PWD; absolute realpath
// otherwise. See renderFullPath() for the policy.
let fsPath: string | undefined;
if (fullPath) {
const resolved = resolveVirtualPath(db, file.filepath);
if (resolved && existsSync(resolved)) fsPath = resolved;
if (resolved && existsSync(resolved)) fsPath = renderFullPath(resolved);
}
// Check size limit
@ -2253,12 +2277,10 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
};
// Helper to pick the visible path for a result. With --full-path we swap
// the qmd:// URI for the file's on-disk path: relative to $PWD when the
// file lives inside the current working directory, otherwise absolute
// (realpath, so symlinks resolve consistently). Falls back to qmd:// if
// the file is no longer resolvable on disk.
// the qmd:// URI for the file's on-disk path via renderFullPath() (./-
// prefixed relative when under $PWD, absolute realpath otherwise). Falls
// back to qmd:// if the file is no longer resolvable on disk.
const linkDbForPaths = opts.fullPath ? getDb() : null;
const cwd = process.cwd();
const displayPathFor = (row: OutputRow): string => {
// Always rebuild from displayPath so the active index name is included
// as ?index=… for non-default indexes. row.file may not carry it.
@ -2266,17 +2288,7 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
if (!opts.fullPath || !linkDbForPaths) return qmdUri;
const absolute = resolveVirtualPath(linkDbForPaths, qmdUri);
if (!absolute || !existsSync(absolute)) return qmdUri;
let real: string;
try { real = realpathSync(absolute); } catch { real = absolute; }
const cwdReal = (() => { try { return realpathSync(cwd); } catch { return cwd; } })();
if (real === cwdReal) return ".";
if (real.startsWith(cwdReal + "/")) {
const rel = relativePath(cwdReal, real);
// Only show as relative when the file is *under* $PWD. If `relative()`
// produced a leading "../" (sibling/parent), fall back to absolute.
if (rel && !rel.startsWith("..")) return rel;
}
return real;
return renderFullPath(absolute);
};
if (opts.format === "json") {
@ -2839,6 +2851,9 @@ function parseCLI() {
"min-score": { type: "string" },
all: { type: "boolean" },
full: { type: "boolean" },
format: { type: "string" }, // preferred: --format cli|json|csv|md|xml|files
// Legacy boolean format aliases. Kept working for back-compat but
// omitted from the documented help; prefer `--format <kind>`.
csv: { type: "boolean" },
md: { type: "boolean" },
xml: { type: "boolean" },
@ -2901,9 +2916,21 @@ function parseCLI() {
}
}
// Determine output format
// Determine output format. Prefer --format <kind>; fall back to the
// legacy boolean aliases (--csv/--md/--xml/--files/--json) which remain
// wired up for back-compat but are no longer documented.
let format: OutputFormat = "cli";
if (values.csv) format = "csv";
const rawFormat = typeof values.format === "string" ? values.format.toLowerCase().trim() : "";
const VALID_FORMATS: ReadonlyArray<OutputFormat> = ["cli", "json", "csv", "md", "xml", "files"];
if (rawFormat) {
if ((VALID_FORMATS as ReadonlyArray<string>).includes(rawFormat)) {
format = rawFormat as OutputFormat;
} else {
console.error(`Unknown --format value: ${values.format}`);
console.error(`Valid: ${VALID_FORMATS.join(", ")}`);
process.exit(1);
}
} else if (values.csv) format = "csv";
else if (values.md) format = "md";
else if (values.xml) format = "xml";
else if (values.files) format = "files";
@ -3427,7 +3454,7 @@ function showHelp(): void {
console.log(" QMD_EDITOR_URI - Editor link template for clickable TTY search output");
console.log("");
console.log("Search options:");
console.log(" -n <num> - Max results (default 5, or 20 for --files/--json)");
console.log(" -n <num> - Max results (default 5, or 20 for --format files|json)");
console.log(" --all - Return all matches (pair with --min-score)");
console.log(" --min-score <num> - Minimum similarity score");
console.log(" --full - Output full document instead of snippet");
@ -3437,9 +3464,9 @@ function showHelp(): void {
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 - Show on-disk paths instead of qmd:// + docid (get/multi-get/search/query)");
console.log(" Paths are relative to $PWD when in a subfolder, absolute otherwise");
console.log(" --explain - Include retrieval score traces (query --json/CLI)");
console.log(" --files | --json | --csv | --md | --xml - Output format");
console.log(" Paths are ./-prefixed when under $PWD, absolute otherwise");
console.log(" --explain - Include retrieval score traces (query, CLI/--format json)");
console.log(" --format <kind> - Output format: cli (default) | json | csv | md | xml | files");
console.log(" -c, --collection <name> - Filter by one or more collections");
console.log("");
console.log("Embed/query options:");
@ -3448,7 +3475,7 @@ function showHelp(): void {
console.log("Multi-get options:");
console.log(" -l <num> - Maximum lines per file");
console.log(" --max-bytes <num> - Skip files larger than N bytes (default 10240)");
console.log(" --json/--csv/--md/--xml/--files - Same formats as search");
console.log(" --format <kind> - Same formats as search");
console.log("");
console.log(`Index: ${getDbPath()}`);
}
@ -4176,7 +4203,7 @@ if (isMain) {
case "multi-get": {
if (!cli.args[0]) {
console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--no-line-numbers] [--full-path] [--json|--csv|--md|--xml|--files]");
console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--no-line-numbers] [--full-path] [--format json|csv|md|xml|files]");
console.error(" pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list");
process.exit(1);
}

View File

@ -895,23 +895,38 @@ describe("CLI Multi-Get Command", () => {
expect(raw.stdout).not.toMatch(/^1: /m);
});
test("--full-path --md shows on-disk paths and drops the docid", async () => {
test("--full-path --md shows ./-prefixed on-disk paths and drops the docid", async () => {
// Default runQmd cwd is fixturesDir, so notes/*.md files are subpaths.
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");
// Headings are ./-prefixed relative paths under fixturesDir.
expect(stdout).toMatch(/^## \.\/notes\/[^\s]+\.md$/m);
expect(stdout).not.toContain("qmd://");
expect(stdout).not.toMatch(/\*\*docid:\*\*/);
});
test("--full-path --json puts the fs path in `file` and omits docid", async () => {
test("--full-path --json puts the ./-prefixed 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.file.startsWith("./notes/")).toBe(true);
expect(entry.docid).toBeUndefined();
}
});
test("--full-path --json uses absolute path when files are outside $PWD", async () => {
const { stdout, exitCode } = await runQmd(
["multi-get", "notes/*.md", "--json", "--full-path"],
{ dbPath: localDbPath, cwd: "/" }
);
expect(exitCode).toBe(0);
const parsed = JSON.parse(stdout);
expect(parsed.length).toBeGreaterThan(0);
for (const entry of parsed) {
expect(entry.file.startsWith("/")).toBe(true);
expect(entry.file).not.toMatch(/^\.\//);
expect(entry.docid).toBeUndefined();
}
});
@ -1678,7 +1693,7 @@ describe("search output formats", () => {
expect(result.docid).toBeUndefined();
});
test("search --full-path --json uses $PWD-relative path when in a parent of the file", async () => {
test("search --full-path --json uses ./-prefixed $PWD-relative path when in a parent of the file", async () => {
const { stdout, exitCode } = await runQmd(
["search", "test", "--full-path", "--json", "-n", "1"],
{ dbPath: localDbPath, configDir: localConfigDir, cwd: fixturesDir }
@ -1688,8 +1703,9 @@ describe("search output formats", () => {
expect(results.length).toBeGreaterThan(0);
const result = results[0];
expect(result.file).not.toMatch(/^qmd:\/\//);
// Should be relative (no leading slash) because the file is inside $PWD.
expect(result.file.startsWith("/")).toBe(false);
// Must start with "./" so it's unambiguously a filesystem path and not
// mistaken for a bare collection-relative string.
expect(result.file.startsWith("./")).toBe(true);
expect(result.file).not.toMatch(/^\.\.\//);
expect(result.file).toMatch(/\.md$/);
});
@ -1720,6 +1736,33 @@ describe("search output formats", () => {
expect(stdout).toMatch(/\*\*file:\*\* `\/.+\.md`/);
});
test("search --format json matches the legacy --json behavior", async () => {
const a = await runQmd(["search", "test", "--format", "json", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
const b = await runQmd(["search", "test", "--json", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(a.exitCode).toBe(0);
expect(b.exitCode).toBe(0);
// Both must yield valid JSON with at least one result.
const ar = JSON.parse(a.stdout);
const br = JSON.parse(b.stdout);
expect(ar.length).toBeGreaterThan(0);
expect(br.length).toBeGreaterThan(0);
// Identical first-result file path (the rest may differ in score formatting only).
expect(ar[0].file).toBe(br[0].file);
});
test("search --format md works equivalent to legacy --md", async () => {
const a = await runQmd(["search", "test", "--format", "md", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(a.exitCode).toBe(0);
expect(a.stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/);
expect(a.stdout).toMatch(new RegExp(`\\*\\*file:\\*\\* \`qmd://${collName}/`));
});
test("search --format with an unknown kind fails cleanly", async () => {
const { exitCode, stderr } = await runQmd(["search", "test", "--format", "yaml", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).not.toBe(0);
expect(stderr).toContain("Unknown --format value");
});
test("search default CLI format includes plain qmd:// path, docid, and context in non-TTY mode", async () => {
const { stdout, exitCode } = await runQmd(["search", "test", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
@ -1895,19 +1938,31 @@ describe("get command path normalization", () => {
expect(stdout).not.toMatch(/^1: /m);
});
test("get --full-path shows the on-disk path instead of qmd:// + docid", async () => {
test("get --full-path shows ./-prefixed path when file is under $PWD", async () => {
// Default runQmd cwd is fixturesDir, and test1.md lives in fixturesDir,
// so the rendered path must be relative-with-./ prefix.
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).toMatch(/^\.\/test1\.md$/m);
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 shows absolute path when file is outside $PWD", async () => {
const { stdout, exitCode } = await runQmd(
["get", `${collName}/test1.md`, "--full-path"],
{ dbPath: localDbPath, configDir: localConfigDir, cwd: "/" }
);
expect(exitCode).toBe(0);
// Absolute realpath (allow macOS /var → /private/var).
expect(stdout).toMatch(/^\/.+\/test1\.md$/m);
expect(stdout).not.toMatch(/^\.\//m);
expect(stdout).not.toContain("qmd://");
expect(stdout).not.toMatch(/#[a-f0-9]{6}/);
});
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");