octarin-cli 0.3.1 → 0.3.3
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.
- package/assets/backfill.py +53 -1
- package/assets/claude_code/hook.py +79 -11
- package/assets/cursor/lib/canonical.js +4 -0
- package/assets/repo-template/dot-claude/octarin/hook.py +42 -1
- package/assets/repo-template/dot-cursor/hooks/lib/canonical.js +4 -0
- package/dist/index.js +0 -0
- package/package.json +1 -1
package/assets/backfill.py
CHANGED
|
@@ -44,6 +44,7 @@ import getpass
|
|
|
44
44
|
import json
|
|
45
45
|
import os
|
|
46
46
|
import re
|
|
47
|
+
import ssl
|
|
47
48
|
import subprocess
|
|
48
49
|
import sys
|
|
49
50
|
import urllib.error
|
|
@@ -289,6 +290,14 @@ def parse_claude_transcript( # noqa: PLR0915 - top-down jsonl parser; splitting
|
|
|
289
290
|
:_OUTPUT_CAP
|
|
290
291
|
],
|
|
291
292
|
"status": "ok",
|
|
293
|
+
# subagent turns bill against the session model;
|
|
294
|
+
# carry it so per-model analytics can attribute
|
|
295
|
+
# their tokens
|
|
296
|
+
**(
|
|
297
|
+
{"model": model}
|
|
298
|
+
if stype == "agent" and model
|
|
299
|
+
else {}
|
|
300
|
+
),
|
|
292
301
|
}
|
|
293
302
|
)
|
|
294
303
|
prev_ts = ts
|
|
@@ -940,6 +949,49 @@ def _filter_spans_by_cutoff(event: dict, cutoff: datetime | None) -> dict | None
|
|
|
940
949
|
return event
|
|
941
950
|
|
|
942
951
|
|
|
952
|
+
_SSL_CTX: ssl.SSLContext | None = None
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
956
|
+
"""A cert-verifying TLS context that also works on Pythons whose default
|
|
957
|
+
trust store is empty — the cause of ``CERTIFICATE_VERIFY_FAILED: unable to
|
|
958
|
+
get local issuer certificate`` on many macOS (python.org) installs. Resolves
|
|
959
|
+
a CA bundle, all options verifying: ``OCTARIN_CA_BUNDLE`` / ``SSL_CERT_FILE``
|
|
960
|
+
env, then ``certifi`` if importable, then known system bundles, then the
|
|
961
|
+
interpreter default. Built once and reused.
|
|
962
|
+
"""
|
|
963
|
+
global _SSL_CTX
|
|
964
|
+
if _SSL_CTX is not None:
|
|
965
|
+
return _SSL_CTX
|
|
966
|
+
cafile: str | None = None
|
|
967
|
+
for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
|
|
968
|
+
p = os.environ.get(env)
|
|
969
|
+
if p and os.path.exists(p):
|
|
970
|
+
cafile = p
|
|
971
|
+
break
|
|
972
|
+
if cafile is None:
|
|
973
|
+
try:
|
|
974
|
+
import certifi # noqa: PLC0415 - optional, used only if present
|
|
975
|
+
|
|
976
|
+
cafile = certifi.where()
|
|
977
|
+
except Exception:
|
|
978
|
+
for p in (
|
|
979
|
+
"/etc/ssl/cert.pem", # macOS base system, many Linux
|
|
980
|
+
"/opt/homebrew/etc/openssl@3/cert.pem", # Apple-silicon Homebrew
|
|
981
|
+
"/usr/local/etc/openssl@3/cert.pem", # Intel Homebrew
|
|
982
|
+
"/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
|
|
983
|
+
"/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS
|
|
984
|
+
):
|
|
985
|
+
if os.path.exists(p):
|
|
986
|
+
cafile = p
|
|
987
|
+
break
|
|
988
|
+
try:
|
|
989
|
+
_SSL_CTX = ssl.create_default_context(cafile=cafile)
|
|
990
|
+
except Exception:
|
|
991
|
+
_SSL_CTX = ssl.create_default_context()
|
|
992
|
+
return _SSL_CTX
|
|
993
|
+
|
|
994
|
+
|
|
943
995
|
def post_event(
|
|
944
996
|
url: str, key: str, event: dict, timeout: float = 20.0
|
|
945
997
|
) -> tuple[bool, str]:
|
|
@@ -955,7 +1007,7 @@ def post_event(
|
|
|
955
1007
|
method="POST",
|
|
956
1008
|
)
|
|
957
1009
|
try:
|
|
958
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
1010
|
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
|
|
959
1011
|
raw = resp.read().decode("utf-8", "replace")
|
|
960
1012
|
try:
|
|
961
1013
|
n = json.loads(raw).get("span_count")
|
|
@@ -24,9 +24,9 @@ import getpass
|
|
|
24
24
|
import hashlib
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
+
import ssl
|
|
27
28
|
import subprocess
|
|
28
29
|
import sys
|
|
29
|
-
import time
|
|
30
30
|
import urllib.request
|
|
31
31
|
import uuid
|
|
32
32
|
from datetime import datetime, timezone
|
|
@@ -476,6 +476,46 @@ def user_ref() -> str:
|
|
|
476
476
|
return "unknown"
|
|
477
477
|
|
|
478
478
|
|
|
479
|
+
_SSL_CTX = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _ssl_context():
|
|
483
|
+
"""Cert-verifying TLS context resilient to empty trust stores (the macOS
|
|
484
|
+
python.org ``CERTIFICATE_VERIFY_FAILED`` issue). Resolves a CA bundle —
|
|
485
|
+
OCTARIN_CA_BUNDLE / SSL_CERT_FILE env, certifi if importable, known system
|
|
486
|
+
bundles, else the interpreter default — and reuses it."""
|
|
487
|
+
global _SSL_CTX
|
|
488
|
+
if _SSL_CTX is not None:
|
|
489
|
+
return _SSL_CTX
|
|
490
|
+
cafile = None
|
|
491
|
+
for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
|
|
492
|
+
p = os.environ.get(env)
|
|
493
|
+
if p and os.path.exists(p):
|
|
494
|
+
cafile = p
|
|
495
|
+
break
|
|
496
|
+
if cafile is None:
|
|
497
|
+
try:
|
|
498
|
+
import certifi
|
|
499
|
+
|
|
500
|
+
cafile = certifi.where()
|
|
501
|
+
except Exception:
|
|
502
|
+
for p in (
|
|
503
|
+
"/etc/ssl/cert.pem",
|
|
504
|
+
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
505
|
+
"/usr/local/etc/openssl@3/cert.pem",
|
|
506
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
507
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
508
|
+
):
|
|
509
|
+
if os.path.exists(p):
|
|
510
|
+
cafile = p
|
|
511
|
+
break
|
|
512
|
+
try:
|
|
513
|
+
_SSL_CTX = ssl.create_default_context(cafile=cafile)
|
|
514
|
+
except Exception:
|
|
515
|
+
_SSL_CTX = ssl.create_default_context()
|
|
516
|
+
return _SSL_CTX
|
|
517
|
+
|
|
518
|
+
|
|
479
519
|
def post_event(event: dict) -> bool:
|
|
480
520
|
"""POST the IngestEvent. Returns True on 2xx, False otherwise (fail-open)."""
|
|
481
521
|
url = os.environ.get("OCTARIN_INGEST_URL")
|
|
@@ -491,25 +531,48 @@ def post_event(event: dict) -> bool:
|
|
|
491
531
|
if api_key:
|
|
492
532
|
req.add_header("Authorization", f"Bearer {api_key}")
|
|
493
533
|
try:
|
|
494
|
-
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
|
534
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S, context=_ssl_context()) as resp:
|
|
495
535
|
return 200 <= resp.status < 300
|
|
496
536
|
except Exception:
|
|
497
537
|
return False
|
|
498
538
|
|
|
499
539
|
|
|
500
|
-
def
|
|
540
|
+
def _state_file(key: str) -> Path:
|
|
541
|
+
"""Per-session state file.
|
|
542
|
+
|
|
543
|
+
One file per session key, NOT one shared JSON: with the shared file two
|
|
544
|
+
concurrent sessions raced load→save and the last writer clobbered the other
|
|
545
|
+
session's offset back, so its next fire re-read (and re-sent) transcript
|
|
546
|
+
chunks it had already shipped.
|
|
547
|
+
"""
|
|
548
|
+
return STATE_DIR / f"claude_code_state.{key[:32]}.json"
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def load_state(key: str) -> dict:
|
|
552
|
+
f = _state_file(key)
|
|
501
553
|
try:
|
|
502
|
-
|
|
554
|
+
if f.exists():
|
|
555
|
+
return {key: json.loads(f.read_text(encoding="utf-8"))}
|
|
503
556
|
except Exception:
|
|
504
557
|
return {}
|
|
558
|
+
# One-time migration: pick this session's entry out of the legacy shared file.
|
|
559
|
+
try:
|
|
560
|
+
if STATE_FILE.exists():
|
|
561
|
+
legacy = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
562
|
+
if key in legacy:
|
|
563
|
+
return {key: legacy[key]}
|
|
564
|
+
except Exception:
|
|
565
|
+
pass
|
|
566
|
+
return {}
|
|
505
567
|
|
|
506
568
|
|
|
507
|
-
def save_state(state: dict) -> None:
|
|
569
|
+
def save_state(state: dict, key: str) -> None:
|
|
508
570
|
try:
|
|
509
571
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
510
|
-
|
|
511
|
-
tmp
|
|
512
|
-
|
|
572
|
+
f = _state_file(key)
|
|
573
|
+
tmp = f.with_suffix(".tmp")
|
|
574
|
+
tmp.write_text(json.dumps(state.get(key) or {}, sort_keys=True), encoding="utf-8")
|
|
575
|
+
os.replace(tmp, f)
|
|
513
576
|
except Exception:
|
|
514
577
|
pass
|
|
515
578
|
|
|
@@ -520,10 +583,10 @@ def build_event(payload: dict) -> dict | None:
|
|
|
520
583
|
if not session_id or path is None:
|
|
521
584
|
return None
|
|
522
585
|
|
|
523
|
-
state = load_state()
|
|
524
586
|
key = hashlib.sha256(f"{session_id}::{path}".encode()).hexdigest()
|
|
587
|
+
state = load_state(key)
|
|
525
588
|
entries = read_new_entries(path, state, key)
|
|
526
|
-
save_state(state)
|
|
589
|
+
save_state(state, key)
|
|
527
590
|
if not entries:
|
|
528
591
|
return None
|
|
529
592
|
|
|
@@ -532,7 +595,12 @@ def build_event(payload: dict) -> dict | None:
|
|
|
532
595
|
return None
|
|
533
596
|
|
|
534
597
|
repo = Path(cwd).name if cwd else None
|
|
535
|
-
|
|
598
|
+
# Day-stable trace id (like the Cursor hook): every fire for a session
|
|
599
|
+
# upserts the SAME trace, so a re-sent chunk (offset race, retry) replaces
|
|
600
|
+
# its span rows in the ReplacingMergeTree instead of minting a new trace
|
|
601
|
+
# per second and double-counting. The date suffix splits multi-day sessions.
|
|
602
|
+
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
603
|
+
src_trace = f"{session_id}:{day}"
|
|
536
604
|
trace_id = str(uuid.uuid5(_TRACE_NAMESPACE, f"{SOURCE}:{src_trace}"))
|
|
537
605
|
times = [s["start_time"] for s in spans]
|
|
538
606
|
|
|
@@ -128,6 +128,9 @@ function toolSpanId(input) {
|
|
|
128
128
|
function fromPostToolUse(input) {
|
|
129
129
|
const tool = input.tool_name || "tool";
|
|
130
130
|
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
131
|
+
// Cursor never exposes token usage, so the backend estimates tool spans from
|
|
132
|
+
// their I/O — stamp the session model so the estimate prices at its real rate.
|
|
133
|
+
span.model = input.model || null;
|
|
131
134
|
span.input = truncate(asText(input.tool_input));
|
|
132
135
|
span.output = truncate(asText(input.tool_output));
|
|
133
136
|
span.attributes = {
|
|
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
|
|
|
144
147
|
function fromPostToolUseFailure(input) {
|
|
145
148
|
const tool = input.tool_name || "tool";
|
|
146
149
|
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
150
|
+
span.model = input.model || null;
|
|
147
151
|
span.status = "error";
|
|
148
152
|
span.error_message = input.error_message || input.failure_type || "tool failed";
|
|
149
153
|
span.input = truncate(asText(input.tool_input));
|
|
@@ -42,6 +42,7 @@ import getpass
|
|
|
42
42
|
import hashlib
|
|
43
43
|
import json
|
|
44
44
|
import os
|
|
45
|
+
import ssl
|
|
45
46
|
import subprocess
|
|
46
47
|
import sys
|
|
47
48
|
import time
|
|
@@ -547,6 +548,46 @@ def _notify_auth_required_once(project: str) -> None:
|
|
|
547
548
|
)
|
|
548
549
|
|
|
549
550
|
|
|
551
|
+
_SSL_CTX = None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _ssl_context():
|
|
555
|
+
"""Cert-verifying TLS context resilient to empty trust stores (the macOS
|
|
556
|
+
python.org ``CERTIFICATE_VERIFY_FAILED`` issue). Resolves a CA bundle —
|
|
557
|
+
OCTARIN_CA_BUNDLE / SSL_CERT_FILE env, certifi if importable, known system
|
|
558
|
+
bundles, else the interpreter default — and reuses it."""
|
|
559
|
+
global _SSL_CTX
|
|
560
|
+
if _SSL_CTX is not None:
|
|
561
|
+
return _SSL_CTX
|
|
562
|
+
cafile = None
|
|
563
|
+
for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
|
|
564
|
+
p = os.environ.get(env)
|
|
565
|
+
if p and os.path.exists(p):
|
|
566
|
+
cafile = p
|
|
567
|
+
break
|
|
568
|
+
if cafile is None:
|
|
569
|
+
try:
|
|
570
|
+
import certifi
|
|
571
|
+
|
|
572
|
+
cafile = certifi.where()
|
|
573
|
+
except Exception:
|
|
574
|
+
for p in (
|
|
575
|
+
"/etc/ssl/cert.pem",
|
|
576
|
+
"/opt/homebrew/etc/openssl@3/cert.pem",
|
|
577
|
+
"/usr/local/etc/openssl@3/cert.pem",
|
|
578
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
579
|
+
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
580
|
+
):
|
|
581
|
+
if os.path.exists(p):
|
|
582
|
+
cafile = p
|
|
583
|
+
break
|
|
584
|
+
try:
|
|
585
|
+
_SSL_CTX = ssl.create_default_context(cafile=cafile)
|
|
586
|
+
except Exception:
|
|
587
|
+
_SSL_CTX = ssl.create_default_context()
|
|
588
|
+
return _SSL_CTX
|
|
589
|
+
|
|
590
|
+
|
|
550
591
|
def post_event(event: dict) -> bool:
|
|
551
592
|
"""POST the IngestEvent. Returns True on 2xx, False otherwise (fail-open).
|
|
552
593
|
|
|
@@ -585,7 +626,7 @@ def post_event(event: dict) -> bool:
|
|
|
585
626
|
elif project:
|
|
586
627
|
req.add_header("X-Octarin-Project", project)
|
|
587
628
|
try:
|
|
588
|
-
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
|
629
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S, context=_ssl_context()) as resp:
|
|
589
630
|
return HTTP_OK <= resp.status < HTTP_MULTIPLE_CHOICES
|
|
590
631
|
except urllib.error.HTTPError as exc:
|
|
591
632
|
# Strict-auth signal from the server: print the login.sh hint, once.
|
|
@@ -128,6 +128,9 @@ function toolSpanId(input) {
|
|
|
128
128
|
function fromPostToolUse(input) {
|
|
129
129
|
const tool = input.tool_name || "tool";
|
|
130
130
|
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
131
|
+
// Cursor never exposes token usage, so the backend estimates tool spans from
|
|
132
|
+
// their I/O — stamp the session model so the estimate prices at its real rate.
|
|
133
|
+
span.model = input.model || null;
|
|
131
134
|
span.input = truncate(asText(input.tool_input));
|
|
132
135
|
span.output = truncate(asText(input.tool_output));
|
|
133
136
|
span.attributes = {
|
|
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
|
|
|
144
147
|
function fromPostToolUseFailure(input) {
|
|
145
148
|
const tool = input.tool_name || "tool";
|
|
146
149
|
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
150
|
+
span.model = input.model || null;
|
|
147
151
|
span.status = "error";
|
|
148
152
|
span.error_message = input.error_message || input.failure_type || "tool failed";
|
|
149
153
|
span.input = truncate(asText(input.tool_input));
|
package/dist/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octarin-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Octarin's per-user CLI: install AI-coding capture (`octarin init` / `init-repo`) and authorize a machine (`octarin login`). Streams your Claude Code / Cursor / Codex usage to your Octarin workspace.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"octarin",
|