agenthud 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.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # agenthud
2
+
3
+ Terminal dashboard for AI agent development.
4
+
5
+ ```
6
+ ┌─ Git ────────────────────────────────────────────────────┐
7
+ │ main · +1068 -166 · 12 commits · 23 files │
8
+ │ • 1d00dc0 refactor: rename project from agent-dashboa... │
9
+ │ • 3529727 fix: Decisions separator line width now mat... │
10
+ │ • d7652c4 fix: use proper box character for Decisions... │
11
+ │ • 1594987 feat: move progress bar to Plan title line │
12
+ │ • d5a9f4d feat: improve Plan panel and Tests panel di... │
13
+ └──────────────────────────────────────────────────────────┘
14
+
15
+ ┌─ Plan ─────────────────────────────────── 7/10 ███████░░░┐
16
+ │ Build agenthud CLI tool │
17
+ │ ✓ Set up project (npm, TypeScript, Vitest) │
18
+ │ ✓ Implement git data collection module │
19
+ │ ✓ Create GitPanel UI component │
20
+ │ ✓ Add CLI entry point with watch mode │
21
+ │ ✓ Fix getTodayStats bug │
22
+ │ ○ Add --dir flag for project directory │
23
+ │ ○ Add --json flag for JSON output │
24
+ │ ✓ Add PlanPanel component │
25
+ │ ✓ Add TestPanel component │
26
+ │ ○ Publish to npm │
27
+ ├─ Decisions ──────────────────────────────────────────────┤
28
+ │ • Use dependency injection for git module testing │
29
+ │ • Use Ink for terminal UI │
30
+ │ • Use git log --numstat for stats instead of git diff... │
31
+ └──────────────────────────────────────────────────────────┘
32
+
33
+ ┌─ Tests ──────────────────────────────────────────────────┐
34
+ │ ⚠ Outdated (1 commit behind) │
35
+ │ ✓ 70 passed · 3529727 · 40m ago │
36
+ └──────────────────────────────────────────────────────────┘
37
+ ```
38
+
39
+ ## Install
40
+ ```bash
41
+ npx agenthud
42
+ ```
43
+
44
+ ## Features
45
+
46
+ - **Git**: Branch, commits, line changes
47
+ - **Plan**: Progress, decisions from `.agent/plan.json`
48
+ - **Tests**: Results with outdated detection
49
+
50
+ ## Setup
51
+
52
+ Add to your `CLAUDE.md`:
53
+ ```markdown
54
+ Maintain `.agent/` directory:
55
+ - Update `plan.json` when plan changes
56
+ - Update `decisions.json` for key decisions
57
+ ```
58
+
59
+ ## Keyboard
60
+
61
+ - `q` - quit
62
+ - `r` - refresh
63
+
64
+ ## License
65
+
66
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import React2 from "react";
5
+ import { render } from "ink";
6
+
7
+ // src/ui/App.tsx
8
+ import { useState, useEffect, useCallback } from "react";
9
+ import { Box as Box4, Text as Text4, useApp, useInput } from "ink";
10
+
11
+ // src/ui/GitPanel.tsx
12
+ import { Box, Text } from "ink";
13
+
14
+ // src/ui/constants.ts
15
+ var PANEL_WIDTH = 60;
16
+ var CONTENT_WIDTH = PANEL_WIDTH - 4;
17
+ var SEPARATOR = "\u2500".repeat(CONTENT_WIDTH);
18
+ function truncate(text, maxLength) {
19
+ if (text.length <= maxLength) return text;
20
+ return text.slice(0, maxLength - 3) + "...";
21
+ }
22
+
23
+ // src/ui/GitPanel.tsx
24
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
25
+ var MAX_COMMITS = 5;
26
+ var MAX_MESSAGE_LENGTH = CONTENT_WIDTH - 10;
27
+ function GitPanel({ branch, commits, stats }) {
28
+ if (branch === null) {
29
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
30
+ /* @__PURE__ */ jsx(Box, { marginTop: -1, children: /* @__PURE__ */ jsx(Text, { children: " Git " }) }),
31
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Not a git repository" })
32
+ ] });
33
+ }
34
+ const displayCommits = commits.slice(0, MAX_COMMITS);
35
+ const hasCommits = commits.length > 0;
36
+ const commitWord = commits.length === 1 ? "commit" : "commits";
37
+ const fileWord = stats.files === 1 ? "file" : "files";
38
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
39
+ /* @__PURE__ */ jsx(Box, { marginTop: -1, children: /* @__PURE__ */ jsx(Text, { children: " Git " }) }),
40
+ /* @__PURE__ */ jsxs(Text, { children: [
41
+ /* @__PURE__ */ jsx(Text, { color: "green", children: branch }),
42
+ hasCommits && /* @__PURE__ */ jsxs(Fragment, { children: [
43
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: " \xB7 " }),
44
+ /* @__PURE__ */ jsxs(Text, { color: "green", children: [
45
+ "+",
46
+ stats.added
47
+ ] }),
48
+ /* @__PURE__ */ jsx(Text, { children: " " }),
49
+ /* @__PURE__ */ jsxs(Text, { color: "red", children: [
50
+ "-",
51
+ stats.deleted
52
+ ] }),
53
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
54
+ " \xB7 ",
55
+ commits.length,
56
+ " ",
57
+ commitWord,
58
+ " \xB7 ",
59
+ stats.files,
60
+ " ",
61
+ fileWord
62
+ ] })
63
+ ] })
64
+ ] }),
65
+ hasCommits ? /* @__PURE__ */ jsx(Fragment, { children: displayCommits.map((commit) => /* @__PURE__ */ jsxs(Text, { children: [
66
+ "\u2022 ",
67
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: commit.hash.slice(0, 7) }),
68
+ " ",
69
+ truncate(commit.message, MAX_MESSAGE_LENGTH)
70
+ ] }, commit.hash)) }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: "No commits today" })
71
+ ] });
72
+ }
73
+
74
+ // src/ui/PlanPanel.tsx
75
+ import { Box as Box2, Text as Text2 } from "ink";
76
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
77
+ var BOX = { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" };
78
+ var PROGRESS_BAR_WIDTH = 10;
79
+ var MAX_STEP_LENGTH = CONTENT_WIDTH - 2;
80
+ var MAX_DECISION_LENGTH = CONTENT_WIDTH - 2;
81
+ function createProgressBar(done, total) {
82
+ if (total === 0) return "\u2591".repeat(PROGRESS_BAR_WIDTH);
83
+ const filled = Math.round(done / total * PROGRESS_BAR_WIDTH);
84
+ const empty = PROGRESS_BAR_WIDTH - filled;
85
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty);
86
+ }
87
+ var INNER_WIDTH = PANEL_WIDTH - 2;
88
+ function createTitleLine(done, total) {
89
+ const label = " Plan ";
90
+ const count = ` ${done}/${total} `;
91
+ const bar = createProgressBar(done, total);
92
+ const dashCount = PANEL_WIDTH - 3 - label.length - count.length - bar.length;
93
+ const dashes = BOX.h.repeat(Math.max(0, dashCount));
94
+ return BOX.tl + BOX.h + label + dashes + count + bar + BOX.tr;
95
+ }
96
+ function createBottomLine() {
97
+ return BOX.bl + BOX.h.repeat(INNER_WIDTH) + BOX.br;
98
+ }
99
+ function padLine(content) {
100
+ const padding = INNER_WIDTH - content.length;
101
+ return content + " ".repeat(Math.max(0, padding));
102
+ }
103
+ function createDecisionsHeader() {
104
+ const label = "\u2500 Decisions ";
105
+ const dashCount = PANEL_WIDTH - 1 - label.length - 1;
106
+ return label + "\u2500".repeat(dashCount) + "\u2524";
107
+ }
108
+ function PlanPanel({ plan, decisions, error }) {
109
+ if (error || !plan) {
110
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
111
+ /* @__PURE__ */ jsxs2(Text2, { children: [
112
+ BOX.tl,
113
+ BOX.h,
114
+ " Plan ",
115
+ BOX.h.repeat(INNER_WIDTH - 7),
116
+ BOX.tr
117
+ ] }),
118
+ /* @__PURE__ */ jsxs2(Text2, { children: [
119
+ BOX.v,
120
+ padLine(" " + (error || "No plan found")),
121
+ BOX.v
122
+ ] }),
123
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
124
+ ] });
125
+ }
126
+ const doneCount = plan.steps.filter((s) => s.status === "done").length;
127
+ const totalCount = plan.steps.length;
128
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: PANEL_WIDTH, children: [
129
+ /* @__PURE__ */ jsx2(Text2, { children: createTitleLine(doneCount, totalCount) }),
130
+ /* @__PURE__ */ jsxs2(Text2, { children: [
131
+ BOX.v,
132
+ padLine(" " + truncate(plan.goal, CONTENT_WIDTH)),
133
+ BOX.v
134
+ ] }),
135
+ plan.steps.map((step, index) => {
136
+ const stepText = " " + (step.status === "done" ? "\u2713" : step.status === "in-progress" ? "\u2192" : "\u25CB") + " " + truncate(step.step, MAX_STEP_LENGTH);
137
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
138
+ BOX.v,
139
+ padLine(stepText),
140
+ BOX.v
141
+ ] }, index);
142
+ }),
143
+ decisions.length > 0 && /* @__PURE__ */ jsxs2(Fragment2, { children: [
144
+ /* @__PURE__ */ jsxs2(Text2, { children: [
145
+ "\u251C",
146
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: createDecisionsHeader() })
147
+ ] }),
148
+ decisions.map((decision, index) => {
149
+ const decText = " \u2022 " + truncate(decision.decision, MAX_DECISION_LENGTH);
150
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
151
+ BOX.v,
152
+ /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: padLine(decText) }),
153
+ BOX.v
154
+ ] }, index);
155
+ })
156
+ ] }),
157
+ /* @__PURE__ */ jsx2(Text2, { children: createBottomLine() })
158
+ ] });
159
+ }
160
+
161
+ // src/ui/TestPanel.tsx
162
+ import { Box as Box3, Text as Text3 } from "ink";
163
+ import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
164
+ function formatRelativeTime(timestamp) {
165
+ const now = Date.now();
166
+ const then = new Date(timestamp).getTime();
167
+ const diffMs = now - then;
168
+ const diffMins = Math.floor(diffMs / 6e4);
169
+ const diffHours = Math.floor(diffMs / 36e5);
170
+ const diffDays = Math.floor(diffMs / 864e5);
171
+ if (diffMins < 1) return "just now";
172
+ if (diffMins < 60) return `${diffMins}m ago`;
173
+ if (diffHours < 24) return `${diffHours}h ago`;
174
+ return `${diffDays}d ago`;
175
+ }
176
+ function TestPanel({
177
+ results,
178
+ isOutdated,
179
+ commitsBehind,
180
+ error
181
+ }) {
182
+ if (error || !results) {
183
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
184
+ /* @__PURE__ */ jsx3(Box3, { marginTop: -1, children: /* @__PURE__ */ jsx3(Text3, { children: " Tests " }) }),
185
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: error || "No test results" })
186
+ ] });
187
+ }
188
+ const hasFailures = results.failures.length > 0;
189
+ const relativeTime = formatRelativeTime(results.timestamp);
190
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", borderStyle: "single", paddingX: 1, width: PANEL_WIDTH, children: [
191
+ /* @__PURE__ */ jsx3(Box3, { marginTop: -1, children: /* @__PURE__ */ jsx3(Text3, { children: " Tests " }) }),
192
+ isOutdated && /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
193
+ "\u26A0 Outdated (",
194
+ commitsBehind,
195
+ " ",
196
+ commitsBehind === 1 ? "commit" : "commits",
197
+ " behind)"
198
+ ] }),
199
+ /* @__PURE__ */ jsxs3(Text3, { children: [
200
+ /* @__PURE__ */ jsxs3(Text3, { color: "green", children: [
201
+ "\u2713 ",
202
+ results.passed,
203
+ " passed"
204
+ ] }),
205
+ results.failed > 0 && /* @__PURE__ */ jsxs3(Fragment3, { children: [
206
+ " ",
207
+ /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
208
+ "\u2717 ",
209
+ results.failed,
210
+ " failed"
211
+ ] })
212
+ ] }),
213
+ results.skipped > 0 && /* @__PURE__ */ jsxs3(Fragment3, { children: [
214
+ " ",
215
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
216
+ "\u25CB ",
217
+ results.skipped,
218
+ " skipped"
219
+ ] })
220
+ ] }),
221
+ /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
222
+ " ",
223
+ "\xB7 ",
224
+ results.hash,
225
+ " \xB7 ",
226
+ relativeTime
227
+ ] })
228
+ ] }),
229
+ hasFailures && /* @__PURE__ */ jsxs3(Fragment3, { children: [
230
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: SEPARATOR }),
231
+ results.failures.map((failure, index) => /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
232
+ /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
233
+ "\u2717 ",
234
+ truncate(failure.file, CONTENT_WIDTH - 2)
235
+ ] }),
236
+ /* @__PURE__ */ jsxs3(Text3, { children: [
237
+ " ",
238
+ "\u2022 ",
239
+ truncate(failure.name, CONTENT_WIDTH - 4)
240
+ ] })
241
+ ] }, index))
242
+ ] })
243
+ ] });
244
+ }
245
+
246
+ // src/data/git.ts
247
+ import { execSync as nodeExecSync } from "child_process";
248
+ var execFn = (command, options2) => nodeExecSync(command, options2);
249
+ function getCurrentBranch() {
250
+ try {
251
+ const result = execFn("git branch --show-current", {
252
+ encoding: "utf-8"
253
+ });
254
+ return result.trim();
255
+ } catch {
256
+ return null;
257
+ }
258
+ }
259
+ function getTodayCommits() {
260
+ try {
261
+ const result = execFn('git log --since=midnight --format="%h|%aI|%s"', {
262
+ encoding: "utf-8"
263
+ });
264
+ const lines = result.trim().split("\n").filter(Boolean);
265
+ return lines.map((line) => {
266
+ const [hash, timestamp, ...messageParts] = line.split("|");
267
+ return {
268
+ hash,
269
+ message: messageParts.join("|"),
270
+ timestamp: new Date(timestamp)
271
+ };
272
+ });
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+ function getTodayStats() {
278
+ try {
279
+ const result = execFn('git log --since=midnight --numstat --format=""', {
280
+ encoding: "utf-8"
281
+ });
282
+ const lines = result.trim().split("\n").filter(Boolean);
283
+ let added = 0;
284
+ let deleted = 0;
285
+ const filesSet = /* @__PURE__ */ new Set();
286
+ for (const line of lines) {
287
+ const [addedStr, deletedStr, filename] = line.split(" ");
288
+ if (addedStr === "-" || deletedStr === "-") {
289
+ if (filename) filesSet.add(filename);
290
+ continue;
291
+ }
292
+ added += parseInt(addedStr, 10) || 0;
293
+ deleted += parseInt(deletedStr, 10) || 0;
294
+ if (filename) filesSet.add(filename);
295
+ }
296
+ return { added, deleted, files: filesSet.size };
297
+ } catch {
298
+ return { added: 0, deleted: 0, files: 0 };
299
+ }
300
+ }
301
+
302
+ // src/data/plan.ts
303
+ import { readFileSync as nodeReadFileSync } from "fs";
304
+ import { join } from "path";
305
+ var AGENT_DIR = ".agent";
306
+ var PLAN_FILE = "plan.json";
307
+ var DECISIONS_FILE = "decisions.json";
308
+ var MAX_DECISIONS = 3;
309
+ var readFileFn = (path) => nodeReadFileSync(path, "utf-8");
310
+ function getPlanData(dir = process.cwd()) {
311
+ const planPath = join(dir, AGENT_DIR, PLAN_FILE);
312
+ const decisionsPath = join(dir, AGENT_DIR, DECISIONS_FILE);
313
+ let plan = null;
314
+ let decisions = [];
315
+ let error;
316
+ try {
317
+ const content = readFileFn(planPath);
318
+ plan = JSON.parse(content);
319
+ } catch (e) {
320
+ if (e instanceof SyntaxError) {
321
+ error = "Invalid plan.json";
322
+ } else {
323
+ error = "No plan found";
324
+ }
325
+ plan = null;
326
+ }
327
+ try {
328
+ const content = readFileFn(decisionsPath);
329
+ const parsed = JSON.parse(content);
330
+ decisions = (parsed.decisions || []).slice(0, MAX_DECISIONS);
331
+ } catch {
332
+ decisions = [];
333
+ }
334
+ return { plan, decisions, error };
335
+ }
336
+
337
+ // src/data/tests.ts
338
+ import { readFileSync as nodeReadFileSync2 } from "fs";
339
+ import { execSync as nodeExecSync2 } from "child_process";
340
+ import { join as join2 } from "path";
341
+ var AGENT_DIR2 = ".agent";
342
+ var TEST_RESULTS_FILE = "test-results.json";
343
+ var readFileFn2 = (path) => nodeReadFileSync2(path, "utf-8");
344
+ var getHeadHashFn = () => {
345
+ return nodeExecSync2("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
346
+ };
347
+ var getCommitCountFn = (fromHash) => {
348
+ const result = nodeExecSync2(`git rev-list ${fromHash}..HEAD --count`, {
349
+ encoding: "utf-8"
350
+ }).trim();
351
+ return parseInt(result, 10) || 0;
352
+ };
353
+ function getTestData(dir = process.cwd()) {
354
+ const testResultsPath = join2(dir, AGENT_DIR2, TEST_RESULTS_FILE);
355
+ let results = null;
356
+ let isOutdated = false;
357
+ let commitsBehind = 0;
358
+ let error;
359
+ try {
360
+ const content = readFileFn2(testResultsPath);
361
+ results = JSON.parse(content);
362
+ } catch (e) {
363
+ if (e instanceof SyntaxError) {
364
+ error = "Invalid test-results.json";
365
+ } else {
366
+ error = "No test results";
367
+ }
368
+ return { results: null, isOutdated: false, commitsBehind: 0, error };
369
+ }
370
+ try {
371
+ const currentHash = getHeadHashFn();
372
+ if (results.hash !== currentHash) {
373
+ isOutdated = true;
374
+ commitsBehind = getCommitCountFn(results.hash);
375
+ }
376
+ } catch {
377
+ isOutdated = false;
378
+ commitsBehind = 0;
379
+ }
380
+ return { results, isOutdated, commitsBehind, error };
381
+ }
382
+
383
+ // src/ui/App.tsx
384
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
385
+ var REFRESH_INTERVAL = 5e3;
386
+ var REFRESH_SECONDS = REFRESH_INTERVAL / 1e3;
387
+ function useGitData() {
388
+ const [data, setData] = useState(() => ({
389
+ branch: getCurrentBranch(),
390
+ commits: getTodayCommits(),
391
+ stats: getTodayStats()
392
+ }));
393
+ const refresh = useCallback(() => {
394
+ setData({
395
+ branch: getCurrentBranch(),
396
+ commits: getTodayCommits(),
397
+ stats: getTodayStats()
398
+ });
399
+ }, []);
400
+ return [data, refresh];
401
+ }
402
+ function usePlanData() {
403
+ const [data, setData] = useState(() => getPlanData());
404
+ const refresh = useCallback(() => {
405
+ setData(getPlanData());
406
+ }, []);
407
+ return [data, refresh];
408
+ }
409
+ function useTestData() {
410
+ const [data, setData] = useState(() => getTestData());
411
+ const refresh = useCallback(() => {
412
+ setData(getTestData());
413
+ }, []);
414
+ return [data, refresh];
415
+ }
416
+ function App({ mode }) {
417
+ const { exit } = useApp();
418
+ const [gitData, refreshGit] = useGitData();
419
+ const [planData, refreshPlan] = usePlanData();
420
+ const [testData, refreshTest] = useTestData();
421
+ const [countdown, setCountdown] = useState(REFRESH_SECONDS);
422
+ const refreshAll = useCallback(() => {
423
+ refreshGit();
424
+ refreshPlan();
425
+ refreshTest();
426
+ setCountdown(REFRESH_SECONDS);
427
+ }, [refreshGit, refreshPlan, refreshTest]);
428
+ useEffect(() => {
429
+ if (mode !== "watch") return;
430
+ const interval = setInterval(refreshAll, REFRESH_INTERVAL);
431
+ return () => clearInterval(interval);
432
+ }, [mode, refreshAll]);
433
+ useEffect(() => {
434
+ if (mode !== "watch") return;
435
+ const tick = setInterval(() => {
436
+ setCountdown((prev) => prev > 1 ? prev - 1 : REFRESH_SECONDS);
437
+ }, 1e3);
438
+ return () => clearInterval(tick);
439
+ }, [mode]);
440
+ useInput(
441
+ (input) => {
442
+ if (mode !== "watch") return;
443
+ if (input === "q") {
444
+ exit();
445
+ }
446
+ if (input === "r") {
447
+ refreshAll();
448
+ }
449
+ },
450
+ { isActive: mode === "watch" }
451
+ );
452
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
453
+ /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsx4(
454
+ GitPanel,
455
+ {
456
+ branch: gitData.branch,
457
+ commits: gitData.commits,
458
+ stats: gitData.stats
459
+ }
460
+ ) }),
461
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(
462
+ PlanPanel,
463
+ {
464
+ plan: planData.plan,
465
+ decisions: planData.decisions,
466
+ error: planData.error
467
+ }
468
+ ) }),
469
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(
470
+ TestPanel,
471
+ {
472
+ results: testData.results,
473
+ isOutdated: testData.isOutdated,
474
+ commitsBehind: testData.commitsBehind,
475
+ error: testData.error
476
+ }
477
+ ) }),
478
+ mode === "watch" && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, width: PANEL_WIDTH, children: /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
479
+ "\u21BB ",
480
+ countdown,
481
+ "s \xB7 ",
482
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "q:" }),
483
+ " quit \xB7 ",
484
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "r:" }),
485
+ " refresh"
486
+ ] }) })
487
+ ] });
488
+ }
489
+
490
+ // src/cli.ts
491
+ var clearFn = () => console.clear();
492
+ function clearScreen() {
493
+ clearFn();
494
+ }
495
+ function parseArgs(args) {
496
+ const hasOnce = args.includes("--once");
497
+ const hasWatch = args.includes("--watch") || args.includes("-w");
498
+ if (hasOnce) {
499
+ return { mode: "once" };
500
+ }
501
+ return { mode: "watch" };
502
+ }
503
+
504
+ // src/index.ts
505
+ var options = parseArgs(process.argv.slice(2));
506
+ if (options.mode === "watch") {
507
+ clearScreen();
508
+ }
509
+ var { waitUntilExit } = render(React2.createElement(App, { mode: options.mode }));
510
+ if (options.mode === "once") {
511
+ setTimeout(() => process.exit(0), 100);
512
+ } else {
513
+ waitUntilExit().then(() => process.exit(0));
514
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "agenthud",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to monitor agent status in real-time. Works with Claude Code, multi-agent workflows, and any AI agent system.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "agenthud": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "scripts"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:save": "npx tsx scripts/save-test-results.ts",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/neochoon/agenthud.git"
27
+ },
28
+ "keywords": [
29
+ "cli",
30
+ "agent",
31
+ "dashboard",
32
+ "monitoring",
33
+ "claude",
34
+ "ai",
35
+ "terminal"
36
+ ],
37
+ "author": "",
38
+ "license": "MIT",
39
+ "bugs": {
40
+ "url": "https://github.com/neochoon/agenthud/issues"
41
+ },
42
+ "homepage": "https://github.com/neochoon/agenthud#readme",
43
+ "devDependencies": {
44
+ "@types/node": "^25.0.3",
45
+ "@types/react": "^19.2.7",
46
+ "ink-testing-library": "^4.0.0",
47
+ "tsup": "^8.5.1",
48
+ "tsx": "^4.21.0",
49
+ "typescript": "^5.9.3",
50
+ "vitest": "^4.0.16"
51
+ },
52
+ "dependencies": {
53
+ "ink": "^6.6.0",
54
+ "react": "^19.2.3"
55
+ }
56
+ }
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Wrapper script to run vitest and save results with git context.
4
+ *
5
+ * Usage:
6
+ * npx tsx scripts/save-test-results.ts
7
+ * npm run test:save
8
+ *
9
+ * Output:
10
+ * .agent/test-results.json
11
+ */
12
+
13
+ import { execSync } from "child_process";
14
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
15
+ import { join } from "path";
16
+
17
+ interface VitestResult {
18
+ numPassedTests: number;
19
+ numFailedTests: number;
20
+ numPendingTests: number;
21
+ testResults: Array<{
22
+ name: string;
23
+ assertionResults: Array<{
24
+ title: string;
25
+ status: "passed" | "failed" | "pending";
26
+ }>;
27
+ }>;
28
+ }
29
+
30
+ interface TestFailure {
31
+ file: string;
32
+ name: string;
33
+ }
34
+
35
+ interface TestResults {
36
+ hash: string;
37
+ timestamp: string;
38
+ passed: number;
39
+ failed: number;
40
+ skipped: number;
41
+ failures: TestFailure[];
42
+ }
43
+
44
+ function getGitHash(): string {
45
+ try {
46
+ return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
47
+ } catch {
48
+ return "unknown";
49
+ }
50
+ }
51
+
52
+ function runVitest(): VitestResult | null {
53
+ try {
54
+ const output = execSync("npx vitest run --reporter=json", {
55
+ encoding: "utf-8",
56
+ stdio: ["pipe", "pipe", "pipe"],
57
+ });
58
+ return JSON.parse(output) as VitestResult;
59
+ } catch (e: unknown) {
60
+ // Vitest exits with code 1 if tests fail, but still outputs JSON
61
+ const error = e as { stdout?: string };
62
+ if (error.stdout) {
63
+ try {
64
+ return JSON.parse(error.stdout) as VitestResult;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function extractFailures(vitestResult: VitestResult): TestFailure[] {
74
+ const failures: TestFailure[] = [];
75
+
76
+ for (const testFile of vitestResult.testResults) {
77
+ for (const assertion of testFile.assertionResults) {
78
+ if (assertion.status === "failed") {
79
+ failures.push({
80
+ file: testFile.name.replace(process.cwd() + "/", ""),
81
+ name: assertion.title,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ return failures;
88
+ }
89
+
90
+ function main(): void {
91
+ console.log("Running tests...\n");
92
+
93
+ const vitestResult = runVitest();
94
+
95
+ if (!vitestResult) {
96
+ console.error("Failed to run vitest or parse results");
97
+ process.exit(1);
98
+ }
99
+
100
+ const results: TestResults = {
101
+ hash: getGitHash(),
102
+ timestamp: new Date().toISOString(),
103
+ passed: vitestResult.numPassedTests,
104
+ failed: vitestResult.numFailedTests,
105
+ skipped: vitestResult.numPendingTests,
106
+ failures: extractFailures(vitestResult),
107
+ };
108
+
109
+ // Ensure .agent directory exists
110
+ const agentDir = join(process.cwd(), ".agent");
111
+ if (!existsSync(agentDir)) {
112
+ mkdirSync(agentDir, { recursive: true });
113
+ }
114
+
115
+ // Write results
116
+ const outputPath = join(agentDir, "test-results.json");
117
+ writeFileSync(outputPath, JSON.stringify(results, null, 2));
118
+
119
+ // Print summary
120
+ console.log(`\n✓ ${results.passed} passed`);
121
+ if (results.failed > 0) {
122
+ console.log(`✗ ${results.failed} failed`);
123
+ }
124
+ if (results.skipped > 0) {
125
+ console.log(`○ ${results.skipped} skipped`);
126
+ }
127
+ console.log(`\nSaved to ${outputPath}`);
128
+ console.log(`Git hash: ${results.hash}`);
129
+ }
130
+
131
+ main();