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.
package/dist/client.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * HTTP client for the Octarin SQL gateway.
3
+ *
4
+ * Resolves auth + base URL from flags/env, then calls `POST /v1/sql/query` and
5
+ * `GET /v1/sql/schema` with the API key as a Bearer token. The CLI holds ONLY
6
+ * the API key — never ClickHouse credentials; all scoping/safety is server-side.
7
+ *
8
+ * Errors map the gateway's JSON envelope (`{ "error": { code, message } }`) to a
9
+ * thrown `CliError` so the command layer can print a clean message to stderr and
10
+ * exit non-zero.
11
+ */
12
+ /** A user-facing error carrying a process exit code. */
13
+ export class CliError extends Error {
14
+ exitCode;
15
+ constructor(message, exitCode = 1) {
16
+ super(message);
17
+ this.name = "CliError";
18
+ this.exitCode = exitCode;
19
+ }
20
+ }
21
+ const DEFAULT_BASE_URL = "https://api.octarin.ai";
22
+ /**
23
+ * Resolve the base URL from explicit flag, env, or the default.
24
+ *
25
+ * Precedence: `--base-url` > `OCTARIN_BASE_URL` > `https://api.octarin.ai`. A
26
+ * `--port` flag (self-host parity) rewrites the port of whichever base URL won,
27
+ * defaulting its host to `http://localhost` when no base URL was given.
28
+ */
29
+ export function resolveBaseUrl(opts) {
30
+ let base = opts.baseUrl || process.env.OCTARIN_BASE_URL || DEFAULT_BASE_URL;
31
+ if (opts.port) {
32
+ // If only a port was provided (no explicit base), assume local self-host.
33
+ if (!opts.baseUrl && !process.env.OCTARIN_BASE_URL) {
34
+ base = `http://localhost:${opts.port}`;
35
+ }
36
+ else {
37
+ const u = new URL(base);
38
+ u.port = opts.port;
39
+ base = u.origin;
40
+ }
41
+ }
42
+ return base.replace(/\/+$/, "");
43
+ }
44
+ /**
45
+ * Resolve the API key from explicit flag or env, or throw a clear `CliError`.
46
+ *
47
+ * Precedence: `--api-key` > `OCTARIN_API_KEY`.
48
+ */
49
+ export function resolveApiKey(apiKeyFlag) {
50
+ const key = apiKeyFlag || process.env.OCTARIN_API_KEY;
51
+ if (!key) {
52
+ throw new CliError("No API key. Set OCTARIN_API_KEY or pass --api-key <oct_...>.", 2);
53
+ }
54
+ return key;
55
+ }
56
+ /** Perform a request to the gateway and parse its JSON, mapping errors. */
57
+ async function request(cfg, path, init) {
58
+ const url = `${cfg.baseUrl}${path}`;
59
+ let resp;
60
+ try {
61
+ resp = await fetch(url, {
62
+ ...init,
63
+ headers: {
64
+ Authorization: `Bearer ${cfg.apiKey}`,
65
+ "Content-Type": "application/json",
66
+ ...(init.headers || {}),
67
+ },
68
+ });
69
+ }
70
+ catch (err) {
71
+ const detail = err instanceof Error ? err.message : String(err);
72
+ throw new CliError(`Could not reach ${url}: ${detail}`, 1);
73
+ }
74
+ const text = await resp.text();
75
+ let body = undefined;
76
+ if (text) {
77
+ try {
78
+ body = JSON.parse(text);
79
+ }
80
+ catch {
81
+ // Non-JSON body (e.g. a proxy 502 page). Fall through to status handling.
82
+ }
83
+ }
84
+ if (!resp.ok) {
85
+ const envelope = body;
86
+ const msg = envelope?.error?.message || text || `HTTP ${resp.status}`;
87
+ const code = envelope?.error?.code;
88
+ throw new CliError(`Request failed (${resp.status}${code ? ` ${code}` : ""}): ${msg}`, 1);
89
+ }
90
+ return body;
91
+ }
92
+ /** POST a SELECT to `/v1/sql/query`. */
93
+ export function runQuery(cfg, query, limit) {
94
+ const payload = { query };
95
+ if (limit !== undefined)
96
+ payload.limit = limit;
97
+ return request(cfg, "/v1/sql/query", {
98
+ method: "POST",
99
+ body: JSON.stringify(payload),
100
+ });
101
+ }
102
+ /** GET the queryable schema from `/v1/sql/schema`. */
103
+ export function getSchema(cfg) {
104
+ return request(cfg, "/v1/sql/schema", { method: "GET" });
105
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared helpers for the per-user credentials file at `~/.octarin/octarin.env`.
3
+ *
4
+ * Both `octarin init` (writes the ingest key from the dashboard) and
5
+ * `octarin login` (writes a device-code key) land here, and each capture hook
6
+ * sources it when `OCTARIN_API_KEY` isn't already in the environment. Keeping
7
+ * the parser + writer in one place means the two commands can't drift on
8
+ * format, mode bits, or merge behaviour.
9
+ */
10
+ import { promises as fs } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, resolve as resolvePath } from "node:path";
13
+ /** Absolute path to the per-user credentials file. */
14
+ export function userEnvPath() {
15
+ return resolvePath(homedir(), ".octarin", "octarin.env");
16
+ }
17
+ /**
18
+ * Parse a `.env`-style file ("KEY=value" lines, '#' comments) into a record.
19
+ * Doesn't handle quoting / multi-line values — the files we read here only ever
20
+ * carry plain KEY=value (written by us or by `repo-install`'s descendants).
21
+ */
22
+ export function parseEnvFile(text) {
23
+ const out = {};
24
+ for (const raw of text.split(/\r?\n/)) {
25
+ const line = raw.trim();
26
+ if (!line || line.startsWith("#"))
27
+ continue;
28
+ const eq = line.indexOf("=");
29
+ if (eq < 0)
30
+ continue;
31
+ const key = line.slice(0, eq).trim();
32
+ const val = line.slice(eq + 1).trim();
33
+ if (key)
34
+ out[key] = val;
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Merge the given KEY=value pairs into `~/.octarin/octarin.env` (mode 600),
40
+ * preserving any keys already present. Returns the path written.
41
+ *
42
+ * Merging (rather than clobbering) matters because `init` and `login` write
43
+ * overlapping-but-not-identical sets — e.g. `init` sets OCTARIN_INGEST_URL +
44
+ * OCTARIN_API_KEY, `login` sets OCTARIN_API_KEY + OCTARIN_PROJECT. Re-running
45
+ * either must not drop the other's keys.
46
+ */
47
+ export async function mergeUserEnv(updates, header = "# Octarin capture credentials. Do not commit.") {
48
+ const path = userEnvPath();
49
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
50
+ let existing = {};
51
+ try {
52
+ existing = parseEnvFile(await fs.readFile(path, "utf8"));
53
+ }
54
+ catch {
55
+ // first run — no prior file
56
+ }
57
+ const merged = { ...existing, ...updates };
58
+ const lines = [
59
+ header,
60
+ ...Object.entries(merged).map(([k, v]) => `${k}=${v}`),
61
+ "",
62
+ ];
63
+ await fs.writeFile(path, lines.join("\n"), { mode: 0o600 });
64
+ // writeFile honours `mode` only on create; chmod explicitly so a pre-existing
65
+ // file is also locked down.
66
+ await fs.chmod(path, 0o600);
67
+ return path;
68
+ }
69
+ /**
70
+ * Walk up from `start` looking for `.octarin/project` (the file committed by the
71
+ * team install — a subdir path, never matched by the *.env rule in common
72
+ * .gitignore patterns). Stops at the filesystem root or at `$HOME`. Returns
73
+ * `null` if none is found.
74
+ */
75
+ export async function findRepoProjectFile(start) {
76
+ const home = homedir();
77
+ let dir = start;
78
+ while (true) {
79
+ const candidate = resolvePath(dir, ".octarin/project");
80
+ try {
81
+ await fs.access(candidate);
82
+ return candidate;
83
+ }
84
+ catch {
85
+ // fall through
86
+ }
87
+ const parent = dirname(dir);
88
+ if (parent === dir)
89
+ return null; // hit the root
90
+ if (parent === home)
91
+ return null; // don't escape past the user's home
92
+ dir = parent;
93
+ }
94
+ }
package/dist/index.js ADDED
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `octarin` — a thin, agent-friendly CLI over the Octarin SQL gateway.
4
+ *
5
+ * Two commands today, mirroring `lmnr-cli`'s stable noun-verb surface:
6
+ * octarin sql query "<SELECT ...>" [--json] [--limit N]
7
+ * octarin sql schema [--json]
8
+ *
9
+ * Auth: `OCTARIN_API_KEY` env var or `--api-key`. Base URL: `https://api.octarin.ai`
10
+ * by default, overridable with `--base-url` / `OCTARIN_BASE_URL` (and `--port`
11
+ * for self-host parity). The CLI holds only the API key — never DB credentials;
12
+ * the server enforces read-only access, the table allowlist, and tenant scoping.
13
+ *
14
+ * I/O contract (so `--json | jq` pipes cleanly): structured results on stdout,
15
+ * all human/progress/error messages on stderr, non-zero exit on failure.
16
+ */
17
+ import { flagBool, flagStr, parseArgs } from "./args.js";
18
+ import { CliError, getSchema, resolveApiKey, resolveBaseUrl, runQuery, } from "./client.js";
19
+ import { cmdLogin } from "./login.js";
20
+ import { cmdInit } from "./init.js";
21
+ import { cmdInitRepo } from "./init_repo.js";
22
+ import { logErr, outJson, outTable } from "./output.js";
23
+ const VERSION = "0.2.0";
24
+ const ROOT_HELP = `octarin — per-user CLI for Octarin AI-usage analytics
25
+
26
+ USAGE
27
+ octarin <command> [subcommand] [args] [options]
28
+
29
+ COMMANDS
30
+ init <oct_key> Install AI-coding capture on THIS machine (personal).
31
+ Sets up Claude Code / Cursor / Codex hooks and
32
+ streams your sessions to your Octarin workspace.
33
+ init-repo <org/project> Commit shared capture config to a repo (team), so
34
+ every teammate streams on clone. Opens a PR.
35
+ login Authorize this machine via a browser sign-in.
36
+ Writes ~/.octarin/octarin.env so capture hooks
37
+ pick up your per-user ingest key.
38
+ sql query "<SELECT ...>" Run a read-only SELECT against your octarin.* tables
39
+ sql schema List the queryable tables and their columns
40
+
41
+ GLOBAL OPTIONS
42
+ --api-key <key> Octarin API key (oct_...). Or set OCTARIN_API_KEY.
43
+ (Not used by \`octarin login\` — that's how you GET a key.)
44
+ --base-url <url> API base URL. Or set OCTARIN_BASE_URL.
45
+ Default: https://api.octarin.ai
46
+ --port <port> Override the port (self-host parity).
47
+ --json Emit machine-readable JSON on stdout (pipe to jq).
48
+ -h, --help Show help.
49
+ --version Show the CLI version.
50
+
51
+ EXAMPLES
52
+ octarin init oct_xxx # personal capture install
53
+ octarin init-repo nace/default # team install (opens a PR)
54
+ octarin login # per-user auth (team installs)
55
+ octarin sql schema
56
+ octarin sql query "SELECT model, count() FROM spans GROUP BY model"
57
+ `;
58
+ const SQL_HELP = `octarin sql — query your own Octarin data (read-only)
59
+
60
+ USAGE
61
+ octarin sql query "<SELECT ...>" [--json] [--limit N]
62
+ octarin sql schema [--json]
63
+
64
+ OPTIONS
65
+ --limit N Max rows to return (server caps at 10000).
66
+ --json Emit JSON on stdout instead of a table.
67
+ --api-key <key> Octarin API key (oct_...). Or set OCTARIN_API_KEY.
68
+ --base-url <url> API base URL. Or set OCTARIN_BASE_URL.
69
+ --port <port> Override the port (self-host parity).
70
+ -h, --help Show this help.
71
+
72
+ NOTES
73
+ Only a single read-only SELECT over your octarin.* tables is allowed
74
+ (spans, traces, session_insights, span_vectors, events). The server enforces
75
+ the table allowlist and scopes every result to your organization's projects.
76
+ `;
77
+ /** Build the client config (auth + base URL) from parsed flags. */
78
+ function buildConfig(flags) {
79
+ const apiKey = resolveApiKey(flagStr(flags, "api-key"));
80
+ const baseUrl = resolveBaseUrl({
81
+ baseUrl: flagStr(flags, "base-url"),
82
+ port: flagStr(flags, "port"),
83
+ });
84
+ return { apiKey, baseUrl };
85
+ }
86
+ /** `octarin sql query "<SELECT>"` */
87
+ async function cmdSqlQuery(positionals, flags) {
88
+ const query = positionals[0];
89
+ if (!query) {
90
+ throw new CliError('Missing query. Usage: octarin sql query "<SELECT ...>"', 2);
91
+ }
92
+ let limit;
93
+ const limitStr = flagStr(flags, "limit");
94
+ if (limitStr !== undefined) {
95
+ const n = Number(limitStr);
96
+ if (!Number.isInteger(n) || n < 1) {
97
+ throw new CliError(`--limit must be a positive integer, got "${limitStr}".`, 2);
98
+ }
99
+ limit = n;
100
+ }
101
+ const cfg = buildConfig(flags);
102
+ logErr(`> querying ${cfg.baseUrl} ...`);
103
+ const result = await runQuery(cfg, query, limit);
104
+ if (flagBool(flags, "json")) {
105
+ outJson(result);
106
+ }
107
+ else {
108
+ outTable(result.columns, result.rows);
109
+ logErr(`(${result.row_count} row${result.row_count === 1 ? "" : "s"})`);
110
+ }
111
+ }
112
+ /** `octarin sql schema` */
113
+ async function cmdSqlSchema(flags) {
114
+ const cfg = buildConfig(flags);
115
+ logErr(`> fetching schema from ${cfg.baseUrl} ...`);
116
+ const schema = await getSchema(cfg);
117
+ if (flagBool(flags, "json")) {
118
+ outJson(schema);
119
+ return;
120
+ }
121
+ // Human-readable: one block per table.
122
+ for (const table of schema.tables) {
123
+ outTable(["column", "type"], table.columns.map((c) => [c.name, c.type]));
124
+ logErr(` ${table.qualified_name} — ${table.columns.length} columns\n`);
125
+ }
126
+ }
127
+ /** Dispatch the `sql` command group. */
128
+ async function cmdSql(positionals, flags) {
129
+ const sub = positionals[0];
130
+ const rest = positionals.slice(1);
131
+ if (flagBool(flags, "help") || !sub) {
132
+ logErr(SQL_HELP);
133
+ if (!sub)
134
+ throw new CliError("Missing subcommand (query | schema).", 2);
135
+ return;
136
+ }
137
+ switch (sub) {
138
+ case "query":
139
+ return cmdSqlQuery(rest, flags);
140
+ case "schema":
141
+ return cmdSqlSchema(flags);
142
+ default:
143
+ logErr(SQL_HELP);
144
+ throw new CliError(`Unknown sql subcommand: ${sub}`, 2);
145
+ }
146
+ }
147
+ /** Entry point: parse argv, route to a command, map errors to exit codes. */
148
+ async function main() {
149
+ const { positionals, flags } = parseArgs(process.argv.slice(2));
150
+ if (flags.version === true) {
151
+ // Version is structured-ish output; print to stdout.
152
+ process.stdout.write(VERSION + "\n");
153
+ return;
154
+ }
155
+ const command = positionals[0];
156
+ const rest = positionals.slice(1);
157
+ if (!command || (flagBool(flags, "help") && !command)) {
158
+ logErr(ROOT_HELP);
159
+ if (!command)
160
+ process.exit(2);
161
+ return;
162
+ }
163
+ switch (command) {
164
+ case "init":
165
+ await cmdInit(rest, flags);
166
+ break;
167
+ case "init-repo":
168
+ await cmdInitRepo(rest, flags);
169
+ break;
170
+ case "login":
171
+ await cmdLogin(rest, flags);
172
+ break;
173
+ case "sql":
174
+ await cmdSql(rest, flags);
175
+ break;
176
+ case "help":
177
+ logErr(ROOT_HELP);
178
+ break;
179
+ default:
180
+ logErr(ROOT_HELP);
181
+ throw new CliError(`Unknown command: ${command}`, 2);
182
+ }
183
+ }
184
+ main().catch((err) => {
185
+ if (err instanceof CliError) {
186
+ logErr(`error: ${err.message}`);
187
+ process.exit(err.exitCode);
188
+ }
189
+ const message = err instanceof Error ? err.message : String(err);
190
+ logErr(`error: ${message}`);
191
+ process.exit(1);
192
+ });