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:
parent
436420e927
commit
3de3162e1a
16
CHANGELOG.md
16
CHANGELOG.md
@ -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
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user