@indigoai-us/hq-cli 5.85.3 → 5.86.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.86.0]
6
+
7
+ ### Added
8
+
9
+ - Added `hq search` and `hq index` commands for keyword, semantic, and hybrid
10
+ search across reconciled QMD collections, with explicit opt-in embedding and
11
+ package-local QMD resolution. Registered unmanaged collections remain
12
+ untouched and are reported as `registered (unmanaged)`. (#306)
13
+
5
14
  ## [5.85.3]
6
15
 
7
16
  ### Changed
@@ -0,0 +1,16 @@
1
+ import { Command } from 'commander';
2
+ import { type RunQmdOptions, type SearchCollection, type QmdProcessResult } from '../lib/search-index/index.js';
3
+ export type SearchIndexDependencies = {
4
+ reconcileCollections: (hqRoot: string) => unknown;
5
+ deriveCollections: (hqRoot: string) => SearchCollection[];
6
+ listRegisteredCollections: (hqRoot: string, options?: RunQmdOptions) => Set<string>;
7
+ resolveQmdBin: () => string;
8
+ resolveQmdVersion: () => string | undefined;
9
+ runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
10
+ };
11
+ /** Incrementally update qmd, embedding only when an operator explicitly asks. */
12
+ export declare function syncSearchIndex(hqRoot: string, embed: boolean, dependencies?: SearchIndexDependencies): void;
13
+ export declare function collectionStatusLines(expected: SearchCollection[], registered: ReadonlySet<string>): string[];
14
+ export declare function collectionSummary(expected: SearchCollection[], registered: ReadonlySet<string>): string;
15
+ export declare function registerIndexCommand(program: Command, dependencies?: SearchIndexDependencies): void;
16
+ //# sourceMappingURL=index-cmd.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { deriveCollections, listRegisteredCollections, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
2
+ import { findHqRoot } from '../utils/manifest.js';
3
+ const defaults = {
4
+ reconcileCollections,
5
+ deriveCollections,
6
+ listRegisteredCollections,
7
+ resolveQmdBin,
8
+ resolveQmdVersion,
9
+ runQmd,
10
+ };
11
+ /** Incrementally update qmd, embedding only when an operator explicitly asks. */
12
+ export function syncSearchIndex(hqRoot, embed, dependencies = defaults) {
13
+ dependencies.reconcileCollections(hqRoot);
14
+ dependencies.runQmd(['update'], { cwd: hqRoot });
15
+ if (embed)
16
+ dependencies.runQmd(['embed'], { cwd: hqRoot });
17
+ }
18
+ function resolveRoot(hqRoot) {
19
+ return hqRoot ?? findHqRoot();
20
+ }
21
+ export function collectionStatusLines(expected, registered) {
22
+ const expectedNames = new Set(expected.map((collection) => collection.name));
23
+ const managed = expected.map((collection) => `${registered.has(collection.name) ? 'registered' : 'missing'} ${collection.name} ${collection.path}`);
24
+ const unmanaged = [...registered]
25
+ .filter((name) => !expectedNames.has(name))
26
+ .sort((left, right) => left.localeCompare(right))
27
+ .map((name) => `registered (unmanaged) ${name}`);
28
+ return [...managed, ...unmanaged];
29
+ }
30
+ export function collectionSummary(expected, registered) {
31
+ const expectedNames = new Set(expected.map((collection) => collection.name));
32
+ const unmanaged = [...registered].filter((name) => !expectedNames.has(name)).length;
33
+ return `collections: ${registered.size} registered; ${expected.length} expected; ${unmanaged} unmanaged`;
34
+ }
35
+ export function registerIndexCommand(program, dependencies = defaults) {
36
+ const index = program.command('index').description('Manage the local HQ search index');
37
+ index
38
+ .command('sync')
39
+ .description('Reconcile collections and incrementally update the qmd index')
40
+ .option('--embed', 'Also rebuild expensive semantic embeddings')
41
+ .option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
42
+ .action((options) => {
43
+ const hqRoot = resolveRoot(options.hqRoot);
44
+ syncSearchIndex(hqRoot, options.embed === true);
45
+ console.log(`Updated search index for ${hqRoot}${options.embed ? ' (including embeddings)' : ''}.`);
46
+ });
47
+ index
48
+ .command('collections')
49
+ .description('Show expected and registered qmd collections')
50
+ .option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
51
+ .action((options) => {
52
+ const hqRoot = resolveRoot(options.hqRoot);
53
+ const registered = dependencies.listRegisteredCollections(hqRoot);
54
+ for (const line of collectionStatusLines(dependencies.deriveCollections(hqRoot), registered))
55
+ console.log(line);
56
+ });
57
+ index
58
+ .command('status')
59
+ .description('Show qmd binary, collection, and index status')
60
+ .option('--hq-root <path>', 'HQ root to inspect (defaults to auto-detected root)')
61
+ .action((options) => {
62
+ const hqRoot = resolveRoot(options.hqRoot);
63
+ const bin = dependencies.resolveQmdBin();
64
+ const registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
65
+ const expected = dependencies.deriveCollections(hqRoot);
66
+ const qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
67
+ const qmdVersion = dependencies.resolveQmdVersion();
68
+ console.log(`qmd: ${bin}${qmdVersion ? ` (version ${qmdVersion})` : ''}`);
69
+ console.log(collectionSummary(expected, registered));
70
+ if (qmdStatus.stdout)
71
+ process.stdout.write(qmdStatus.stdout);
72
+ if (qmdStatus.stderr)
73
+ process.stderr.write(qmdStatus.stderr);
74
+ });
75
+ }
76
+ //# sourceMappingURL=index-cmd.js.map
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander';
2
+ export type SearchMode = 'keyword' | 'semantic' | 'hybrid';
3
+ export type SearchOptions = {
4
+ mode?: SearchMode;
5
+ collection?: string;
6
+ count?: number;
7
+ json?: boolean;
8
+ };
9
+ export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
10
+ export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
11
+ export declare function registerSearchCommand(program: Command): void;
12
+ //# sourceMappingURL=search.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { runQmd } from '../lib/search-index/index.js';
2
+ export function buildSearchArgs(query, options = {}) {
3
+ const command = { keyword: 'search', semantic: 'vsearch', hybrid: 'query' }[options.mode ?? 'keyword'];
4
+ const args = [command, query];
5
+ if (options.collection)
6
+ args.push('-c', options.collection);
7
+ if (options.count !== undefined)
8
+ args.push('-n', String(options.count));
9
+ if (options.json)
10
+ args.push('--json');
11
+ return args;
12
+ }
13
+ export function buildGetArgs(document, options = {}) {
14
+ const args = ['get', document];
15
+ if (options.collection)
16
+ args.push('-c', options.collection);
17
+ return args;
18
+ }
19
+ function relay(result) {
20
+ if (result.stdout)
21
+ process.stdout.write(result.stdout);
22
+ if (result.stderr)
23
+ process.stderr.write(result.stderr);
24
+ }
25
+ export function registerSearchCommand(program) {
26
+ const search = program.command('search').description('Search the local HQ qmd index');
27
+ search
28
+ .command('get <document>')
29
+ .description('Retrieve a qmd document by path or document id')
30
+ .option('-c, --collection <collection>', 'Restrict retrieval to a collection')
31
+ .action((document, options) => {
32
+ relay(runQmd(buildGetArgs(document, options)));
33
+ });
34
+ search
35
+ .command('<query>')
36
+ .description('Search the local HQ qmd index')
37
+ .option('--mode <mode>', 'Search mode: keyword, semantic, or hybrid', 'keyword')
38
+ .option('-c, --collection <collection>', 'Restrict search to a collection')
39
+ .option('-n, --count <count>', 'Maximum result count', (value) => Number(value))
40
+ .option('--json', 'Request machine-readable qmd output')
41
+ .action((query, options) => {
42
+ if (!['keyword', 'semantic', 'hybrid'].includes(options.mode ?? 'keyword')) {
43
+ throw new Error(`Unknown search mode '${options.mode}'. Expected keyword, semantic, or hybrid.`);
44
+ }
45
+ relay(runQmd(buildSearchArgs(query, options)));
46
+ });
47
+ }
48
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1,58 @@
1
+ export type QmdProcessResult = {
2
+ status: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ error?: Error;
6
+ };
7
+ export type QmdProcessRunner = (bin: string, args: string[], options: {
8
+ cwd?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ }) => QmdProcessResult;
11
+ export declare class QmdBinaryMissingError extends Error {
12
+ name: string;
13
+ }
14
+ export declare class QmdExitError extends Error {
15
+ readonly args: string[];
16
+ readonly status: number | null;
17
+ readonly stdout: string;
18
+ readonly stderr: string;
19
+ name: string;
20
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string);
21
+ }
22
+ export declare class QmdCollectionMissingError extends QmdExitError {
23
+ name: string;
24
+ }
25
+ export type ResolveQmdBinOptions = {
26
+ env?: Record<string, string | undefined>;
27
+ isExecutable?: (candidate: string) => boolean;
28
+ packageBin?: () => string | undefined;
29
+ pathBin?: () => string | undefined;
30
+ };
31
+ /** Return the pinned package version when qmd is supplied by this CLI. */
32
+ export declare function resolveQmdVersion(): string | undefined;
33
+ /** Resolve qmd without relying on a globally installed copy. */
34
+ export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
35
+ export type RunQmdOptions = {
36
+ bin?: string;
37
+ cwd?: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ runner?: QmdProcessRunner;
40
+ };
41
+ /** Run qmd with captured output and typed failures. */
42
+ export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
43
+ export type SearchCollection = {
44
+ name: string;
45
+ path: string;
46
+ mask: string;
47
+ context: string;
48
+ };
49
+ /** Derive the local qmd collection policy for one HQ tree. */
50
+ export declare function deriveCollections(hqRoot: string): SearchCollection[];
51
+ export type ReconcileCollectionsOptions = {
52
+ bin?: string;
53
+ runner?: QmdProcessRunner;
54
+ };
55
+ /** Register expected collections that qmd does not yet know about. */
56
+ export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
57
+ export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
58
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,197 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import * as path from 'node:path';
5
+ const require = createRequire(import.meta.url);
6
+ export class QmdBinaryMissingError extends Error {
7
+ name = 'QmdBinaryMissingError';
8
+ }
9
+ export class QmdExitError extends Error {
10
+ args;
11
+ status;
12
+ stdout;
13
+ stderr;
14
+ name = 'QmdExitError';
15
+ constructor(message, args, status, stdout, stderr) {
16
+ super(message);
17
+ this.args = args;
18
+ this.status = status;
19
+ this.stdout = stdout;
20
+ this.stderr = stderr;
21
+ }
22
+ }
23
+ export class QmdCollectionMissingError extends QmdExitError {
24
+ name = 'QmdCollectionMissingError';
25
+ }
26
+ function isExecutable(candidate) {
27
+ try {
28
+ fs.accessSync(candidate, fs.constants.X_OK);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function packageLocalBin() {
36
+ try {
37
+ const packageJson = require.resolve('@tobilu/qmd/package.json');
38
+ return path.join(path.dirname(packageJson), 'qmd');
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /** Return the pinned package version when qmd is supplied by this CLI. */
45
+ export function resolveQmdVersion() {
46
+ try {
47
+ const packageJson = require('@tobilu/qmd/package.json');
48
+ return typeof packageJson.version === 'string' ? packageJson.version : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ function pathBin() {
55
+ const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
56
+ const names = process.platform === 'win32' ? ['qmd.exe', 'qmd.cmd', 'qmd'] : ['qmd'];
57
+ for (const directory of paths) {
58
+ for (const name of names) {
59
+ const candidate = path.join(directory, name);
60
+ if (isExecutable(candidate))
61
+ return candidate;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Resolve qmd without relying on a globally installed copy. */
67
+ export function resolveQmdBin(options = {}) {
68
+ const env = options.env ?? process.env;
69
+ const executable = options.isExecutable ?? isExecutable;
70
+ const probes = [];
71
+ const override = env.HQ_QMD_BIN;
72
+ if (override) {
73
+ if (executable(override))
74
+ return override;
75
+ probes.push(`HQ_QMD_BIN (${override})`);
76
+ }
77
+ else {
78
+ probes.push('HQ_QMD_BIN (not set)');
79
+ }
80
+ const installed = (options.packageBin ?? packageLocalBin)();
81
+ if (installed && executable(installed))
82
+ return installed;
83
+ probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
84
+ const onPath = (options.pathBin ?? pathBin)();
85
+ if (onPath && executable(onPath))
86
+ return onPath;
87
+ probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
88
+ throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
89
+ }
90
+ function defaultRunner(bin, args, options) {
91
+ const result = spawnSync(bin, args, { cwd: options.cwd, env: options.env, encoding: 'utf8' });
92
+ return {
93
+ status: result.status,
94
+ stdout: result.stdout ?? '',
95
+ stderr: result.stderr ?? '',
96
+ error: result.error,
97
+ };
98
+ }
99
+ /** Run qmd with captured output and typed failures. */
100
+ export function runQmd(args, options = {}) {
101
+ const bin = options.bin ?? resolveQmdBin({ env: options.env });
102
+ const result = (options.runner ?? defaultRunner)(bin, args, { cwd: options.cwd, env: options.env });
103
+ if (result.error) {
104
+ throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${result.error.message}`);
105
+ }
106
+ if (result.status === 0)
107
+ return result;
108
+ const detail = result.stderr || result.stdout || 'qmd returned no diagnostic output';
109
+ const message = `qmd ${args.join(' ')} exited with ${result.status ?? 'an unknown status'}: ${detail}`;
110
+ if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
111
+ throw new QmdCollectionMissingError(message, args, result.status, result.stdout, result.stderr);
112
+ }
113
+ throw new QmdExitError(message, args, result.status, result.stdout, result.stderr);
114
+ }
115
+ function containsIndexedMarkdown(directory) {
116
+ if (!fs.existsSync(directory))
117
+ return false;
118
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
119
+ const child = path.join(directory, entry.name);
120
+ if (entry.isDirectory() && containsIndexedMarkdown(child))
121
+ return true;
122
+ if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'INDEX.md')
123
+ return true;
124
+ }
125
+ return false;
126
+ }
127
+ function containsProjectSource(directory) {
128
+ if (!fs.existsSync(directory))
129
+ return false;
130
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
131
+ const child = path.join(directory, entry.name);
132
+ if (entry.isDirectory() && containsProjectSource(child))
133
+ return true;
134
+ if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json')))
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ /** Derive the local qmd collection policy for one HQ tree. */
140
+ export function deriveCollections(hqRoot) {
141
+ const root = path.resolve(hqRoot);
142
+ const companiesDir = path.join(root, 'companies');
143
+ const companies = fs.existsSync(companiesDir)
144
+ ? fs.readdirSync(companiesDir, { withFileTypes: true })
145
+ .filter((entry) => entry.isDirectory())
146
+ .sort((a, b) => a.name.localeCompare(b.name))
147
+ : [];
148
+ const collections = [];
149
+ for (const entry of companies) {
150
+ const knowledge = path.join(companiesDir, entry.name, 'knowledge');
151
+ if (!containsIndexedMarkdown(knowledge))
152
+ continue;
153
+ collections.push({
154
+ name: entry.name,
155
+ path: knowledge,
156
+ mask: '**/*.md',
157
+ context: `Knowledge base for ${entry.name}.`,
158
+ });
159
+ }
160
+ for (const entry of companies) {
161
+ const projects = path.join(companiesDir, entry.name, 'projects');
162
+ if (!containsProjectSource(projects))
163
+ continue;
164
+ collections.push({
165
+ name: `${entry.name}-projects`,
166
+ path: projects,
167
+ mask: '**/*.{md,json}',
168
+ context: `Project PRDs and documentation for ${entry.name}.`,
169
+ });
170
+ }
171
+ const personalKnowledge = path.join(root, 'personal', 'knowledge');
172
+ if (containsIndexedMarkdown(personalKnowledge)) {
173
+ collections.push({
174
+ name: 'personal-knowledge',
175
+ path: personalKnowledge,
176
+ mask: '**/*.md',
177
+ context: 'Personal knowledge base (owner overlay).',
178
+ });
179
+ }
180
+ return collections;
181
+ }
182
+ /** Register expected collections that qmd does not yet know about. */
183
+ export function reconcileCollections(hqRoot, options = {}) {
184
+ const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
185
+ const registered = listRegisteredCollections(hqRoot, runOptions);
186
+ const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
187
+ for (const collection of missing) {
188
+ runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
189
+ runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
190
+ }
191
+ return missing;
192
+ }
193
+ export function listRegisteredCollections(hqRoot, options = {}) {
194
+ const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
195
+ return new Set([...result.stdout.matchAll(/qmd:\/\/([^/\s]+)/g)].map((match) => match[1]));
196
+ }
197
+ //# sourceMappingURL=index.js.map
package/dist/main.js CHANGED
@@ -57,6 +57,8 @@ import { registerOutpostsCommand } from "./commands/outposts.js";
57
57
  import { registerBillingCommand } from "./commands/billing.js";
58
58
  import { registerDbCommand } from "./commands/db.js";
59
59
  import { registerCoreCommands } from "./commands/core.js";
60
+ import { registerSearchCommand } from "./commands/search.js";
61
+ import { registerIndexCommand } from "./commands/index-cmd.js";
60
62
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
61
63
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
62
64
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
@@ -228,6 +230,10 @@ registerBillingCommand(program);
228
230
  // the source-root entries are maintainer tools that must never touch a live
229
231
  // install. Registered from a manifest in the module, not wired per script here.
230
232
  registerCoreCommands(program);
233
+ // Local qmd search and index management. Kept distinct from `hq reindex`,
234
+ // which converges scaffold-owned files and hooks rather than search data.
235
+ registerSearchCommand(program);
236
+ registerIndexCommand(program);
231
237
  program.hook("preAction", async () => {
232
238
  await emitCliSessionStarted();
233
239
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.85.3",
3
+ "version": "5.86.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "@indigoai-us/hq-cloud": "^6.14.45",
33
33
  "@indigoai-us/hq-onboarding": "^0.1.0",
34
34
  "@sentry/node": "^10.49.0",
35
+ "@tobilu/qmd": "1.0.7",
35
36
  "better-sqlite3": "^12.11.1",
36
37
  "chalk": "^5.3.0",
37
38
  "commander": "^12.1.0",