@pragma-sh/junie-plugin 0.1.0-alpha.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.
- package/.junie-plugin/plugin.json +8 -0
- package/assets/junie.svg +17 -0
- package/dist/install.mjs +61 -0
- package/dist/pragma-plugin.mjs +700 -0
- package/hooks/hooks.json +81 -0
- package/hooks/report.sh +715 -0
- package/package.json +42 -0
- package/pragma-plugin.json +14 -0
- package/scripts/install-local.ts +127 -0
- package/src/acp.ts +223 -0
- package/src/pragma-plugin.test.ts +82 -0
- package/src/pragma-plugin.ts +193 -0
- package/src/usage-limits.test.ts +95 -0
- package/src/usage-limits.ts +225 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildResult,
|
|
5
|
+
parseJunieUsage,
|
|
6
|
+
parsePeaksFile,
|
|
7
|
+
peakKey,
|
|
8
|
+
PRIMARY_LIMIT_ID,
|
|
9
|
+
serializePeaksFile,
|
|
10
|
+
} from "./usage-limits";
|
|
11
|
+
|
|
12
|
+
/** The exact report Junie 26.8.3 renders for a trial account. */
|
|
13
|
+
const TRIAL_REPORT = [
|
|
14
|
+
"Session usage ",
|
|
15
|
+
"Here is a breakdown of the usage for the current session: ",
|
|
16
|
+
"",
|
|
17
|
+
"**Total usage** ",
|
|
18
|
+
"Total tokens used: 0 ",
|
|
19
|
+
"",
|
|
20
|
+
"License: JetBrains Trial ",
|
|
21
|
+
"Balance left: $4.99 ",
|
|
22
|
+
"Top-up: https://jb.gg/junie_top_up",
|
|
23
|
+
].join("\n");
|
|
24
|
+
|
|
25
|
+
describe("parseJunieUsage", () => {
|
|
26
|
+
it("reads a money balance", () => {
|
|
27
|
+
expect(parseJunieUsage(TRIAL_REPORT)).toEqual({
|
|
28
|
+
licenseType: "JetBrains Trial",
|
|
29
|
+
remaining: 4.99,
|
|
30
|
+
unit: "$",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads a credit quota", () => {
|
|
35
|
+
expect(parseJunieUsage("License: AI Pro\nQuota: 1,250 credits remaining")).toEqual({
|
|
36
|
+
licenseType: "AI Pro",
|
|
37
|
+
remaining: 1250,
|
|
38
|
+
unit: "credits",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when Junie defers to the IDE", () => {
|
|
43
|
+
expect(
|
|
44
|
+
parseJunieUsage("You can check your quota by clicking the JetBrains AI icon."),
|
|
45
|
+
).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("buildResult", () => {
|
|
50
|
+
const usage = { licenseType: "JetBrains Trial", remaining: 3, unit: "$" };
|
|
51
|
+
|
|
52
|
+
it("measures spend against the highest balance seen", () => {
|
|
53
|
+
expect(buildResult(usage, 5, 1000)).toEqual({
|
|
54
|
+
status: "ready",
|
|
55
|
+
observedAt: 1000,
|
|
56
|
+
limits: [{ id: PRIMARY_LIMIT_ID, title: "JetBrains Trial credits", used: 2, limit: 5 }],
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("reads 0% used on the first observation", () => {
|
|
61
|
+
expect(buildResult(usage, 3, 1000)).toMatchObject({
|
|
62
|
+
limits: [expect.objectContaining({ used: 0, limit: 3 })],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reports unavailable rather than an empty bar without a peak", () => {
|
|
67
|
+
expect(buildResult({ ...usage, remaining: 0 }, 0, 1000)).toEqual({
|
|
68
|
+
status: "unavailable",
|
|
69
|
+
reason: "unsupported",
|
|
70
|
+
message: "Junie reports no balance left.",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("peakKey", () => {
|
|
76
|
+
it("separates licenses and units", () => {
|
|
77
|
+
expect(peakKey({ licenseType: "AI Pro", remaining: 1, unit: "credits" })).toBe(
|
|
78
|
+
"AI Pro|credits",
|
|
79
|
+
);
|
|
80
|
+
expect(peakKey({ licenseType: null, remaining: 1, unit: "$" })).toBe("unknown|$");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("parsePeaksFile", () => {
|
|
85
|
+
it("round-trips a valid cache", () => {
|
|
86
|
+
const file = { version: 1 as const, peaks: { "AI Pro|credits": 1250 } };
|
|
87
|
+
expect(parsePeaksFile(serializePeaksFile(file))).toEqual(file);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("treats missing or corrupt input as empty", () => {
|
|
91
|
+
expect(parsePeaksFile("")).toEqual({ version: 1, peaks: {} });
|
|
92
|
+
expect(parsePeaksFile("not-json")).toEqual({ version: 1, peaks: {} });
|
|
93
|
+
expect(parsePeaksFile('{"version":2,"peaks":{}}')).toEqual({ version: 1, peaks: {} });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Junie's account quota, read through the ACP `/usage` slash command.
|
|
2
|
+
//
|
|
3
|
+
// JetBrains reports a *remaining* balance only — `IngrazzioAuthInfo` carries
|
|
4
|
+
// `balanceLeft` plus its unit and the license type, and nothing anywhere in the
|
|
5
|
+
// CLI exposes the period's starting allowance. Pragma's `UsageLimit` is a
|
|
6
|
+
// used/limit pair, so the denominator is reconstructed from the highest balance
|
|
7
|
+
// this machine has observed (`~/.pragma/cache/junie-usage.json`): a balance only
|
|
8
|
+
// falls as credits are spent and only rises on a top-up or renewal, so the peak
|
|
9
|
+
// is exactly the current period's allowance once one full period has been seen.
|
|
10
|
+
// The first observation therefore reads 0% used and the bar becomes meaningful
|
|
11
|
+
// as the balance drops.
|
|
12
|
+
//
|
|
13
|
+
// Peak I/O goes through `ctx.sdk.exec.run`, never `node:fs`: the same bundle
|
|
14
|
+
// loads inside the production desktop webview (blob-URL import), where Node
|
|
15
|
+
// built-ins make the whole plugin fail to load, and a direct local read would
|
|
16
|
+
// also target the wrong machine for a remote project.
|
|
17
|
+
import type { PluginContext, UsageLimit, UsageLimitsResult } from "@pragma/plugin/catalog";
|
|
18
|
+
|
|
19
|
+
import { readJunieAcp, shellQuote } from "./acp";
|
|
20
|
+
|
|
21
|
+
/** Id of the category rendered in the provider's collapsed summary row. */
|
|
22
|
+
export const PRIMARY_LIMIT_ID = "credits";
|
|
23
|
+
|
|
24
|
+
/** Peak-cache path on the project's host (expanded by the shell). */
|
|
25
|
+
const PEAKS_DIR = '"$HOME"/.pragma/cache';
|
|
26
|
+
const PEAKS_FILE = `${PEAKS_DIR}/junie-usage.json`;
|
|
27
|
+
|
|
28
|
+
/** Bump when {@link PeaksFile} gains a field an older build would misread. */
|
|
29
|
+
const PEAKS_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** One parsed `/usage` report. */
|
|
32
|
+
export interface JunieUsage {
|
|
33
|
+
/** Junie's license name, e.g. `JetBrains Trial`. Null when unreported. */
|
|
34
|
+
licenseType: string | null;
|
|
35
|
+
/** Remaining balance in `unit`. */
|
|
36
|
+
remaining: number;
|
|
37
|
+
/** `$` for a money balance, `credits` for a credit balance. */
|
|
38
|
+
unit: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** On-disk peak cache, keyed by `<license>|<unit>`. */
|
|
42
|
+
export interface PeaksFile {
|
|
43
|
+
version: number;
|
|
44
|
+
peaks: Record<string, number>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Loads Junie plan usage through its ACP `/usage` session command. */
|
|
48
|
+
export async function loadJunieUsageLimits(ctx: PluginContext): Promise<UsageLimitsResult> {
|
|
49
|
+
const { missing, unsupportedShell, usageText } = await readJunieAcp(ctx, { usage: true });
|
|
50
|
+
if (unsupportedShell) {
|
|
51
|
+
return {
|
|
52
|
+
status: "unavailable",
|
|
53
|
+
reason: "unsupported",
|
|
54
|
+
message: "Junie usage limits require a POSIX shell on the project's host.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (missing) {
|
|
58
|
+
return {
|
|
59
|
+
status: "unavailable",
|
|
60
|
+
reason: "not-configured",
|
|
61
|
+
message: "Install the Junie CLI to load usage limits.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (usageText === null || usageText.trim() === "") {
|
|
65
|
+
throw new Error("Junie returned no usage report");
|
|
66
|
+
}
|
|
67
|
+
if (/log ?in|sign ?in|authenticat|not authorized/i.test(usageText)) {
|
|
68
|
+
return {
|
|
69
|
+
status: "unavailable",
|
|
70
|
+
reason: "authentication-required",
|
|
71
|
+
message: "Run `junie` and sign in to your JetBrains account to load usage limits.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const usage = parseJunieUsage(usageText);
|
|
75
|
+
if (usage === null) {
|
|
76
|
+
return {
|
|
77
|
+
status: "unavailable",
|
|
78
|
+
reason: "unsupported",
|
|
79
|
+
message: "Junie did not report a balance for this license.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const peak = await recordPeak(ctx, peakKey(usage), usage.remaining);
|
|
83
|
+
return buildResult(usage, peak, Date.now());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extracts the balance from a `/usage` report.
|
|
88
|
+
*
|
|
89
|
+
* Junie renders one of two lines depending on the license: `Balance left: $4.99`
|
|
90
|
+
* for a money balance, `Quota: 250 credits remaining` for a credit balance.
|
|
91
|
+
* Anything else (notably the "check your quota by clicking the JetBrains AI
|
|
92
|
+
* icon" text Junie prints when it runs inside an IDE) yields null.
|
|
93
|
+
*/
|
|
94
|
+
export function parseJunieUsage(text: string): JunieUsage | null {
|
|
95
|
+
const licenseType = /^\s*-?\s*License:\s*(.+?)\s*$/m.exec(text)?.[1] ?? null;
|
|
96
|
+
const money = /^\s*-?\s*Balance left:\s*([^\d\s]*)\s*([\d.,]+)\s*$/m.exec(text);
|
|
97
|
+
if (money) {
|
|
98
|
+
const remaining = parseAmount(money[2]);
|
|
99
|
+
return remaining === null ? null : { licenseType, remaining, unit: money[1] || "$" };
|
|
100
|
+
}
|
|
101
|
+
const credits = /^\s*-?\s*Quota:\s*([\d.,]+)\s*(\S+)?\s*remaining\s*$/m.exec(text);
|
|
102
|
+
if (credits) {
|
|
103
|
+
const remaining = parseAmount(credits[1]);
|
|
104
|
+
return remaining === null ? null : { licenseType, remaining, unit: credits[2] ?? "credits" };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Turns a parsed balance and its observed peak into Pragma's used/limit pair. */
|
|
110
|
+
export function buildResult(
|
|
111
|
+
usage: JunieUsage,
|
|
112
|
+
peak: number,
|
|
113
|
+
observedAt: number,
|
|
114
|
+
): UsageLimitsResult {
|
|
115
|
+
if (peak <= 0) {
|
|
116
|
+
return {
|
|
117
|
+
status: "unavailable",
|
|
118
|
+
reason: "unsupported",
|
|
119
|
+
message: `Junie reports no ${usage.unit === "$" ? "balance" : usage.unit} left.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const limit: UsageLimit = {
|
|
123
|
+
id: PRIMARY_LIMIT_ID,
|
|
124
|
+
title: usage.licenseType === null ? "Credits" : `${usage.licenseType} credits`,
|
|
125
|
+
used: Math.min(peak, Math.max(0, peak - usage.remaining)),
|
|
126
|
+
limit: peak,
|
|
127
|
+
};
|
|
128
|
+
return { status: "ready", observedAt, limits: [limit] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Cache key: a license change or a unit change starts its own period. */
|
|
132
|
+
export function peakKey(usage: JunieUsage): string {
|
|
133
|
+
return `${usage.licenseType ?? "unknown"}|${usage.unit}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Returns the highest balance seen for `key`, raising the stored peak first when
|
|
138
|
+
* the current balance exceeds it. Cache failures are not fatal: a missing or
|
|
139
|
+
* unreadable file just means the current balance becomes the peak.
|
|
140
|
+
*/
|
|
141
|
+
async function recordPeak(ctx: PluginContext, key: string, remaining: number): Promise<number> {
|
|
142
|
+
const cwd = ctx.project?.path ?? "/tmp";
|
|
143
|
+
const file = parsePeaksFile(await readPeaksText(ctx, cwd));
|
|
144
|
+
const stored = file.peaks[key] ?? 0;
|
|
145
|
+
if (remaining <= stored) {
|
|
146
|
+
return stored;
|
|
147
|
+
}
|
|
148
|
+
file.peaks[key] = remaining;
|
|
149
|
+
try {
|
|
150
|
+
await writePeaksText(ctx, cwd, serializePeaksFile(file));
|
|
151
|
+
} catch {
|
|
152
|
+
// A read-only or unwritable home directory only costs us the history, not
|
|
153
|
+
// the reading: the peak falls back to this observation for this refresh.
|
|
154
|
+
}
|
|
155
|
+
return remaining;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Reads the peak-cache file contents from the project's host. */
|
|
159
|
+
async function readPeaksText(ctx: PluginContext, cwd: string): Promise<string> {
|
|
160
|
+
try {
|
|
161
|
+
const [result] = await ctx.sdk.exec.run({
|
|
162
|
+
cwd,
|
|
163
|
+
commands: [`cat ${PEAKS_FILE} 2>/dev/null || true`],
|
|
164
|
+
});
|
|
165
|
+
return result?.stdout ?? "";
|
|
166
|
+
} catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Writes the peak-cache file on the project's host. */
|
|
172
|
+
async function writePeaksText(ctx: PluginContext, cwd: string, contents: string): Promise<void> {
|
|
173
|
+
const [result] = await ctx.sdk.exec.run({
|
|
174
|
+
cwd,
|
|
175
|
+
commands: [`mkdir -p ${PEAKS_DIR} && printf '%s\\n' ${shellQuote(contents)} > ${PEAKS_FILE}`],
|
|
176
|
+
});
|
|
177
|
+
if (!result || result.status !== 0) {
|
|
178
|
+
throw new Error(result?.stderr.trim() || "failed to write Junie usage cache");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parses peak-cache JSON, treating any unusable payload as empty. */
|
|
183
|
+
export function parsePeaksFile(text: string): PeaksFile {
|
|
184
|
+
const trimmed = text.trim();
|
|
185
|
+
if (trimmed === "") {
|
|
186
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
190
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
191
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
192
|
+
}
|
|
193
|
+
const record = parsed as Partial<PeaksFile>;
|
|
194
|
+
if (
|
|
195
|
+
record.version !== PEAKS_VERSION ||
|
|
196
|
+
typeof record.peaks !== "object" ||
|
|
197
|
+
record.peaks === null
|
|
198
|
+
) {
|
|
199
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
200
|
+
}
|
|
201
|
+
const peaks: Record<string, number> = {};
|
|
202
|
+
for (const [key, value] of Object.entries(record.peaks)) {
|
|
203
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
204
|
+
peaks[key] = value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { version: PEAKS_VERSION, peaks };
|
|
208
|
+
} catch {
|
|
209
|
+
return { version: PEAKS_VERSION, peaks: {} };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Serializes a peak cache for the on-disk JSON file. */
|
|
214
|
+
export function serializePeaksFile(file: PeaksFile): string {
|
|
215
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Parses `1,234.56` or `4.99` into a finite number, or null. */
|
|
219
|
+
function parseAmount(value: string | undefined): number | null {
|
|
220
|
+
if (value === undefined) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const parsed = Number.parseFloat(value.replaceAll(",", ""));
|
|
224
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
225
|
+
}
|