@openlines/opl-pi-sht 0.2.10 → 0.2.11
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/README.md +4 -2
- package/configs/opl-footer.json.sample +1 -1
- package/extensions/opl-footer/README.md +8 -0
- package/extensions/opl-footer/config.ts +1 -1
- package/extensions/opl-footer/index.ts +55 -0
- package/extensions/opl-footer/openrouter-usage.ts +77 -0
- package/extensions/opl-footer/segments/index.ts +2 -0
- package/extensions/opl-footer/segments/openrouter-usage.ts +21 -0
- package/extensions/opl-footer/types.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
A portable collection of various Pi coding agent extensions. Repository directories and config files use `opl-`; established Pi-facing commands and tool names stay compatible.
|
|
16
16
|
|
|
17
17
|
>
|
|
18
|
+
> ### v0.2.11 - OpenRouter key-limit usage in opl-footer
|
|
19
|
+
> - `opl-footer` added an OpenRouter API key's used amount, cap, and percentage.
|
|
18
20
|
> ### v0.2.10 - Codex subscription usage in opl-footer
|
|
19
21
|
> - `opl-footer` added ChatGPT Codex subs 5-hour/weekly quota segment with reset countdowns and stale-snapshot fallback.
|
|
20
22
|
> ### v0.2.9 - pi dependencies patch, updated test scripts
|
|
@@ -24,7 +26,7 @@ A portable collection of various Pi coding agent extensions. Repository director
|
|
|
24
26
|
>
|
|
25
27
|
> See [CHANGELOG](CHANGELOG.md) for more details.
|
|
26
28
|
>
|
|
27
|
-
> `Current project state also marked 'SAFE' for pi
|
|
29
|
+
> `Current project state also marked 'SAFE' for pi v1.0.0 release`
|
|
28
30
|
>
|
|
29
31
|
|
|
30
32
|
## Installation
|
|
@@ -139,7 +141,7 @@ Cold prompt-cache write, measured /init session (opl-modes lazy tools + MCP adap
|
|
|
139
141
|
|
|
140
142
|
### See what the agent is doing
|
|
141
143
|
|
|
142
|
-
- **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, per-turn timing,
|
|
144
|
+
- **`opl-footer`** surfaces model, cost, token and cache activity, git state, agent status, per-turn timing, an optional exact remaining ChatGPT Codex subscription-quota segment, and optional OpenRouter API-key cap usage. The OpenRouter segment resolves Pi's active key locally and calls only OpenRouter's key endpoint; keys and response bodies are neither persisted nor logged. `/configure-opl` edits and reorders its layout interactively.
|
|
143
145
|
- **`opl-input`** is a configurable editor that reflects the active mode's identity, so you always know which mode you are typing into.
|
|
144
146
|

|
|
145
147
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, codex_usage, text:<literal>. codex_usage
|
|
2
|
+
"_comment": "Available segment IDs: pi, model, path, git, thinking, caveman, plan_mode, chat_mode, mode_switcher, separator, token_in, token_out, token_total, cache_read, cache_write, cost, context_pct, context_total, session_stats, perf_stats, status, codex_usage, openrouter_usage, text:<literal>. codex_usage and openrouter_usage are hidden by default; add codex_usage to show ChatGPT Codex subscription windows, or openrouter_usage to show the active OpenRouter API-key limit usage.",
|
|
3
3
|
"row1LeftSegments": [
|
|
4
4
|
"pi",
|
|
5
5
|
"separator",
|
|
@@ -32,6 +32,7 @@ The third row is populated after the first completed turn. Its session and perfo
|
|
|
32
32
|
- **Git integration**: branch plus staged, unstaged, and untracked counts, with invalidation after relevant file and Git commands
|
|
33
33
|
- **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
|
|
34
34
|
- **Codex subscription quota**: optional exact 5-hour and weekly ChatGPT Codex usage percentages with reset countdowns and stale-snapshot fallback
|
|
35
|
+
- **OpenRouter key-limit usage**: optional per-key paid-usage amount, limit, and percentage with stale-snapshot fallback
|
|
35
36
|
- **Session statistics**: prompt, API-call, and model tool-call counts
|
|
36
37
|
- **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
|
|
37
38
|
- **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
|
|
@@ -93,6 +94,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
|
|
|
93
94
|
| `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
|
|
94
95
|
| `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
|
|
95
96
|
| `codex_usage` | `5h 76% ↻2h18m · W 37% ↻3d7h` | Exact remaining ChatGPT Codex subscription quota; OAuth-only, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
|
|
97
|
+
| `openrouter_usage` | `$3.4500 / $25.0000 (13.8%)` | OpenRouter API-key limit usage; only on the selected `openrouter` provider, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
|
|
96
98
|
| `separator` | `\|` divider | Coloured via `separator` in `colors` |
|
|
97
99
|
| `text:...` | Literal text, e.g. `text:⚡` | — |
|
|
98
100
|
|
|
@@ -102,6 +104,12 @@ Add `codex_usage` to any row through `/configure-opl` or `opl-footer.json`. Whil
|
|
|
102
104
|
|
|
103
105
|
No OAuth token, account ID, or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `chatgpt.com` for that request. **The footer retains only the parsed 5-hour/weekly percentage and reset time in process memory for the active session.** The endpoint is an internal ChatGPT backend API and can change without notice. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
|
|
104
106
|
|
|
107
|
+
## OpenRouter Usage
|
|
108
|
+
|
|
109
|
+
Add `openrouter_usage` to any row through `/configure-opl` or `opl-footer.json`. While the selected model provider is `openrouter`, the footer resolves the same API key Pi uses for inference and sends `GET https://openrouter.ai/api/v1/key` from the local Pi process. It renders the configured per-key limit as `$<limit - limit_remaining> / $<limit> (<percent>%)`; an absent, malformed, or unlimited key cap leaves the segment hidden. The numerator deliberately uses OpenRouter's authoritative `limit_remaining`, rather than `usage` or `byok_usage`, because those ledgers may not both count against the key limit.
|
|
110
|
+
|
|
111
|
+
It runs after session start, model selection, and settled agent turns, with a 30-second refresh floor and a 15-second request timeout. No API key or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `openrouter.ai`. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
|
|
112
|
+
|
|
105
113
|
## Context Bar
|
|
106
114
|
|
|
107
115
|
The `context_pct` segment's bar is fully configurable via `segmentOptions.contextBar`:
|
|
@@ -27,7 +27,7 @@ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
|
|
|
27
27
|
"pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
|
|
28
28
|
"chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
|
|
29
29
|
"cache_read", "cache_write", "cost", "context_pct", "context_total",
|
|
30
|
-
"session_stats", "perf_stats", "status", "codex_usage",
|
|
30
|
+
"session_stats", "perf_stats", "status", "codex_usage", "openrouter_usage",
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
|
|
@@ -13,6 +13,7 @@ import { getIcons } from "./icons.js";
|
|
|
13
13
|
import { getDefaultColors, fg } from "./theme.js";
|
|
14
14
|
import { showFooterConfigurator } from "./configure.js";
|
|
15
15
|
import { fetchCodexUsage, refreshCodexUsageSnapshot, type CodexUsageSnapshot } from "./codex-usage.js";
|
|
16
|
+
import { fetchOpenRouterUsage, refreshOpenRouterUsageSnapshot, type OpenRouterUsageSnapshot } from "./openrouter-usage.js";
|
|
16
17
|
|
|
17
18
|
const GIT_BRANCH_PATTERNS: RegExp[] = [
|
|
18
19
|
// init/clone included: creating a repo mid-session must clear the not-a-repo back-off.
|
|
@@ -123,6 +124,51 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
123
124
|
let codexUsageLastAttempt = 0;
|
|
124
125
|
let codexUsageGeneration = 0;
|
|
125
126
|
let codexUsageInFlight: Promise<void> | null = null;
|
|
127
|
+
let openRouterUsage: OpenRouterUsageSnapshot | null = null;
|
|
128
|
+
let openRouterUsageLastAttempt = 0;
|
|
129
|
+
let openRouterUsageGeneration = 0;
|
|
130
|
+
let openRouterUsageInFlight: Promise<void> | null = null;
|
|
131
|
+
|
|
132
|
+
const refreshOpenRouterUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
|
|
133
|
+
const isOpenRouter = ctx.model?.provider === "openrouter";
|
|
134
|
+
if (!isOpenRouter) {
|
|
135
|
+
openRouterUsageGeneration++;
|
|
136
|
+
openRouterUsageInFlight = null;
|
|
137
|
+
openRouterUsage = null;
|
|
138
|
+
tuiRef?.requestRender();
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const config = getEffectiveConfig();
|
|
143
|
+
const enabled = LAYOUT_ROWS.some((row) => config[row]?.includes("openrouter_usage"));
|
|
144
|
+
if (!enabled) {
|
|
145
|
+
openRouterUsageGeneration++;
|
|
146
|
+
openRouterUsageInFlight = null;
|
|
147
|
+
openRouterUsage = null;
|
|
148
|
+
openRouterUsageLastAttempt = 0;
|
|
149
|
+
tuiRef?.requestRender();
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
}
|
|
152
|
+
if (openRouterUsageInFlight) return openRouterUsageInFlight;
|
|
153
|
+
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (!force && now - openRouterUsageLastAttempt < CODEX_USAGE_REFRESH_MS) return Promise.resolve();
|
|
156
|
+
openRouterUsageLastAttempt = now;
|
|
157
|
+
const generation = ++openRouterUsageGeneration;
|
|
158
|
+
|
|
159
|
+
let request: Promise<void>;
|
|
160
|
+
request = refreshOpenRouterUsageSnapshot(openRouterUsage, () => fetchOpenRouterUsage(ctx))
|
|
161
|
+
.then((snapshot) => {
|
|
162
|
+
if (generation !== openRouterUsageGeneration) return;
|
|
163
|
+
openRouterUsage = snapshot;
|
|
164
|
+
tuiRef?.requestRender();
|
|
165
|
+
})
|
|
166
|
+
.finally(() => {
|
|
167
|
+
if (openRouterUsageInFlight === request) openRouterUsageInFlight = null;
|
|
168
|
+
});
|
|
169
|
+
openRouterUsageInFlight = request;
|
|
170
|
+
return request;
|
|
171
|
+
};
|
|
126
172
|
|
|
127
173
|
const refreshCodexUsage = (ctx: ExtensionContext, force = false): Promise<void> => {
|
|
128
174
|
const model = ctx.model;
|
|
@@ -184,6 +230,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
184
230
|
await showFooterConfigurator(ctx, () => {
|
|
185
231
|
tuiRef?.requestRender();
|
|
186
232
|
void refreshCodexUsage(ctx, true);
|
|
233
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
187
234
|
});
|
|
188
235
|
},
|
|
189
236
|
});
|
|
@@ -198,6 +245,10 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
198
245
|
codexUsageLastAttempt = 0;
|
|
199
246
|
codexUsageGeneration++;
|
|
200
247
|
codexUsageInFlight = null;
|
|
248
|
+
openRouterUsage = null;
|
|
249
|
+
openRouterUsageLastAttempt = 0;
|
|
250
|
+
openRouterUsageGeneration++;
|
|
251
|
+
openRouterUsageInFlight = null;
|
|
201
252
|
llmMs = 0;
|
|
202
253
|
toolMs = 0;
|
|
203
254
|
ttftSamples = [];
|
|
@@ -212,11 +263,13 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
212
263
|
if (ctx.hasUI) {
|
|
213
264
|
setupFooter(ctx);
|
|
214
265
|
void refreshCodexUsage(ctx, true);
|
|
266
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
215
267
|
}
|
|
216
268
|
});
|
|
217
269
|
|
|
218
270
|
pi.on("model_select", async (_event: unknown, ctx: ExtensionContext) => {
|
|
219
271
|
void refreshCodexUsage(ctx, true);
|
|
272
|
+
void refreshOpenRouterUsage(ctx, true);
|
|
220
273
|
});
|
|
221
274
|
|
|
222
275
|
// Track user-prompt-to-completion turnaround. agent_start may fire multiple
|
|
@@ -237,6 +290,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
237
290
|
statusTracker.agentSettled();
|
|
238
291
|
tuiRef?.requestRender();
|
|
239
292
|
void refreshCodexUsage(_ctx);
|
|
293
|
+
void refreshOpenRouterUsage(_ctx);
|
|
240
294
|
});
|
|
241
295
|
|
|
242
296
|
pi.on("turn_start", async (_event: unknown, _ctx: ExtensionContext) => {
|
|
@@ -402,6 +456,7 @@ export default function footer(pi: ExtensionAPI) {
|
|
|
402
456
|
sessionStats: { prompts: branchPrompts, apiCalls: branchApiCalls, toolCalls: branchToolCalls, llmMs, toolMs, ttftSamples, lastTurnaroundMs },
|
|
403
457
|
agentStatus: statusTracker.status(),
|
|
404
458
|
codexUsage,
|
|
459
|
+
openRouterUsage,
|
|
405
460
|
};
|
|
406
461
|
}
|
|
407
462
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const USAGE_URL = "https://openrouter.ai/api/v1/key";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
export interface OpenRouterUsageSnapshot {
|
|
7
|
+
used: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
stale: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
type UsageRecord = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
function asRecord(value: unknown): UsageRecord | null {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
18
|
+
? value as UsageRecord
|
|
19
|
+
: null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseOpenRouterUsage(data: unknown, fetchedAt = Date.now()): OpenRouterUsageSnapshot | null {
|
|
23
|
+
const root = asRecord(data);
|
|
24
|
+
const key = asRecord(root?.data);
|
|
25
|
+
const limit = key?.limit;
|
|
26
|
+
const remaining = key?.limit_remaining;
|
|
27
|
+
if (
|
|
28
|
+
typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0 ||
|
|
29
|
+
typeof remaining !== "number" || !Number.isFinite(remaining)
|
|
30
|
+
) return null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
used: Math.max(0, Math.round((limit - remaining) * 1_000_000_000) / 1_000_000_000),
|
|
34
|
+
limit,
|
|
35
|
+
fetchedAt,
|
|
36
|
+
stale: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function refreshOpenRouterUsageSnapshot(
|
|
41
|
+
previous: OpenRouterUsageSnapshot | null,
|
|
42
|
+
load: () => Promise<OpenRouterUsageSnapshot>,
|
|
43
|
+
): Promise<OpenRouterUsageSnapshot | null> {
|
|
44
|
+
try {
|
|
45
|
+
return await load();
|
|
46
|
+
} catch {
|
|
47
|
+
return previous ? { ...previous, stale: true } : null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function fetchOpenRouterUsage(
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
fetchFn: FetchLike = globalThis.fetch,
|
|
54
|
+
fetchedAt = Date.now(),
|
|
55
|
+
): Promise<OpenRouterUsageSnapshot> {
|
|
56
|
+
const model = ctx.model;
|
|
57
|
+
if (!model || model.provider !== "openrouter") {
|
|
58
|
+
throw new Error("OpenRouter usage requires an openrouter model");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
62
|
+
if (!auth.ok || !auth.apiKey) throw new Error("OpenRouter authentication is unavailable");
|
|
63
|
+
|
|
64
|
+
const response = await fetchFn(USAGE_URL, {
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${auth.apiKey}`,
|
|
67
|
+
Accept: "application/json",
|
|
68
|
+
"User-Agent": "opl-footer-openrouter-usage",
|
|
69
|
+
},
|
|
70
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) throw new Error(`OpenRouter usage request failed (${response.status})`);
|
|
73
|
+
|
|
74
|
+
const snapshot = parseOpenRouterUsage(await response.json(), fetchedAt);
|
|
75
|
+
if (!snapshot) throw new Error("OpenRouter usage response contained no usable key limit");
|
|
76
|
+
return snapshot;
|
|
77
|
+
}
|
|
@@ -15,6 +15,7 @@ import { modeSwitcherSegment } from "./mode-switcher.js";
|
|
|
15
15
|
import { sessionStatsSegment, perfStatsSegment } from "./session-stats.js";
|
|
16
16
|
import { statusSegment } from "./status.js";
|
|
17
17
|
import { codexUsageSegment } from "./codex-usage.js";
|
|
18
|
+
import { openRouterUsageSegment } from "./openrouter-usage.js";
|
|
18
19
|
|
|
19
20
|
const SEGMENTS = {
|
|
20
21
|
pi: piSegment,
|
|
@@ -39,6 +40,7 @@ const SEGMENTS = {
|
|
|
39
40
|
perf_stats: perfStatsSegment,
|
|
40
41
|
status: statusSegment,
|
|
41
42
|
codex_usage: codexUsageSegment,
|
|
43
|
+
openrouter_usage: openRouterUsageSegment,
|
|
42
44
|
};
|
|
43
45
|
|
|
44
46
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RenderedSegment, SegmentContext } from "../types.js";
|
|
2
|
+
import { applyColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
function formatUsd(value: number): string {
|
|
5
|
+
return `$${value.toFixed(4)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const openRouterUsageSegment = {
|
|
9
|
+
id: "openrouter_usage" as const,
|
|
10
|
+
render(ctx: SegmentContext): RenderedSegment {
|
|
11
|
+
const snapshot = ctx.openRouterUsage;
|
|
12
|
+
if (!snapshot) return { content: "", visible: false };
|
|
13
|
+
|
|
14
|
+
const percent = (snapshot.used / snapshot.limit) * 100;
|
|
15
|
+
const stale = snapshot.stale ? applyColor(ctx.theme, "dim", " (stale)") : "";
|
|
16
|
+
return {
|
|
17
|
+
content: `${formatUsd(snapshot.used)} / ${formatUsd(snapshot.limit)} (${percent.toFixed(1)}%)${stale}`,
|
|
18
|
+
visible: true,
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { IconSet } from "./icons.js";
|
|
3
3
|
import type { CodexUsageSnapshot } from "./codex-usage.js";
|
|
4
|
+
import type { OpenRouterUsageSnapshot } from "./openrouter-usage.js";
|
|
4
5
|
|
|
5
6
|
// Theme color - either a pi theme color name or a custom hex color
|
|
6
7
|
export type ColorValue = ThemeColor | `#${string}`;
|
|
@@ -56,6 +57,7 @@ export type StatusLineSegmentId =
|
|
|
56
57
|
| "perf_stats"
|
|
57
58
|
| "status"
|
|
58
59
|
| "codex_usage"
|
|
60
|
+
| "openrouter_usage"
|
|
59
61
|
| "separator"
|
|
60
62
|
| `text:${string}`;
|
|
61
63
|
|
|
@@ -134,6 +136,7 @@ export interface SegmentContext {
|
|
|
134
136
|
sessionStats: SessionStats;
|
|
135
137
|
agentStatus: AgentStatus;
|
|
136
138
|
codexUsage: CodexUsageSnapshot | null;
|
|
139
|
+
openRouterUsage: OpenRouterUsageSnapshot | null;
|
|
137
140
|
now?: number;
|
|
138
141
|
}
|
|
139
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlines/opl-pi-sht",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "opl-pi-sht (shouldHaveThat) Pi coding-agent extension kit: token-efficient context, tool and session safety, fingerprinted /init, model benchmarking, web access, and browser automation.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|