openusage 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.
Files changed (58) hide show
  1. package/bin/openusage +91 -0
  2. package/package.json +33 -0
  3. package/plugins/amp/icon.svg +6 -0
  4. package/plugins/amp/plugin.js +175 -0
  5. package/plugins/amp/plugin.json +20 -0
  6. package/plugins/amp/plugin.test.js +365 -0
  7. package/plugins/antigravity/icon.svg +3 -0
  8. package/plugins/antigravity/plugin.js +484 -0
  9. package/plugins/antigravity/plugin.json +17 -0
  10. package/plugins/antigravity/plugin.test.js +1356 -0
  11. package/plugins/claude/icon.svg +3 -0
  12. package/plugins/claude/plugin.js +565 -0
  13. package/plugins/claude/plugin.json +28 -0
  14. package/plugins/claude/plugin.test.js +1012 -0
  15. package/plugins/codex/icon.svg +3 -0
  16. package/plugins/codex/plugin.js +673 -0
  17. package/plugins/codex/plugin.json +30 -0
  18. package/plugins/codex/plugin.test.js +1071 -0
  19. package/plugins/copilot/icon.svg +3 -0
  20. package/plugins/copilot/plugin.js +264 -0
  21. package/plugins/copilot/plugin.json +20 -0
  22. package/plugins/copilot/plugin.test.js +529 -0
  23. package/plugins/cursor/icon.svg +3 -0
  24. package/plugins/cursor/plugin.js +526 -0
  25. package/plugins/cursor/plugin.json +24 -0
  26. package/plugins/cursor/plugin.test.js +1168 -0
  27. package/plugins/factory/icon.svg +1 -0
  28. package/plugins/factory/plugin.js +407 -0
  29. package/plugins/factory/plugin.json +19 -0
  30. package/plugins/factory/plugin.test.js +833 -0
  31. package/plugins/gemini/icon.svg +4 -0
  32. package/plugins/gemini/plugin.js +413 -0
  33. package/plugins/gemini/plugin.json +20 -0
  34. package/plugins/gemini/plugin.test.js +735 -0
  35. package/plugins/jetbrains-ai-assistant/icon.svg +3 -0
  36. package/plugins/jetbrains-ai-assistant/plugin.js +357 -0
  37. package/plugins/jetbrains-ai-assistant/plugin.json +17 -0
  38. package/plugins/jetbrains-ai-assistant/plugin.test.js +338 -0
  39. package/plugins/kimi/icon.svg +3 -0
  40. package/plugins/kimi/plugin.js +358 -0
  41. package/plugins/kimi/plugin.json +19 -0
  42. package/plugins/kimi/plugin.test.js +619 -0
  43. package/plugins/minimax/icon.svg +4 -0
  44. package/plugins/minimax/plugin.js +388 -0
  45. package/plugins/minimax/plugin.json +17 -0
  46. package/plugins/minimax/plugin.test.js +943 -0
  47. package/plugins/perplexity/icon.svg +1 -0
  48. package/plugins/perplexity/plugin.js +378 -0
  49. package/plugins/perplexity/plugin.json +15 -0
  50. package/plugins/perplexity/plugin.test.js +602 -0
  51. package/plugins/windsurf/icon.svg +3 -0
  52. package/plugins/windsurf/plugin.js +218 -0
  53. package/plugins/windsurf/plugin.json +16 -0
  54. package/plugins/windsurf/plugin.test.js +455 -0
  55. package/plugins/zai/icon.svg +5 -0
  56. package/plugins/zai/plugin.js +156 -0
  57. package/plugins/zai/plugin.json +18 -0
  58. package/plugins/zai/plugin.test.js +396 -0
package/bin/openusage ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { execFileSync } = require("child_process");
5
+ const path = require("path");
6
+ const os = require("os");
7
+
8
+ const PLATFORM_PACKAGES = {
9
+ "linux-x64": "openusage-linux-x64",
10
+ "linux-arm64": "openusage-linux-arm64",
11
+ "darwin-x64": "openusage-darwin-x64",
12
+ "darwin-arm64": "openusage-darwin-arm64",
13
+ };
14
+
15
+ function getPlatformKey() {
16
+ const platform = os.platform();
17
+ const arch = os.arch();
18
+
19
+ // Normalize arch names
20
+ const archMap = { x64: "x64", arm64: "arm64" };
21
+ const normalizedArch = archMap[arch];
22
+
23
+ if (!normalizedArch) {
24
+ throw new Error(
25
+ `Unsupported architecture: ${arch}. openusage supports x64 and arm64.`
26
+ );
27
+ }
28
+
29
+ const key = `${platform}-${normalizedArch}`;
30
+ if (!PLATFORM_PACKAGES[key]) {
31
+ throw new Error(
32
+ `Unsupported platform: ${platform} ${arch}. openusage supports Linux and macOS on x64/arm64.`
33
+ );
34
+ }
35
+
36
+ return key;
37
+ }
38
+
39
+ function findBinary() {
40
+ const key = getPlatformKey();
41
+ const pkg = PLATFORM_PACKAGES[key];
42
+
43
+ // Try npm-installed package first
44
+ try {
45
+ const pkgDir = path.dirname(require.resolve(`${pkg}/package.json`));
46
+ const binPath = path.join(pkgDir, "bin", "openusage");
47
+ return binPath;
48
+ } catch {
49
+ // Fall back to sibling directory (local development / monorepo layout)
50
+ const siblingPath = path.join(__dirname, "..", "..", pkg, "bin", "openusage");
51
+ if (require("fs").existsSync(siblingPath)) {
52
+ return siblingPath;
53
+ }
54
+
55
+ throw new Error(
56
+ `Could not find the openusage binary for your platform (${key}).\n` +
57
+ `Make sure the optional dependency "${pkg}" is installed.\n` +
58
+ `Try: npm install ${pkg}`
59
+ );
60
+ }
61
+ }
62
+
63
+ function main() {
64
+ const binPath = findBinary();
65
+
66
+ // Resolve plugins directory: bundled with the root package
67
+ const pluginsDir = path.join(__dirname, "..", "plugins");
68
+
69
+ // Build args, injecting --plugins-dir if not already specified
70
+ const args = process.argv.slice(2);
71
+ if (!args.includes("--plugins-dir")) {
72
+ args.unshift("--plugins-dir", pluginsDir);
73
+ }
74
+
75
+ try {
76
+ execFileSync(binPath, args, {
77
+ stdio: "inherit",
78
+ env: {
79
+ ...process.env,
80
+ OPENUSAGE_PLUGINS_DIR: pluginsDir,
81
+ },
82
+ });
83
+ } catch (err) {
84
+ if (err.status != null) {
85
+ process.exit(err.status);
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ main();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "openusage",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool for tracking AI coding subscription usage across providers",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nicepkg/openusage.git"
9
+ },
10
+ "bin": {
11
+ "openusage": "bin/openusage"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "plugins"
16
+ ],
17
+ "optionalDependencies": {
18
+ "openusage-linux-x64": "0.1.0",
19
+ "openusage-linux-arm64": "0.1.0",
20
+ "openusage-darwin-x64": "0.1.0",
21
+ "openusage-darwin-arm64": "0.1.0"
22
+ },
23
+ "keywords": [
24
+ "ai",
25
+ "usage",
26
+ "cli",
27
+ "claude",
28
+ "copilot",
29
+ "cursor",
30
+ "openai",
31
+ "subscription"
32
+ ]
33
+ }
@@ -0,0 +1,6 @@
1
+ <svg width="100" height="100" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="currentColor"/>
3
+ <path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="currentColor"/>
4
+ <path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="currentColor"/>
5
+ <path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="currentColor"/>
6
+ </svg>
@@ -0,0 +1,175 @@
1
+ (function () {
2
+ var SECRETS_FILE = "~/.local/share/amp/secrets.json"
3
+ var SECRETS_KEY = "apiKey@https://ampcode.com/"
4
+ var API_URL = "https://ampcode.com/api/internal"
5
+
6
+ function loadApiKey(ctx) {
7
+ if (!ctx.host.fs.exists(SECRETS_FILE)) return null
8
+ try {
9
+ var text = ctx.host.fs.readText(SECRETS_FILE)
10
+ var parsed = ctx.util.tryParseJson(text)
11
+ if (parsed && parsed[SECRETS_KEY]) {
12
+ ctx.host.log.info("api key loaded from secrets file")
13
+ return parsed[SECRETS_KEY]
14
+ }
15
+ } catch (e) {
16
+ ctx.host.log.warn("secrets file read failed: " + String(e))
17
+ }
18
+ return null
19
+ }
20
+
21
+ function fetchBalanceInfo(ctx, apiKey) {
22
+ return ctx.util.requestJson({
23
+ method: "POST",
24
+ url: API_URL,
25
+ headers: {
26
+ "Authorization": "Bearer " + apiKey,
27
+ "Content-Type": "application/json",
28
+ },
29
+ bodyText: JSON.stringify({ method: "userDisplayBalanceInfo", params: {} }),
30
+ timeoutMs: 15000,
31
+ })
32
+ }
33
+
34
+ function parseMoney(s) {
35
+ return Number(s.replace(/,/g, ""))
36
+ }
37
+
38
+ function parseBalanceText(text) {
39
+ if (!text || typeof text !== "string") return null
40
+
41
+ var result = {
42
+ remaining: null,
43
+ total: null,
44
+ hourlyRate: 0,
45
+ bonusPct: null,
46
+ bonusDays: null,
47
+ credits: null,
48
+ }
49
+
50
+ var balanceMatch = text.match(/\$([0-9][0-9,]*(?:\.[0-9]+)?)\/\$([0-9][0-9,]*(?:\.[0-9]+)?) remaining/)
51
+ if (balanceMatch) {
52
+ var remaining = parseMoney(balanceMatch[1])
53
+ var total = parseMoney(balanceMatch[2])
54
+ if (Number.isFinite(remaining) && Number.isFinite(total)) {
55
+ result.remaining = remaining
56
+ result.total = total
57
+ }
58
+ }
59
+
60
+ var rateMatch = text.match(/replenishes \+\$([0-9][0-9,]*(?:\.[0-9]+)?)\/hour/)
61
+ if (rateMatch) {
62
+ var rate = parseMoney(rateMatch[1])
63
+ if (Number.isFinite(rate)) result.hourlyRate = rate
64
+ }
65
+
66
+ var bonusMatch = text.match(/\+(\d+)% bonus for (\d+) more days?/)
67
+ if (bonusMatch) {
68
+ var pct = Number(bonusMatch[1])
69
+ var days = Number(bonusMatch[2])
70
+ if (Number.isFinite(pct) && Number.isFinite(days)) {
71
+ result.bonusPct = pct
72
+ result.bonusDays = days
73
+ }
74
+ }
75
+
76
+ var creditsMatch = text.match(/Individual credits: \$([0-9][0-9,]*(?:\.[0-9]+)?) remaining/)
77
+ if (creditsMatch) {
78
+ var credits = parseMoney(creditsMatch[1])
79
+ if (Number.isFinite(credits)) result.credits = credits
80
+ }
81
+
82
+ if (result.total === null && result.credits === null) return null
83
+
84
+ return result
85
+ }
86
+
87
+ function probe(ctx) {
88
+ var apiKey = loadApiKey(ctx)
89
+ if (!apiKey) {
90
+ throw "Amp not installed. Install Amp Code to get started."
91
+ }
92
+
93
+ var result
94
+ try {
95
+ result = fetchBalanceInfo(ctx, apiKey)
96
+ } catch (e) {
97
+ ctx.host.log.error("balance info request failed: " + String(e))
98
+ throw "Request failed. Check your connection."
99
+ }
100
+
101
+ var resp = result.resp
102
+ var json = result.json
103
+
104
+ if (resp.status === 401 || resp.status === 403) {
105
+ throw "Session expired. Re-authenticate in Amp Code."
106
+ }
107
+ if (resp.status < 200 || resp.status >= 300) {
108
+ var detail = json && json.error && json.error.message ? json.error.message : ""
109
+ if (detail) {
110
+ ctx.host.log.error("api returned " + resp.status + ": " + detail)
111
+ throw detail
112
+ }
113
+ ctx.host.log.error("api returned: " + resp.status)
114
+ throw "Request failed (HTTP " + resp.status + "). Try again later."
115
+ }
116
+
117
+ if (!json || !json.ok || !json.result || !json.result.displayText) {
118
+ ctx.host.log.error("unexpected response structure")
119
+ throw "Could not parse usage data."
120
+ }
121
+
122
+ var balance = parseBalanceText(json.result.displayText)
123
+ if (!balance) {
124
+ if (/Amp Free/.test(json.result.displayText)) {
125
+ ctx.host.log.error("failed to parse display text: " + json.result.displayText)
126
+ throw "Could not parse usage data."
127
+ }
128
+ ctx.host.log.warn("no balance data found, assuming credits-only: " + json.result.displayText)
129
+ balance = { remaining: null, total: null, hourlyRate: 0, bonusPct: null, bonusDays: null, credits: 0 }
130
+ }
131
+
132
+ var lines = []
133
+ var plan = "Free"
134
+
135
+ if (balance.total !== null) {
136
+ var used = Math.max(0, balance.total - balance.remaining)
137
+ var total = balance.total
138
+
139
+ var resetsAtMs = null
140
+ if (used > 0 && balance.hourlyRate > 0) {
141
+ var hoursToFull = used / balance.hourlyRate
142
+ resetsAtMs = Date.now() + hoursToFull * 3600 * 1000
143
+ }
144
+
145
+ lines.push(ctx.line.progress({
146
+ label: "Free",
147
+ used: used,
148
+ limit: total,
149
+ format: { kind: "dollars" },
150
+ resetsAt: ctx.util.toIso(resetsAtMs),
151
+ periodDurationMs: 24 * 3600 * 1000,
152
+ }))
153
+
154
+ if (balance.bonusPct && balance.bonusDays) {
155
+ lines.push(ctx.line.text({
156
+ label: "Bonus",
157
+ value: "+" + balance.bonusPct + "% for " + balance.bonusDays + "d",
158
+ }))
159
+ }
160
+ }
161
+
162
+ if (balance.credits !== null && balance.total === null) plan = "Credits"
163
+
164
+ if (balance.credits !== null && (balance.credits > 0 || balance.total === null)) {
165
+ lines.push(ctx.line.text({
166
+ label: "Credits",
167
+ value: "$" + balance.credits.toFixed(2),
168
+ }))
169
+ }
170
+
171
+ return { plan: plan, lines: lines }
172
+ }
173
+
174
+ globalThis.__openusage_plugin = { id: "amp", probe: probe }
175
+ })()
@@ -0,0 +1,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "amp",
4
+ "name": "Amp",
5
+ "version": "0.0.1",
6
+ "entry": "plugin.js",
7
+ "icon": "icon.svg",
8
+ "brandColor": "#F34E3F",
9
+ "cli": {
10
+ "category": "cli",
11
+ "binaryName": "amp",
12
+ "installCmd": null,
13
+ "loginCmd": "amp auth login"
14
+ },
15
+ "lines": [
16
+ { "type": "progress", "label": "Free", "scope": "overview", "primaryOrder": 1 },
17
+ { "type": "text", "label": "Bonus", "scope": "detail" },
18
+ { "type": "text", "label": "Credits", "scope": "detail" }
19
+ ]
20
+ }
@@ -0,0 +1,365 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { makeCtx } from "../test-helpers.js"
3
+
4
+ const SECRETS_FILE = "~/.local/share/amp/secrets.json"
5
+ const SECRETS_KEY = "apiKey@https://ampcode.com/"
6
+ const API_URL = "https://ampcode.com/api/internal"
7
+
8
+ const loadPlugin = async () => {
9
+ await import("./plugin.js")
10
+ return globalThis.__openusage_plugin
11
+ }
12
+
13
+ function writeSecrets(ctx, apiKey) {
14
+ var obj = {}
15
+ obj[SECRETS_KEY] = apiKey || "test-api-key"
16
+ ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify(obj))
17
+ }
18
+
19
+ function balanceResponse(displayText) {
20
+ return {
21
+ status: 200,
22
+ bodyText: JSON.stringify({ ok: true, result: { displayText: displayText } }),
23
+ }
24
+ }
25
+
26
+ function standardDisplayText(opts) {
27
+ opts = opts || {}
28
+ var remaining = opts.remaining !== undefined ? opts.remaining : 1.66
29
+ var total = opts.total !== undefined ? opts.total : 20
30
+ var rate = opts.rate !== undefined ? opts.rate : 0.83
31
+ var text = "Signed in as user@test.com (testuser)\n"
32
+ text += "Amp Free: $" + remaining + "/$" + total + " remaining (replenishes +$" + rate + "/hour)"
33
+ if (opts.bonus !== false) {
34
+ var pct = opts.bonusPct || 100
35
+ var days = opts.bonusDays || 2
36
+ text += " [+" + pct + "% bonus for " + days + " more days]"
37
+ }
38
+ text += " - https://ampcode.com/settings#amp-free\n"
39
+ var credits = opts.credits !== undefined ? opts.credits : 0
40
+ text += "Individual credits: $" + credits + " remaining - https://ampcode.com/settings"
41
+ return text
42
+ }
43
+
44
+ describe("amp plugin", () => {
45
+ beforeEach(() => {
46
+ delete globalThis.__openusage_plugin
47
+ vi.resetModules()
48
+ })
49
+
50
+ // --- Auth ---
51
+
52
+ it("throws when secrets file not found", async () => {
53
+ var ctx = makeCtx()
54
+ var plugin = await loadPlugin()
55
+ expect(() => plugin.probe(ctx)).toThrow("Amp not installed")
56
+ })
57
+
58
+ it("throws when secrets file has no api key", async () => {
59
+ var ctx = makeCtx()
60
+ ctx.host.fs.writeText(SECRETS_FILE, JSON.stringify({ other: "value" }))
61
+ var plugin = await loadPlugin()
62
+ expect(() => plugin.probe(ctx)).toThrow("Amp not installed")
63
+ })
64
+
65
+ it("throws on invalid JSON in secrets file", async () => {
66
+ var ctx = makeCtx()
67
+ ctx.host.fs.writeText(SECRETS_FILE, "{bad json")
68
+ var plugin = await loadPlugin()
69
+ expect(() => plugin.probe(ctx)).toThrow("Amp not installed")
70
+ })
71
+
72
+ // --- API request ---
73
+
74
+ it("sends POST with Bearer auth to api/internal", async () => {
75
+ var ctx = makeCtx()
76
+ writeSecrets(ctx, "my-api-key")
77
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText()))
78
+ var plugin = await loadPlugin()
79
+ plugin.probe(ctx)
80
+ var call = ctx.host.http.request.mock.calls[0][0]
81
+ expect(call.method).toBe("POST")
82
+ expect(call.url).toBe(API_URL)
83
+ expect(call.headers.Authorization).toBe("Bearer my-api-key")
84
+ expect(call.headers["Content-Type"]).toBe("application/json")
85
+ var body = JSON.parse(call.bodyText)
86
+ expect(body.method).toBe("userDisplayBalanceInfo")
87
+ expect(body.params).toEqual({})
88
+ })
89
+
90
+ // --- HTTP errors ---
91
+
92
+ it("throws on HTTP 401", async () => {
93
+ var ctx = makeCtx()
94
+ writeSecrets(ctx)
95
+ ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" })
96
+ var plugin = await loadPlugin()
97
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
98
+ })
99
+
100
+ it("throws on HTTP 403", async () => {
101
+ var ctx = makeCtx()
102
+ writeSecrets(ctx)
103
+ ctx.host.http.request.mockReturnValue({ status: 403, bodyText: "" })
104
+ var plugin = await loadPlugin()
105
+ expect(() => plugin.probe(ctx)).toThrow("Session expired")
106
+ })
107
+
108
+ it("throws with error detail on non-2xx with JSON error", async () => {
109
+ var ctx = makeCtx()
110
+ writeSecrets(ctx)
111
+ ctx.host.http.request.mockReturnValue({
112
+ status: 402,
113
+ bodyText: JSON.stringify({ error: { message: "Credits required for this feature." } }),
114
+ })
115
+ var plugin = await loadPlugin()
116
+ expect(() => plugin.probe(ctx)).toThrow("Credits required for this feature.")
117
+ })
118
+
119
+ it("throws generic error on HTTP 500", async () => {
120
+ var ctx = makeCtx()
121
+ writeSecrets(ctx)
122
+ ctx.host.http.request.mockReturnValue({ status: 500, bodyText: "" })
123
+ var plugin = await loadPlugin()
124
+ expect(() => plugin.probe(ctx)).toThrow("Request failed (HTTP 500)")
125
+ })
126
+
127
+ it("throws on network error", async () => {
128
+ var ctx = makeCtx()
129
+ writeSecrets(ctx)
130
+ ctx.host.http.request.mockImplementation(() => { throw new Error("ECONNREFUSED") })
131
+ var plugin = await loadPlugin()
132
+ expect(() => plugin.probe(ctx)).toThrow("Request failed. Check your connection.")
133
+ })
134
+
135
+ // --- Response structure errors ---
136
+
137
+ it("throws when response has no ok field", async () => {
138
+ var ctx = makeCtx()
139
+ writeSecrets(ctx)
140
+ ctx.host.http.request.mockReturnValue({ status: 200, bodyText: JSON.stringify({ result: {} }) })
141
+ var plugin = await loadPlugin()
142
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
143
+ })
144
+
145
+ it("throws when response has no displayText", async () => {
146
+ var ctx = makeCtx()
147
+ writeSecrets(ctx)
148
+ ctx.host.http.request.mockReturnValue({
149
+ status: 200,
150
+ bodyText: JSON.stringify({ ok: true, result: {} }),
151
+ })
152
+ var plugin = await loadPlugin()
153
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
154
+ })
155
+
156
+ it("throws when free tier present but unparseable", async () => {
157
+ var ctx = makeCtx()
158
+ writeSecrets(ctx)
159
+ ctx.host.http.request.mockReturnValue(balanceResponse("Amp Free: unparseable data"))
160
+ var plugin = await loadPlugin()
161
+ expect(() => plugin.probe(ctx)).toThrow("Could not parse usage data")
162
+ })
163
+
164
+ // --- Balance parsing ---
165
+
166
+ it("parses standard balance text", async () => {
167
+ var ctx = makeCtx()
168
+ writeSecrets(ctx)
169
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
170
+ remaining: 1.66, total: 20, rate: 0.83,
171
+ })))
172
+ var plugin = await loadPlugin()
173
+ var result = plugin.probe(ctx)
174
+ var line = result.lines[0]
175
+ expect(line.type).toBe("progress")
176
+ expect(line.label).toBe("Free")
177
+ expect(line.used).toBeCloseTo(18.34, 2)
178
+ expect(line.limit).toBe(20)
179
+ expect(line.format.kind).toBe("dollars")
180
+ })
181
+
182
+ it("parses balance with no bonus bracket", async () => {
183
+ var ctx = makeCtx()
184
+ writeSecrets(ctx)
185
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
186
+ remaining: 10, total: 20, rate: 0.83, bonus: false,
187
+ })))
188
+ var plugin = await loadPlugin()
189
+ var result = plugin.probe(ctx)
190
+ expect(result.lines.length).toBe(1)
191
+ expect(result.lines[0].used).toBe(10)
192
+ })
193
+
194
+ it("includes bonus text line when present", async () => {
195
+ var ctx = makeCtx()
196
+ writeSecrets(ctx)
197
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
198
+ bonusPct: 100, bonusDays: 2,
199
+ })))
200
+ var plugin = await loadPlugin()
201
+ var result = plugin.probe(ctx)
202
+ var bonusLine = result.lines.find(function (l) { return l.label === "Bonus" })
203
+ expect(bonusLine).toBeTruthy()
204
+ expect(bonusLine.value).toBe("+100% for 2d")
205
+ })
206
+
207
+ it("includes credits text line when credits > 0", async () => {
208
+ var ctx = makeCtx()
209
+ writeSecrets(ctx)
210
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
211
+ credits: 5.50,
212
+ })))
213
+ var plugin = await loadPlugin()
214
+ var result = plugin.probe(ctx)
215
+ var creditsLine = result.lines.find(function (l) { return l.label === "Credits" })
216
+ expect(creditsLine).toBeTruthy()
217
+ expect(creditsLine.value).toBe("$5.50")
218
+ })
219
+
220
+ it("omits credits line when credits are zero", async () => {
221
+ var ctx = makeCtx()
222
+ writeSecrets(ctx)
223
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
224
+ credits: 0,
225
+ })))
226
+ var plugin = await loadPlugin()
227
+ var result = plugin.probe(ctx)
228
+ var creditsLine = result.lines.find(function (l) { return l.label === "Credits" })
229
+ expect(creditsLine).toBeUndefined()
230
+ })
231
+
232
+ it("clamps used to 0 when remaining exceeds total", async () => {
233
+ var ctx = makeCtx()
234
+ writeSecrets(ctx)
235
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
236
+ remaining: 25, total: 20,
237
+ })))
238
+ var plugin = await loadPlugin()
239
+ var result = plugin.probe(ctx)
240
+ expect(result.lines[0].used).toBe(0)
241
+ })
242
+
243
+ // --- Reset time and period ---
244
+
245
+ it("returns resetsAt and periodDurationMs", async () => {
246
+ var ctx = makeCtx()
247
+ writeSecrets(ctx)
248
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
249
+ remaining: 1.66, total: 20, rate: 0.83,
250
+ })))
251
+ var plugin = await loadPlugin()
252
+ var result = plugin.probe(ctx)
253
+ var line = result.lines[0]
254
+ expect(line.resetsAt).toBeTruthy()
255
+ expect(line.periodDurationMs).toBe(24 * 3600 * 1000)
256
+ })
257
+
258
+ it("returns null resetsAt when nothing used", async () => {
259
+ var ctx = makeCtx()
260
+ writeSecrets(ctx)
261
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
262
+ remaining: 20, total: 20, rate: 0.83,
263
+ })))
264
+ var plugin = await loadPlugin()
265
+ var result = plugin.probe(ctx)
266
+ expect(result.lines[0].used).toBe(0)
267
+ expect(result.lines[0].resetsAt).toBeUndefined()
268
+ })
269
+
270
+ it("returns null resetsAt when hourly rate is zero", async () => {
271
+ var ctx = makeCtx()
272
+ writeSecrets(ctx)
273
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
274
+ remaining: 10, total: 20, rate: 0,
275
+ })))
276
+ var plugin = await loadPlugin()
277
+ var result = plugin.probe(ctx)
278
+ expect(result.lines[0].used).toBe(10)
279
+ expect(result.lines[0].resetsAt).toBeUndefined()
280
+ })
281
+
282
+ // --- Plan ---
283
+
284
+ it("returns Free as plan", async () => {
285
+ var ctx = makeCtx()
286
+ writeSecrets(ctx)
287
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText()))
288
+ var plugin = await loadPlugin()
289
+ var result = plugin.probe(ctx)
290
+ expect(result.plan).toBe("Free")
291
+ })
292
+
293
+ // --- Credits only ---
294
+
295
+ it("handles credits-only user", async () => {
296
+ var ctx = makeCtx()
297
+ writeSecrets(ctx)
298
+ var text = "Signed in as user@test.com (testuser)\nIndividual credits: $25.50 remaining - https://ampcode.com/settings"
299
+ ctx.host.http.request.mockReturnValue(balanceResponse(text))
300
+ var plugin = await loadPlugin()
301
+ var result = plugin.probe(ctx)
302
+ expect(result.plan).toBe("Credits")
303
+ expect(result.lines.length).toBe(1)
304
+ expect(result.lines[0].label).toBe("Credits")
305
+ expect(result.lines[0].value).toBe("$25.50")
306
+ })
307
+
308
+ it("shows both free tier and credits when both present", async () => {
309
+ var ctx = makeCtx()
310
+ writeSecrets(ctx)
311
+ ctx.host.http.request.mockReturnValue(balanceResponse(standardDisplayText({
312
+ credits: 10,
313
+ })))
314
+ var plugin = await loadPlugin()
315
+ var result = plugin.probe(ctx)
316
+ expect(result.plan).toBe("Free")
317
+ var progressLine = result.lines.find(function (l) { return l.type === "progress" })
318
+ var creditsLine = result.lines.find(function (l) { return l.label === "Credits" })
319
+ expect(progressLine).toBeTruthy()
320
+ expect(creditsLine).toBeTruthy()
321
+ expect(creditsLine.value).toBe("$10.00")
322
+ })
323
+
324
+ it("falls back to credits-only when no balance or credits parsed", async () => {
325
+ var ctx = makeCtx()
326
+ writeSecrets(ctx)
327
+ ctx.host.http.request.mockReturnValue(balanceResponse("Signed in as user@test.com (testuser)"))
328
+ var plugin = await loadPlugin()
329
+ var result = plugin.probe(ctx)
330
+ expect(result.plan).toBe("Credits")
331
+ expect(result.lines.length).toBe(1)
332
+ expect(result.lines[0].label).toBe("Credits")
333
+ expect(result.lines[0].value).toBe("$0.00")
334
+ })
335
+
336
+ // --- Credits-only $0 ---
337
+
338
+ it("shows $0.00 for credits-only user with zero balance", async () => {
339
+ var ctx = makeCtx()
340
+ writeSecrets(ctx)
341
+ var text = "Signed in as user@test.com (testuser)\nIndividual credits: $0 remaining - https://ampcode.com/settings"
342
+ ctx.host.http.request.mockReturnValue(balanceResponse(text))
343
+ var plugin = await loadPlugin()
344
+ var result = plugin.probe(ctx)
345
+ expect(result.plan).toBe("Credits")
346
+ expect(result.lines.length).toBe(1)
347
+ expect(result.lines[0].label).toBe("Credits")
348
+ expect(result.lines[0].value).toBe("$0.00")
349
+ })
350
+
351
+ // --- Regex resilience ---
352
+
353
+ it("parses balance with comma-formatted amounts", async () => {
354
+ var ctx = makeCtx()
355
+ writeSecrets(ctx)
356
+ var text = "Signed in as user@test.com (testuser)\n"
357
+ + "Amp Free: $1,000.50/$2,000 remaining (replenishes +$0.83/hour) - https://ampcode.com/settings#amp-free\n"
358
+ + "Individual credits: $0 remaining - https://ampcode.com/settings"
359
+ ctx.host.http.request.mockReturnValue(balanceResponse(text))
360
+ var plugin = await loadPlugin()
361
+ var result = plugin.probe(ctx)
362
+ expect(result.lines[0].limit).toBe(2000)
363
+ expect(result.lines[0].used).toBeCloseTo(999.50, 2)
364
+ })
365
+ })
@@ -0,0 +1,3 @@
1
+ <svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M85.2843 88.0301C90.1329 91.6664 97.4057 89.2422 90.7389 82.5755C70.7389 63.1816 74.9813 9.84827 50.1329 9.84827C25.2843 9.84827 29.5267 63.1816 9.52673 82.5755C2.25402 89.8483 10.1328 91.6664 14.9813 88.0301C33.7692 75.3028 32.5571 52.8786 50.1329 52.8786C67.7086 52.8786 66.4965 75.3028 85.2843 88.0301Z" fill="#4285F4"/>
3
+ </svg>