chub-dev 0.2.0-beta.3 → 0.3.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/src/lib/cache.js CHANGED
@@ -31,6 +31,10 @@ function getSourceRegistryPath(sourceName) {
31
31
  return join(getSourceDir(sourceName), 'registry.json');
32
32
  }
33
33
 
34
+ function getSourceSearchIndexPath(sourceName) {
35
+ return join(getSourceDir(sourceName), 'search-index.json');
36
+ }
37
+
34
38
  function readMeta(sourceName) {
35
39
  try {
36
40
  return JSON.parse(readFileSync(getSourceMetaPath(sourceName), 'utf8'));
@@ -47,38 +51,99 @@ function writeMeta(sourceName, meta) {
47
51
 
48
52
  function isSourceCacheFresh(sourceName) {
49
53
  const meta = readMeta(sourceName);
50
- if (!meta.lastUpdated) return false;
54
+ if (!meta.lastUpdated && meta.lastUpdated !== 0) return false;
51
55
  const config = loadConfig();
52
56
  const age = (Date.now() - meta.lastUpdated) / 1000;
53
57
  return age < config.refresh_interval;
54
58
  }
55
59
 
56
- /**
57
- * Fetch registry for a single remote source.
58
- */
59
- async function fetchRemoteRegistry(source, force = false) {
60
- if (!force && isSourceCacheFresh(source.name) && existsSync(getSourceRegistryPath(source.name))) {
61
- return;
60
+ function isTimestampFresh(timestamp) {
61
+ if (timestamp === undefined || timestamp === null) return false;
62
+ const config = loadConfig();
63
+ const age = (Date.now() - timestamp) / 1000;
64
+ return age < config.refresh_interval;
65
+ }
66
+
67
+ function hasFreshSearchIndexState(sourceName) {
68
+ if (existsSync(getSourceSearchIndexPath(sourceName))) {
69
+ return true;
62
70
  }
63
71
 
64
- const url = `${source.url}/registry.json`;
72
+ const meta = readMeta(sourceName);
73
+ return meta.searchIndexAvailable === false && isTimestampFresh(meta.searchIndexCheckedAt);
74
+ }
75
+
76
+ function shouldFetchRemoteRegistry(sourceName, force = false) {
77
+ if (force) return true;
78
+ return !(
79
+ isSourceCacheFresh(sourceName)
80
+ && existsSync(getSourceRegistryPath(sourceName))
81
+ && hasFreshSearchIndexState(sourceName)
82
+ );
83
+ }
84
+
85
+ async function fetchRemoteText(url) {
65
86
  const controller = new AbortController();
66
87
  const timeout = setTimeout(() => controller.abort(), 30000);
67
- let res;
68
88
  try {
69
- res = await fetch(url, { signal: controller.signal });
89
+ const res = await fetch(url, { signal: controller.signal });
90
+ if (!res.ok) {
91
+ throw new Error(`${res.status} ${res.statusText}`);
92
+ }
93
+ return await res.text();
70
94
  } finally {
71
95
  clearTimeout(timeout);
72
96
  }
73
- if (!res.ok) {
74
- throw new Error(`Failed to fetch registry from ${source.name}: ${res.status} ${res.statusText}`);
97
+ }
98
+
99
+ /**
100
+ * Fetch registry for a single remote source.
101
+ */
102
+ async function fetchRemoteRegistry(source, force = false) {
103
+ if (!shouldFetchRemoteRegistry(source.name, force)) {
104
+ return;
105
+ }
106
+
107
+ const registryUrl = `${source.url}/registry.json`;
108
+ let registryText;
109
+ try {
110
+ registryText = await fetchRemoteText(registryUrl);
111
+ } catch (err) {
112
+ throw new Error(`Failed to fetch registry from ${source.name}: ${err.message}`);
75
113
  }
76
114
 
77
- const data = await res.text();
78
115
  const dir = getSourceDir(source.name);
79
116
  mkdirSync(dir, { recursive: true });
80
- writeFileSync(getSourceRegistryPath(source.name), data);
81
- writeMeta(source.name, { ...readMeta(source.name), lastUpdated: Date.now() });
117
+ writeFileSync(getSourceRegistryPath(source.name), registryText);
118
+
119
+ const searchIndexUrl = `${source.url}/search-index.json`;
120
+ const searchIndexCheckedAt = Date.now();
121
+ let searchIndexAvailable;
122
+ try {
123
+ const searchIndexText = await fetchRemoteText(searchIndexUrl);
124
+ writeFileSync(getSourceSearchIndexPath(source.name), searchIndexText);
125
+ searchIndexAvailable = true;
126
+ } catch (err) {
127
+ // Avoid serving a stale local search index after a registry refresh.
128
+ rmSync(getSourceSearchIndexPath(source.name), { force: true });
129
+ if (err.message?.startsWith('404 ')) {
130
+ searchIndexAvailable = false;
131
+ }
132
+ }
133
+
134
+ const nextMeta = {
135
+ ...readMeta(source.name),
136
+ lastUpdated: Date.now(),
137
+ };
138
+ delete nextMeta.searchIndexAvailable;
139
+ delete nextMeta.searchIndexCheckedAt;
140
+
141
+ if (searchIndexAvailable !== undefined) {
142
+ nextMeta.searchIndexAvailable = searchIndexAvailable;
143
+ nextMeta.searchIndexCheckedAt = searchIndexCheckedAt;
144
+ }
145
+
146
+ writeMeta(source.name, nextMeta);
82
147
  }
83
148
 
84
149
  /**
@@ -141,6 +206,14 @@ export async function fetchFullBundle(sourceName) {
141
206
  writeFileSync(getSourceRegistryPath(sourceName), regData);
142
207
  }
143
208
 
209
+ const extractedSearchIndex = join(dataDir, 'search-index.json');
210
+ if (existsSync(extractedSearchIndex)) {
211
+ const searchIndexData = readFileSync(extractedSearchIndex, 'utf8');
212
+ writeFileSync(getSourceSearchIndexPath(sourceName), searchIndexData);
213
+ } else {
214
+ rmSync(getSourceSearchIndexPath(sourceName), { force: true });
215
+ }
216
+
144
217
  writeMeta(sourceName, { ...readMeta(sourceName), lastUpdated: Date.now(), fullBundle: true });
145
218
  rmSync(tmpPath, { force: true });
146
219
  }
@@ -187,7 +260,7 @@ export async function fetchDoc(source, docPath) {
187
260
  const content = await res.text();
188
261
 
189
262
  // Cache locally
190
- const dir = cachedPath.substring(0, cachedPath.lastIndexOf('/'));
263
+ const dir = dirname(cachedPath);
191
264
  mkdirSync(dir, { recursive: true });
192
265
  writeFileSync(cachedPath, content);
193
266
 
@@ -225,6 +298,20 @@ export function loadSourceRegistry(source) {
225
298
  return JSON.parse(readFileSync(regPath, 'utf8'));
226
299
  }
227
300
 
301
+ /**
302
+ * Load BM25 search index for a single source (if available).
303
+ */
304
+ export function loadSearchIndex(source) {
305
+ const basePath = source.path || getSourceDir(source.name);
306
+ const indexPath = join(basePath, 'search-index.json');
307
+ if (!existsSync(indexPath)) return null;
308
+ try {
309
+ return JSON.parse(readFileSync(indexPath, 'utf8'));
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
228
315
  /**
229
316
  * Get cache stats.
230
317
  */
@@ -313,7 +400,7 @@ export async function ensureRegistry() {
313
400
  // Auto-refresh stale remote registries (best-effort)
314
401
  for (const source of config.sources) {
315
402
  if (source.path) continue;
316
- if (!isSourceCacheFresh(source.name)) {
403
+ if (shouldFetchRemoteRegistry(source.name)) {
317
404
  try { await fetchRemoteRegistry(source); } catch { /* use stale */ }
318
405
  }
319
406
  }
@@ -327,6 +414,10 @@ export async function ensureRegistry() {
327
414
  const defaultDir = getSourceDir('default');
328
415
  mkdirSync(defaultDir, { recursive: true });
329
416
  writeFileSync(getSourceRegistryPath('default'), readFileSync(bundledRegistry, 'utf8'));
417
+ const bundledSearchIndex = join(getBundledDir(), 'search-index.json');
418
+ if (existsSync(bundledSearchIndex)) {
419
+ writeFileSync(getSourceSearchIndexPath('default'), readFileSync(bundledSearchIndex, 'utf8'));
420
+ }
330
421
  writeMeta('default', { lastUpdated: 0, bundledSeed: true }); // lastUpdated=0 → stale, so chub update will refresh
331
422
  return;
332
423
  }
package/src/lib/config.js CHANGED
@@ -5,20 +5,25 @@ import { parse as parseYaml } from 'yaml';
5
5
 
6
6
  const DEFAULT_CDN_URL = 'https://cdn.aichub.org/v1';
7
7
  const DEFAULT_TELEMETRY_URL = 'https://api.aichub.org/v1';
8
+ const DEFAULT_HELP_URL = 'https://cdn.aichub.org/v1/help.json';
9
+ const DEFAULT_HELP_TIMEOUT_MS = 2000;
8
10
 
9
11
  const DEFAULTS = {
10
12
  output_dir: '.context',
11
- refresh_interval: 86400,
13
+ refresh_interval: 21600,
12
14
  output_format: 'human',
13
15
  source: 'official,maintainer,community',
14
16
  telemetry: true,
17
+ feedback: true,
15
18
  telemetry_url: DEFAULT_TELEMETRY_URL,
19
+ help_url: DEFAULT_HELP_URL,
20
+ help_timeout_ms: DEFAULT_HELP_TIMEOUT_MS,
16
21
  };
17
22
 
18
23
  let _config = null;
19
24
 
20
25
  export function getChubDir() {
21
- return join(homedir(), '.chub');
26
+ return process.env.CHUB_DIR || join(homedir(), '.chub');
22
27
  }
23
28
 
24
29
  export function loadConfig() {
@@ -50,7 +55,15 @@ export function loadConfig() {
50
55
  output_format: fileConfig.output_format || DEFAULTS.output_format,
51
56
  source: fileConfig.source || DEFAULTS.source,
52
57
  telemetry: fileConfig.telemetry !== undefined ? fileConfig.telemetry : DEFAULTS.telemetry,
58
+ feedback: fileConfig.feedback !== undefined ? fileConfig.feedback : DEFAULTS.feedback,
53
59
  telemetry_url: fileConfig.telemetry_url || DEFAULTS.telemetry_url,
60
+ help_url: process.env.CHUB_HELP_URL
61
+ ?? (fileConfig.help_url !== undefined ? fileConfig.help_url : DEFAULTS.help_url),
62
+ help_timeout_ms: Number.parseInt(
63
+ process.env.CHUB_HELP_TIMEOUT_MS
64
+ ?? (fileConfig.help_timeout_ms ?? DEFAULTS.help_timeout_ms),
65
+ 10
66
+ ) || DEFAULTS.help_timeout_ms,
54
67
  };
55
68
 
56
69
  return _config;
@@ -0,0 +1,158 @@
1
+ import { loadConfig } from './config.js';
2
+
3
+ function normalizeHelpUrl(value) {
4
+ if (value === false || value === null) return null;
5
+ if (typeof value !== 'string') return value || null;
6
+
7
+ const trimmed = value.trim();
8
+ if (!trimmed) return null;
9
+
10
+ const normalized = trimmed.toLowerCase();
11
+ if (['0', 'false', 'off', 'disabled', 'none', 'local'].includes(normalized)) {
12
+ return null;
13
+ }
14
+
15
+ return trimmed;
16
+ }
17
+
18
+ function buildRemoteHelpUrl(helpUrl, cliVersion) {
19
+ try {
20
+ const url = new URL(helpUrl);
21
+ if (!['http:', 'https:'].includes(url.protocol)) {
22
+ return helpUrl;
23
+ }
24
+ url.searchParams.set('cli_version', cliVersion);
25
+ return url.toString();
26
+ } catch {
27
+ return helpUrl;
28
+ }
29
+ }
30
+
31
+ function parseRemoteHelpPayload(raw, contentType = '') {
32
+ const trimmed = raw.trim();
33
+ const looksLikeJson = contentType.includes('json') || (trimmed.startsWith('{') && trimmed.endsWith('}'));
34
+
35
+ if (!looksLikeJson) {
36
+ return { content: raw };
37
+ }
38
+
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ } catch {
43
+ return { content: raw };
44
+ }
45
+
46
+ if (typeof parsed === 'string') {
47
+ return { content: parsed };
48
+ }
49
+
50
+ if (!parsed || typeof parsed.content !== 'string') {
51
+ throw new Error('Invalid remote help payload');
52
+ }
53
+
54
+ const result = { content: parsed.content };
55
+ if (typeof parsed.updatedAt === 'string') result.updatedAt = parsed.updatedAt;
56
+ if (typeof parsed.version === 'string') result.version = parsed.version;
57
+ if (typeof parsed.minimumCliVersion === 'string') {
58
+ result.minimumCliVersion = parsed.minimumCliVersion;
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ export function getLocalHelpText(cliVersion) {
65
+ return [
66
+ `chub — Context Hub CLI v${cliVersion}`,
67
+ 'Search and retrieve LLM-optimized docs and skills.',
68
+ '',
69
+ 'Bootstrap workflow for coding agents',
70
+ '',
71
+ ' 1. Start with chub before writing code against an external API, SDK, or library.',
72
+ ' 2. Find the best entry with `chub search`.',
73
+ ' 3. Fetch the exact doc or skill with `chub get`.',
74
+ ' 4. Use that content instead of guessing from training data.',
75
+ ' 5. If you discover a gap, save it with `chub annotate`.',
76
+ ' 6. Always leave feedback after using a doc or skill.',
77
+ '',
78
+ 'Quick start',
79
+ '',
80
+ ' $ chub search "openai" --json',
81
+ ' $ chub get openai/chat --lang py',
82
+ ' $ chub feedback openai/chat up --label accurate "Current and clear"',
83
+ '',
84
+ 'Distribution surfaces',
85
+ '',
86
+ ' $ npx skills add chub-dev',
87
+ ' Copy `skills/get-api-docs/SKILL.md` into your agent\'s skill or rule directory when manual install is needed.',
88
+ ' Use `chub-mcp` for MCP-compatible agents that prefer tools over local skill files.',
89
+ '',
90
+ 'If chub is not installed yet',
91
+ '',
92
+ ' $ npm install -g chub-dev',
93
+ '',
94
+ 'Core commands',
95
+ '',
96
+ ' search [query] Search docs and skills (no query = list all)',
97
+ ' get <ids...> Fetch docs or skills by ID',
98
+ ' annotate [id] [note] Save a note that appears on future fetches',
99
+ ' feedback <id> <up|down> Rate a doc or skill after using it',
100
+ ' update Refresh the cached registry',
101
+ ' cache status|clear Manage the local cache',
102
+ '',
103
+ 'Feedback loop',
104
+ '',
105
+ ' After using a doc or skill, always run one of these:',
106
+ ' $ chub feedback <id> up --label accurate "What was useful"',
107
+ ' $ chub feedback <id> down --label outdated "What needs to be fixed"',
108
+ '',
109
+ ' Valid labels: accurate, well-structured, helpful, good-examples, outdated, inaccurate, incomplete, wrong-examples, wrong-version, poorly-structured',
110
+ ].join('\n');
111
+ }
112
+
113
+ export async function loadHelpContent(cliVersion, { fetchImpl = globalThis.fetch } = {}) {
114
+ const config = loadConfig();
115
+ const helpUrl = normalizeHelpUrl(config.help_url);
116
+
117
+ if (!helpUrl || typeof fetchImpl !== 'function') {
118
+ return {
119
+ source: 'local',
120
+ content: getLocalHelpText(cliVersion),
121
+ url: helpUrl || null,
122
+ };
123
+ }
124
+
125
+ const controller = new AbortController();
126
+ const timeout = setTimeout(() => controller.abort(), config.help_timeout_ms || 2000);
127
+
128
+ try {
129
+ const response = await fetchImpl(buildRemoteHelpUrl(helpUrl, cliVersion), {
130
+ signal: controller.signal,
131
+ headers: {
132
+ accept: 'application/json, text/plain;q=0.9, text/markdown;q=0.8',
133
+ },
134
+ });
135
+
136
+ if (!response.ok) {
137
+ throw new Error(`${response.status} ${response.statusText}`);
138
+ }
139
+
140
+ const contentType = response.headers?.get?.('content-type') || '';
141
+ const payload = parseRemoteHelpPayload(await response.text(), contentType);
142
+
143
+ return {
144
+ source: 'remote',
145
+ url: helpUrl,
146
+ ...payload,
147
+ };
148
+ } catch (err) {
149
+ return {
150
+ source: 'local',
151
+ content: getLocalHelpText(cliVersion),
152
+ url: helpUrl,
153
+ fallbackReason: err?.name === 'AbortError' ? 'timeout' : (err?.message || 'remote_help_unavailable'),
154
+ };
155
+ } finally {
156
+ clearTimeout(timeout);
157
+ }
158
+ }
@@ -63,7 +63,7 @@ export async function getOrCreateClientId() {
63
63
  // File doesn't exist or is unreadable
64
64
  }
65
65
 
66
- // Generate from machine UUID
66
+ // Generate from machine UUID — this is a first-time user
67
67
  const uuid = getMachineUUID();
68
68
  const hash = createHash('sha256').update(uuid).digest('hex');
69
69
 
@@ -74,9 +74,20 @@ export async function getOrCreateClientId() {
74
74
 
75
75
  writeFileSync(idPath, hash, 'utf8');
76
76
  _cachedClientId = hash;
77
+ _isFirstRun = true;
77
78
  return hash;
78
79
  }
79
80
 
81
+ let _isFirstRun = false;
82
+
83
+ /**
84
+ * Returns true if this is the first time the CLI has run on this machine.
85
+ * Only valid after getOrCreateClientId() has been called.
86
+ */
87
+ export function isFirstRun() {
88
+ return _isFirstRun;
89
+ }
90
+
80
91
  /**
81
92
  * Auto-detect the AI coding tool from environment variables.
82
93
  */