@tobilu/qmd 1.1.6 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { openDatabase } from "./db.js";
2
+ import { openDatabase } from "../db.js";
3
3
  import fastGlob from "fast-glob";
4
4
  import { execSync, spawn as nodeSpawn } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
- import { dirname, join as pathJoin } from "path";
6
+ import { dirname, join as pathJoin, relative as relativePath } from "path";
7
7
  import { parseArgs } from "util";
8
- import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync } from "fs";
9
- import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, } from "./store.js";
10
- import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "./llm.js";
8
+ import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs";
9
+ import { createInterface } from "readline/promises";
10
+ import { getPwd, getRealPath, homedir, resolve, enableProductionMode, searchFTS, extractSnippet, getContextForFile, getContextForPath, listCollections, removeCollection, renameCollection, findSimilarFiles, findDocumentByDocid, isDocid, matchFilesByGlob, getHashesNeedingEmbedding, getHashesForEmbedding, clearAllEmbeddings, insertEmbedding, getStatus, hashContent, extractTitle, formatDocForEmbedding, chunkDocumentByTokens, clearCache, getCacheKey, getCachedResult, setCachedResult, getIndexHealth, parseVirtualPath, buildVirtualPath, isVirtualPath, resolveVirtualPath, toVirtualPath, insertContent, insertDocument, findActiveDocument, updateDocumentTitle, updateDocument, deactivateDocument, getActiveDocumentPaths, cleanupOrphanedContent, deleteLLMCache, deleteInactiveDocuments, cleanupOrphanedVectors, vacuumDatabase, getCollectionsWithoutContext, getTopLevelPathsWithoutContext, handelize, hybridQuery, vectorSearchQuery, structuredSearch, addLineNumbers, DEFAULT_EMBED_MODEL, DEFAULT_RERANK_MODEL, DEFAULT_GLOB, DEFAULT_MULTI_GET_MAX_BYTES, createStore, getDefaultDbPath, reindexCollection, generateEmbeddings, syncConfigToDb, } from "../store.js";
11
+ import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
11
12
  import { formatSearchResults, formatDocuments, escapeXml, escapeCSV, } from "./formatter.js";
12
- import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, setGlobalContext, listAllContexts, setConfigIndexName, } from "./collections.js";
13
+ import { getCollection as getCollectionFromYaml, listCollections as yamlListCollections, getDefaultCollectionNames, addContext as yamlAddContext, removeContext as yamlRemoveContext, removeCollection as yamlRemoveCollectionFn, renameCollection as yamlRenameCollectionFn, setGlobalContext, listAllContexts, setConfigIndexName, loadConfig, } from "../collections.js";
14
+ import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
13
15
  // Enable production mode - allows using default database path
14
16
  // Tests must set INDEX_PATH or use createStore() with explicit path
15
17
  enableProductionMode();
@@ -21,12 +23,33 @@ let storeDbPathOverride;
21
23
  function getStore() {
22
24
  if (!store) {
23
25
  store = createStore(storeDbPathOverride);
26
+ // Sync YAML config into SQLite store_collections so store.ts reads from DB
27
+ try {
28
+ const config = loadConfig();
29
+ syncConfigToDb(store.db, config);
30
+ }
31
+ catch {
32
+ // Config may not exist yet — that's fine, DB works without it
33
+ }
24
34
  }
25
35
  return store;
26
36
  }
27
37
  function getDb() {
28
38
  return getStore().db;
29
39
  }
40
+ /** Re-sync YAML config into SQLite after CLI mutations (add/remove/rename collection, context changes) */
41
+ function resyncConfig() {
42
+ const s = getStore();
43
+ try {
44
+ const config = loadConfig();
45
+ // Clear config hash to force re-sync
46
+ s.db.prepare(`DELETE FROM store_config WHERE key = 'config_hash'`).run();
47
+ syncConfigToDb(s.db, config);
48
+ }
49
+ catch {
50
+ // Config may not exist — that's fine
51
+ }
52
+ }
30
53
  function closeDb() {
31
54
  if (store) {
32
55
  store.close();
@@ -354,6 +377,7 @@ async function showStatus() {
354
377
  }
355
378
  async function updateCollections() {
356
379
  const db = getDb();
380
+ const storeInstance = getStore();
357
381
  // Collections are defined in YAML; no duplicate cleanup needed.
358
382
  // Clear Ollama cache on update
359
383
  clearCache(db);
@@ -363,7 +387,6 @@ async function updateCollections() {
363
387
  closeDb();
364
388
  return;
365
389
  }
366
- // Don't close db here - indexFiles will reuse it and close at the end
367
390
  console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
368
391
  for (let i = 0; i < collections.length; i++) {
369
392
  const col = collections[i];
@@ -403,12 +426,30 @@ async function updateCollections() {
403
426
  process.exit(1);
404
427
  }
405
428
  }
406
- await indexFiles(col.pwd, col.glob_pattern, col.name, true, yamlCol?.ignore);
429
+ const startTime = Date.now();
430
+ console.log(`Collection: ${col.pwd} (${col.glob_pattern})`);
431
+ progress.indeterminate();
432
+ const result = await reindexCollection(storeInstance, col.pwd, col.glob_pattern, col.name, {
433
+ ignorePatterns: yamlCol?.ignore,
434
+ onProgress: (info) => {
435
+ progress.set((info.current / info.total) * 100);
436
+ const elapsed = (Date.now() - startTime) / 1000;
437
+ const rate = info.current / elapsed;
438
+ const remaining = (info.total - info.current) / rate;
439
+ const eta = info.current > 2 ? ` ETA: ${formatETA(remaining)}` : "";
440
+ if (isTTY)
441
+ process.stderr.write(`\rIndexing: ${info.current}/${info.total}${eta} `);
442
+ },
443
+ });
444
+ progress.clear();
445
+ console.log(`\nIndexed: ${result.indexed} new, ${result.updated} updated, ${result.unchanged} unchanged, ${result.removed} removed`);
446
+ if (result.orphanedCleaned > 0) {
447
+ console.log(`Cleaned up ${result.orphanedCleaned} orphaned content hash(es)`);
448
+ }
407
449
  console.log("");
408
450
  }
409
451
  // Check if any documents need embedding (show once at end)
410
- const finalDb = getDb();
411
- const needsEmbedding = getHashesNeedingEmbedding(finalDb);
452
+ const needsEmbedding = getHashesNeedingEmbedding(db);
412
453
  closeDb();
413
454
  console.log(`${c.green}✓ All collections updated.${c.reset}`);
414
455
  if (needsEmbedding > 0) {
@@ -452,6 +493,7 @@ async function contextAdd(pathArg, contextText) {
452
493
  // Handle "/" as global context (applies to all collections)
453
494
  if (pathArg === '/') {
454
495
  setGlobalContext(contextText);
496
+ resyncConfig();
455
497
  console.log(`${c.green}✓${c.reset} Set global context`);
456
498
  console.log(`${c.dim}Context: ${contextText}${c.reset}`);
457
499
  closeDb();
@@ -481,6 +523,7 @@ async function contextAdd(pathArg, contextText) {
481
523
  process.exit(1);
482
524
  }
483
525
  yamlAddContext(parsed.collectionName, parsed.path, contextText);
526
+ resyncConfig();
484
527
  const displayPath = parsed.path
485
528
  ? `qmd://${parsed.collectionName}/${parsed.path}`
486
529
  : `qmd://${parsed.collectionName}/ (collection root)`;
@@ -497,6 +540,7 @@ async function contextAdd(pathArg, contextText) {
497
540
  process.exit(1);
498
541
  }
499
542
  yamlAddContext(detected.collectionName, detected.relativePath, contextText);
543
+ resyncConfig();
500
544
  const displayPath = detected.relativePath ? `qmd://${detected.collectionName}/${detected.relativePath}` : `qmd://${detected.collectionName}/`;
501
545
  console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
502
546
  console.log(`${c.dim}Context: ${contextText}${c.reset}`);
@@ -527,6 +571,10 @@ function contextRemove(pathArg) {
527
571
  if (pathArg === '/') {
528
572
  // Remove global context
529
573
  setGlobalContext(undefined);
574
+ // Resync so SQLite store_config is updated
575
+ const s = getStore();
576
+ resyncConfig();
577
+ closeDb();
530
578
  console.log(`${c.green}✓${c.reset} Removed global context`);
531
579
  return;
532
580
  }
@@ -1141,9 +1189,10 @@ async function collectionAdd(pwd, globPattern, name) {
1141
1189
  console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
1142
1190
  process.exit(1);
1143
1191
  }
1144
- // Add to YAML config
1145
- const { addCollection } = await import("./collections.js");
1192
+ // Add to YAML config + sync to SQLite
1193
+ const { addCollection } = await import("../collections.js");
1146
1194
  addCollection(collName, pwd, globPattern);
1195
+ resyncConfig();
1147
1196
  // Create the collection and index files
1148
1197
  console.log(`Creating collection '${collName}'...`);
1149
1198
  const newColl = getCollectionFromYaml(collName);
@@ -1160,6 +1209,8 @@ function collectionRemove(name) {
1160
1209
  }
1161
1210
  const db = getDb();
1162
1211
  const result = removeCollection(db, name);
1212
+ // Also remove from YAML config
1213
+ yamlRemoveCollectionFn(name);
1163
1214
  closeDb();
1164
1215
  console.log(`${c.green}✓${c.reset} Removed collection '${name}'`);
1165
1216
  console.log(` Deleted ${result.deletedDocs} documents`);
@@ -1184,6 +1235,8 @@ function collectionRename(oldName, newName) {
1184
1235
  }
1185
1236
  const db = getDb();
1186
1237
  renameCollection(db, oldName, newName);
1238
+ // Also rename in YAML config
1239
+ yamlRenameCollectionFn(oldName, newName);
1187
1240
  closeDb();
1188
1241
  console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
1189
1242
  console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
@@ -1315,150 +1368,56 @@ function renderProgressBar(percent, width = 30) {
1315
1368
  return bar;
1316
1369
  }
1317
1370
  async function vectorIndex(model = DEFAULT_EMBED_MODEL, force = false) {
1318
- const db = getDb();
1319
- const now = new Date().toISOString();
1320
- // If force, clear all vectors
1371
+ const storeInstance = getStore();
1372
+ const db = storeInstance.db;
1321
1373
  if (force) {
1322
1374
  console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
1323
- clearAllEmbeddings(db);
1324
1375
  }
1325
- // Find unique hashes that need embedding (from active documents)
1376
+ // Check if there's work to do before starting
1326
1377
  const hashesToEmbed = getHashesForEmbedding(db);
1327
- if (hashesToEmbed.length === 0) {
1378
+ if (hashesToEmbed.length === 0 && !force) {
1328
1379
  console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
1329
1380
  closeDb();
1330
1381
  return;
1331
1382
  }
1332
- const allChunks = [];
1333
- let multiChunkDocs = 0;
1334
- // Chunk all documents using actual token counts
1335
- process.stderr.write(`Chunking ${hashesToEmbed.length} documents by token count...\n`);
1336
- for (const item of hashesToEmbed) {
1337
- const encoder = new TextEncoder();
1338
- const bodyBytes = encoder.encode(item.body).length;
1339
- if (bodyBytes === 0)
1340
- continue; // Skip empty
1341
- const title = extractTitle(item.body, item.path);
1342
- const displayName = item.path;
1343
- const chunks = await chunkDocumentByTokens(item.body); // Uses actual tokenizer
1344
- if (chunks.length > 1)
1345
- multiChunkDocs++;
1346
- for (let seq = 0; seq < chunks.length; seq++) {
1347
- allChunks.push({
1348
- hash: item.hash,
1349
- title,
1350
- text: chunks[seq].text, // Chunk is guaranteed to exist by seq loop
1351
- seq,
1352
- pos: chunks[seq].pos,
1353
- tokens: chunks[seq].tokens,
1354
- bytes: encoder.encode(chunks[seq].text).length,
1355
- displayName,
1356
- });
1357
- }
1358
- }
1359
- if (allChunks.length === 0) {
1360
- console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
1361
- closeDb();
1362
- return;
1363
- }
1364
- const totalBytes = allChunks.reduce((sum, chk) => sum + chk.bytes, 0);
1365
- const totalChunks = allChunks.length;
1366
- const totalDocs = hashesToEmbed.length;
1367
- console.log(`${c.bold}Embedding ${totalDocs} documents${c.reset} ${c.dim}(${totalChunks} chunks, ${formatBytes(totalBytes)})${c.reset}`);
1368
- if (multiChunkDocs > 0) {
1369
- console.log(`${c.dim}${multiChunkDocs} documents split into multiple chunks${c.reset}`);
1370
- }
1371
1383
  console.log(`${c.dim}Model: ${model}${c.reset}\n`);
1372
- // Hide cursor during embedding
1373
1384
  cursor.hide();
1374
- // Wrap all LLM embedding operations in a session for lifecycle management
1375
- // Use 30 minute timeout for large collections
1376
- await withLLMSession(async (session) => {
1377
- // Get embedding dimensions from first chunk
1378
- progress.indeterminate();
1379
- const firstChunk = allChunks[0];
1380
- if (!firstChunk) {
1381
- throw new Error("No chunks available to embed");
1382
- }
1383
- const firstText = formatDocForEmbedding(firstChunk.text, firstChunk.title);
1384
- const firstResult = await session.embed(firstText);
1385
- if (!firstResult) {
1386
- throw new Error("Failed to get embedding dimensions from first chunk");
1387
- }
1388
- ensureVecTable(db, firstResult.embedding.length);
1389
- let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
1390
- const startTime = Date.now();
1391
- // Batch embedding for better throughput
1392
- // Process in batches of 32 to balance memory usage and efficiency
1393
- const BATCH_SIZE = 32;
1394
- for (let batchStart = 0; batchStart < allChunks.length; batchStart += BATCH_SIZE) {
1395
- const batchEnd = Math.min(batchStart + BATCH_SIZE, allChunks.length);
1396
- const batch = allChunks.slice(batchStart, batchEnd);
1397
- // Format texts for embedding
1398
- const texts = batch.map(chunk => formatDocForEmbedding(chunk.text, chunk.title));
1399
- try {
1400
- // Batch embed all texts at once
1401
- const embeddings = await session.embedBatch(texts);
1402
- // Insert each embedding
1403
- for (let i = 0; i < batch.length; i++) {
1404
- const chunk = batch[i];
1405
- const embedding = embeddings[i];
1406
- if (embedding) {
1407
- insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(embedding.embedding), model, now);
1408
- chunksEmbedded++;
1409
- }
1410
- else {
1411
- errors++;
1412
- console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}${c.reset}`);
1413
- }
1414
- bytesProcessed += chunk.bytes;
1415
- }
1416
- }
1417
- catch (err) {
1418
- // If batch fails, try individual embeddings as fallback
1419
- for (const chunk of batch) {
1420
- try {
1421
- const text = formatDocForEmbedding(chunk.text, chunk.title);
1422
- const result = await session.embed(text);
1423
- if (result) {
1424
- insertEmbedding(db, chunk.hash, chunk.seq, chunk.pos, new Float32Array(result.embedding), model, now);
1425
- chunksEmbedded++;
1426
- }
1427
- else {
1428
- errors++;
1429
- }
1430
- }
1431
- catch (innerErr) {
1432
- errors++;
1433
- console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${innerErr}${c.reset}`);
1434
- }
1435
- bytesProcessed += chunk.bytes;
1436
- }
1437
- }
1438
- const percent = (bytesProcessed / totalBytes) * 100;
1385
+ progress.indeterminate();
1386
+ const startTime = Date.now();
1387
+ const result = await generateEmbeddings(storeInstance, {
1388
+ force,
1389
+ model,
1390
+ onProgress: (info) => {
1391
+ if (info.totalBytes === 0)
1392
+ return;
1393
+ const percent = (info.bytesProcessed / info.totalBytes) * 100;
1439
1394
  progress.set(percent);
1440
1395
  const elapsed = (Date.now() - startTime) / 1000;
1441
- const bytesPerSec = bytesProcessed / elapsed;
1442
- const remainingBytes = totalBytes - bytesProcessed;
1396
+ const bytesPerSec = info.bytesProcessed / elapsed;
1397
+ const remainingBytes = info.totalBytes - info.bytesProcessed;
1443
1398
  const etaSec = remainingBytes / bytesPerSec;
1444
1399
  const bar = renderProgressBar(percent);
1445
1400
  const percentStr = percent.toFixed(0).padStart(3);
1446
1401
  const throughput = `${formatBytes(bytesPerSec)}/s`;
1447
1402
  const eta = elapsed > 2 ? formatETA(etaSec) : "...";
1448
- const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
1403
+ const errStr = info.errors > 0 ? ` ${c.yellow}${info.errors} err${c.reset}` : "";
1449
1404
  if (isTTY)
1450
- process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${chunksEmbedded}/${totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
1451
- }
1452
- progress.clear();
1453
- cursor.show();
1454
- const totalTimeSec = (Date.now() - startTime) / 1000;
1455
- const avgThroughput = formatBytes(totalBytes / totalTimeSec);
1405
+ process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${info.chunksEmbedded}/${info.totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
1406
+ },
1407
+ });
1408
+ progress.clear();
1409
+ cursor.show();
1410
+ const totalTimeSec = result.durationMs / 1000;
1411
+ if (result.chunksEmbedded === 0 && result.docsProcessed === 0) {
1412
+ console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
1413
+ }
1414
+ else {
1456
1415
  console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
1457
- console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${chunksEmbedded}${c.reset} chunks from ${c.bold}${totalDocs}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset} ${c.dim}(${avgThroughput}/s)${c.reset}`);
1458
- if (errors > 0) {
1459
- console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
1416
+ console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${result.chunksEmbedded}${c.reset} chunks from ${c.bold}${result.docsProcessed}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset}`);
1417
+ if (result.errors > 0) {
1418
+ console.log(`${c.yellow}⚠ ${result.errors} chunks failed${c.reset}`);
1460
1419
  }
1461
- }, { maxDuration: 30 * 60 * 1000, name: 'embed-command' });
1420
+ }
1462
1421
  closeDb();
1463
1422
  }
1464
1423
  // Sanitize a term for FTS5: remove punctuation except apostrophes
@@ -1820,7 +1779,7 @@ function logExpansionTree(originalQuery, expanded) {
1820
1779
  const lines = [];
1821
1780
  lines.push(`${c.dim}├─ ${originalQuery}${c.reset}`);
1822
1781
  for (const q of expanded) {
1823
- let preview = q.text.replace(/\n/g, ' ');
1782
+ let preview = q.query.replace(/\n/g, ' ');
1824
1783
  if (preview.length > 72)
1825
1784
  preview = preview.substring(0, 69) + '...';
1826
1785
  lines.push(`${c.dim}├─ ${q.type}: ${preview}${c.reset}`);
@@ -2012,6 +1971,8 @@ function parseCLI() {
2012
1971
  help: { type: "boolean", short: "h" },
2013
1972
  version: { type: "boolean", short: "v" },
2014
1973
  skill: { type: "boolean" },
1974
+ global: { type: "boolean" },
1975
+ yes: { type: "boolean" },
2015
1976
  // Search options
2016
1977
  n: { type: "string" },
2017
1978
  "min-score": { type: "string" },
@@ -2090,19 +2051,116 @@ function parseCLI() {
2090
2051
  values,
2091
2052
  };
2092
2053
  }
2054
+ function getSkillInstallDir(globalInstall) {
2055
+ return globalInstall
2056
+ ? resolve(homedir(), ".agents", "skills", "qmd")
2057
+ : resolve(getPwd(), ".agents", "skills", "qmd");
2058
+ }
2059
+ function getClaudeSkillLinkPath(globalInstall) {
2060
+ return globalInstall
2061
+ ? resolve(homedir(), ".claude", "skills", "qmd")
2062
+ : resolve(getPwd(), ".claude", "skills", "qmd");
2063
+ }
2064
+ function pathExists(path) {
2065
+ try {
2066
+ lstatSync(path);
2067
+ return true;
2068
+ }
2069
+ catch {
2070
+ return false;
2071
+ }
2072
+ }
2073
+ function removePath(path) {
2074
+ const stat = lstatSync(path);
2075
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
2076
+ rmSync(path, { recursive: true, force: true });
2077
+ }
2078
+ else {
2079
+ unlinkSync(path);
2080
+ }
2081
+ }
2093
2082
  function showSkill() {
2094
- const scriptDir = dirname(fileURLToPath(import.meta.url));
2095
- const relativePath = pathJoin("skills", "qmd", "SKILL.md");
2096
- const skillPath = pathJoin(scriptDir, "..", relativePath);
2097
- console.log(`QMD Skill (${relativePath})`);
2098
- console.log(`Location: ${skillPath}`);
2083
+ console.log("QMD Skill (embedded)");
2099
2084
  console.log("");
2100
- if (!existsSync(skillPath)) {
2101
- console.error("SKILL.md not found. If you built from source, ensure skills/qmd/SKILL.md exists.");
2085
+ const content = getEmbeddedQmdSkillContent();
2086
+ process.stdout.write(content.endsWith("\n") ? content : content + "\n");
2087
+ }
2088
+ function writeEmbeddedSkill(targetDir, force) {
2089
+ if (pathExists(targetDir)) {
2090
+ if (!force) {
2091
+ throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`);
2092
+ }
2093
+ removePath(targetDir);
2094
+ }
2095
+ mkdirSync(targetDir, { recursive: true });
2096
+ for (const file of getEmbeddedQmdSkillFiles()) {
2097
+ const destination = resolve(targetDir, file.relativePath);
2098
+ mkdirSync(dirname(destination), { recursive: true });
2099
+ writeFileSync(destination, file.content, "utf-8");
2100
+ }
2101
+ }
2102
+ function ensureClaudeSymlink(linkPath, targetDir, force) {
2103
+ const parentDir = dirname(linkPath);
2104
+ if (pathExists(parentDir)) {
2105
+ const resolvedTargetDir = realpathSync(dirname(targetDir));
2106
+ const resolvedLinkParent = realpathSync(parentDir);
2107
+ // If .claude/skills already resolves to the same directory as .agents/skills,
2108
+ // the skill is already visible to Claude and creating qmd -> qmd would loop.
2109
+ if (resolvedTargetDir === resolvedLinkParent) {
2110
+ return false;
2111
+ }
2112
+ }
2113
+ const linkTarget = relativePath(parentDir, targetDir) || ".";
2114
+ mkdirSync(parentDir, { recursive: true });
2115
+ if (pathExists(linkPath)) {
2116
+ const stat = lstatSync(linkPath);
2117
+ if (stat.isSymbolicLink() && readlinkSync(linkPath) === linkTarget) {
2118
+ return true;
2119
+ }
2120
+ if (!force) {
2121
+ throw new Error(`Claude skill path already exists: ${linkPath} (use --force to replace it)`);
2122
+ }
2123
+ removePath(linkPath);
2124
+ }
2125
+ symlinkSync(linkTarget, linkPath, "dir");
2126
+ return true;
2127
+ }
2128
+ async function shouldCreateClaudeSymlink(linkPath, autoYes) {
2129
+ if (autoYes) {
2130
+ return true;
2131
+ }
2132
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2133
+ console.log(`Tip: create a Claude symlink manually at ${linkPath}`);
2134
+ return false;
2135
+ }
2136
+ const rl = createInterface({
2137
+ input: process.stdin,
2138
+ output: process.stdout,
2139
+ });
2140
+ try {
2141
+ const answer = await rl.question(`Create a symlink in ${linkPath}? [y/N] `);
2142
+ const normalized = answer.trim().toLowerCase();
2143
+ return normalized === "y" || normalized === "yes";
2144
+ }
2145
+ finally {
2146
+ rl.close();
2147
+ }
2148
+ }
2149
+ async function installSkill(globalInstall, force, autoYes) {
2150
+ const installDir = getSkillInstallDir(globalInstall);
2151
+ writeEmbeddedSkill(installDir, force);
2152
+ console.log(`✓ Installed QMD skill to ${installDir}`);
2153
+ const claudeLinkPath = getClaudeSkillLinkPath(globalInstall);
2154
+ if (!(await shouldCreateClaudeSymlink(claudeLinkPath, autoYes))) {
2102
2155
  return;
2103
2156
  }
2104
- const content = readFileSync(skillPath, "utf-8");
2105
- process.stdout.write(content.endsWith("\n") ? content : content + "\n");
2157
+ const linked = ensureClaudeSymlink(claudeLinkPath, installDir, force);
2158
+ if (linked) {
2159
+ console.log(`✓ Linked Claude skill at ${claudeLinkPath}`);
2160
+ }
2161
+ else {
2162
+ console.log(`✓ Claude already sees the skill via ${dirname(claudeLinkPath)}`);
2163
+ }
2106
2164
  }
2107
2165
  function showHelp() {
2108
2166
  console.log("qmd — Quick Markdown Search");
@@ -2117,6 +2175,7 @@ function showHelp() {
2117
2175
  console.log(" qmd vsearch <query> - Vector similarity only");
2118
2176
  console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
2119
2177
  console.log(" qmd multi-get <pattern> - Batch fetch via glob or comma-separated list");
2178
+ console.log(" qmd skill show/install - Show or install the packaged QMD skill");
2120
2179
  console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)");
2121
2180
  console.log("");
2122
2181
  console.log("Collections & context:");
@@ -2166,7 +2225,9 @@ function showHelp() {
2166
2225
  console.log("");
2167
2226
  console.log("AI agents & integrations:");
2168
2227
  console.log(" - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs.");
2169
- console.log(" - `qmd --skill` prints the packaged skills/qmd/SKILL.md (path + contents).");
2228
+ console.log(" - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd.");
2229
+ console.log(" - Use `qmd skill install --global` for ~/.agents/skills/qmd.");
2230
+ console.log(" - `qmd --skill` is kept as an alias for `qmd skill show`.");
2170
2231
  console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports.");
2171
2232
  console.log("");
2172
2233
  console.log("Global options:");
@@ -2192,7 +2253,7 @@ function showHelp() {
2192
2253
  }
2193
2254
  async function showVersion() {
2194
2255
  const scriptDir = dirname(fileURLToPath(import.meta.url));
2195
- const pkgPath = resolve(scriptDir, "..", "package.json");
2256
+ const pkgPath = resolve(scriptDir, "..", "..", "package.json");
2196
2257
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
2197
2258
  let commit = "";
2198
2259
  try {
@@ -2221,6 +2282,19 @@ if (isMain) {
2221
2282
  showSkill();
2222
2283
  process.exit(0);
2223
2284
  }
2285
+ if (cli.values.help && cli.command === "skill") {
2286
+ console.log("Usage: qmd skill <show|install> [options]");
2287
+ console.log("");
2288
+ console.log("Commands:");
2289
+ console.log(" show Print the packaged QMD skill");
2290
+ console.log(" install Install into ./.agents/skills/qmd");
2291
+ console.log("");
2292
+ console.log("Options:");
2293
+ console.log(" --global Install into ~/.agents/skills/qmd");
2294
+ console.log(" --yes Also create the .claude/skills/qmd symlink");
2295
+ console.log(" -f, --force Replace existing install or symlink");
2296
+ process.exit(0);
2297
+ }
2224
2298
  if (!cli.command || cli.values.help) {
2225
2299
  showHelp();
2226
2300
  process.exit(cli.values.help ? 0 : 1);
@@ -2365,7 +2439,7 @@ if (isMain) {
2365
2439
  console.error(" Omit command to clear it");
2366
2440
  process.exit(1);
2367
2441
  }
2368
- const { updateCollectionSettings, getCollection } = await import("./collections.js");
2442
+ const { updateCollectionSettings, getCollection } = await import("../collections.js");
2369
2443
  const col = getCollection(name);
2370
2444
  if (!col) {
2371
2445
  console.error(`Collection not found: ${name}`);
@@ -2388,7 +2462,7 @@ if (isMain) {
2388
2462
  console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
2389
2463
  process.exit(1);
2390
2464
  }
2391
- const { updateCollectionSettings, getCollection } = await import("./collections.js");
2465
+ const { updateCollectionSettings, getCollection } = await import("../collections.js");
2392
2466
  const col = getCollection(name);
2393
2467
  if (!col) {
2394
2468
  console.error(`Collection not found: ${name}`);
@@ -2406,7 +2480,7 @@ if (isMain) {
2406
2480
  console.error("Usage: qmd collection show <name>");
2407
2481
  process.exit(1);
2408
2482
  }
2409
- const { getCollection } = await import("./collections.js");
2483
+ const { getCollection } = await import("../collections.js");
2410
2484
  const col = getCollection(name);
2411
2485
  if (!col) {
2412
2486
  console.error(`Collection not found: ${name}`);
@@ -2553,7 +2627,7 @@ if (isMain) {
2553
2627
  const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
2554
2628
  const selfPath = fileURLToPath(import.meta.url);
2555
2629
  const spawnArgs = selfPath.endsWith(".ts")
2556
- ? ["--import", pathJoin(dirname(selfPath), "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
2630
+ ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
2557
2631
  : [selfPath, "mcp", "--http", "--port", String(port)];
2558
2632
  const child = nodeSpawn(process.execPath, spawnArgs, {
2559
2633
  stdio: ["ignore", logFd, logFd],
@@ -2570,7 +2644,7 @@ if (isMain) {
2570
2644
  // async cleanup handlers in startMcpHttpServer actually run.
2571
2645
  process.removeAllListeners("SIGTERM");
2572
2646
  process.removeAllListeners("SIGINT");
2573
- const { startMcpHttpServer } = await import("./mcp.js");
2647
+ const { startMcpHttpServer } = await import("../mcp/server.js");
2574
2648
  try {
2575
2649
  await startMcpHttpServer(port);
2576
2650
  }
@@ -2584,11 +2658,49 @@ if (isMain) {
2584
2658
  }
2585
2659
  else {
2586
2660
  // Default: stdio transport
2587
- const { startMcpServer } = await import("./mcp.js");
2661
+ const { startMcpServer } = await import("../mcp/server.js");
2588
2662
  await startMcpServer();
2589
2663
  }
2590
2664
  break;
2591
2665
  }
2666
+ case "skill": {
2667
+ const subcommand = cli.args[0];
2668
+ switch (subcommand) {
2669
+ case "show": {
2670
+ showSkill();
2671
+ break;
2672
+ }
2673
+ case "install": {
2674
+ try {
2675
+ await installSkill(Boolean(cli.values.global), Boolean(cli.values.force), Boolean(cli.values.yes));
2676
+ }
2677
+ catch (error) {
2678
+ console.error(error instanceof Error ? error.message : String(error));
2679
+ process.exit(1);
2680
+ }
2681
+ break;
2682
+ }
2683
+ case "help":
2684
+ case undefined: {
2685
+ console.log("Usage: qmd skill <show|install> [options]");
2686
+ console.log("");
2687
+ console.log("Commands:");
2688
+ console.log(" show Print the packaged QMD skill");
2689
+ console.log(" install Install into ./.agents/skills/qmd");
2690
+ console.log("");
2691
+ console.log("Options:");
2692
+ console.log(" --global Install into ~/.agents/skills/qmd");
2693
+ console.log(" --yes Also create the .claude/skills/qmd symlink");
2694
+ console.log(" -f, --force Replace existing install or symlink");
2695
+ process.exit(0);
2696
+ }
2697
+ default:
2698
+ console.error(`Unknown subcommand: ${subcommand}`);
2699
+ console.error("Run 'qmd skill help' for usage");
2700
+ process.exit(1);
2701
+ }
2702
+ break;
2703
+ }
2592
2704
  case "cleanup": {
2593
2705
  const db = getDb();
2594
2706
  // 1. Clear llm_cache
@@ -0,0 +1,6 @@
1
+ export type EmbeddedSkillFile = {
2
+ relativePath: string;
3
+ content: string;
4
+ };
5
+ export declare function getEmbeddedQmdSkillFiles(): EmbeddedSkillFile[];
6
+ export declare function getEmbeddedQmdSkillContent(): string;