refactor: move CLI and MCP to subdirectories, MCP consumes SDK

Move frontends into src/cli/ and src/mcp/ to separate them from the
core library. The MCP server is fully rewritten to import only from
the SDK (src/index.ts) — zero direct store.ts/collections.ts/llm.ts
access.

- src/qmd.ts → src/cli/qmd.ts
- src/formatter.ts → src/cli/formatter.ts
- src/mcp.ts → src/mcp/server.ts (rewritten to use QMDStore SDK)
- New src/maintenance.ts: Maintenance class for CLI housekeeping
- SDK gains: getDocumentBody(), getDefaultCollectionNames(),
  extractSnippet/addLineNumbers/DEFAULT_MULTI_GET_MAX_BYTES exports,
  getDefaultDbPath re-export, InternalStore type export
- package.json bin/scripts updated for new paths
- All 692 tests pass
This commit is contained in:
Tobi Lutke 2026-03-10 11:39:55 -04:00
parent 839d774a06
commit c68904fe08
No known key found for this signature in database
14 changed files with 197 additions and 106 deletions

View File

@ -118,7 +118,7 @@ qmd multi-get "#abc123, #def456"
## Development
```sh
bun src/qmd.ts <command> # Run from source
bun src/cli/qmd.ts <command> # Run from source
bun link # Install globally as 'qmd'
```

View File

@ -12,7 +12,7 @@
}
},
"bin": {
"qmd": "dist/qmd.js"
"qmd": "dist/cli/qmd.js"
},
"files": [
"dist/",
@ -21,15 +21,15 @@
],
"scripts": {
"prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true",
"build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/qmd.js > dist/qmd.tmp && mv dist/qmd.tmp dist/qmd.js && chmod +x dist/qmd.js",
"build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/cli/qmd.js > dist/cli/qmd.tmp && mv dist/cli/qmd.tmp dist/cli/qmd.js && chmod +x dist/cli/qmd.js",
"test": "vitest run --reporter=verbose test/",
"qmd": "tsx src/qmd.ts",
"index": "tsx src/qmd.ts index",
"vector": "tsx src/qmd.ts vector",
"search": "tsx src/qmd.ts search",
"vsearch": "tsx src/qmd.ts vsearch",
"rerank": "tsx src/qmd.ts rerank",
"inspector": "npx @modelcontextprotocol/inspector tsx src/qmd.ts mcp",
"qmd": "tsx src/cli/qmd.ts",
"index": "tsx src/cli/qmd.ts index",
"vector": "tsx src/cli/qmd.ts vector",
"search": "tsx src/cli/qmd.ts search",
"vsearch": "tsx src/cli/qmd.ts vsearch",
"rerank": "tsx src/cli/qmd.ts rerank",
"inspector": "npx @modelcontextprotocol/inspector tsx src/cli/qmd.ts mcp",
"release": "./scripts/release.sh"
},
"publishConfig": {

View File

@ -5,8 +5,8 @@
* JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
*/
import { extractSnippet } from "./store.js";
import type { SearchResult, MultiGetResult, DocumentResult } from "./store.js";
import { extractSnippet } from "../store.js";
import type { SearchResult, MultiGetResult, DocumentResult } from "../store.js";
// =============================================================================
// Types

View File

@ -1,5 +1,5 @@
import { openDatabase } from "./db.js";
import type { Database } from "./db.js";
import { openDatabase } from "../db.js";
import type { Database } from "../db.js";
import fastGlob from "fast-glob";
import { execSync, spawn as nodeSpawn } from "child_process";
import { fileURLToPath } from "url";
@ -73,8 +73,8 @@ import {
generateEmbeddings,
syncConfigToDb,
type ReindexResult,
} from "./store.js";
import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "./llm.js";
} from "../store.js";
import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
import {
formatSearchResults,
formatDocuments,
@ -94,7 +94,7 @@ import {
listAllContexts,
setConfigIndexName,
loadConfig,
} from "./collections.js";
} from "../collections.js";
// Enable production mode - allows using default database path
// Tests must set INDEX_PATH or use createStore() with explicit path
@ -1400,7 +1400,7 @@ async function collectionAdd(pwd: string, globPattern: string, name?: string): P
}
// Add to YAML config + sync to SQLite
const { addCollection } = await import("./collections.js");
const { addCollection } = await import("../collections.js");
addCollection(collName, pwd, globPattern);
resyncConfig();
@ -2395,7 +2395,7 @@ function parseCLI() {
function showSkill(): void {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const relativePath = pathJoin("skills", "qmd", "SKILL.md");
const skillPath = pathJoin(scriptDir, "..", relativePath);
const skillPath = pathJoin(scriptDir, "..", "..", relativePath);
console.log(`QMD Skill (${relativePath})`);
console.log(`Location: ${skillPath}`);
@ -2499,7 +2499,7 @@ function showHelp(): void {
async function showVersion(): Promise<void> {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(scriptDir, "..", "package.json");
const pkgPath = resolve(scriptDir, "..", "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
let commit = "";
@ -2694,7 +2694,7 @@ if (isMain) {
console.error(" Omit command to clear it");
process.exit(1);
}
const { updateCollectionSettings, getCollection } = await import("./collections.js");
const { updateCollectionSettings, getCollection } = await import("../collections.js");
const col = getCollection(name);
if (!col) {
console.error(`Collection not found: ${name}`);
@ -2717,7 +2717,7 @@ if (isMain) {
console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
process.exit(1);
}
const { updateCollectionSettings, getCollection } = await import("./collections.js");
const { updateCollectionSettings, getCollection } = await import("../collections.js");
const col = getCollection(name);
if (!col) {
console.error(`Collection not found: ${name}`);
@ -2736,7 +2736,7 @@ if (isMain) {
console.error("Usage: qmd collection show <name>");
process.exit(1);
}
const { getCollection } = await import("./collections.js");
const { getCollection } = await import("../collections.js");
const col = getCollection(name);
if (!col) {
console.error(`Collection not found: ${name}`);
@ -2896,7 +2896,7 @@ if (isMain) {
const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
const selfPath = fileURLToPath(import.meta.url);
const spawnArgs = selfPath.endsWith(".ts")
? ["--import", pathJoin(dirname(selfPath), "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
: [selfPath, "mcp", "--http", "--port", String(port)];
const child = nodeSpawn(process.execPath, spawnArgs, {
stdio: ["ignore", logFd, logFd],
@ -2915,7 +2915,7 @@ if (isMain) {
// async cleanup handlers in startMcpHttpServer actually run.
process.removeAllListeners("SIGTERM");
process.removeAllListeners("SIGINT");
const { startMcpHttpServer } = await import("./mcp.js");
const { startMcpHttpServer } = await import("../mcp/server.js");
try {
await startMcpHttpServer(port);
} catch (e: any) {
@ -2927,7 +2927,7 @@ if (isMain) {
}
} else {
// Default: stdio transport
const { startMcpServer } = await import("./mcp.js");
const { startMcpServer } = await import("../mcp/server.js");
await startMcpServer();
}
break;

View File

@ -21,7 +21,10 @@ import {
createStore as createStoreInternal,
hybridQuery,
structuredSearch,
extractSnippet,
addLineNumbers,
DEFAULT_EMBED_MODEL,
DEFAULT_MULTI_GET_MAX_BYTES,
reindexCollection,
generateEmbeddings,
listCollections as storeListCollections,
@ -36,6 +39,12 @@ import {
updateStoreContext,
removeStoreContext,
setStoreGlobalContext,
vacuumDatabase,
cleanupOrphanedContent,
cleanupOrphanedVectors,
deleteLLMCache,
deleteInactiveDocuments,
clearAllEmbeddings,
type Store as InternalStore,
type DocumentResult,
type DocumentNotFound,
@ -96,6 +105,18 @@ export type {
ContextMap,
};
// Re-export the internal Store type for advanced consumers
export type { InternalStore };
// Re-export utility functions used by frontends
export { extractSnippet, addLineNumbers, DEFAULT_MULTI_GET_MAX_BYTES };
// Re-export getDefaultDbPath for CLI/MCP that need the default database location
export { getDefaultDbPath } from "./store.js";
// Re-export Maintenance class for CLI housekeeping operations
export { Maintenance } from "./maintenance.js";
/**
* Progress info emitted during update() for each file processed.
*/
@ -213,6 +234,9 @@ export interface QMDStore {
/** Get a single document by path or docid */
get(pathOrDocid: string, options?: { includeBody?: boolean }): Promise<DocumentResult | DocumentNotFound>;
/** Get the body content of a document, optionally sliced by line range */
getDocumentBody(pathOrDocid: string, opts?: { fromLine?: number; maxLines?: number }): Promise<string | null>;
/** Get multiple documents by glob pattern or comma-separated list */
multiGet(pattern: string, options?: { includeBody?: boolean; maxBytes?: number }): Promise<{ docs: MultiGetResult[]; errors: string[] }>;
@ -228,7 +252,10 @@ export interface QMDStore {
renameCollection(oldName: string, newName: string): Promise<boolean>;
/** List all collections with document stats */
listCollections(): Promise<{ name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null }[]>;
listCollections(): Promise<{ name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null; includeByDefault: boolean }[]>;
/** Get names of collections included by default in queries */
getDefaultCollectionNames(): Promise<string[]>;
// ── Context Management ──────────────────────────────────────────────
@ -379,6 +406,11 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
searchVector: async (q, opts) => internal.searchVec(q, DEFAULT_EMBED_MODEL, opts?.limit, opts?.collection),
expandQuery: async (q, opts) => internal.expandQuery(q, undefined, opts?.intent),
get: async (pathOrDocid, opts) => internal.findDocument(pathOrDocid, opts),
getDocumentBody: async (pathOrDocid, opts) => {
const result = internal.findDocument(pathOrDocid, { includeBody: false });
if ("error" in result) return null;
return internal.getDocumentBody(result, opts?.fromLine, opts?.maxLines);
},
multiGet: async (pattern, opts) => internal.findDocuments(pattern, opts),
// Collection Management — write to SQLite + write-through to YAML/inline if configured
@ -403,6 +435,10 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
return result;
},
listCollections: async () => storeListCollections(db),
getDefaultCollectionNames: async () => {
const collections = storeListCollections(db);
return collections.filter(c => c.includeByDefault).map(c => c.name);
},
// Context Management — write to SQLite + write-through to YAML/inline if configured
addContext: async (collectionName, pathPrefix, contextText) => {

View File

@ -1440,6 +1440,25 @@ export async function withLLMSession<T>(
}
}
/**
* Execute a function with a scoped LLM session using a specific LlamaCpp instance.
* Unlike withLLMSession, this does not use the global singleton.
*/
export async function withLLMSessionForLlm<T>(
llm: LlamaCpp,
fn: (session: ILLMSession) => Promise<T>,
options?: LLMSessionOptions
): Promise<T> {
const manager = new LLMSessionManager(llm);
const session = new LLMSession(manager, options);
try {
return await fn(session);
} finally {
session.release();
}
}
/**
* Check if idle unload is safe (no active sessions or operations).
* Used internally by LlamaCpp idle timer.

54
src/maintenance.ts Normal file
View File

@ -0,0 +1,54 @@
/**
* Maintenance - Database cleanup operations for QMD.
*
* Wraps low-level store operations that the CLI needs for housekeeping.
* Takes an internal Store in the constructor allowed to access DB directly.
*/
import type { Store } from "./store.js";
import {
vacuumDatabase,
cleanupOrphanedContent,
cleanupOrphanedVectors,
deleteLLMCache,
deleteInactiveDocuments,
clearAllEmbeddings,
} from "./store.js";
export class Maintenance {
private store: Store;
constructor(store: Store) {
this.store = store;
}
/** Run VACUUM on the SQLite database to reclaim space */
vacuum(): void {
vacuumDatabase(this.store.db);
}
/** Remove content rows that are no longer referenced by any document */
cleanupOrphanedContent(): number {
return cleanupOrphanedContent(this.store.db);
}
/** Remove vector embeddings for content that no longer exists */
cleanupOrphanedVectors(): number {
return cleanupOrphanedVectors(this.store.db);
}
/** Clear the LLM response cache (query expansion, reranking) */
clearLLMCache(): number {
return deleteLLMCache(this.store.db);
}
/** Delete documents marked as inactive (removed from filesystem) */
deleteInactiveDocs(): number {
return deleteInactiveDocuments(this.store.db);
}
/** Clear all vector embeddings (forces re-embedding) */
clearEmbeddings(): void {
clearAllEmbeddings(this.store.db);
}
}

View File

@ -20,12 +20,12 @@ import {
createStore,
extractSnippet,
addLineNumbers,
structuredSearch,
getDefaultDbPath,
DEFAULT_MULTI_GET_MAX_BYTES,
} from "./store.js";
import type { Store, ExpandedQuery } from "./store.js";
import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js";
import { disposeDefaultLlamaCpp } from "./llm.js";
type QMDStore,
type ExpandedQuery,
type IndexStatus,
} from "../index.js";
// =============================================================================
// Types for structured content
@ -46,8 +46,8 @@ type StatusResult = {
hasVectorIndex: boolean;
collections: {
name: string;
path: string;
pattern: string;
path: string | null;
pattern: string | null;
documents: number;
lastUpdated: string;
}[];
@ -89,12 +89,13 @@ function formatSearchSummary(results: SearchResultItem[], query: string): string
* Injected into the LLM's system prompt via MCP initialize response
* gives the LLM immediate context about what's searchable without a tool call.
*/
function buildInstructions(store: Store): string {
const status = store.getStatus();
async function buildInstructions(store: QMDStore): Promise<string> {
const status = await store.getStatus();
const contexts = await store.listContexts();
const globalCtx = await store.getGlobalContext();
const lines: string[] = [];
// --- What is this? ---
const globalCtx = getGlobalContext();
lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
if (globalCtx) lines.push(`Context: ${globalCtx}`);
@ -103,9 +104,9 @@ function buildInstructions(store: Store): string {
lines.push("");
lines.push("Collections (scope with `collection` parameter):");
for (const col of status.collections) {
const collConfig = getCollection(col.name);
const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"];
const desc = rootCtx ? `${rootCtx}` : "";
// Find root context for this collection
const rootCtx = contexts.find(c => c.collection === col.name && (c.path === "" || c.path === "/"));
const desc = rootCtx ? `${rootCtx.context}` : "";
lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
}
}
@ -154,12 +155,15 @@ function buildInstructions(store: Store): string {
* Create an MCP server with all QMD tools, resources, and prompts registered.
* Shared by both stdio and HTTP transports.
*/
function createMcpServer(store: Store): McpServer {
async function createMcpServer(store: QMDStore): Promise<McpServer> {
const server = new McpServer(
{ name: "qmd", version: "0.9.9" },
{ instructions: buildInstructions(store) },
{ instructions: await buildInstructions(store) },
);
// Pre-fetch default collection names for search tools
const defaultCollectionNames = await store.getDefaultCollectionNames();
// ---------------------------------------------------------------------------
// Resource: qmd://{path} - read-only access to documents by path
// Note: No list() - documents are discovered via search tools
@ -178,49 +182,23 @@ function createMcpServer(store: Store): McpServer {
const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
const decodedPath = decodeURIComponent(pathStr);
// Parse virtual path: collection/relative/path
const parts = decodedPath.split('/');
const collection = parts[0] || '';
const relativePath = parts.slice(1).join('/');
// Use SDK to find document — findDocument handles collection/path resolution
const result = await store.get(decodedPath, { includeBody: true });
// Find document by collection and path, join with content table
let doc = store.db.prepare(`
SELECT d.collection, d.path, d.title, c.doc as body
FROM documents d
JOIN content c ON c.hash = d.hash
WHERE d.collection = ? AND d.path = ? AND d.active = 1
`).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
// Try suffix match if exact match fails
if (!doc) {
doc = store.db.prepare(`
SELECT d.collection, d.path, d.title, c.doc as body
FROM documents d
JOIN content c ON c.hash = d.hash
WHERE d.path LIKE ? AND d.active = 1
LIMIT 1
`).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
}
if (!doc) {
if ("error" in result) {
return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
}
// Construct virtual path for context lookup
const virtualPath = `qmd://${doc.collection}/${doc.path}`;
const context = store.getContextForFile(virtualPath);
let text = addLineNumbers(doc.body); // Default to line numbers
if (context) {
text = `<!-- Context: ${context} -->\n\n` + text;
let text = addLineNumbers(result.body || ""); // Default to line numbers
if (result.context) {
text = `<!-- Context: ${result.context} -->\n\n` + text;
}
const displayName = `${doc.collection}/${doc.path}`;
return {
contents: [{
uri: uri.href,
name: displayName,
title: doc.title || doc.path,
name: result.displayPath,
title: result.title || result.displayPath,
mimeType: "text/markdown",
text,
}],
@ -322,19 +300,19 @@ Intent-aware lex (C++ performance, not sports):
},
async ({ searches, limit, minScore, candidateLimit, collections, intent }) => {
// Map to internal format
const subSearches: ExpandedQuery[] = searches.map(s => ({
const queries: ExpandedQuery[] = searches.map(s => ({
type: s.type,
query: s.query,
}));
// Use default collections if none specified
const effectiveCollections = collections ?? getDefaultCollectionNames();
const effectiveCollections = collections ?? defaultCollectionNames;
const results = await structuredSearch(store, subSearches, {
const results = await store.search({
queries,
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
limit,
minScore,
candidateLimit,
intent,
});
@ -389,7 +367,7 @@ Intent-aware lex (C++ performance, not sports):
lookup = lookup.slice(0, -colonMatch[0].length);
}
const result = store.findDocument(lookup, { includeBody: false });
const result = await store.get(lookup, { includeBody: false });
if ("error" in result) {
let msg = `Document not found: ${file}`;
@ -402,7 +380,7 @@ Intent-aware lex (C++ performance, not sports):
};
}
const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
const body = await store.getDocumentBody(result.filepath, { fromLine: parsedFromLine, maxLines }) ?? "";
let text = body;
if (lineNumbers) {
const startLine = parsedFromLine || 1;
@ -445,7 +423,7 @@ Intent-aware lex (C++ performance, not sports):
},
},
async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
const { docs, errors } = await store.multiGet(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
if (docs.length === 0 && errors.length === 0) {
return {
@ -513,7 +491,7 @@ Intent-aware lex (C++ performance, not sports):
inputSchema: {},
},
async () => {
const status: StatusResult = store.getStatus();
const status: StatusResult = await store.getStatus();
const summary = [
`QMD Index Status:`,
@ -542,8 +520,8 @@ Intent-aware lex (C++ performance, not sports):
// =============================================================================
export async function startMcpServer(): Promise<void> {
const store = createStore();
const server = createMcpServer(store);
const store = await createStore({ dbPath: getDefaultDbPath() });
const server = await createMcpServer(store);
const transport = new StdioServerTransport();
await server.connect(transport);
}
@ -563,7 +541,10 @@ export type HttpServerHandle = {
* Binds to localhost only. Returns a handle for shutdown and port discovery.
*/
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
const store = createStore();
const store = await createStore({ dbPath: getDefaultDbPath() });
// Pre-fetch default collection names for REST endpoint
const defaultCollectionNames = await store.getDefaultCollectionNames();
// Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
// The store is shared — it's stateless SQLite, safe for concurrent access.
@ -578,7 +559,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
},
});
const server = createMcpServer(store);
const server = await createMcpServer(store);
await server.connect(transport);
transport.onclose = () => {
@ -645,7 +626,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
const rawBody = await collectBody(nodeReq);
const params = JSON.parse(rawBody);
// Validate required fields
if (!params.searches || !Array.isArray(params.searches)) {
nodeRes.writeHead(400, { "Content-Type": "application/json" });
@ -654,19 +635,20 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
}
// Map to internal format
const subSearches: ExpandedQuery[] = params.searches.map((s: any) => ({
const queries: ExpandedQuery[] = params.searches.map((s: any) => ({
type: s.type as 'lex' | 'vec' | 'hyde',
query: String(s.query || ""),
}));
// Use default collections if none specified
const effectiveCollections = params.collections ?? getDefaultCollectionNames();
const effectiveCollections = params.collections ?? defaultCollectionNames;
const results = await structuredSearch(store, subSearches, {
const results = await store.search({
queries,
collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
limit: params.limit ?? 10,
minScore: params.minScore ?? 0,
candidateLimit: params.candidateLimit,
intent: params.intent,
});
// Use first lex or vec query for snippet extraction
@ -801,8 +783,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
}
sessions.clear();
httpServer.close();
store.close();
await disposeDefaultLlamaCpp();
await store.close();
};
process.on("SIGTERM", async () => {
@ -821,6 +802,6 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole
}
// Run if this is the main module
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/mcp.js")) {
if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js")) {
startMcpServer().catch(console.error);
}

View File

@ -16,7 +16,7 @@ import type { Database } from "./db.js";
import picomatch from "picomatch";
import { createHash } from "crypto";
import { readFileSync, realpathSync, statSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
// Note: node:path resolve is not imported — we export our own cross-platform resolve()
import fastGlob from "fast-glob";
import {
LlamaCpp,
@ -2267,7 +2267,7 @@ export function getCollectionByName(db: Database, name: string): { name: string;
* List all collections with document counts from database.
* Merges store_collections config with database statistics.
*/
export function listCollections(db: Database): { name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null }[] {
export function listCollections(db: Database): { name: string; pwd: string; glob_pattern: string; doc_count: number; active_count: number; last_modified: string | null; includeByDefault: boolean }[] {
const collections = getStoreCollections(db);
// Get document counts from database for each collection
@ -2288,6 +2288,7 @@ export function listCollections(db: Database): { name: string; pwd: string; glob
doc_count: stats?.doc_count || 0,
active_count: stats?.active_count || 0,
last_modified: stats?.last_modified || null,
includeByDefault: coll.includeByDefault !== false,
};
});

View File

@ -24,7 +24,7 @@ let testCounter = 0; // Unique counter for each test run
// Get the directory where this test file lives
const thisDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(thisDir, "..");
const qmdScript = join(projectRoot, "src", "qmd.ts");
const qmdScript = join(projectRoot, "src", "cli", "qmd.ts");
// Resolve tsx binary from project's node_modules (not cwd-dependent)
const tsxBin = (() => {
const candidate = join(projectRoot, "node_modules", ".bin", "tsx");
@ -485,7 +485,7 @@ ${token}
const update = await runQmd(["update"], { dbPath, configDir });
expect(update.exitCode).toBe(0);
expect(update.stdout).toContain("No files found matching pattern.");
expect(update.stdout).toContain("0 new, 0 updated, 0 unchanged, 1 removed");
const after = await runQmd(["get", "qmd://empty-check/only.md"], { dbPath, configDir });
expect(after.exitCode).toBe(1);

View File

@ -138,7 +138,7 @@ interface SearchResult {
function runSearch(query: string): SearchResult[] {
try {
const output = execSync(
`bun src/qmd.ts search "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
`bun src/cli/qmd.ts search "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
{ encoding: "utf-8", timeout: 30000 }
);
return JSON.parse(output);
@ -150,7 +150,7 @@ function runSearch(query: string): SearchResult[] {
function runQuery(query: string): SearchResult[] {
try {
const output = execSync(
`bun src/qmd.ts query "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
`bun src/cli/qmd.ts query "${query.replace(/"/g, '\\"')}" --json -n 5 2>/dev/null`,
{ encoding: "utf-8", timeout: 60000 }
);
return JSON.parse(output);
@ -207,7 +207,7 @@ console.log(`Testing ${evalQueries.length} queries across 6 documents`);
// Check if eval-docs collection exists
try {
const status = execSync("bun src/qmd.ts status --json 2>/dev/null", { encoding: "utf-8" });
const status = execSync("bun src/cli/qmd.ts status --json 2>/dev/null", { encoding: "utf-8" });
if (!status.includes("eval-docs")) {
console.log("\n⚠ eval-docs collection not found. Run:");
console.log(" qmd collection add test/eval-docs --name eval-docs");

View File

@ -27,7 +27,7 @@ import {
documentToXml,
formatDocument,
type MultiGetFile,
} from "../src/formatter.js";
} from "../src/cli/formatter.js";
import type { SearchResult, DocumentResult } from "../src/store.js";
// =============================================================================

View File

@ -22,7 +22,7 @@ import {
} from "../src/store.js";
// =============================================================================
// parseStructuredQuery — duplicated from src/qmd.ts for unit testing
// parseStructuredQuery — duplicated from src/cli/qmd.ts for unit testing
// (qmd.ts doesn't export it since it's a CLI internal)
// =============================================================================

View File

@ -894,7 +894,7 @@ describe("MCP Server", () => {
// HTTP Transport Tests
// =============================================================================
import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp";
import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp/server";
import { enableProductionMode } from "../src/store";
describe("MCP HTTP Transport", () => {