diff --git a/README.md b/README.md index 29885b01..eaa8b00a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Kimi Code**](docs/providers/kimi.md) / session, weekly - [**MiniMax**](docs/providers/minimax.md) / coding plan session - [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits +- [**Warp**](docs/providers/warp.md) / AI credits - [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits - [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches diff --git a/docs/plugins/api.md b/docs/plugins/api.md index a6d56c62..d9e5d797 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -258,6 +258,34 @@ if (ctx.host.fs.exists("~/.myapp/credentials.json")) { } ``` +## User Defaults System (macOS only) + +```typescript +host.defaults.read(domain: string, key: string): string +``` + +Reads a value from the macOS user defaults. + +### Behavior + +- **macOS only**: Throws on other platforms. +- **Returns raw string**: Returns the value associated with the domain and key as a string. +- **Throws on failure**: Throws if the domain or key does not exist. + +### Example + +```javascript +try { + const json = ctx.host.defaults.read("com.example.MyApp", "AIUsageInfo") + const data = ctx.util.tryParseJson(json) + if (data) { + ctx.host.log.info("Warp limit: " + data.limit) + } +} catch (e) { + // Handle key not found +} +``` + ## SQLite ### Query (Read-Only) diff --git a/docs/providers/warp.md b/docs/providers/warp.md new file mode 100644 index 00000000..c3d380c3 --- /dev/null +++ b/docs/providers/warp.md @@ -0,0 +1,38 @@ +# Warp + +Tracks AI usage credits for the Warp terminal. + +## Data Source + +The plugin reads the **AI Credits** quota from Warp's User Defaults System. + +- **Domain**: `dev.warp.Warp-Stable` +- **Key**: `AIRequestLimitInfo` + +## Prerequisites + +- Warp must be installed. +- You must be logged into your Warp account. +- You must have used Warp AI at least once to initialize the local User Defaults System data. + - If your quota has recently reset, you must use Warp AI again to trigger a local update. + +## Parsed Fields + +From `AIRequestLimitInfo`: +- `num_requests_used_since_refresh`: used credits +- `limit`: total credit limit +- `next_refresh_time`: reset timestamp + +## Displayed Lines + +| Line | Scope | Description | +| :--- | :--- | :--- | +| AI Credits | Overview | Total AI credits used in the current billing cycle. Includes reset timer. | + +## Errors + +| Condition | Message | +| :--- | :--- | +| Key not found in defaults | "No Warp AI usage data found. Ensure Warp is installed and you have used AI at least once. If you have, this may be a plugin bug." | +| Data malformed or incomplete | "Warp AI quota data is malformed. This may be a plugin bug." | +| Data is stale (expired reset time) | "No active Warp AI quota found. Have your credits reset recently? Try using Warp AI once to refresh it." | diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index af699e0c..3837bda3 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -77,6 +77,9 @@ export const makeCtx = () => { http: { request: vi.fn(), }, + defaults: { + read: vi.fn(), + }, ls: { discover: vi.fn(() => null), }, diff --git a/plugins/warp/icon.svg b/plugins/warp/icon.svg new file mode 100644 index 00000000..fb2e2fb5 --- /dev/null +++ b/plugins/warp/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/warp/plugin.js b/plugins/warp/plugin.js new file mode 100644 index 00000000..b8902959 --- /dev/null +++ b/plugins/warp/plugin.js @@ -0,0 +1,53 @@ +(function () { + function probe(ctx) { + // Read the data from user defaults + let json + try { + json = ctx.host.defaults.read("dev.warp.Warp-Stable", "AIRequestLimitInfo") + } catch (e) { + ctx.host.log.info("Warp: AIRequestLimitInfo key not found in preferences: " + String(e)) + throw "No Warp AI usage data found. Ensure Warp is installed and you have used AI at least once. If you have, this may be a plugin bug." + } + + // Parse and validate the data structure + const data = ctx.util.tryParseJson(json) + if (!data || typeof data !== "object") { + ctx.host.log.error("Warp: Malformed quota data: " + json) + throw "Warp AI quota data is malformed. This may be a plugin bug." + } + + const { num_requests_used_since_refresh: used, limit, next_refresh_time: resetsAt } = data + + if ( + !Number.isFinite(used) || + !Number.isFinite(limit) || + limit <= 0 || + !resetsAt || + !Number.isFinite(new Date(resetsAt).getTime()) + ) { + ctx.host.log.error("Warp: Incomplete quota data: " + json) + throw "Warp AI quota data is malformed. This may be a plugin bug." + } + + // Staleness check: Ensure the data belongs to an active cycle + if (new Date(resetsAt) < new Date(ctx.nowIso)) { + ctx.host.log.info("Warp: Quota data is stale (expired " + resetsAt + ")") + throw "No active Warp AI quota found. Have your credits reset recently? Try using Warp AI once to refresh it." + } + + // Return the formatted usage lines + return { + lines: [ + ctx.line.progress({ + label: "AI Credits", + used, + limit, + format: { kind: "count", suffix: "credits" }, + resetsAt: ctx.util.toIso(resetsAt), + }), + ], + } + } + + globalThis.__openusage_plugin = { id: "warp", probe } +})() diff --git a/plugins/warp/plugin.json b/plugins/warp/plugin.json new file mode 100644 index 00000000..9feacc96 --- /dev/null +++ b/plugins/warp/plugin.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "id": "warp", + "name": "Warp", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "links": [ + { "label": "Warp AI", "url": "https://www.warp.dev/warp-ai" } + ], + "lines": [ + { "type": "progress", "label": "AI Credits", "scope": "overview", "primaryOrder": 1 } + ] +} diff --git a/plugins/warp/plugin.test.js b/plugins/warp/plugin.test.js new file mode 100644 index 00000000..d9490c7f --- /dev/null +++ b/plugins/warp/plugin.test.js @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +describe("warp plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws and logs info if defaults key is not found", async () => { + const ctx = makeCtx() + ctx.host.defaults.read.mockImplementation(() => { + throw new Error("key not found") + }) + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow( + "No Warp AI usage data found. Ensure Warp is installed and you have used AI at least once. If you have, this may be a plugin bug." + ) + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("Warp: AIRequestLimitInfo key not found in preferences") + ) + }) + + it("throws and logs error if JSON is malformed or not an object", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + + const malformedInputs = [ + "invalid json", + "null", + "42", + "true", + "", + ] + + for (const input of malformedInputs) { + ctx.host.defaults.read.mockReturnValue(input) + expect(() => plugin.probe(ctx)).toThrow("Warp AI quota data is malformed. This may be a plugin bug.") + expect(ctx.host.log.error).toHaveBeenCalledWith( + expect.stringContaining("Warp: Malformed quota data") + ) + vi.clearAllMocks() + } + }) + + it("throws and logs error if quota data fields are invalid or missing", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + + const invalidPayloads = [ + {}, // empty object + [], // array + { limit: 100, next_refresh_time: "2026-06-01T00:00:00Z" }, // missing used + { num_requests_used_since_refresh: 10, next_refresh_time: "2026-06-01T00:00:00Z" }, // missing limit + { num_requests_used_since_refresh: 10, limit: 100 }, // missing resetsAt + { num_requests_used_since_refresh: "10", limit: 100, next_refresh_time: "2026-06-01T00:00:00Z" }, // used is string + { num_requests_used_since_refresh: 10, limit: "100", next_refresh_time: "2026-06-01T00:00:00Z" }, // limit is string + { num_requests_used_since_refresh: 10, limit: 0, next_refresh_time: "2026-06-01T00:00:00Z" }, // limit is 0 + { num_requests_used_since_refresh: 10, limit: -5, next_refresh_time: "2026-06-01T00:00:00Z" }, // limit is negative + { num_requests_used_since_refresh: 10, limit: 100, next_refresh_time: "" }, // resetsAt is empty string + { num_requests_used_since_refresh: 10, limit: 100, next_refresh_time: "not-a-date" }, // resetsAt is invalid date string + { num_requests_used_since_refresh: NaN, limit: 100, next_refresh_time: "2026-06-01T00:00:00Z" }, // used is NaN + { num_requests_used_since_refresh: 10, limit: NaN, next_refresh_time: "2026-06-01T00:00:00Z" }, // limit is NaN + { num_requests_used_since_refresh: Infinity, limit: 100, next_refresh_time: "2026-06-01T00:00:00Z" }, // used is Infinity + { num_requests_used_since_refresh: 10, limit: Infinity, next_refresh_time: "2026-06-01T00:00:00Z" }, // limit is Infinity + ] + + for (const payload of invalidPayloads) { + const json = JSON.stringify(payload) + ctx.host.defaults.read.mockReturnValue(json) + expect(() => plugin.probe(ctx)).toThrow("Warp AI quota data is malformed. This may be a plugin bug.") + expect(ctx.host.log.error).toHaveBeenCalledWith( + expect.stringContaining("Warp: Incomplete quota data") + ) + vi.clearAllMocks() + } + }) + + it("throws and logs info if data is stale", async () => { + const ctx = makeCtx() + ctx.nowIso = "2026-05-20T12:00:00Z" + const resetsAt = "2026-05-19T00:00:00Z" + ctx.host.defaults.read.mockReturnValue( + JSON.stringify({ + num_requests_used_since_refresh: 10, + limit: 100, + next_refresh_time: resetsAt, + }) + ) + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow("No active Warp AI quota found. Have your credits reset recently?") + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("Warp: Quota data is stale") + ) + }) + + it("parses valid credits data", async () => { + const ctx = makeCtx() + ctx.nowIso = "2026-05-20T12:00:00Z" + ctx.host.defaults.read.mockReturnValue( + JSON.stringify({ + num_requests_used_since_refresh: 42, + limit: 100, + next_refresh_time: "2026-06-01T00:00:00Z", + }) + ) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(ctx.host.defaults.read).toHaveBeenCalledWith("dev.warp.Warp-Stable", "AIRequestLimitInfo") + expect(result.lines).toHaveLength(1) + + const credits = result.lines.find((l) => l.label === "AI Credits") + expect(credits.used).toBe(42) + expect(credits.limit).toBe(100) + expect(credits.resetsAt).toBe("2026-06-01T00:00:00.000Z") + }) +}) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index be532de3..f4ae2efd 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -612,6 +612,7 @@ pub(crate) fn inject_host_api_with_deadline<'js>( inject_env(ctx, &host, plugin_id)?; inject_http(ctx, &host, plugin_id, deadline)?; inject_keychain(ctx, &host, plugin_id)?; + inject_defaults(ctx, &host, plugin_id)?; inject_sqlite(ctx, &host)?; inject_ls(ctx, &host, plugin_id)?; inject_ccusage(ctx, &host, plugin_id, deadline)?; @@ -2625,6 +2626,64 @@ fn inject_keychain<'js>( Ok(()) } +fn inject_defaults<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + plugin_id: &str, +) -> rquickjs::Result<()> { + let defaults_obj = Object::new(ctx.clone())?; + let pid = plugin_id.to_string(); + + defaults_obj.set( + "read", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, domain: String, key: String| -> rquickjs::Result { + if !cfg!(target_os = "macos") { + return Err(Exception::throw_message( + &ctx_inner, + "User Defaults System is only supported on macOS", + )); + } + + log::info!("[plugin:{}] defaults read: domain={}, key={}", pid, domain, key); + + let output = std::process::Command::new("/usr/bin/defaults") + .args(["read", &domain, &key]) + .output() + .map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("defaults execution failed: {}", e), + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let err_msg = stderr.trim().to_string(); + log::warn!( + "[plugin:{}] defaults read failed: domain={}, key={}, error={}", + pid, + domain, + key, + err_msg + ); + return Err(Exception::throw_message( + &ctx_inner, + &format!("defaults read failed: {}", err_msg), + )); + } + + log::info!("[plugin:{}] defaults read success", pid); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + }, + )?, + )?; + + host.set("defaults", defaults_obj)?; + Ok(()) +} + fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let sqlite_obj = Object::new(ctx.clone())?; @@ -4320,4 +4379,19 @@ wait let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn defaults_api_exposes_read() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let defaults: Object = host.get("defaults").expect("defaults"); + let _read: Function = defaults.get("read").expect("read"); + }); + } }