diff --git a/.claude/skills/setup/diagnostics.md b/.claude/skills/setup/diagnostics.md index d59f633..deb6509 100644 --- a/.claude/skills/setup/diagnostics.md +++ b/.claude/skills/setup/diagnostics.md @@ -2,36 +2,56 @@ After setup is complete, offer to send anonymous diagnostics. -## 1. Check opt-out +## 1. Write the event -```bash -npx tsx scripts/send-diagnostics.ts --event setup_complete --success --data '{}' --dry-run +Write a JSON file to `/tmp/nanoclaw-diagnostics.json` with the setup outcome. Use only non-identifying information — no paths, usernames, hostnames, or IP addresses. + +```json +{ + "event": "setup_complete", + "success": true, + "properties": { + "nanoclaw_version": "1.2.21", + "os_platform": "darwin", + "arch": "arm64", + "node_major_version": 22, + "channels_selected": ["telegram", "whatsapp"], + "error_count": 0, + "failed_step": null + } +} ``` -If no output, the user opted out permanently — stop here. +Fill in the values based on what happened during the session. -## 2. Dry run +## 2. Show and ask -```bash -npx tsx scripts/send-diagnostics.ts --event setup_complete --success --data '{"channels_selected":["telegram"],"error_count":0,"failed_step":null,"exit_code":null}' --dry-run -``` - -Use `--failure` instead of `--success` if setup failed. Fill in the values based on what happened during the session. - -## 3. Ask the user - -Show the payload and ask: +Show the contents of the file to the user and ask: > "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:" > -> (show JSON payload) +> (show JSON) > > **Yes** / **No** / **Never ask again** Use AskUserQuestion. -## 4. Handle response +## 3. Handle response -- **Yes**: Run the command again without `--dry-run`. Confirm: "Diagnostics sent." -- **No**: Do nothing. -- **Never ask again**: Run `npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();"` — confirm: "Got it — you won't be asked again." +**Yes**: Send it: +```bash +curl -s -X POST https://us.i.posthog.com/capture/ \ + -H 'Content-Type: application/json' \ + -d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"$(jq -r .event /tmp/nanoclaw-diagnostics.json)\",\"distinct_id\":\"$(uuidgen)\",\"properties\":$(jq .properties /tmp/nanoclaw-diagnostics.json)}" +rm /tmp/nanoclaw-diagnostics.json +``` +Confirm: "Diagnostics sent." + +**No**: `rm /tmp/nanoclaw-diagnostics.json` — do nothing else. + +**Never ask again**: Replace the contents of this file (`diagnostics.md`) with: +``` +# Diagnostics — opted out +``` +Then remove the "Diagnostics (Optional)" section from the end of `SKILL.md` in this skill directory. Delete `/tmp/nanoclaw-diagnostics.json`. +Confirm: "Got it — you won't be asked again." diff --git a/.claude/skills/update-nanoclaw/diagnostics.md b/.claude/skills/update-nanoclaw/diagnostics.md index 58638b3..5d98855 100644 --- a/.claude/skills/update-nanoclaw/diagnostics.md +++ b/.claude/skills/update-nanoclaw/diagnostics.md @@ -2,36 +2,58 @@ After the update is complete, offer to send anonymous diagnostics. -## 1. Check opt-out +## 1. Write the event -```bash -npx tsx scripts/send-diagnostics.ts --event update_complete --success --data '{}' --dry-run +Write a JSON file to `/tmp/nanoclaw-diagnostics.json` with the update outcome. Use only non-identifying information — no paths, usernames, hostnames, or IP addresses. + +```json +{ + "event": "update_complete", + "success": true, + "properties": { + "nanoclaw_version": "1.2.21", + "os_platform": "darwin", + "arch": "arm64", + "node_major_version": 22, + "version_age_days": 45, + "update_method": "merge", + "conflict_count": 0, + "breaking_changes_found": false, + "error_count": 0 + } +} ``` -If no output, the user opted out permanently — stop here. +Fill in the values based on what happened during the session. -## 2. Prepare event +## 2. Show and ask -Run `--dry-run` to get the final payload: - -```bash -npx tsx scripts/send-diagnostics.ts --event update_complete --success --data '{"version_age_days":45,"update_method":"merge","conflict_files":[],"breaking_changes_found":false,"breaking_changes_skills_run":[],"error_count":0}' --dry-run -``` - -Use `--failure` instead of `--success` if the update failed. Fill in the values based on what actually happened during the session. - -## 3. Ask the user +Show the contents of the file to the user and ask: > "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:" > -> (show JSON payload) +> (show JSON) > > **Yes** / **No** / **Never ask again** Use AskUserQuestion. -## 4. Handle response +## 3. Handle response -- **Yes**: Run the command again without `--dry-run`. Confirm: "Diagnostics sent." -- **No**: Do nothing. -- **Never ask again**: Run `npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();"` — confirm: "Got it — you won't be asked again." +**Yes**: Send it: +```bash +curl -s -X POST https://us.i.posthog.com/capture/ \ + -H 'Content-Type: application/json' \ + -d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"$(jq -r .event /tmp/nanoclaw-diagnostics.json)\",\"distinct_id\":\"$(uuidgen)\",\"properties\":$(jq .properties /tmp/nanoclaw-diagnostics.json)}" +rm /tmp/nanoclaw-diagnostics.json +``` +Confirm: "Diagnostics sent." + +**No**: `rm /tmp/nanoclaw-diagnostics.json` — do nothing else. + +**Never ask again**: Replace the contents of this file (`diagnostics.md`) with: +``` +# Diagnostics — opted out +``` +Then remove the "Diagnostics (Optional)" section from the end of `SKILL.md` in this skill directory. Delete `/tmp/nanoclaw-diagnostics.json`. +Confirm: "Got it — you won't be asked again." diff --git a/scripts/send-diagnostics.ts b/scripts/send-diagnostics.ts deleted file mode 100644 index 5b5399c..0000000 --- a/scripts/send-diagnostics.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * send-diagnostics.ts — opt-in, privacy-first diagnostics for NanoClaw. - * - * Collects system info, accepts event-specific data via --data JSON arg, - * gates conflict filenames against upstream, and sends to PostHog. - * - * Usage: - * npx tsx scripts/send-diagnostics.ts \ - * --event \ - * [--success|--failure] \ - * [--data ''] \ - * [--dry-run] - * - * Never exits non-zero on telemetry failures. - */ - -import { execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; - -const POSTHOG_ENDPOINT = 'https://us.i.posthog.com/capture/'; -const POSTHOG_TOKEN = 'phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP'; -const SEND_TIMEOUT_MS = 5000; - -const PROJECT_ROOT = path.resolve(import.meta.dirname, '..'); -const STATE_YAML_PATH = path.join(PROJECT_ROOT, '.nanoclaw', 'state.yaml'); - -// --- Args --- - -function parseArgs(): { - event: string; - success?: boolean; - data: Record; - dryRun: boolean; -} { - const args = process.argv.slice(2); - let event = ''; - let success: boolean | undefined; - let data: Record = {}; - let dryRun = false; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--event': - event = args[++i] || ''; - break; - case '--success': - success = true; - break; - case '--failure': - success = false; - break; - case '--data': - try { - data = JSON.parse(args[++i] || '{}'); - } catch { - console.error('Warning: --data JSON parse failed, ignoring'); - } - break; - case '--dry-run': - dryRun = true; - break; - } - } - - if (!event) { - console.error('Error: --event is required'); - process.exit(0); // exit 0 — never fail on diagnostics - } - - return { event, success, data, dryRun }; -} - -// --- State (neverAsk) --- - -function readState(): Record { - try { - const raw = fs.readFileSync(STATE_YAML_PATH, 'utf-8'); - return parseYaml(raw) || {}; - } catch { - return {}; - } -} - -function isNeverAsk(): boolean { - const state = readState(); - return state.neverAsk === true; -} - -export function setNeverAsk(): void { - const state = readState(); - state.neverAsk = true; - const dir = path.dirname(STATE_YAML_PATH); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(STATE_YAML_PATH, stringifyYaml(state)); -} - -// --- Git helpers --- - -/** Resolve the upstream remote ref (could be 'upstream/main' or 'origin/main'). */ -function resolveUpstreamRef(): string | null { - for (const ref of ['upstream/main', 'origin/main']) { - try { - execSync(`git rev-parse --verify ${ref}`, { - cwd: PROJECT_ROOT, - stdio: 'ignore', - }); - return ref; - } catch { - continue; - } - } - return null; -} - -// --- System info --- - -function getNanoclawVersion(): string { - try { - const pkg = JSON.parse( - fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf-8'), - ); - return pkg.version || 'unknown'; - } catch { - return 'unknown'; - } -} - -function getNodeMajorVersion(): number | null { - const match = process.version.match(/^v(\d+)/); - return match ? parseInt(match[1], 10) : null; -} - -function getContainerRuntime(): string { - try { - const src = fs.readFileSync( - path.join(PROJECT_ROOT, 'src', 'container-runtime.ts'), - 'utf-8', - ); - const match = src.match(/CONTAINER_RUNTIME_BIN\s*=\s*['"]([^'"]+)['"]/); - return match ? match[1] : 'unknown'; - } catch { - return 'unknown'; - } -} - -function isUpstreamCommit(): boolean { - const ref = resolveUpstreamRef(); - if (!ref) return false; - try { - const head = execSync('git rev-parse HEAD', { - encoding: 'utf-8', - cwd: PROJECT_ROOT, - stdio: ['pipe', 'pipe', 'ignore'], - }).trim(); - execSync(`git merge-base --is-ancestor ${head} ${ref}`, { - cwd: PROJECT_ROOT, - stdio: 'ignore', - }); - return true; - } catch { - return false; - } -} - -function collectSystemInfo(): Record { - return { - nanoclaw_version: getNanoclawVersion(), - os_platform: process.platform, - arch: process.arch, - node_major_version: getNodeMajorVersion(), - container_runtime: getContainerRuntime(), - is_upstream_commit: isUpstreamCommit(), - }; -} - -// --- Conflict filename gating --- - -function getUpstreamFiles(): Set | null { - const ref = resolveUpstreamRef(); - if (!ref) return null; - try { - const output = execSync(`git ls-tree -r --name-only ${ref}`, { - encoding: 'utf-8', - cwd: PROJECT_ROOT, - stdio: ['pipe', 'pipe', 'ignore'], - }); - return new Set(output.trim().split('\n').filter(Boolean)); - } catch { - return null; - } -} - -function gateConflictFiles(data: Record): void { - if (!Array.isArray(data.conflict_files)) return; - - const rawFiles: string[] = data.conflict_files; - const upstreamFiles = getUpstreamFiles(); - const totalCount = rawFiles.length; - - if (!upstreamFiles) { - // Can't verify — fail-closed - data.conflict_files = []; - data.conflict_count = totalCount; - data.has_non_upstream_conflicts = totalCount > 0; - return; - } - - const safe: string[] = []; - let hasNonUpstream = false; - - for (const file of rawFiles) { - if (upstreamFiles.has(file)) { - safe.push(file); - } else { - hasNonUpstream = true; - } - } - - data.conflict_files = safe; - data.conflict_count = totalCount; - data.has_non_upstream_conflicts = hasNonUpstream; -} - -// --- Build & send --- - -function buildPayload( - event: string, - systemInfo: Record, - eventData: Record, - success?: boolean, -): Record { - const properties: Record = { - $process_person_profile: false, - $lib: 'nanoclaw-diagnostics', - ...systemInfo, - ...eventData, - }; - - if (success !== undefined) { - properties.success = success; - } - - return { - api_key: POSTHOG_TOKEN, - event, - distinct_id: crypto.randomUUID(), - properties, - }; -} - -async function sendToPostHog( - payload: Record, -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); - - try { - const response = await fetch(POSTHOG_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, - }); - if (response.ok) { - console.log('Diagnostics sent successfully.'); - } else { - console.error( - `Diagnostics send failed (HTTP ${response.status}). This is fine.`, - ); - } - } catch (err) { - console.error('Diagnostics send failed (network error). This is fine.'); - } finally { - clearTimeout(timeout); - } -} - -// --- Main --- - -async function main(): Promise { - try { - if (isNeverAsk()) { - // User opted out permanently — exit silently - return; - } - - const { event, success, data, dryRun } = parseArgs(); - - // Gate conflict filenames before building payload - gateConflictFiles(data); - - const systemInfo = collectSystemInfo(); - const payload = buildPayload(event, systemInfo, data, success); - - if (dryRun) { - // Strip internal fields before showing to user - const { api_key, distinct_id, ...visible } = payload; - const props = visible.properties as Record; - delete props.$process_person_profile; - delete props.$lib; - console.log(JSON.stringify(visible, null, 2)); - return; - } - - await sendToPostHog(payload); - } catch (err) { - // Never fail on diagnostics - console.error('Diagnostics error (this is fine):', (err as Error).message); - } -} - -main();