chub-dev 0.1.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/bin/chub ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/index.js';
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "chub-dev",
3
+ "version": "0.1.0",
4
+ "description": "CLI for Context Hub - search and retrieve LLM-optimized docs and skills",
5
+ "type": "module",
6
+ "bin": {
7
+ "chub": "./bin/chub"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "llm",
19
+ "documentation",
20
+ "agent",
21
+ "cli",
22
+ "context",
23
+ "skills"
24
+ ],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/andrewyng/context-hub"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/andrewyng/context-hub/issues"
32
+ },
33
+ "homepage": "https://github.com/andrewyng/context-hub#readme",
34
+ "dependencies": {
35
+ "commander": "^12.0.0",
36
+ "chalk": "^5.3.0",
37
+ "yaml": "^2.3.0",
38
+ "tar": "^7.0.0"
39
+ }
40
+ }
@@ -0,0 +1,321 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, cpSync } from 'node:fs';
2
+ import { join, relative, dirname } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { parseFrontmatter } from '../lib/frontmatter.js';
5
+ import { info } from '../lib/output.js';
6
+
7
+ /**
8
+ * Recursively find all DOC.md and SKILL.md files under a directory.
9
+ */
10
+ function findEntryFiles(dir, base = dir) {
11
+ const results = [];
12
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
+ const full = join(dir, entry.name);
14
+ if (entry.isDirectory()) {
15
+ results.push(...findEntryFiles(full, base));
16
+ } else if (entry.name === 'DOC.md' || entry.name === 'SKILL.md') {
17
+ results.push({ path: full, relPath: relative(base, full), type: entry.name === 'SKILL.md' ? 'skill' : 'doc' });
18
+ }
19
+ }
20
+ return results;
21
+ }
22
+
23
+ /**
24
+ * Get all files in a directory (relative to that directory).
25
+ */
26
+ function listDirFiles(dir) {
27
+ const results = [];
28
+ const walk = (d) => {
29
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
30
+ const full = join(d, entry.name);
31
+ if (entry.isDirectory()) walk(full);
32
+ else results.push(relative(dir, full));
33
+ }
34
+ };
35
+ walk(dir);
36
+ return results;
37
+ }
38
+
39
+ /**
40
+ * Compute total size of all files in a directory.
41
+ */
42
+ function dirSize(dir) {
43
+ let total = 0;
44
+ const walk = (d) => {
45
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
46
+ const full = join(d, entry.name);
47
+ if (entry.isDirectory()) walk(full);
48
+ else total += statSync(full).size;
49
+ }
50
+ };
51
+ walk(dir);
52
+ return total;
53
+ }
54
+
55
+ /**
56
+ * Process an author directory with auto-discovery.
57
+ */
58
+ function discoverAuthor(authorDir, authorName, contentDir) {
59
+ const entryFiles = findEntryFiles(authorDir);
60
+ const docs = new Map(); // name → { description, source, tags, languages: Map<lang, versions[]> }
61
+ const skills = new Map(); // name → skill entry
62
+ const warnings = [];
63
+ const errors = [];
64
+
65
+ for (const ef of entryFiles) {
66
+ const content = readFileSync(ef.path, 'utf8');
67
+ const { attributes } = parseFrontmatter(content);
68
+
69
+ if (!attributes.name) {
70
+ errors.push(`${ef.relPath}: missing 'name' in frontmatter`);
71
+ continue;
72
+ }
73
+ if (!attributes.description) {
74
+ warnings.push(`${ef.relPath}: missing 'description' in frontmatter`);
75
+ }
76
+
77
+ const meta = attributes.metadata || {};
78
+ const name = attributes.name;
79
+ const description = attributes.description || '';
80
+ const source = meta.source || 'community';
81
+ const tags = meta.tags ? meta.tags.split(',').map((t) => t.trim()) : [];
82
+ const updatedOn = meta['updated-on'] || new Date().toISOString().split('T')[0];
83
+ const entryDir = dirname(ef.path);
84
+ const entryPath = relative(contentDir, entryDir);
85
+ const files = listDirFiles(entryDir);
86
+ const size = dirSize(entryDir);
87
+
88
+ if (!meta.source) {
89
+ warnings.push(`${ef.relPath}: missing 'metadata.source', defaulting to 'community'`);
90
+ }
91
+
92
+ if (ef.type === 'skill') {
93
+ // Skills are flat — no language/version
94
+ if (skills.has(name)) {
95
+ errors.push(`${ef.relPath}: duplicate skill name '${name}'`);
96
+ continue;
97
+ }
98
+ skills.set(name, {
99
+ id: `${authorName}/${name}`,
100
+ name,
101
+ description,
102
+ source,
103
+ tags,
104
+ path: entryPath,
105
+ files,
106
+ size,
107
+ lastUpdated: updatedOn,
108
+ });
109
+ } else {
110
+ // Docs need language and version
111
+ const languages = meta.languages
112
+ ? meta.languages.split(',').map((l) => l.trim().toLowerCase())
113
+ : null;
114
+ const versions = meta.versions
115
+ ? meta.versions.split(',').map((v) => v.trim())
116
+ : null;
117
+
118
+ if (!languages || languages.length === 0) {
119
+ errors.push(`${ef.relPath}: missing 'metadata.languages' in frontmatter`);
120
+ continue;
121
+ }
122
+ if (!versions || versions.length === 0) {
123
+ errors.push(`${ef.relPath}: missing 'metadata.versions' in frontmatter`);
124
+ continue;
125
+ }
126
+
127
+ if (!docs.has(name)) {
128
+ docs.set(name, { description, source, tags, languages: new Map() });
129
+ }
130
+
131
+ const doc = docs.get(name);
132
+
133
+ for (const lang of languages) {
134
+ if (!doc.languages.has(lang)) {
135
+ doc.languages.set(lang, []);
136
+ }
137
+ for (const ver of versions) {
138
+ doc.languages.get(lang).push({
139
+ version: ver,
140
+ path: entryPath,
141
+ files,
142
+ size,
143
+ lastUpdated: updatedOn,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // Convert docs map to array format
151
+ const docsArray = [];
152
+ for (const [name, doc] of docs) {
153
+ const languages = [];
154
+ for (const [lang, versions] of doc.languages) {
155
+ // Sort versions descending (simple string sort, good enough for semver)
156
+ versions.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }));
157
+ languages.push({
158
+ language: lang,
159
+ versions,
160
+ recommendedVersion: versions[0].version,
161
+ });
162
+ }
163
+ docsArray.push({
164
+ id: `${authorName}/${name}`,
165
+ name,
166
+ description: doc.description,
167
+ source: doc.source,
168
+ tags: doc.tags,
169
+ languages,
170
+ });
171
+ }
172
+
173
+ return {
174
+ docs: docsArray,
175
+ skills: [...skills.values()],
176
+ warnings,
177
+ errors,
178
+ };
179
+ }
180
+
181
+ export function registerBuildCommand(program) {
182
+ program
183
+ .command('build <content-dir>')
184
+ .description('Build registry.json from a content directory')
185
+ .option('-o, --output <dir>', 'Output directory')
186
+ .option('--base-url <url>', 'Base URL for CDN deployment')
187
+ .option('--validate-only', 'Validate without writing output')
188
+ .action((contentDir, opts) => {
189
+ const globalOpts = program.optsWithGlobals();
190
+
191
+ if (!existsSync(contentDir)) {
192
+ process.stderr.write(`Error: Content directory not found: ${contentDir}\n`);
193
+ process.exit(1);
194
+ }
195
+
196
+ const outputDir = opts.output || join(contentDir, 'dist');
197
+ const allDocs = [];
198
+ const allSkills = [];
199
+ const allWarnings = [];
200
+ const allErrors = [];
201
+
202
+ // List top-level directories (author directories)
203
+ const topLevel = readdirSync(contentDir, { withFileTypes: true })
204
+ .filter((e) => e.isDirectory() && e.name !== 'dist' && !e.name.startsWith('.'));
205
+
206
+ for (const authorEntry of topLevel) {
207
+ const authorDir = join(contentDir, authorEntry.name);
208
+ const authorRegistry = join(authorDir, 'registry.json');
209
+
210
+ if (existsSync(authorRegistry)) {
211
+ // Author provides registry.json — use it directly
212
+ try {
213
+ const reg = JSON.parse(readFileSync(authorRegistry, 'utf8'));
214
+ // Prefix paths with author dir name
215
+ if (reg.docs) {
216
+ for (const doc of reg.docs) {
217
+ if (!doc.id) doc.id = `${authorEntry.name}/${doc.name}`;
218
+ else if (!doc.id.includes('/')) doc.id = `${authorEntry.name}/${doc.id}`;
219
+ for (const lang of doc.languages || []) {
220
+ for (const ver of lang.versions || []) {
221
+ ver.path = `${authorEntry.name}/${ver.path}`;
222
+ }
223
+ }
224
+ allDocs.push(doc);
225
+ }
226
+ }
227
+ if (reg.skills) {
228
+ for (const skill of reg.skills) {
229
+ if (!skill.id) skill.id = `${authorEntry.name}/${skill.name}`;
230
+ else if (!skill.id.includes('/')) skill.id = `${authorEntry.name}/${skill.id}`;
231
+ skill.path = `${authorEntry.name}/${skill.path}`;
232
+ allSkills.push(skill);
233
+ }
234
+ }
235
+ info(`${authorEntry.name}: loaded registry.json`);
236
+ } catch (err) {
237
+ allErrors.push(`${authorEntry.name}/registry.json: ${err.message}`);
238
+ }
239
+ } else {
240
+ // Auto-discover
241
+ const result = discoverAuthor(authorDir, authorEntry.name, contentDir);
242
+ allDocs.push(...result.docs);
243
+ allSkills.push(...result.skills);
244
+ allWarnings.push(...result.warnings);
245
+ allErrors.push(...result.errors);
246
+ }
247
+ }
248
+
249
+ // Check for id collisions (should be rare since ids are author/name)
250
+ const docIds = new Map();
251
+ for (const doc of allDocs) {
252
+ if (docIds.has(doc.id)) {
253
+ allErrors.push(`Duplicate doc id '${doc.id}'`);
254
+ }
255
+ docIds.set(doc.id, true);
256
+ }
257
+ const skillIds = new Map();
258
+ for (const skill of allSkills) {
259
+ if (skillIds.has(skill.id)) {
260
+ allErrors.push(`Duplicate skill id '${skill.id}'`);
261
+ }
262
+ skillIds.set(skill.id, true);
263
+ }
264
+
265
+ // Print warnings
266
+ for (const w of allWarnings) {
267
+ process.stderr.write(chalk.yellow(`Warning: ${w}\n`));
268
+ }
269
+
270
+ // Print errors
271
+ if (allErrors.length > 0) {
272
+ for (const e of allErrors) {
273
+ process.stderr.write(chalk.red(`Error: ${e}\n`));
274
+ }
275
+ process.exit(1);
276
+ }
277
+
278
+ const registry = {
279
+ version: '1.0.0',
280
+ generated: new Date().toISOString(),
281
+ docs: allDocs,
282
+ skills: allSkills,
283
+ };
284
+
285
+ if (opts.baseUrl) {
286
+ registry.base_url = opts.baseUrl;
287
+ }
288
+
289
+ if (opts.validateOnly) {
290
+ const summary = { docs: allDocs.length, skills: allSkills.length, warnings: allWarnings.length };
291
+ if (globalOpts.json) {
292
+ console.log(JSON.stringify(summary));
293
+ } else {
294
+ console.log(chalk.green(`Valid: ${summary.docs} docs, ${summary.skills} skills, ${summary.warnings} warnings`));
295
+ }
296
+ return;
297
+ }
298
+
299
+ // Write output
300
+ mkdirSync(outputDir, { recursive: true });
301
+ writeFileSync(join(outputDir, 'registry.json'), JSON.stringify(registry, null, 2));
302
+
303
+ // Copy content tree
304
+ for (const authorEntry of topLevel) {
305
+ const src = join(contentDir, authorEntry.name);
306
+ const dest = join(outputDir, authorEntry.name);
307
+ // Skip registry.json in author dirs
308
+ cpSync(src, dest, {
309
+ recursive: true,
310
+ filter: (s) => !s.endsWith('/registry.json') || s === join(src, 'registry.json') === false,
311
+ });
312
+ }
313
+
314
+ const summary = { docs: allDocs.length, skills: allSkills.length, warnings: allWarnings.length };
315
+ if (globalOpts.json) {
316
+ console.log(JSON.stringify({ ...summary, output: outputDir }));
317
+ } else {
318
+ console.log(chalk.green(`Built: ${summary.docs} docs, ${summary.skills} skills → ${outputDir}`));
319
+ }
320
+ });
321
+ }
@@ -0,0 +1,52 @@
1
+ import chalk from 'chalk';
2
+ import { getCacheStats, clearCache } from '../lib/cache.js';
3
+ import { output } from '../lib/output.js';
4
+
5
+ export function registerCacheCommand(program) {
6
+ const cache = program
7
+ .command('cache')
8
+ .description('Manage the local cache');
9
+
10
+ cache
11
+ .command('status')
12
+ .description('Show cache information')
13
+ .action(() => {
14
+ const globalOpts = program.optsWithGlobals();
15
+ const stats = getCacheStats();
16
+
17
+ output(stats, (s) => {
18
+ if (!s.exists || s.sources.length === 0) {
19
+ console.log(chalk.yellow('No cache found. Run `chub update` to initialize.'));
20
+ return;
21
+ }
22
+ console.log(chalk.bold('Cache Status\n'));
23
+ for (const src of s.sources) {
24
+ if (src.type === 'local') {
25
+ console.log(` ${chalk.bold(src.name)} ${chalk.dim('(local)')}`);
26
+ console.log(` Path: ${src.path}`);
27
+ } else {
28
+ console.log(` ${chalk.bold(src.name)} ${chalk.dim('(remote)')}`);
29
+ console.log(` Registry: ${src.hasRegistry ? chalk.green('yes') : chalk.red('no')}`);
30
+ console.log(` Last updated: ${src.lastUpdated || 'never'}`);
31
+ console.log(` Full bundle: ${src.fullBundle ? 'yes' : 'no'}`);
32
+ console.log(` Cached files: ${src.fileCount}`);
33
+ console.log(` Size: ${(src.dataSize / 1024).toFixed(1)} KB`);
34
+ }
35
+ }
36
+ }, globalOpts);
37
+ });
38
+
39
+ cache
40
+ .command('clear')
41
+ .description('Clear cached data')
42
+ .option('--force', 'Skip confirmation')
43
+ .action((opts) => {
44
+ const globalOpts = program.optsWithGlobals();
45
+ clearCache();
46
+ output(
47
+ { status: 'cleared' },
48
+ () => console.log(chalk.green('Cache cleared.')),
49
+ globalOpts
50
+ );
51
+ });
52
+ }
@@ -0,0 +1,157 @@
1
+ import { writeFileSync, mkdirSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { getEntry, resolveDocPath, resolveEntryFile } from '../lib/registry.js';
5
+ import { fetchDoc, fetchDocFull } from '../lib/cache.js';
6
+ import { output, error, info } from '../lib/output.js';
7
+
8
+ /**
9
+ * Core fetch logic shared by `get docs` and `get skills`.
10
+ * @param {string} type - "doc" or "skill"
11
+ * @param {string[]} ids - one or more entry ids
12
+ * @param {object} opts - command options (lang, version, output, full)
13
+ * @param {object} globalOpts - global options (json)
14
+ */
15
+ async function fetchEntries(type, ids, opts, globalOpts) {
16
+ const results = [];
17
+
18
+ for (const id of ids) {
19
+ const result = getEntry(id, type);
20
+
21
+ if (result.ambiguous) {
22
+ error(
23
+ `Multiple entries with id "${id}". Be specific:\n ${result.alternatives.join('\n ')}`,
24
+ globalOpts
25
+ );
26
+ }
27
+
28
+ if (!result.entry) {
29
+ error(`Entry "${id}" not found in ${type}s.`, globalOpts);
30
+ }
31
+
32
+ const entry = result.entry;
33
+ const resolved = resolveDocPath(entry, opts.lang, opts.version);
34
+
35
+ if (!resolved) {
36
+ error(`Could not resolve path for "${id}" ${opts.lang || ''} ${opts.version || ''}`.trim(), globalOpts);
37
+ }
38
+
39
+ if (resolved.needsLanguage) {
40
+ error(
41
+ `Multiple languages available for "${id}": ${resolved.available.join(', ')}. Specify --lang.`,
42
+ globalOpts
43
+ );
44
+ }
45
+
46
+ const entryFile = resolveEntryFile(resolved, type);
47
+ if (entryFile.error) {
48
+ error(`"${id}" ${entryFile.error}`, globalOpts);
49
+ }
50
+
51
+ try {
52
+ if (opts.full && resolved.files.length > 0) {
53
+ const allFiles = await fetchDocFull(resolved.source, resolved.path, resolved.files);
54
+ results.push({ id: entry.id, files: allFiles, path: resolved.path });
55
+ } else {
56
+ const content = await fetchDoc(resolved.source, entryFile.filePath);
57
+ results.push({ id: entry.id, content, path: entryFile.filePath });
58
+ }
59
+ } catch (err) {
60
+ error(err.message, globalOpts);
61
+ }
62
+ }
63
+
64
+ // Output
65
+ if (opts.output) {
66
+ if (opts.full) {
67
+ // --full -o: write individual files preserving directory structure
68
+ for (const r of results) {
69
+ if (r.files) {
70
+ const baseDir = ids.length > 1 ? join(opts.output, r.id) : opts.output;
71
+ mkdirSync(baseDir, { recursive: true });
72
+ for (const f of r.files) {
73
+ const outPath = join(baseDir, f.name);
74
+ mkdirSync(dirname(outPath), { recursive: true });
75
+ writeFileSync(outPath, f.content);
76
+ }
77
+ info(`Written ${r.files.length} files to ${baseDir}`);
78
+ } else {
79
+ const outPath = join(opts.output, `${r.id}.md`);
80
+ mkdirSync(dirname(outPath), { recursive: true });
81
+ writeFileSync(outPath, r.content);
82
+ info(`Written to ${outPath}`);
83
+ }
84
+ }
85
+ } else {
86
+ const isDir = opts.output.endsWith('/');
87
+ if (isDir && results.length > 1) {
88
+ mkdirSync(opts.output, { recursive: true });
89
+ for (const r of results) {
90
+ const outPath = join(opts.output, `${r.id}.md`);
91
+ writeFileSync(outPath, r.content);
92
+ info(`Written to ${outPath}`);
93
+ }
94
+ } else {
95
+ const outPath = isDir ? join(opts.output, `${results[0].id}.md`) : opts.output;
96
+ mkdirSync(dirname(outPath), { recursive: true });
97
+ const combined = results.map((r) => r.content).join('\n\n---\n\n');
98
+ writeFileSync(outPath, combined);
99
+ info(`Written to ${outPath}`);
100
+ }
101
+ }
102
+ if (globalOpts.json) {
103
+ console.log(JSON.stringify(results.map((r) => ({ id: r.id, path: opts.output }))));
104
+ }
105
+ } else {
106
+ // stdout
107
+ if (results.length === 1 && !results[0].files) {
108
+ output(
109
+ { id: results[0].id, content: results[0].content, path: results[0].path },
110
+ (data) => process.stdout.write(data.content),
111
+ globalOpts
112
+ );
113
+ } else {
114
+ // Concatenate all content (--full to stdout, or multiple entries)
115
+ const parts = results.flatMap((r) => {
116
+ if (r.files) {
117
+ return r.files.map((f) => `# FILE: ${f.name}\n\n${f.content}`);
118
+ }
119
+ return [r.content];
120
+ });
121
+ const combined = parts.join('\n\n---\n\n');
122
+ output(
123
+ results.map((r) => ({ id: r.id, path: r.path })),
124
+ () => process.stdout.write(combined),
125
+ globalOpts
126
+ );
127
+ }
128
+ }
129
+ }
130
+
131
+ export function registerGetCommand(program) {
132
+ const get = program
133
+ .command('get')
134
+ .description('Retrieve docs or skills');
135
+
136
+ get
137
+ .command('docs <ids...>')
138
+ .description('Fetch documentation content')
139
+ .option('--lang <language>', 'Language variant')
140
+ .option('--version <version>', 'Specific version')
141
+ .option('-o, --output <path>', 'Write to file or directory')
142
+ .option('--full', 'Fetch all files (not just entry point)')
143
+ .action(async (ids, opts) => {
144
+ const globalOpts = program.optsWithGlobals();
145
+ await fetchEntries('doc', ids, opts, globalOpts);
146
+ });
147
+
148
+ get
149
+ .command('skills <ids...>')
150
+ .description('Fetch skill content')
151
+ .option('-o, --output <path>', 'Write to file or directory')
152
+ .option('--full', 'Fetch all files (not just entry point)')
153
+ .action(async (ids, opts) => {
154
+ const globalOpts = program.optsWithGlobals();
155
+ await fetchEntries('skill', ids, opts, globalOpts);
156
+ });
157
+ }
@@ -0,0 +1,105 @@
1
+ import chalk from 'chalk';
2
+ import { searchEntries, listEntries, getEntry, getDisplayId, isMultiSource } from '../lib/registry.js';
3
+ import { displayLanguage } from '../lib/normalize.js';
4
+ import { output } from '../lib/output.js';
5
+
6
+ function formatEntryList(entries) {
7
+ const multi = isMultiSource();
8
+ for (const entry of entries) {
9
+ const id = getDisplayId(entry);
10
+ const source = entry.source ? chalk.dim(`[${entry.source}]`) : '';
11
+ const sourceName = multi ? chalk.cyan(`(${entry._source})`) : '';
12
+ const type = entry._type === 'skill' ? chalk.magenta('[skill]') : chalk.blue('[doc]');
13
+ const langs = (entry.languages || []).map((l) => displayLanguage(l.language)).join(', ');
14
+ const desc = entry.description
15
+ ? entry.description.length > 60
16
+ ? entry.description.slice(0, 57) + '...'
17
+ : entry.description
18
+ : '';
19
+ console.log(` ${chalk.bold(id)} ${type} ${chalk.dim(langs)} ${source} ${sourceName}`.trimEnd());
20
+ if (desc) console.log(` ${chalk.dim(desc)}`);
21
+ }
22
+ }
23
+
24
+ function formatEntryDetail(entry) {
25
+ console.log(chalk.bold(entry.name));
26
+ if (isMultiSource()) console.log(` Source: ${entry._source}`);
27
+ if (entry.source) console.log(` Quality: ${entry.source}`);
28
+ if (entry.description) console.log(` ${chalk.dim(entry.description)}`);
29
+ if (entry.tags?.length) console.log(` Tags: ${entry.tags.join(', ')}`);
30
+ console.log();
31
+ if (entry.languages) {
32
+ for (const lang of entry.languages) {
33
+ console.log(` ${chalk.bold(displayLanguage(lang.language))}`);
34
+ console.log(` Recommended: ${lang.recommendedVersion}`);
35
+ for (const v of lang.versions || []) {
36
+ const size = v.size ? ` (${(v.size / 1024).toFixed(1)} KB)` : '';
37
+ console.log(` ${v.version}${size} updated: ${v.lastUpdated}`);
38
+ }
39
+ }
40
+ } else {
41
+ // Skill — flat structure
42
+ const size = entry.size ? ` (${(entry.size / 1024).toFixed(1)} KB)` : '';
43
+ console.log(` Path: ${entry.path}${size}`);
44
+ if (entry.lastUpdated) console.log(` Updated: ${entry.lastUpdated}`);
45
+ if (entry.files?.length) console.log(` Files: ${entry.files.join(', ')}`);
46
+ }
47
+ }
48
+
49
+ export function registerSearchCommand(program) {
50
+ program
51
+ .command('search [query]')
52
+ .description('Search docs and skills (no query lists all)')
53
+ .option('--tags <tags>', 'Filter by tags (comma-separated)')
54
+ .option('--lang <language>', 'Filter by language')
55
+ .option('--limit <n>', 'Max results', '20')
56
+ .action((query, opts) => {
57
+ const globalOpts = program.optsWithGlobals();
58
+ const limit = parseInt(opts.limit, 10);
59
+
60
+ // No query: list all
61
+ if (!query) {
62
+ const entries = listEntries(opts).slice(0, limit);
63
+ output({ results: entries, total: entries.length }, (data) => {
64
+ if (data.results.length === 0) {
65
+ console.log(chalk.yellow('No entries found.'));
66
+ return;
67
+ }
68
+ console.log(chalk.bold(`${data.total} entries:\n`));
69
+ formatEntryList(data.results);
70
+ }, globalOpts);
71
+ return;
72
+ }
73
+
74
+ // Exact id match: show detail
75
+ const result = getEntry(query);
76
+ if (result.ambiguous) {
77
+ output(
78
+ { error: 'ambiguous', alternatives: result.alternatives },
79
+ () => {
80
+ console.log(chalk.yellow(`Multiple entries with id "${query}". Be specific:`));
81
+ for (const alt of result.alternatives) {
82
+ console.log(` ${chalk.bold(alt)}`);
83
+ }
84
+ },
85
+ globalOpts
86
+ );
87
+ return;
88
+ }
89
+ if (result.entry) {
90
+ output(result.entry, formatEntryDetail, globalOpts);
91
+ return;
92
+ }
93
+
94
+ // Fuzzy search
95
+ const results = searchEntries(query, opts).slice(0, limit);
96
+ output({ results, total: results.length, query }, (data) => {
97
+ if (data.results.length === 0) {
98
+ console.log(chalk.yellow(`No results for "${query}".`));
99
+ return;
100
+ }
101
+ console.log(chalk.bold(`${data.total} results for "${query}":\n`));
102
+ formatEntryList(data.results);
103
+ }, globalOpts);
104
+ });
105
+ }