opencode-glm-quota 1.1.0 → 1.3.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/README.md CHANGED
@@ -21,10 +21,21 @@ OpenCode plugin to query Z.ai GLM Coding Plan usage statistics with real-time qu
21
21
  ### Option 1: npm (Recommended)
22
22
 
23
23
  ```bash
24
- npm install opencode-glm-quota
24
+ # Install the plugin
25
+ npm install @opencode-glm-quota/plugin
26
+
27
+ # Run the installer to configure OpenCode
28
+ npx @opencode-glm-quota/plugin install
29
+
30
+ # Add to your OpenCode config (~/.config/opencode/opencode.json)
31
+ echo '"@opencode-glm-quota/plugin"' >> ~/.config/opencode/opencode.json
25
32
  ```
26
33
 
27
- OpenCode automatically discovers and loads plugins from npm. No additional configuration required.
34
+ **What the installer does:**
35
+ - Copies `/glm_quota` command to `~/.config/opencode/command/glm_quota.md`
36
+ - Copies skill documentation to `~/.config/opencode/skill/glm-quota-skill.md`
37
+ - Merges agent configuration into `~/.config/opencode/opencode.json`
38
+ - Supports `--force` flag to overwrite existing files
28
39
 
29
40
  ### Option 2: From GitHub
30
41
 
@@ -43,6 +54,9 @@ npm run build
43
54
 
44
55
  # Link for local testing
45
56
  npm link
57
+
58
+ # Run the installer for local testing
59
+ node bin/install.js
46
60
  ```
47
61
 
48
62
  ## Quick Start
@@ -222,9 +236,14 @@ src/
222
236
  date-formatter.ts # Date/time formatting utilities
223
237
  progress-bar.ts # ASCII progress bar rendering
224
238
  time-window.ts # Rolling window calculation
239
+ integration/
240
+ command/glm_quota.md # /glm_quota slash command
241
+ skill/glm-quota-skill.md # Skill documentation
242
+ opencode.jsonc # Agent configuration (JSONC)
243
+ bin/
244
+ install.js # Installation script
225
245
  dist/ # Compiled JavaScript (generated)
226
246
  tests/ # Test suite
227
- .opencode/ # OpenCode integration files
228
247
  package.json # Dependencies and scripts
229
248
  tsconfig.json # TypeScript configuration
230
249
  ```
package/bin/install.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GLM Quota Plugin Installer
5
+ *
6
+ * This script installs the GLM Quota Plugin integration files into the user's
7
+ * OpenCode configuration directory (~/.config/opencode/).
8
+ *
9
+ * Usage:
10
+ * node bin/install.js # Interactive install (ask before overwriting)
11
+ * node bin/install.js --force # Force overwrite existing files
12
+ */
13
+
14
+ import * as fs from 'fs'
15
+ import * as path from 'path'
16
+ import * as os from 'os'
17
+ import { parse as parseJsonc } from 'jsonc-parser'
18
+
19
+ // ==========================================
20
+ // CONSTANTS
21
+ // ==========================================
22
+
23
+ const __filename = decodeURIComponent(new URL(import.meta.url).pathname)
24
+ const __dirname = path.dirname(__filename)
25
+ const SOURCE_DIR = path.join(__dirname, '..', 'integration')
26
+ const COMMAND_FILE = path.join(SOURCE_DIR, 'command', 'glm_quota.md')
27
+ const SKILL_FILE = path.join(SOURCE_DIR, 'skill', 'glm-quota-skill.md')
28
+ const AGENT_CONFIG = path.join(SOURCE_DIR, 'opencode.jsonc')
29
+
30
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode')
31
+ const TARGET_COMMAND = path.join(CONFIG_DIR, 'command', 'glm_quota.md')
32
+ const TARGET_SKILL = path.join(CONFIG_DIR, 'skill', 'glm-quota-skill.md')
33
+
34
+ // Check which config file exists (opencode.json or opencode.jsonc)
35
+ const TARGET_CONFIG_JSON = path.join(CONFIG_DIR, 'opencode.json')
36
+ const TARGET_CONFIG_JSONC = path.join(CONFIG_DIR, 'opencode.jsonc')
37
+ let TARGET_CONFIG = null
38
+ if (fileExists(TARGET_CONFIG_JSON)) {
39
+ TARGET_CONFIG = TARGET_CONFIG_JSON
40
+ } else if (fileExists(TARGET_CONFIG_JSONC)) {
41
+ TARGET_CONFIG = TARGET_CONFIG_JSONC
42
+ } else {
43
+ // Default to opencode.json if neither exists
44
+ TARGET_CONFIG = TARGET_CONFIG_JSON
45
+ }
46
+
47
+ // ==========================================
48
+ // UTILITY FUNCTIONS
49
+ // ==========================================
50
+
51
+ /**
52
+ * Ensure directory exists, create if missing
53
+ */
54
+ function ensureDirectory(dirPath) {
55
+ if (!fs.existsSync(dirPath)) {
56
+ fs.mkdirSync(dirPath, { recursive: true })
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Check if file exists
62
+ */
63
+ function fileExists(filePath) {
64
+ return fs.existsSync(filePath)
65
+ }
66
+
67
+ /**
68
+ * Copy file from source to destination
69
+ */
70
+ function copyFile(source, destination) {
71
+ ensureDirectory(path.dirname(destination))
72
+ fs.copyFileSync(source, destination)
73
+ }
74
+
75
+ /**
76
+ * Parse JSON or JSONC file
77
+ */
78
+ function parseConfig(filePath) {
79
+ try {
80
+ const content = fs.readFileSync(filePath, 'utf-8')
81
+ return parseJsonc(content)
82
+ } catch (error) {
83
+ throw new Error(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Write JSON file
89
+ */
90
+ function writeConfig(filePath, data) {
91
+ ensureDirectory(path.dirname(filePath))
92
+ const json = JSON.stringify(data, null, 2) + '\n'
93
+ fs.writeFileSync(filePath, json)
94
+ console.log(` ✓ Wrote ${filePath} (${json.length} bytes)`)
95
+ }
96
+
97
+ /**
98
+ * Deep merge objects
99
+ */
100
+ function deepMerge(target, source) {
101
+ const result = { ...target }
102
+
103
+ for (const key of Object.keys(source)) {
104
+ if (source[key] instanceof Object && key in result && result[key] instanceof Object) {
105
+ result[key] = deepMerge(result[key], source[key])
106
+ } else {
107
+ result[key] = source[key]
108
+ }
109
+ }
110
+
111
+ return result
112
+ }
113
+
114
+ /**
115
+ * Prompt user for confirmation
116
+ */
117
+ function promptConfirm(message) {
118
+ process.stdout.write(`${message} (y/N) `)
119
+ const response = process.stdin.read()
120
+ return response?.trim().toLowerCase() === 'y'
121
+ }
122
+
123
+ // ==========================================
124
+ // INSTALLATION FUNCTIONS
125
+ // ==========================================
126
+
127
+ /**
128
+ * Install command file
129
+ */
130
+ function installCommand(force) {
131
+ if (fileExists(TARGET_COMMAND) && !force) {
132
+ if (!promptConfirm(`Command file exists: ${TARGET_COMMAND}\nOverwrite?`)) {
133
+ console.log(` ⊘ Skipped ${TARGET_COMMAND}`)
134
+ return
135
+ }
136
+ }
137
+
138
+ copyFile(COMMAND_FILE, TARGET_COMMAND)
139
+ console.log(` ✓ Created ${TARGET_COMMAND}`)
140
+ }
141
+
142
+ /**
143
+ * Install skill file
144
+ */
145
+ function installSkill(force) {
146
+ if (fileExists(TARGET_SKILL) && !force) {
147
+ if (!promptConfirm(`Skill file exists: ${TARGET_SKILL}\nOverwrite?`)) {
148
+ console.log(` ⊘ Skipped ${TARGET_SKILL}`)
149
+ return
150
+ }
151
+ }
152
+
153
+ copyFile(SKILL_FILE, TARGET_SKILL)
154
+ console.log(` ✓ Created ${TARGET_SKILL}`)
155
+ }
156
+
157
+ /**
158
+ * Merge agent configuration and add plugin to plugins array
159
+ */
160
+ function mergeConfig() {
161
+ // Parse existing config if it exists (same file type will be written)
162
+ let existingConfig = {}
163
+ if (fileExists(TARGET_CONFIG)) {
164
+ existingConfig = parseConfig(TARGET_CONFIG)
165
+ }
166
+
167
+ // Parse new agent config from integration
168
+ const newConfig = parseConfig(AGENT_CONFIG)
169
+
170
+ // Merge agent definitions first
171
+ const mergedConfig = deepMerge(existingConfig, newConfig)
172
+
173
+ // Ensure plugins array exists and add our plugin
174
+ if (!mergedConfig.plugins) {
175
+ mergedConfig.plugins = []
176
+ }
177
+
178
+ const PLUGIN_NAME = '@opencode-glm-quota/plugin'
179
+ const plugins = Array.isArray(mergedConfig.plugins) ? mergedConfig.plugins : []
180
+
181
+ // Only add if not already present
182
+ if (!plugins.includes(PLUGIN_NAME)) {
183
+ plugins.push(PLUGIN_NAME)
184
+ mergedConfig.plugins = plugins
185
+ console.log(` ✓ Added ${PLUGIN_NAME} to plugins array`)
186
+ } else {
187
+ console.log(` ⊙ Plugin ${PLUGIN_NAME} already in plugins array`)
188
+ }
189
+
190
+ // Write merged config back to the same file (opencode.json or opencode.jsonc)
191
+ writeConfig(TARGET_CONFIG, mergedConfig)
192
+ console.log(` ✓ Merged configuration into ${path.basename(TARGET_CONFIG)}`)
193
+ }
194
+
195
+ // ==========================================
196
+ // MAIN INSTALLATION FUNCTION
197
+ // ==========================================
198
+
199
+ /**
200
+ * Main installer function
201
+ */
202
+ function main() {
203
+ try {
204
+ // Parse command line arguments
205
+ const args = process.argv.slice(2)
206
+ const forceFlag = args.includes('--force')
207
+
208
+ console.log('✓ Installing GLM Quota Plugin...\n')
209
+
210
+ // Install integration files
211
+ installCommand(forceFlag)
212
+ installSkill(forceFlag)
213
+ mergeConfig()
214
+
215
+ console.log()
216
+ console.log('✓ Installation complete!')
217
+ console.log('✓ Restart OpenCode to use /glm_quota command')
218
+
219
+ } catch (error) {
220
+ console.error(`\n✗ Installation failed: ${error instanceof Error ? error.message : String(error)}`)
221
+ console.error('✗ Check file permissions and try again')
222
+ process.exit(1)
223
+ }
224
+ }
225
+
226
+ // Run installer
227
+ if (import.meta.url === `file://${process.argv[1]}`) {
228
+ main()
229
+ }
@@ -2,7 +2,7 @@
2
2
  * HTTP client module
3
3
  * Makes HTTPS requests to Z.ai API endpoints
4
4
  */
5
- import * as https from 'https';
5
+ import * as https from 'node:https';
6
6
  /**
7
7
  * Make HTTPS request to API endpoint
8
8
  * @param options - Request options
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { getEndpoints } from "./api/endpoints.js";
13
13
  import { queryEndpoint } from "./api/client.js";
14
14
  import { getTimeWindow, getTimeWindowQueryParams } from "./utils/time-window.js";
15
15
  import { formatProgressLine } from "./utils/progress-bar.js";
16
+ import { formatTimeUntilReset } from "./utils/reset-timer.js";
16
17
  // ============================================================================
17
18
  // CONSTANTS
18
19
  // ============================================================================
@@ -128,7 +129,8 @@ function processQuotaLimit(data) {
128
129
  if (limit.type === 'TOKENS_LIMIT') {
129
130
  return {
130
131
  type: 'Token usage(5 Hour)',
131
- percentage: typeof limit.percentage === 'number' ? limit.percentage : 0
132
+ percentage: typeof limit.percentage === 'number' ? limit.percentage : 0,
133
+ nextResetTime: limit.nextResetTime
132
134
  };
133
135
  }
134
136
  if (limit.type === 'TIME_LIMIT') {
@@ -156,38 +158,55 @@ function formatNumber(num) {
156
158
  return num.toLocaleString('en-US');
157
159
  }
158
160
  /**
159
- * Format model usage data as readable lines
161
+ * Get token limit information from quota data
160
162
  */
161
- function formatModelUsage(data, quotaData) {
162
- const lines = [];
163
- const totalUsage = data.totalUsage;
164
- // Find token limit info from quota
163
+ function getTokenLimitInfo(quotaData) {
165
164
  let tokenLimit = 40000000; // Default 40M
166
165
  let tokenPct = 0;
167
- if (quotaData?.limits) {
168
- for (const limit of quotaData.limits) {
169
- if (limit.type === 'Token usage(5 Hour)') {
170
- tokenPct = typeof limit.percentage === 'number' ? limit.percentage : 0;
171
- tokenLimit = limit.total || 40000000;
172
- break;
173
- }
166
+ if (!quotaData?.limits)
167
+ return { tokenLimit, tokenPct };
168
+ for (const limit of quotaData.limits) {
169
+ if (limit.type === 'Token usage(5 Hour)') {
170
+ tokenPct = typeof limit.percentage === 'number' ? limit.percentage : 0;
171
+ tokenLimit = limit.total || 40000000;
172
+ break;
174
173
  }
175
174
  }
176
- if (totalUsage) {
177
- const calls = totalUsage.totalModelCallCount;
178
- const tokens = totalUsage.totalTokensUsage;
179
- if (tokens !== undefined) {
180
- // Show 24h tokens and percentage relative to 5h limit
181
- const pct24h = Math.round((tokens / tokenLimit) * 100);
182
- lines.push(` Total Tokens (24h): ${formatNumber(tokens)} (${pct24h}% of 5h limit)`);
183
- lines.push(` 5h Window Usage: ${tokenPct}% of ${formatNumber(tokenLimit)}`);
184
- }
185
- if (calls !== undefined) {
186
- lines.push(` Total Calls: ${formatNumber(calls)}`);
187
- }
175
+ return { tokenLimit, tokenPct };
176
+ }
177
+ /**
178
+ * Format MCP tool details as readable lines
179
+ */
180
+ function formatMcpToolLines(details) {
181
+ const lines = [];
182
+ const mcpTotal = details.reduce((sum, d) => sum + (d.usage || 0), 0);
183
+ for (const d of details) {
184
+ const pct = mcpTotal > 0 ? Math.round((d.usage / mcpTotal) * 100) : 0;
185
+ lines.push(` - ${d.modelCode}: ${d.usage} (${pct}%)`);
188
186
  }
189
- else {
187
+ return lines;
188
+ }
189
+ /**
190
+ * Format model usage data as readable lines
191
+ */
192
+ function formatModelUsage(data, quotaData) {
193
+ const lines = [];
194
+ const totalUsage = data.totalUsage;
195
+ // Get token limit info from quota
196
+ const { tokenLimit, tokenPct } = getTokenLimitInfo(quotaData);
197
+ if (!totalUsage) {
190
198
  lines.push(' No usage data');
199
+ return lines;
200
+ }
201
+ const calls = totalUsage.totalModelCallCount;
202
+ const tokens = totalUsage.totalTokensUsage;
203
+ if (tokens !== undefined) {
204
+ // Show 24h tokens and percentage relative to 5h limit
205
+ const pct24h = Math.round((tokens / tokenLimit) * 100);
206
+ lines.push(` Total Tokens (24h): ${formatNumber(tokens)} (${pct24h}% of 5h limit)`, ` 5h Window Usage: ${tokenPct}% of ${formatNumber(tokenLimit)}`);
207
+ }
208
+ if (calls !== undefined) {
209
+ lines.push(` Total Calls: ${formatNumber(calls)}`);
191
210
  }
192
211
  return lines;
193
212
  }
@@ -202,24 +221,15 @@ function formatToolUsage(data, quotaData) {
202
221
  const search = totalUsage.totalNetworkSearchCount;
203
222
  const webRead = totalUsage.totalWebReadMcpCount;
204
223
  const zread = totalUsage.totalZreadMcpCount;
205
- if (search !== undefined)
206
- lines.push(` Network Searches: ${formatNumber(search)}`);
207
- if (webRead !== undefined)
208
- lines.push(` Web Reads: ${formatNumber(webRead)}`);
209
- if (zread !== undefined)
210
- lines.push(` ZRead Calls: ${formatNumber(zread)}`);
224
+ lines.push(...(search !== undefined ? [` Network Searches: ${formatNumber(search)}`] : []), ...(webRead !== undefined ? [` Web Reads: ${formatNumber(webRead)}`] : []), ...(zread !== undefined ? [` ZRead Calls: ${formatNumber(zread)}`] : []));
211
225
  }
212
226
  // Show MCP usage details from quota if available
213
227
  if (quotaData?.limits) {
214
228
  for (const limit of quotaData.limits) {
215
229
  if (limit.type === 'MCP usage(1 Month)' && limit.usageDetails) {
216
- const details = limit.usageDetails;
217
230
  lines.push(' MCP Tool Details:');
218
- const mcpTotal = details.reduce((sum, d) => sum + (d.usage || 0), 0);
219
- for (const d of details) {
220
- const pct = mcpTotal > 0 ? Math.round((d.usage / mcpTotal) * 100) : 0;
221
- lines.push(` - ${d.modelCode}: ${d.usage} (${pct}%)`);
222
- }
231
+ const details = limit.usageDetails;
232
+ lines.push(...formatMcpToolLines(details));
223
233
  break;
224
234
  }
225
235
  }
@@ -229,46 +239,65 @@ function formatToolUsage(data, quotaData) {
229
239
  }
230
240
  return lines;
231
241
  }
242
+ // ============================================================================
243
+ // HELPER FUNCTIONS FOR OUTPUT FORMATTING
244
+ // ============================================================================
232
245
  /**
233
- * Format usage statistics as ASCII table
234
- * @param platform - Platform name
246
+ * Format a single line with box characters
247
+ * @param content - Content to display (without padding)
248
+ * @param lineIndent - Total line width after padding
249
+ * @returns Formatted line with box characters
250
+ */
251
+ function formatBoxLine(content, lineIndent) {
252
+ return '║ ' + content.padEnd(lineIndent) + '║';
253
+ }
254
+ /**
255
+ * Format header section
256
+ * @param platformName - Platform name
235
257
  * @param startTime - Start time string
236
258
  * @param endTime - End time string
237
- * @param quotaData - Quota limit data
238
- * @param modelData - Model usage data
239
- * @param toolData - Tool usage data
240
- * @returns Formatted output string
259
+ * @returns Array of header lines
241
260
  */
242
- function formatOutput(platform, startTime, endTime, quotaData, modelData, toolData) {
261
+ function formatHeader(platformName, startTime, endTime) {
243
262
  const lines = [];
244
- const platformName = getPlatformName(platform);
245
- // Constants for line width (total 60 chars)
246
263
  const LINE_WIDTH = 60;
247
- const LINE_CONTENT = 58; // Between ║ and ║
248
- const LINE_INDENT = 56; // After "║ "
249
- // Header
250
264
  lines.push('╔' + '═'.repeat(58) + '╗');
251
265
  lines.push('║' + ' '.repeat(58) + '║');
252
266
  lines.push('║' + ' Z.ai GLM Coding Plan Usage Statistics '.padStart(35).padEnd(58) + '║');
253
267
  lines.push('║' + ' '.repeat(58) + '║');
254
268
  lines.push('╠' + '═'.repeat(58) + '╣');
255
- // Platform line: "║ Platform: " (13 chars) + name + padding + "║"
256
269
  lines.push('║ Platform: ' + platformName.padEnd(LINE_WIDTH - 13 - 1) + '║');
257
- // Period line: "║ Period: " (14 chars) + start + " → " + end + "║"
258
270
  const periodLine = '║ Period: ' + startTime + ' → ' + endTime;
259
271
  lines.push(periodLine.padEnd(LINE_WIDTH) + '║');
260
272
  lines.push('╠' + '═'.repeat(58) + '╣');
261
- // Quota Limits
273
+ return lines;
274
+ }
275
+ /**
276
+ * Format quota limits section
277
+ * @param quotaData - Quota limit data
278
+ * @returns Array of quota lines
279
+ */
280
+ function formatQuotaLimits(quotaData) {
281
+ const lines = [];
282
+ const LINE_CONTENT = 58;
283
+ const LINE_INDENT = 56;
262
284
  lines.push('║ 📊 QUOTA LIMITS' + ' '.repeat(LINE_CONTENT - 14) + '║');
263
285
  lines.push('╟' + '─'.repeat(58) + '╢');
264
- if (quotaData?.limits && Array.isArray(quotaData.limits)) {
265
- for (const limit of quotaData.limits) {
286
+ const limits = quotaData?.limits;
287
+ if (limits && Array.isArray(limits)) {
288
+ for (const limit of limits) {
266
289
  const pct = typeof limit.percentage === 'number' ? limit.percentage : 0;
267
290
  const line = formatProgressLine(limit.type || 'Unknown', pct);
268
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
291
+ lines.push(formatBoxLine(line, LINE_INDENT));
292
+ if (limit.nextResetTime !== undefined) {
293
+ const resetMsg = formatTimeUntilReset(limit.nextResetTime);
294
+ if (resetMsg) {
295
+ lines.push(formatBoxLine(resetMsg, LINE_INDENT));
296
+ }
297
+ }
269
298
  if (limit.currentValue !== undefined && limit.total !== undefined) {
270
299
  const usageStr = ' Used: ' + limit.currentValue + '/' + limit.total;
271
- lines.push('║ ' + usageStr.padEnd(LINE_INDENT) + '║');
300
+ lines.push(formatBoxLine(usageStr, LINE_INDENT));
272
301
  }
273
302
  }
274
303
  }
@@ -276,33 +305,60 @@ function formatOutput(platform, startTime, endTime, quotaData, modelData, toolDa
276
305
  lines.push('║ No quota data available' + ' '.repeat(LINE_INDENT - 21) + '║');
277
306
  }
278
307
  lines.push('╠' + '═'.repeat(58) + '╣');
279
- // Model Usage
280
- lines.push('║ 🤖 MODEL USAGE (24h)' + ' '.repeat(LINE_INDENT - 17) + '║');
308
+ return lines;
309
+ }
310
+ /**
311
+ * Format data section with optional data
312
+ * @param title - Section title
313
+ * @param data - Data object or null
314
+ * @param formatter - Function to format data if present
315
+ * @param noDataMessage - Message to show if no data
316
+ * @param LINE_INDENT - Line indent width
317
+ * @returns Array of section lines
318
+ */
319
+ function formatDataSection(title, data, formatter, quotaData, noDataMessage, LINE_INDENT) {
320
+ const lines = [];
321
+ const LINE_CONTENT = 58;
322
+ lines.push('║ ' + title + ' '.repeat(LINE_CONTENT - title.length) + '║');
281
323
  lines.push('╟' + '─'.repeat(58) + '╢');
282
- if (modelData) {
283
- const modelLines = formatModelUsage(modelData, quotaData);
284
- for (const line of modelLines) {
285
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
324
+ if (data) {
325
+ const formattedLines = formatter(data, quotaData);
326
+ for (const line of formattedLines) {
327
+ lines.push(formatBoxLine(line, LINE_INDENT));
286
328
  }
287
329
  }
288
330
  else {
289
- lines.push('║ No model usage data available' + ' '.repeat(LINE_INDENT - 25) + '║');
331
+ lines.push('║ ' + noDataMessage + ' '.repeat(LINE_INDENT - noDataMessage.length) + '║');
290
332
  }
291
333
  lines.push('╠' + '═'.repeat(58) + '╣');
292
- // Tool Usage
293
- lines.push('║ 🔧 TOOL/MCP USAGE (24h)' + ' '.repeat(LINE_INDENT - 20) + '║');
294
- lines.push('╟' + '─'.repeat(58) + '╢');
295
- if (toolData) {
296
- const toolLines = formatToolUsage(toolData, quotaData);
297
- for (const line of toolLines) {
298
- lines.push('║ ' + line.padEnd(LINE_INDENT) + '║');
299
- }
300
- }
301
- else {
302
- lines.push('║ No tool usage data available' + ' '.repeat(LINE_INDENT - 24) + '║');
303
- }
304
- // Footer
305
- lines.push('╚' + '═'.repeat(58) + '╝');
334
+ return lines;
335
+ }
336
+ /**
337
+ * Format footer section
338
+ * @returns Footer lines
339
+ */
340
+ function formatFooter() {
341
+ return ['╚' + '═'.repeat(58) + '╝'];
342
+ }
343
+ /**
344
+ * Format usage statistics as ASCII table
345
+ * @param platform - Platform name
346
+ * @param startTime - Start time string
347
+ * @param endTime - End time string
348
+ * @param quotaData - Quota limit data
349
+ * @param modelData - Model usage data
350
+ * @param toolData - Tool usage data
351
+ * @returns Formatted output string
352
+ */
353
+ function formatOutput(platform, startTime, endTime, quotaData, modelData, toolData) {
354
+ const lines = [];
355
+ const platformName = getPlatformName(platform);
356
+ const LINE_INDENT = 56;
357
+ lines.push(...formatHeader(platformName, startTime, endTime));
358
+ lines.push(...formatQuotaLimits(quotaData));
359
+ lines.push(...formatDataSection('🤖 MODEL USAGE (24h)', modelData, formatModelUsage, quotaData, 'No model usage data available', LINE_INDENT));
360
+ lines.push(...formatDataSection('🔧 TOOL/MCP USAGE (24h)', toolData, formatToolUsage, quotaData, 'No tool usage data available', LINE_INDENT));
361
+ lines.push(...formatFooter());
306
362
  return lines.join('\n');
307
363
  }
308
364
  // ============================================================================
@@ -22,10 +22,11 @@ export function formatDateTime(date) {
22
22
  * @returns Date object
23
23
  */
24
24
  export function parseDateTime(dateTime) {
25
- const match = dateTime.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
25
+ const dateTimeRegex = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
26
+ const match = dateTimeRegex.exec(dateTime);
26
27
  if (!match) {
27
28
  throw new Error(`Invalid datetime format: ${dateTime}`);
28
29
  }
29
30
  const [, year, month, day, hours, minutes, seconds] = match;
30
- return new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes), parseInt(seconds));
31
+ return new Date(Number.parseInt(year), Number.parseInt(month) - 1, Number.parseInt(day), Number.parseInt(hours), Number.parseInt(minutes), Number.parseInt(seconds));
31
32
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reset timer utility module
3
+ * Formats Unix timestamps to human-readable countdown strings
4
+ */
5
+ /**
6
+ * Format time until reset as "X hours Y minutes"
7
+ * @param resetTime - Unix timestamp in milliseconds (from API's nextResetTime field)
8
+ * @returns Human-readable countdown string or empty string if invalid/past
9
+ */
10
+ export function formatTimeUntilReset(resetTime) {
11
+ // Validate input
12
+ if (resetTime === null || resetTime === undefined) {
13
+ return '';
14
+ }
15
+ // Check if valid number
16
+ if (typeof resetTime !== 'number' || Number.isNaN(resetTime) || resetTime < 0) {
17
+ return '';
18
+ }
19
+ // Calculate time difference
20
+ const now = Date.now();
21
+ const diffMs = resetTime - now;
22
+ // Return empty for past timestamps
23
+ if (diffMs <= 0) {
24
+ return '';
25
+ }
26
+ // Convert to hours and minutes
27
+ const totalMinutes = Math.floor(diffMs / (1000 * 60));
28
+ const hours = Math.floor(totalMinutes / 60);
29
+ const minutes = totalMinutes % 60;
30
+ return `Resets in ${hours} hours ${minutes} minutes`;
31
+ }
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: Execute GLM quota check
3
+ agent: glm-quota-exec
4
+ ---
5
+ Execute glm_quota tool.
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "agent": {
4
+ "glm-quota-exec": {
5
+ "mode": "subagent",
6
+ "system": "You are a minimal tool executor. Your only purpose is to execute the glm_quota tool when requested. Do not explain, reason, or add any commentary. Simply call the tool and return its output directly.",
7
+ "provider": "opencode",
8
+ "options": {
9
+ "system": "You are a minimal tool executor. Your only purpose is to execute the glm_quota tool when requested. Do not explain, reason, or add any commentary. Simply call the tool and return its output directly.",
10
+ "provider": "opencode"
11
+ },
12
+ "permission": {}
13
+ }
14
+ }
15
+ // Note: This agent definition is merged into user's existing OpenCode config
16
+ // by the installer script to avoid overwriting their custom configurations
17
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: glm-quota
3
+ description: Query Z.ai GLM Coding Plan usage statistics including quota limits, model usage, and MCP tool usage
4
+ parameters:
5
+ - name: detailed
6
+ type: boolean
7
+ optional: true
8
+ description: Show detailed usage breakdown
9
+ ---
10
+ Query GLM quota usage including token limits and MCP tool usage.
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "opencode-glm-quota",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin to query Z.ai GLM Coding Plan usage statistics including quota limits, model usage, and MCP tool usage",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "opencode-glm-quota-install": "./bin/install.js"
10
+ },
8
11
  "files": [
9
12
  "dist",
13
+ "integration",
14
+ "bin",
10
15
  "README.md",
11
16
  "LICENSE"
12
17
  ],
@@ -14,7 +19,9 @@
14
19
  "build": "tsc",
15
20
  "clean": "rm -rf dist",
16
21
  "prepublishOnly": "npm run clean && npm run build",
17
- "test": "tsx --test",
22
+ "postinstall": "node bin/install.js",
23
+ "test": "tsx --test $(find tests -name '*.test.ts' -type f)",
24
+ "test:coverage": "c8 --reporter=lcov --reporter=text -- npm test",
18
25
  "lint": "eslint src/"
19
26
  },
20
27
  "keywords": [
@@ -40,6 +47,9 @@
40
47
  "url": "https://github.com/guyinwonder168/opencode-glm-quota/issues"
41
48
  },
42
49
  "homepage": "https://github.com/guyinwonder168/opencode-glm-quota#readme",
50
+ "dependencies": {
51
+ "jsonc-parser": "^3.2.0"
52
+ },
43
53
  "peerDependencies": {
44
54
  "@opencode-ai/plugin": ">=0.1.0"
45
55
  },
@@ -47,12 +57,14 @@
47
57
  "access": "public"
48
58
  },
49
59
  "devDependencies": {
50
- "@opencode-ai/plugin": "latest",
60
+ "@opencode-ai/plugin": "^1.1.30",
51
61
  "@types/node": "^20.0.0",
52
- "tsx": "^4.0.0",
53
- "typescript": "^5.0.0",
54
- "eslint": "^8.0.0",
55
62
  "@typescript-eslint/eslint-plugin": "^6.0.0",
56
- "@typescript-eslint/parser": "^6.0.0"
63
+ "@typescript-eslint/parser": "^6.0.0",
64
+ "baseline-browser-mapping": "^2.9.17",
65
+ "c8": "^10.1.3",
66
+ "eslint": "^8.0.0",
67
+ "tsx": "^4.0.0",
68
+ "typescript": "^5.0.0"
57
69
  }
58
70
  }