octarin-cli 0.2.0

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * canonical.js — map Cursor hook payloads to Octarin canonical IngestEvents.
3
+ *
4
+ * Cursor fires a separate process per hook event (beforeSubmitPrompt,
5
+ * afterAgentResponse, afterFileEdit, stop, ...), so each event becomes its own
6
+ * IngestEvent carrying one span. All events for a conversation share a
7
+ * deterministic trace_id (derived from `conversation_id`) so the backend rolls
8
+ * them into a single trace. Shape: backend/app/schema/canonical.py::IngestEvent.
9
+ */
10
+
11
+ import { SOURCE, truncate, nowIso, userRef, deterministicTraceId } from "./utils.js";
12
+
13
+ function repoFromRoots(roots) {
14
+ if (!Array.isArray(roots) || roots.length === 0) return null;
15
+ const r = String(roots[0]);
16
+ return r.split("/").filter(Boolean).pop() || null;
17
+ }
18
+
19
+ /** Wrap one span into a full IngestEvent for the given conversation. */
20
+ function envelope(input, span, model) {
21
+ const conv = input.conversation_id || input.generation_id || "cursor-session";
22
+ return {
23
+ trace_id: deterministicTraceId(conv),
24
+ source: SOURCE,
25
+ session_id: input.conversation_id || null,
26
+ // Prefer the signed-in email Cursor puts on the event; else resolve a real
27
+ // identity (git / OS user) rather than an opaque machine hash.
28
+ user_ref: (input.user_email || "").trim() || userRef(),
29
+ repo: repoFromRoots(input.workspace_roots),
30
+ model: model || input.model || null,
31
+ spans: [span],
32
+ start_time: span.start_time,
33
+ end_time: span.end_time,
34
+ };
35
+ }
36
+
37
+ function baseSpan(spanId, name, spanType) {
38
+ const ts = nowIso();
39
+ return {
40
+ span_id: spanId,
41
+ parent_span_id: null,
42
+ name,
43
+ span_type: spanType,
44
+ start_time: ts,
45
+ end_time: ts,
46
+ status: "ok",
47
+ attributes: {},
48
+ };
49
+ }
50
+
51
+ function spanId(input, suffix) {
52
+ const conv = input.conversation_id || "conv";
53
+ const gen = input.generation_id || Date.now();
54
+ return `${conv}:${gen}:${suffix}`;
55
+ }
56
+
57
+ /**
58
+ * Cursor `afterAgentResponse` carries the model output.
59
+ *
60
+ * TOKEN USAGE: as of Cursor 1.7 NO hook event exposes per-turn token usage —
61
+ * the documented `afterAgentResponse` fields are only `text` (+ the shared
62
+ * envelope: conversation_id, generation_id, model, workspace_roots,
63
+ * transcript_path, ...). There is no `usage`, `input_tokens`, or `output_tokens`
64
+ * anywhere in the hook payload, so these spans legitimately carry 0 tokens (not
65
+ * a capture bug). The lookup below stays defensive against several possible
66
+ * field shapes so we transparently pick usage up IF a future Cursor version
67
+ * starts emitting it — but we never fabricate counts when it is absent.
68
+ * (`preCompact.context_tokens` is context-window utilisation, not billable
69
+ * usage, so we deliberately do not treat it as tokens.)
70
+ */
71
+ function fromAfterAgentResponse(input) {
72
+ const span = baseSpan(spanId(input, "gen"), "Cursor agent response", "llm");
73
+ span.model = input.model || null;
74
+ span.input = truncate(input.prompt || "");
75
+ span.output = truncate(input.text || "");
76
+ const usage = input.usage || input.token_usage || input.tokens || {};
77
+ span.input_tokens = Number(usage.input_tokens || usage.prompt_tokens || 0) || 0;
78
+ span.output_tokens = Number(usage.output_tokens || usage.completion_tokens || 0) || 0;
79
+ span.cache_read_tokens =
80
+ Number(usage.cache_read_input_tokens || usage.cached_input_tokens || 0) || 0;
81
+ span.cache_write_tokens =
82
+ Number(usage.cache_creation_input_tokens || usage.cache_write_tokens || 0) || 0;
83
+ span.total_tokens =
84
+ Number(usage.total_tokens || 0) || span.input_tokens + span.output_tokens;
85
+ span.attributes = {
86
+ generation_id: input.generation_id,
87
+ hook: "afterAgentResponse",
88
+ // Flag when Cursor supplied no usage so the gap is visible downstream
89
+ // rather than looking like a silently-dropped count.
90
+ usage_available: Object.keys(usage).length > 0,
91
+ };
92
+ return envelope(input, span, input.model);
93
+ }
94
+
95
+ /** `beforeSubmitPrompt` records the user turn (no tokens yet). */
96
+ function fromBeforeSubmitPrompt(input) {
97
+ const span = baseSpan(spanId(input, "prompt"), "Cursor user prompt", "agent");
98
+ span.input = truncate(input.prompt || "");
99
+ span.attributes = {
100
+ generation_id: input.generation_id,
101
+ attachment_count: (input.attachments || []).length,
102
+ hook: "beforeSubmitPrompt",
103
+ };
104
+ return envelope(input, span, input.model);
105
+ }
106
+
107
+ function asText(v) {
108
+ if (v == null) return "";
109
+ return typeof v === "string" ? v : JSON.stringify(v);
110
+ }
111
+
112
+ /**
113
+ * Tool span id keyed on Cursor's `tool_use_id` (unique per call) so multiple
114
+ * tools in ONE turn don't collide on the same id and get deduped to one (the
115
+ * old `afterFileEdit` keyed on conversation:generation, so 3 edits in a turn
116
+ * overwrote each other). Falls back to a per-call unique when absent.
117
+ */
118
+ function toolSpanId(input) {
119
+ const conv = input.conversation_id || "conv";
120
+ return `${conv}:tool:${input.tool_use_id || input.generation_id || Date.now()}`;
121
+ }
122
+
123
+ /**
124
+ * `postToolUse` — the GENERIC post-tool hook; fires for EVERY tool (edit, shell,
125
+ * read, MCP, ...) with its result. Replaces the per-tool afterFileEdit /
126
+ * afterShellExecution / afterMCPExecution (which would double-count).
127
+ */
128
+ function fromPostToolUse(input) {
129
+ const tool = input.tool_name || "tool";
130
+ const span = baseSpan(toolSpanId(input), tool, "tool");
131
+ span.input = truncate(asText(input.tool_input));
132
+ span.output = truncate(asText(input.tool_output));
133
+ span.attributes = {
134
+ tool_name: tool,
135
+ tool_use_id: input.tool_use_id,
136
+ duration_ms: input.duration,
137
+ hook: "postToolUse",
138
+ };
139
+ return envelope(input, span, input.model);
140
+ }
141
+
142
+ /** `postToolUseFailure` — a tool that errored; this is what powers a real
143
+ * Cursor error rate (status=error, failure_type, is_interrupt). */
144
+ function fromPostToolUseFailure(input) {
145
+ const tool = input.tool_name || "tool";
146
+ const span = baseSpan(toolSpanId(input), tool, "tool");
147
+ span.status = "error";
148
+ span.error_message = input.error_message || input.failure_type || "tool failed";
149
+ span.input = truncate(asText(input.tool_input));
150
+ span.attributes = {
151
+ tool_name: tool,
152
+ tool_use_id: input.tool_use_id,
153
+ failure_type: input.failure_type,
154
+ is_interrupt: input.is_interrupt,
155
+ duration_ms: input.duration,
156
+ hook: "postToolUseFailure",
157
+ };
158
+ return envelope(input, span, input.model);
159
+ }
160
+
161
+ /** `sessionStart` — explicit session boundary (identity rides the envelope). */
162
+ function fromSessionStart(input) {
163
+ const span = baseSpan(spanId(input, "session-start"), "Cursor session start", "agent");
164
+ span.attributes = {
165
+ session_id: input.session_id,
166
+ is_background_agent: input.is_background_agent,
167
+ composer_mode: input.composer_mode,
168
+ hook: "sessionStart",
169
+ };
170
+ return envelope(input, span, input.model);
171
+ }
172
+
173
+ /** `sessionEnd` — richer close than `stop`: final status + reason + duration. */
174
+ function fromSessionEnd(input) {
175
+ const span = baseSpan(spanId(input, "session-end"), "Cursor session end", "agent");
176
+ span.status = input.final_status === "error" ? "error" : "ok";
177
+ if (span.status === "error") span.error_message = input.error_message || "session error";
178
+ span.attributes = {
179
+ session_id: input.session_id,
180
+ reason: input.reason,
181
+ final_status: input.final_status,
182
+ duration_ms: input.duration_ms,
183
+ hook: "sessionEnd",
184
+ };
185
+ return envelope(input, span, input.model);
186
+ }
187
+
188
+ /**
189
+ * `preCompact` — context-window compaction signal. NOTE: `context_tokens` here
190
+ * is context UTILISATION, not billable usage, so it is deliberately NOT mapped
191
+ * to span tokens (no Cursor hook exposes real usage).
192
+ */
193
+ function fromPreCompact(input) {
194
+ const span = baseSpan(
195
+ spanId(input, `compact-${input.message_count || 0}`),
196
+ "Context compaction",
197
+ "agent",
198
+ );
199
+ span.attributes = {
200
+ hook: "preCompact",
201
+ trigger: input.trigger,
202
+ context_usage_percent: input.context_usage_percent,
203
+ context_tokens: input.context_tokens,
204
+ context_window_size: input.context_window_size,
205
+ message_count: input.message_count,
206
+ messages_to_compact: input.messages_to_compact,
207
+ is_first_compaction: input.is_first_compaction,
208
+ };
209
+ return envelope(input, span, input.model);
210
+ }
211
+
212
+ /** `stop` records task completion + status. */
213
+ function fromStop(input) {
214
+ const span = baseSpan(spanId(input, "stop"), "Cursor agent stopped", "agent");
215
+ span.status = input.status === "error" ? "error" : "ok";
216
+ if (input.status === "error") span.error_message = "agent error";
217
+ span.attributes = { status: input.status, loop_count: input.loop_count, hook: "stop" };
218
+ return envelope(input, span, input.model);
219
+ }
220
+
221
+ const BUILDERS = {
222
+ sessionStart: fromSessionStart,
223
+ beforeSubmitPrompt: fromBeforeSubmitPrompt,
224
+ afterAgentResponse: fromAfterAgentResponse,
225
+ postToolUse: fromPostToolUse,
226
+ postToolUseFailure: fromPostToolUseFailure,
227
+ preCompact: fromPreCompact,
228
+ stop: fromStop,
229
+ sessionEnd: fromSessionEnd,
230
+ };
231
+
232
+ /**
233
+ * Build an IngestEvent for a Cursor hook event, or null if this event carries
234
+ * nothing worth sending. `hookName` falls back to `input.hook_event_name`.
235
+ */
236
+ export function buildEvent(hookName, input) {
237
+ const name = hookName || input.hook_event_name;
238
+ const builder = BUILDERS[name];
239
+ return builder ? builder(input) : null;
240
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * utils.js — tiny stdlib helpers for the Cursor -> Octarin capture hook.
3
+ *
4
+ * Zero npm dependencies: stdin reading, a raw `https`/`http` POST with a hard
5
+ * timeout, a real-identity user_ref (git email / OS user), and a deterministic UUID5 (matching the
6
+ * backend's trace-id namespace). Everything here is fail-open friendly — the
7
+ * caller decides what to do on rejection.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import https from "node:https";
12
+ import http from "node:http";
13
+ import crypto from "node:crypto";
14
+ import os from "node:os";
15
+ import { execFileSync } from "node:child_process";
16
+
17
+ export const SOURCE = "cursor";
18
+ export const MAX_TEXT = 20000;
19
+ export const HTTP_TIMEOUT_MS = 5000;
20
+ // Same namespace as backend deterministic_trace_id so retries de-duplicate.
21
+ const TRACE_NAMESPACE = "6f8d2c1e-9a3b-4f5e-8c7d-1a2b3c4d5e6f";
22
+
23
+ export function readStdin() {
24
+ return new Promise((resolve) => {
25
+ let data = "";
26
+ process.stdin.setEncoding("utf8");
27
+ process.stdin.on("data", (chunk) => (data += chunk));
28
+ process.stdin.on("end", () => {
29
+ try {
30
+ resolve(data.trim() ? JSON.parse(data) : {});
31
+ } catch {
32
+ resolve({});
33
+ }
34
+ });
35
+ process.stdin.on("error", () => resolve({}));
36
+ });
37
+ }
38
+
39
+ export function truncate(text) {
40
+ if (typeof text !== "string") return text == null ? "" : String(text);
41
+ return text.length <= MAX_TEXT ? text : text.slice(0, MAX_TEXT);
42
+ }
43
+
44
+ export function nowIso() {
45
+ return new Date().toISOString();
46
+ }
47
+
48
+ /** The committing git identity, or "" if git isn't configured here. */
49
+ function gitEmail() {
50
+ try {
51
+ return execFileSync("git", ["config", "user.email"], {
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ })
54
+ .toString()
55
+ .trim();
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Resolve the engineer's real identity for attribution.
63
+ *
64
+ * Priority: an explicit OCTARIN_USER override → the git user.email → the OS
65
+ * username. We attribute to a real person (matching backfill.py + the per-user
66
+ * ingest key) rather than an opaque per-machine hash, so the dashboard shows who
67
+ * actually did the work. When a per-user key is present the server overrides
68
+ * this with the key owner anyway; a real identity here is what ANONYMOUS
69
+ * (slug-only) sends rely on. Cursor exposes no signed-in account email locally,
70
+ * so git is the best available signal (the backend prefers the event's
71
+ * user_email when Cursor supplies one).
72
+ */
73
+ export function userRef() {
74
+ const env = (process.env.OCTARIN_USER || "").trim();
75
+ if (env) return env;
76
+ const email = gitEmail();
77
+ if (email) return email;
78
+ return os.userInfo().username || "unknown";
79
+ }
80
+
81
+ /** RFC-4122 v5 UUID from (namespace, name) — matches Python's uuid.uuid5. */
82
+ export function uuid5(name) {
83
+ const ns = Buffer.from(TRACE_NAMESPACE.replace(/-/g, ""), "hex");
84
+ const hash = crypto.createHash("sha1").update(Buffer.concat([ns, Buffer.from(name, "utf8")])).digest();
85
+ const bytes = hash.subarray(0, 16);
86
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
87
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
88
+ const hex = bytes.toString("hex");
89
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
90
+ }
91
+
92
+ export function deterministicTraceId(sourceTraceId) {
93
+ return uuid5(`${SOURCE}:${sourceTraceId}`);
94
+ }
95
+
96
+ /**
97
+ * Fire-and-forget POST of an IngestEvent. Resolves true on 2xx, false otherwise.
98
+ * Never throws — the hook must stay fail-open.
99
+ *
100
+ * Two auth modes (same as the Python hook):
101
+ * - Bearer: ``OCTARIN_API_KEY`` set → ``Authorization: Bearer …``.
102
+ * - Slug-only: only ``OCTARIN_PROJECT`` set → ``X-Octarin-Project: <slug>``
103
+ * header + the slug embedded in the body. Server enforces the project's
104
+ * ``allow_anonymous_ingest`` policy. On a ``auth_required`` 401 we print
105
+ * the one-time ``login.sh`` hint via the same marker mechanism the
106
+ * Python hook uses.
107
+ */
108
+ export function postEvent(event) {
109
+ return new Promise((resolve) => {
110
+ let url = process.env.OCTARIN_INGEST_URL;
111
+ if (!url) {
112
+ const base = (process.env.OCTARIN_API_BASE || "").replace(/\/+$/, "");
113
+ if (!base) return resolve(false);
114
+ url = `${base}/v1/ingest`;
115
+ }
116
+ let parsed;
117
+ try {
118
+ parsed = new URL(url);
119
+ } catch {
120
+ return resolve(false);
121
+ }
122
+ const apiKey = process.env.OCTARIN_API_KEY || "";
123
+ const project = (process.env.OCTARIN_PROJECT || "").trim();
124
+
125
+ // Embed `project` in the body for slug-auth (the server reads it from
126
+ // EITHER the body or the X-Octarin-Project header). No-op when a Bearer
127
+ // is present.
128
+ const payload = { ...event };
129
+ if (project && payload.project == null) payload.project = project;
130
+
131
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
132
+ const headers = { "Content-Type": "application/json", "Content-Length": body.length };
133
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
134
+ else if (project) headers["X-Octarin-Project"] = project;
135
+
136
+ const lib = parsed.protocol === "http:" ? http : https;
137
+ const req = lib.request(
138
+ {
139
+ method: "POST",
140
+ hostname: parsed.hostname,
141
+ port: parsed.port || (parsed.protocol === "http:" ? 80 : 443),
142
+ path: parsed.pathname + parsed.search,
143
+ headers,
144
+ timeout: HTTP_TIMEOUT_MS,
145
+ },
146
+ (res) => {
147
+ const chunks = [];
148
+ res.on("data", (c) => chunks.push(c));
149
+ res.on("end", () => {
150
+ const ok = res.statusCode >= 200 && res.statusCode < 300;
151
+ // Strict-auth signal from the server — print the bootstrap hint once.
152
+ if (!ok && res.statusCode === 401 && project && !apiKey) {
153
+ try {
154
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
155
+ if (body && body.error && body.error.code === "auth_required") {
156
+ notifyAuthRequiredOnce(project);
157
+ }
158
+ } catch {
159
+ // ignore parse errors — hook stays fail-open
160
+ }
161
+ }
162
+ resolve(ok);
163
+ });
164
+ },
165
+ );
166
+ req.on("error", () => resolve(false));
167
+ req.on("timeout", () => {
168
+ req.destroy();
169
+ resolve(false);
170
+ });
171
+ req.write(body);
172
+ req.end();
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Print the one-time stderr hint when the project requires per-user auth.
178
+ * Marker file at ~/.octarin/hint.<sha12> so we don't re-print on every event.
179
+ */
180
+ function notifyAuthRequiredOnce(project) {
181
+ try {
182
+ const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
183
+ const dir = `${home}/.octarin`;
184
+ fs.mkdirSync(dir, { recursive: true });
185
+ const sha = crypto.createHash("sha256").update(project).digest("hex").slice(0, 12);
186
+ const marker = `${dir}/auth_hint.${sha}`;
187
+ if (fs.existsSync(marker)) return;
188
+ fs.writeFileSync(marker, "");
189
+ } catch {
190
+ // fall through — better to nag once-a-session than to spam
191
+ }
192
+ process.stderr.write(
193
+ `[octarin] project '${project}' now requires per-user auth. Run once to authorize:\n` +
194
+ "[octarin] curl -fsSL https://octarin.ai/hooks/login.sh | bash\n",
195
+ );
196
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # Wrapper that runs the Octarin Cursor capture hook (hook-handler.js).
3
+ #
4
+ # Mirrors the Claude/Codex wrappers so all three agents load config the same way:
5
+ # source the committed team config (.octarin/project — carries OCTARIN_PROJECT +
6
+ # OCTARIN_INGEST_URL) FIRST, then the per-user key from ~/.octarin/octarin.env
7
+ # (written by `octarin login`), then exec node so Cursor's stdin payload passes
8
+ # straight through. Plain `bash` (no login shell) keeps per-event latency low —
9
+ # postToolUse fires on every tool call. Fails open: any problem exits 0 so Cursor
10
+ # is never blocked.
11
+
12
+ set +e
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd)"
16
+
17
+ # Committed team config — gives the ingest URL + project slug. Subdir path, so it
18
+ # never matches the `*.env` rule in common .gitignore patterns.
19
+ for envfile in "$PROJECT_ROOT/.octarin/project" "$PWD/.octarin/project"; do
20
+ if [ -n "$envfile" ] && [ -f "$envfile" ]; then
21
+ set -a
22
+ # shellcheck disable=SC1090
23
+ . "$envfile"
24
+ set +a
25
+ break
26
+ fi
27
+ done
28
+
29
+ # Per-user key from `octarin login` (only if the team config didn't already set one).
30
+ if [ -z "${OCTARIN_API_KEY:-}" ] && [ -f "$HOME/.octarin/octarin.env" ]; then
31
+ set -a
32
+ # shellcheck disable=SC1090,SC1091
33
+ . "$HOME/.octarin/octarin.env"
34
+ set +a
35
+ fi
36
+
37
+ if command -v node >/dev/null 2>&1; then
38
+ exec node "$SCRIPT_DIR/hook-handler.js"
39
+ fi
40
+
41
+ exit 0
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [{ "command": "bash .cursor/hooks/run.sh" }],
5
+ "beforeSubmitPrompt": [{ "command": "bash .cursor/hooks/run.sh" }],
6
+ "afterAgentResponse": [{ "command": "bash .cursor/hooks/run.sh" }],
7
+ "postToolUse": [{ "command": "bash .cursor/hooks/run.sh" }],
8
+ "postToolUseFailure": [{ "command": "bash .cursor/hooks/run.sh" }],
9
+ "preCompact": [{ "command": "bash .cursor/hooks/run.sh" }],
10
+ "stop": [{ "command": "bash .cursor/hooks/run.sh" }],
11
+ "sessionEnd": [{ "command": "bash .cursor/hooks/run.sh" }]
12
+ }
13
+ }
package/dist/args.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tiny zero-dependency argv parser.
3
+ *
4
+ * Splits a raw argv tail into positional arguments and a flag map. Supports
5
+ * `--flag value`, `--flag=value`, and boolean `--flag` (no value), plus the
6
+ * universal `-h` / `--help`. Keeping this in-house avoids any runtime dependency
7
+ * (the package ships zero deps), which matters for `npx` cold-start time.
8
+ *
9
+ * Known value-taking flags are declared so `--json` (boolean) and
10
+ * `--limit 100` (value) are disambiguated without guessing.
11
+ */
12
+ /** Flags that consume the following token as their value. */
13
+ const VALUE_FLAGS = new Set([
14
+ "api-key",
15
+ "base-url",
16
+ "port",
17
+ "limit",
18
+ // init / init-repo / login
19
+ "key",
20
+ "url",
21
+ "tools",
22
+ "backfill",
23
+ "project",
24
+ "device-label",
25
+ ]);
26
+ /** Boolean flags that never consume a value. */
27
+ const BOOL_FLAGS = new Set(["json", "help"]);
28
+ /** Map of short aliases to their long-flag names. */
29
+ const ALIASES = { h: "help" };
30
+ /** Parse an argv tail (after the node + script entries) into positionals+flags. */
31
+ export function parseArgs(argv) {
32
+ const positionals = [];
33
+ const flags = {};
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const token = argv[i];
36
+ if (token === "--") {
37
+ // Everything after `--` is positional.
38
+ positionals.push(...argv.slice(i + 1));
39
+ break;
40
+ }
41
+ if (token.startsWith("--")) {
42
+ const body = token.slice(2);
43
+ const eq = body.indexOf("=");
44
+ if (eq !== -1) {
45
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
46
+ continue;
47
+ }
48
+ if (BOOL_FLAGS.has(body)) {
49
+ flags[body] = true;
50
+ continue;
51
+ }
52
+ if (VALUE_FLAGS.has(body)) {
53
+ const next = argv[i + 1];
54
+ if (next === undefined || next.startsWith("-")) {
55
+ // Missing value — record empty so the command can error clearly.
56
+ flags[body] = "";
57
+ }
58
+ else {
59
+ flags[body] = next;
60
+ i++;
61
+ }
62
+ continue;
63
+ }
64
+ // Unknown long flag: treat as boolean (the command validates).
65
+ flags[body] = true;
66
+ continue;
67
+ }
68
+ if (token.startsWith("-") && token.length > 1) {
69
+ const name = ALIASES[token.slice(1)] ?? token.slice(1);
70
+ flags[name] = true;
71
+ continue;
72
+ }
73
+ positionals.push(token);
74
+ }
75
+ return { positionals, flags };
76
+ }
77
+ /** Read a flag as a string, or undefined if absent/boolean. */
78
+ export function flagStr(flags, name) {
79
+ const v = flags[name];
80
+ return typeof v === "string" ? v : undefined;
81
+ }
82
+ /** Read a flag as a boolean. */
83
+ export function flagBool(flags, name) {
84
+ return flags[name] === true;
85
+ }
package/dist/assets.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Locate the bundled capture-client assets shipped inside this package.
3
+ *
4
+ * `copy-assets.mjs` stages ../clients into ./assets at build time, and
5
+ * package.json's "files" ships that directory. At runtime the compiled entry
6
+ * lives at dist/index.js, so assets sit one level up, at <package>/assets.
7
+ * Resolving relative to import.meta.url (not cwd) keeps `npx octarin-cli` working
8
+ * regardless of where the user invoked it.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ /** Absolute path to the bundled assets directory. */
14
+ export function assetsDir() {
15
+ const here = dirname(fileURLToPath(import.meta.url)); // <package>/dist
16
+ return resolve(here, "..", "assets");
17
+ }
18
+ /** Absolute path to a file/dir within the bundled assets. */
19
+ export function assetPath(...parts) {
20
+ return resolve(assetsDir(), ...parts);
21
+ }
22
+ /** Throw a clear error if the assets weren't bundled (e.g. ran tsc without copy-assets). */
23
+ export function assertAssets() {
24
+ const dir = assetsDir();
25
+ if (!existsSync(resolve(dir, "backfill.py"))) {
26
+ throw new Error(`Bundled assets missing at ${dir}. This is a packaging bug — please re-install octarin.`);
27
+ }
28
+ }