diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c03f5..0062701 100644 --- a/CHANGELOG.md +++ b/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 ` 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 diff --git a/skills/qmd/SKILL.md b/skills/qmd/SKILL.md index 2922af6..0d4b048 100644 --- a/skills/qmd/SKILL.md +++ b/skills/qmd/SKILL.md @@ -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` diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index 4272ecf..c8bc464 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -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 `. csv: { type: "boolean" }, md: { type: "boolean" }, xml: { type: "boolean" }, @@ -2901,9 +2916,21 @@ function parseCLI() { } } - // Determine output format + // Determine output format. Prefer --format ; 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 = ["cli", "json", "csv", "md", "xml", "files"]; + if (rawFormat) { + if ((VALID_FORMATS as ReadonlyArray).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 - Max results (default 5, or 20 for --files/--json)"); + console.log(" -n - Max results (default 5, or 20 for --format files|json)"); console.log(" --all - Return all matches (pair with --min-score)"); console.log(" --min-score - 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 - Output format: cli (default) | json | csv | md | xml | files"); console.log(" -c, --collection - 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 - Maximum lines per file"); console.log(" --max-bytes - Skip files larger than N bytes (default 10240)"); - console.log(" --json/--csv/--md/--xml/--files - Same formats as search"); + console.log(" --format - 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 [-l ] [--max-bytes ] [--no-line-numbers] [--full-path] [--json|--csv|--md|--xml|--files]"); + console.error("Usage: qmd multi-get [-l ] [--max-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); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 8816801..40484b3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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");