Skip to content

Commit 1cf9c68

Browse files
authored
Refactor plugins to use ctx.util helpers (#54)
* Add shared plugin helper API * Add shared plugin helpers * Add ctx.util helpers dedup
1 parent 9e276bf commit 1cf9c68

18 files changed

Lines changed: 529 additions & 392 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Panel footer ticker sync
2+
3+
Date: 2026-02-03
4+
5+
## Goal
6+
- Ensure countdown syncs immediately when `autoUpdateNextAt` changes.
7+
8+
## Change
9+
- Add optional `resetKey` to `useNowTicker` and include it in effect deps.
10+
- Pass `resetKey: autoUpdateNextAt` in `PanelFooter`.
11+
12+
## Non-goals
13+
- No behavior changes elsewhere.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Plugin util + dedup refactor
2+
3+
Date: 2026-02-03
4+
5+
## Goal
6+
- Add `ctx.util` helpers injected by host for shared JSON/HTTP/retry/time logic.
7+
- Refactor claude/codex/cursor plugins to use `ctx.util` helpers.
8+
- Align plugin tests with new ctx surface.
9+
- Dedup frontend plugin display state type, timer logic, settings constants.
10+
11+
## Non-goals
12+
- No plugin bundling.
13+
- No behavior changes beyond refactor/dedup.
14+
15+
## API additions
16+
- `ctx.util.tryParseJson(text)` -> value | null
17+
- `ctx.util.safeJsonParse(text)` -> { ok: true, value } | { ok: false }
18+
- `ctx.util.request(opts)` -> resp
19+
- `ctx.util.requestJson(opts)` -> { resp, json }
20+
- `ctx.util.isAuthStatus(status)` -> boolean
21+
- `ctx.util.retryOnceOnAuth({ request, refresh })` -> resp
22+
- `ctx.util.parseDateMs(value)` -> number | null
23+
- `ctx.util.needsRefreshByExpiry({ nowMs, expiresAtMs, bufferMs })` -> boolean
24+
25+
## Plan
26+
1. Inject `ctx.util` in `src-tauri/src/plugin_engine/host_api.rs`.
27+
2. Refactor claude/codex/cursor plugins to use `ctx.util` helpers.
28+
3. Create `plugins/test-helpers.js`, update plugin tests to use it.
29+
4. Frontend dedup: shared `PluginDisplayState`, `useNowTicker`, settings options constants.
30+
5. Verify: `bun run test`, `bun run build`.

plugins/claude/plugin.js

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,15 @@
9696
return out
9797
}
9898

99-
function tryParseCredentialJSON(text) {
99+
function tryParseCredentialJSON(ctx, text) {
100100
if (!text) return null
101-
const trimmed = String(text).trim()
102-
if (!trimmed) return null
103-
try {
104-
return JSON.parse(trimmed)
105-
} catch {}
101+
const parsed = ctx.util.tryParseJson(text)
102+
if (parsed) return parsed
106103

107104
// Some macOS keychain items are returned by `security ... -w` as hex-encoded UTF-8 bytes.
108105
// Example prefix: "7b0a" ( "{\\n" ).
109106
// Support both plain hex and "0x..." forms.
110-
let hex = trimmed
107+
let hex = String(text).trim()
111108
if (hex.startsWith("0x") || hex.startsWith("0X")) hex = hex.slice(2)
112109
if (!hex || hex.length % 2 !== 0) return null
113110
if (!/^[0-9a-fA-F]+$/.test(hex)) return null
@@ -117,7 +114,8 @@
117114
bytes.push(parseInt(hex.slice(i, i + 2), 16))
118115
}
119116
const decoded = utf8DecodeBytes(bytes)
120-
return JSON.parse(decoded)
117+
const decodedParsed = ctx.util.tryParseJson(decoded)
118+
if (decodedParsed) return decodedParsed
121119
} catch {}
122120

123121
return null
@@ -128,7 +126,7 @@
128126
if (ctx.host.fs.exists(CRED_FILE)) {
129127
try {
130128
const text = ctx.host.fs.readText(CRED_FILE)
131-
const parsed = tryParseCredentialJSON(text)
129+
const parsed = tryParseCredentialJSON(ctx, text)
132130
if (parsed) {
133131
const oauth = parsed.claudeAiOauth
134132
if (oauth && oauth.accessToken) {
@@ -143,7 +141,7 @@
143141
try {
144142
const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE)
145143
if (keychainValue) {
146-
const parsed = tryParseCredentialJSON(keychainValue)
144+
const parsed = tryParseCredentialJSON(ctx, keychainValue)
147145
if (parsed) {
148146
const oauth = parsed.claudeAiOauth
149147
if (oauth && oauth.accessToken) {
@@ -174,19 +172,20 @@
174172
}
175173
}
176174

177-
function needsRefresh(oauth, nowMs) {
178-
if (!oauth.expiresAt) return true
179-
const expiresAt = Number(oauth.expiresAt)
180-
if (!Number.isFinite(expiresAt)) return true
181-
return nowMs + REFRESH_BUFFER_MS >= expiresAt
175+
function needsRefresh(ctx, oauth, nowMs) {
176+
return ctx.util.needsRefreshByExpiry({
177+
nowMs,
178+
expiresAtMs: oauth.expiresAt,
179+
bufferMs: REFRESH_BUFFER_MS,
180+
})
182181
}
183182

184183
function refreshToken(ctx, creds) {
185184
const { oauth, source, fullData } = creds
186185
if (!oauth.refreshToken) return null
187186

188187
try {
189-
const resp = ctx.host.http.request({
188+
const resp = ctx.util.request({
190189
method: "POST",
191190
url: REFRESH_URL,
192191
headers: { "Content-Type": "application/json" },
@@ -201,18 +200,17 @@
201200

202201
if (resp.status === 400 || resp.status === 401) {
203202
let errorCode = null
204-
try {
205-
const body = JSON.parse(resp.bodyText)
206-
errorCode = body.error || body.error_description
207-
} catch {}
203+
const body = ctx.util.tryParseJson(resp.bodyText)
204+
if (body) errorCode = body.error || body.error_description
208205
if (errorCode === "invalid_grant") {
209206
throw "Session expired. Run `claude` to log in again."
210207
}
211208
throw "Token expired. Run `claude` to log in again."
212209
}
213210
if (resp.status < 200 || resp.status >= 300) return null
214211

215-
const body = JSON.parse(resp.bodyText)
212+
const body = ctx.util.tryParseJson(resp.bodyText)
213+
if (!body) return null
216214
const newAccessToken = body.access_token
217215
if (!newAccessToken) return null
218216

@@ -235,7 +233,7 @@
235233
}
236234

237235
function fetchUsage(ctx, accessToken) {
238-
return ctx.host.http.request({
236+
return ctx.util.request({
239237
method: "GET",
240238
url: USAGE_URL,
241239
headers: {
@@ -251,8 +249,8 @@
251249

252250
function getResetInFromIso(ctx, isoString) {
253251
if (!isoString) return null
254-
const ts = Date.parse(isoString)
255-
if (!Number.isFinite(ts)) return null
252+
const ts = ctx.util.parseDateMs(isoString)
253+
if (ts === null) return null
256254
const diffSeconds = Math.floor((ts - Date.now()) / 1000)
257255
return ctx.fmt.resetIn(diffSeconds)
258256
}
@@ -267,42 +265,46 @@
267265
let accessToken = creds.oauth.accessToken
268266

269267
// Proactively refresh if token is expired or about to expire
270-
if (needsRefresh(creds.oauth, nowMs)) {
268+
if (needsRefresh(ctx, creds.oauth, nowMs)) {
271269
const refreshed = refreshToken(ctx, creds)
272270
if (refreshed) accessToken = refreshed
273271
}
274272

275273
let resp
274+
let didRefresh = false
276275
try {
277-
resp = fetchUsage(ctx, accessToken)
276+
resp = ctx.util.retryOnceOnAuth({
277+
request: (token) => {
278+
try {
279+
return fetchUsage(ctx, token || accessToken)
280+
} catch (e) {
281+
if (didRefresh) {
282+
throw "Usage request failed after refresh. Try again."
283+
}
284+
throw "Usage request failed. Check your connection."
285+
}
286+
},
287+
refresh: () => {
288+
didRefresh = true
289+
return refreshToken(ctx, creds)
290+
},
291+
})
278292
} catch (e) {
293+
if (typeof e === "string") throw e
279294
throw "Usage request failed. Check your connection."
280295
}
281296

282-
// On 401/403, try refreshing once and retry
283-
if (resp.status === 401 || resp.status === 403) {
284-
const refreshed = refreshToken(ctx, creds)
285-
if (!refreshed) {
286-
throw "Token expired. Run `claude` to log in again."
287-
}
288-
try {
289-
resp = fetchUsage(ctx, refreshed)
290-
} catch (e) {
291-
throw "Usage request failed after refresh. Try again."
292-
}
293-
if (resp.status === 401 || resp.status === 403) {
294-
throw "Token expired. Run `claude` to log in again."
295-
}
297+
if (ctx.util.isAuthStatus(resp.status)) {
298+
throw "Token expired. Run `claude` to log in again."
296299
}
297300

298301
if (resp.status < 200 || resp.status >= 300) {
299302
throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later."
300303
}
301304

302305
let data
303-
try {
304-
data = JSON.parse(resp.bodyText)
305-
} catch {
306+
data = ctx.util.tryParseJson(resp.bodyText)
307+
if (data === null) {
306308
throw "Usage response invalid. Try again later."
307309
}
308310

plugins/claude/plugin.test.js

Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,11 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
import { makeCtx } from "../test-helpers.js"
23

34
const loadPlugin = async () => {
45
await import("./plugin.js")
56
return globalThis.__openusage_plugin
67
}
78

8-
const makeCtx = () => {
9-
const files = new Map()
10-
return {
11-
host: {
12-
fs: {
13-
exists: (path) => files.has(path),
14-
readText: (path) => files.get(path),
15-
writeText: vi.fn((path, text) => files.set(path, text)),
16-
},
17-
keychain: {
18-
readGenericPassword: vi.fn(),
19-
writeGenericPassword: vi.fn(),
20-
},
21-
http: {
22-
request: vi.fn(),
23-
},
24-
log: {
25-
error: vi.fn(),
26-
},
27-
},
28-
line: {
29-
text: (opts) => {
30-
const line = { type: "text", label: opts.label, value: opts.value }
31-
if (opts.color) line.color = opts.color
32-
if (opts.subtitle) line.subtitle = opts.subtitle
33-
return line
34-
},
35-
progress: (opts) => {
36-
const line = { type: "progress", label: opts.label, value: opts.value, max: opts.max }
37-
if (opts.unit) line.unit = opts.unit
38-
if (opts.color) line.color = opts.color
39-
if (opts.subtitle) line.subtitle = opts.subtitle
40-
return line
41-
},
42-
badge: (opts) => {
43-
const line = { type: "badge", label: opts.label, text: opts.text }
44-
if (opts.color) line.color = opts.color
45-
if (opts.subtitle) line.subtitle = opts.subtitle
46-
return line
47-
},
48-
},
49-
fmt: {
50-
planLabel: (value) => {
51-
const text = String(value || "").trim()
52-
if (!text) return ""
53-
return text.replace(/(^|\s)([a-z])/g, (match, space, letter) => space + letter.toUpperCase())
54-
},
55-
resetIn: (secondsUntil) => {
56-
if (!Number.isFinite(secondsUntil) || secondsUntil < 0) return null
57-
const totalMinutes = Math.floor(secondsUntil / 60)
58-
const totalHours = Math.floor(totalMinutes / 60)
59-
const days = Math.floor(totalHours / 24)
60-
const hours = totalHours % 24
61-
const minutes = totalMinutes % 60
62-
if (days > 0) return `${days}d ${hours}h`
63-
if (totalHours > 0) return `${totalHours}h ${minutes}m`
64-
if (totalMinutes > 0) return `${totalMinutes}m`
65-
return "<1m"
66-
},
67-
dollars: (cents) => Math.round((cents / 100) * 100) / 100,
68-
date: (unixMs) => {
69-
const d = new Date(Number(unixMs))
70-
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
71-
return months[d.getMonth()] + " " + String(d.getDate())
72-
},
73-
},
74-
}
75-
}
76-
779
describe("claude plugin", () => {
7810
beforeEach(() => {
7911
delete globalThis.__openusage_plugin

0 commit comments

Comments
 (0)