Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions docs/plugins/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions docs/providers/warp.md
Original file line number Diff line number Diff line change
@@ -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." |
3 changes: 3 additions & 0 deletions plugins/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ export const makeCtx = () => {
http: {
request: vi.fn(),
},
defaults: {
read: vi.fn(),
},
ls: {
discover: vi.fn(() => null),
},
Expand Down
4 changes: 4 additions & 0 deletions plugins/warp/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions plugins/warp/plugin.js
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Warp plugin hardcodes a single defaults domain and fails for Warp Preview users

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/warp/plugin.js, line 6:

<comment>Warp plugin hardcodes a single defaults domain and fails for Warp Preview users</comment>

<file context>
@@ -0,0 +1,53 @@
+    // 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))
</file context>
Fix with Cubic

} 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 }
})()
15 changes: 15 additions & 0 deletions plugins/warp/plugin.json
Original file line number Diff line number Diff line change
@@ -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 }
]
}
126 changes: 126 additions & 0 deletions plugins/warp/plugin.test.js
Original file line number Diff line number Diff line change
@@ -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")
})
})
74 changes: 74 additions & 0 deletions src-tauri/src/plugin_engine/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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<String> {
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())?;

Expand Down Expand Up @@ -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");
});
}
}
Loading