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");
+ });
+ }
}