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.
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk';
2
+ import { fetchAllRegistries, fetchFullBundle } from '../lib/cache.js';
3
+ import { loadConfig } from '../lib/config.js';
4
+ import { output, info } from '../lib/output.js';
5
+
6
+ export function registerUpdateCommand(program) {
7
+ program
8
+ .command('update')
9
+ .description('Refresh the cached registry index')
10
+ .option('--force', 'Force re-download even if cache is fresh')
11
+ .option('--full', 'Download the full bundle for offline use')
12
+ .action(async (opts) => {
13
+ const globalOpts = program.optsWithGlobals();
14
+ const config = loadConfig();
15
+
16
+ try {
17
+ if (opts.full) {
18
+ // Download full bundle for each remote source
19
+ for (const source of config.sources) {
20
+ if (source.path) {
21
+ info(`Skipping local source: ${source.name}`);
22
+ continue;
23
+ }
24
+ info(`Downloading full bundle for ${source.name}...`);
25
+ await fetchFullBundle(source.name);
26
+ }
27
+ output(
28
+ { status: 'ok', mode: 'full' },
29
+ () => console.log(chalk.green('Full bundle(s) downloaded and extracted.')),
30
+ globalOpts
31
+ );
32
+ } else {
33
+ info('Updating registries...');
34
+ const errors = await fetchAllRegistries(opts.force || true);
35
+ if (errors.length > 0) {
36
+ for (const e of errors) {
37
+ process.stderr.write(chalk.yellow(`Warning: ${e.source}: ${e.error}\n`));
38
+ }
39
+ }
40
+ const updated = config.sources.filter((s) => !s.path).length - errors.length;
41
+ output(
42
+ { status: 'ok', mode: 'registry', updated, errors },
43
+ () => console.log(chalk.green(`Registry updated (${updated} remote source(s)).`)),
44
+ globalOpts
45
+ );
46
+ }
47
+ } catch (err) {
48
+ output(
49
+ { error: err.message },
50
+ () => console.error(chalk.red(`Update failed: ${err.message}`)),
51
+ globalOpts
52
+ );
53
+ process.exit(1);
54
+ }
55
+ });
56
+ }
package/src/index.js ADDED
@@ -0,0 +1,109 @@
1
+ import chalk from 'chalk';
2
+ import { Command } from 'commander';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { ensureRegistry } from './lib/cache.js';
7
+ import { registerUpdateCommand } from './commands/update.js';
8
+ import { registerCacheCommand } from './commands/cache.js';
9
+ import { registerSearchCommand } from './commands/search.js';
10
+ import { registerGetCommand } from './commands/get.js';
11
+ import { registerBuildCommand } from './commands/build.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
15
+
16
+ function printUsage() {
17
+ console.log(`
18
+ ${chalk.bold('chub')} — Context Hub CLI v${pkg.version}
19
+ Search and retrieve LLM-optimized docs and skills.
20
+
21
+ ${chalk.bold.underline('Getting Started')}
22
+
23
+ ${chalk.dim('$')} chub update ${chalk.dim('# download the registry')}
24
+ ${chalk.dim('$')} chub search ${chalk.dim('# list everything available')}
25
+ ${chalk.dim('$')} chub search "stripe" ${chalk.dim('# fuzzy search')}
26
+ ${chalk.dim('$')} chub search stripe/payments ${chalk.dim('# exact id → full detail')}
27
+ ${chalk.dim('$')} chub get docs stripe/payments ${chalk.dim('# print doc to terminal')}
28
+ ${chalk.dim('$')} chub get docs stripe/payments -o doc.md ${chalk.dim('# save to file')}
29
+ ${chalk.dim('$')} chub get docs stripe/payments --lang py ${chalk.dim('# specific language')}
30
+ ${chalk.dim('$')} chub get skills pw/login-flows ${chalk.dim('# fetch a skill')}
31
+ ${chalk.dim('$')} chub get docs openai/chat stripe/payments ${chalk.dim('# fetch multiple')}
32
+
33
+ ${chalk.bold.underline('Commands')}
34
+
35
+ ${chalk.bold('search')} [query] Search docs and skills (no query = list all)
36
+ ${chalk.bold('get docs')} <ids...> Fetch documentation content
37
+ ${chalk.bold('get skills')} <ids...> Fetch skill content
38
+ ${chalk.bold('update')} Refresh the cached registry
39
+ ${chalk.bold('cache')} status|clear Manage the local cache
40
+ ${chalk.bold('build')} <content-dir> Build registry from content directory
41
+
42
+ ${chalk.bold.underline('Flags')}
43
+
44
+ --json Structured JSON output (for agents and piping)
45
+ --tags <csv> Filter by tags (e.g. docs, skill, openai, browser)
46
+ --lang <language> Language variant (js, py, ts)
47
+ --full Fetch all files, not just the entry point
48
+ -o, --output <path> Write content to file or directory
49
+
50
+ ${chalk.bold.underline('Agent Piping Patterns')}
51
+
52
+ ${chalk.dim('# Get the top result id')}
53
+ ${chalk.dim('$')} chub search "stripe" --json | jq -r '.results[0].id'
54
+
55
+ ${chalk.dim('# Search → pick → fetch → save')}
56
+ ${chalk.dim('$')} ID=$(chub search "stripe" --json | jq -r '.results[0].id')
57
+ ${chalk.dim('$')} chub get docs "$ID" --lang js -o .context/stripe.md
58
+
59
+ ${chalk.dim('# Fetch multiple docs at once')}
60
+ ${chalk.dim('$')} chub get docs openai/chat stripe/payments -o .context/
61
+
62
+ ${chalk.bold.underline('Multi-Source Config')} ${chalk.dim('(~/.chub/config.yaml)')}
63
+
64
+ ${chalk.dim('sources:')}
65
+ ${chalk.dim(' - name: community')}
66
+ ${chalk.dim(' url: https://cdn.contexthub.dev/v1')}
67
+ ${chalk.dim(' - name: internal')}
68
+ ${chalk.dim(' path: /path/to/local/docs')}
69
+
70
+ ${chalk.dim('# On id collision, use source: prefix: chub get docs internal:openai/chat')}
71
+ `);
72
+ }
73
+
74
+ const program = new Command();
75
+
76
+ program
77
+ .name('chub')
78
+ .description('Context Hub - search and retrieve LLM-optimized docs and skills')
79
+ .version(pkg.version)
80
+ .option('--json', 'Output as JSON (machine-readable)')
81
+ .action(() => {
82
+ printUsage();
83
+ });
84
+
85
+ // Commands that don't need registry
86
+ const SKIP_REGISTRY = ['update', 'cache', 'build', 'help'];
87
+
88
+ program.hook('preAction', async (thisCommand) => {
89
+ const cmdName = thisCommand.args?.[0] || thisCommand.name();
90
+ if (SKIP_REGISTRY.includes(cmdName)) return;
91
+ if (thisCommand.parent?.name() === 'cache') return;
92
+ // Don't fetch registry for default action (no command)
93
+ if (cmdName === 'chub') return;
94
+ try {
95
+ await ensureRegistry();
96
+ } catch (err) {
97
+ process.stderr.write(`Warning: Could not load registry: ${err.message}\n`);
98
+ process.stderr.write(`Run \`chub update\` to initialize.\n`);
99
+ process.exit(1);
100
+ }
101
+ });
102
+
103
+ registerUpdateCommand(program);
104
+ registerCacheCommand(program);
105
+ registerSearchCommand(program);
106
+ registerGetCommand(program);
107
+ registerBuildCommand(program);
108
+
109
+ program.parse();
@@ -0,0 +1,287 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { getChubDir, loadConfig } from './config.js';
6
+
7
+ function getSourceDir(sourceName) {
8
+ return join(getChubDir(), 'sources', sourceName);
9
+ }
10
+
11
+ function getSourceDataDir(sourceName) {
12
+ return join(getSourceDir(sourceName), 'data');
13
+ }
14
+
15
+ function getSourceMetaPath(sourceName) {
16
+ return join(getSourceDir(sourceName), 'meta.json');
17
+ }
18
+
19
+ function getSourceRegistryPath(sourceName) {
20
+ return join(getSourceDir(sourceName), 'registry.json');
21
+ }
22
+
23
+ function readMeta(sourceName) {
24
+ try {
25
+ return JSON.parse(readFileSync(getSourceMetaPath(sourceName), 'utf8'));
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeMeta(sourceName, meta) {
32
+ const dir = getSourceDir(sourceName);
33
+ mkdirSync(dir, { recursive: true });
34
+ writeFileSync(getSourceMetaPath(sourceName), JSON.stringify(meta, null, 2));
35
+ }
36
+
37
+ function isSourceCacheFresh(sourceName) {
38
+ const meta = readMeta(sourceName);
39
+ if (!meta.lastUpdated) return false;
40
+ const config = loadConfig();
41
+ const age = (Date.now() - meta.lastUpdated) / 1000;
42
+ return age < config.refresh_interval;
43
+ }
44
+
45
+ /**
46
+ * Fetch registry for a single remote source.
47
+ */
48
+ async function fetchRemoteRegistry(source, force = false) {
49
+ if (!force && isSourceCacheFresh(source.name) && existsSync(getSourceRegistryPath(source.name))) {
50
+ return;
51
+ }
52
+
53
+ const url = `${source.url}/registry.json`;
54
+ const res = await fetch(url);
55
+ if (!res.ok) {
56
+ throw new Error(`Failed to fetch registry from ${source.name}: ${res.status} ${res.statusText}`);
57
+ }
58
+
59
+ const data = await res.text();
60
+ const dir = getSourceDir(source.name);
61
+ mkdirSync(dir, { recursive: true });
62
+ writeFileSync(getSourceRegistryPath(source.name), data);
63
+ writeMeta(source.name, { ...readMeta(source.name), lastUpdated: Date.now() });
64
+ }
65
+
66
+ /**
67
+ * Fetch registries for all configured sources.
68
+ */
69
+ export async function fetchAllRegistries(force = false) {
70
+ const config = loadConfig();
71
+ const errors = [];
72
+
73
+ for (const source of config.sources) {
74
+ if (source.path) continue; // Local sources don't need fetching
75
+ try {
76
+ await fetchRemoteRegistry(source, force);
77
+ } catch (err) {
78
+ errors.push({ source: source.name, error: err.message });
79
+ }
80
+ }
81
+
82
+ return errors;
83
+ }
84
+
85
+ /**
86
+ * Download full bundle for a remote source.
87
+ */
88
+ export async function fetchFullBundle(sourceName) {
89
+ const config = loadConfig();
90
+ const source = config.sources.find((s) => s.name === sourceName);
91
+ if (!source || source.path) {
92
+ throw new Error(`Source "${sourceName}" is not a remote source.`);
93
+ }
94
+
95
+ const url = `${source.url}/bundle.tar.gz`;
96
+ const tmpPath = join(getSourceDir(sourceName), 'bundle.tar.gz');
97
+
98
+ const res = await fetch(url);
99
+ if (!res.ok) {
100
+ throw new Error(`Failed to fetch bundle from ${sourceName}: ${res.status} ${res.statusText}`);
101
+ }
102
+
103
+ const dir = getSourceDir(sourceName);
104
+ mkdirSync(dir, { recursive: true });
105
+ await pipeline(res.body, createWriteStream(tmpPath));
106
+
107
+ const { extract } = await import('tar');
108
+ const dataDir = getSourceDataDir(sourceName);
109
+ mkdirSync(dataDir, { recursive: true });
110
+ await extract({ file: tmpPath, cwd: dataDir });
111
+
112
+ // Copy registry.json from extracted bundle if present
113
+ const extractedRegistry = join(dataDir, 'registry.json');
114
+ if (existsSync(extractedRegistry)) {
115
+ const regData = readFileSync(extractedRegistry, 'utf8');
116
+ writeFileSync(getSourceRegistryPath(sourceName), regData);
117
+ }
118
+
119
+ writeMeta(sourceName, { ...readMeta(sourceName), lastUpdated: Date.now(), fullBundle: true });
120
+ rmSync(tmpPath, { force: true });
121
+ }
122
+
123
+ /**
124
+ * Fetch a single doc. Source object must have name + (url or path).
125
+ */
126
+ export async function fetchDoc(source, docPath) {
127
+ // Local source: read directly
128
+ if (source.path) {
129
+ const localPath = join(source.path, docPath);
130
+ if (!existsSync(localPath)) {
131
+ throw new Error(`File not found: ${localPath}`);
132
+ }
133
+ return readFileSync(localPath, 'utf8');
134
+ }
135
+
136
+ // Remote source: check cache first
137
+ const cachedPath = join(getSourceDataDir(source.name), docPath);
138
+ if (existsSync(cachedPath)) {
139
+ return readFileSync(cachedPath, 'utf8');
140
+ }
141
+
142
+ // Fetch from CDN
143
+ const url = `${source.url}/${docPath}`;
144
+ const res = await fetch(url);
145
+ if (!res.ok) {
146
+ throw new Error(`Failed to fetch ${docPath} from ${source.name}: ${res.status} ${res.statusText}`);
147
+ }
148
+
149
+ const content = await res.text();
150
+
151
+ // Cache locally
152
+ const dir = cachedPath.substring(0, cachedPath.lastIndexOf('/'));
153
+ mkdirSync(dir, { recursive: true });
154
+ writeFileSync(cachedPath, content);
155
+
156
+ return content;
157
+ }
158
+
159
+ /**
160
+ * Fetch all files in an entry directory.
161
+ * Returns array of { name, content }.
162
+ */
163
+ export async function fetchDocFull(source, basePath, files) {
164
+ const results = [];
165
+ for (const file of files) {
166
+ const filePath = `${basePath}/${file}`;
167
+ const content = await fetchDoc(source, filePath);
168
+ results.push({ name: file, content });
169
+ }
170
+ return results;
171
+ }
172
+
173
+ /**
174
+ * Load cached/local registry for a single source.
175
+ */
176
+ export function loadSourceRegistry(source) {
177
+ if (source.path) {
178
+ // Local source: read registry.json from the folder
179
+ const regPath = join(source.path, 'registry.json');
180
+ if (!existsSync(regPath)) return null;
181
+ return JSON.parse(readFileSync(regPath, 'utf8'));
182
+ }
183
+
184
+ // Remote source: read from cache
185
+ const regPath = getSourceRegistryPath(source.name);
186
+ if (!existsSync(regPath)) return null;
187
+ return JSON.parse(readFileSync(regPath, 'utf8'));
188
+ }
189
+
190
+ /**
191
+ * Get cache stats.
192
+ */
193
+ export function getCacheStats() {
194
+ const chubDir = getChubDir();
195
+ if (!existsSync(chubDir)) {
196
+ return { exists: false, sources: [] };
197
+ }
198
+
199
+ const config = loadConfig();
200
+ const sourceStats = [];
201
+
202
+ for (const source of config.sources) {
203
+ if (source.path) {
204
+ sourceStats.push({ name: source.name, type: 'local', path: source.path });
205
+ continue;
206
+ }
207
+
208
+ const meta = readMeta(source.name);
209
+ const dataDir = getSourceDataDir(source.name);
210
+ let dataSize = 0;
211
+ let fileCount = 0;
212
+
213
+ if (existsSync(dataDir)) {
214
+ const walk = (dir) => {
215
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
216
+ const full = join(dir, entry.name);
217
+ if (entry.isDirectory()) walk(full);
218
+ else { dataSize += statSync(full).size; fileCount++; }
219
+ }
220
+ };
221
+ walk(dataDir);
222
+ }
223
+
224
+ sourceStats.push({
225
+ name: source.name,
226
+ type: 'remote',
227
+ hasRegistry: existsSync(getSourceRegistryPath(source.name)),
228
+ lastUpdated: meta.lastUpdated ? new Date(meta.lastUpdated).toISOString() : null,
229
+ fullBundle: meta.fullBundle || false,
230
+ fileCount,
231
+ dataSize,
232
+ });
233
+ }
234
+
235
+ return { exists: true, sources: sourceStats };
236
+ }
237
+
238
+ /**
239
+ * Clear the cache (preserves config.yaml).
240
+ */
241
+ export function clearCache() {
242
+ const chubDir = getChubDir();
243
+ const configPath = join(chubDir, 'config.yaml');
244
+ let configContent = null;
245
+ if (existsSync(configPath)) {
246
+ configContent = readFileSync(configPath, 'utf8');
247
+ }
248
+
249
+ rmSync(chubDir, { recursive: true, force: true });
250
+
251
+ if (configContent) {
252
+ mkdirSync(chubDir, { recursive: true });
253
+ writeFileSync(configPath, configContent);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Ensure at least one registry is available.
259
+ */
260
+ export async function ensureRegistry() {
261
+ const config = loadConfig();
262
+
263
+ // Check if any source has a registry available
264
+ let hasAny = false;
265
+ for (const source of config.sources) {
266
+ if (source.path) {
267
+ const regPath = join(source.path, 'registry.json');
268
+ if (existsSync(regPath)) { hasAny = true; break; }
269
+ } else {
270
+ if (existsSync(getSourceRegistryPath(source.name))) { hasAny = true; break; }
271
+ }
272
+ }
273
+
274
+ if (hasAny) {
275
+ // Auto-refresh stale remote registries (best-effort)
276
+ for (const source of config.sources) {
277
+ if (source.path) continue;
278
+ if (!isSourceCacheFresh(source.name)) {
279
+ try { await fetchRemoteRegistry(source); } catch { /* use stale */ }
280
+ }
281
+ }
282
+ return;
283
+ }
284
+
285
+ // No registries at all — must download remote ones
286
+ await fetchAllRegistries(true);
287
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ const DEFAULT_CDN_URL = 'https://github.com/context-hub/context-hub/releases/latest/download';
7
+
8
+ const DEFAULTS = {
9
+ output_dir: '.context',
10
+ refresh_interval: 86400,
11
+ output_format: 'human',
12
+ source: 'official,maintainer,community',
13
+ };
14
+
15
+ let _config = null;
16
+
17
+ export function getChubDir() {
18
+ return join(homedir(), '.chub');
19
+ }
20
+
21
+ export function loadConfig() {
22
+ if (_config) return _config;
23
+
24
+ let fileConfig = {};
25
+ const configPath = join(getChubDir(), 'config.yaml');
26
+ try {
27
+ const raw = readFileSync(configPath, 'utf8');
28
+ fileConfig = parseYaml(raw) || {};
29
+ } catch {
30
+ // No config file, use defaults
31
+ }
32
+
33
+ // Build sources list
34
+ let sources;
35
+ if (fileConfig.sources && Array.isArray(fileConfig.sources)) {
36
+ sources = fileConfig.sources;
37
+ } else {
38
+ // Backward compat: single cdn_url becomes a single source
39
+ const url = process.env.CHUB_BUNDLE_URL || fileConfig.cdn_url || DEFAULT_CDN_URL;
40
+ sources = [{ name: 'default', url }];
41
+ }
42
+
43
+ _config = {
44
+ sources,
45
+ output_dir: fileConfig.output_dir || DEFAULTS.output_dir,
46
+ refresh_interval: fileConfig.refresh_interval ?? DEFAULTS.refresh_interval,
47
+ output_format: fileConfig.output_format || DEFAULTS.output_format,
48
+ source: fileConfig.source || DEFAULTS.source,
49
+ };
50
+
51
+ return _config;
52
+ }
@@ -0,0 +1,14 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+
3
+ /**
4
+ * Parse YAML frontmatter from markdown content.
5
+ * Returns { attributes, body } where attributes is the parsed YAML object.
6
+ */
7
+ export function parseFrontmatter(content) {
8
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
9
+ if (!match) return { attributes: {}, body: content };
10
+ return {
11
+ attributes: parseYaml(match[1]) || {},
12
+ body: match[2],
13
+ };
14
+ }
@@ -0,0 +1,25 @@
1
+ const ALIASES = {
2
+ js: 'javascript',
3
+ ts: 'typescript',
4
+ py: 'python',
5
+ rb: 'ruby',
6
+ cs: 'csharp',
7
+ };
8
+
9
+ const DISPLAY = {
10
+ javascript: 'js',
11
+ typescript: 'ts',
12
+ python: 'py',
13
+ ruby: 'rb',
14
+ csharp: 'cs',
15
+ };
16
+
17
+ export function normalizeLanguage(lang) {
18
+ if (!lang) return null;
19
+ const lower = lang.toLowerCase();
20
+ return ALIASES[lower] || lower;
21
+ }
22
+
23
+ export function displayLanguage(lang) {
24
+ return DISPLAY[lang] || lang;
25
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Dual-mode output: human-friendly (default) or JSON (--json flag).
3
+ *
4
+ * Every command calls `output(data, humanFormatter, opts)`.
5
+ * - In JSON mode: prints JSON to stdout, nothing else.
6
+ * - In human mode: calls humanFormatter(data) which prints with chalk.
7
+ */
8
+ export function output(data, humanFormatter, opts) {
9
+ if (opts?.json) {
10
+ console.log(JSON.stringify(data, null, 2));
11
+ } else {
12
+ humanFormatter(data);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Print a message to stderr (for confirmations when -o is used).
18
+ */
19
+ export function info(msg) {
20
+ process.stderr.write(msg + '\n');
21
+ }
22
+
23
+ /**
24
+ * Print an error and exit.
25
+ */
26
+ export function error(msg, opts) {
27
+ if (opts?.json) {
28
+ console.log(JSON.stringify({ error: msg }));
29
+ } else {
30
+ process.stderr.write(`Error: ${msg}\n`);
31
+ }
32
+ process.exit(1);
33
+ }