@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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/branding.js +28 -0
- package/dist/cli/backfill.js +122 -0
- package/dist/cli/claude-settings.js +54 -0
- package/dist/cli/codex-config.js +60 -0
- package/dist/cli/codex.js +97 -0
- package/dist/cli/config.js +41 -0
- package/dist/cli/doctor.js +159 -0
- package/dist/cli/hook.js +70 -0
- package/dist/cli/identity.js +163 -0
- package/dist/cli/import.js +61 -0
- package/dist/cli/index.js +70 -0
- package/dist/cli/install.js +23 -0
- package/dist/cli/password.js +14 -0
- package/dist/cli/serve.js +63 -0
- package/dist/cli/setup.js +314 -0
- package/dist/cli/ui.js +15 -0
- package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
- package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
- package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
- package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
- package/dist/client/assets/index-6OIY_8fO.css +1 -0
- package/dist/client/assets/index-9aN8py7_.js +1 -0
- package/dist/client/assets/index-B-sjMmTS.js +1636 -0
- package/dist/client/assets/index-B4HbP3X6.js +1 -0
- package/dist/client/assets/index-B9wgN1BV.js +1636 -0
- package/dist/client/assets/index-BHlFz1Th.js +1652 -0
- package/dist/client/assets/index-BJyvYca7.js +1636 -0
- package/dist/client/assets/index-BKBTeJLz.js +1 -0
- package/dist/client/assets/index-BN6CbirS.js +1444 -0
- package/dist/client/assets/index-BW4_7xR6.js +1460 -0
- package/dist/client/assets/index-BaLElA30.js +1 -0
- package/dist/client/assets/index-BaQ02V5d.css +1 -0
- package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
- package/dist/client/assets/index-Bie86XRc.js +1 -0
- package/dist/client/assets/index-Bijt5al-.css +1 -0
- package/dist/client/assets/index-BikJP2HS.js +1636 -0
- package/dist/client/assets/index-BkwrvP-J.js +1 -0
- package/dist/client/assets/index-BwVuUJSv.js +1 -0
- package/dist/client/assets/index-BweXI4-D.css +1 -0
- package/dist/client/assets/index-BwqdHcDE.js +1 -0
- package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
- package/dist/client/assets/index-C2RmKzem.js +1636 -0
- package/dist/client/assets/index-CHz-iKIQ.js +1 -0
- package/dist/client/assets/index-CIGl5oW_.js +1646 -0
- package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
- package/dist/client/assets/index-Ca9UVGK1.js +1 -0
- package/dist/client/assets/index-CeWDkmJN.js +1 -0
- package/dist/client/assets/index-CpsNq0zm.css +1 -0
- package/dist/client/assets/index-CrUS6abD.css +1 -0
- package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
- package/dist/client/assets/index-D1ktp0pp.js +1 -0
- package/dist/client/assets/index-D3BoYpFi.css +1 -0
- package/dist/client/assets/index-D59GxlrT.js +1636 -0
- package/dist/client/assets/index-D5Wkww8x.css +1 -0
- package/dist/client/assets/index-DC94jMGe.js +1 -0
- package/dist/client/assets/index-DFcIBkv1.js +1652 -0
- package/dist/client/assets/index-DmKj5Jqc.css +1 -0
- package/dist/client/assets/index-Dx52i05H.js +1465 -0
- package/dist/client/assets/index-L3GnPzmU.css +1 -0
- package/dist/client/assets/index-OZADsKet.js +1652 -0
- package/dist/client/assets/index-Qt124kj1.js +1652 -0
- package/dist/client/assets/index-nHzwQ3EM.js +1 -0
- package/dist/client/assets/index-s9Mg6LTO.js +1 -0
- package/dist/client/assets/index-ye8oxz8P.js +1 -0
- package/dist/client/assets/index-yqJS7tUY.css +1 -0
- package/dist/client/favicon.svg +35 -0
- package/dist/client/finius-dashboard.png +0 -0
- package/dist/client/index.html +38 -0
- package/dist/server/app.js +285 -0
- package/dist/server/claude.js +124 -0
- package/dist/server/codex.js +94 -0
- package/dist/server/events.js +12 -0
- package/dist/server/index.js +119 -0
- package/dist/server/otel.js +231 -0
- package/dist/server/pricing-backfill.js +41 -0
- package/dist/server/pricing.js +138 -0
- package/dist/server/queue.js +35 -0
- package/dist/server/storage/blob.js +17 -0
- package/dist/server/storage/query-helpers.js +104 -0
- package/dist/server/storage/sqlite.js +1167 -0
- package/dist/server/transcripts.js +46 -0
- package/dist/server/types.js +1 -0
- package/dist/shared/api-types.js +1 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clifton Cunningham
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="public/favicon.svg" alt="Finius" width="96" height="96" />
|
|
4
|
+
|
|
5
|
+
# Finius
|
|
6
|
+
|
|
7
|
+
**Local-first Claude Code usage & cost tracker.**
|
|
8
|
+
|
|
9
|
+
A [Hono](https://hono.dev) server ingests OTLP HTTP/JSON metrics & logs (and JSONL transcripts) into
|
|
10
|
+
SQLite; a React + Vite dashboard renders cost, token, session, person, and model breakdowns with live
|
|
11
|
+
SSE updates. Everything runs on your machine — no data leaves your laptop.
|
|
12
|
+
|
|
13
|
+
<img src="public/finius-dashboard.png" alt="Finius dashboard showing local usage and cost analytics" width="900" />
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
## Quick start (local)
|
|
18
|
+
|
|
19
|
+
The fastest way to get running locally is the `finius` CLI. You need **Node 22.5+** (Finius uses the
|
|
20
|
+
built-in `node:sqlite` module; developed on Node 24).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx @cliftonc/finius # first run: installs finius globally, then walks you through setup
|
|
24
|
+
finius serve # start the server + dashboard at http://localhost:8787
|
|
25
|
+
finius import all # optional: import old Claude Code + Codex sessions
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then launch Claude Code in a new terminal and start coding — the dashboard at
|
|
29
|
+
**http://localhost:8787** updates live as telemetry arrives.
|
|
30
|
+
|
|
31
|
+
That's it. Three things just happened:
|
|
32
|
+
|
|
33
|
+
1. **`npx @cliftonc/finius`** installed `finius` globally and ran `finius setup`, which saved
|
|
34
|
+
`~/.finius/config.json` and — with your consent — edited `~/.claude/settings.json` to add the OTLP
|
|
35
|
+
env vars plus a `SessionEnd` + `PreCompact` hook (`finius hook`) that uploads each session
|
|
36
|
+
transcript.
|
|
37
|
+
2. **`finius serve`** started a single process exposing the API **and** the dashboard on one port.
|
|
38
|
+
Its data lives under `~/.finius` (override with `FINIUS_DB_PATH` / `FINIUS_BLOB_DIR`).
|
|
39
|
+
3. Any Claude Code session you run now reports usage to that local server. If you ran
|
|
40
|
+
`finius import all`, Finius also backfilled historical Claude Code and Codex transcripts already on
|
|
41
|
+
disk. Use `finius import claude` or `finius import codex` to import only one agent.
|
|
42
|
+
|
|
43
|
+
Re-run `finius setup` any time to reconfigure, or `finius doctor` to diagnose telemetry that isn't
|
|
44
|
+
arriving.
|
|
45
|
+
|
|
46
|
+
## Running from source (development)
|
|
47
|
+
|
|
48
|
+
Prefer to hack on Finius itself? Clone the repo and run the dev servers.
|
|
49
|
+
|
|
50
|
+
### 1. Install & start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install
|
|
54
|
+
npm run dev
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`npm run dev` runs the API and UI together (via `concurrently`):
|
|
58
|
+
|
|
59
|
+
- **UI** → http://localhost:5173 (Vite dev server; proxies `/api`, `/otlp`, `/events` to the API)
|
|
60
|
+
- **API** → http://localhost:8787
|
|
61
|
+
|
|
62
|
+
Run them separately if you prefer: `npm run dev:server` (API only) or `npm run dev:client` (UI only).
|
|
63
|
+
|
|
64
|
+
### 2. Point Claude Code at the server
|
|
65
|
+
|
|
66
|
+
In the shell where you launch Claude Code, use the bundled helper — it checks the server is up,
|
|
67
|
+
exports the OTLP env vars, then runs `claude`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
./scripts/run-claude.sh # forwards any extra args to `claude`
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or export the variables manually:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
export CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
77
|
+
export OTEL_METRICS_EXPORTER=otlp
|
|
78
|
+
export OTEL_LOGS_EXPORTER=otlp
|
|
79
|
+
export OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json
|
|
80
|
+
export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL=http/json
|
|
81
|
+
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://localhost:8787/otlp/v1/metrics
|
|
82
|
+
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:8787/otlp/v1/logs
|
|
83
|
+
claude
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run a Claude Code session and the dashboard updates live (SSE) as telemetry arrives.
|
|
87
|
+
|
|
88
|
+
### 3. (Optional) production build
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run build # tsc -> dist + vite build -> dist/client
|
|
92
|
+
npm start # node dist/server/index.js, serves the built UI from dist/client on :8787
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
When a build exists, `npm start` serves the UI and API from the single port http://localhost:8787.
|
|
96
|
+
|
|
97
|
+
## Using the dashboard
|
|
98
|
+
|
|
99
|
+
- **Home** — KPIs (cost, tokens, cache, lines, edits, sessions, people), tokens/cost/lines/edits
|
|
100
|
+
charts over time, plus Models / Users / Sources breakdowns.
|
|
101
|
+
- **Sessions**, **People**, **Models** — lists ranked by recency / cost. Click any row (or any Home
|
|
102
|
+
breakdown row) to drill into the Home view filtered to that session, person, or model.
|
|
103
|
+
- **Filters** — time range, source, user, and model selects apply everywhere. All view and filter
|
|
104
|
+
state lives in the URL query string, so any view is shareable/bookmarkable and back/forward works.
|
|
105
|
+
|
|
106
|
+
## Importing transcripts
|
|
107
|
+
|
|
108
|
+
Besides live OTLP telemetry, you can backfill from JSONL transcripts:
|
|
109
|
+
|
|
110
|
+
- `POST /api/import/jsonl` — body `{ content, source?, sessionId? }` (or raw JSONL text).
|
|
111
|
+
- `POST /api/import/claude-hook` — body `{ transcript_path, session_id?, cwd? }`; reads a local
|
|
112
|
+
Claude Code transcript file (restricted to `~/.claude/projects` or the given `cwd`).
|
|
113
|
+
|
|
114
|
+
Imports are idempotent — re-sending the same file (matched by content hash) is detected and skipped.
|
|
115
|
+
The original transcript is stored as a file and can be viewed from the session drill-down
|
|
116
|
+
(`GET /api/sessions/:id/transcript`).
|
|
117
|
+
|
|
118
|
+
## Storage
|
|
119
|
+
|
|
120
|
+
Data is stored in `data/finius.sqlite` by default. Override with `FINIUS_DB_PATH=/path/to/db.sqlite`.
|
|
121
|
+
Override the API port with `PORT`. Imported transcript files live under `<db-dir>/transcripts`
|
|
122
|
+
(override with `FINIUS_BLOB_DIR`).
|
|
123
|
+
|
|
124
|
+
Dashboard reads are served from a pre-aggregated hourly `metric_rollup`; raw `metric_points` keep
|
|
125
|
+
the full-resolution data for the live view and drill-downs.
|
|
126
|
+
|
|
127
|
+
### Raw batch retention
|
|
128
|
+
|
|
129
|
+
The full OTLP payload of each ingest batch is kept in `raw_batches` only to allow replaying history
|
|
130
|
+
into new metric classifications. Control it with:
|
|
131
|
+
|
|
132
|
+
- `FINIUS_RAW_PAYLOADS=retain` (default) | `off` — `off` keeps only the dedup hash, not the payload.
|
|
133
|
+
- `FINIUS_RAW_RETENTION_DAYS=7` (default) — age cutoff used by the prune endpoint below.
|
|
134
|
+
- `FINIUS_CRON_TOKEN=<secret>` — enables `POST /api/maintenance/prune-raw-batches`. Without it the
|
|
135
|
+
endpoint is disabled (returns 503). Wire a cron to it:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
curl -fsS -X POST -H "Authorization: Bearer $FINIUS_CRON_TOKEN" \
|
|
139
|
+
http://127.0.0.1:8787/api/maintenance/prune-raw-batches
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Other commands
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm test # vitest run
|
|
146
|
+
npm run typecheck # tsc --noEmit (strict)
|
|
147
|
+
```
|
package/dist/branding.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
// Shared brand presentation with NO interactive-prompt dependency, so both the CLI (cli/ui.ts) and
|
|
3
|
+
// the long-running server (server/index.ts) can render the wordmark without pulling in @clack/prompts.
|
|
4
|
+
export const BANNER_LINES = [
|
|
5
|
+
"███████╗██╗███╗ ██╗██╗██╗ ██╗███████╗",
|
|
6
|
+
"██╔════╝██║████╗ ██║██║██║ ██║██╔════╝",
|
|
7
|
+
"█████╗ ██║██╔██╗ ██║██║██║ ██║███████╗",
|
|
8
|
+
"██╔══╝ ██║██║╚██╗██║██║██║ ██║╚════██║",
|
|
9
|
+
"██║ ██║██║ ╚████║██║╚██████╔╝███████║",
|
|
10
|
+
"╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝"
|
|
11
|
+
];
|
|
12
|
+
export const TAGLINE = "Local-first AI coding usage & cost tracker";
|
|
13
|
+
// The colored wordmark + tagline. `subtitle` names the running command (e.g. "setup", "serve").
|
|
14
|
+
export function renderBanner(subtitle) {
|
|
15
|
+
const art = BANNER_LINES.map((line) => pc.cyan(line)).join("\n");
|
|
16
|
+
const sub = subtitle ? ` ${pc.dim("·")} ${pc.cyan(subtitle)}` : "";
|
|
17
|
+
return `\n${art}\n${pc.dim(TAGLINE)}${sub}\n`;
|
|
18
|
+
}
|
|
19
|
+
export function banner(subtitle) {
|
|
20
|
+
process.stdout.write(renderBanner(subtitle));
|
|
21
|
+
}
|
|
22
|
+
// An aligned label/value block (dim labels, padded to a common width). ANSI-safe because padding is
|
|
23
|
+
// computed on the plain label text before any coloring is applied to the value.
|
|
24
|
+
export function panel(rows) {
|
|
25
|
+
const width = Math.max(0, ...rows.map(([label]) => label.length));
|
|
26
|
+
return rows.map(([label, value]) => ` ${pc.dim(label.padEnd(width))} ${value}`).join("\n");
|
|
27
|
+
}
|
|
28
|
+
export { pc };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { log, spinner } from "@clack/prompts";
|
|
5
|
+
import { loadConfig, resolveAuthToken, resolveServerUrl } from "./config.js";
|
|
6
|
+
import { resolveIdentity } from "./identity.js";
|
|
7
|
+
import { pc } from "./ui.js";
|
|
8
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
9
|
+
// Recursively collect files under `dir` whose basename matches `match`. Safe on missing dirs.
|
|
10
|
+
export function walkFiles(dir, match) {
|
|
11
|
+
const out = [];
|
|
12
|
+
const walk = (d) => {
|
|
13
|
+
let names;
|
|
14
|
+
try {
|
|
15
|
+
names = readdirSync(d);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
const p = join(d, name);
|
|
22
|
+
let st;
|
|
23
|
+
try {
|
|
24
|
+
st = statSync(p);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (st.isDirectory())
|
|
30
|
+
walk(p);
|
|
31
|
+
else if (st.isFile() && match(name))
|
|
32
|
+
out.push(p);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
walk(dir);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
// Every Claude Code transcript on disk (~/.claude/projects/**/<session>.jsonl).
|
|
39
|
+
export function findClaudeTranscripts() {
|
|
40
|
+
return walkFiles(CLAUDE_PROJECTS_DIR, (n) => n.endsWith(".jsonl"));
|
|
41
|
+
}
|
|
42
|
+
// Upload one transcript file to the server. Idempotent server-side (content hash / replace-by-session).
|
|
43
|
+
// Attributes the upload to a user: an explicit `identity` if given, else resolved from config by format
|
|
44
|
+
// (so both the setup backfill and the Codex hook attach a user with no extra threading).
|
|
45
|
+
export async function uploadTranscript(path, opts) {
|
|
46
|
+
let content;
|
|
47
|
+
try {
|
|
48
|
+
content = readFileSync(path, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "failed";
|
|
52
|
+
}
|
|
53
|
+
if (!content.trim())
|
|
54
|
+
return "failed";
|
|
55
|
+
const config = loadConfig();
|
|
56
|
+
const endpoint = `${resolveServerUrl()}/api/import/jsonl`;
|
|
57
|
+
const headers = { "content-type": "application/json" };
|
|
58
|
+
const token = resolveAuthToken(config);
|
|
59
|
+
if (token)
|
|
60
|
+
headers.authorization = `Bearer ${token}`;
|
|
61
|
+
const identity = opts.identity ?? resolveIdentity(opts.format === "codex" ? "codex" : "claude", config, opts.cwd);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(endpoint, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers,
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
content,
|
|
68
|
+
source: opts.source,
|
|
69
|
+
format: opts.format,
|
|
70
|
+
sessionId: opts.sessionId,
|
|
71
|
+
userEmail: identity?.email,
|
|
72
|
+
userAccountId: identity?.accountId,
|
|
73
|
+
userId: identity?.userId,
|
|
74
|
+
githubLogin: identity?.githubLogin,
|
|
75
|
+
displayName: identity?.displayName
|
|
76
|
+
}),
|
|
77
|
+
signal: AbortSignal.timeout(60_000)
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
process.stderr.write(`\nfinius: ${endpoint} returned ${res.status}\n`);
|
|
81
|
+
return "failed";
|
|
82
|
+
}
|
|
83
|
+
const body = (await res.json().catch(() => ({})));
|
|
84
|
+
return body.duplicate ? "duplicate" : "ok";
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
process.stderr.write(`\nfinius: upload to ${endpoint} failed (${err.message})\n`);
|
|
88
|
+
return "failed";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Upload a list of transcripts ONE AT A TIME — never concurrently, which would swamp the server with
|
|
92
|
+
// large bodies — rendering a live spinner with progress. Returns tallied outcomes.
|
|
93
|
+
export async function backfill(files, opts) {
|
|
94
|
+
const result = { uploaded: 0, duplicate: 0, failed: 0, total: files.length };
|
|
95
|
+
if (files.length === 0) {
|
|
96
|
+
log.info(`${opts.label}: nothing to import.`);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
const total = files.length;
|
|
100
|
+
const s = spinner();
|
|
101
|
+
s.start(`Importing ${opts.label}`);
|
|
102
|
+
let done = 0;
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const outcome = await uploadTranscript(file, { source: opts.source, format: opts.format });
|
|
105
|
+
if (outcome === "ok")
|
|
106
|
+
result.uploaded++;
|
|
107
|
+
else if (outcome === "duplicate")
|
|
108
|
+
result.duplicate++;
|
|
109
|
+
else
|
|
110
|
+
result.failed++;
|
|
111
|
+
done++;
|
|
112
|
+
s.message(`Importing ${opts.label} ${pc.dim(`(${done}/${total})`)}`);
|
|
113
|
+
}
|
|
114
|
+
const summary = `${opts.label}: ${pc.green(`${result.uploaded} new`)}, ${result.duplicate} already-present` +
|
|
115
|
+
(result.failed > 0 ? `, ${pc.red(`${result.failed} failed`)}` : "") +
|
|
116
|
+
` (of ${result.total}).`;
|
|
117
|
+
s.stop(summary);
|
|
118
|
+
if (result.failed > 0) {
|
|
119
|
+
log.warn("Failures usually mean the server isn't running — `finius serve`, then re-run setup.");
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure helpers for merging Finius configuration into Claude Code's ~/.claude/settings.json.
|
|
2
|
+
// Kept side-effect-free (no fs) so they're easy to unit test; setup.ts handles the actual read/write.
|
|
3
|
+
// Hook events that upload the session transcript. SessionEnd covers /clear and exit; PreCompact
|
|
4
|
+
// captures the full transcript before a compaction shrinks the context window.
|
|
5
|
+
export const TELEMETRY_HOOK_EVENTS = ["SessionEnd", "PreCompact"];
|
|
6
|
+
export const HOOK_TIMEOUT_SECONDS = 60;
|
|
7
|
+
// Substring used to recognize (and replace) a previously-installed Finius hook. The package is named
|
|
8
|
+
// "finius" and is virtually always installed under a path containing it, so this reliably matches our
|
|
9
|
+
// own hook command without touching unrelated user hooks.
|
|
10
|
+
const FINIUS_MARKER = "finius";
|
|
11
|
+
// Point Claude Code's OTLP exporters at the given Finius server. Preserves any existing env vars.
|
|
12
|
+
// When `authToken` is set (Secure Mode), also send it on every OTLP export via the standard
|
|
13
|
+
// Authorization header; when absent, strip any header we previously wrote so toggling auth off cleans
|
|
14
|
+
// up. Tokens are URL-safe hex, so the single space in "Bearer " is the only special char and the OTel
|
|
15
|
+
// JS header parser (comma/equals-delimited key=value) preserves it.
|
|
16
|
+
export function withTelemetryEnv(settings, serverUrl, authToken) {
|
|
17
|
+
settings.env = {
|
|
18
|
+
...settings.env,
|
|
19
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
20
|
+
OTEL_METRICS_EXPORTER: "otlp",
|
|
21
|
+
OTEL_LOGS_EXPORTER: "otlp",
|
|
22
|
+
// Finius ingests OTLP/JSON. Set both the generic and per-signal protocol keys — some OTel SDK
|
|
23
|
+
// versions only read the generic one, and its default (http/protobuf) would be rejected.
|
|
24
|
+
OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
|
|
25
|
+
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: "http/json",
|
|
26
|
+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json",
|
|
27
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: `${serverUrl}/otlp/v1/metrics`,
|
|
28
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${serverUrl}/otlp/v1/logs`,
|
|
29
|
+
// Flush quickly so usage shows up in seconds rather than the 60s/5s defaults.
|
|
30
|
+
OTEL_METRIC_EXPORT_INTERVAL: "10000",
|
|
31
|
+
OTEL_LOGS_EXPORT_INTERVAL: "5000"
|
|
32
|
+
};
|
|
33
|
+
if (authToken) {
|
|
34
|
+
settings.env.OTEL_EXPORTER_OTLP_HEADERS = `Authorization=Bearer ${authToken}`;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
delete settings.env.OTEL_EXPORTER_OTLP_HEADERS;
|
|
38
|
+
}
|
|
39
|
+
return settings;
|
|
40
|
+
}
|
|
41
|
+
// Install the Finius transcript-upload hook for each telemetry event. Any prior Finius hook group is
|
|
42
|
+
// dropped first so re-running setup never duplicates the entry; unrelated user hooks are preserved.
|
|
43
|
+
export function withFiniusHook(settings, command) {
|
|
44
|
+
const group = { hooks: [{ type: "command", command, timeout: HOOK_TIMEOUT_SECONDS }] };
|
|
45
|
+
const hooks = settings.hooks ?? (settings.hooks = {});
|
|
46
|
+
for (const event of TELEMETRY_HOOK_EVENTS) {
|
|
47
|
+
const kept = (hooks[event] ?? []).filter((g) => !isFiniusGroup(g));
|
|
48
|
+
hooks[event] = [...kept, group];
|
|
49
|
+
}
|
|
50
|
+
return settings;
|
|
51
|
+
}
|
|
52
|
+
function isFiniusGroup(group) {
|
|
53
|
+
return group.hooks?.some((h) => h.command?.includes(FINIUS_MARKER)) ?? false;
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pure helpers for merging Finius config into OpenAI Codex's ~/.codex/config.toml. Like
|
|
2
|
+
// claude-settings.ts these are side-effect-free (no fs) so they unit-test cleanly; codex.ts does the
|
|
3
|
+
// read/write. Codex's config is TOML and we ship no TOML parser, so Finius owns a single clearly
|
|
4
|
+
// delimited block appended at EOF — valid TOML because it only opens fresh top-level tables. Re-running
|
|
5
|
+
// setup replaces that block in place; existing user tables are never touched, and we refuse to add a
|
|
6
|
+
// table the user already defines (so we can never produce a duplicate-key parse error).
|
|
7
|
+
export const CODEX_HOOK_EVENT = "Stop";
|
|
8
|
+
const BLOCK_BEGIN = "# >>> finius (managed — safe to delete this whole block) >>>";
|
|
9
|
+
const BLOCK_END = "# <<< finius (managed) <<<";
|
|
10
|
+
export function withFiniusCodexBlock(toml, opts) {
|
|
11
|
+
const outside = stripFiniusBlock(toml);
|
|
12
|
+
const hasUserHooks = /^\s*\[\[?\s*hooks(\.|\s*\])/m.test(outside);
|
|
13
|
+
const hasUserOtel = /^\s*\[\s*otel\s*\]/m.test(outside);
|
|
14
|
+
const wantHook = !!opts.hookCommand;
|
|
15
|
+
const wantOtel = !!opts.otlpLogsEndpoint;
|
|
16
|
+
const addedHook = wantHook && !hasUserHooks;
|
|
17
|
+
const addedOtel = wantOtel && !hasUserOtel;
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (addedHook) {
|
|
20
|
+
parts.push(`[[hooks.${CODEX_HOOK_EVENT}]]`, `[[hooks.${CODEX_HOOK_EVENT}.hooks]]`, `type = "command"`, `command = ${tomlString(opts.hookCommand)}`, "");
|
|
21
|
+
}
|
|
22
|
+
if (addedOtel) {
|
|
23
|
+
const headers = opts.authToken
|
|
24
|
+
? `, headers = { Authorization = ${tomlString(`Bearer ${opts.authToken}`)} }`
|
|
25
|
+
: "";
|
|
26
|
+
parts.push("[otel]", `environment = "finius"`, "log_user_prompt = false", `exporter = { otlp-http = { endpoint = ${tomlString(opts.otlpLogsEndpoint)}, protocol = "json"${headers} } }`, "");
|
|
27
|
+
}
|
|
28
|
+
const result = {
|
|
29
|
+
toml: outside,
|
|
30
|
+
changed: outside !== toml,
|
|
31
|
+
addedHook,
|
|
32
|
+
addedOtel,
|
|
33
|
+
skippedHook: wantHook && hasUserHooks,
|
|
34
|
+
skippedOtel: wantOtel && hasUserOtel
|
|
35
|
+
};
|
|
36
|
+
if (parts.length === 0)
|
|
37
|
+
return result;
|
|
38
|
+
const block = [BLOCK_BEGIN, ...parts, BLOCK_END].join("\n");
|
|
39
|
+
const base = outside.replace(/\s*$/, "");
|
|
40
|
+
result.toml = base.length ? `${base}\n\n${block}\n` : `${block}\n`;
|
|
41
|
+
result.changed = true;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
// Remove a previously-written Finius block (between the markers), leaving the rest untouched.
|
|
45
|
+
export function stripFiniusBlock(toml) {
|
|
46
|
+
const begin = toml.indexOf(BLOCK_BEGIN);
|
|
47
|
+
if (begin === -1)
|
|
48
|
+
return toml;
|
|
49
|
+
const endIdx = toml.indexOf(BLOCK_END, begin);
|
|
50
|
+
const before = toml.slice(0, begin).replace(/\s*$/, "");
|
|
51
|
+
const after = endIdx === -1 ? "" : toml.slice(endIdx + BLOCK_END.length).replace(/^\s*\n/, "");
|
|
52
|
+
const joined = after ? `${before}\n${after}` : `${before}\n`;
|
|
53
|
+
return joined.replace(/\n{3,}/g, "\n\n");
|
|
54
|
+
}
|
|
55
|
+
export function hasFiniusCodexBlock(toml) {
|
|
56
|
+
return toml.includes(BLOCK_BEGIN);
|
|
57
|
+
}
|
|
58
|
+
function tomlString(value) {
|
|
59
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { withFiniusCodexBlock } from "./codex-config.js";
|
|
5
|
+
import { uploadTranscript, walkFiles } from "./backfill.js";
|
|
6
|
+
// Codex stores everything under ~/.codex (override with CODEX_HOME, as the app itself does).
|
|
7
|
+
export const CODEX_HOME = process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), ".codex");
|
|
8
|
+
export const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
|
|
9
|
+
const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
|
|
10
|
+
export const CODEX_SOURCE = "codex-cli-jsonl";
|
|
11
|
+
// Codex is "installed" if its home dir or the macOS app bundle is present.
|
|
12
|
+
export function isCodexInstalled() {
|
|
13
|
+
return existsSync(CODEX_HOME) || existsSync("/Applications/Codex.app");
|
|
14
|
+
}
|
|
15
|
+
// Apply (or refresh) the Finius-managed block in ~/.codex/config.toml. Pure merge in codex-config.ts;
|
|
16
|
+
// this just does the read/write. Creates the file if missing.
|
|
17
|
+
export function applyCodexConfig(opts) {
|
|
18
|
+
const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf8") : "";
|
|
19
|
+
const result = withFiniusCodexBlock(current, opts);
|
|
20
|
+
if (result.changed) {
|
|
21
|
+
mkdirSync(dirname(CODEX_CONFIG_PATH), { recursive: true });
|
|
22
|
+
writeFileSync(CODEX_CONFIG_PATH, result.toml, "utf8");
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
// Every Codex rollout transcript on disk (~/.codex/sessions/**/rollout-*.jsonl).
|
|
27
|
+
export function findCodexRollouts() {
|
|
28
|
+
return walkFiles(CODEX_SESSIONS_DIR, (n) => n.startsWith("rollout-") && n.endsWith(".jsonl"));
|
|
29
|
+
}
|
|
30
|
+
// `finius codex-hook` — invoked by Codex's Stop hook. Codex passes a JSON payload on stdin (shape not
|
|
31
|
+
// fully documented), so we read it best-effort but don't depend on it: we locate the session's rollout
|
|
32
|
+
// file ourselves (by session id if present, else the most-recently-modified rollout) and upload it.
|
|
33
|
+
// The server replaces the session's prior Codex points on every upload, so re-firing per turn is safe.
|
|
34
|
+
// Always resolves 0 — a hook must never block or fail the agent.
|
|
35
|
+
export async function runCodexHook() {
|
|
36
|
+
let payload = {};
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readStdin();
|
|
39
|
+
if (raw.trim())
|
|
40
|
+
payload = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// unparseable stdin — fall back to "newest rollout"
|
|
44
|
+
}
|
|
45
|
+
const sessionId = firstString(payload, ["session_id", "sessionId", "conversation_id", "id", "thread_id"]);
|
|
46
|
+
const explicit = firstString(payload, ["rollout_path", "transcript_path", "path", "rollout"]);
|
|
47
|
+
const cwd = firstString(payload, ["cwd", "workdir", "working_directory"]);
|
|
48
|
+
const rollout = explicit && existsSync(explicit) ? explicit : findRollout(sessionId);
|
|
49
|
+
if (!rollout)
|
|
50
|
+
return 0;
|
|
51
|
+
// uploadTranscript resolves the Codex identity from config (git-fallback uses cwd) on its own.
|
|
52
|
+
await uploadTranscript(rollout, { source: CODEX_SOURCE, format: "codex", sessionId, cwd });
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
// Newest rollout under ~/.codex/sessions/**, optionally restricted to those whose filename contains
|
|
56
|
+
// the given session id (Codex names files `rollout-<ts>-<sessionId>.jsonl`).
|
|
57
|
+
export function findRollout(sessionId) {
|
|
58
|
+
const all = findCodexRollouts();
|
|
59
|
+
if (!all.length)
|
|
60
|
+
return null;
|
|
61
|
+
const pool = sessionId ? all.filter((f) => f.includes(sessionId)) : all;
|
|
62
|
+
const candidates = pool.length ? pool : all;
|
|
63
|
+
let best = candidates[0];
|
|
64
|
+
let bestMtime = mtime(best);
|
|
65
|
+
for (const f of candidates.slice(1)) {
|
|
66
|
+
const m = mtime(f);
|
|
67
|
+
if (m > bestMtime) {
|
|
68
|
+
best = f;
|
|
69
|
+
bestMtime = m;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
function mtime(path) {
|
|
75
|
+
try {
|
|
76
|
+
return statSync(path).mtimeMs;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function firstString(obj, keys) {
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const value = obj[key];
|
|
85
|
+
if (typeof value === "string" && value.length > 0)
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
async function readStdin() {
|
|
91
|
+
if (process.stdin.isTTY)
|
|
92
|
+
return "";
|
|
93
|
+
const chunks = [];
|
|
94
|
+
for await (const chunk of process.stdin)
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
97
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
// Everything the CLI owns lives under ~/.finius (override with FINIUS_HOME).
|
|
5
|
+
export const FINIUS_HOME = process.env.FINIUS_HOME ? resolve(process.env.FINIUS_HOME) : join(homedir(), ".finius");
|
|
6
|
+
export const CONFIG_PATH = join(FINIUS_HOME, "config.json");
|
|
7
|
+
export const DEFAULT_SERVER_URL = "http://localhost:8787";
|
|
8
|
+
export function configExists() {
|
|
9
|
+
return existsSync(CONFIG_PATH);
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
if (!existsSync(CONFIG_PATH))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
16
|
+
return {
|
|
17
|
+
...raw,
|
|
18
|
+
serverUrl: normalizeUrl(raw.serverUrl) || DEFAULT_SERVER_URL
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// This machine's credential for Secure Mode. Only minted session tokens are valid on protected
|
|
26
|
+
// endpoints; the master password is accepted solely by /api/auth/login.
|
|
27
|
+
export function resolveAuthToken(config) {
|
|
28
|
+
return config?.authToken ?? undefined;
|
|
29
|
+
}
|
|
30
|
+
export function saveConfig(config) {
|
|
31
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
32
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
// Resolution order: explicit env override → saved config → built-in default.
|
|
35
|
+
export function resolveServerUrl() {
|
|
36
|
+
return normalizeUrl(process.env.FINIUS_SERVER_URL) || loadConfig()?.serverUrl || DEFAULT_SERVER_URL;
|
|
37
|
+
}
|
|
38
|
+
// Trim whitespace and any trailing slashes so we can safely append paths.
|
|
39
|
+
export function normalizeUrl(value) {
|
|
40
|
+
return (value ?? "").trim().replace(/\/+$/, "");
|
|
41
|
+
}
|