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 +13 -4
- package/src/commands/build.js +3 -1
- package/src/commands/feedback.js +150 -0
- package/src/commands/get.js +10 -0
- package/src/commands/search.js +7 -0
- package/src/index.js +12 -2
- package/src/lib/analytics.js +90 -0
- package/src/lib/cache.js +24 -3
- package/src/lib/config.js +6 -1
- package/src/lib/identity.js +99 -0
- package/src/lib/telemetry.js +86 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chub-dev",
|
|
3
|
-
"version": "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
|
-
"
|
|
38
|
-
"
|
|
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
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -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')
|
|
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
|
+
}
|
package/src/commands/get.js
CHANGED
|
@@ -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) {
|
package/src/commands/search.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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://
|
|
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
|
+
}
|