Setting the file. One moment.
Skill Event · Expo Skill Feedback · expo/skills · Skills Docs
ContentsBack to the top of the page scripts/ skill-event.cjs
JavaScript · 180 lines · 8 KB
const
fs
=
require
(
"fs"
);
15 const path = require ( "path" );
16
17 const {
18 POSTHOG_PROJECT_API_KEY ,
19 SOURCE ,
20 telemetryActive ,
21 telemetryConfigured ,
22 detectHarness ,
23 platformProps ,
24 telemetryIdentity ,
25 sendToPosthog ,
26 } = require ( "./telemetry_common.cjs" );
27
28 const EVENT = "skill_invoked" ;
29
30 function parseArgs ( argv ) {
31 const args = { skill: "" , agentHarness: "" , initiator: "" , pluginRoot: "" , dryRun: false , quiet: false , detach: false };
32 for ( let i = 0 ; i < argv. length ; i ++ ) {
33 const flag = argv[i];
34 const next = () => argv[ ++ i] || "" ;
35 switch (flag) {
36 case "--skill" : args.skill = next (); break ;
37 case "--agent-harness" : args.agentHarness = next (); break ;
38 case "--initiator" : args.initiator = next (); break ;
39 case "--plugin-root" : args.pluginRoot = next (); break ;
40 case "--detach" : args.detach = true ; break ;
41 case "--dry-run" : args.dryRun = true ; break ;
42 case "--quiet" : args.quiet = true ; break ;
43 default : break ; // ignore unknown flags
44 }
45 }
46 return args;
47 }
48
49 // Read the hook payload from stdin (fd 0) and parse it as JSON. Only used to resolve
50 // `--skill auto` in the foreground hook process; the detached sender is handed the
51 // already-resolved name and never touches stdin.
52 function readHookInput () {
53 try {
54 if (process.stdin.isTTY) return {};
55 const raw = (fs. readFileSync ( 0 , "utf8" ) || "" ). trim (); // fd 0 = stdin
56 if ( ! raw) return {};
57 const parsed = JSON . parse (raw);
58 return parsed && typeof parsed === "object" && ! Array. isArray (parsed) ? parsed : {};
59 } catch {
60 return {};
61 }
62 }
63
64 // Resolve the invoked skill name from the hook payload. The name arrives in different
65 // fields across payload shapes, so check every plausible location:
66 // - Claude Code Skill tool: tool_input.skill (e.g. "expo:eas-observe")
67 // - Claude Code /slash command: command_name (UserPromptExpansion)
68 // - other payload shapes: tool_input.skill_name, top-level skill / skill_name
69 // Namespaced names must be OURS: "expo:<skill>" keeps the final segment, any other
70 // namespace is dropped — another plugin's "foo:expo-ui" must not count as ours, and
71 // skillBelongsToPlugin() can't tell name collisions apart. Bare names stay permissive
72 // and are scoped by skillBelongsToPlugin() downstream.
73 function skillFromHook ( hookInput ) {
74 const ti = hookInput && typeof hookInput.tool_input === "object" && hookInput.tool_input ? hookInput.tool_input : {};
75 const raw = String (
76 ti.skill || ti.skill_name || hookInput.command_name || hookInput.skill || hookInput.skill_name || ""
77 ). trim (). replace ( / ^ \/ / , "" ); // tolerate a leading "/" from slash-command payloads
78 if ( ! raw. includes ( ":" )) return raw;
79 const sep = raw. lastIndexOf ( ":" );
80 return raw. slice ( 0 , sep) === "expo" ? raw. slice (sep + 1 ) : "" ;
81 }
82
83 function pluginRootFor ( args ) {
84 // Self-derive from this script's location: <root>/skills/expo-skill-feedback/scripts.
85 return args.pluginRoot || path. resolve (__dirname, ".." , ".." , ".." );
86 }
87
88 // Only emit for skills that belong to THIS plugin (so we never track other plugins'
89 // or the user's own skills). Confirms <pluginRoot>/skills/<skill>/SKILL.md exists.
90 // The skill name must be a single kebab-case segment — this also blocks path traversal
91 // (e.g. "../../x") from a malformed payload reaching path.join or the event property.
92 function skillBelongsToPlugin ( skill , pluginRoot ) {
93 if ( ! skill || ! pluginRoot) return false ;
94 if ( ! / ^ [a-z0-9][a-z0-9-] *$ / . test (skill)) return false ;
95 try { return fs. existsSync (path. join (pluginRoot, "skills" , skill, "SKILL.md" )); }
96 catch { return false ; }
97 }
98
99 function eventPayload ( skill , args ) {
100 const agentHarness = args.agentHarness. trim () || detectHarness ();
101 const initiator = args.initiator. trim ();
102 const timestamp = new Date (). toISOString ();
103 const [ distinctId , identityProperties ] = telemetryIdentity (agentHarness, { createInstallation: ! args.dryRun });
104
105 const properties = {
106 $process_person_profile: false ,
107 source: SOURCE ,
108 skill,
109 agent_harness: agentHarness,
110 ... (initiator ? { initiator } : {}),
111 ... platformProps (),
112 ... identityProperties,
113 };
114
115 return { api_key: POSTHOG_PROJECT_API_KEY , event: EVENT , distinct_id: distinctId, timestamp, properties };
116 }
117
118 // Re-launch this script DETACHED to perform the network POST off the agent's critical
119 // path — the cross-platform equivalent of `node skill-event.cjs … &`. We pass the already
120 // resolved `--skill <name>` (not "auto") and drop `--detach`, so the child sends inline and
121 // never reads stdin or re-detaches. It runs under the same runtime that launched us
122 // (process.execPath = node or bun) and inherits our env (CLAUDECODE, EXPO_SKILLS_*, …).
123 // windowsHide avoids a console-window flash on Windows; failures are ignored (best-effort).
124 function spawnDetachedSend ( skill , args ) {
125 try {
126 const { spawn } = require ( "child_process" );
127 const childArgs = [__filename, "--skill" , skill, "--quiet" ];
128 if (args.initiator. trim ()) childArgs. push ( "--initiator" , args.initiator. trim ());
129 if (args.agentHarness. trim ()) childArgs. push ( "--agent-harness" , args.agentHarness. trim ());
130 const child = spawn (process.execPath, childArgs, { detached: true , stdio: "ignore" , windowsHide: true });
131 child. unref ();
132 } catch {
133 // best-effort: if the child can't be spawned, skip the send rather than block
134 }
135 }
136
137 async function main ( argv ) {
138 const args = parseArgs (argv);
139
140 // Resolve which skill ran. `--skill auto` means "read it from the hook payload on
141 // stdin" — which must happen HERE, in the foreground hook process, because a detached
142 // child's stdin is /dev/null.
143 let skill = args.skill. trim ();
144 if (skill === "auto" ) skill = skillFromHook ( readHookInput ());
145
146 // Cheap, local, no-network gates: decide up front whether anything will be sent, so the
147 // common "not an Expo skill / opted out" cases cost nothing and never spawn a child.
148 if ( ! skill) return 0 ; // not a skill invocation
149 if ( ! args.dryRun && ! telemetryActive ()) return 0 ; // opt-in: off until enabled (dry-run inspects regardless)
150 if ( ! telemetryConfigured () && ! args.dryRun) return 0 ; // no key in this build (e.g. a fork) -> inert
151 if ( ! skillBelongsToPlugin (skill, pluginRootFor (args))) return 0 ; // not one of ours
152
153 // Hook path: hand the network POST to a detached copy of ourselves so the turn never
154 // blocks on it, then return immediately. (--dry-run stays inline so it can be inspected.)
155 if (args.detach && ! args.dryRun) {
156 spawnDetachedSend (skill, args);
157 return 0 ;
158 }
159
160 const payload = eventPayload (skill, args);
161
162 if (args.dryRun) {
163 console. log ( JSON . stringify ({ ... payload, api_key: "phc_..." }, null , 2 ));
164 return 0 ;
165 }
166
167 try {
168 await sendToPosthog (payload, { userAgent: "expo-skills/skill-event" , timeoutMs: 3000 });
169 } catch (err) {
170 if ( ! args.quiet) console. error ( `skill-event: ${ err . message }` );
171 return args.quiet ? 0 : 1 ;
172 }
173
174 if ( ! args.quiet) console. log ( `sent ${ EVENT }: ${ payload . properties . skill } (${ payload . properties . initiator || "?"})` );
175 return 0 ;
176 }
177
178 main (process.argv. slice ( 2 ))
179 . then (( code ) => process. exit (code))
180 . catch (() => process. exit ( 0 ));