@cliftonc/finius 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/branding.js +28 -0
  4. package/dist/cli/backfill.js +122 -0
  5. package/dist/cli/claude-settings.js +54 -0
  6. package/dist/cli/codex-config.js +60 -0
  7. package/dist/cli/codex.js +97 -0
  8. package/dist/cli/config.js +41 -0
  9. package/dist/cli/doctor.js +159 -0
  10. package/dist/cli/hook.js +70 -0
  11. package/dist/cli/identity.js +163 -0
  12. package/dist/cli/import.js +61 -0
  13. package/dist/cli/index.js +70 -0
  14. package/dist/cli/install.js +23 -0
  15. package/dist/cli/password.js +14 -0
  16. package/dist/cli/serve.js +63 -0
  17. package/dist/cli/setup.js +314 -0
  18. package/dist/cli/ui.js +15 -0
  19. package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
  20. package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
  21. package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
  22. package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
  23. package/dist/client/assets/index-6OIY_8fO.css +1 -0
  24. package/dist/client/assets/index-9aN8py7_.js +1 -0
  25. package/dist/client/assets/index-B-sjMmTS.js +1636 -0
  26. package/dist/client/assets/index-B4HbP3X6.js +1 -0
  27. package/dist/client/assets/index-B9wgN1BV.js +1636 -0
  28. package/dist/client/assets/index-BHlFz1Th.js +1652 -0
  29. package/dist/client/assets/index-BJyvYca7.js +1636 -0
  30. package/dist/client/assets/index-BKBTeJLz.js +1 -0
  31. package/dist/client/assets/index-BN6CbirS.js +1444 -0
  32. package/dist/client/assets/index-BW4_7xR6.js +1460 -0
  33. package/dist/client/assets/index-BaLElA30.js +1 -0
  34. package/dist/client/assets/index-BaQ02V5d.css +1 -0
  35. package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
  36. package/dist/client/assets/index-Bie86XRc.js +1 -0
  37. package/dist/client/assets/index-Bijt5al-.css +1 -0
  38. package/dist/client/assets/index-BikJP2HS.js +1636 -0
  39. package/dist/client/assets/index-BkwrvP-J.js +1 -0
  40. package/dist/client/assets/index-BwVuUJSv.js +1 -0
  41. package/dist/client/assets/index-BweXI4-D.css +1 -0
  42. package/dist/client/assets/index-BwqdHcDE.js +1 -0
  43. package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
  44. package/dist/client/assets/index-C2RmKzem.js +1636 -0
  45. package/dist/client/assets/index-CHz-iKIQ.js +1 -0
  46. package/dist/client/assets/index-CIGl5oW_.js +1646 -0
  47. package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
  48. package/dist/client/assets/index-Ca9UVGK1.js +1 -0
  49. package/dist/client/assets/index-CeWDkmJN.js +1 -0
  50. package/dist/client/assets/index-CpsNq0zm.css +1 -0
  51. package/dist/client/assets/index-CrUS6abD.css +1 -0
  52. package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
  53. package/dist/client/assets/index-D1ktp0pp.js +1 -0
  54. package/dist/client/assets/index-D3BoYpFi.css +1 -0
  55. package/dist/client/assets/index-D59GxlrT.js +1636 -0
  56. package/dist/client/assets/index-D5Wkww8x.css +1 -0
  57. package/dist/client/assets/index-DC94jMGe.js +1 -0
  58. package/dist/client/assets/index-DFcIBkv1.js +1652 -0
  59. package/dist/client/assets/index-DmKj5Jqc.css +1 -0
  60. package/dist/client/assets/index-Dx52i05H.js +1465 -0
  61. package/dist/client/assets/index-L3GnPzmU.css +1 -0
  62. package/dist/client/assets/index-OZADsKet.js +1652 -0
  63. package/dist/client/assets/index-Qt124kj1.js +1652 -0
  64. package/dist/client/assets/index-nHzwQ3EM.js +1 -0
  65. package/dist/client/assets/index-s9Mg6LTO.js +1 -0
  66. package/dist/client/assets/index-ye8oxz8P.js +1 -0
  67. package/dist/client/assets/index-yqJS7tUY.css +1 -0
  68. package/dist/client/favicon.svg +35 -0
  69. package/dist/client/finius-dashboard.png +0 -0
  70. package/dist/client/index.html +38 -0
  71. package/dist/server/app.js +285 -0
  72. package/dist/server/claude.js +124 -0
  73. package/dist/server/codex.js +94 -0
  74. package/dist/server/events.js +12 -0
  75. package/dist/server/index.js +119 -0
  76. package/dist/server/otel.js +231 -0
  77. package/dist/server/pricing-backfill.js +41 -0
  78. package/dist/server/pricing.js +138 -0
  79. package/dist/server/queue.js +35 -0
  80. package/dist/server/storage/blob.js +17 -0
  81. package/dist/server/storage/query-helpers.js +104 -0
  82. package/dist/server/storage/sqlite.js +1167 -0
  83. package/dist/server/transcripts.js +46 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/shared/api-types.js +1 -0
  86. package/package.json +72 -0
@@ -0,0 +1,159 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { intro, log, outro } from "@clack/prompts";
5
+ import { TELEMETRY_HOOK_EVENTS } from "./claude-settings.js";
6
+ import { CONFIG_PATH, loadConfig, normalizeUrl, resolveAuthToken } from "./config.js";
7
+ import { isFiniusOnPath } from "./install.js";
8
+ import { banner, pc } from "./ui.js";
9
+ const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
10
+ // `finius doctor` — checks that the three things that must agree actually do: the configured server
11
+ // URL, the OTEL endpoints in Claude Code's settings, and a reachable healthy server. Prints a
12
+ // checklist and exits non-zero if anything is broken.
13
+ export async function runDoctor() {
14
+ let problems = 0;
15
+ const ok = (label) => log.success(label);
16
+ const warn = (label, hint) => {
17
+ log.warn(`${label}${hint ? `\n${pc.dim(`→ ${hint}`)}` : ""}`);
18
+ };
19
+ const bad = (label, hint) => {
20
+ problems++;
21
+ log.error(`${label}${hint ? `\n${pc.dim(`→ ${hint}`)}` : ""}`);
22
+ };
23
+ const section = (title, detail) => log.message(pc.bold(title) + (detail ? pc.dim(` ${detail}`) : ""));
24
+ banner("doctor");
25
+ intro(pc.bgCyan(pc.black(" Diagnostics ")));
26
+ // --- Config -----------------------------------------------------------------
27
+ section("Config", CONFIG_PATH);
28
+ const config = loadConfig();
29
+ const serverUrl = config?.serverUrl;
30
+ if (serverUrl)
31
+ ok(`serverUrl = ${serverUrl}`);
32
+ else
33
+ bad("no config found", "run `finius setup`");
34
+ const serverOrigin = serverUrl ? originOf(serverUrl) : null;
35
+ // --- Claude Code telemetry --------------------------------------------------
36
+ section("Claude Code telemetry", CLAUDE_SETTINGS_PATH);
37
+ const settings = readSettings();
38
+ if (!settings) {
39
+ bad("settings.json missing or invalid JSON", "run `finius setup`");
40
+ }
41
+ else {
42
+ const env = settings.env ?? {};
43
+ if (env.CLAUDE_CODE_ENABLE_TELEMETRY === "1")
44
+ ok("CLAUDE_CODE_ENABLE_TELEMETRY = 1");
45
+ else
46
+ bad("telemetry not enabled (CLAUDE_CODE_ENABLE_TELEMETRY != 1)", "run `finius setup`");
47
+ const metrics = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
48
+ const logs = env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT;
49
+ metrics ? ok(`metrics endpoint → ${metrics}`) : bad("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT not set");
50
+ logs ? ok(`logs endpoint → ${logs}`) : bad("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT not set");
51
+ // The classic failure: endpoints point at a different origin than the server we serve/configure.
52
+ for (const ep of [metrics, logs].filter(Boolean)) {
53
+ const epOrigin = originOf(ep);
54
+ if (serverOrigin && epOrigin && epOrigin !== serverOrigin) {
55
+ bad(`endpoint ${epOrigin} does not match serverUrl ${serverOrigin}`, "re-run `finius setup` so the endpoints and the served port agree");
56
+ }
57
+ }
58
+ const protocol = env.OTEL_EXPORTER_OTLP_PROTOCOL ?? env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL;
59
+ if (protocol === "http/json")
60
+ ok("protocol = http/json");
61
+ else
62
+ warn(`protocol = ${protocol ?? "(unset → defaults to http/protobuf)"}`, "Finius expects http/json");
63
+ // Hook
64
+ const eventsWithHook = TELEMETRY_HOOK_EVENTS.filter((e) => (settings.hooks?.[e] ?? []).some((g) => g.hooks?.some((h) => h.command?.includes("finius"))));
65
+ if (eventsWithHook.length === TELEMETRY_HOOK_EVENTS.length)
66
+ ok(`hook installed for ${eventsWithHook.join(" + ")}`);
67
+ else if (eventsWithHook.length > 0)
68
+ warn(`hook only on ${eventsWithHook.join(", ")}`, "re-run `finius setup`");
69
+ else
70
+ bad("transcript-upload hook not installed", "run `finius setup`");
71
+ }
72
+ // --- CLI --------------------------------------------------------------------
73
+ section("CLI");
74
+ if (isFiniusOnPath())
75
+ ok("`finius` is on PATH");
76
+ else
77
+ warn("`finius` not on PATH", "install globally with `npm i -g finius` so hooks can run it");
78
+ // --- Server -----------------------------------------------------------------
79
+ section("Server");
80
+ if (serverUrl) {
81
+ const health = await probeHealth(`${serverUrl}/api/health`);
82
+ if (health.status === 200) {
83
+ ok(`reachable at ${serverUrl} (/api/health 200)`);
84
+ // --- Auth (Secure Mode) -------------------------------------------------
85
+ const credential = resolveAuthToken(config);
86
+ if (health.secure) {
87
+ ok("server is in Secure Mode (auth required)");
88
+ if (!credential) {
89
+ bad("server requires auth but no credential is configured", "run `finius setup` to log in");
90
+ }
91
+ else {
92
+ // Verify the credential actually authenticates against a protected endpoint.
93
+ const authed = await probe(`${serverUrl}/api/meta`, credential);
94
+ if (authed === 200)
95
+ ok("configured credential authenticates");
96
+ else if (authed === 401)
97
+ bad("configured credential was rejected (401)", "re-run `finius setup` to refresh it");
98
+ else
99
+ warn(`couldn't verify credential (/api/meta ${authed})`);
100
+ }
101
+ }
102
+ else {
103
+ ok("server is open (no auth)");
104
+ if (credential)
105
+ warn("a credential is configured but the server isn't in Secure Mode", "harmless, but you can clear it");
106
+ }
107
+ }
108
+ else {
109
+ bad(`not reachable at ${serverUrl} (${health.status})`, "start it with `finius serve`");
110
+ }
111
+ }
112
+ // --- Reminders --------------------------------------------------------------
113
+ log.message(pc.bold("Reminders") +
114
+ "\n" +
115
+ pc.dim("• Restart Claude Code after changing settings — env is read at startup.\n" +
116
+ "• Telemetry settings apply to the local CLI, not Claude Code on the web.\n" +
117
+ "• Metrics flush ~10s, events ~5s; give a new session a moment to report."));
118
+ outro(problems === 0 ? pc.green("All good.") : pc.red(`${problems} problem(s) found.`));
119
+ return problems === 0 ? 0 : 1;
120
+ }
121
+ function readSettings() {
122
+ if (!existsSync(CLAUDE_SETTINGS_PATH))
123
+ return null;
124
+ try {
125
+ return JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ function originOf(url) {
132
+ try {
133
+ const u = new URL(normalizeUrl(url) || url);
134
+ return `${u.protocol}//${u.host}`;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ async function probe(url, credential) {
141
+ try {
142
+ const headers = credential ? { authorization: `Bearer ${credential}` } : undefined;
143
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(2_000) });
144
+ return res.status;
145
+ }
146
+ catch (err) {
147
+ return err.name === "TimeoutError" ? "timeout" : "no response";
148
+ }
149
+ }
150
+ async function probeHealth(url) {
151
+ try {
152
+ const res = await fetch(url, { signal: AbortSignal.timeout(2_000) });
153
+ const body = res.ok ? (await res.json().catch(() => ({}))) : {};
154
+ return { status: res.status, secure: !!body.secure };
155
+ }
156
+ catch (err) {
157
+ return { status: err.name === "TimeoutError" ? "timeout" : "no response", secure: false };
158
+ }
159
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { loadConfig, resolveAuthToken, resolveServerUrl } from "./config.js";
3
+ import { resolveIdentity } from "./identity.js";
4
+ async function readStdin() {
5
+ if (process.stdin.isTTY)
6
+ return ""; // run interactively with no piped input — nothing to do
7
+ const chunks = [];
8
+ for await (const chunk of process.stdin)
9
+ chunks.push(chunk);
10
+ return Buffer.concat(chunks).toString("utf8");
11
+ }
12
+ // Invoked by Claude Code's SessionEnd / PreCompact hooks. Reads the session transcript and uploads
13
+ // its contents to the Finius server. Always resolves with exit code 0 — a hook must never block or
14
+ // fail Claude Code, even when the server is down.
15
+ export async function runHook() {
16
+ let input = {};
17
+ try {
18
+ const raw = await readStdin();
19
+ if (raw.trim())
20
+ input = JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return 0; // unparseable stdin — nothing actionable
24
+ }
25
+ const transcriptPath = input.transcript_path;
26
+ if (!transcriptPath)
27
+ return 0;
28
+ let content;
29
+ try {
30
+ content = readFileSync(transcriptPath, "utf8");
31
+ }
32
+ catch {
33
+ return 0; // file may have been cleaned up (e.g. a very short session)
34
+ }
35
+ if (!content.trim())
36
+ return 0;
37
+ const endpoint = `${resolveServerUrl()}/api/import/claude-hook`;
38
+ const headers = { "content-type": "application/json" };
39
+ const config = loadConfig();
40
+ const authToken = resolveAuthToken(config);
41
+ if (authToken)
42
+ headers.authorization = `Bearer ${authToken}`;
43
+ // Attribute the upload to a user (config-confirmed → live Claude account → git), so a hook-imported
44
+ // transcript lands under the same identity as that session's live OTEL metrics.
45
+ const identity = resolveIdentity("claude", config, input.cwd);
46
+ try {
47
+ const res = await fetch(endpoint, {
48
+ method: "POST",
49
+ headers,
50
+ body: JSON.stringify({
51
+ session_id: input.session_id,
52
+ transcript: content,
53
+ cwd: input.cwd,
54
+ hook_event_name: input.hook_event_name,
55
+ user_email: identity?.email,
56
+ user_account_id: identity?.accountId,
57
+ user_id: identity?.userId,
58
+ github_login: identity?.githubLogin,
59
+ display_name: identity?.displayName
60
+ }),
61
+ signal: AbortSignal.timeout(60_000)
62
+ });
63
+ if (!res.ok)
64
+ process.stderr.write(`finius hook: ${endpoint} returned ${res.status}\n`);
65
+ }
66
+ catch (err) {
67
+ process.stderr.write(`finius hook: upload to ${endpoint} failed (${err.message})\n`);
68
+ }
69
+ return 0;
70
+ }
@@ -0,0 +1,163 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ const CLAUDE_CONFIG_PATH = join(homedir(), ".claude.json");
6
+ const CODEX_HOME = process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), ".codex");
7
+ // --- Pure parsers (no I/O — unit-tested) ----------------------------------------------------------
8
+ // Claude Code records the signed-in account in ~/.claude.json under `oauthAccount`. This is the same
9
+ // account live OTEL tags as user.email / user.account_uuid / user.id, so preferring it keeps a
10
+ // hook-imported transcript under the same identity as that session's metrics.
11
+ export function parseClaudeAccount(raw) {
12
+ const json = safeParseObject(raw);
13
+ if (!json)
14
+ return null;
15
+ const acct = asObject(json.oauthAccount);
16
+ const email = asString(acct?.emailAddress);
17
+ const accountId = asString(acct?.accountUuid);
18
+ const userId = asString(json.userID);
19
+ const displayName = asString(acct?.displayName);
20
+ if (!email && !accountId && !userId)
21
+ return null;
22
+ return { email, accountId, userId, displayName, source: "claude" };
23
+ }
24
+ // Codex stores its ChatGPT login in ~/.codex/auth.json: `tokens.account_id` plus a JWT `id_token`
25
+ // whose payload carries the account email. We decode the JWT payload locally (NO signature
26
+ // verification — we only read the `email` claim for attribution, never trust it for auth).
27
+ export function parseCodexAuth(raw) {
28
+ const json = safeParseObject(raw);
29
+ if (!json)
30
+ return null;
31
+ const tokens = asObject(json.tokens);
32
+ const accountId = asString(tokens?.account_id);
33
+ const claims = asString(tokens?.id_token) ? decodeJwtPayload(asString(tokens?.id_token)) : null;
34
+ const email = asString(claims?.email);
35
+ const displayName = asString(claims?.name);
36
+ if (!email && !accountId)
37
+ return null;
38
+ return { email, accountId, displayName, source: "codex" };
39
+ }
40
+ // Decode (WITHOUT verifying) the payload segment of a JWT. Returns null on any malformed input.
41
+ export function decodeJwtPayload(token) {
42
+ const parts = token.split(".");
43
+ if (parts.length < 2)
44
+ return null;
45
+ try {
46
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
47
+ const json = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
48
+ return json && typeof json === "object" ? json : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ // Build an Identity from the GitHub fields `gh api user` returns. Pure so it's testable without `gh`.
55
+ export function githubIdentity(login, name, email) {
56
+ if (!login && !email)
57
+ return null;
58
+ return { githubLogin: login || undefined, displayName: name || undefined, email: email || undefined, source: "github" };
59
+ }
60
+ // --- Live readers (filesystem / subprocess) -------------------------------------------------------
61
+ export function readClaudeAccount() {
62
+ try {
63
+ return parseClaudeAccount(readFileSync(CLAUDE_CONFIG_PATH, "utf8"));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ export function readCodexAccount() {
70
+ try {
71
+ return parseCodexAuth(readFileSync(join(CODEX_HOME, "auth.json"), "utf8"));
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ // The repo-local commit identity (git config user.email) for the directory a session ran in. A weak
78
+ // fallback — it's authorship, not an account — but universal across agents and present in any repo.
79
+ export function readGitIdentity(cwd) {
80
+ const email = gitConfig("user.email", cwd);
81
+ if (!email)
82
+ return null;
83
+ return { email, displayName: gitConfig("user.name", cwd), source: "git" };
84
+ }
85
+ // GitHub identity via the `gh` CLI. `gh api user` (a network call) returns login + name + email — run
86
+ // at setup only, never in the hot hook path. Returns null if `gh` is absent or signed out.
87
+ export function readGithub() {
88
+ try {
89
+ const out = execFileSync("gh", ["api", "user", "--jq", "{login, name, email}"], {
90
+ encoding: "utf8",
91
+ stdio: ["ignore", "pipe", "ignore"],
92
+ timeout: 8_000
93
+ }).trim();
94
+ if (!out)
95
+ return null;
96
+ const json = safeParseObject(out);
97
+ if (!json)
98
+ return null;
99
+ return githubIdentity(asString(json.login), asString(json.name), asString(json.email));
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ // --- Resolution -----------------------------------------------------------------------------------
106
+ // Resolve the identity to attach to an upload, honoring the setup-confirmed config first, then a live
107
+ // account read, then the repo's git identity. The shared GitHub login/display name (captured at setup)
108
+ // are layered on regardless of which slot won, so the server `users` table can be enriched with them.
109
+ export function resolveIdentity(kind, config, cwd) {
110
+ const stored = kind === "claude" ? config?.identity?.claude : config?.identity?.codex;
111
+ const live = kind === "claude" ? readClaudeAccount() : readCodexAccount();
112
+ const base = fromStored(stored) ?? live ?? readGitIdentity(cwd);
113
+ const githubLogin = config?.identity?.githubLogin;
114
+ const displayName = config?.identity?.displayName;
115
+ if (!base) {
116
+ if (!githubLogin && !displayName)
117
+ return null;
118
+ return { githubLogin, displayName, source: "github" };
119
+ }
120
+ return {
121
+ ...base,
122
+ githubLogin: base.githubLogin ?? githubLogin,
123
+ displayName: base.displayName ?? displayName
124
+ };
125
+ }
126
+ function fromStored(stored) {
127
+ if (!stored)
128
+ return null;
129
+ const { email, accountId, userId } = stored;
130
+ if (!email && !accountId && !userId)
131
+ return null;
132
+ return { email, accountId, userId, source: "config" };
133
+ }
134
+ // --- Small narrowing helpers ----------------------------------------------------------------------
135
+ function safeParseObject(raw) {
136
+ try {
137
+ const v = JSON.parse(raw);
138
+ return v && typeof v === "object" ? v : null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ function asObject(v) {
145
+ return v && typeof v === "object" ? v : undefined;
146
+ }
147
+ function asString(v) {
148
+ return typeof v === "string" && v.length > 0 ? v : undefined;
149
+ }
150
+ function gitConfig(key, cwd) {
151
+ try {
152
+ const out = execFileSync("git", ["config", "--get", key], {
153
+ cwd: cwd && existsSync(cwd) ? cwd : undefined,
154
+ encoding: "utf8",
155
+ stdio: ["ignore", "pipe", "ignore"],
156
+ timeout: 2_000
157
+ }).trim();
158
+ return out || undefined;
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ }
@@ -0,0 +1,61 @@
1
+ import { intro, outro, select, spinner } from "@clack/prompts";
2
+ import { backfill, findClaudeTranscripts } from "./backfill.js";
3
+ import { CODEX_SOURCE, findCodexRollouts } from "./codex.js";
4
+ import { resolveServerUrl } from "./config.js";
5
+ import { ask, banner, pc } from "./ui.js";
6
+ export async function runImport(args = []) {
7
+ banner("import");
8
+ intro(pc.bgCyan(pc.black(" Import historical sessions ")));
9
+ const target = await resolveTarget(args[0]);
10
+ if (!target) {
11
+ process.stderr.write(`${pc.red(`Unknown import target: ${args[0]}`)}\n`);
12
+ process.stderr.write(`Use ${pc.cyan("finius import claude")}, ${pc.cyan("finius import codex")}, or ${pc.cyan("finius import all")}.\n`);
13
+ return 1;
14
+ }
15
+ const serverUrl = resolveServerUrl();
16
+ if (!(await checkServer(serverUrl))) {
17
+ outro(pc.yellow(`Server not reachable at ${serverUrl}. Start it with \`finius serve\`, then re-run import.`));
18
+ return 1;
19
+ }
20
+ let failed = 0;
21
+ if (target === "claude" || target === "all") {
22
+ failed += (await backfill(findClaudeTranscripts(), { source: "claude-code-jsonl", format: "claude", label: "Claude sessions" })).failed;
23
+ }
24
+ if (target === "codex" || target === "all") {
25
+ failed += (await backfill(findCodexRollouts(), { source: CODEX_SOURCE, format: "codex", label: "Codex sessions" })).failed;
26
+ }
27
+ outro(failed ? pc.yellow("Import finished with failures.") : pc.green("Import finished."));
28
+ return failed ? 1 : 0;
29
+ }
30
+ async function resolveTarget(value) {
31
+ if (!value) {
32
+ return ask(await select({
33
+ message: "Which historical sessions should be imported?",
34
+ options: [
35
+ { value: "all", label: "Claude + Codex" },
36
+ { value: "claude", label: "Claude only" },
37
+ { value: "codex", label: "Codex only" }
38
+ ]
39
+ }));
40
+ }
41
+ const normalized = value.toLowerCase();
42
+ if (normalized === "claude" || normalized === "codex" || normalized === "all")
43
+ return normalized;
44
+ return null;
45
+ }
46
+ async function checkServer(serverUrl) {
47
+ const s = spinner();
48
+ s.start(`Checking server at ${serverUrl}`);
49
+ try {
50
+ const res = await fetch(`${serverUrl}/api/health`, { signal: AbortSignal.timeout(2_000) });
51
+ if (res.ok) {
52
+ s.stop(`Server reachable at ${pc.cyan(serverUrl)}`);
53
+ return true;
54
+ }
55
+ s.stop(pc.yellow(`Server responded with ${res.status}`));
56
+ }
57
+ catch {
58
+ s.stop(pc.yellow("Server not reachable"));
59
+ }
60
+ return false;
61
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { runCodexHook } from "./codex.js";
3
+ import { CONFIG_PATH, configExists, loadConfig } from "./config.js";
4
+ import { runDoctor } from "./doctor.js";
5
+ import { runHook } from "./hook.js";
6
+ import { runImport } from "./import.js";
7
+ import { runServe } from "./serve.js";
8
+ import { runSetup } from "./setup.js";
9
+ import { banner, pc } from "./ui.js";
10
+ const COMMANDS = [
11
+ ["finius", "Run setup if not configured, otherwise show status & help"],
12
+ ["finius setup [url]", "Configure server URL + Claude Code telemetry env & hook"],
13
+ ["finius import [claude|codex|all]", "Import historical Claude/Codex sessions only"],
14
+ ["finius serve [--port N]", "Start the Finius server (API + dashboard)"],
15
+ ["finius doctor", "Diagnose telemetry/hook/server config and connectivity"],
16
+ ["finius hook", "Internal: upload the current session transcript (run by Claude Code hooks)"],
17
+ ["finius codex-hook", "Internal: upload the current Codex rollout (run by the Codex Stop hook)"],
18
+ ["finius help", "Show this help"]
19
+ ];
20
+ function helpText() {
21
+ const width = Math.max(...COMMANDS.map(([cmd]) => cmd.length));
22
+ const rows = COMMANDS.map(([cmd, desc]) => ` ${pc.cyan(cmd.padEnd(width))} ${pc.dim(desc)}`).join("\n");
23
+ return `${pc.bold("Usage")}\n${rows}\n\n${pc.dim("Config:")} ${CONFIG_PATH}\n`;
24
+ }
25
+ function printStatus() {
26
+ banner();
27
+ const config = loadConfig();
28
+ process.stdout.write(`\n${pc.green("Finius is configured.")}\n\n`);
29
+ process.stdout.write(` ${pc.dim("Server URL")} ${config?.serverUrl ?? "(unknown)"}\n`);
30
+ process.stdout.write(` ${pc.dim("Config")} ${CONFIG_PATH}\n\n`);
31
+ process.stdout.write(`Run ${pc.cyan("finius setup")} to change settings, or ${pc.cyan("finius serve")} to start the server.\n\n`);
32
+ }
33
+ async function main() {
34
+ const [cmd, ...rest] = process.argv.slice(2);
35
+ switch (cmd) {
36
+ case undefined:
37
+ if (!configExists())
38
+ return runSetup();
39
+ printStatus();
40
+ process.stdout.write(helpText());
41
+ return 0;
42
+ case "setup":
43
+ return runSetup(rest);
44
+ case "serve":
45
+ return runServe(rest);
46
+ case "doctor":
47
+ return runDoctor();
48
+ case "import":
49
+ return runImport(rest);
50
+ case "hook":
51
+ return runHook();
52
+ case "codex-hook":
53
+ return runCodexHook();
54
+ case "help":
55
+ case "--help":
56
+ case "-h":
57
+ banner();
58
+ process.stdout.write(`\n${helpText()}`);
59
+ return 0;
60
+ default:
61
+ process.stderr.write(`${pc.red(`Unknown command: ${cmd}`)}\n\n${helpText()}`);
62
+ return 1;
63
+ }
64
+ }
65
+ main()
66
+ .then((code) => process.exit(code ?? 0))
67
+ .catch((err) => {
68
+ process.stderr.write(`finius: ${err instanceof Error ? err.message : String(err)}\n`);
69
+ process.exit(1);
70
+ });
@@ -0,0 +1,23 @@
1
+ import { execSync } from "node:child_process";
2
+ // Is the `finius` command resolvable on PATH? (i.e. installed globally, not just running via npx)
3
+ export function isFiniusOnPath() {
4
+ const probe = process.platform === "win32" ? "where finius" : "command -v finius";
5
+ try {
6
+ execSync(probe, { stdio: "ignore" });
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ // Install finius globally so the bare `finius` command works everywhere (including from Claude Code
14
+ // hooks). Returns true once `finius` is on PATH. Output is streamed so the user sees npm's progress.
15
+ export function installGlobally() {
16
+ try {
17
+ execSync("npm install -g @cliftonc/finius", { stdio: "inherit" });
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ return isFiniusOnPath();
23
+ }
@@ -0,0 +1,14 @@
1
+ import { randomBytes, randomInt } from "node:crypto";
2
+ import { adjectives, animals, colors } from "unique-names-generator";
3
+ // Generate a memorable word-password like `blue-happy-otter` for Secure Mode. We draw the words from
4
+ // unique-names-generator's curated dictionaries but pick with node:crypto (randomInt) rather than the
5
+ // library's Math.random-based generator, so the password is cryptographically random — it guards a
6
+ // server. Three words from these lists is ~30 bits; the real protection is that it's only ever used
7
+ // once to mint a revocable session token (it never travels on normal requests).
8
+ export function generatePassword() {
9
+ const pick = (list) => list[randomInt(list.length)];
10
+ return [pick(colors), pick(adjectives), pick(animals)].join("-").toLowerCase();
11
+ }
12
+ export function generateAuthToken() {
13
+ return randomBytes(32).toString("hex");
14
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { FINIUS_HOME, loadConfig, saveConfig } from "./config.js";
4
+ import { startServer } from "../server/index.js";
5
+ import { generateAuthToken } from "./password.js";
6
+ // `finius serve [--port N]` — start the single-process server (API + built dashboard). Data is kept
7
+ // under ~/.finius by default so an `npx`/globally-installed CLI has a stable home, independent of cwd.
8
+ export async function runServe(argv) {
9
+ let port;
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const arg = argv[i];
12
+ if ((arg === "--port" || arg === "-p") && argv[i + 1])
13
+ port = Number(argv[++i]);
14
+ else if (arg.startsWith("--port="))
15
+ port = Number(arg.slice("--port=".length));
16
+ }
17
+ if (port !== undefined && !Number.isInteger(port)) {
18
+ process.stderr.write("finius serve: --port must be an integer\n");
19
+ return 1;
20
+ }
21
+ // Without an explicit --port, bind to the port from the configured server URL (set in `finius
22
+ // setup`) so the dashboard the CLI points users at is the one we actually serve.
23
+ if (port === undefined)
24
+ port = portFromConfig();
25
+ const dataDir = join(FINIUS_HOME, "data");
26
+ mkdirSync(dataDir, { recursive: true });
27
+ // Secure Mode: if `finius setup` saved a master password on this (owner) machine, run the server
28
+ // locked. An explicit env var still wins (handy for one-off overrides).
29
+ const config = loadConfig();
30
+ const authSecret = process.env.FINIUS_AUTH_PASSWORD ?? config?.authPassword;
31
+ let initialAuthToken = config?.authToken;
32
+ if (authSecret && config?.authPassword && !initialAuthToken) {
33
+ initialAuthToken = generateAuthToken();
34
+ saveConfig({ ...config, authToken: initialAuthToken });
35
+ }
36
+ startServer({
37
+ port,
38
+ dbPath: process.env.FINIUS_DB_PATH ?? join(dataDir, "finius.sqlite"),
39
+ blobDir: process.env.FINIUS_BLOB_DIR ?? join(FINIUS_HOME, "transcripts"),
40
+ authSecret,
41
+ initialAuthToken
42
+ });
43
+ // The server runs until the process is signalled. Never resolve, so the CLI entrypoint doesn't
44
+ // `process.exit()` out from under the listening server. startServer() installs SIGINT/SIGTERM
45
+ // handlers that close the DB and exit cleanly.
46
+ await new Promise(() => { });
47
+ return 0; // unreachable
48
+ }
49
+ // Derive the listen port from the saved serverUrl (e.g. http://localhost:8787 → 8787).
50
+ function portFromConfig() {
51
+ const config = loadConfig();
52
+ if (!config)
53
+ return undefined;
54
+ try {
55
+ const url = new URL(config.serverUrl);
56
+ if (url.port)
57
+ return Number(url.port);
58
+ return url.protocol === "https:" ? 443 : 80;
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }