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
|
@@ -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();
|
package/src/lib/cache.js
ADDED
|
@@ -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
|
+
}
|