@toninho09/opencode-usage 1.0.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/AGENTS.md ADDED
@@ -0,0 +1,226 @@
1
+ # OpenCode Plugin - Agent Guidelines
2
+
3
+ ## Development Commands
4
+
5
+ ### Type Checking
6
+ ```bash
7
+ npx tsc --noEmit
8
+ ```
9
+ Run TypeScript compiler in check-only mode. No files are emitted due to `noEmit: true` in tsconfig.json.
10
+
11
+ **Note:** This project has no build, lint, or test scripts configured in package.json. Focus on type correctness and follow established code patterns.
12
+
13
+ ## Code Style Guidelines
14
+
15
+ ### Imports
16
+ - Use ES6 module syntax with named imports preferred over default imports
17
+ - Explicit type-only imports with `import type { }` for TypeScript types
18
+ - Node.js built-in modules: Use `import * as fs from "fs"` or modern `import { } from "node:stream"` protocol
19
+ - Import order: type imports first, then external dependencies, then internal modules
20
+ - Group related imports together
21
+
22
+ ```typescript
23
+ import type { Plugin } from "@opencode-ai/plugin"
24
+ import * as fs from "fs"
25
+ import * as path from "path"
26
+ import * as os from "os"
27
+ import { fetchWithTimeout, createProgressBar } from "./utils"
28
+ import type { QueryResult } from "./types"
29
+ ```
30
+
31
+ ### Formatting
32
+ - **Indentation:** 2 spaces (no tabs)
33
+ - **Semicolons:** Required at end of statements
34
+ - **Quotes:** Double quotes for strings (observed in codebase)
35
+ - **Trailing commas:** Allowed in multi-line arrays/objects
36
+ - **Line length:** Reasonable limits, prioritize readability over strict line length
37
+
38
+ ### TypeScript & Types
39
+ - **Interfaces:** Use for object shapes, API responses, and contracts (`interface QueryResult`, `interface AuthConfig`)
40
+ - **Type aliases:** Use for unions and when exporting existing types (`export type { CopilotUsageResponse }`)
41
+ - **Annotations:** Explicit types required for function parameters and return types
42
+ - **Type assertions:** Use `as Type` sparingly, prefer type guards and narrowing
43
+ - **External types:** Use `any` for library types without definitions, add comments explaining usage
44
+ - **Strict mode:** Enabled in tsconfig.json - no implicit any, strict null checks
45
+
46
+ ```typescript
47
+ interface AuthProvider {
48
+ access?: string
49
+ refresh?: string
50
+ expires?: number
51
+ username?: string
52
+ type?: string
53
+ }
54
+
55
+ interface AuthConfig {
56
+ "github-copilot"?: AuthProvider
57
+ "anthropic"?: AuthProvider
58
+ }
59
+
60
+ export async function fetchCopilotUsage(authData: AuthProvider): Promise<CopilotUsageResponse>
61
+ ```
62
+
63
+ ### Naming Conventions
64
+ - **Functions/Variables:** camelCase (`getCopilotUsageData`, `oauthToken`, `resetCountdown`)
65
+ - **Interfaces/Types:** PascalCase (`QueryResult`, `CopilotUsageResponse`, `AuthProvider`)
66
+ - **Constants:** SCREAMING_SNAKE_CASE (`GITHUB_API_BASE_URL`, `COPILOT_HEADERS`, `AUTH_CONFIG_PATH`)
67
+ - **File names:** kebab-case (`copilot-handler.ts`, `notification.ts`, `usage-handler.ts`)
68
+ - **Private functions:** No prefix, simply not exported from module
69
+ - **Exported functions:** Export individually using `export function` or `export async function`
70
+
71
+ ### Error Handling
72
+ - Always wrap network operations in try-catch blocks
73
+ - Check `instanceof Error` before accessing error properties
74
+ - Throw errors for critical failures (authentication, configuration issues)
75
+ - Return `null` for non-critical failures (token exchange, optional file reads)
76
+ - Provide descriptive error messages with context and status codes
77
+
78
+ ```typescript
79
+ // Critical error - throw
80
+ if (!oauthToken) {
81
+ throw new Error("No OAuth token found in auth data")
82
+ }
83
+
84
+ // Network error with detailed context
85
+ try {
86
+ const response = await fetchWithTimeout(url, { headers })
87
+ if (!response.ok) {
88
+ const errorText = await response.text()
89
+ throw new Error(`GitHub API Error ${response.status}: ${errorText}`)
90
+ }
91
+ return await response.json()
92
+ } catch (err) {
93
+ return {
94
+ success: false,
95
+ error: err instanceof Error ? err.message : String(err)
96
+ }
97
+ }
98
+
99
+ // Non-critical failure - return null
100
+ function readAuthConfig(): AuthConfig | null {
101
+ try {
102
+ if (!fs.existsSync(AUTH_CONFIG_PATH)) {
103
+ return null
104
+ }
105
+ const content = fs.readFileSync(AUTH_CONFIG_PATH, "utf-8")
106
+ return JSON.parse(content) as AuthConfig
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+ ```
112
+
113
+ ### File Organization
114
+ - **Entry point:** `index.ts` - plugin registration, command setup, exports `TestPlugin`
115
+ - **Library code:** `lib/` directory for all implementation
116
+ - **Types:** `lib/types.ts` - shared interfaces and type definitions
117
+ - **Utilities:** `lib/utils.ts` - reusable helper functions (`fetchWithTimeout`, `createProgressBar`)
118
+ - **Feature modules:** One concern per file (`copilot.ts`, `claude.ts`)
119
+ - **Handlers:** `lib/*-handler.ts` - command handlers with output formatting
120
+ - **Services:** `lib/notification.ts` - cross-cutting services like message sending
121
+
122
+ ### Function Design
123
+ - **Small functions:** Single responsibility principle, aim for < 50 lines per function
124
+ - **Internal vs exported:** Keep implementation details internal, export only public APIs
125
+ - **Parameter objects:** Use interfaces for complex function parameters
126
+ - **Pure functions:** Prefer pure functions for formatting and data transformation
127
+ - **Async patterns:** Use explicit `async/await`, avoid callback patterns
128
+
129
+ ```typescript
130
+ // Internal helper - not exported
131
+ function formatQuotaLine(name: string, quota: QuotaDetail | undefined, width: number = 20): string {
132
+ if (!quota) return ""
133
+ if (quota.unlimited) {
134
+ return `${name.padEnd(14)} Unlimited`
135
+ }
136
+ const total = quota.entitlement
137
+ const used = total - quota.remaining
138
+ const percentRemaining = Math.round(quota.percent_remaining)
139
+ const progressBar = createProgressBar(percentRemaining, width)
140
+ return `${name.padEnd(14)} ${progressBar} ${percentRemaining}% (${used}/${total})`
141
+ }
142
+
143
+ // Public API - exported
144
+ export async function getCopilotUsageData(): Promise<CopilotUsageResponse | null> {
145
+ const auth = readAuthConfig()
146
+ const copilotAuth = auth?.["github-copilot"]
147
+ if (!copilotAuth?.refresh) {
148
+ return null
149
+ }
150
+ return fetchCopilotUsage(copilotAuth)
151
+ }
152
+ ```
153
+
154
+ ### API Integration
155
+ - **Timeouts:** Always use `fetchWithTimeout` wrapper for network requests (default 10s timeout)
156
+ - **Headers:** Build header objects with helper functions (`buildGitHubHeaders`, `buildClaudeHeaders`)
157
+ - **Versioning:** Include API version in URLs (`/copilot_internal/v2/token`)
158
+ - **User-Agent:** Set appropriate user agent for API requests
159
+ - **Response handling:** Always check `response.ok` before parsing JSON
160
+ - **Token management:** Support both access and refresh tokens with fallback logic
161
+
162
+ ### Configuration
163
+ - **Auth file:** `~/.local/share/opencode/auth.json` (OpenCode standard location)
164
+ - **Plugin config:** `opencode.json` for plugin registration
165
+ - **Constants:** Define at module level for API URLs, timeouts, headers
166
+ - **Environment:** No .env files - read from OpenCode's auth.json
167
+ - **Path construction:** Use `path.join()` with `os.homedir()` for cross-platform compatibility
168
+
169
+ ```typescript
170
+ const AUTH_CONFIG_PATH = path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
171
+ ```
172
+
173
+ ### Output Formatting
174
+ - **Message sending:** Use `sendIgnoredMessage` for non-interactive responses
175
+ - **Progress bars:** `createProgressBar(percent, width)` for visual quota indicators
176
+ - **Date/Time:** Use `Date` API for countdown calculations
177
+ - **Localization:** Portuguese for user-facing command descriptions
178
+
179
+ ```typescript
180
+ // Send formatted message to user
181
+ await sendIgnoredMessage(client, sessionID, formattedOutput, params)
182
+
183
+ // Create progress bar
184
+ const progressBar = createProgressBar(percentRemaining, 20) // [##########----------] style
185
+ ```
186
+
187
+ ## Plugin Registration
188
+ Commands registered in `index.ts`:
189
+ - Plugin exports a function that returns hook configuration
190
+ - Register commands in `config` hook via `opencodeConfig.command["name"]`
191
+ - Set `template: ""` for slash commands without parameters
192
+ - Set `description` in Portuguese for user-facing help
193
+ - Handle command execution in `command.execute.before` hook
194
+ - Throw `Error("__COMMAND_HANDLED__")` or similar to prevent default processing
195
+
196
+ ```typescript
197
+ export const TestPlugin: Plugin = async ({ client }) => {
198
+ return {
199
+ config: async (opencodeConfig) => {
200
+ opencodeConfig.command ??= {}
201
+ opencodeConfig.command["usage"] = {
202
+ template: "",
203
+ description: "Mostra uso do Copilot e Claude Code",
204
+ }
205
+ },
206
+ "command.execute.before": async (input, output) => {
207
+ if (input.command === "usage") {
208
+ try {
209
+ await handleUsageCommand({ client, sessionID: input.sessionID, params: output })
210
+ } catch (err) {
211
+ console.error(err instanceof Error ? err.message : String(err))
212
+ }
213
+ throw new Error("__USAGE_COMMAND_HANDLED__")
214
+ }
215
+ },
216
+ }
217
+ }
218
+ ```
219
+
220
+ ## Testing
221
+ No test framework currently configured. When adding tests:
222
+ - Add test runner to devDependencies (e.g., vitest, jest)
223
+ - Add test scripts to package.json
224
+ - Prefer unit tests for pure functions (`formatQuotaLine`, `createProgressBar`, `getResetCountdown`)
225
+ - Integration tests for API calls (use mocked fetch responses)
226
+ - Always ensure type checks pass: `npx tsc --noEmit`
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # OpenCode Usage Plugin
2
+
3
+ Track your AI coding assistant usage in one place. This plugin shows quota information and usage statistics for GitHub Copilot, Claude Code, and Z.ai with a single command.
4
+
5
+ ## Table of Contents
6
+
7
+ > **Note:** This plugin requires [OpenCode](https://opencode.ai) to be installed.
8
+
9
+ - [Requirements](#requirements)
10
+ - [Quick Start](#quick-start)
11
+ - [What You'll See](#what-youll-see)
12
+ - [Features](#features)
13
+ - [Configuration Reference](#configuration-reference)
14
+ - [Troubleshooting](#troubleshooting)
15
+ - [Development](#development)
16
+
17
+ ## Requirements
18
+
19
+ Before installing this plugin, make sure you have:
20
+
21
+ - [OpenCode installed](https://opencode.ai/docs)
22
+ - Node.js installed (for local plugin installation)
23
+ - API keys/tokens for at least one of the supported services:
24
+ - GitHub Copilot account
25
+ - Anthropic (Claude) API key
26
+ - Z.ai account
27
+
28
+ ## Quick Start
29
+
30
+ ### Step 1: Install OpenCode
31
+
32
+ If you haven't already, install OpenCode using one of these methods:
33
+
34
+ ```bash
35
+ # Quick install (recommended)
36
+ curl -fsSL https://opencode.ai/install | bash
37
+
38
+ # Or using npm
39
+ npm install -g opencode-ai
40
+
41
+ # Or using Homebrew (macOS/Linux)
42
+ brew install anomalyco/tap/opencode
43
+ ```
44
+
45
+ ### Step 2: Install the Usage Plugin
46
+
47
+ Choose one of the following installation methods:
48
+
49
+ #### Option A: Install from Local Files
50
+
51
+ Clone this repository to your project's plugins directory:
52
+
53
+ ```bash
54
+ cd /path/to/your/project
55
+ mkdir -p .opencode/plugins
56
+ git clone https://github.com/toninho09/opencode-usage.git .opencode/plugins/opencode-usage
57
+ cd .opencode/plugins/opencode-usage
58
+ ```
59
+
60
+ **Tip:** For global installation (available in all projects), use:
61
+
62
+ ```bash
63
+ mkdir -p ~/.config/opencode/plugins
64
+ git clone https://github.com/toninho09/opencode-usage.git ~/.config/opencode/plugins/opencode-usage
65
+ cd ~/.config/opencode/plugins/opencode-usage
66
+ npm install
67
+ ```
68
+
69
+ #### Option B: Install via npm (coming soon)
70
+
71
+ ```bash
72
+ npm install -g opencode-usage
73
+ ```
74
+
75
+ Then add to your `opencode.json` config file:
76
+
77
+ ```json
78
+ {
79
+ "$schema": "https://opencode.ai/config.json",
80
+ "plugin": ["@toninho09/opencode-usage@latest"]
81
+ }
82
+ ```
83
+
84
+ ### Step 3: Run OpenCode and Check Usage
85
+
86
+ Navigate to your project and start OpenCode:
87
+
88
+ ```bash
89
+ cd /path/to/your/project
90
+ opencode
91
+ ```
92
+
93
+ Now check your usage:
94
+
95
+ ```
96
+ /usage
97
+ ```
98
+
99
+ ## What You'll See
100
+
101
+ ```
102
+ ╔════════════════════════════════════════╗
103
+ ║ GITHUB COPILOT ║
104
+ ╚════════════════════════════════════════╝
105
+ Plan: individual
106
+ Premium: [## ] 11% (33/300)
107
+ Chat: Unlimited
108
+ Completions: Unlimited
109
+ Quota Resets: 18d 20h (9999-12-31 23:59 UTC-03:00)
110
+ ╔════════════════════════════════════════╗
111
+ ║ CLAUDE CODE ║
112
+ ╚════════════════════════════════════════╝
113
+ 5 Hour: [################### ] 94%
114
+ 7 Day: [# ] 7%
115
+ 5h Resets: 2h (9999-12-31 23:59 UTC-03:00)
116
+ 7d Resets: 6d 21h (9999-12-31 23:59 UTC-03:00)
117
+ Extra Usage: Disabled
118
+ ╔════════════════════════════════════════╗
119
+ ║ Z.AI CODING PLAN ║
120
+ ╚════════════════════════════════════════╝
121
+ Account: xxxxxxxx............ (Z.AI Coding Plan)
122
+ Tokens: [# ] 4%
123
+ 5h Resets: 3h (9999-12-31 23:59 UTC-03:00)
124
+ MCP Searches: [ ] 0% (0/100)
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT
130
+
131
+ ## Contributing
132
+
133
+ Contributions welcome! Feel free to open an issue or pull request.
package/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { handleUsageCommand } from "./lib/usage-handler"
3
+
4
+ export const TestPlugin: Plugin = async ({ client }) => {
5
+ return {
6
+ config: async (opencodeConfig) => {
7
+ opencodeConfig.command ??= {}
8
+ opencodeConfig.command["usage"] = {
9
+ template: "",
10
+ description: "Shows usage for Copilot, Claude Code and Z.ai",
11
+ }
12
+ },
13
+ "command.execute.before": async (input, output) => {
14
+ if (input.command === "usage") {
15
+ try {
16
+ await handleUsageCommand({
17
+ client,
18
+ sessionID: input.sessionID,
19
+ params: output,
20
+ })
21
+ } catch (err) {
22
+ console.error(err instanceof Error ? err.message : String(err))
23
+ }
24
+
25
+ throw new Error("__USAGE_COMMAND_HANDLED__")
26
+ }
27
+ },
28
+ }
29
+ }
30
+
31
+ export default TestPlugin
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Common interface for all usage monitoring providers
3
+ */
4
+ export interface UsageProvider {
5
+ /**
6
+ * Display name of provider (e.g., "GitHub Copilot")
7
+ */
8
+ readonly name: string;
9
+
10
+ /**
11
+ * Unique provider ID for internal use (e.g., "copilot")
12
+ */
13
+ readonly id: string;
14
+
15
+ /**
16
+ * Brief description of provider
17
+ */
18
+ readonly description: string;
19
+
20
+ /**
21
+ * Fetches usage data from provider API
22
+ * @returns ProviderMessage with formatted content or null if not configured
23
+ */
24
+ getUsageData(): Promise<ProviderMessage | null>;
25
+
26
+ /**
27
+ * Checks if provider is configured in auth file
28
+ * @returns true if provider is configured and ready to use
29
+ */
30
+ isConfigured(): boolean;
31
+ }
32
+
33
+ /**
34
+ * Message returned by provider with formatted usage data
35
+ */
36
+ export interface ProviderMessage {
37
+ /**
38
+ * Formatted content for display to user
39
+ */
40
+ readonly content: string;
41
+
42
+ /**
43
+ * Error message if any problem occurred (optional)
44
+ */
45
+ readonly error?: string;
46
+ }
@@ -0,0 +1,72 @@
1
+ import { fetchWithTimeout } from "../../shared/utils";
2
+ import { readAuthConfig, type AuthProvider } from "../../shared/auth";
3
+ import type { ClaudeUsageResponse } from "./types";
4
+
5
+ const ANTHROPIC_API_BASE_URL = "https://api.anthropic.com";
6
+
7
+ export class ClaudeClient {
8
+ private readonly apiBaseUrl = ANTHROPIC_API_BASE_URL;
9
+
10
+ /**
11
+ * Builds headers for authentication with Claude API
12
+ */
13
+ private buildClaudeHeaders(token: string): Record<string, string> {
14
+ return {
15
+ "Accept": "application/json, text/plain, */*",
16
+ "Content-Type": "application/json",
17
+ "User-Agent": "claude-code/2.0.32",
18
+ "Authorization": `Bearer ${token}`,
19
+ "anthropic-beta": "oauth-2025-04-20",
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Fetches usage data from Claude API
25
+ */
26
+ private async fetchClaudeUsage(authData: AuthProvider): Promise<ClaudeUsageResponse> {
27
+ const token = authData.access || authData.refresh;
28
+
29
+ if (!token) {
30
+ throw new Error("No token found in Anthropic auth data");
31
+ }
32
+
33
+ const url = `${this.apiBaseUrl}/api/oauth/usage`;
34
+ const response = await fetchWithTimeout(url, {
35
+ headers: this.buildClaudeHeaders(token),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ const errorText = await response.text();
40
+ throw new Error(`Anthropic API Error ${response.status}: ${errorText}`);
41
+ }
42
+
43
+ return response.json();
44
+ }
45
+
46
+ /**
47
+ * Fetches Claude usage data
48
+ */
49
+ async fetchUsage(): Promise<ClaudeUsageResponse | null> {
50
+ const authConfig = readAuthConfig();
51
+
52
+ if (!authConfig) {
53
+ return null;
54
+ }
55
+
56
+ const authData = authConfig.anthropic;
57
+
58
+ if (!authData) {
59
+ return null;
60
+ }
61
+
62
+ return this.fetchClaudeUsage(authData);
63
+ }
64
+
65
+ /**
66
+ * Checks if Claude is configured
67
+ */
68
+ isConfigured(): boolean {
69
+ const authConfig = readAuthConfig();
70
+ return !!authConfig?.anthropic;
71
+ }
72
+ }
@@ -0,0 +1,56 @@
1
+ import { formatResetLine } from "../../shared/formatting";
2
+ import { createUsedProgressBar } from "../../shared/utils";
3
+ import type { ClaudeUsageResponse, QuotaPeriod } from "./types";
4
+
5
+ export class ClaudeFormatter {
6
+ /**
7
+ * Formats a Claude quota line with progress bar
8
+ */
9
+ private formatQuotaLine(name: string, quota: QuotaPeriod | null): string {
10
+ if (!quota) {
11
+ return `${name.padEnd(16)} N/A`;
12
+ }
13
+
14
+ const percentUsed = Math.round(quota.utilization);
15
+ const progressBar = createUsedProgressBar(percentUsed, 20);
16
+
17
+ return `${name.padEnd(16)} ${progressBar} ${percentUsed}%`;
18
+ }
19
+
20
+ /**
21
+ * Formats Claude usage data for display
22
+ */
23
+ format(data: ClaudeUsageResponse): string {
24
+ const lines: string[] = [];
25
+
26
+ lines.push("╔════════════════════════════════════════╗");
27
+ lines.push("║ CLAUDE CODE ║");
28
+ lines.push("╚════════════════════════════════════════╝");
29
+
30
+ lines.push(this.formatQuotaLine("5 Hour:", data.five_hour));
31
+ lines.push(this.formatQuotaLine("7 Day:", data.seven_day));
32
+
33
+ if (data.five_hour) {
34
+ lines.push(formatResetLine("5h Resets:", data.five_hour.resets_at, 16));
35
+ }
36
+
37
+ if (data.seven_day) {
38
+ lines.push(formatResetLine("7d Resets:", data.seven_day.resets_at, 16));
39
+ }
40
+
41
+ const extraUsage = data.extra_usage;
42
+ if (extraUsage.is_enabled) {
43
+ const utilization =
44
+ extraUsage.utilization !== null ? `${Math.round(extraUsage.utilization)}%` : "N/A";
45
+ const credits =
46
+ extraUsage.used_credits !== null && extraUsage.monthly_limit !== null
47
+ ? `${extraUsage.used_credits}/${extraUsage.monthly_limit}`
48
+ : "N/A";
49
+ lines.push(`Extra Usage: Enabled - ${utilization} (${credits})`);
50
+ } else {
51
+ lines.push("Extra Usage: Disabled");
52
+ }
53
+
54
+ return lines.join("\n");
55
+ }
56
+ }
@@ -0,0 +1,36 @@
1
+ import type { UsageProvider, ProviderMessage } from "../base";
2
+ import { ClaudeClient } from "./client";
3
+ import { ClaudeFormatter } from "./formatter";
4
+
5
+ const client = new ClaudeClient();
6
+ const formatter = new ClaudeFormatter();
7
+
8
+ export const claudeProvider: UsageProvider = {
9
+ name: "Claude Code",
10
+ id: "claude",
11
+ description: "Monitoramento de uso do Claude Code",
12
+
13
+ async getUsageData(): Promise<ProviderMessage | null> {
14
+ try {
15
+ const data = await client.fetchUsage();
16
+ if (!data) {
17
+ return null;
18
+ }
19
+
20
+ return {
21
+ content: formatter.format(data),
22
+ };
23
+ } catch (error) {
24
+ return {
25
+ content: "",
26
+ error: error instanceof Error ? error.message : String(error),
27
+ };
28
+ }
29
+ },
30
+
31
+ isConfigured(): boolean {
32
+ return client.isConfigured();
33
+ },
34
+ };
35
+
36
+ export default claudeProvider;
@@ -0,0 +1,22 @@
1
+ export interface QuotaPeriod {
2
+ utilization: number;
3
+ resets_at: string;
4
+ }
5
+
6
+ export interface ExtraUsage {
7
+ is_enabled: boolean;
8
+ monthly_limit: number | null;
9
+ used_credits: number | null;
10
+ utilization: number | null;
11
+ }
12
+
13
+ export interface ClaudeUsageResponse {
14
+ five_hour: QuotaPeriod | null;
15
+ seven_day: QuotaPeriod | null;
16
+ seven_day_oauth_apps: QuotaPeriod | null;
17
+ seven_day_opus: QuotaPeriod | null;
18
+ seven_day_sonnet: QuotaPeriod | null;
19
+ seven_day_cowork: QuotaPeriod | null;
20
+ iguana_necktie: unknown | null;
21
+ extra_usage: ExtraUsage;
22
+ }