diff --git a/README.md b/README.md index 19543d2..6ffddd3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Render order (top → bottom): **info · prayers · money · zai**. ## The money line is a ledger, not a tracker -Data-flow diagram: the omp sessions tree on disk feeds the money line; the z.ai quota API (key from the pi auth file, read-only) feeds the provider-gated zai line; the aladhan API, cached per local day, feeds the prayers and info lines. +Data-flow diagram: the omp sessions tree on disk feeds the money line; the z.ai quota API (key from omp's credential store, with the pi auth file as fallback) feeds the provider-gated zai line; the aladhan API, cached per local day, feeds the prayers and info lines. Costs are disk-scanned from omp's own session tree (`~/.omp/agent/sessions/`), not tracked live: @@ -53,8 +53,7 @@ Or from a checkout: `omp plugin link /path/to/omp-statusline`. ```json { "zai": { - "pollIntervalMs": 180000, - "authJsonPath": "~/.pi/agent/auth.json" + "pollIntervalMs": 180000 }, "deen": { "city": "Jakarta", @@ -68,7 +67,7 @@ Or from a checkout: `omp plugin link /path/to/omp-statusline`. | Key | Default | Notes | |---|---|---| | `zai.pollIntervalMs` | `180000` | Poll cycle for quota fetch + money rescan (clamped to ≥ `30000`). | -| `zai.authJsonPath` | `~/.pi/agent/auth.json` | pi-style auth JSON `{"zai": {"key": "…"}}`, read-only. omp has no auth store of its own, so the pi auth file is read by default. | +| `zai.authJsonPath` | *unset* | Explicit pi-style auth JSON `{"zai": {"key": "…"}}`, read-only — pins that file as the sole key source. Unset (default): the key resolves from **omp's own credential store** (`omp /login` → `models.yml` → env → auth broker) via the host, falling back to `~/.pi/agent/auth.json` — omp-only setups need no pi files. | | `deen.city` / `deen.country` | `Jakarta` / `Indonesia` | aladhan lookup. | | `deen.method` | `auto` | Calculation method (`auto` → aladhan default). | | `deen.escalateMinutes` | `30` | Minutes-until-next-prayer threshold for the `soon` escalation band. | @@ -80,7 +79,7 @@ State (deen cache) lives beside the config in `~/.omp/agent/omp-statusline/`. Forces a zai + deen + money refresh and notifies full source state: quota freshness, today's plan credits, 7-day per-model split, usage streaks, cache-hit rate, prayer city/hijri, and the spend breakdown including subagent share and entry count: ``` -zai 5h 16% · weekly 24% · fetched 0m ago · today 7.7K · models 7d glm-5.3 28K · gpt-5.4 9.2K · streak 46d (best 61d) · cache 71% | deen Jakarta · 25 Rabīʿ al-awwal 1448 · fresh | money REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88 · sub $11.02 · 1234 entries +zai key omp credentials · 5h 16% · weekly 24% · fetched 0m ago · today 7.7K · models 7d glm-5.3 28K · gpt-5.4 9.2K · streak 46d (best 61d) · cache 71% | deen Jakarta · 25 Rabīʿ al-awwal 1448 · fresh | money REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88 · sub $11.02 · 1234 entries ``` The z.ai dashboard endpoints (`credit-usage/usage-detail`, `credit-usage/activity`) accept the same API key as the quota API — no browser or cookie session needed. diff --git a/assets/dataflow.svg b/assets/dataflow.svg index 9b5695c..9a0d7fe 100644 --- a/assets/dataflow.svg +++ b/assets/dataflow.svg @@ -27,8 +27,8 @@ z.ai quota API zai · provider-gated - key from ~/.pi/agent/auth.json - read-only — omp has no auth store of its own + key: omp credential store (/login) + fallback: ~/.pi/agent/auth.json (read-only) aladhan API diff --git a/package.json b/package.json index 3042e62..aaae0c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/omp-statusline", - "version": "0.3.0", + "version": "0.4.0", "description": "Four-line omp statusline widget: zai coding-plan quota (provider-gated), prayer times, hijri clock, and API-spend ledger (REPO/DAY/7DAY/30DAY, subagent-inclusive).", "keywords": [ "pi-package", diff --git a/src/index.ts b/src/index.ts index 3275bf6..9f4540d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,9 @@ // 󰄬 REPO $68.36 · DAY $26.50 · 7DAY $315.27 · 30DAY $492.88 // Data layer vendored from @getpipher/pi-statusline (quota/zai, format, deen, adapters); // money comes from the omp sessions disk-scan (money.ts — subagent-inclusive). State -// lives under ~/.omp/agent/omp-statusline/; the only ~/.pi read is the zai key -// (configurable authJsonPath — omp has no auth.json of its own). +// lives under ~/.omp/agent/omp-statusline/. The zai key resolves from omp's own +// credential store first (ctx.modelRegistry — /login, models.yml, env, broker); +// the pi-style auth file is only a fallback (authJsonPath pins it explicitly). import { homedir } from "node:os"; import { join } from "node:path"; import { readFileSync, mkdirSync } from "node:fs"; @@ -47,12 +48,22 @@ interface SlUi { interface SlModel { provider?: string; } -interface SlCtx { +export interface SlCtx { ui: SlUi; // omp-native model facade; absent in contexts that don't expose it → provider reads // as undefined and the zai gate falls back to "show" (inert-adapter philosophy: // plan data stays visible rather than vanishing when we can't read the provider). models?: { current(): SlModel | null }; + // omp-native credential ladder (models.yml apiKey → OAuth → /login-stored key → + // env → auth broker) — the same resolution the host uses for provider "zai". + // Absent in contexts that don't expose it → key resolution falls back to file. + modelRegistry?: { + getApiKeyForProvider( + provider: string, + sessionId?: unknown, + opts?: { forceRefresh?: boolean }, + ): Promise; + }; } interface SlApi { on(event: "session_start" | "model_select", handler: (event: unknown, ctx: SlCtx) => void): void; @@ -66,10 +77,16 @@ interface SlApi { const STATE_DIR = join(homedir(), ".omp", "agent", "omp-statusline"); const CONFIG_PATH = join(STATE_DIR, "config.json"); const DEEN_CACHE = join(STATE_DIR, "deen-cache.json"); +// Fallback file only — omp keeps its own credentials in ~/.omp/agent/agent.db +// (auth_credentials), so omp-only setups never need this pi-era path. +const PI_AUTH_JSON = join(homedir(), ".pi", "agent", "auth.json"); export interface LiveConfig { zaiPollMs: number; - authJsonPath: string; + // null = not configured → key resolves via ctx.modelRegistry first, then the + // pi-style auth file (PI_AUTH_JSON). A configured path pins the file as the + // sole source (explicit intent beats the credential store). + authJsonPath: string | null; deen: DeenSourceConfig; } @@ -78,7 +95,7 @@ export interface LiveConfig { export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig { const defaults: LiveConfig = { zaiPollMs: 180_000, - authJsonPath: join(homedir(), ".pi", "agent", "auth.json"), + authJsonPath: null, deen: { city: "Jakarta", country: "Indonesia", method: "auto", escalateMinutes: 30 }, }; let parsed: { zai?: { pollIntervalMs?: unknown; authJsonPath?: unknown }; deen?: Record }; @@ -92,7 +109,7 @@ export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig { const esc = parsed.deen?.escalateMinutes; return { zaiPollMs: typeof pollMs === "number" && Number.isFinite(pollMs) && pollMs >= 30_000 ? pollMs : defaults.zaiPollMs, - authJsonPath: typeof authPath === "string" && authPath !== "" ? authPath : defaults.authJsonPath, + authJsonPath: typeof authPath === "string" && authPath !== "" ? authPath : null, deen: { city: typeof parsed.deen?.city === "string" ? parsed.deen.city : defaults.deen.city, country: typeof parsed.deen?.country === "string" ? parsed.deen.country : defaults.deen.country, @@ -102,6 +119,30 @@ export function loadLiveConfig(configPath = CONFIG_PATH): LiveConfig { }; } +// Resolved key + its origin (diagnostics: an omp /login key and a pi auth.json key +// can be different z.ai accounts — /sl reports which one fed the quota). +export interface ZaiKey { + key: string; + source: string; +} +export async function resolveZaiKey( + ctx: SlCtx | null, + cfg: LiveConfig, + defaultFile: string = PI_AUTH_JSON, +): Promise { + if (cfg.authJsonPath === null && ctx?.modelRegistry) { + try { + const key = await ctx.modelRegistry.getApiKeyForProvider("zai"); + if (typeof key === "string" && key !== "") return { key, source: "omp credentials" }; + } catch { + /* registry unavailable → file fallback */ + } + } + const file = cfg.authJsonPath ?? defaultFile; + const fileKey = readZaiKey(file); + return fileKey ? { key: fileKey, source: file } : null; +} + // --- line renderers (approved mockup, tmux window slmock, 2026-09-07) --------- // Heat bands: quota windows tint accent → warning (≥70) → error (≥90); next prayer // success-green; past prayers dim ✓; upcoming text; labels/glyphs/separators dim. @@ -251,15 +292,18 @@ export default function ompStatusline(pi: SlApi): void { async function pollZai(): Promise { try { - const key = readZaiKey(cfg.authJsonPath); - if (!key) { + const resolved = await resolveZaiKey(ctx, cfg); + if (!resolved) { if (!warnedNoKey) { warnedNoKey = true; - ctx?.ui.notify(`omp-statusline: no zai key at ${cfg.authJsonPath} — quota line inert`, "warning"); + ctx?.ui.notify( + `omp-statusline: no zai key (omp credential store / ${cfg.authJsonPath ?? PI_AUTH_JSON}) — quota line inert`, + "warning", + ); } return; } - const result = await fetchQuota(key); + const result = await fetchQuota(resolved.key); if (result) zaiData = result; } catch { /* keep last-good; next poll retries */ @@ -308,17 +352,18 @@ export default function ompStatusline(pi: SlApi): void { await pollDeen(); pollMoney(); const s = deen.current(); - const key = readZaiKey(cfg.authJsonPath); - const report = key ? await fetchZaiReport(key) : null; + const resolved = await resolveZaiKey(cmdCtx, cfg); + const report = resolved ? await fetchZaiReport(resolved.key) : null; const zaiPart = zaiData ? [ + `key ${resolved?.source ?? "?"}`, zaiStatusDetail(zaiData, Date.now()), report && report.todayCredits !== null ? `today ${compactK(report.todayCredits)}` : "", report && report.models.length ? `models 7d ${report.models.map((m) => `${m.name} ${compactK(m.credits)}`).join(" · ")}` : "", report && report.streakDays !== null ? `streak ${report.streakDays}d (best ${report.longestStreakDays ?? "?"}d)` : "", report && report.cacheHitRate !== null ? `cache ${Math.round(report.cacheHitRate * 100)}%` : "", ].filter(Boolean).join(" · ") - : "no data"; + : resolved ? "no quota data" : "no key"; const deenPart = s ? `${s.city} · ${s.hijri}${s.staleMinutes !== null ? ` · stale ${s.staleMinutes}m` : " · fresh"}` : "no data"; const moneyPart = `REPO $${money.repo.toFixed(2)} · DAY $${money.day.toFixed(2)} · 7DAY $${money.week.toFixed(2)} · 30DAY $${money.month.toFixed(2)} · sub $${money.sub.toFixed(2)} · ${money.entries} entries`; cmdCtx.ui.notify(`zai ${zaiPart} | deen ${deenPart} | money ${moneyPart}`, "info"); diff --git a/test/zai-key.test.ts b/test/zai-key.test.ts new file mode 100644 index 0000000..cae8798 --- /dev/null +++ b/test/zai-key.test.ts @@ -0,0 +1,90 @@ +// test/zai-key.test.ts — zai key resolution order (v0.4.0): omp credential store +// first, pi-style auth file fallback, explicit authJsonPath pins the file. This is +// the omp-only-user contract — no ~/.pi required for the quota row to live. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { loadLiveConfig, resolveZaiKey, type LiveConfig, type SlCtx } from "../src/index.ts"; + +const ui = { + setStatus: () => {}, + setWidget: () => {}, + notify: () => {}, +}; +function ctxWithRegistry(key: string | null | undefined, calls: string[] = []): SlCtx { + return { + ui, + modelRegistry: { + getApiKeyForProvider: (provider: string) => { + calls.push(provider); + if (key === null) return Promise.reject(new Error("registry unavailable")); + return Promise.resolve(key); + }, + }, + }; +} + +function tmpAuthFile(body: string): string { + const dir = mkdtempSync(join(tmpdir(), "osl-key-")); + const file = join(dir, "auth.json"); + writeFileSync(file, body); + return file; +} + +const unconfigured: LiveConfig = { + zaiPollMs: 180_000, + authJsonPath: null, + deen: { city: "Jakarta", country: "Indonesia", method: "auto", escalateMinutes: 30 }, +}; + +test("loadLiveConfig: authJsonPath null unless explicitly a non-empty string", () => { + const dir = mkdtempSync(join(tmpdir(), "osl-cfg-")); + const absent = join(dir, "absent.json"); + assert.equal(loadLiveConfig(absent).authJsonPath, null); // unreadable → default + + const configured = join(dir, "configured.json"); + writeFileSync(configured, JSON.stringify({ zai: { authJsonPath: "/custom/auth.json" } })); + assert.equal(loadLiveConfig(configured).authJsonPath, "/custom/auth.json"); + + const badType = join(dir, "bad.json"); + writeFileSync(badType, JSON.stringify({ zai: { authJsonPath: 42 } })); + assert.equal(loadLiveConfig(badType).authJsonPath, null); + + const empty = join(dir, "empty.json"); + writeFileSync(empty, JSON.stringify({ zai: { authJsonPath: "" } })); + assert.equal(loadLiveConfig(empty).authJsonPath, null); +}); + +test("resolveZaiKey: credential store wins when unconfigured (omp-only user)", async () => { + const calls: string[] = []; + const fallback = tmpAuthFile(JSON.stringify({ zai: { key: "file-key" } })); + const resolved = await resolveZaiKey(ctxWithRegistry("store-key", calls), unconfigured, fallback); + assert.deepEqual(resolved, { key: "store-key", source: "omp credentials" }); + assert.deepEqual(calls, ["zai"]); // asked the host, never touched the file +}); + +test("resolveZaiKey: file fallback when registry misses, rejects, or ctx lacks it", async () => { + const fallback = tmpAuthFile(JSON.stringify({ zai: { key: "file-key" } })); + for (const ctx of [ctxWithRegistry(null), ctxWithRegistry(undefined), ctxWithRegistry(""), { ui }]) { + const resolved = await resolveZaiKey(ctx, unconfigured, fallback); + assert.deepEqual(resolved, { key: "file-key", source: fallback }); + } + // and no file either → inert, not an error + assert.equal(await resolveZaiKey({ ui }, unconfigured, join(tmpdir(), "osl-no-such-auth.json")), null); +}); + +test("resolveZaiKey: explicit authJsonPath pins the file — registry never consulted", async () => { + const calls: string[] = []; + const pinned = tmpAuthFile(JSON.stringify({ zai: { key: "pinned-key" } })); + const cfg: LiveConfig = { ...unconfigured, authJsonPath: pinned }; + const resolved = await resolveZaiKey(ctxWithRegistry("store-key", calls), cfg, "/never/read.json"); + assert.deepEqual(resolved, { key: "pinned-key", source: pinned }); + assert.deepEqual(calls, []); // explicit intent beats the credential store + // pinned path with no key there → null (no silent fallback to the store) + const deadPin = join(pinned, "..", "nope.json"); + assert.equal(await resolveZaiKey(ctxWithRegistry("store-key", calls), { ...unconfigured, authJsonPath: deadPin }), null); + assert.deepEqual(calls, []); +});