diff --git a/AGENTS.md b/AGENTS.md index 04e596e..79190cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,3 +252,9 @@ codex plugin marketplace add expo/skills --ref main - Include a brief `README.md` for each plugin. - Keep references close to the skill that uses them. - Avoid broad rewrites when updating a skill; preserve the skill's existing scope and trigger intent. + +## Usage Telemetry & Feedback + +Telemetry is anonymous, **opt-in, and off by default** — nothing is sent until the user enables it with `node plugins/expo/skills/expo-skill-feedback/scripts/telemetry.cjs --on` or `EXPO_SKILLS_TELEMETRY=1` (`--off` / `=0` / `DO_NOT_TRACK=1` disable; CI never sends). When enabled, two events fire on **Claude Code only**: automatic `skill_invoked` (plugin-level `hooks/hooks.json`) and explicit `skill_feedback` (the `expo-skill-feedback` skill). The gate is `telemetryActive()` in `telemetry_common.cjs`. + +For contributors: new skills need **no telemetry edits and no feedback footer** — the plugin-level hook covers them automatically. Codex and Cursor cannot host plugin hooks (verified against their sources; don't re-investigate), so they ship no hooks and send nothing. diff --git a/README.md b/README.md index bfcb79d..c616d91 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,16 @@ Use the plugin for Claude Code or Codex; it stays updated through the plugin mar Expo documentation, Expo CLI, and EAS CLI are the source of truth. These skills teach agents how to apply Expo guidance in real projects. +## Usage telemetry & feedback + +**Off by default — these skills send nothing unless you turn telemetry on.** When enabled (Claude Code only), they send anonymous usage events: the skill name, platform, and a hash of a random local install id. Never code, prompts, file paths, or personal data. + +- **Turn on:** ask your agent to **"enable Expo skills telemetry"**, or set `EXPO_SKILLS_TELEMETRY=1`. +- **Turn off:** ask it to disable telemetry, or set `EXPO_SKILLS_TELEMETRY=0` / `DO_NOT_TRACK=1`. CI never sends. +- **Feedback:** with telemetry on, the `expo-skill-feedback` skill sends a quick anonymous rating + note about a skill. + +Details live in the `expo-skill-feedback` skill. + ## License MIT diff --git a/plugins/expo/.claude-plugin/plugin.json b/plugins/expo/.claude-plugin/plugin.json index 82849f7..535e84b 100644 --- a/plugins/expo/.claude-plugin/plugin.json +++ b/plugins/expo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "expo", - "version": "1.5.0", + "version": "1.6.0", "description": "Official Expo skills for building, deploying, upgrading, and debugging Expo apps", "author": { "name": "Expo Team", diff --git a/plugins/expo/.codex-plugin/plugin.json b/plugins/expo/.codex-plugin/plugin.json index 8c63ac0..afedcb4 100644 --- a/plugins/expo/.codex-plugin/plugin.json +++ b/plugins/expo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "expo", - "version": "1.5.0", + "version": "1.6.0", "description": "Official Expo skills for building, deploying, upgrading, and debugging Expo and React Native apps.", "author": { "name": "Expo Team", diff --git a/plugins/expo/.cursor-plugin/plugin.json b/plugins/expo/.cursor-plugin/plugin.json index 2e15e93..0175432 100644 --- a/plugins/expo/.cursor-plugin/plugin.json +++ b/plugins/expo/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "expo", "displayName": "Expo", - "version": "1.5.0", + "version": "1.6.0", "description": "Official Expo skills for building, deploying, upgrading, and debugging Expo and React Native apps.", "author": { "name": "Expo Team", diff --git a/plugins/expo/README.md b/plugins/expo/README.md index 9093104..608ce68 100644 --- a/plugins/expo/README.md +++ b/plugins/expo/README.md @@ -85,6 +85,10 @@ Official AI agent skills from the Expo team for building, deploying, upgrading, - **upgrading-expo** — Upgrade Expo SDK versions and fix dependency issues +## Usage telemetry & feedback + +**Off by default** — nothing is sent unless you turn telemetry on. When enabled (Claude Code only), the plugin sends anonymous usage events — the skill name, platform, and a hash of a random local install id — never code, prompts, file paths, or personal data. Ask your agent to **"enable Expo skills telemetry"** to opt in (or set `EXPO_SKILLS_TELEMETRY=1`); turn off with `EXPO_SKILLS_TELEMETRY=0` / `DO_NOT_TRACK=1`. Feedback on a skill goes through the **expo-skill-feedback** skill. + ## License MIT diff --git a/plugins/expo/hooks/hooks.json b/plugins/expo/hooks/hooks.json new file mode 100644 index 0000000..fbc5913 --- /dev/null +++ b/plugins/expo/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-event.cjs\" --skill auto --initiator ai --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --detach --quiet", + "timeout": 5 + } + ] + } + ], + "UserPromptExpansion": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-event.cjs\" --skill auto --initiator user --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --detach --quiet", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/plugins/expo/skills/expo-skill-feedback/SKILL.md b/plugins/expo/skills/expo-skill-feedback/SKILL.md new file mode 100644 index 0000000..627016d --- /dev/null +++ b/plugins/expo/skills/expo-skill-feedback/SKILL.md @@ -0,0 +1,54 @@ +--- +name: expo-skill-feedback +description: Submit feedback on an Expo skill — or on Expo itself — or turn the bundled anonymous usage telemetry on or off (off by default / opt-in; the user saying "enable Expo skills telemetry" in conversation is the switch). Use when an Expo skill was useful, confusing, broken, missing context, or worth improving; when something fell short because of Expo (an SDK bug or confusing framework behavior) rather than the skill; or when the user wants to enable, turn on, opt in to, disable, turn off, opt out of, check the status of, or understand the anonymous usage tracking these skills can send. +--- + +# Expo Skill Feedback + +Send anonymous feedback on an Expo skill — or on Expo itself — and turn the bundled usage +telemetry on or off. **Telemetry is off by default (opt-in)** — nothing is sent until it's +enabled. Feedback goes to the Expo team via PostHog. Only the skill name, your rating + note, +platform, and a hash of a random local install id are sent — never code, prompts, file paths, +secrets, or personal data. (A separate automatic `skill_invoked` event fires when a skill runs +*once enabled*; the switch below controls both.) + +## Submitting feedback + +Be proactive: when a skill was wrong, confusing, outdated, or missing — or the trouble is +with **Expo itself** — send 1-3 concrete sentences. Pass the skill you're rating to +`--skill` (the skill being rated, not `expo-skill-feedback`): + +```bash +node "${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/skill-feedback.cjs" \ + --skill expo-deployment --rating useful \ + --text "TestFlight steps were clear and worked first try." +``` + +- `--rating`: `useful` · `confusing` · `bug` · `idea` · `other` +- `--about`: `skill` (default) · `expo` (the issue is Expo itself, not the skill) +- `--dry-run` prints the payload without sending + +Never include secrets, source code, long prompts, or stack traces. + +If the command refuses because telemetry is off, don't drop the feedback — ask the user +once (see *Turning it on or off*), and resend the same command after they enable. + +## Turning it on or off + +Telemetry is anonymous and **off by default (opt-in)** — nothing (neither the automatic +`skill_invoked` event nor feedback) is sent until the user enables it. Saying so in +conversation is the switch: when the user says **"enable Expo skills telemetry"** — or +answers yes when you offer — run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/skills/expo-skill-feedback/scripts/telemetry.cjs" --on +``` + +`--off` turns it off again. When the user asks whether telemetry is on, run `--status` +and relay its output — don't answer from memory; env vars and CI can override the saved +state. Env equivalents: `EXPO_SKILLS_TELEMETRY=1` to enable, `=0` or `DO_NOT_TRACK=1` to +disable; CI never sends. + +**Never enable it on your own.** If a feedback send was refused because telemetry is off, +ask the user once — "Expo skills telemetry is off; enable it (anonymous) and send this?" — +and run `--on` only after an explicit yes. If they decline, drop the feedback silently. diff --git a/plugins/expo/skills/expo-skill-feedback/scripts/skill-event.cjs b/plugins/expo/skills/expo-skill-feedback/scripts/skill-event.cjs new file mode 100644 index 0000000..5c940c7 --- /dev/null +++ b/plugins/expo/skills/expo-skill-feedback/scripts/skill-event.cjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// Submit a `skill_invoked` event to PostHog — fire-and-forget. +// +// Invoked two ways, both cross-platform (plain `node`, no shell wrapper, so it runs the +// same on macOS / Linux / Windows): +// 1. From Claude Code hooks (../../../hooks/hooks.json) as +// `node skill-event.cjs --skill auto --initiator --plugin-root --detach --quiet`. +// The foreground process reads the hook payload from stdin, resolves which skill ran, +// runs the cheap local gates, then — for `--detach` — re-launches a DETACHED copy of +// itself to do the network POST and returns immediately, so the agent turn never blocks. +// 2. Directly (that detached child, or manual / --dry-run testing) with a resolved +// `--skill `, which builds the event and sends it inline. + +const fs = require("fs"); +const path = require("path"); + +const { + POSTHOG_PROJECT_API_KEY, + SOURCE, + telemetryActive, + telemetryConfigured, + detectHarness, + platformProps, + telemetryIdentity, + sendToPosthog, +} = require("./telemetry_common.cjs"); + +const EVENT = "skill_invoked"; + +function parseArgs(argv) { + const args = { skill: "", agentHarness: "", initiator: "", pluginRoot: "", dryRun: false, quiet: false, detach: false }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const next = () => argv[++i] || ""; + switch (flag) { + case "--skill": args.skill = next(); break; + case "--agent-harness": args.agentHarness = next(); break; + case "--initiator": args.initiator = next(); break; + case "--plugin-root": args.pluginRoot = next(); break; + case "--detach": args.detach = true; break; + case "--dry-run": args.dryRun = true; break; + case "--quiet": args.quiet = true; break; + default: break; // ignore unknown flags + } + } + return args; +} + +// Read the hook payload from stdin (fd 0) and parse it as JSON. Only used to resolve +// `--skill auto` in the foreground hook process; the detached sender is handed the +// already-resolved name and never touches stdin. +function readHookInput() { + try { + if (process.stdin.isTTY) return {}; + const raw = (fs.readFileSync(0, "utf8") || "").trim(); // fd 0 = stdin + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +// Resolve the invoked skill name from the hook payload. The name arrives in different +// fields across harnesses and payload shapes, so check every plausible location — the +// strict skillBelongsToPlugin() scoping downstream keeps this safe even when permissive +// (anything that isn't really one of our skills is dropped): +// - Claude Code Skill tool: tool_input.skill (e.g. "expo:expo-observe") +// - Claude Code /slash command: command_name (UserPromptExpansion) +// - other payload shapes: tool_input.skill_name, top-level skill / skill_name +// Plugin skills are namespaced (e.g. "expo:expo-observe") — keep the final segment. +function skillFromHook(hookInput) { + const ti = hookInput && typeof hookInput.tool_input === "object" && hookInput.tool_input ? hookInput.tool_input : {}; + const raw = String( + ti.skill || ti.skill_name || hookInput.command_name || hookInput.skill || hookInput.skill_name || "" + ).trim().replace(/^\//, ""); // tolerate a leading "/" from slash-command payloads + return raw.includes(":") ? raw.slice(raw.lastIndexOf(":") + 1) : raw; +} + +function pluginRootFor(args) { + // Self-derive from this script's location: /skills/expo-skill-feedback/scripts. + return args.pluginRoot || path.resolve(__dirname, "..", "..", ".."); +} + +// Only emit for skills that belong to THIS plugin (so we never track other plugins' +// or the user's own skills). Confirms /skills//SKILL.md exists. +// The skill name must be a single kebab-case segment — this also blocks path traversal +// (e.g. "../../x") from a malformed payload reaching path.join or the event property. +function skillBelongsToPlugin(skill, pluginRoot) { + if (!skill || !pluginRoot) return false; + if (!/^[a-z0-9][a-z0-9-]*$/.test(skill)) return false; + try { return fs.existsSync(path.join(pluginRoot, "skills", skill, "SKILL.md")); } + catch { return false; } +} + +function eventPayload(skill, args) { + const agentHarness = args.agentHarness.trim() || detectHarness(); + const initiator = args.initiator.trim(); + const timestamp = new Date().toISOString(); + const [distinctId, identityProperties] = telemetryIdentity(agentHarness, { createInstallation: !args.dryRun }); + + const properties = { + $process_person_profile: false, + source: SOURCE, + skill, + agent_harness: agentHarness, + ...(initiator ? { initiator } : {}), + ...platformProps(), + ...identityProperties, + }; + + return { api_key: POSTHOG_PROJECT_API_KEY, event: EVENT, distinct_id: distinctId, timestamp, properties }; +} + +// Re-launch this script DETACHED to perform the network POST off the agent's critical +// path — the cross-platform equivalent of `node skill-event.cjs … &`. We pass the already +// resolved `--skill ` (not "auto") and drop `--detach`, so the child sends inline and +// never reads stdin or re-detaches. It runs under the same runtime that launched us +// (process.execPath = node or bun) and inherits our env (CLAUDECODE, EXPO_SKILLS_*, …). +// windowsHide avoids a console-window flash on Windows; failures are ignored (best-effort). +function spawnDetachedSend(skill, args) { + try { + const { spawn } = require("child_process"); + const childArgs = [__filename, "--skill", skill, "--quiet"]; + if (args.initiator.trim()) childArgs.push("--initiator", args.initiator.trim()); + if (args.agentHarness.trim()) childArgs.push("--agent-harness", args.agentHarness.trim()); + const child = spawn(process.execPath, childArgs, { detached: true, stdio: "ignore", windowsHide: true }); + child.unref(); + } catch { + // best-effort: if the child can't be spawned, skip the send rather than block + } +} + +async function main(argv) { + const args = parseArgs(argv); + + // Resolve which skill ran. `--skill auto` means "read it from the hook payload on + // stdin" — which must happen HERE, in the foreground hook process, because a detached + // child's stdin is /dev/null. + let skill = args.skill.trim(); + if (skill === "auto") skill = skillFromHook(readHookInput()); + + // Cheap, local, no-network gates: decide up front whether anything will be sent, so the + // common "not an Expo skill / opted out" cases cost nothing and never spawn a child. + if (!skill) return 0; // not a skill invocation + if (!args.dryRun && !telemetryActive()) return 0; // opt-in: off until enabled (dry-run inspects regardless) + if (!telemetryConfigured() && !args.dryRun) return 0; // no key in this build (e.g. a fork) -> inert + if (!skillBelongsToPlugin(skill, pluginRootFor(args))) return 0; // not one of ours + + // Hook path: hand the network POST to a detached copy of ourselves so the turn never + // blocks on it, then return immediately. (--dry-run stays inline so it can be inspected.) + if (args.detach && !args.dryRun) { + spawnDetachedSend(skill, args); + return 0; + } + + const payload = eventPayload(skill, args); + + if (args.dryRun) { + console.log(JSON.stringify({ ...payload, api_key: "phc_..." }, null, 2)); + return 0; + } + + try { + await sendToPosthog(payload, { userAgent: "expo-skills/skill-event", timeoutMs: 3000 }); + } catch (err) { + if (!args.quiet) console.error(`skill-event: ${err.message}`); + return args.quiet ? 0 : 1; + } + + if (!args.quiet) console.log(`sent ${EVENT}: ${payload.properties.skill} (${payload.properties.initiator || "?"})`); + return 0; +} + +main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch(() => process.exit(0)); diff --git a/plugins/expo/skills/expo-skill-feedback/scripts/skill-feedback.cjs b/plugins/expo/skills/expo-skill-feedback/scripts/skill-feedback.cjs new file mode 100644 index 0000000..5d09800 --- /dev/null +++ b/plugins/expo/skills/expo-skill-feedback/scripts/skill-feedback.cjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Submit feedback about an Expo skill to PostHog. +// +// Usage: +// node skill-feedback.cjs --skill --rating --text "..." \ +// [--about skill|expo] [--agent-harness ] [--dry-run] + +const { + POSTHOG_PROJECT_API_KEY, + SOURCE, + telemetryActive, + telemetryConfigured, + detectHarness, + platformProps, + telemetryIdentity, + sendToPosthog, +} = require("./telemetry_common.cjs"); + +const EVENT_NAME = "skill_feedback"; +const RATINGS = ["useful", "confusing", "bug", "idea", "other"]; +// What the feedback is about: the skill's own guidance (default), or Expo the +// framework — when a skill fell short because of Expo itself, not the skill. +const ABOUT_VALUES = ["skill", "expo"]; +const MAX_FEEDBACK_CHARS = 4000; + +function parseArgs(argv) { + const args = { skill: "", rating: "", text: "", about: "skill", agentHarness: "", dryRun: false }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const next = () => argv[++i] || ""; + switch (flag) { + case "--skill": args.skill = next(); break; + case "--rating": args.rating = next(); break; + case "--text": args.text = next(); break; + case "--about": args.about = next(); break; + case "--agent-harness": args.agentHarness = next(); break; + case "--dry-run": args.dryRun = true; break; + default: break; + } + } + return args; +} + +function eventPayload(args) { + const feedback = args.text.trim().slice(0, MAX_FEEDBACK_CHARS); + const skill = args.skill.trim(); + const agentHarness = args.agentHarness.trim() || detectHarness(); + + if (!feedback) throw new Error("--text cannot be empty"); + if (!skill) throw new Error("--skill cannot be empty"); + if (!RATINGS.includes(args.rating)) throw new Error(`--rating must be one of: ${RATINGS.join(", ")}`); + const about = (args.about || "skill").trim() || "skill"; + if (!ABOUT_VALUES.includes(about)) throw new Error(`--about must be one of: ${ABOUT_VALUES.join(", ")}`); + + const timestamp = new Date().toISOString(); + const [distinctId, identityProperties] = telemetryIdentity(agentHarness, { createInstallation: !args.dryRun }); + + return { + api_key: POSTHOG_PROJECT_API_KEY, + event: EVENT_NAME, + distinct_id: distinctId, + timestamp, + properties: { + $process_person_profile: false, + source: SOURCE, + ...identityProperties, + agent_harness: agentHarness, + ...platformProps(), + skill, + about, + rating: args.rating, + feedback_text: feedback, + }, + }; +} + +async function main(argv) { + const args = parseArgs(argv); + + if (!args.dryRun && !telemetryActive()) { + console.error("skill-feedback: telemetry is off (opt-in, off by default); nothing sent. Enable with `telemetry.cjs --on` or EXPO_SKILLS_TELEMETRY=1."); + return 0; + } + if (!telemetryConfigured() && !args.dryRun) { + console.error("skill-feedback: no PostHog key in this build (key stripped to placeholder); nothing sent. Set EXPO_SKILLS_POSTHOG_KEY or restore the key in telemetry_common.cjs."); + return 0; + } + + let payload; + try { + payload = eventPayload(args); + } catch (err) { + console.error(`skill-feedback: ${err.message}`); + return 2; + } + + if (args.dryRun) { + console.log(JSON.stringify({ ...payload, api_key: "phc_..." }, null, 2)); + return 0; + } + + try { + await sendToPosthog(payload, { userAgent: "expo-skills/skill-feedback", timeoutMs: 10000 }); + } catch (err) { + console.error(`skill-feedback: ${err.message}`); + return 1; + } + + console.log(`sent skill feedback: ${payload.properties.skill}`); + return 0; +} + +main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + console.error(`skill-feedback: ${err && err.message ? err.message : err}`); + process.exit(1); + }); diff --git a/plugins/expo/skills/expo-skill-feedback/scripts/telemetry.cjs b/plugins/expo/skills/expo-skill-feedback/scripts/telemetry.cjs new file mode 100644 index 0000000..718e4fe --- /dev/null +++ b/plugins/expo/skills/expo-skill-feedback/scripts/telemetry.cjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Turn Expo skills usage telemetry on or off, or check its status. +// +// Telemetry is OFF by default (opt-in) — nothing is sent until you enable it here or +// via EXPO_SKILLS_TELEMETRY=1. Usage: +// node telemetry.cjs --status # show whether telemetry is on/off and why +// node telemetry.cjs --on # enable (writes the opt-in marker) +// node telemetry.cjs --off # disable (removes the opt-in marker — the default) +// +// The opt-in marker is the reliable switch: it works no matter how the agent was +// launched. EXPO_SKILLS_TELEMETRY=1/0 and DO_NOT_TRACK also work (handy for CI), but +// env vars don't always reach hook subprocesses. + +const fs = require("fs"); +const path = require("path"); +const { + OPT_IN_PATH, + telemetryActive, + telemetryEnvSignal, + telemetryConfigured, + isCI, +} = require("./telemetry_common.cjs"); + +// One-line explanation of the current state (mirrors telemetryActive()'s precedence). +function reason() { + const env = telemetryEnvSignal(); + if (env === "off") return "env var (EXPO_SKILLS_TELEMETRY=0 / DO_NOT_TRACK)"; + if (isCI()) return "CI environment (telemetry never emits from CI)"; + if (env === "on") return "env var EXPO_SKILLS_TELEMETRY=1"; + if (safeExists(OPT_IN_PATH)) return `opt-in marker (${OPT_IN_PATH})`; + return "default (opt-in — off until enabled)"; +} + +function safeExists(p) { try { return fs.existsSync(p); } catch { return false; } } + +function printStatus() { + const on = telemetryActive(); + if (on && !telemetryConfigured()) { + console.log(`Expo skills telemetry: ON via ${reason()}, but no PostHog key in this build (stripped to placeholder) — nothing is sent.`); + } else if (on) { + console.log(`Expo skills telemetry: ON (anonymous) — ${reason()}. Turn off with: telemetry.cjs --off`); + } else { + console.log(`Expo skills telemetry: OFF — ${reason()}. Turn on with: telemetry.cjs --on (or EXPO_SKILLS_TELEMETRY=1)`); + } +} + +const cmd = process.argv[2]; + +if (cmd === "--on" || cmd === "--enable") { + fs.mkdirSync(path.dirname(OPT_IN_PATH), { recursive: true, mode: 0o700 }); + fs.writeFileSync(OPT_IN_PATH, "Expo skills telemetry enabled by user.\n"); + console.log(`Telemetry enabled — wrote ${OPT_IN_PATH}`); + if (telemetryEnvSignal() === "off") console.log("Note: an env var (EXPO_SKILLS_TELEMETRY=0 / DO_NOT_TRACK) still forces it OFF; unset it to send."); + else if (isCI()) console.log("Note: this looks like CI, where telemetry stays OFF regardless."); + console.log("Turn off any time with: telemetry.cjs --off"); +} else if (cmd === "--off" || cmd === "--disable") { + try { fs.rmSync(OPT_IN_PATH, { force: true }); } catch {} + console.log("Telemetry off — removed the opt-in marker (off is the default)."); + if (telemetryEnvSignal() === "on") console.log("Note: EXPO_SKILLS_TELEMETRY=1 still forces it ON; unset it to stay off."); +} else if (cmd === "--status" || cmd === undefined) { + printStatus(); +} else { + console.error(`Unknown option: ${cmd}\nUsage: telemetry.cjs [--status | --on | --off]`); + process.exit(2); +} diff --git a/plugins/expo/skills/expo-skill-feedback/scripts/telemetry_common.cjs b/plugins/expo/skills/expo-skill-feedback/scripts/telemetry_common.cjs new file mode 100644 index 0000000..4e6c47c --- /dev/null +++ b/plugins/expo/skills/expo-skill-feedback/scripts/telemetry_common.cjs @@ -0,0 +1,171 @@ +// Shared helpers for Expo skills PostHog telemetry. +// +// Zero npm dependencies — Node.js built-ins only (crypto, fs, https, os, path). +// Node is a hard requirement for Expo development, so it is available wherever +// these skills are used. If `node` is ever missing, the calling hook simply +// fails non-blocking and no telemetry is sent. + +const crypto = require("crypto"); +const fs = require("fs"); +const https = require("https"); +const os = require("os"); +const path = require("path"); + +const POSTHOG_HOST = "https://us.i.posthog.com"; + +// PostHog project API key. This is a *write-only, public* ingestion key — the +// same kind embedded in browser snippets — so it is safe to commit. Override +// per environment with EXPO_SKILLS_POSTHOG_KEY (e.g. a staging project). +// NOTE: never put a PostHog *personal* API key (phx_...) here — those are secret. +const POSTHOG_PROJECT_API_KEY = + process.env.EXPO_SKILLS_POSTHOG_KEY || "phc_w8xRytdAAwkV3oExnuUozqH64PMzCmDLnyoChpPBcNXs"; + +const SOURCE = "expo-skills"; +const INSTALLATION_ID_PATH = path.join(os.homedir(), ".expo-skills", "installation-id"); + +// Opt-in is the model: telemetry is OFF by default and only sends once the user +// explicitly enables it. This is the one product knob — flip to true for an opt-out +// (on-by-default) model instead. +const DEFAULT_ON = false; + +// Persistent opt-in marker. Its presence turns telemetry on across sessions, regardless +// of how the agent was launched (env vars don't always reach hook subprocesses). +// Written / removed by scripts/telemetry.cjs --on / --off. +const OPT_IN_PATH = path.join(os.homedir(), ".expo-skills", "opt-in"); + +// CI detection — never emit from automated environments so usage data reflects real +// humans. Honors the common CI=true convention plus major providers' signals. +function isCI() { + const ci = String(process.env.CI || "").trim().toLowerCase(); + if (ci && ci !== "0" && ci !== "false") return true; + return Boolean( + process.env.GITHUB_ACTIONS || + process.env.GITLAB_CI || + process.env.CIRCLECI || + process.env.TRAVIS || + process.env.BUILDKITE || + process.env.JENKINS_URL || + process.env.TEAMCITY_VERSION || + process.env.TF_BUILD + ); +} + +// Explicit on/off intent from env vars: returns "on" | "off" | null. +// DO_NOT_TRACK=1 -> off (https://consoledonottrack.com) +// EXPO_SKILLS_TELEMETRY=0/off/false/no -> off +// EXPO_SKILLS_TELEMETRY=1/on/true/yes -> on +function telemetryEnvSignal() { + const dnt = String(process.env.DO_NOT_TRACK || "").trim().toLowerCase(); + if (dnt && dnt !== "0" && dnt !== "false") return "off"; + const flag = String(process.env.EXPO_SKILLS_TELEMETRY || "").trim().toLowerCase(); + if (["0", "false", "off", "no"].includes(flag)) return "off"; + if (["1", "true", "on", "yes"].includes(flag)) return "on"; + return null; +} + +// Master gate. Nothing is sent unless this returns true. Precedence (highest first): +// 1. explicit env off (DO_NOT_TRACK / EXPO_SKILLS_TELEMETRY=0) -> off +// 2. CI -> off (never emit from bots) +// 3. explicit env on (EXPO_SKILLS_TELEMETRY=1) -> on +// 4. persistent opt-in marker (telemetry.cjs --on) -> on +// 5. default -> DEFAULT_ON (off = opt-in) +// So explicit-off and CI always win; otherwise it's on only when explicitly enabled. +function telemetryActive() { + const env = telemetryEnvSignal(); + if (env === "off") return false; + if (isCI()) return false; + if (env === "on") return true; + try { if (fs.existsSync(OPT_IN_PATH)) return true; } catch {} + return DEFAULT_ON; +} + +// A real key ships in this file, so telemetry is ON by default. This guard only +// makes the scripts inert if someone strips the key (e.g. a fork or private build). +function telemetryConfigured() { + const key = String(POSTHOG_PROJECT_API_KEY || "").trim(); + return key.length > 0 && key !== "phc_REPLACE_ME"; +} + +// Best-effort agent-harness label for the event (default when --agent-harness isn't passed). +function detectHarness() { + if (process.env.CLAUDECODE) return "claude-code"; + if (process.env.CODEX_SANDBOX || process.env.CODEX_SANDBOX_NETWORK_DISABLED || + String(process.env.AGENT || "").toLowerCase() === "codex") return "codex"; + return "unknown"; +} + +// Friendly OS + CPU arch for event properties (non-PII). +function platformProps() { + const osName = { darwin: "macos", win32: "windows" }[process.platform] || process.platform; + return { os: osName, arch: process.arch }; +} + +// Random, anonymous, per-install id — created once at 0600, only its hash is ever sent. +function readInstallationId(create = true) { + try { + if (fs.existsSync(INSTALLATION_ID_PATH)) { + const existing = fs.readFileSync(INSTALLATION_ID_PATH, "utf8").trim(); + if (existing) return existing; + } + if (!create) return null; + fs.mkdirSync(path.dirname(INSTALLATION_ID_PATH), { recursive: true, mode: 0o700 }); + try { fs.chmodSync(path.dirname(INSTALLATION_ID_PATH), 0o700); } catch {} + const installationId = crypto.randomUUID().replace(/-/g, ""); + try { + // 'wx' = O_CREAT | O_EXCL | O_WRONLY — atomic create, fails if it exists. + const fd = fs.openSync(INSTALLATION_ID_PATH, "wx", 0o600); + try { fs.writeFileSync(fd, installationId + "\n"); } finally { fs.closeSync(fd); } + return installationId; + } catch (err) { + if (err && err.code === "EEXIST") return fs.readFileSync(INSTALLATION_ID_PATH, "utf8").trim() || null; + throw err; + } + } catch { + return null; + } +} + +function telemetryIdentity(agentHarness, { createInstallation = true } = {}) { + const id = readInstallationId(createInstallation); + const installHash = id ? crypto.createHash("sha256").update(id).digest("hex").slice(0, 32) : null; + if (installHash) return [`expo-skills-installation:${installHash}`, { installation_id_hash: installHash }]; + return [`expo-skills-events:${agentHarness}`, {}]; +} + +function sendToPosthog(payload, { userAgent, timeoutMs }) { + return new Promise((resolve, reject) => { + const url = new URL("/i/v0/e/", POSTHOG_HOST); + const body = Buffer.from(JSON.stringify(payload), "utf8"); + const req = https.request(url, { + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": body.length, "User-Agent": userAgent }, + timeout: timeoutMs, + }, (res) => { + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const status = res.statusCode || 0; + if (status >= 200 && status < 300) return resolve(); + reject(new Error(`HTTP ${status} ${Buffer.concat(chunks).toString("utf8")}`)); + }); + }); + req.on("timeout", () => req.destroy(new Error("request timed out"))); + req.on("error", reject); + req.write(body); + req.end(); + }); +} + +module.exports = { + POSTHOG_PROJECT_API_KEY, + SOURCE, + OPT_IN_PATH, + telemetryActive, + telemetryEnvSignal, + telemetryConfigured, + detectHarness, + isCI, + platformProps, + telemetryIdentity, + sendToPosthog, +};