opencode-dux 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.
Files changed (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Local file-based storage for subscription accounts.
3
+ *
4
+ * Stores account credentials in a local JSON file alongside tui-state.json,
5
+ * NOT in the plugin config, so auth tokens are never committed to repos or
6
+ * exposed in the published schema.
7
+ *
8
+ * Supports multiple providers (OpenCode Go, Neuralwatt) via discriminated
9
+ * unions on the `provider` field.
10
+ *
11
+ */
12
+
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import type { StoredAccount, SubscriptionProvider } from './types';
17
+
18
+ // Re-export for consumers
19
+ export type { StoredAccount };
20
+
21
+ interface AccountsFile {
22
+ version: 2;
23
+ accounts: StoredAccount[];
24
+ }
25
+
26
+ export type LoadAccountsResult =
27
+ | { ok: true; accounts: StoredAccount[] }
28
+ | { ok: false; accounts: StoredAccount[] };
29
+
30
+ const STATE_DIR = 'opencode-dux';
31
+ const ACCOUNTS_FILE = 'subscriptions.json';
32
+
33
+ function dataDir(): string {
34
+ return (
35
+ process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share')
36
+ );
37
+ }
38
+
39
+ function getAccountsPath(): string {
40
+ return path.join(dataDir(), 'opencode', 'storage', STATE_DIR, ACCOUNTS_FILE);
41
+ }
42
+
43
+ function emptyFile(): AccountsFile {
44
+ return { version: 2, accounts: [] };
45
+ }
46
+
47
+ function parseAccountsFile(value: string): AccountsFile | null {
48
+ try {
49
+ const parsed = JSON.parse(value) as Partial<AccountsFile>;
50
+ if (parsed?.version === 2 && Array.isArray(parsed.accounts)) {
51
+ return {
52
+ version: 2,
53
+ accounts: parsed.accounts,
54
+ };
55
+ }
56
+ } catch {
57
+ // Fall through to null
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function writeAccountsFile(file: AccountsFile): void {
63
+ try {
64
+ const filePath = getAccountsPath();
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ fs.writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`);
67
+ } catch {
68
+ // Best-effort
69
+ }
70
+ }
71
+
72
+ function loadAccountsResult(): LoadAccountsResult {
73
+ const accountsPath = getAccountsPath();
74
+ try {
75
+ const parsed = parseAccountsFile(fs.readFileSync(accountsPath, 'utf8'));
76
+ if (!parsed) return { ok: false, accounts: [] };
77
+ return { ok: true, accounts: parsed.accounts };
78
+ } catch (error) {
79
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
80
+ return { ok: true, accounts: [] };
81
+ }
82
+ return { ok: false, accounts: [] };
83
+ }
84
+ }
85
+
86
+ function readAccountsFile(): AccountsFile {
87
+ const result = loadAccountsResult();
88
+ if (!result.ok) return emptyFile();
89
+ return { version: 2, accounts: result.accounts };
90
+ }
91
+
92
+ /**
93
+ * Load all stored accounts.
94
+ */
95
+ export function loadAccounts(): StoredAccount[] {
96
+ return loadAccountsResult().accounts;
97
+ }
98
+
99
+ export { loadAccountsResult };
100
+
101
+ /**
102
+ * Load accounts filtered by provider.
103
+ */
104
+ export function getAccountsByProvider(
105
+ provider: SubscriptionProvider,
106
+ ): StoredAccount[] {
107
+ return readAccountsFile().accounts.filter((a) => a.provider === provider);
108
+ }
109
+
110
+ /**
111
+ * Add a new account. If an account with the same name already exists,
112
+ * overwrites it (update).
113
+ */
114
+ export function saveAccount(account: StoredAccount): void {
115
+ const file = readAccountsFile();
116
+ const existing = file.accounts.findIndex((a) => a.name === account.name);
117
+ if (existing >= 0) {
118
+ file.accounts[existing] = account;
119
+ } else {
120
+ file.accounts.push(account);
121
+ }
122
+ writeAccountsFile(file);
123
+ }
124
+
125
+ /**
126
+ * Remove an account by name. Returns true if deleted, false if not found.
127
+ */
128
+ export function removeAccount(name: string): boolean {
129
+ const file = readAccountsFile();
130
+ const index = file.accounts.findIndex((a) => a.name === name);
131
+ if (index < 0) return false;
132
+ file.accounts.splice(index, 1);
133
+ writeAccountsFile(file);
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Update the auth cookie for an existing OpenCode Go account.
139
+ * Returns true if updated, false if account not found or not an opencode-go account.
140
+ */
141
+ export function updateAccountCookie(name: string, authCookie: string): boolean {
142
+ const file = readAccountsFile();
143
+ const account = file.accounts.find((a) => a.name === name);
144
+ if (!account || account.provider !== 'opencode-go') return false;
145
+ account.authCookie = authCookie;
146
+ writeAccountsFile(file);
147
+ return true;
148
+ }
149
+
150
+ /**
151
+ * Mask an auth cookie for display (show first 8 + last 4 chars).
152
+ */
153
+ export function maskCookie(cookie: string): string {
154
+ if (cookie.length <= 16) {
155
+ return `${cookie.slice(0, 4)}...${cookie.slice(-4)}`;
156
+ }
157
+ return `${cookie.slice(0, 8)}...${cookie.slice(-4)}`;
158
+ }
159
+
160
+ /**
161
+ * Look up a stored account by name.
162
+ */
163
+ export function getAccount(name: string): StoredAccount | undefined {
164
+ const file = readAccountsFile();
165
+ return file.accounts.find((a) => a.name === name);
166
+ }
167
+
168
+ /**
169
+ * Set the provider and API key for an existing account.
170
+ * Returns true if updated, false if account not found.
171
+ */
172
+ export function setAccountKey(
173
+ name: string,
174
+ provider: string,
175
+ apiKey: string,
176
+ ): boolean {
177
+ const file = readAccountsFile();
178
+ const account = file.accounts.find((a) => a.name === name);
179
+ if (!account) return false;
180
+ account.provider = provider as SubscriptionProvider;
181
+ account.apiKey = apiKey;
182
+ writeAccountsFile(file);
183
+ return true;
184
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Multi-provider subscription tracking module.
3
+ *
4
+ * Provides account management (local storage), provider-specific scrapers,
5
+ * caching, and slash-command support for subscription usage data displayed
6
+ * in the TUI sidebar and via /subscriptions command.
7
+ */
8
+
9
+ export type { StoredAccount } from './accounts-store';
10
+ export {
11
+ getAccount,
12
+ getAccountsByProvider,
13
+ loadAccounts,
14
+ loadAccountsResult,
15
+ maskCookie,
16
+ removeAccount,
17
+ saveAccount,
18
+ setAccountKey,
19
+ updateAccountCookie,
20
+ } from './accounts-store';
21
+ export { scrapeNeuralwattQuota } from './neuralwatt-scraper';
22
+ export { scrapeQuota, scrapeUsagePage } from './opencode-go-scraper';
23
+ export type {
24
+ NeuralwattUsageEntry,
25
+ OpenCodeGoUsageEntry,
26
+ SubscriptionUsageEntry,
27
+ UsageDetail,
28
+ UsageWindow,
29
+ } from './types';
30
+ export { createUsageService, UsageService } from './usage-service';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Neuralwatt quota API scraper.
3
+ *
4
+ * Fetches usage data from the Neuralwatt REST API using Bearer token
5
+ * authentication. Returns structured quota data including credit balance,
6
+ * energy usage (kWh), and subscription details.
7
+ *
8
+ * Rate limit: 1 request per second per customer.
9
+ */
10
+
11
+ import type { NeuralwattUsageEntry } from './types';
12
+
13
+ const NEURALWATT_QUOTA_URL = 'https://api.neuralwatt.com/v1/quota';
14
+
15
+ const EMPTY_BALANCE = {
16
+ credits_remaining_usd: 0,
17
+ total_credits_usd: 0,
18
+ credits_used_usd: 0,
19
+ accounting_method: 'energy',
20
+ };
21
+
22
+ const EMPTY_USAGE_PERIOD = {
23
+ cost_usd: 0,
24
+ requests: 0,
25
+ tokens: 0,
26
+ energy_kwh: 0,
27
+ };
28
+
29
+ const EMPTY_USAGE = {
30
+ lifetime: { ...EMPTY_USAGE_PERIOD },
31
+ current_month: { ...EMPTY_USAGE_PERIOD },
32
+ };
33
+
34
+ /**
35
+ * Fetch Neuralwatt quota data via the REST API.
36
+ */
37
+ export async function scrapeNeuralwattQuota(
38
+ apiKey: string,
39
+ signal?: AbortSignal,
40
+ ): Promise<NeuralwattUsageEntry> {
41
+ const now = Date.now();
42
+
43
+ try {
44
+ const response = await fetch(NEURALWATT_QUOTA_URL, {
45
+ method: 'GET',
46
+ headers: {
47
+ Authorization: `Bearer ${apiKey}`,
48
+ Accept: 'application/json',
49
+ },
50
+ signal,
51
+ });
52
+
53
+ if (!response.ok) {
54
+ let detail = '';
55
+ try {
56
+ const body = await response.text();
57
+ detail = body.slice(0, 200).replace(/\s+/g, ' ').trim();
58
+ } catch {
59
+ // Ignore body read errors
60
+ }
61
+ return {
62
+ provider: 'neuralwatt',
63
+ accountName: '',
64
+ snapshot_at: '',
65
+ balance: { ...EMPTY_BALANCE },
66
+ usage: {
67
+ lifetime: { ...EMPTY_USAGE_PERIOD },
68
+ current_month: { ...EMPTY_USAGE_PERIOD },
69
+ },
70
+ subscription: null,
71
+ fetchedAt: now,
72
+ error: `API error ${response.status}${detail ? `: ${detail}` : ''}`,
73
+ };
74
+ }
75
+
76
+ const data = (await response.json()) as Record<string, unknown>;
77
+
78
+ return {
79
+ provider: 'neuralwatt',
80
+ accountName: '',
81
+ snapshot_at: (data.snapshot_at as string) ?? '',
82
+ balance: (data.balance as NeuralwattUsageEntry['balance']) ?? {
83
+ ...EMPTY_BALANCE,
84
+ },
85
+ usage: (data.usage as NeuralwattUsageEntry['usage']) ?? {
86
+ ...EMPTY_USAGE,
87
+ },
88
+ subscription:
89
+ (data.subscription as NeuralwattUsageEntry['subscription']) ?? null,
90
+ fetchedAt: now,
91
+ };
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : String(err);
94
+ return {
95
+ provider: 'neuralwatt',
96
+ accountName: '',
97
+ snapshot_at: '',
98
+ balance: { ...EMPTY_BALANCE },
99
+ usage: {
100
+ lifetime: { ...EMPTY_USAGE_PERIOD },
101
+ current_month: { ...EMPTY_USAGE_PERIOD },
102
+ },
103
+ subscription: null,
104
+ fetchedAt: now,
105
+ error: `Fetch failed: ${message}`,
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * OpenCode Go dashboard scraper.
3
+ *
4
+ * Fetches the OpenCode Go workspace dashboard page and parses SolidJS SSR
5
+ * hydration output for known usage windows (rollingUsage, weeklyUsage,
6
+ * monthlyUsage) containing usagePercent and resetInSec fields.
7
+ */
8
+
9
+ import type { OpenCodeGoUsageEntry, UsageDetail, UsageWindow } from './types';
10
+
11
+ const DASHBOARD_URL_PREFIX = 'https://opencode.ai/workspace/';
12
+ const DASHBOARD_URL_SUFFIX = '/go';
13
+ const USAGE_URL_SUFFIX = '/usage';
14
+ const USER_AGENT =
15
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0';
16
+ const _SCRAPE_TIMEOUT_MS = 10_000;
17
+
18
+ /**
19
+ * Regex patterns matching SolidJS SSR hydration output for usage windows.
20
+ * Field order may vary, so we try both orderings.
21
+ */
22
+ const SCRAPED_NUMBER_PATTERN = String.raw`(-?\d+(?:\.\d+)?)`;
23
+
24
+ const RE_ROLLING_PCT_FIRST = new RegExp(
25
+ String.raw`rollingUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
26
+ );
27
+ const RE_ROLLING_RESET_FIRST = new RegExp(
28
+ String.raw`rollingUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
29
+ );
30
+
31
+ const RE_WEEKLY_PCT_FIRST = new RegExp(
32
+ String.raw`weeklyUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
33
+ );
34
+ const RE_WEEKLY_RESET_FIRST = new RegExp(
35
+ String.raw`weeklyUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
36
+ );
37
+
38
+ const RE_MONTHLY_PCT_FIRST = new RegExp(
39
+ String.raw`monthlyUsage:\$R\[\d+\]=\{[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
40
+ );
41
+ const RE_MONTHLY_RESET_FIRST = new RegExp(
42
+ String.raw`monthlyUsage:\$R\[\d+\]=\{[^}]*resetInSec:${SCRAPED_NUMBER_PATTERN}[^}]*usagePercent:${SCRAPED_NUMBER_PATTERN}[^}]*\}`,
43
+ );
44
+
45
+ interface ScrapedWindowUsage {
46
+ usagePercent: number;
47
+ resetInSec: number;
48
+ }
49
+
50
+ function parseWindowUsage(
51
+ html: string,
52
+ rePctFirst: RegExp,
53
+ reResetFirst: RegExp,
54
+ ): ScrapedWindowUsage | null {
55
+ const pctFirstMatch = rePctFirst.exec(html);
56
+ if (pctFirstMatch) {
57
+ const usagePercent = Number(pctFirstMatch[1]);
58
+ const resetInSec = Number(pctFirstMatch[2]);
59
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
60
+ return { usagePercent, resetInSec };
61
+ }
62
+ }
63
+
64
+ const resetFirstMatch = reResetFirst.exec(html);
65
+ if (resetFirstMatch) {
66
+ const resetInSec = Number(resetFirstMatch[1]);
67
+ const usagePercent = Number(resetFirstMatch[2]);
68
+ if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
69
+ return { usagePercent, resetInSec };
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ function normalizeWindowUsage(
77
+ window: ScrapedWindowUsage,
78
+ now: number,
79
+ ): UsageWindow {
80
+ const usagePercent = Math.max(0, window.usagePercent);
81
+ const resetInSec = Math.max(0, window.resetInSec);
82
+
83
+ return {
84
+ usagePercent,
85
+ resetInSec,
86
+ percentRemaining: 100 - usagePercent,
87
+ resetTimeIso: new Date(now + resetInSec * 1000).toISOString(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Fetch the OpenCode Go dashboard /go page and extract quota usage data.
93
+ */
94
+ export async function scrapeQuota(
95
+ workspaceId: string,
96
+ authCookie: string,
97
+ signal?: AbortSignal,
98
+ ): Promise<OpenCodeGoUsageEntry> {
99
+ const now = Date.now();
100
+ const url = `${DASHBOARD_URL_PREFIX}${encodeURIComponent(workspaceId)}${DASHBOARD_URL_SUFFIX}`;
101
+
102
+ try {
103
+ const response = await fetch(url, {
104
+ method: 'GET',
105
+ headers: {
106
+ 'User-Agent': USER_AGENT,
107
+ Accept: 'text/html',
108
+ Cookie: `auth=${authCookie}`,
109
+ },
110
+ signal,
111
+ });
112
+
113
+ if (!response.ok) {
114
+ const text = await response.text();
115
+ const snippet = text.slice(0, 200).replace(/\s+/g, ' ').trim();
116
+ return {
117
+ provider: 'opencode-go',
118
+ accountName: '',
119
+ workspaceId,
120
+ fetchedAt: now,
121
+ error: `Dashboard error ${response.status}: ${snippet}`,
122
+ };
123
+ }
124
+
125
+ const html = await response.text();
126
+
127
+ const rolling = parseWindowUsage(
128
+ html,
129
+ RE_ROLLING_PCT_FIRST,
130
+ RE_ROLLING_RESET_FIRST,
131
+ );
132
+ const weekly = parseWindowUsage(
133
+ html,
134
+ RE_WEEKLY_PCT_FIRST,
135
+ RE_WEEKLY_RESET_FIRST,
136
+ );
137
+ const monthly = parseWindowUsage(
138
+ html,
139
+ RE_MONTHLY_PCT_FIRST,
140
+ RE_MONTHLY_RESET_FIRST,
141
+ );
142
+
143
+ if (!rolling && !weekly && !monthly) {
144
+ return {
145
+ provider: 'opencode-go',
146
+ accountName: '',
147
+ workspaceId,
148
+ fetchedAt: now,
149
+ error:
150
+ 'Could not parse any usage windows (rollingUsage, weeklyUsage, monthlyUsage) from dashboard. ' +
151
+ 'The dashboard format may have changed.',
152
+ };
153
+ }
154
+
155
+ return {
156
+ provider: 'opencode-go',
157
+ accountName: '',
158
+ workspaceId,
159
+ fetchedAt: now,
160
+ ...(rolling ? { rolling: normalizeWindowUsage(rolling, now) } : {}),
161
+ ...(weekly ? { weekly: normalizeWindowUsage(weekly, now) } : {}),
162
+ ...(monthly ? { monthly: normalizeWindowUsage(monthly, now) } : {}),
163
+ };
164
+ } catch (err) {
165
+ const message = err instanceof Error ? err.message : String(err);
166
+ return {
167
+ provider: 'opencode-go',
168
+ accountName: '',
169
+ workspaceId,
170
+ fetchedAt: now,
171
+ error: `Fetch failed: ${message}`,
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Scrape detailed usage data from the /usage page.
178
+ * The page embeds individual usage records in SolidJS SSR hydration format
179
+ * as $R[N]={id:"usg_...} records with model, inputTokens, outputTokens, cost fields.
180
+ * No total or summary is present - we aggregate from individual records.
181
+ */
182
+ export async function scrapeUsagePage(
183
+ workspaceId: string,
184
+ authCookie: string,
185
+ signal?: AbortSignal,
186
+ ): Promise<UsageDetail | { error: string }> {
187
+ const url = `${DASHBOARD_URL_PREFIX}${encodeURIComponent(workspaceId)}${USAGE_URL_SUFFIX}`;
188
+
189
+ try {
190
+ const response = await fetch(url, {
191
+ method: 'GET',
192
+ headers: {
193
+ 'User-Agent': USER_AGENT,
194
+ Accept: 'text/html',
195
+ Cookie: `auth=${authCookie}`,
196
+ },
197
+ signal,
198
+ });
199
+
200
+ if (!response.ok) {
201
+ const text = await response.text();
202
+ const snippet = text.slice(0, 200).replace(/\s+/g, ' ').trim();
203
+ return { error: `Usage page error ${response.status}: ${snippet}` };
204
+ }
205
+
206
+ const html = await response.text();
207
+
208
+ // Parse SSR hydration data directly from the raw HTML.
209
+ // The /usage page embeds records as $R[N]={id:"usg_...} in SolidJS SSR output.
210
+ return parseUsageSSR(html);
211
+ } catch (err) {
212
+ const message = err instanceof Error ? err.message : String(err);
213
+ return { error: `Failed to fetch usage page: ${message}` };
214
+ }
215
+ }
216
+
217
+ function parseUsageSSR(payload: string): UsageDetail | { error: string } {
218
+ // Find all usage records embedded in SSR hydration data.
219
+ // Format: $R[N]={id:"usg_...",...,model:"...",inputTokens:N,outputTokens:N,cost:N,...}
220
+ const records: Array<{
221
+ model: string;
222
+ inputTokens: number;
223
+ outputTokens: number;
224
+ cost: number;
225
+ }> = [];
226
+
227
+ const recordRegex = /\$R\[\d+\]=\{id:"usg_[^}]+?\}/g;
228
+
229
+ for (
230
+ let match: RegExpExecArray | null = recordRegex.exec(payload);
231
+ match;
232
+ match = recordRegex.exec(payload)
233
+ ) {
234
+ const text = match[0];
235
+
236
+ const modelMatch = text.match(/model:"([^"]+)"/);
237
+ if (!modelMatch) continue;
238
+
239
+ const inputTokens = Number(text.match(/inputTokens:(\d+)/)?.[1] ?? 0);
240
+ const outputTokens = Number(text.match(/outputTokens:(\d+)/)?.[1] ?? 0);
241
+ const cost = Number(text.match(/cost:(\d+)/)?.[1] ?? 0);
242
+
243
+ records.push({
244
+ model: modelMatch[1],
245
+ inputTokens,
246
+ outputTokens,
247
+ cost,
248
+ });
249
+ }
250
+
251
+ if (records.length === 0) {
252
+ return { error: 'Could not parse usage data from the page.' };
253
+ }
254
+
255
+ // Aggregate by model
256
+ const perModelMap = new Map<
257
+ string,
258
+ { calls: number; cost: number; inputTokens: number; outputTokens: number }
259
+ >();
260
+
261
+ for (const r of records) {
262
+ const existing = perModelMap.get(r.model) ?? {
263
+ calls: 0,
264
+ cost: 0,
265
+ inputTokens: 0,
266
+ outputTokens: 0,
267
+ };
268
+ existing.calls += 1;
269
+ existing.cost += r.cost;
270
+ existing.inputTokens += r.inputTokens;
271
+ existing.outputTokens += r.outputTokens;
272
+ perModelMap.set(r.model, existing);
273
+ }
274
+
275
+ const totalCalls = records.length;
276
+ // Cost appears to be in micro-dollars (1/1,000,000 of a dollar)
277
+ // Divide by 1,000,000 to get dollar amount
278
+ const totalCostDollars =
279
+ records.reduce((sum, r) => sum + r.cost, 0) / 1_000_000;
280
+
281
+ const perModel = Array.from(perModelMap.entries())
282
+ .map(([model, data]) => ({
283
+ model,
284
+ calls: data.calls,
285
+ cost: data.cost / 1_000_000,
286
+ }))
287
+ .sort((a, b) => b.calls - a.calls);
288
+
289
+ return { totalCalls, totalCost: totalCostDollars, perModel };
290
+ }
291
+
292
+ function _parseUsageHTML(
293
+ _doc: globalThis.Document,
294
+ ): UsageDetail | { error: string } {
295
+ // The /usage page is client-side rendered and all data is in SSR hydration.
296
+ // HTML table parsing is not applicable.
297
+ return { error: 'Could not parse usage data from the page.' };
298
+ }
299
+
300
+ // Export for testing
301
+ export { parseWindowUsage as _parseWindowUsage };