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 +2 -0
- package/package.json +40 -0
- package/src/commands/build.js +321 -0
- package/src/commands/cache.js +52 -0
- package/src/commands/get.js +157 -0
- package/src/commands/search.js +105 -0
- package/src/commands/update.js +56 -0
- package/src/index.js +109 -0
- package/src/lib/cache.js +287 -0
- package/src/lib/config.js +52 -0
- package/src/lib/frontmatter.js +14 -0
- package/src/lib/normalize.js +25 -0
- package/src/lib/output.js +33 -0
- package/src/lib/registry.js +284 -0
package/bin/chub
ADDED
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
|
+
}
|