@leo000001/opencode-quota-sidebar 1.7.0 → 1.8.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
@@ -136,9 +136,35 @@ You can add these command templates in `opencode.json` so you can run `/qday`, `
136
136
  }
137
137
  ```
138
138
 
139
- ## Optional project config
139
+ ## Configuration files
140
140
 
141
- Create `quota-sidebar.config.json` under your project root:
141
+ Recommended global config:
142
+
143
+ - `~/.config/opencode/quota-sidebar.config.json`
144
+
145
+ Optional project overrides:
146
+
147
+ - `<worktree>/quota-sidebar.config.json`
148
+ - `<worktree>/.opencode/quota-sidebar.config.json`
149
+
150
+ Optional explicit override:
151
+
152
+ - `OPENCODE_QUOTA_CONFIG=/absolute/path/to/config.json`
153
+
154
+ Optional config-home override:
155
+
156
+ - `OPENCODE_QUOTA_CONFIG_HOME=/absolute/path/to/config-home`
157
+
158
+ Resolution order (low -> high):
159
+
160
+ 1. Global config (`~/.config/opencode/...`)
161
+ 2. Project root config
162
+ 3. `.opencode` project config
163
+ 4. `OPENCODE_QUOTA_CONFIG`
164
+
165
+ Values are layered; later sources override earlier ones.
166
+
167
+ Example config:
142
168
 
143
169
  ```json
144
170
  {
@@ -215,6 +241,10 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
215
241
  - State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
216
242
  - The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
217
243
  path (for testing); do not set this in production.
244
+ - The `OPENCODE_QUOTA_CONFIG_HOME` env var overrides global config directory
245
+ lookup (`<config-home>/opencode`).
246
+ - The `OPENCODE_QUOTA_CONFIG` env var points to an explicit config file and
247
+ applies as the highest-priority override.
218
248
 
219
249
  ## Contributing
220
250
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { renderMarkdownReport, renderSidebarTitle, renderToastMessage, } from './format.js';
3
3
  import { createQuotaRuntime } from './quota.js';
4
- import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
4
+ import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
5
5
  import { debug, swallow } from './helpers.js';
6
6
  import { normalizeBaseTitle } from './title.js';
7
7
  import { createDescendantsResolver } from './descendants.js';
@@ -14,9 +14,15 @@ import { createUsageService } from './usage_service.js';
14
14
  import { createTitleApplicator } from './title_apply.js';
15
15
  export async function QuotaSidebarPlugin(input) {
16
16
  const quotaRuntime = createQuotaRuntime();
17
+ const configDir = resolveOpencodeConfigDir();
18
+ const configOverride = process.env.OPENCODE_QUOTA_CONFIG?.trim();
17
19
  const config = await loadConfig([
18
- path.join(input.directory, 'quota-sidebar.config.json'),
20
+ path.join(configDir, 'quota-sidebar.config.json'),
19
21
  path.join(input.worktree, 'quota-sidebar.config.json'),
22
+ path.join(input.directory, 'quota-sidebar.config.json'),
23
+ path.join(input.worktree, '.opencode', 'quota-sidebar.config.json'),
24
+ path.join(input.directory, '.opencode', 'quota-sidebar.config.json'),
25
+ ...(configOverride ? [path.resolve(configOverride)] : []),
20
26
  ]);
21
27
  const dataDir = resolveOpencodeDataDir();
22
28
  const statePath = stateFilePath(dataDir);
package/dist/storage.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { dateKeyFromTimestamp, normalizeTimestampMs } from './storage_dates.js';
2
- import { authFilePath, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
2
+ import { authFilePath, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath } from './storage_paths.js';
3
3
  import type { CachedSessionUsage, IncrementalCursor, QuotaSidebarConfig, QuotaSidebarState, SessionState } from './types.js';
4
- export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeDataDir, stateFilePath, };
4
+ export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
5
5
  export declare const defaultConfig: QuotaSidebarConfig;
6
6
  export declare function defaultState(): QuotaSidebarState;
7
7
  export declare function loadConfig(paths: string[]): Promise<QuotaSidebarConfig>;
package/dist/storage.js CHANGED
@@ -4,8 +4,8 @@ import { asBoolean, asNumber, debug, isRecord, mapConcurrent, swallow, } from '.
4
4
  import { discoverChunks, readDayChunk, safeWriteFile, writeDayChunk, } from './storage_chunks.js';
5
5
  import { dateKeyFromTimestamp, dateKeysInRange, dateStartFromKey, isDateKey, normalizeTimestampMs, } from './storage_dates.js';
6
6
  import { parseQuotaCache } from './storage_parse.js';
7
- import { authFilePath, chunkRootPathFromStateFile, resolveOpencodeDataDir, stateFilePath, } from './storage_paths.js';
8
- export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeDataDir, stateFilePath, };
7
+ import { authFilePath, chunkRootPathFromStateFile, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, } from './storage_paths.js';
8
+ export { authFilePath, dateKeyFromTimestamp, normalizeTimestampMs, resolveOpencodeConfigDir, resolveOpencodeDataDir, stateFilePath, };
9
9
  // ─── Default config ──────────────────────────────────────────────────────────
10
10
  export const defaultConfig = {
11
11
  sidebar: {
@@ -44,43 +44,14 @@ export function defaultState() {
44
44
  }
45
45
  // ─── Config loading ──────────────────────────────────────────────────────────
46
46
  export async function loadConfig(paths) {
47
- const existing = await Promise.all(paths.map(async (filePath) => {
48
- const stat = await fs.stat(filePath).catch(swallow('loadConfig:stat'));
49
- if (!stat || !stat.isFile())
50
- return undefined;
51
- return filePath;
52
- }));
53
- const selected = existing.find((value) => value);
54
- if (!selected)
55
- return defaultConfig;
56
- const parsed = await fs
57
- .readFile(selected, 'utf8')
58
- .then((value) => JSON.parse(value))
59
- .catch(swallow('loadConfig:read'));
60
- if (!isRecord(parsed))
61
- return defaultConfig;
62
- const sidebar = isRecord(parsed.sidebar) ? parsed.sidebar : {};
63
- const quota = isRecord(parsed.quota) ? parsed.quota : {};
64
- const toast = isRecord(parsed.toast) ? parsed.toast : {};
65
- const providers = isRecord(quota.providers) ? quota.providers : {};
66
- return {
67
- sidebar: {
68
- enabled: asBoolean(sidebar.enabled, defaultConfig.sidebar.enabled),
69
- width: Math.max(20, Math.min(60, asNumber(sidebar.width, defaultConfig.sidebar.width))),
70
- showCost: asBoolean(sidebar.showCost, defaultConfig.sidebar.showCost),
71
- showQuota: asBoolean(sidebar.showQuota, defaultConfig.sidebar.showQuota),
72
- wrapQuotaLines: asBoolean(sidebar.wrapQuotaLines, defaultConfig.sidebar.wrapQuotaLines),
73
- includeChildren: asBoolean(sidebar.includeChildren, defaultConfig.sidebar.includeChildren),
74
- childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, defaultConfig.sidebar.childrenMaxDepth)))),
75
- childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, defaultConfig.sidebar.childrenMaxSessions)))),
76
- childrenConcurrency: Math.max(1, Math.min(10, Math.floor(asNumber(sidebar.childrenConcurrency, defaultConfig.sidebar.childrenConcurrency)))),
77
- },
78
- quota: {
79
- refreshMs: Math.max(30_000, asNumber(quota.refreshMs, defaultConfig.quota.refreshMs)),
80
- includeOpenAI: asBoolean(quota.includeOpenAI, defaultConfig.quota.includeOpenAI),
81
- includeCopilot: asBoolean(quota.includeCopilot, defaultConfig.quota.includeCopilot),
82
- includeAnthropic: asBoolean(quota.includeAnthropic, defaultConfig.quota.includeAnthropic),
83
- providers: Object.entries(providers).reduce((acc, [id, value]) => {
47
+ const mergeLayer = (base, parsed) => {
48
+ const sidebar = isRecord(parsed.sidebar) ? parsed.sidebar : {};
49
+ const quota = isRecord(parsed.quota) ? parsed.quota : {};
50
+ const toast = isRecord(parsed.toast) ? parsed.toast : {};
51
+ const providers = isRecord(quota.providers) ? quota.providers : {};
52
+ const mergedProviders = {
53
+ ...base.quota.providers,
54
+ ...Object.entries(providers).reduce((acc, [id, value]) => {
84
55
  if (!isRecord(value))
85
56
  return acc;
86
57
  if (typeof value.enabled === 'boolean') {
@@ -88,14 +59,54 @@ export async function loadConfig(paths) {
88
59
  }
89
60
  return acc;
90
61
  }, {}),
91
- refreshAccessToken: asBoolean(quota.refreshAccessToken, defaultConfig.quota.refreshAccessToken),
92
- requestTimeoutMs: Math.max(1000, asNumber(quota.requestTimeoutMs, defaultConfig.quota.requestTimeoutMs)),
93
- },
94
- toast: {
95
- durationMs: Math.max(1000, asNumber(toast.durationMs, defaultConfig.toast.durationMs)),
96
- },
97
- retentionDays: Math.max(1, asNumber(parsed.retentionDays, defaultConfig.retentionDays)),
62
+ };
63
+ return {
64
+ sidebar: {
65
+ enabled: asBoolean(sidebar.enabled, base.sidebar.enabled),
66
+ width: Math.max(20, Math.min(60, asNumber(sidebar.width, base.sidebar.width))),
67
+ showCost: asBoolean(sidebar.showCost, base.sidebar.showCost),
68
+ showQuota: asBoolean(sidebar.showQuota, base.sidebar.showQuota),
69
+ wrapQuotaLines: asBoolean(sidebar.wrapQuotaLines, base.sidebar.wrapQuotaLines),
70
+ includeChildren: asBoolean(sidebar.includeChildren, base.sidebar.includeChildren),
71
+ childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, base.sidebar.childrenMaxDepth)))),
72
+ childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, base.sidebar.childrenMaxSessions)))),
73
+ childrenConcurrency: Math.max(1, Math.min(10, Math.floor(asNumber(sidebar.childrenConcurrency, base.sidebar.childrenConcurrency)))),
74
+ },
75
+ quota: {
76
+ refreshMs: Math.max(30_000, asNumber(quota.refreshMs, base.quota.refreshMs)),
77
+ includeOpenAI: asBoolean(quota.includeOpenAI, base.quota.includeOpenAI),
78
+ includeCopilot: asBoolean(quota.includeCopilot, base.quota.includeCopilot),
79
+ includeAnthropic: asBoolean(quota.includeAnthropic, base.quota.includeAnthropic),
80
+ providers: mergedProviders,
81
+ refreshAccessToken: asBoolean(quota.refreshAccessToken, base.quota.refreshAccessToken),
82
+ requestTimeoutMs: Math.max(1000, asNumber(quota.requestTimeoutMs, base.quota.requestTimeoutMs)),
83
+ },
84
+ toast: {
85
+ durationMs: Math.max(1000, asNumber(toast.durationMs, base.toast.durationMs)),
86
+ },
87
+ retentionDays: Math.max(1, asNumber(parsed.retentionDays, base.retentionDays)),
88
+ };
98
89
  };
90
+ const seen = new Set();
91
+ let config = defaultConfig;
92
+ for (const originalPath of paths) {
93
+ const filePath = path.resolve(originalPath);
94
+ const key = process.platform === 'win32' ? filePath.toLowerCase() : filePath;
95
+ if (seen.has(key))
96
+ continue;
97
+ seen.add(key);
98
+ const stat = await fs.stat(filePath).catch(swallow('loadConfig:stat'));
99
+ if (!stat || !stat.isFile())
100
+ continue;
101
+ const parsed = await fs
102
+ .readFile(filePath, 'utf8')
103
+ .then((value) => JSON.parse(value))
104
+ .catch(swallow('loadConfig:read'));
105
+ if (!isRecord(parsed))
106
+ continue;
107
+ config = mergeLayer(config, parsed);
108
+ }
109
+ return config;
99
110
  }
100
111
  // ─── State loading ───────────────────────────────────────────────────────────
101
112
  /** P2: Lazy chunk loading — only load chunks for sessions in sessionDateMap. */
@@ -9,6 +9,12 @@
9
9
  * OPENCODE_QUOTA_DATA_HOME overrides the full data directory path.
10
10
  */
11
11
  export declare function resolveOpencodeDataDir(): string;
12
+ /**
13
+ * Resolve OpenCode config directory.
14
+ *
15
+ * Uses XDG config conventions with optional plugin-scoped override.
16
+ */
17
+ export declare function resolveOpencodeConfigDir(): string;
12
18
  export declare function stateFilePath(dataDir: string): string;
13
19
  export declare function authFilePath(dataDir: string): string;
14
20
  export declare function chunkRootPathFromStateFile(statePath: string): string;
@@ -20,6 +20,20 @@ export function resolveOpencodeDataDir() {
20
20
  return path.join(path.resolve(xdg), 'opencode');
21
21
  return path.join(os.homedir(), '.local', 'share', 'opencode');
22
22
  }
23
+ /**
24
+ * Resolve OpenCode config directory.
25
+ *
26
+ * Uses XDG config conventions with optional plugin-scoped override.
27
+ */
28
+ export function resolveOpencodeConfigDir() {
29
+ const override = process.env.OPENCODE_QUOTA_CONFIG_HOME?.trim();
30
+ if (override)
31
+ return path.resolve(override);
32
+ const xdg = process.env.XDG_CONFIG_HOME?.trim();
33
+ if (xdg)
34
+ return path.join(path.resolve(xdg), 'opencode');
35
+ return path.join(os.homedir(), '.config', 'opencode');
36
+ }
23
37
  export function stateFilePath(dataDir) {
24
38
  return path.join(dataDir, 'quota-sidebar.state.json');
25
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",