From 20b937e18eb080f4ef27a1ccd574f862f73f2a6e Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 12 Dec 2025 15:55:21 -0500 Subject: [PATCH] Implement 'qmd ls' command for virtual file tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add listFiles() function to list collections and files - Support three modes: - qmd ls - list all collections with file counts - qmd ls - list all files in collection - qmd ls /path - list files under path - Support both virtual paths (qmd://) and short form - Add CLI command handler and help text - Add comprehensive tests (5 test cases, all passing) Closes qmd-e2c 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .beads/issues.jsonl | 4 +- cli.test.ts | 46 ++++++++++++++++++ qmd.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8c3be25..fb44112 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,10 +4,10 @@ {"id":"qmd-c0m","title":"Comprehensive CLI review and consistency pass","description":"Review entire CLI command structure:\n- Consistent naming (add vs create, remove vs delete)\n- Consistent flag usage (--name, --mask, etc)\n- Update help text for all commands\n- Ensure virtual paths work everywhere\n- Test all commands end-to-end","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-12T15:29:38.083564-05:00","updated_at":"2025-12-12T15:29:38.083564-05:00"} {"id":"qmd-deh","title":"Refactor database introduce qmd collection *","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T10:56:04.516137-05:00","updated_at":"2025-12-10T10:56:04.516137-05:00"} {"id":"qmd-dmi","title":"Implement 'qmd collection' commands","description":"Add explicit collection management:\n- qmd collection add . --name \u003cname\u003e --mask '**/*.md'\n- qmd collection list\n- qmd collection remove \u003cname\u003e\n\nThis gives users control over collection names and patterns.","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-12T15:29:53.810666-05:00","updated_at":"2025-12-12T15:29:53.810666-05:00","dependencies":[{"issue_id":"qmd-dmi","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.811294-05:00","created_by":"daemon"}]} -{"id":"qmd-e2c","title":"Implement 'qmd ls' command","description":"Add command to explore virtual file tree:\n- qmd ls → list all collections\n- qmd ls \u003ccollection\u003e → list files in collection\n- qmd ls \u003ccollection\u003e/\u003cpath\u003e → list files under path\nOutput: flat list of qmd:// paths","status":"open","priority":1,"issue_type":"feature","created_at":"2025-12-12T15:29:53.859804-05:00","updated_at":"2025-12-12T15:29:53.859804-05:00","dependencies":[{"issue_id":"qmd-e2c","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.860535-05:00","created_by":"daemon"}]} +{"id":"qmd-e2c","title":"Implement 'qmd ls' command","description":"Add command to explore virtual file tree:\n- qmd ls → list all collections\n- qmd ls \u003ccollection\u003e → list files in collection\n- qmd ls \u003ccollection\u003e/\u003cpath\u003e → list files under path\nOutput: flat list of qmd:// paths","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-12-12T15:29:53.859804-05:00","updated_at":"2025-12-12T15:55:12.777701-05:00","closed_at":"2025-12-12T15:55:12.777701-05:00","dependencies":[{"issue_id":"qmd-e2c","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.860535-05:00","created_by":"daemon"}]} {"id":"qmd-j9z","title":"Add unit tests for content addressable hashes","description":"add same file from multiple places and verify that they both point at same hash. drop one collection and the content stays.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-12T15:39:15.459504-05:00","updated_at":"2025-12-12T15:39:15.459504-05:00"} {"id":"qmd-p1h","title":"Create collection add|remove","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-10T10:57:00.717864-05:00","updated_at":"2025-12-10T10:57:00.717864-05:00"} {"id":"qmd-rhd","title":"Fix 'qmd status' output for new schema","description":"Update status to show collections by name, cleaner context display, virtual path examples.","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:54.020596-05:00","updated_at":"2025-12-12T15:29:54.020596-05:00","dependencies":[{"issue_id":"qmd-rhd","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:54.021095-05:00","created_by":"daemon"}]} {"id":"qmd-s1y","title":"Update 'qmd add-context' for collection scoping","description":"Update add-context to work with collection-scoped contexts using new path_contexts schema.","notes":"Refactoring to:\n- qmd context add [path] \"text\" (defaults to current collection if in one)\n- qmd context list\n- qmd context rm \u003cpath\u003e\n- Support \"/\" for global/system context\n- Auto-detect collection from pwd","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:54.076582-05:00","updated_at":"2025-12-12T15:37:47.683263-05:00","closed_at":"2025-12-12T15:37:47.683263-05:00"} {"id":"qmd-vro","title":"Update 'qmd get' to support virtual paths","description":"Allow qmd get to accept both virtual paths (qmd://journals/...) and filesystem paths, plus fuzzy matching by filename.","status":"closed","priority":0,"issue_type":"task","created_at":"2025-12-12T15:29:53.963113-05:00","updated_at":"2025-12-12T15:47:29.178955-05:00","closed_at":"2025-12-12T15:47:29.178955-05:00","dependencies":[{"issue_id":"qmd-vro","depends_on_id":"qmd-ama","type":"discovered-from","created_at":"2025-12-12T15:29:53.963641-05:00","created_by":"daemon"}]} -{"id":"qmd-x19","title":"Update 'qmd add-context' for collection-scoped contexts","description":"Update add-context to work with collections:\n- qmd add-context \u003ccollection\u003e/\u003cpath\u003e \"context description\"\n- Support both virtual and filesystem paths\n- Update to use new path_contexts schema","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:38.142575-05:00","updated_at":"2025-12-12T15:29:38.142575-05:00"} +{"id":"qmd-x19","title":"Update 'qmd add-context' for collection-scoped contexts","description":"Update add-context to work with collections:\n- qmd add-context \u003ccollection\u003e/\u003cpath\u003e \"context description\"\n- Support both virtual and filesystem paths\n- Update to use new path_contexts schema","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-12T15:29:38.142575-05:00","updated_at":"2025-12-12T15:53:00.525001-05:00","closed_at":"2025-12-12T15:53:00.525001-05:00"} diff --git a/cli.test.ts b/cli.test.ts index 7d48c02..3ac3862 100644 --- a/cli.test.ts +++ b/cli.test.ts @@ -514,3 +514,49 @@ describe("CLI Context Management", () => { expect(stderr || stdout).toContain("not found"); }); }); + +describe("CLI ls Command", () => { + let localDbPath: string; + + beforeEach(async () => { + // Use a fresh database for this test suite + localDbPath = getFreshDbPath(); + // Index some files first + await runQmd(["add", "."], { dbPath: localDbPath }); + }); + + test("lists all collections", async () => { + const { stdout, exitCode } = await runQmd(["ls"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Collections:"); + expect(stdout).toContain("qmd://fixtures/"); + }); + + test("lists files in a collection", async () => { + const { stdout, exitCode } = await runQmd(["ls", "fixtures"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + expect(stdout).toContain("qmd://fixtures/README.md"); + expect(stdout).toContain("qmd://fixtures/notes/meeting.md"); + }); + + test("lists files with path prefix", async () => { + const { stdout, exitCode } = await runQmd(["ls", "fixtures/notes"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + expect(stdout).toContain("qmd://fixtures/notes/meeting.md"); + expect(stdout).toContain("qmd://fixtures/notes/ideas.md"); + // Should not include files outside the prefix + expect(stdout).not.toContain("qmd://fixtures/README.md"); + }); + + test("lists files with virtual path", async () => { + const { stdout, exitCode } = await runQmd(["ls", "qmd://fixtures/docs"], { dbPath: localDbPath }); + expect(exitCode).toBe(0); + expect(stdout).toContain("qmd://fixtures/docs/api.md"); + }); + + test("handles non-existent collection", async () => { + const { stderr, exitCode } = await runQmd(["ls", "nonexistent"], { dbPath: localDbPath }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Collection not found"); + }); +}); diff --git a/qmd.ts b/qmd.ts index 393586e..a5b5b71 100755 --- a/qmd.ts +++ b/qmd.ts @@ -1125,6 +1125,110 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT } } +// List files in virtual file tree +function listFiles(pathArg?: string): void { + const db = getDb(); + + if (!pathArg) { + // No argument - list all collections + const collections = db.prepare(` + SELECT name, COUNT(d.id) as file_count + FROM collections c + LEFT JOIN documents d ON d.collection_id = c.id AND d.active = 1 + GROUP BY c.id, c.name + ORDER BY c.name + `).all() as { name: string; file_count: number }[]; + + if (collections.length === 0) { + console.log("No collections found. Run 'qmd add .' to index files."); + closeDb(); + return; + } + + console.log(`${c.bold}Collections:${c.reset}\n`); + for (const coll of collections) { + console.log(`${c.cyan}qmd://${coll.name}/${c.reset} (${coll.file_count} files)`); + } + closeDb(); + return; + } + + // Parse the path argument + let collectionName: string; + let pathPrefix: string | null = null; + + if (pathArg.startsWith('qmd://')) { + // Virtual path format: qmd://collection/path + const parsed = parseVirtualPath(pathArg); + if (!parsed) { + console.error(`Invalid virtual path: ${pathArg}`); + closeDb(); + process.exit(1); + } + collectionName = parsed.collectionName; + pathPrefix = parsed.path; + } else { + // Just collection name or collection/path + const parts = pathArg.split('/'); + collectionName = parts[0]; + if (parts.length > 1) { + pathPrefix = parts.slice(1).join('/'); + } + } + + // Get the collection + const coll = getCollectionByName(db, collectionName); + if (!coll) { + console.error(`Collection not found: ${collectionName}`); + console.error(`Run 'qmd ls' to see available collections.`); + closeDb(); + process.exit(1); + } + + // List files in the collection (optionally filtered by path prefix) + let query: string; + let params: any[]; + + if (pathPrefix) { + // List files under a specific path + query = ` + SELECT d.path + FROM documents d + WHERE d.collection_id = ? AND d.path LIKE ? AND d.active = 1 + ORDER BY d.path + `; + params = [coll.id, `${pathPrefix}%`]; + } else { + // List all files in the collection + query = ` + SELECT d.path + FROM documents d + WHERE d.collection_id = ? AND d.active = 1 + ORDER BY d.path + `; + params = [coll.id]; + } + + const files = db.prepare(query).all(...params) as { path: string }[]; + + if (files.length === 0) { + if (pathPrefix) { + console.log(`No files found under qmd://${collectionName}/${pathPrefix}`); + } else { + console.log(`No files found in collection: ${collectionName}`); + } + closeDb(); + return; + } + + // Output virtual paths + for (const file of files) { + console.log(buildVirtualPath(collectionName, file.path)); + } + + closeDb(); +} + async function dropCollection(globPattern: string): Promise { const db = getDb(); const pwd = getPwd(); @@ -2088,6 +2192,7 @@ function showHelp(): void { console.log(" qmd context rm - Remove context"); console.log(" qmd get [:line] [-l N] [--from N] - Get document (optionally from line, max N lines)"); console.log(" qmd multi-get [-l N] [--max-bytes N] - Get multiple docs by glob or comma-separated list"); + console.log(" qmd ls [collection[/path]] - List collections or files in a collection"); console.log(" qmd status - Show index status and collections"); console.log(" qmd update - Re-index all collections"); console.log(" qmd embed [-f] - Create vector embeddings (chunks ~6KB each)"); @@ -2264,6 +2369,11 @@ switch (cli.command) { break; } + case "ls": { + listFiles(cli.args[0]); + break; + } + case "status": showStatus(); break;