Browse Source

Implement 'qmd ls' command for virtual file tree

- Add listFiles() function to list collections and files
- Support three modes:
  - qmd ls - list all collections with file counts
  - qmd ls <collection> - list all files in collection
  - qmd ls <collection>/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 <noreply@anthropic.com>
Tobi Lutke 5 months ago
parent
commit
20b937e18e
3 changed files with 158 additions and 2 deletions
  1. 2 2
      .beads/issues.jsonl
  2. 46 0
      cli.test.ts
  3. 110 0
      qmd.ts

+ 2 - 2
.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"}

+ 46 - 0
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");
+  });
+});

+ 110 - 0
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<void> {
   const db = getDb();
   const pwd = getPwd();
@@ -2088,6 +2192,7 @@ function showHelp(): void {
   console.log("  qmd context rm <path>         - Remove context");
   console.log("  qmd get <file>[:line] [-l N] [--from N]  - Get document (optionally from line, max N lines)");
   console.log("  qmd multi-get <pattern> [-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;