@sooneocean/claude-hud 0.1.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 (210) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/LICENSE +21 -0
  4. package/README.md +379 -0
  5. package/commands/configure.md +361 -0
  6. package/commands/export.md +43 -0
  7. package/commands/health.md +61 -0
  8. package/commands/setup.md +287 -0
  9. package/commands/theme.md +31 -0
  10. package/dist/alert.d.ts +31 -0
  11. package/dist/alert.d.ts.map +1 -0
  12. package/dist/alert.js +53 -0
  13. package/dist/alert.js.map +1 -0
  14. package/dist/burn-rate.d.ts +4 -0
  15. package/dist/burn-rate.d.ts.map +1 -0
  16. package/dist/burn-rate.js +36 -0
  17. package/dist/burn-rate.js.map +1 -0
  18. package/dist/cache.d.ts +6 -0
  19. package/dist/cache.d.ts.map +1 -0
  20. package/dist/cache.js +47 -0
  21. package/dist/cache.js.map +1 -0
  22. package/dist/claude-config-dir.d.ts +4 -0
  23. package/dist/claude-config-dir.d.ts.map +1 -0
  24. package/dist/claude-config-dir.js +24 -0
  25. package/dist/claude-config-dir.js.map +1 -0
  26. package/dist/config-io.d.ts +6 -0
  27. package/dist/config-io.d.ts.map +1 -0
  28. package/dist/config-io.js +27 -0
  29. package/dist/config-io.js.map +1 -0
  30. package/dist/config-reader.d.ts +8 -0
  31. package/dist/config-reader.d.ts.map +1 -0
  32. package/dist/config-reader.js +204 -0
  33. package/dist/config-reader.js.map +1 -0
  34. package/dist/config.d.ts +94 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +358 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/constants.d.ts +11 -0
  39. package/dist/constants.d.ts.map +1 -0
  40. package/dist/constants.js +11 -0
  41. package/dist/constants.js.map +1 -0
  42. package/dist/cost-tracker.d.ts +9 -0
  43. package/dist/cost-tracker.d.ts.map +1 -0
  44. package/dist/cost-tracker.js +46 -0
  45. package/dist/cost-tracker.js.map +1 -0
  46. package/dist/debug.d.ts +6 -0
  47. package/dist/debug.d.ts.map +1 -0
  48. package/dist/debug.js +15 -0
  49. package/dist/debug.js.map +1 -0
  50. package/dist/extra-cmd.d.ts +20 -0
  51. package/dist/extra-cmd.d.ts.map +1 -0
  52. package/dist/extra-cmd.js +112 -0
  53. package/dist/extra-cmd.js.map +1 -0
  54. package/dist/git.d.ts +16 -0
  55. package/dist/git.d.ts.map +1 -0
  56. package/dist/git.js +94 -0
  57. package/dist/git.js.map +1 -0
  58. package/dist/health-check.d.ts +12 -0
  59. package/dist/health-check.d.ts.map +1 -0
  60. package/dist/health-check.js +37 -0
  61. package/dist/health-check.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +198 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/providers/agent-teams-provider.d.ts +10 -0
  67. package/dist/providers/agent-teams-provider.d.ts.map +1 -0
  68. package/dist/providers/agent-teams-provider.js +57 -0
  69. package/dist/providers/agent-teams-provider.js.map +1 -0
  70. package/dist/providers/agw-provider.d.ts +10 -0
  71. package/dist/providers/agw-provider.d.ts.map +1 -0
  72. package/dist/providers/agw-provider.js +49 -0
  73. package/dist/providers/agw-provider.js.map +1 -0
  74. package/dist/providers/index.d.ts +14 -0
  75. package/dist/providers/index.d.ts.map +1 -0
  76. package/dist/providers/index.js +25 -0
  77. package/dist/providers/index.js.map +1 -0
  78. package/dist/render/agents-line.d.ts +3 -0
  79. package/dist/render/agents-line.d.ts.map +1 -0
  80. package/dist/render/agents-line.js +40 -0
  81. package/dist/render/agents-line.js.map +1 -0
  82. package/dist/render/alert-line.d.ts +3 -0
  83. package/dist/render/alert-line.d.ts.map +1 -0
  84. package/dist/render/alert-line.js +11 -0
  85. package/dist/render/alert-line.js.map +1 -0
  86. package/dist/render/colors.d.ts +39 -0
  87. package/dist/render/colors.d.ts.map +1 -0
  88. package/dist/render/colors.js +109 -0
  89. package/dist/render/colors.js.map +1 -0
  90. package/dist/render/framework-line.d.ts +3 -0
  91. package/dist/render/framework-line.d.ts.map +1 -0
  92. package/dist/render/framework-line.js +32 -0
  93. package/dist/render/framework-line.js.map +1 -0
  94. package/dist/render/index.d.ts +3 -0
  95. package/dist/render/index.d.ts.map +1 -0
  96. package/dist/render/index.js +435 -0
  97. package/dist/render/index.js.map +1 -0
  98. package/dist/render/lines/environment.d.ts +3 -0
  99. package/dist/render/lines/environment.d.ts.map +1 -0
  100. package/dist/render/lines/environment.js +30 -0
  101. package/dist/render/lines/environment.js.map +1 -0
  102. package/dist/render/lines/identity.d.ts +3 -0
  103. package/dist/render/lines/identity.d.ts.map +1 -0
  104. package/dist/render/lines/identity.js +93 -0
  105. package/dist/render/lines/identity.js.map +1 -0
  106. package/dist/render/lines/index.d.ts +5 -0
  107. package/dist/render/lines/index.d.ts.map +1 -0
  108. package/dist/render/lines/index.js +5 -0
  109. package/dist/render/lines/index.js.map +1 -0
  110. package/dist/render/lines/project.d.ts +3 -0
  111. package/dist/render/lines/project.d.ts.map +1 -0
  112. package/dist/render/lines/project.js +100 -0
  113. package/dist/render/lines/project.js.map +1 -0
  114. package/dist/render/lines/usage.d.ts +3 -0
  115. package/dist/render/lines/usage.d.ts.map +1 -0
  116. package/dist/render/lines/usage.js +65 -0
  117. package/dist/render/lines/usage.js.map +1 -0
  118. package/dist/render/session-line.d.ts +7 -0
  119. package/dist/render/session-line.d.ts.map +1 -0
  120. package/dist/render/session-line.js +227 -0
  121. package/dist/render/session-line.js.map +1 -0
  122. package/dist/render/todos-line.d.ts +3 -0
  123. package/dist/render/todos-line.d.ts.map +1 -0
  124. package/dist/render/todos-line.js +29 -0
  125. package/dist/render/todos-line.js.map +1 -0
  126. package/dist/render/tools-line.d.ts +3 -0
  127. package/dist/render/tools-line.d.ts.map +1 -0
  128. package/dist/render/tools-line.js +45 -0
  129. package/dist/render/tools-line.js.map +1 -0
  130. package/dist/session-history.d.ts +15 -0
  131. package/dist/session-history.d.ts.map +1 -0
  132. package/dist/session-history.js +46 -0
  133. package/dist/session-history.js.map +1 -0
  134. package/dist/session-stats.d.ts +11 -0
  135. package/dist/session-stats.d.ts.map +1 -0
  136. package/dist/session-stats.js +48 -0
  137. package/dist/session-stats.js.map +1 -0
  138. package/dist/speed-tracker.d.ts +7 -0
  139. package/dist/speed-tracker.d.ts.map +1 -0
  140. package/dist/speed-tracker.js +34 -0
  141. package/dist/speed-tracker.js.map +1 -0
  142. package/dist/stdin.d.ts +9 -0
  143. package/dist/stdin.d.ts.map +1 -0
  144. package/dist/stdin.js +142 -0
  145. package/dist/stdin.js.map +1 -0
  146. package/dist/themes.d.ts +10 -0
  147. package/dist/themes.d.ts.map +1 -0
  148. package/dist/themes.js +81 -0
  149. package/dist/themes.js.map +1 -0
  150. package/dist/transcript.d.ts +3 -0
  151. package/dist/transcript.d.ts.map +1 -0
  152. package/dist/transcript.js +221 -0
  153. package/dist/transcript.js.map +1 -0
  154. package/dist/types.d.ts +124 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +5 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/usage-api.d.ts +62 -0
  159. package/dist/usage-api.d.ts.map +1 -0
  160. package/dist/usage-api.js +908 -0
  161. package/dist/usage-api.js.map +1 -0
  162. package/dist/utils/format.d.ts +9 -0
  163. package/dist/utils/format.d.ts.map +1 -0
  164. package/dist/utils/format.js +75 -0
  165. package/dist/utils/format.js.map +1 -0
  166. package/dist/utils/terminal.d.ts +5 -0
  167. package/dist/utils/terminal.d.ts.map +1 -0
  168. package/dist/utils/terminal.js +42 -0
  169. package/dist/utils/terminal.js.map +1 -0
  170. package/package.json +36 -0
  171. package/src/alert.ts +75 -0
  172. package/src/burn-rate.ts +45 -0
  173. package/src/cache.ts +57 -0
  174. package/src/claude-config-dir.ts +27 -0
  175. package/src/config-io.ts +26 -0
  176. package/src/config-reader.ts +236 -0
  177. package/src/config.ts +496 -0
  178. package/src/constants.ts +10 -0
  179. package/src/cost-tracker.ts +53 -0
  180. package/src/debug.ts +16 -0
  181. package/src/extra-cmd.ts +125 -0
  182. package/src/git.ts +126 -0
  183. package/src/health-check.ts +50 -0
  184. package/src/index.ts +234 -0
  185. package/src/providers/agent-teams-provider.ts +56 -0
  186. package/src/providers/agw-provider.ts +47 -0
  187. package/src/providers/index.ts +27 -0
  188. package/src/render/agents-line.ts +51 -0
  189. package/src/render/alert-line.ts +11 -0
  190. package/src/render/colors.ts +145 -0
  191. package/src/render/framework-line.ts +34 -0
  192. package/src/render/index.ts +512 -0
  193. package/src/render/lines/environment.ts +41 -0
  194. package/src/render/lines/identity.ts +109 -0
  195. package/src/render/lines/index.ts +4 -0
  196. package/src/render/lines/project.ts +113 -0
  197. package/src/render/lines/usage.ts +79 -0
  198. package/src/render/session-line.ts +253 -0
  199. package/src/render/todos-line.ts +35 -0
  200. package/src/render/tools-line.ts +58 -0
  201. package/src/session-history.ts +62 -0
  202. package/src/session-stats.ts +65 -0
  203. package/src/speed-tracker.ts +51 -0
  204. package/src/stdin.ts +169 -0
  205. package/src/themes.ts +90 -0
  206. package/src/transcript.ts +268 -0
  207. package/src/types.ts +146 -0
  208. package/src/usage-api.ts +1090 -0
  209. package/src/utils/format.ts +79 -0
  210. package/src/utils/terminal.ts +46 -0
package/src/config.ts ADDED
@@ -0,0 +1,496 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { getHudPluginDir } from './claude-config-dir.js';
5
+ import type { AlertAction } from './types.js';
6
+ import { getTheme } from './themes.js';
7
+
8
+ export type LineLayoutType = 'compact' | 'expanded';
9
+
10
+ export type AutocompactBufferMode = 'enabled' | 'disabled';
11
+ export type ContextValueMode = 'percent' | 'tokens' | 'remaining';
12
+ export type HudElement = 'project' | 'context' | 'usage' | 'environment' | 'framework' | 'tools' | 'agents' | 'todos' | 'alert';
13
+ export type HudColorName =
14
+ | 'red'
15
+ | 'green'
16
+ | 'yellow'
17
+ | 'magenta'
18
+ | 'cyan'
19
+ | 'brightBlue'
20
+ | 'brightMagenta';
21
+
22
+ /** A color value: named preset, 256-color index (0-255), or hex string (#rrggbb). */
23
+ export type HudColorValue = HudColorName | number | string;
24
+
25
+ export interface HudColorOverrides {
26
+ context: HudColorValue;
27
+ usage: HudColorValue;
28
+ warning: HudColorValue;
29
+ usageWarning: HudColorValue;
30
+ critical: HudColorValue;
31
+ }
32
+
33
+ export const DEFAULT_ELEMENT_ORDER: HudElement[] = [
34
+ 'project', 'context', 'usage', 'environment', 'framework', 'tools', 'agents', 'todos', 'alert',
35
+ ];
36
+
37
+ const KNOWN_ELEMENTS = new Set<HudElement>(DEFAULT_ELEMENT_ORDER);
38
+
39
+ export interface HudConfig {
40
+ lineLayout: LineLayoutType;
41
+ showSeparators: boolean;
42
+ pathLevels: 1 | 2 | 3;
43
+ elementOrder: HudElement[];
44
+ gitStatus: {
45
+ enabled: boolean;
46
+ showDirty: boolean;
47
+ showAheadBehind: boolean;
48
+ showFileStats: boolean;
49
+ };
50
+ display: {
51
+ showModel: boolean;
52
+ showProject: boolean;
53
+ showContextBar: boolean;
54
+ contextValue: ContextValueMode;
55
+ showConfigCounts: boolean;
56
+ showDuration: boolean;
57
+ showSpeed: boolean;
58
+ showTokenBreakdown: boolean;
59
+ showUsage: boolean;
60
+ usageBarEnabled: boolean;
61
+ showTools: boolean;
62
+ showAgents: boolean;
63
+ showTodos: boolean;
64
+ showSessionName: boolean;
65
+ autocompactBuffer: AutocompactBufferMode;
66
+ usageThreshold: number;
67
+ sevenDayThreshold: number;
68
+ environmentThreshold: number;
69
+ customLine: string;
70
+ showFrameworks: boolean;
71
+ showBurnRate: boolean;
72
+ showAlerts: boolean;
73
+ activityIndicator: boolean;
74
+ treePrefixes: boolean;
75
+ mergeToolsAgents: boolean;
76
+ barStyle: 'classic' | 'modern';
77
+ showCost: boolean;
78
+ showNotifications: boolean;
79
+ };
80
+ theme: string;
81
+ usage: {
82
+ cacheTtlSeconds: number;
83
+ failureCacheTtlSeconds: number;
84
+ };
85
+ colors: HudColorOverrides;
86
+ frameworks: {
87
+ agw: { enabled: boolean; endpoint: string };
88
+ agentTeams: { enabled: boolean };
89
+ };
90
+ alerts: {
91
+ context: { warningThreshold: number; criticalThreshold: number; actions: AlertAction };
92
+ usage5h: { warningThreshold: number; criticalThreshold: number; actions: AlertAction };
93
+ usage7d: { warningThreshold: number; actions: AlertAction };
94
+ };
95
+ }
96
+
97
+ export const DEFAULT_CONFIG: HudConfig = {
98
+ lineLayout: 'expanded',
99
+ showSeparators: false,
100
+ pathLevels: 1,
101
+ elementOrder: [...DEFAULT_ELEMENT_ORDER],
102
+ gitStatus: {
103
+ enabled: true,
104
+ showDirty: true,
105
+ showAheadBehind: false,
106
+ showFileStats: false,
107
+ },
108
+ display: {
109
+ showModel: true,
110
+ showProject: true,
111
+ showContextBar: true,
112
+ contextValue: 'percent',
113
+ showConfigCounts: false,
114
+ showDuration: false,
115
+ showSpeed: false,
116
+ showTokenBreakdown: true,
117
+ showUsage: true,
118
+ usageBarEnabled: true,
119
+ showTools: false,
120
+ showAgents: false,
121
+ showTodos: false,
122
+ showSessionName: false,
123
+ autocompactBuffer: 'enabled',
124
+ usageThreshold: 0,
125
+ sevenDayThreshold: 80,
126
+ environmentThreshold: 0,
127
+ customLine: '',
128
+ showFrameworks: false,
129
+ showBurnRate: false,
130
+ showAlerts: true,
131
+ activityIndicator: true,
132
+ treePrefixes: true,
133
+ mergeToolsAgents: true,
134
+ barStyle: 'classic' as const,
135
+ showCost: false,
136
+ showNotifications: false,
137
+ },
138
+ theme: 'default',
139
+ usage: {
140
+ cacheTtlSeconds: 60,
141
+ failureCacheTtlSeconds: 15,
142
+ },
143
+ colors: {
144
+ context: 'green',
145
+ usage: 'brightBlue',
146
+ warning: 'yellow',
147
+ usageWarning: 'brightMagenta',
148
+ critical: 'red',
149
+ },
150
+ frameworks: {
151
+ agw: { enabled: true, endpoint: 'http://localhost:3000' },
152
+ agentTeams: { enabled: true },
153
+ },
154
+ alerts: {
155
+ context: { warningThreshold: 70, criticalThreshold: 85, actions: { visual: true, bell: false, predict: true } },
156
+ usage5h: { warningThreshold: 70, criticalThreshold: 90, actions: { visual: true, bell: true, predict: true } },
157
+ usage7d: { warningThreshold: 80, actions: { visual: true, bell: false, predict: true } },
158
+ },
159
+ };
160
+
161
+ export function getConfigPath(): string {
162
+ const homeDir = os.homedir();
163
+ return path.join(getHudPluginDir(homeDir), 'config.json');
164
+ }
165
+
166
+ function validatePathLevels(value: unknown): value is 1 | 2 | 3 {
167
+ return value === 1 || value === 2 || value === 3;
168
+ }
169
+
170
+ function validateLineLayout(value: unknown): value is LineLayoutType {
171
+ return value === 'compact' || value === 'expanded';
172
+ }
173
+
174
+ function validateAutocompactBuffer(value: unknown): value is AutocompactBufferMode {
175
+ return value === 'enabled' || value === 'disabled';
176
+ }
177
+
178
+ function validateContextValue(value: unknown): value is ContextValueMode {
179
+ return value === 'percent' || value === 'tokens' || value === 'remaining';
180
+ }
181
+
182
+ function validateColorName(value: unknown): value is HudColorName {
183
+ return value === 'red'
184
+ || value === 'green'
185
+ || value === 'yellow'
186
+ || value === 'magenta'
187
+ || value === 'cyan'
188
+ || value === 'brightBlue'
189
+ || value === 'brightMagenta';
190
+ }
191
+
192
+ const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
193
+
194
+ function validateColorValue(value: unknown): value is HudColorValue {
195
+ if (validateColorName(value)) return true;
196
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255) return true;
197
+ if (typeof value === 'string' && HEX_COLOR_PATTERN.test(value)) return true;
198
+ return false;
199
+ }
200
+
201
+ function validateElementOrder(value: unknown): HudElement[] {
202
+ if (!Array.isArray(value) || value.length === 0) {
203
+ return [...DEFAULT_ELEMENT_ORDER];
204
+ }
205
+
206
+ const seen = new Set<HudElement>();
207
+ const elementOrder: HudElement[] = [];
208
+
209
+ for (const item of value) {
210
+ if (typeof item !== 'string' || !KNOWN_ELEMENTS.has(item as HudElement)) {
211
+ continue;
212
+ }
213
+
214
+ const element = item as HudElement;
215
+ if (seen.has(element)) {
216
+ continue;
217
+ }
218
+
219
+ seen.add(element);
220
+ elementOrder.push(element);
221
+ }
222
+
223
+ return elementOrder.length > 0 ? elementOrder : [...DEFAULT_ELEMENT_ORDER];
224
+ }
225
+
226
+ interface LegacyConfig {
227
+ layout?: 'default' | 'separators' | Record<string, unknown>;
228
+ }
229
+
230
+ function migrateConfig(userConfig: Partial<HudConfig> & LegacyConfig): Partial<HudConfig> {
231
+ const migrated = { ...userConfig } as Partial<HudConfig> & LegacyConfig;
232
+
233
+ if ('layout' in userConfig && !('lineLayout' in userConfig)) {
234
+ if (typeof userConfig.layout === 'string') {
235
+ // Legacy string migration (v0.0.x → v0.1.x)
236
+ if (userConfig.layout === 'separators') {
237
+ migrated.lineLayout = 'compact';
238
+ migrated.showSeparators = true;
239
+ } else {
240
+ migrated.lineLayout = 'compact';
241
+ migrated.showSeparators = false;
242
+ }
243
+ } else if (typeof userConfig.layout === 'object' && userConfig.layout !== null) {
244
+ // Object layout written by third-party tools — extract nested fields
245
+ const obj = userConfig.layout as Record<string, unknown>;
246
+ if (typeof obj.lineLayout === 'string') migrated.lineLayout = obj.lineLayout as LineLayoutType;
247
+ if (typeof obj.showSeparators === 'boolean') migrated.showSeparators = obj.showSeparators;
248
+ if (typeof obj.pathLevels === 'number') migrated.pathLevels = obj.pathLevels as 1 | 2 | 3;
249
+ }
250
+ delete migrated.layout;
251
+ }
252
+
253
+ return migrated;
254
+ }
255
+
256
+ function validateThreshold(value: unknown, max = 100): number {
257
+ if (typeof value !== 'number') return 0;
258
+ return Math.max(0, Math.min(max, value));
259
+ }
260
+
261
+ function validatePositiveInt(value: unknown, defaultValue: number): number {
262
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return defaultValue;
263
+ return value;
264
+ }
265
+
266
+ export function mergeConfig(userConfig: Partial<HudConfig>): HudConfig {
267
+ const migrated = migrateConfig(userConfig);
268
+
269
+ const lineLayout = validateLineLayout(migrated.lineLayout)
270
+ ? migrated.lineLayout
271
+ : DEFAULT_CONFIG.lineLayout;
272
+
273
+ const showSeparators = typeof migrated.showSeparators === 'boolean'
274
+ ? migrated.showSeparators
275
+ : DEFAULT_CONFIG.showSeparators;
276
+
277
+ const pathLevels = validatePathLevels(migrated.pathLevels)
278
+ ? migrated.pathLevels
279
+ : DEFAULT_CONFIG.pathLevels;
280
+
281
+ const elementOrder = validateElementOrder(migrated.elementOrder);
282
+
283
+ const gitStatus = {
284
+ enabled: typeof migrated.gitStatus?.enabled === 'boolean'
285
+ ? migrated.gitStatus.enabled
286
+ : DEFAULT_CONFIG.gitStatus.enabled,
287
+ showDirty: typeof migrated.gitStatus?.showDirty === 'boolean'
288
+ ? migrated.gitStatus.showDirty
289
+ : DEFAULT_CONFIG.gitStatus.showDirty,
290
+ showAheadBehind: typeof migrated.gitStatus?.showAheadBehind === 'boolean'
291
+ ? migrated.gitStatus.showAheadBehind
292
+ : DEFAULT_CONFIG.gitStatus.showAheadBehind,
293
+ showFileStats: typeof migrated.gitStatus?.showFileStats === 'boolean'
294
+ ? migrated.gitStatus.showFileStats
295
+ : DEFAULT_CONFIG.gitStatus.showFileStats,
296
+ };
297
+
298
+ const display = {
299
+ showModel: typeof migrated.display?.showModel === 'boolean'
300
+ ? migrated.display.showModel
301
+ : DEFAULT_CONFIG.display.showModel,
302
+ showProject: typeof migrated.display?.showProject === 'boolean'
303
+ ? migrated.display.showProject
304
+ : DEFAULT_CONFIG.display.showProject,
305
+ showContextBar: typeof migrated.display?.showContextBar === 'boolean'
306
+ ? migrated.display.showContextBar
307
+ : DEFAULT_CONFIG.display.showContextBar,
308
+ contextValue: validateContextValue(migrated.display?.contextValue)
309
+ ? migrated.display.contextValue
310
+ : DEFAULT_CONFIG.display.contextValue,
311
+ showConfigCounts: typeof migrated.display?.showConfigCounts === 'boolean'
312
+ ? migrated.display.showConfigCounts
313
+ : DEFAULT_CONFIG.display.showConfigCounts,
314
+ showDuration: typeof migrated.display?.showDuration === 'boolean'
315
+ ? migrated.display.showDuration
316
+ : DEFAULT_CONFIG.display.showDuration,
317
+ showSpeed: typeof migrated.display?.showSpeed === 'boolean'
318
+ ? migrated.display.showSpeed
319
+ : DEFAULT_CONFIG.display.showSpeed,
320
+ showTokenBreakdown: typeof migrated.display?.showTokenBreakdown === 'boolean'
321
+ ? migrated.display.showTokenBreakdown
322
+ : DEFAULT_CONFIG.display.showTokenBreakdown,
323
+ showUsage: typeof migrated.display?.showUsage === 'boolean'
324
+ ? migrated.display.showUsage
325
+ : DEFAULT_CONFIG.display.showUsage,
326
+ usageBarEnabled: typeof migrated.display?.usageBarEnabled === 'boolean'
327
+ ? migrated.display.usageBarEnabled
328
+ : DEFAULT_CONFIG.display.usageBarEnabled,
329
+ showTools: typeof migrated.display?.showTools === 'boolean'
330
+ ? migrated.display.showTools
331
+ : DEFAULT_CONFIG.display.showTools,
332
+ showAgents: typeof migrated.display?.showAgents === 'boolean'
333
+ ? migrated.display.showAgents
334
+ : DEFAULT_CONFIG.display.showAgents,
335
+ showTodos: typeof migrated.display?.showTodos === 'boolean'
336
+ ? migrated.display.showTodos
337
+ : DEFAULT_CONFIG.display.showTodos,
338
+ showSessionName: typeof migrated.display?.showSessionName === 'boolean'
339
+ ? migrated.display.showSessionName
340
+ : DEFAULT_CONFIG.display.showSessionName,
341
+ autocompactBuffer: validateAutocompactBuffer(migrated.display?.autocompactBuffer)
342
+ ? migrated.display.autocompactBuffer
343
+ : DEFAULT_CONFIG.display.autocompactBuffer,
344
+ usageThreshold: validateThreshold(migrated.display?.usageThreshold, 100),
345
+ sevenDayThreshold: validateThreshold(migrated.display?.sevenDayThreshold, 100),
346
+ environmentThreshold: validateThreshold(migrated.display?.environmentThreshold, 100),
347
+ customLine: typeof migrated.display?.customLine === 'string'
348
+ ? migrated.display.customLine.slice(0, 80)
349
+ : DEFAULT_CONFIG.display.customLine,
350
+ showFrameworks: typeof migrated.display?.showFrameworks === 'boolean'
351
+ ? migrated.display.showFrameworks
352
+ : DEFAULT_CONFIG.display.showFrameworks,
353
+ showBurnRate: typeof migrated.display?.showBurnRate === 'boolean'
354
+ ? migrated.display.showBurnRate
355
+ : DEFAULT_CONFIG.display.showBurnRate,
356
+ showAlerts: typeof migrated.display?.showAlerts === 'boolean'
357
+ ? migrated.display.showAlerts
358
+ : DEFAULT_CONFIG.display.showAlerts,
359
+ activityIndicator: typeof migrated.display?.activityIndicator === 'boolean'
360
+ ? migrated.display.activityIndicator
361
+ : DEFAULT_CONFIG.display.activityIndicator,
362
+ treePrefixes: typeof migrated.display?.treePrefixes === 'boolean'
363
+ ? migrated.display.treePrefixes
364
+ : DEFAULT_CONFIG.display.treePrefixes,
365
+ mergeToolsAgents: typeof migrated.display?.mergeToolsAgents === 'boolean'
366
+ ? migrated.display.mergeToolsAgents
367
+ : DEFAULT_CONFIG.display.mergeToolsAgents,
368
+ barStyle: (migrated.display?.barStyle === 'classic' || migrated.display?.barStyle === 'modern')
369
+ ? migrated.display.barStyle
370
+ : DEFAULT_CONFIG.display.barStyle,
371
+ showCost: typeof migrated.display?.showCost === 'boolean'
372
+ ? migrated.display.showCost
373
+ : DEFAULT_CONFIG.display.showCost,
374
+ showNotifications: typeof migrated.display?.showNotifications === 'boolean'
375
+ ? migrated.display.showNotifications
376
+ : DEFAULT_CONFIG.display.showNotifications,
377
+ };
378
+
379
+ const usage = {
380
+ cacheTtlSeconds: validatePositiveInt(
381
+ migrated.usage?.cacheTtlSeconds,
382
+ DEFAULT_CONFIG.usage.cacheTtlSeconds
383
+ ),
384
+ failureCacheTtlSeconds: validatePositiveInt(
385
+ migrated.usage?.failureCacheTtlSeconds,
386
+ DEFAULT_CONFIG.usage.failureCacheTtlSeconds
387
+ ),
388
+ };
389
+
390
+ const theme = typeof migrated.theme === 'string' ? migrated.theme : DEFAULT_CONFIG.theme;
391
+
392
+ // Start with default colors
393
+ const defaultColors = { ...DEFAULT_CONFIG.colors };
394
+
395
+ // Apply theme colors as base (if a valid theme is set)
396
+ const resolvedTheme = getTheme(theme);
397
+ const themeColors = resolvedTheme ? { ...resolvedTheme.colors } : defaultColors;
398
+
399
+ // User's explicit color overrides take precedence over theme
400
+ const colors = {
401
+ context: validateColorValue(migrated.colors?.context)
402
+ ? migrated.colors.context
403
+ : themeColors.context,
404
+ usage: validateColorValue(migrated.colors?.usage)
405
+ ? migrated.colors.usage
406
+ : themeColors.usage,
407
+ warning: validateColorValue(migrated.colors?.warning)
408
+ ? migrated.colors.warning
409
+ : themeColors.warning,
410
+ usageWarning: validateColorValue(migrated.colors?.usageWarning)
411
+ ? migrated.colors.usageWarning
412
+ : themeColors.usageWarning,
413
+ critical: validateColorValue(migrated.colors?.critical)
414
+ ? migrated.colors.critical
415
+ : themeColors.critical,
416
+ };
417
+
418
+ const frameworks = {
419
+ agw: {
420
+ enabled: typeof migrated.frameworks?.agw?.enabled === 'boolean'
421
+ ? migrated.frameworks.agw.enabled
422
+ : DEFAULT_CONFIG.frameworks.agw.enabled,
423
+ endpoint: typeof migrated.frameworks?.agw?.endpoint === 'string'
424
+ ? migrated.frameworks.agw.endpoint
425
+ : DEFAULT_CONFIG.frameworks.agw.endpoint,
426
+ },
427
+ agentTeams: {
428
+ enabled: typeof migrated.frameworks?.agentTeams?.enabled === 'boolean'
429
+ ? migrated.frameworks.agentTeams.enabled
430
+ : DEFAULT_CONFIG.frameworks.agentTeams.enabled,
431
+ },
432
+ };
433
+
434
+ function mergeAlertThreshold(value: unknown, defaultValue: number): number {
435
+ if (typeof value === 'number' && value >= 0 && value <= 100) return value;
436
+ return defaultValue;
437
+ }
438
+
439
+ function mergeAlertActions(userActions: Partial<AlertAction> | undefined, defaultActions: AlertAction): AlertAction {
440
+ return {
441
+ visual: typeof userActions?.visual === 'boolean' ? userActions.visual : defaultActions.visual,
442
+ bell: typeof userActions?.bell === 'boolean' ? userActions.bell : defaultActions.bell,
443
+ predict: typeof userActions?.predict === 'boolean' ? userActions.predict : defaultActions.predict,
444
+ };
445
+ }
446
+
447
+ const alerts = {
448
+ context: {
449
+ warningThreshold: mergeAlertThreshold(
450
+ migrated.alerts?.context?.warningThreshold,
451
+ DEFAULT_CONFIG.alerts.context.warningThreshold
452
+ ),
453
+ criticalThreshold: mergeAlertThreshold(
454
+ migrated.alerts?.context?.criticalThreshold,
455
+ DEFAULT_CONFIG.alerts.context.criticalThreshold
456
+ ),
457
+ actions: mergeAlertActions(migrated.alerts?.context?.actions, DEFAULT_CONFIG.alerts.context.actions),
458
+ },
459
+ usage5h: {
460
+ warningThreshold: mergeAlertThreshold(
461
+ migrated.alerts?.usage5h?.warningThreshold,
462
+ DEFAULT_CONFIG.alerts.usage5h.warningThreshold
463
+ ),
464
+ criticalThreshold: mergeAlertThreshold(
465
+ migrated.alerts?.usage5h?.criticalThreshold,
466
+ DEFAULT_CONFIG.alerts.usage5h.criticalThreshold
467
+ ),
468
+ actions: mergeAlertActions(migrated.alerts?.usage5h?.actions, DEFAULT_CONFIG.alerts.usage5h.actions),
469
+ },
470
+ usage7d: {
471
+ warningThreshold: mergeAlertThreshold(
472
+ migrated.alerts?.usage7d?.warningThreshold,
473
+ DEFAULT_CONFIG.alerts.usage7d.warningThreshold
474
+ ),
475
+ actions: mergeAlertActions(migrated.alerts?.usage7d?.actions, DEFAULT_CONFIG.alerts.usage7d.actions),
476
+ },
477
+ };
478
+
479
+ return { lineLayout, showSeparators, pathLevels, elementOrder, gitStatus, display, theme, usage, colors, frameworks, alerts };
480
+ }
481
+
482
+ export async function loadConfig(): Promise<HudConfig> {
483
+ const configPath = getConfigPath();
484
+
485
+ try {
486
+ if (!fs.existsSync(configPath)) {
487
+ return DEFAULT_CONFIG;
488
+ }
489
+
490
+ const content = fs.readFileSync(configPath, 'utf-8');
491
+ const userConfig = JSON.parse(content) as Partial<HudConfig>;
492
+ return mergeConfig(userConfig);
493
+ } catch {
494
+ return DEFAULT_CONFIG;
495
+ }
496
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Autocompact buffer percentage.
3
+ *
4
+ * NOTE: This value is applied as a percentage of Claude Code's reported
5
+ * context window size. The `33k/200k` example is just the 200k-window case.
6
+ * It is empirically derived from current Claude Code `/context` output, is
7
+ * not officially documented by Anthropic, and may need adjustment if users
8
+ * report mismatches in future Claude Code versions.
9
+ */
10
+ export const AUTOCOMPACT_BUFFER_PERCENT = 0.165;
@@ -0,0 +1,53 @@
1
+ import type { StdinData } from './types.js';
2
+
3
+ export interface CostEstimate {
4
+ sessionCost: number; // USD
5
+ inputCostPer1M: number;
6
+ outputCostPer1M: number;
7
+ }
8
+
9
+ // Pricing per 1M tokens (approximate, as of 2026)
10
+ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
11
+ 'opus': { input: 15, output: 75 },
12
+ 'sonnet': { input: 3, output: 15 },
13
+ 'haiku': { input: 0.25, output: 1.25 },
14
+ };
15
+
16
+ function getModelTier(modelName: string): string {
17
+ const lower = modelName.toLowerCase();
18
+ if (lower.includes('opus')) return 'opus';
19
+ if (lower.includes('sonnet')) return 'sonnet';
20
+ if (lower.includes('haiku')) return 'haiku';
21
+ return 'sonnet'; // default
22
+ }
23
+
24
+ export function estimateCost(stdin: StdinData, _cacheDir: string): CostEstimate | null {
25
+ const modelName = stdin.model?.display_name || stdin.model?.id || '';
26
+ if (!modelName) return null;
27
+
28
+ const usage = stdin.context_window?.current_usage;
29
+ if (!usage) return null;
30
+
31
+ const tier = getModelTier(modelName);
32
+ const pricing = MODEL_PRICING[tier];
33
+ if (!pricing) return null;
34
+
35
+ const inputTokens = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
36
+ const outputTokens = usage.output_tokens ?? 0;
37
+
38
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
39
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
40
+ const sessionCost = inputCost + outputCost;
41
+
42
+ return {
43
+ sessionCost,
44
+ inputCostPer1M: pricing.input,
45
+ outputCostPer1M: pricing.output,
46
+ };
47
+ }
48
+
49
+ export function formatCost(cost: number): string {
50
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
51
+ if (cost < 1) return `$${cost.toFixed(2)}`;
52
+ return `$${cost.toFixed(2)}`;
53
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Shared debug logging utility
2
+ // Enable via: DEBUG=claude-hud or DEBUG=*
3
+
4
+ const DEBUG = process.env.DEBUG?.includes('claude-hud') || process.env.DEBUG === '*';
5
+
6
+ /**
7
+ * Create a namespaced debug logger
8
+ * @param namespace - Tag for log messages (e.g., 'config', 'usage')
9
+ */
10
+ export function createDebug(namespace: string) {
11
+ return function debug(msg: string, ...args: unknown[]): void {
12
+ if (DEBUG) {
13
+ console.error(`[claude-hud:${namespace}] ${msg}`, ...args);
14
+ }
15
+ };
16
+ }
@@ -0,0 +1,125 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ const MAX_BUFFER = 10 * 1024; // 10KB - plenty for a label
7
+ const MAX_LABEL_LENGTH = 50;
8
+ const TIMEOUT_MS = 3000;
9
+
10
+ const isDebug = process.env.DEBUG?.includes('claude-hud') ?? false;
11
+
12
+ const SHELL_ESCAPE_RE = /[\x00-\x1f\x7f`$\\]/g;
13
+ const CONSECUTIVE_SPACES_RE = /\s{2,}/g;
14
+ const LEADING_TRAILING_SPACES_RE = /^\s+|\s+$/g;
15
+
16
+ // ANSI/control sequence patterns used in sanitize()
17
+ const CSI_SEQUENCE_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
18
+ const OSC_SEQUENCE_RE = /\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g;
19
+ const C1_ESCAPE_RE = /\x1B[@-Z\\-_]/g;
20
+ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g;
21
+ const BIDI_CHARS_RE = /[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069\u206A-\u206F]/g;
22
+
23
+ function debug(message: string): void {
24
+ if (isDebug) {
25
+ console.error(`[claude-hud:extra-cmd] ${message}`);
26
+ }
27
+ }
28
+
29
+ interface ExtraLabel {
30
+ label: string;
31
+ }
32
+
33
+ /**
34
+ * Sanitize output to prevent terminal escape injection.
35
+ * Strips ANSI escapes, OSC sequences, control characters, and bidi controls.
36
+ */
37
+ export function sanitize(input: string): string {
38
+ return input
39
+ .replace(CSI_SEQUENCE_RE, '') // CSI sequences
40
+ .replace(OSC_SEQUENCE_RE, '') // OSC sequences
41
+ .replace(C1_ESCAPE_RE, '') // 7-bit C1 / ESC Fe
42
+ .replace(CONTROL_CHARS_RE, '') // C0/C1 controls
43
+ .replace(BIDI_CHARS_RE, ''); // bidi
44
+ }
45
+
46
+ /**
47
+ * Parse --extra-cmd argument from process.argv
48
+ * Supports both: --extra-cmd "command" and --extra-cmd="command"
49
+ */
50
+ export function parseExtraCmdArg(argv: string[] = process.argv): string | null {
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const arg = argv[i];
53
+
54
+ // Handle --extra-cmd=value syntax
55
+ if (arg.startsWith('--extra-cmd=')) {
56
+ const value = arg.slice('--extra-cmd='.length);
57
+ if (value === '') {
58
+ debug('Warning: --extra-cmd value is empty, ignoring');
59
+ return null;
60
+ }
61
+ return value;
62
+ }
63
+
64
+ // Handle --extra-cmd value syntax
65
+ if (arg === '--extra-cmd') {
66
+ if (i + 1 >= argv.length) {
67
+ debug('Warning: --extra-cmd specified but no value provided');
68
+ return null;
69
+ }
70
+ const value = argv[i + 1];
71
+ if (value === '') {
72
+ debug('Warning: --extra-cmd value is empty, ignoring');
73
+ return null;
74
+ }
75
+ return value;
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Execute a command and parse JSON output expecting { label: string }
84
+ * Returns null on any error (timeout, parse failure, missing label)
85
+ *
86
+ * SECURITY NOTE: The cmd parameter is sourced exclusively from CLI arguments
87
+ * (--extra-cmd) typed by the user. Since the user controls their own shell,
88
+ * shell injection is not a concern here - it's intentional user input.
89
+ */
90
+ export async function runExtraCmd(cmd: string, timeout: number = TIMEOUT_MS): Promise<string | null> {
91
+ try {
92
+ const { stdout } = await execAsync(cmd, {
93
+ timeout,
94
+ maxBuffer: MAX_BUFFER,
95
+ });
96
+ const data: unknown = JSON.parse(stdout.trim());
97
+ if (
98
+ typeof data === 'object' &&
99
+ data !== null &&
100
+ 'label' in data &&
101
+ typeof (data as ExtraLabel).label === 'string'
102
+ ) {
103
+ let label = sanitize((data as ExtraLabel).label);
104
+ if (label.length > MAX_LABEL_LENGTH) {
105
+ label = label.slice(0, MAX_LABEL_LENGTH - 1) + '…';
106
+ }
107
+ return label;
108
+ }
109
+ debug(`Command output missing 'label' field or invalid type: ${JSON.stringify(data)}`);
110
+ return null;
111
+ } catch (err) {
112
+ if (err instanceof Error) {
113
+ if (err.message.includes('TIMEOUT') || err.message.includes('killed')) {
114
+ debug(`Command timed out after ${timeout}ms: ${cmd}`);
115
+ } else if (err instanceof SyntaxError) {
116
+ debug(`Failed to parse JSON output: ${err.message}`);
117
+ } else {
118
+ debug(`Command failed: ${err.message}`);
119
+ }
120
+ } else {
121
+ debug(`Command failed with unknown error`);
122
+ }
123
+ return null;
124
+ }
125
+ }