chub-dev 0.1.0 → 0.2.0-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chub-dev",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "CLI for Context Hub - search and retrieve LLM-optimized docs and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,10 +31,19 @@
31
31
  "url": "https://github.com/andrewyng/context-hub/issues"
32
32
  },
33
33
  "homepage": "https://github.com/andrewyng/context-hub#readme",
34
+ "scripts": {
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "test:coverage": "vitest run --coverage"
38
+ },
34
39
  "dependencies": {
35
- "commander": "^12.0.0",
36
40
  "chalk": "^5.3.0",
37
- "yaml": "^2.3.0",
38
- "tar": "^7.0.0"
41
+ "commander": "^12.0.0",
42
+ "posthog-node": "^5.24.17",
43
+ "tar": "^7.5.8",
44
+ "yaml": "^2.3.0"
45
+ },
46
+ "devDependencies": {
47
+ "vitest": "^3.0.0"
39
48
  }
40
49
  }
@@ -3,6 +3,7 @@ import { join, relative, dirname } from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { parseFrontmatter } from '../lib/frontmatter.js';
5
5
  import { info } from '../lib/output.js';
6
+ import { trackEvent } from '../lib/analytics.js';
6
7
 
7
8
  /**
8
9
  * Recursively find all DOC.md and SKILL.md files under a directory.
@@ -307,11 +308,12 @@ export function registerBuildCommand(program) {
307
308
  // Skip registry.json in author dirs
308
309
  cpSync(src, dest, {
309
310
  recursive: true,
310
- filter: (s) => !s.endsWith('/registry.json') || s === join(src, 'registry.json') === false,
311
+ filter: (s) => !s.endsWith('/registry.json'),
311
312
  });
312
313
  }
313
314
 
314
315
  const summary = { docs: allDocs.length, skills: allSkills.length, warnings: allWarnings.length };
316
+ trackEvent('build', { doc_count: allDocs.length, skill_count: allSkills.length }).catch(() => {});
315
317
  if (globalOpts.json) {
316
318
  console.log(JSON.stringify({ ...summary, output: outputDir }));
317
319
  } else {
@@ -0,0 +1,150 @@
1
+ import chalk from 'chalk';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { getEntry } from '../lib/registry.js';
6
+ import { sendFeedback, isTelemetryEnabled, getTelemetryUrl } from '../lib/telemetry.js';
7
+ import { getOrCreateClientId } from '../lib/identity.js';
8
+ import { output, error } from '../lib/output.js';
9
+ import { trackEvent } from '../lib/analytics.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const VALID_LABELS = [
14
+ 'accurate', 'well-structured', 'helpful', 'good-examples',
15
+ 'outdated', 'inaccurate', 'incomplete', 'wrong-examples',
16
+ 'wrong-version', 'poorly-structured',
17
+ ];
18
+
19
+ function collect(val, acc) {
20
+ acc.push(val);
21
+ return acc;
22
+ }
23
+
24
+ export function registerFeedbackCommand(program) {
25
+ program
26
+ .command('feedback [id] [rating] [comment]')
27
+ .description('Rate a doc or skill (up/down)')
28
+ .option('--type <type>', 'Explicit type: doc or skill')
29
+ .option('--lang <language>', 'Language variant of the doc')
30
+ .option('--doc-version <version>', 'Version of the doc')
31
+ .option('--file <file>', 'Specific file within the entry (e.g. references/streaming.md)')
32
+ .option('--label <label>', 'Feedback label (repeatable: --label outdated --label wrong-examples)', collect, [])
33
+ .option('--agent <name>', 'AI coding tool name')
34
+ .option('--model <model>', 'LLM model name')
35
+ .option('--status', 'Show telemetry status')
36
+ .action(async (id, rating, comment, opts) => {
37
+ const globalOpts = program.optsWithGlobals();
38
+
39
+ // --status flag
40
+ if (opts.status) {
41
+ const enabled = isTelemetryEnabled();
42
+ if (globalOpts.json) {
43
+ let clientId = null;
44
+ try { clientId = await getOrCreateClientId(); } catch {}
45
+ console.log(JSON.stringify({
46
+ telemetry: enabled,
47
+ client_id_prefix: clientId ? clientId.slice(0, 8) : null,
48
+ endpoint: getTelemetryUrl(),
49
+ valid_labels: VALID_LABELS,
50
+ }));
51
+ } else {
52
+ console.log(`Telemetry: ${enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
53
+ try {
54
+ const cid = await getOrCreateClientId();
55
+ console.log(`Client ID: ${cid.slice(0, 8)}...`);
56
+ } catch {}
57
+ console.log(`Endpoint: ${getTelemetryUrl()}`);
58
+ console.log(`Labels: ${VALID_LABELS.join(', ')}`);
59
+ }
60
+ return;
61
+ }
62
+
63
+ // BUG #1 FIX: Validation errors respect --json flag
64
+ if (!id || !rating) {
65
+ error('Missing arguments. Usage: chub feedback <id> <up|down> [comment]', globalOpts);
66
+ }
67
+
68
+ if (rating !== 'up' && rating !== 'down') {
69
+ error('Rating must be "up" or "down".', globalOpts);
70
+ }
71
+
72
+ if (!isTelemetryEnabled()) {
73
+ output(
74
+ { status: 'skipped', reason: 'telemetry_disabled' },
75
+ () => console.log(chalk.yellow('Telemetry is disabled. Enable with: telemetry: true in ~/.chub/config.yaml')),
76
+ globalOpts
77
+ );
78
+ return;
79
+ }
80
+
81
+ // BUG #2 FIX: Only auto-detect type if --type not explicitly set
82
+ let entryType = opts.type || null;
83
+ let docLang = opts.lang || undefined;
84
+ let docVersion = opts.docVersion || undefined;
85
+ let source;
86
+ try {
87
+ const result = getEntry(id);
88
+ if (result.entry) {
89
+ if (!entryType) {
90
+ entryType = result.entry.languages ? 'doc' : 'skill';
91
+ }
92
+ source = result.entry._source;
93
+
94
+ // If doc and user didn't specify lang/version, try to infer from entry
95
+ if (result.entry.languages && !docLang && result.entry.languages.length === 1) {
96
+ docLang = result.entry.languages[0].language;
97
+ }
98
+ if (result.entry.languages && !docVersion) {
99
+ const lang = result.entry.languages.find((l) => l.language === docLang) || result.entry.languages[0];
100
+ if (lang) docVersion = lang.recommendedVersion;
101
+ }
102
+ }
103
+ } catch {
104
+ // Registry not loaded — use explicit flags
105
+ }
106
+ if (!entryType) entryType = 'doc'; // Final fallback
107
+
108
+ // Parse labels (--label is repeatable, collected into an array)
109
+ let labels;
110
+ if (opts.label && opts.label.length > 0) {
111
+ labels = opts.label.map((l) => l.trim().toLowerCase()).filter((l) => VALID_LABELS.includes(l));
112
+ if (labels.length === 0) labels = undefined;
113
+ }
114
+
115
+ // Read CLI version
116
+ let cliVersion;
117
+ try {
118
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
119
+ cliVersion = pkg.version;
120
+ } catch {}
121
+
122
+ const result = await sendFeedback(id, entryType, rating, {
123
+ comment,
124
+ docLang,
125
+ docVersion,
126
+ targetFile: opts.file,
127
+ labels,
128
+ agent: opts.agent,
129
+ model: opts.model,
130
+ cliVersion,
131
+ source,
132
+ });
133
+
134
+ if (result.status === 'sent') {
135
+ trackEvent('feedback_sent', { entry_id: id, rating, entry_type: entryType }).catch(() => {});
136
+ }
137
+
138
+ output(result, (data) => {
139
+ if (data.status === 'sent') {
140
+ const parts = [chalk.green(`Feedback recorded for ${id}`)];
141
+ if (docLang) parts.push(chalk.dim(`lang=${docLang}`));
142
+ if (docVersion) parts.push(chalk.dim(`version=${docVersion}`));
143
+ if (opts.file) parts.push(chalk.dim(`file=${opts.file}`));
144
+ console.log(parts.join(' '));
145
+ } else if (data.status === 'error') {
146
+ process.stderr.write(chalk.red(`Failed to send feedback: ${data.reason || `HTTP ${data.code}`}\n`));
147
+ }
148
+ }, globalOpts);
149
+ });
150
+ }
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { getEntry, resolveDocPath, resolveEntryFile } from '../lib/registry.js';
5
5
  import { fetchDoc, fetchDocFull } from '../lib/cache.js';
6
6
  import { output, error, info } from '../lib/output.js';
7
+ import { trackEvent } from '../lib/analytics.js';
7
8
 
8
9
  /**
9
10
  * Core fetch logic shared by `get docs` and `get skills`.
@@ -61,6 +62,15 @@ async function fetchEntries(type, ids, opts, globalOpts) {
61
62
  }
62
63
  }
63
64
 
65
+ // Track fetches
66
+ for (const r of results) {
67
+ trackEvent(type === 'doc' ? 'doc_fetched' : 'skill_fetched', {
68
+ entry_id: r.id,
69
+ full: !!opts.full,
70
+ lang: opts.lang || undefined,
71
+ }).catch(() => {});
72
+ }
73
+
64
74
  // Output
65
75
  if (opts.output) {
66
76
  if (opts.full) {
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { searchEntries, listEntries, getEntry, getDisplayId, isMultiSource } from '../lib/registry.js';
3
3
  import { displayLanguage } from '../lib/normalize.js';
4
4
  import { output } from '../lib/output.js';
5
+ import { trackEvent } from '../lib/analytics.js';
5
6
 
6
7
  function formatEntryList(entries) {
7
8
  const multi = isMultiSource();
@@ -93,6 +94,12 @@ export function registerSearchCommand(program) {
93
94
 
94
95
  // Fuzzy search
95
96
  const results = searchEntries(query, opts).slice(0, limit);
97
+ trackEvent('search', {
98
+ query_length: query.length,
99
+ result_count: results.length,
100
+ has_tags: !!opts.tags,
101
+ has_lang: !!opts.lang,
102
+ }).catch(() => {});
96
103
  output({ results, total: results.length, query }, (data) => {
97
104
  if (data.results.length === 0) {
98
105
  console.log(chalk.yellow(`No results for "${query}".`));
package/src/index.js CHANGED
@@ -9,6 +9,8 @@ import { registerCacheCommand } from './commands/cache.js';
9
9
  import { registerSearchCommand } from './commands/search.js';
10
10
  import { registerGetCommand } from './commands/get.js';
11
11
  import { registerBuildCommand } from './commands/build.js';
12
+ import { registerFeedbackCommand } from './commands/feedback.js';
13
+ import { trackEvent, shutdownAnalytics } from './lib/analytics.js';
12
14
 
13
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
@@ -63,7 +65,7 @@ ${chalk.bold.underline('Multi-Source Config')} ${chalk.dim('(~/.chub/config.yaml
63
65
 
64
66
  ${chalk.dim('sources:')}
65
67
  ${chalk.dim(' - name: community')}
66
- ${chalk.dim(' url: https://cdn.contexthub.dev/v1')}
68
+ ${chalk.dim(' url: https://cdn.aichub.org/v1')}
67
69
  ${chalk.dim(' - name: internal')}
68
70
  ${chalk.dim(' path: /path/to/local/docs')}
69
71
 
@@ -83,10 +85,14 @@ program
83
85
  });
84
86
 
85
87
  // Commands that don't need registry
86
- const SKIP_REGISTRY = ['update', 'cache', 'build', 'help'];
88
+ const SKIP_REGISTRY = ['update', 'cache', 'build', 'feedback', 'help'];
87
89
 
88
90
  program.hook('preAction', async (thisCommand) => {
89
91
  const cmdName = thisCommand.args?.[0] || thisCommand.name();
92
+ // Track command usage (fire-and-forget, never blocks)
93
+ if (cmdName !== 'chub') {
94
+ trackEvent('command_run', { command: cmdName }).catch(() => {});
95
+ }
90
96
  if (SKIP_REGISTRY.includes(cmdName)) return;
91
97
  if (thisCommand.parent?.name() === 'cache') return;
92
98
  // Don't fetch registry for default action (no command)
@@ -105,5 +111,9 @@ registerCacheCommand(program);
105
111
  registerSearchCommand(program);
106
112
  registerGetCommand(program);
107
113
  registerBuildCommand(program);
114
+ registerFeedbackCommand(program);
108
115
 
109
116
  program.parse();
117
+
118
+ // Flush analytics before exit (best-effort)
119
+ process.on('beforeExit', () => shutdownAnalytics().catch(() => {}));
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PostHog Cloud analytics for general CLI usage tracking.
3
+ *
4
+ * Tracks: command usage, search patterns, doc/skill popularity, errors.
5
+ * Does NOT track feedback ratings (those go to the custom API via telemetry.js).
6
+ *
7
+ * Respects the same telemetry opt-out: `telemetry: false` in config or CHUB_TELEMETRY=0.
8
+ */
9
+
10
+ import { isTelemetryEnabled } from './telemetry.js';
11
+
12
+ // PostHog project API key (public — standard for client-side analytics)
13
+ const POSTHOG_KEY = 'phc_cUPXY1tAUkIOU9perzGcFYEtFQeCgUhUO6ejT79YLIk';
14
+ const POSTHOG_HOST = 'https://us.i.posthog.com';
15
+
16
+ let _posthog = null;
17
+ let _initFailed = false;
18
+
19
+ /**
20
+ * Lazily initialize PostHog client. Returns null if telemetry is disabled
21
+ * or posthog-node is not installed.
22
+ */
23
+ async function getClient() {
24
+ if (_initFailed) return null;
25
+ if (_posthog) return _posthog;
26
+
27
+ if (!isTelemetryEnabled()) {
28
+ _initFailed = true;
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ const { PostHog } = await import('posthog-node');
34
+ _posthog = new PostHog(POSTHOG_KEY, {
35
+ host: POSTHOG_HOST,
36
+ flushAt: 1, // Send immediately (CLI is short-lived)
37
+ flushInterval: 0, // Don't batch
38
+ });
39
+ return _posthog;
40
+ } catch {
41
+ // posthog-node not installed — skip analytics silently
42
+ _initFailed = true;
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Track an analytics event. Fire-and-forget — never throws, never blocks.
49
+ *
50
+ * @param {string} event - Event name (e.g., 'command_run', 'search', 'doc_fetched')
51
+ * @param {object} properties - Event properties
52
+ */
53
+ export async function trackEvent(event, properties = {}) {
54
+ try {
55
+ const client = await getClient();
56
+ if (!client) return;
57
+
58
+ const { getOrCreateClientId } = await import('./identity.js');
59
+ const distinctId = await getOrCreateClientId();
60
+
61
+ client.capture({
62
+ distinctId,
63
+ event,
64
+ properties: {
65
+ ...properties,
66
+ platform: process.platform,
67
+ node_version: process.version,
68
+ },
69
+ });
70
+
71
+ // Flush immediately since CLI process exits soon
72
+ await client.flush();
73
+ } catch {
74
+ // Silent fail — analytics should never disrupt CLI
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Shut down the PostHog client gracefully.
80
+ * Call this before process exit if possible.
81
+ */
82
+ export async function shutdownAnalytics() {
83
+ if (_posthog) {
84
+ try {
85
+ await _posthog.shutdown();
86
+ } catch {
87
+ // Silent
88
+ }
89
+ }
90
+ }
package/src/lib/cache.js CHANGED
@@ -51,7 +51,14 @@ async function fetchRemoteRegistry(source, force = false) {
51
51
  }
52
52
 
53
53
  const url = `${source.url}/registry.json`;
54
- const res = await fetch(url);
54
+ const controller = new AbortController();
55
+ const timeout = setTimeout(() => controller.abort(), 30000);
56
+ let res;
57
+ try {
58
+ res = await fetch(url, { signal: controller.signal });
59
+ } finally {
60
+ clearTimeout(timeout);
61
+ }
55
62
  if (!res.ok) {
56
63
  throw new Error(`Failed to fetch registry from ${source.name}: ${res.status} ${res.statusText}`);
57
64
  }
@@ -95,7 +102,14 @@ export async function fetchFullBundle(sourceName) {
95
102
  const url = `${source.url}/bundle.tar.gz`;
96
103
  const tmpPath = join(getSourceDir(sourceName), 'bundle.tar.gz');
97
104
 
98
- const res = await fetch(url);
105
+ const controller = new AbortController();
106
+ const timeout = setTimeout(() => controller.abort(), 30000);
107
+ let res;
108
+ try {
109
+ res = await fetch(url, { signal: controller.signal });
110
+ } finally {
111
+ clearTimeout(timeout);
112
+ }
99
113
  if (!res.ok) {
100
114
  throw new Error(`Failed to fetch bundle from ${sourceName}: ${res.status} ${res.statusText}`);
101
115
  }
@@ -141,7 +155,14 @@ export async function fetchDoc(source, docPath) {
141
155
 
142
156
  // Fetch from CDN
143
157
  const url = `${source.url}/${docPath}`;
144
- const res = await fetch(url);
158
+ const controller = new AbortController();
159
+ const timeout = setTimeout(() => controller.abort(), 30000);
160
+ let res;
161
+ try {
162
+ res = await fetch(url, { signal: controller.signal });
163
+ } finally {
164
+ clearTimeout(timeout);
165
+ }
145
166
  if (!res.ok) {
146
167
  throw new Error(`Failed to fetch ${docPath} from ${source.name}: ${res.status} ${res.statusText}`);
147
168
  }
package/src/lib/config.js CHANGED
@@ -3,13 +3,16 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
 
6
- const DEFAULT_CDN_URL = 'https://github.com/context-hub/context-hub/releases/latest/download';
6
+ const DEFAULT_CDN_URL = 'https://cdn.aichub.org/v1';
7
+ const DEFAULT_TELEMETRY_URL = 'https://api.aichub.org/v1';
7
8
 
8
9
  const DEFAULTS = {
9
10
  output_dir: '.context',
10
11
  refresh_interval: 86400,
11
12
  output_format: 'human',
12
13
  source: 'official,maintainer,community',
14
+ telemetry: true,
15
+ telemetry_url: DEFAULT_TELEMETRY_URL,
13
16
  };
14
17
 
15
18
  let _config = null;
@@ -46,6 +49,8 @@ export function loadConfig() {
46
49
  refresh_interval: fileConfig.refresh_interval ?? DEFAULTS.refresh_interval,
47
50
  output_format: fileConfig.output_format || DEFAULTS.output_format,
48
51
  source: fileConfig.source || DEFAULTS.source,
52
+ telemetry: fileConfig.telemetry !== undefined ? fileConfig.telemetry : DEFAULTS.telemetry,
53
+ telemetry_url: fileConfig.telemetry_url || DEFAULTS.telemetry_url,
49
54
  };
50
55
 
51
56
  return _config;
@@ -0,0 +1,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execSync } from 'node:child_process';
3
+ import { platform } from 'node:os';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { getChubDir } from './config.js';
7
+
8
+ let _cachedClientId = null;
9
+
10
+ /**
11
+ * Get the platform-native machine UUID.
12
+ */
13
+ function getMachineUUID() {
14
+ const plat = platform();
15
+
16
+ if (plat === 'darwin') {
17
+ return execSync(
18
+ `ioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'`,
19
+ { encoding: 'utf8' }
20
+ ).trim();
21
+ }
22
+
23
+ if (plat === 'linux') {
24
+ try {
25
+ return readFileSync('/etc/machine-id', 'utf8').trim();
26
+ } catch {
27
+ return readFileSync('/var/lib/dbus/machine-id', 'utf8').trim();
28
+ }
29
+ }
30
+
31
+ if (plat === 'win32') {
32
+ const output = execSync(
33
+ 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
34
+ { encoding: 'utf8' }
35
+ );
36
+ const match = output.match(/MachineGuid\s+REG_SZ\s+(.+)/);
37
+ if (match) return match[1].trim();
38
+ throw new Error('Could not parse MachineGuid from registry');
39
+ }
40
+
41
+ throw new Error(`Unsupported platform: ${plat}`);
42
+ }
43
+
44
+ /**
45
+ * Get or create a stable, anonymous client ID.
46
+ * Checks ~/.chub/client_id for a cached 64-char hex string.
47
+ * If not found, hashes the machine UUID with SHA-256 and saves it.
48
+ */
49
+ export async function getOrCreateClientId() {
50
+ if (_cachedClientId) return _cachedClientId;
51
+
52
+ const chubDir = getChubDir();
53
+ const idPath = join(chubDir, 'client_id');
54
+
55
+ // Try to read existing client id
56
+ try {
57
+ const existing = readFileSync(idPath, 'utf8').trim();
58
+ if (/^[0-9a-f]{64}$/.test(existing)) {
59
+ _cachedClientId = existing;
60
+ return existing;
61
+ }
62
+ } catch {
63
+ // File doesn't exist or is unreadable
64
+ }
65
+
66
+ // Generate from machine UUID
67
+ const uuid = getMachineUUID();
68
+ const hash = createHash('sha256').update(uuid).digest('hex');
69
+
70
+ // Ensure directory exists
71
+ if (!existsSync(chubDir)) {
72
+ mkdirSync(chubDir, { recursive: true });
73
+ }
74
+
75
+ writeFileSync(idPath, hash, 'utf8');
76
+ _cachedClientId = hash;
77
+ return hash;
78
+ }
79
+
80
+ /**
81
+ * Auto-detect the AI coding tool from environment variables.
82
+ */
83
+ export function detectAgent() {
84
+ if (process.env.CLAUDE_CODE || process.env.CLAUDE_SESSION_ID) return 'claude-code';
85
+ if (process.env.CURSOR_SESSION_ID || process.env.CURSOR_TRACE_ID) return 'cursor';
86
+ if (process.env.CODEX_HOME || process.env.CODEX_SESSION) return 'codex';
87
+ if (process.env.WINDSURF_SESSION) return 'windsurf';
88
+ if (process.env.AIDER_MODEL || process.env.AIDER) return 'aider';
89
+ if (process.env.CLINE_SESSION) return 'cline';
90
+ if (process.env.GITHUB_COPILOT) return 'copilot';
91
+ return 'unknown';
92
+ }
93
+
94
+ /**
95
+ * Detect the version of the AI coding tool, if available.
96
+ */
97
+ export function detectAgentVersion() {
98
+ return process.env.CLAUDE_CODE_VERSION || process.env.CURSOR_VERSION || undefined;
99
+ }
@@ -0,0 +1,86 @@
1
+ import { loadConfig } from './config.js';
2
+
3
+ const DEFAULT_TELEMETRY_URL = 'https://api.aichub.org/v1';
4
+
5
+ export function isTelemetryEnabled() {
6
+ if (process.env.CHUB_TELEMETRY === '0' || process.env.CHUB_TELEMETRY === 'false') return false;
7
+ const config = loadConfig();
8
+ return config.telemetry !== false;
9
+ }
10
+
11
+ export function getTelemetryUrl() {
12
+ const url = process.env.CHUB_TELEMETRY_URL;
13
+ if (url) return url;
14
+ const config = loadConfig();
15
+ return config.telemetry_url || DEFAULT_TELEMETRY_URL;
16
+ }
17
+
18
+ /**
19
+ * Send feedback to the API.
20
+ *
21
+ * @param {string} entryId - e.g. "openai/chat"
22
+ * @param {string} entryType - "doc" or "skill"
23
+ * @param {string} rating - "up" or "down"
24
+ * @param {object} opts - Additional context
25
+ * @param {string} [opts.comment]
26
+ * @param {string} [opts.docLang] - Language variant fetched
27
+ * @param {string} [opts.docVersion] - Version fetched
28
+ * @param {string} [opts.targetFile] - Specific file within the entry
29
+ * @param {string[]} [opts.labels] - Structured feedback labels
30
+ * @param {string} [opts.agent] - Agent name override
31
+ * @param {string} [opts.model] - LLM model override
32
+ * @param {string} [opts.cliVersion]
33
+ * @param {string} [opts.source] - Registry source name
34
+ */
35
+ export async function sendFeedback(entryId, entryType, rating, opts = {}) {
36
+ if (!isTelemetryEnabled()) return { status: 'skipped', reason: 'telemetry_disabled' };
37
+
38
+ const { getOrCreateClientId, detectAgent, detectAgentVersion } = await import('./identity.js');
39
+ const clientId = await getOrCreateClientId();
40
+ const telemetryUrl = getTelemetryUrl();
41
+
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), 3000);
44
+
45
+ try {
46
+ const res = await fetch(`${telemetryUrl}/feedback`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'X-Client-ID': clientId,
51
+ },
52
+ body: JSON.stringify({
53
+ entry_id: entryId,
54
+ entry_type: entryType,
55
+ rating,
56
+ // Doc-specific dimensions
57
+ doc_lang: opts.docLang || undefined,
58
+ doc_version: opts.docVersion || undefined,
59
+ target_file: opts.targetFile || undefined,
60
+ // Structured feedback
61
+ labels: opts.labels || undefined,
62
+ comment: opts.comment || undefined,
63
+ // Agent info
64
+ agent: {
65
+ name: opts.agent || detectAgent(),
66
+ version: detectAgentVersion(),
67
+ model: opts.model || undefined,
68
+ },
69
+ // Context
70
+ cli_version: opts.cliVersion || undefined,
71
+ source: opts.source || undefined,
72
+ }),
73
+ signal: controller.signal,
74
+ });
75
+ clearTimeout(timeout);
76
+
77
+ if (res.ok) {
78
+ const data = await res.json();
79
+ return { status: 'sent', feedback_id: data.feedback_id || data.id };
80
+ }
81
+ return { status: 'error', code: res.status };
82
+ } catch (err) {
83
+ clearTimeout(timeout);
84
+ return { status: 'error', reason: 'network' };
85
+ }
86
+ }