replace diagnostics script with curl, simplify flow
Remove send-diagnostics.ts entirely. Claude writes the JSON, shows it to the user, and sends via curl. Opt-out is permanent: Claude replaces diagnostics.md contents and removes the section from SKILL.md. No dependencies, no state files, no .nanoclaw/ directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,36 +2,56 @@
|
|||||||
|
|
||||||
After setup is complete, offer to send anonymous diagnostics.
|
After setup is complete, offer to send anonymous diagnostics.
|
||||||
|
|
||||||
## 1. Check opt-out
|
## 1. Write the event
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
npx tsx scripts/send-diagnostics.ts --event setup_complete --success --data '{}' --dry-run
|
|
||||||
|
```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
|
Show the contents of the file to the user and ask:
|
||||||
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:
|
|
||||||
|
|
||||||
> "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:"
|
> "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**
|
> **Yes** / **No** / **Never ask again**
|
||||||
|
|
||||||
Use AskUserQuestion.
|
Use AskUserQuestion.
|
||||||
|
|
||||||
## 4. Handle response
|
## 3. Handle response
|
||||||
|
|
||||||
- **Yes**: Run the command again without `--dry-run`. Confirm: "Diagnostics sent."
|
**Yes**: Send it:
|
||||||
- **No**: Do nothing.
|
```bash
|
||||||
- **Never ask again**: Run `npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();"` — confirm: "Got it — you won't be asked again."
|
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."
|
||||||
|
|||||||
@@ -2,36 +2,58 @@
|
|||||||
|
|
||||||
After the update is complete, offer to send anonymous diagnostics.
|
After the update is complete, offer to send anonymous diagnostics.
|
||||||
|
|
||||||
## 1. Check opt-out
|
## 1. Write the event
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
npx tsx scripts/send-diagnostics.ts --event update_complete --success --data '{}' --dry-run
|
|
||||||
|
```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:
|
Show the contents of the file to the user and ask:
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
> "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:"
|
> "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**
|
> **Yes** / **No** / **Never ask again**
|
||||||
|
|
||||||
Use AskUserQuestion.
|
Use AskUserQuestion.
|
||||||
|
|
||||||
## 4. Handle response
|
## 3. Handle response
|
||||||
|
|
||||||
- **Yes**: Run the command again without `--dry-run`. Confirm: "Diagnostics sent."
|
**Yes**: Send it:
|
||||||
- **No**: Do nothing.
|
```bash
|
||||||
- **Never ask again**: Run `npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();"` — confirm: "Got it — you won't be asked again."
|
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."
|
||||||
|
|||||||
@@ -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 <setup_complete|skill_applied|update_complete> \
|
|
||||||
* [--success|--failure] \
|
|
||||||
* [--data '<json>'] \
|
|
||||||
* [--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<string, unknown>;
|
|
||||||
dryRun: boolean;
|
|
||||||
} {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
let event = '';
|
|
||||||
let success: boolean | undefined;
|
|
||||||
let data: Record<string, unknown> = {};
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string> | 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<string, unknown>): 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<string, unknown>,
|
|
||||||
eventData: Record<string, unknown>,
|
|
||||||
success?: boolean,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const properties: Record<string, unknown> = {
|
|
||||||
$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<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<string, unknown>;
|
|
||||||
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();
|
|
||||||
Reference in New Issue
Block a user