stdtel 0.2.3__py3-none-any.whl

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.
stdtel/exporter.py ADDED
@@ -0,0 +1,113 @@
1
+ """OTel span emission for std.skill.invocation. Metadata only — never content."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import time
6
+ from typing import Iterable
7
+
8
+ from opentelemetry import trace
9
+ from opentelemetry.sdk.resources import Resource
10
+ from opentelemetry.sdk.trace import TracerProvider
11
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SimpleSpanProcessor
12
+
13
+ SPAN_NAME = "std.skill.invocation"
14
+ SESSION_SPAN_NAME = "std.session.cost"
15
+ DEFAULT_ENDPOINT = "http://localhost:4318"
16
+ DEFAULT_TIMEOUT_S = 2
17
+ # Widened when the hook began seeing every tool, not just Skill: tool_input and
18
+ # tool_response carry file contents, commands and diffs, none of which may leave
19
+ # the machine. Counts only.
20
+ FORBIDDEN_PREFIXES = ("gen_ai.input", "gen_ai.output", "gen_ai.prompt", "gen_ai.completion",
21
+ "tool.input", "tool.output", "tool.arguments", "tool.result",
22
+ "tool_input", "tool_response", "std.tool.input", "std.tool.output")
23
+
24
+
25
+ def build_provider(resource_attrs: dict, exporter: SpanExporter | None = None) -> TracerProvider:
26
+ base = {"service.name": os.environ.get("OTEL_SERVICE_NAME", "stdtel"),
27
+ "std.harness": resource_attrs.get("std.harness", "claude-code")}
28
+ base.update({k: v for k, v in resource_attrs.items() if v is not None})
29
+ provider = TracerProvider(resource=Resource.create(base))
30
+ if exporter is None:
31
+ provider.add_span_processor(BatchSpanProcessor(_otlp_exporter()))
32
+ else:
33
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
34
+ return provider
35
+
36
+
37
+ def _endpoint() -> str:
38
+ """Full traces endpoint URL.
39
+
40
+ Claude Code strips `OTEL_*` from every subprocess it spawns, so a hook can
41
+ never see OTEL_EXPORTER_OTLP_ENDPOINT no matter where it is set — it would
42
+ silently fall back to localhost. STDTEL_OTLP_ENDPOINT survives the scrub and
43
+ wins; the OTEL_* names stay as a fallback for direct CLI/CI use, where
44
+ nothing scrubs them.
45
+ """
46
+ base = (os.environ.get("STDTEL_OTLP_ENDPOINT")
47
+ or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
48
+ or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
49
+ or DEFAULT_ENDPOINT).rstrip("/")
50
+ return base if base.endswith("/v1/traces") else f"{base}/v1/traces"
51
+
52
+
53
+ def _timeout() -> int:
54
+ try:
55
+ return max(1, int(os.environ.get("STDTEL_OTLP_TIMEOUT", DEFAULT_TIMEOUT_S)))
56
+ except ValueError:
57
+ return DEFAULT_TIMEOUT_S
58
+
59
+
60
+ def _otlp_exporter():
61
+ """OTLP/HTTP exporter that fails fast.
62
+
63
+ The timeout must be set on the *exporter*: force_flush(timeout_millis=...)
64
+ is ignored (open-telemetry/opentelemetry-python#4043), and the env var that
65
+ would otherwise bound it (OTEL_EXPORTER_OTLP_TIMEOUT) is scrubbed before the
66
+ hook ever runs. Unbounded, a dead collector stalls the Stop hook for ~7s of
67
+ retry backoff on every turn.
68
+ """
69
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
70
+
71
+ return OTLPSpanExporter(endpoint=_endpoint(), timeout=_timeout())
72
+
73
+
74
+ def scrub(attrs: dict) -> dict:
75
+ """Defence in depth: refuse to emit content attributes even if handed to us."""
76
+ return {k: v for k, v in attrs.items()
77
+ if not k.startswith(FORBIDDEN_PREFIXES) and isinstance(v, (str, int, float, bool))}
78
+
79
+
80
+ def emit_session_cost(provider: TracerProvider, attrs: dict, session_id: str,
81
+ started_at: float, ended_at: float) -> int:
82
+ """One span per Stop carrying the session's whole token cost.
83
+
84
+ Deliberately overlaps std.skill.invocation: that span attributes a slice of
85
+ these tokens to a skill. The two must never be summed — session cost is the
86
+ total, invocation tail is a share of it.
87
+ """
88
+ tracer = provider.get_tracer("stdtel", "0.1.0")
89
+ a = scrub(dict(attrs))
90
+ a["session.id"] = session_id
91
+ span = tracer.start_span(SESSION_SPAN_NAME, attributes=a, start_time=int(started_at * 1e9))
92
+ span.end(end_time=int(ended_at * 1e9))
93
+ return 1
94
+
95
+
96
+ def emit_invocations(provider: TracerProvider, invocations: Iterable[dict], session_id: str) -> int:
97
+ """Each invocation dict: started_at, ended_at, attributes(dict), error(bool)."""
98
+ tracer = provider.get_tracer("stdtel", "0.1.0")
99
+ n = 0
100
+ for inv in invocations:
101
+ start_ns = int(inv["started_at"] * 1e9)
102
+ end_ns = int((inv.get("ended_at") or time.time()) * 1e9)
103
+ attrs = scrub(inv.get("attributes", {}))
104
+ attrs["session.id"] = session_id
105
+ attrs["gen_ai.operation.name"] = "execute_tool"
106
+ attrs["gen_ai.tool.name"] = "Skill"
107
+ span = tracer.start_span(SPAN_NAME, attributes=attrs, start_time=start_ns)
108
+ if inv.get("error"):
109
+ span.set_status(trace.StatusCode.ERROR)
110
+ span.end(end_time=end_ns)
111
+ n += 1
112
+ provider.force_flush()
113
+ return n
File without changes
stdtel/hooks/cli.py ADDED
@@ -0,0 +1,318 @@
1
+ """Single entrypoint for Claude Code hooks: `stdtel-hook <event>`.
2
+
3
+ Reads the hook JSON payload from stdin. Events: session-start, pre-tool-use,
4
+ post-tool-use, stop. Always exits 0 so a telemetry failure never blocks the
5
+ developer (design §7: memory is best-effort, telemetry likewise).
6
+
7
+ Every stdtel import is deliberately function-local. PreToolUse and PostToolUse
8
+ fire on *every* Skill call and are pure latency in the developer's loop, so they
9
+ must not pay for OpenTelemetry (~19ms) or PyYAML when they never touch them.
10
+ Only `stop` needs the exporter; only the catalogue lookup needs the parser.
11
+ Module scope stays stdlib-only.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+
22
+ def _payload() -> dict:
23
+ try:
24
+ return json.load(sys.stdin)
25
+ except Exception:
26
+ return {}
27
+
28
+
29
+ def skills_roots() -> list[Path]:
30
+ """Directories to scan for SKILL.md, in precedence order.
31
+
32
+ STDTEL_SKILLS_ROOT may name several roots separated by os.pathsep; a relative
33
+ one is resolved against CLAUDE_PROJECT_DIR (the project the hook fired in),
34
+ not the process cwd, so `skills` in a project settings file keeps working.
35
+ The user-level catalogue is always searched last, which is what makes hooks
36
+ registered once in ~/.claude/settings.json useful from every project.
37
+ """
38
+ base = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd())
39
+ roots = []
40
+ for raw in os.environ.get("STDTEL_SKILLS_ROOT", "").split(os.pathsep):
41
+ if raw.strip():
42
+ root = Path(raw).expanduser()
43
+ roots.append(root if root.is_absolute() else base / root)
44
+ roots.append(Path.home() / ".claude" / "skills")
45
+ out, seen = [], set()
46
+ for root in roots:
47
+ try:
48
+ resolved = root.resolve()
49
+ except OSError:
50
+ continue
51
+ if resolved not in seen and resolved.is_dir():
52
+ seen.add(resolved)
53
+ out.append(root)
54
+ return out
55
+
56
+
57
+ def _catalogue():
58
+ """Merged catalogue across roots; earlier roots win on name collisions."""
59
+ from stdtel.manifest import load_catalogue # pulls in PyYAML
60
+
61
+ cat: dict = {}
62
+ for root in skills_roots():
63
+ try:
64
+ found = load_catalogue(root, strict=False)
65
+ except Exception:
66
+ continue
67
+ for name, manifest in found.items():
68
+ cat.setdefault(name, manifest)
69
+ return cat
70
+
71
+
72
+ def _skill_from_payload(p: dict) -> tuple[str, str]:
73
+ """(skill name as invoked, trigger).
74
+
75
+ Field name verified against 120 real Skill invocations across 114 transcripts
76
+ in ~/.claude/projects: the input carries `skill` every time (never `name`,
77
+ never `skill_name` — that is the OTel event surface, a different payload).
78
+ `args` also appears and is deliberately not captured: it can hold content.
79
+
80
+ Trigger is provisional here. The transcript carries the tool_use `caller`
81
+ block, which is ground truth, so Stop upgrades this value; a name is never
82
+ slash-prefixed in the observed data, so nothing sets "explicit" in practice.
83
+ """
84
+ inp = p.get("tool_input") or p.get("toolArgs") or {}
85
+ name = str(inp.get("skill") or inp.get("name") or "")
86
+ caller = p.get("caller")
87
+ if name.startswith("/"):
88
+ trigger = "explicit"
89
+ elif isinstance(caller, dict) and caller.get("type"):
90
+ trigger = str(caller["type"])
91
+ else:
92
+ trigger = "unknown"
93
+ return name.lstrip("/"), trigger
94
+
95
+
96
+ def _resolve(name: str, cat: dict):
97
+ """(manifest | None, catalogue name) for a skill as invoked.
98
+
99
+ Plugin-provided skills arrive namespaced — `epic-loop:epic-loop`,
100
+ `anthropic-skills:skill-creator` — which is 71% of real invocations, and is
101
+ what every skill looks like once distributed as a plugin. The catalogue is
102
+ keyed on the bare front-matter `name`, so match the full string first, then
103
+ the segment after the last ":".
104
+ """
105
+ if name in cat:
106
+ return cat[name], name
107
+ bare = name.rsplit(":", 1)[-1]
108
+ if bare in cat:
109
+ return cat[bare], bare
110
+ return None, bare
111
+
112
+
113
+ def session_start(p: dict) -> None:
114
+ from stdtel.enrich import resource_attributes
115
+ from stdtel.state import SessionState
116
+
117
+ st = SessionState.load(p.get("session_id", "unknown"))
118
+ st.resource = resource_attributes(Path(p.get("cwd", ".")), payload=p)
119
+ st.started_at = st.started_at or time.time()
120
+ st.save()
121
+
122
+
123
+ def pre_tool_use(p: dict) -> None:
124
+ if p.get("tool_name") != "Skill":
125
+ return
126
+ from stdtel.state import SessionState
127
+
128
+ st = SessionState.load(p.get("session_id", "unknown"))
129
+ name, trigger = _skill_from_payload(p)
130
+ # Version is resolved from the catalogue at Stop, which loads it anyway to
131
+ # attach the rest of the manifest attributes. Reading it here too would put
132
+ # a PyYAML parse of every SKILL.md on the hot path for a value Stop discards.
133
+ st.open_window(name, "unversioned", trigger, p.get("tool_use_id"),
134
+ prompt_id=str(p.get("prompt_id") or ""),
135
+ permission_mode=str(p.get("permission_mode") or ""))
136
+ st.save()
137
+
138
+
139
+ def post_tool_use(p: dict, error: bool = False) -> None:
140
+ """Fires for every tool, not just Skill.
141
+
142
+ Tool-call failure rate is a first-class metric and a leading indicator of a
143
+ skill instructing the model to do something the environment cannot do. The
144
+ non-Skill path stays at the interpreter floor: state only, no catalogue, no
145
+ exporter, and counts only — tool_input and tool_response never leave here.
146
+ """
147
+ from stdtel.state import SessionState
148
+
149
+ tool = str(p.get("tool_name") or p.get("toolName") or "")
150
+ st = SessionState.load(p.get("session_id", "unknown"))
151
+ st.record_tool(tool, failed=error)
152
+ if tool != "Skill":
153
+ st.save()
154
+ return
155
+ w = st.close_window(p.get("tool_use_id"), error=error)
156
+ if w is not None and p.get("duration_ms"):
157
+ w.duration_ms = int(p["duration_ms"]) # the harness times the tool call itself
158
+ st.save()
159
+
160
+
161
+ def stop(p: dict, exporter=None) -> int:
162
+ from stdtel.exporter import build_provider, emit_invocations, emit_session_cost
163
+ from stdtel.state import SessionState
164
+ from stdtel.transcript import attribute, read_slice
165
+
166
+ sid = p.get("session_id", "unknown")
167
+ st = SessionState.load(sid)
168
+ transcript = Path(p.get("transcript_path", ""))
169
+ sl = read_slice(transcript, st.transcript_offset)
170
+ st.transcript_offset = sl.new_offset
171
+ attributions = {a.skill: a for a in attribute(sl)}
172
+ loads = {l.skill: l for l in sl.skill_loads}
173
+ cat = _catalogue()
174
+ # any window still open at Stop is closed now (turn ended)
175
+ for w in st.open_windows():
176
+ st.close_window(w.tool_use_id)
177
+ invocations = []
178
+ for w in st.drain_closed():
179
+ manifest, resolved = _resolve(w.skill, cat)
180
+ if manifest is not None and not manifest.telemetry_emit:
181
+ # `telemetry.emit: false` in SKILL.md. Parsed and validated since the
182
+ # first commit, and until now read by nothing — an advertised control
183
+ # that did nothing. The session total still counts these tokens; what
184
+ # is suppressed is attributing them to this skill by name.
185
+ continue
186
+ load = loads.get(w.skill)
187
+ attrs = {
188
+ "std.skill.name": resolved,
189
+ "std.skill.invoked_as": w.skill,
190
+ "std.skill.version": w.version,
191
+ # the transcript's caller block is ground truth; the hook payload may not carry it
192
+ "std.skill.trigger": (load.caller if load and load.caller else w.trigger),
193
+ "std.skill.load_tokens": load.load_tokens if load else 0,
194
+ }
195
+ if ":" in w.skill:
196
+ attrs["std.skill.plugin"] = w.skill.rsplit(":", 1)[0]
197
+ if w.prompt_id:
198
+ # join key to Claude Code's native claude_code.* telemetry
199
+ attrs["std.prompt.id"] = w.prompt_id
200
+ if w.permission_mode:
201
+ attrs["std.harness.permission_mode"] = w.permission_mode
202
+ if w.duration_ms:
203
+ attrs["std.skill.duration_ms"] = w.duration_ms
204
+ if manifest:
205
+ attrs.update(manifest.as_attributes())
206
+ a = attributions.get(w.skill)
207
+ if a:
208
+ attrs["std.skill.tail_tokens"] = a.tail.total
209
+ attrs["std.skill.tail_tokens_first_only"] = a.tail_first_only.total
210
+ attrs["std.skill.llm_requests"] = a.request_count
211
+ attrs["gen_ai.request.model"] = a.models[0] if a.models else "unknown"
212
+ attrs.update(a.tail.as_attributes())
213
+ invocations.append({"started_at": w.started_at, "ended_at": w.ended_at,
214
+ "attributes": attrs, "error": w.error})
215
+ # The session's whole cost, emitted whether or not a skill was ever loaded.
216
+ # Without this a session that used no skill produces no telemetry at all, and
217
+ # cost-per-PR has no denominator (CLAUDE.md: unattributed sessions are kept
218
+ # for cost analysis, excluded from outcome analysis).
219
+ totals = sl.totals()
220
+ tool_calls, tool_failures = st.tool_totals()
221
+ session_attrs = {}
222
+ if sl.requests or tool_calls:
223
+ session_attrs = {
224
+ "std.session.llm_requests": len(sl.requests),
225
+ "gen_ai.request.model": (sl.models() or ["unknown"])[0],
226
+ "std.session.tool_calls": tool_calls,
227
+ "std.session.tool_failures": tool_failures,
228
+ **totals.as_attributes(),
229
+ }
230
+ # per-tool counts as std.session.tool.<name>.{calls,failures}
231
+ for name, (calls, failures) in sorted(st.tool_calls.items()):
232
+ session_attrs[f"std.session.tool.{name}.calls"] = calls
233
+ if failures:
234
+ session_attrs[f"std.session.tool.{name}.failures"] = failures
235
+ st.tool_calls = {} # drained with the windows
236
+ st.save()
237
+ if not invocations and not session_attrs:
238
+ return 0
239
+ # ADR-008: when spooling, the hook writes to disk and opens no socket at all.
240
+ # stdtel-export drains it. A hook that never talks to the network cannot stall
241
+ # on one, which is what "never block the developer" was reaching for.
242
+ if exporter is None and _spooling():
243
+ from stdtel.exporter import SESSION_SPAN_NAME, SPAN_NAME
244
+ from stdtel.spool import append
245
+ rows = [{"name": SPAN_NAME, "session_id": sid, "resource": st.resource,
246
+ "started_at": i["started_at"], "ended_at": i["ended_at"],
247
+ "attributes": i["attributes"], "error": i.get("error", False)}
248
+ for i in invocations]
249
+ if session_attrs:
250
+ rows.append({"name": SESSION_SPAN_NAME, "session_id": sid, "resource": st.resource,
251
+ "started_at": (st.started_at or time.time()), "ended_at": time.time(),
252
+ "attributes": session_attrs})
253
+ st.last_export_ok = True # spooled successfully; export is someone else's job
254
+ st.save()
255
+ return append(rows)
256
+
257
+ provider = build_provider(st.resource, exporter=exporter)
258
+ try:
259
+ emitted = emit_invocations(provider, invocations, sid) if invocations else 0
260
+ st.last_export_ok = True
261
+ except Exception:
262
+ st.last_export_ok = False
263
+ st.save()
264
+ raise
265
+ if session_attrs:
266
+ # Session start comes from state, not the transcript: transcript timestamps
267
+ # can be absent or unparseable, and a start near the epoch turns the span's
268
+ # duration into "seconds since 1970" rather than the session's length.
269
+ now = time.time()
270
+ started = st.started_at or min((i["started_at"] for i in invocations), default=now)
271
+ emitted += emit_session_cost(provider, session_attrs, sid, started, now)
272
+ st.save()
273
+ return emitted
274
+
275
+
276
+ def _spooling() -> bool:
277
+ """Write spans to disk rather than exporting inline (ADR-008)."""
278
+ return os.environ.get("STDTEL_SPOOL", "").strip().lower() in ("1", "true", "yes", "on")
279
+
280
+
281
+ def disabled() -> bool:
282
+ """True when the developer has switched telemetry off.
283
+
284
+ Checked before the payload is even read: an opt-out that still parses your
285
+ transcript is not an opt-out.
286
+ """
287
+ return os.environ.get("STDTEL_DISABLED", "").strip().lower() in ("1", "true", "yes", "on")
288
+
289
+
290
+ def main(argv: list[str] | None = None) -> int:
291
+ argv = sys.argv[1:] if argv is None else argv
292
+ event = argv[0] if argv else ""
293
+ if disabled():
294
+ return 0
295
+ p = _payload()
296
+ try:
297
+ if event == "session-start":
298
+ session_start(p)
299
+ elif event == "pre-tool-use":
300
+ pre_tool_use(p)
301
+ elif event == "post-tool-use":
302
+ post_tool_use(p)
303
+ elif event == "post-tool-use-failure":
304
+ post_tool_use(p, error=True)
305
+ elif event == "stop":
306
+ stop(p)
307
+ else:
308
+ # still exit 0: an unknown event must never block the developer
309
+ print(f"stdtel-hook: unknown event {event!r}; expected one of "
310
+ f"session-start, pre-tool-use, post-tool-use, "
311
+ f"post-tool-use-failure, stop", file=sys.stderr)
312
+ except Exception as e: # never block the developer
313
+ print(f"stdtel: {e}", file=sys.stderr)
314
+ return 0
315
+
316
+
317
+ if __name__ == "__main__":
318
+ raise SystemExit(main())
stdtel/install.py ADDED
@@ -0,0 +1,173 @@
1
+ """Render hook configuration bound to *this* installation's absolute path.
2
+
3
+ Hook processes get a non-login `sh -c` and inherit whatever PATH launched the
4
+ harness, so a bare `stdtel-hook` is not resolvable when a version manager (mise,
5
+ asdf, pyenv) or an activated venv is what put it on PATH. Every comparable tool
6
+ solves this the same way — pre-commit bakes `sys.executable` into the generated
7
+ git hook at install time — so we resolve the absolute path here and write it in.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import shlex
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ HOOK_NAME = "stdtel-hook"
19
+ # What the *shipped plugin* manifests invoke. A distributed manifest cannot know
20
+ # the install path, and a bare name does not resolve in a hook's `sh -c` — that
21
+ # failed with "command not found" on every tool call (issue #13). bin/stdtel-hook
22
+ # resolves the real CLI at run time and exits 0 silently when it is absent.
23
+ PLUGIN_LAUNCHER = "${CLAUDE_PLUGIN_ROOT}/bin/stdtel-hook"
24
+
25
+ # (stdtel event, Claude Code event, tool matcher, Copilot event)
26
+ # PreToolUse stays matched to Skill: it only opens skill windows, and firing it on
27
+ # every tool would be pure latency. PostToolUse is unmatched because tool-call
28
+ # failure rate needs every tool, and the non-Skill path is a counter increment.
29
+ EVENTS = (
30
+ ("session-start", "SessionStart", None, "sessionStart"),
31
+ ("pre-tool-use", "PreToolUse", "Skill", "preToolUse"),
32
+ ("post-tool-use", "PostToolUse", None, "postToolUse"),
33
+ ("post-tool-use-failure", "PostToolUseFailure", None, "postToolUseFailure"),
34
+ ("stop", "Stop", None, "agentStop"),
35
+ )
36
+
37
+
38
+ class InstallError(RuntimeError):
39
+ pass
40
+
41
+
42
+ def hook_binary() -> Path:
43
+ """Absolute path to the stdtel-hook belonging to this installation.
44
+
45
+ Prefer the console script beside the running interpreter — for `uv tool
46
+ install` and pipx that is the tool's own bin directory — and only then fall
47
+ back to PATH, which may resolve to a different install.
48
+ """
49
+ # NOT .resolve(): in a venv sys.executable is a symlink to the base
50
+ # interpreter, and resolving it lands in the base install's bin/, not the
51
+ # venv's — where the console script we want does not exist.
52
+ sibling = Path(sys.executable).parent / HOOK_NAME
53
+ if sibling.is_file():
54
+ return sibling
55
+ found = shutil.which(HOOK_NAME)
56
+ if found:
57
+ return Path(found).resolve()
58
+ raise InstallError(
59
+ f"{HOOK_NAME} not found beside {sys.executable} or on PATH; "
60
+ f"install with `uv tool install stdtel` (or `pipx install stdtel`) first")
61
+
62
+
63
+ def _entry(binary: Path, event: str, exec_form: bool, env: dict | None, timeout: int | None) -> dict:
64
+ """One hook entry.
65
+
66
+ Shell form (default) is a single quoted absolute path — the shape Claude Code
67
+ has always accepted. Exec form skips `sh -c` and saves ~3ms per call, but is
68
+ opt-in because we have not executed it against a live harness.
69
+ """
70
+ if exec_form:
71
+ entry: dict = {"type": "command", "command": str(binary), "args": [event]}
72
+ else:
73
+ entry = {"type": "command", "command": f"{shlex.quote(str(binary))} {event}"}
74
+ if timeout is not None:
75
+ entry["timeout"] = timeout
76
+ if env:
77
+ entry["env"] = dict(env)
78
+ return entry
79
+
80
+
81
+ def claude_hooks(binary: "Path | str", exec_form: bool = False) -> dict:
82
+ hooks: dict = {}
83
+ for event, cc_event, matcher, _ in EVENTS:
84
+ group: dict = {"hooks": [_entry(binary, event, exec_form, None, None)]}
85
+ if matcher:
86
+ group["matcher"] = matcher
87
+ hooks[cc_event] = [group]
88
+ return {"hooks": hooks}
89
+
90
+
91
+ def copilot_hooks(binary: "Path | str", harness: str = "copilot-vscode",
92
+ exec_form: bool = False) -> dict:
93
+ """Copilot hook config.
94
+
95
+ STDTEL_HARNESS is set per hook on purpose. Copilot also reads
96
+ `~/.claude/settings.json`, and its snake_case dialect is indistinguishable
97
+ from Claude Code's in the payload, so this env block is the only thing that
98
+ keeps Copilot activity from being recorded as claude-code.
99
+ """
100
+ hooks: dict = {}
101
+ for event, _, matcher, cop_event in EVENTS:
102
+ entry = _entry(binary, event, exec_form, {"STDTEL_HARNESS": harness}, None)
103
+ group: dict = {"hooks": [entry]}
104
+ if matcher:
105
+ group["matcher"] = matcher
106
+ hooks[cop_event] = [group]
107
+ return {"version": 1, "disableAllHooks": False, "hooks": hooks}
108
+
109
+
110
+ def merge_settings(target: Path, block: dict) -> dict:
111
+ """Merge hooks/env into an existing settings file without clobbering it."""
112
+ current = {}
113
+ if target.is_file():
114
+ try:
115
+ current = json.loads(target.read_text() or "{}")
116
+ except json.JSONDecodeError as e:
117
+ raise InstallError(f"{target} is not valid JSON: {e}") from e
118
+ for key, value in block.items():
119
+ if isinstance(value, dict):
120
+ current.setdefault(key, {}).update(value)
121
+ else:
122
+ current[key] = value
123
+ target.parent.mkdir(parents=True, exist_ok=True)
124
+ target.write_text(json.dumps(current, indent=2) + "\n")
125
+ return current
126
+
127
+
128
+ def _dump(obj: dict) -> str:
129
+ return json.dumps(obj, indent=2)
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> int:
133
+ parser = argparse.ArgumentParser(prog="stdtel-install", description=__doc__.splitlines()[0])
134
+ sub = parser.add_subparsers(dest="cmd", required=True)
135
+
136
+ p_hooks = sub.add_parser("hooks", help="print hook config for a harness")
137
+ p_hooks.add_argument("--harness", default="claude-code",
138
+ choices=["claude-code", "copilot-vscode", "copilot-cli"])
139
+ p_hooks.add_argument("--exec-form", action="store_true",
140
+ help="use command+args instead of a shell string (unverified upstream)")
141
+
142
+ p_set = sub.add_parser("settings", help="merge hooks into a Claude Code settings.json")
143
+ p_set.add_argument("--path", type=Path, default=Path.home() / ".claude" / "settings.json")
144
+ p_set.add_argument("--exec-form", action="store_true")
145
+ p_set.add_argument("--dry-run", action="store_true")
146
+
147
+ p_where = sub.add_parser("where", help="print the resolved absolute hook path")
148
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
149
+
150
+ try:
151
+ binary = hook_binary()
152
+ except InstallError as e:
153
+ print(f"stdtel-install: {e}", file=sys.stderr)
154
+ return 1
155
+
156
+ if args.cmd == "where":
157
+ print(binary)
158
+ elif args.cmd == "hooks":
159
+ block = (claude_hooks(binary, args.exec_form) if args.harness == "claude-code"
160
+ else copilot_hooks(binary, args.harness, args.exec_form))
161
+ print(_dump(block))
162
+ elif args.cmd == "settings":
163
+ block = claude_hooks(binary, args.exec_form)
164
+ if args.dry_run:
165
+ print(_dump(block))
166
+ else:
167
+ merge_settings(args.path, block)
168
+ print(f"wrote {len(block['hooks'])} hook events to {args.path} -> {binary}")
169
+ return 0
170
+
171
+
172
+ if __name__ == "__main__":
173
+ raise SystemExit(main())