@towles/tool 0.0.120 → 0.0.122
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/node_modules/@towles/shared/package.json +15 -0
- package/node_modules/@towles/shared/src/date-utils.test.ts +97 -0
- package/node_modules/@towles/shared/src/date-utils.ts +54 -0
- package/node_modules/@towles/shared/src/fs.ts +19 -0
- package/node_modules/@towles/shared/src/git/branch-name.test.ts +83 -0
- package/node_modules/@towles/shared/src/git/branch-name.ts +10 -0
- package/node_modules/@towles/shared/src/git/exec.ts +41 -0
- package/node_modules/@towles/shared/src/git/gh-cli-wrapper.test.ts +55 -0
- package/node_modules/@towles/shared/src/git/gh-cli-wrapper.ts +74 -0
- package/node_modules/@towles/shared/src/index.ts +8 -0
- package/node_modules/@towles/shared/src/render.test.ts +71 -0
- package/node_modules/@towles/shared/src/render.ts +36 -0
- package/package.json +4 -1
- package/packages/agentboard/apps/tui/src/components/DetailPanel.tsx +62 -1
- package/packages/agentboard/packages/runtime/package.json +1 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +38 -1
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +106 -31
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.test.ts +74 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-pid.ts +57 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.test.ts +148 -0
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-usage.ts +78 -0
- package/packages/agentboard/packages/runtime/src/contracts/agent.ts +17 -0
- package/packages/agentboard/packages/runtime/src/server/pane-scanner.ts +10 -4
- package/packages/core/skills/towles-tool/SKILL.md +1 -0
- package/packages/shared/node_modules/consola/LICENSE +47 -0
- package/packages/shared/node_modules/consola/README.md +352 -0
- package/packages/shared/node_modules/consola/basic.d.ts +1 -0
- package/packages/shared/node_modules/consola/browser.d.ts +1 -0
- package/packages/shared/node_modules/consola/core.d.ts +1 -0
- package/packages/shared/node_modules/consola/dist/basic.cjs +32 -0
- package/packages/shared/node_modules/consola/dist/basic.d.cts +23 -0
- package/packages/shared/node_modules/consola/dist/basic.d.mts +21 -0
- package/packages/shared/node_modules/consola/dist/basic.d.ts +23 -0
- package/packages/shared/node_modules/consola/dist/basic.mjs +24 -0
- package/packages/shared/node_modules/consola/dist/browser.cjs +84 -0
- package/packages/shared/node_modules/consola/dist/browser.d.cts +23 -0
- package/packages/shared/node_modules/consola/dist/browser.d.mts +21 -0
- package/packages/shared/node_modules/consola/dist/browser.d.ts +23 -0
- package/packages/shared/node_modules/consola/dist/browser.mjs +76 -0
- package/packages/shared/node_modules/consola/dist/chunks/prompt.cjs +288 -0
- package/packages/shared/node_modules/consola/dist/chunks/prompt.mjs +280 -0
- package/packages/shared/node_modules/consola/dist/core.cjs +517 -0
- package/packages/shared/node_modules/consola/dist/core.d.cts +459 -0
- package/packages/shared/node_modules/consola/dist/core.d.mts +459 -0
- package/packages/shared/node_modules/consola/dist/core.d.ts +459 -0
- package/packages/shared/node_modules/consola/dist/core.mjs +512 -0
- package/packages/shared/node_modules/consola/dist/index.cjs +663 -0
- package/packages/shared/node_modules/consola/dist/index.d.cts +24 -0
- package/packages/shared/node_modules/consola/dist/index.d.mts +22 -0
- package/packages/shared/node_modules/consola/dist/index.d.ts +24 -0
- package/packages/shared/node_modules/consola/dist/index.mjs +651 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DCGIlDNP.cjs +75 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DRwqZj3T.mjs +72 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DXBYu-KD.mjs +288 -0
- package/packages/shared/node_modules/consola/dist/shared/consola.DwRq1yyg.cjs +312 -0
- package/packages/shared/node_modules/consola/dist/utils.cjs +64 -0
- package/packages/shared/node_modules/consola/dist/utils.d.cts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.d.mts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.d.ts +286 -0
- package/packages/shared/node_modules/consola/dist/utils.mjs +54 -0
- package/packages/shared/node_modules/consola/lib/index.cjs +10 -0
- package/packages/shared/node_modules/consola/package.json +136 -0
- package/packages/shared/node_modules/consola/utils.d.ts +1 -0
- package/packages/shared/tsconfig.json +0 -16
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@towles/shared",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"consola": "^3.4.2"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bun": "latest",
|
|
13
|
+
"typescript": "^5.8.3"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatDate, generateJournalFilename, getMondayOfWeek, getWeekInfo } from "./date-utils";
|
|
3
|
+
|
|
4
|
+
describe("date utilities", () => {
|
|
5
|
+
it("should get Monday of the week correctly", () => {
|
|
6
|
+
// Test with a Wednesday (July 9, 2025)
|
|
7
|
+
const wednesday = new Date(2025, 6, 9); // July 9, 2025
|
|
8
|
+
const monday = getMondayOfWeek(wednesday);
|
|
9
|
+
expect(formatDate(monday)).toBe("2025-07-07");
|
|
10
|
+
|
|
11
|
+
// Test with a Friday (July 11, 2025)
|
|
12
|
+
const friday = new Date(2025, 6, 11); // July 11, 2025
|
|
13
|
+
const mondayFromFriday = getMondayOfWeek(friday);
|
|
14
|
+
expect(formatDate(mondayFromFriday)).toBe("2025-07-07");
|
|
15
|
+
|
|
16
|
+
// Test with a Sunday (July 13, 2025) - should return Monday of previous week
|
|
17
|
+
const sunday = new Date(2025, 6, 13); // July 13, 2025
|
|
18
|
+
const mondayFromSunday = getMondayOfWeek(sunday);
|
|
19
|
+
expect(formatDate(mondayFromSunday)).toBe("2025-07-07");
|
|
20
|
+
|
|
21
|
+
// Test with a Monday (July 7, 2025)
|
|
22
|
+
const actualMonday = new Date(2025, 6, 7); // July 7, 2025
|
|
23
|
+
const mondayFromMonday = getMondayOfWeek(actualMonday);
|
|
24
|
+
expect(formatDate(mondayFromMonday)).toBe("2025-07-07");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should generate correct journal filename", () => {
|
|
28
|
+
// Test with different days in the same week
|
|
29
|
+
const wednesday = new Date(2025, 6, 9); // July 9, 2025
|
|
30
|
+
const filename = generateJournalFilename(wednesday);
|
|
31
|
+
expect(filename).toBe("2025-07-07-week.md");
|
|
32
|
+
|
|
33
|
+
const friday = new Date(2025, 6, 11); // July 11, 2025
|
|
34
|
+
const filenameFromFriday = generateJournalFilename(friday);
|
|
35
|
+
expect(filenameFromFriday).toBe("2025-07-07-week.md");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should format date correctly", () => {
|
|
39
|
+
// Use local date constructor (year, month-1, day), not ISO string which is UTC
|
|
40
|
+
const date = new Date(2025, 6, 7); // July 7, 2025 in local time
|
|
41
|
+
expect(formatDate(date)).toBe("2025-07-07");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should get week info correctly", () => {
|
|
45
|
+
// Test with Monday July 7, 2025
|
|
46
|
+
const monday = new Date(2025, 6, 7);
|
|
47
|
+
const weekInfo = getWeekInfo(monday);
|
|
48
|
+
|
|
49
|
+
expect(formatDate(weekInfo.mondayDate)).toBe("2025-07-07");
|
|
50
|
+
expect(formatDate(weekInfo.tuesdayDate)).toBe("2025-07-08");
|
|
51
|
+
expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-07-09");
|
|
52
|
+
expect(formatDate(weekInfo.thursdayDate)).toBe("2025-07-10");
|
|
53
|
+
expect(formatDate(weekInfo.fridayDate)).toBe("2025-07-11");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle edge cases correctly", () => {
|
|
57
|
+
// Test with year boundary - Monday December 30, 2024
|
|
58
|
+
const mondayEndOfYear = new Date(2024, 11, 30);
|
|
59
|
+
const weekInfo = getWeekInfo(mondayEndOfYear);
|
|
60
|
+
|
|
61
|
+
expect(formatDate(weekInfo.mondayDate)).toBe("2024-12-30");
|
|
62
|
+
expect(formatDate(weekInfo.tuesdayDate)).toBe("2024-12-31");
|
|
63
|
+
expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-01-01");
|
|
64
|
+
expect(formatDate(weekInfo.thursdayDate)).toBe("2025-01-02");
|
|
65
|
+
expect(formatDate(weekInfo.fridayDate)).toBe("2025-01-03");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should handle month boundary correctly", () => {
|
|
69
|
+
// Test with month boundary - Monday January 29, 2025
|
|
70
|
+
const mondayEndOfMonth = new Date(2025, 0, 27);
|
|
71
|
+
const weekInfo = getWeekInfo(mondayEndOfMonth);
|
|
72
|
+
|
|
73
|
+
expect(formatDate(weekInfo.mondayDate)).toBe("2025-01-27");
|
|
74
|
+
expect(formatDate(weekInfo.tuesdayDate)).toBe("2025-01-28");
|
|
75
|
+
expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-01-29");
|
|
76
|
+
expect(formatDate(weekInfo.thursdayDate)).toBe("2025-01-30");
|
|
77
|
+
expect(formatDate(weekInfo.fridayDate)).toBe("2025-01-31");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should handle getMondayOfWeek with different timezones", () => {
|
|
81
|
+
// Test with a specific time to ensure hours are reset
|
|
82
|
+
const dateWithTime = new Date(2025, 6, 9, 15, 30, 45); // July 9, 2025 at 3:30:45 PM
|
|
83
|
+
const monday = getMondayOfWeek(dateWithTime);
|
|
84
|
+
|
|
85
|
+
expect(formatDate(monday)).toBe("2025-07-07");
|
|
86
|
+
expect(monday.getHours()).toBe(0);
|
|
87
|
+
expect(monday.getMinutes()).toBe(0);
|
|
88
|
+
expect(monday.getSeconds()).toBe(0);
|
|
89
|
+
expect(monday.getMilliseconds()).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should handle formatDate with different times", () => {
|
|
93
|
+
// Test that formatDate only considers the date part
|
|
94
|
+
const dateWithTime = new Date(2025, 6, 7, 10, 30, 45); // July 7, 2025 at 10:30:45 AM
|
|
95
|
+
expect(formatDate(dateWithTime)).toBe("2025-07-07");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the Monday of the week for a given date
|
|
3
|
+
*/
|
|
4
|
+
export function getMondayOfWeek(date: Date): Date {
|
|
5
|
+
const newDate = new Date(date);
|
|
6
|
+
const day = newDate.getDay();
|
|
7
|
+
const diff = newDate.getDate() - day + (day === 0 ? -6 : 1);
|
|
8
|
+
newDate.setDate(diff);
|
|
9
|
+
newDate.setHours(0, 0, 0, 0);
|
|
10
|
+
return newDate;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface weekInfo {
|
|
14
|
+
mondayDate: Date;
|
|
15
|
+
tuesdayDate: Date;
|
|
16
|
+
wednesdayDate: Date;
|
|
17
|
+
thursdayDate: Date;
|
|
18
|
+
fridayDate: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getWeekInfo(mondayDate: Date): weekInfo {
|
|
22
|
+
const tuesdayDate = new Date(mondayDate);
|
|
23
|
+
tuesdayDate.setDate(mondayDate.getDate() + 1);
|
|
24
|
+
const wednesdayDate = new Date(mondayDate);
|
|
25
|
+
wednesdayDate.setDate(mondayDate.getDate() + 2);
|
|
26
|
+
const thursdayDate = new Date(mondayDate);
|
|
27
|
+
thursdayDate.setDate(mondayDate.getDate() + 3);
|
|
28
|
+
const fridayDate = new Date(mondayDate);
|
|
29
|
+
fridayDate.setDate(mondayDate.getDate() + 4);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
mondayDate,
|
|
33
|
+
tuesdayDate,
|
|
34
|
+
wednesdayDate,
|
|
35
|
+
thursdayDate,
|
|
36
|
+
fridayDate,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Format date as YYYY-MM-DD in local timezone
|
|
42
|
+
*/
|
|
43
|
+
export function formatDate(date: Date): string {
|
|
44
|
+
return date.toLocaleDateString("en-CA");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate journal filename based on Monday of the current week
|
|
49
|
+
* Format: YYYY-MM-DD-week.md (always uses Monday's date)
|
|
50
|
+
*/
|
|
51
|
+
export function generateJournalFilename(date: Date = new Date()): string {
|
|
52
|
+
const monday = getMondayOfWeek(new Date(date));
|
|
53
|
+
return `${formatDate(monday)}-week.md`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function ensureDir(dir: string): void {
|
|
5
|
+
mkdirSync(dir, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function fileExists(path: string): boolean {
|
|
9
|
+
return existsSync(path);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readFile(path: string): string {
|
|
13
|
+
return readFileSync(path, "utf-8");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeFile(path: string, content: string): void {
|
|
17
|
+
ensureDir(dirname(path));
|
|
18
|
+
writeFileSync(path, content, "utf-8");
|
|
19
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createBranchNameFromIssue } from "./branch-name";
|
|
3
|
+
|
|
4
|
+
describe("createBranchNameFromIssue", () => {
|
|
5
|
+
it("creates branch name from issue with basic title", () => {
|
|
6
|
+
const branchName = createBranchNameFromIssue({
|
|
7
|
+
number: 4,
|
|
8
|
+
title: "Long Issue Title - with a lot of words and stuff ",
|
|
9
|
+
});
|
|
10
|
+
expect(branchName).toBe("feature/4-long-issue-title-with-a-lot-of-words-and-stuff");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("handles special characters in title", () => {
|
|
14
|
+
const branchName = createBranchNameFromIssue({
|
|
15
|
+
number: 123,
|
|
16
|
+
title: "Fix bug: @user reported $100 issue!",
|
|
17
|
+
});
|
|
18
|
+
expect(branchName).toBe("feature/123-fix-bug-user-reported-100-issue");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles title with only numbers", () => {
|
|
22
|
+
const branchName = createBranchNameFromIssue({ number: 42, title: "123 456" });
|
|
23
|
+
expect(branchName).toBe("feature/42-123-456");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("trims trailing dashes", () => {
|
|
27
|
+
const branchName = createBranchNameFromIssue({ number: 7, title: "Update docs ---" });
|
|
28
|
+
expect(branchName).toBe("feature/7-update-docs");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles unicode characters", () => {
|
|
32
|
+
const branchName = createBranchNameFromIssue({ number: 99, title: "Fix für Übersetzung" });
|
|
33
|
+
expect(branchName).toBe("feature/99-fix-f-r-bersetzung");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles empty-ish title", () => {
|
|
37
|
+
const branchName = createBranchNameFromIssue({ number: 1, title: " " });
|
|
38
|
+
expect(branchName).toBe("feature/1-");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles title with underscores", () => {
|
|
42
|
+
const branchName = createBranchNameFromIssue({ number: 50, title: "snake_case_title" });
|
|
43
|
+
expect(branchName).toBe("feature/50-snake_case_title");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles very long titles", () => {
|
|
47
|
+
const branchName = createBranchNameFromIssue({
|
|
48
|
+
number: 200,
|
|
49
|
+
title: "This is a very long issue title that goes on and on with many words",
|
|
50
|
+
});
|
|
51
|
+
expect(branchName).toBe(
|
|
52
|
+
"feature/200-this-is-a-very-long-issue-title-that-goes-on-and-on-with-many-words",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("collapses multiple consecutive dashes", () => {
|
|
57
|
+
const branchName = createBranchNameFromIssue({
|
|
58
|
+
number: 15,
|
|
59
|
+
title: "Fix multiple spaces",
|
|
60
|
+
});
|
|
61
|
+
expect(branchName).toBe("feature/15-fix-multiple-spaces");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles title with brackets and parentheses", () => {
|
|
65
|
+
const branchName = createBranchNameFromIssue({
|
|
66
|
+
number: 33,
|
|
67
|
+
title: "[Bug] Fix (critical) issue",
|
|
68
|
+
});
|
|
69
|
+
expect(branchName).toBe("feature/33--bug-fix-critical-issue");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("produces same result whether called with minimal or extra fields", () => {
|
|
73
|
+
const minimal = createBranchNameFromIssue({ number: 90, title: "Add e2e tests" });
|
|
74
|
+
const withExtras = createBranchNameFromIssue({
|
|
75
|
+
number: 90,
|
|
76
|
+
title: "Add e2e tests",
|
|
77
|
+
state: "open",
|
|
78
|
+
labels: [],
|
|
79
|
+
} as { number: number; title: string });
|
|
80
|
+
expect(minimal).toBe(withExtras);
|
|
81
|
+
expect(minimal).toBe("feature/90-add-e2e-tests");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function createBranchNameFromIssue(issue: { number: number; title: string }): string {
|
|
2
|
+
let slug = issue.title.toLowerCase();
|
|
3
|
+
slug = slug.trim();
|
|
4
|
+
slug = slug.replaceAll(" ", "-");
|
|
5
|
+
slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
|
|
6
|
+
slug = slug.replace(/-+/g, "-");
|
|
7
|
+
slug = slug.replace(/-+$/, "");
|
|
8
|
+
|
|
9
|
+
return `feature/${issue.number}-${slug}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface XResult {
|
|
2
|
+
stdout: string;
|
|
3
|
+
stderr: string;
|
|
4
|
+
exitCode: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface XOptions {
|
|
8
|
+
throwOnError?: boolean;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function run(cmd: string, args: string[] = [], options?: XOptions): Promise<XResult> {
|
|
13
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
14
|
+
const proc = Bun.spawn([cmd, ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
15
|
+
const exitCode = await proc.exited;
|
|
16
|
+
const [stdout, stderr] = await Promise.all([
|
|
17
|
+
new Response(proc.stdout).text(),
|
|
18
|
+
new Response(proc.stderr).text(),
|
|
19
|
+
]);
|
|
20
|
+
if (options?.throwOnError && exitCode !== 0) {
|
|
21
|
+
throw new Error(`Command failed (exit ${exitCode}): ${cmd} ${args.join(" ")}\n${stderr}`);
|
|
22
|
+
}
|
|
23
|
+
return { stdout, stderr, exitCode };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function exec(cmd: string, args: string[]): Promise<string> {
|
|
27
|
+
const result = await run(cmd, args, { throwOnError: true });
|
|
28
|
+
return result.stdout.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function execSafe(
|
|
32
|
+
cmd: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
): Promise<{ stdout: string; ok: boolean }> {
|
|
35
|
+
const result = await run(cmd, args);
|
|
36
|
+
return { stdout: result.stdout.trim(), ok: result.exitCode === 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function git(args: string[]): Promise<string> {
|
|
40
|
+
return exec("git", args);
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { Mock } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { XFn } from "./gh-cli-wrapper";
|
|
5
|
+
import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
|
|
6
|
+
|
|
7
|
+
const mockX = vi.fn().mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 }) as Mock & XFn;
|
|
8
|
+
|
|
9
|
+
describe("gh-cli-wrapper", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
mockX.mockResolvedValue({ stdout: "[]", stderr: "", exitCode: 0 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("getIssues", () => {
|
|
16
|
+
it("passes --label flag when label provided", async () => {
|
|
17
|
+
await getIssues({ cwd: ".", label: "auto-claude", exec: mockX });
|
|
18
|
+
|
|
19
|
+
expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--label", "auto-claude"]));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not pass --label flag when label not provided", async () => {
|
|
23
|
+
await getIssues({ cwd: ".", exec: mockX });
|
|
24
|
+
|
|
25
|
+
const args = mockX.mock.calls[0]![1] as string[];
|
|
26
|
+
expect(args).not.toContain("--label");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("passes --assignee @me flag when assignedToMe is true", async () => {
|
|
30
|
+
await getIssues({ cwd: ".", assignedToMe: true, exec: mockX });
|
|
31
|
+
|
|
32
|
+
expect(mockX).toHaveBeenCalledWith("gh", expect.arrayContaining(["--assignee", "@me"]));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("isGithubCliInstalled", () => {
|
|
37
|
+
it("returns true when gh CLI outputs expected string", async () => {
|
|
38
|
+
mockX.mockResolvedValue({
|
|
39
|
+
stdout: "gh version 2.0.0 (https://github.com/cli/cli)",
|
|
40
|
+
stderr: "",
|
|
41
|
+
exitCode: 0,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const result = await isGithubCliInstalled(mockX);
|
|
45
|
+
expect(result).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns false when gh CLI is not available", async () => {
|
|
49
|
+
mockX.mockRejectedValue(new Error("command not found"));
|
|
50
|
+
|
|
51
|
+
const result = await isGithubCliInstalled(mockX);
|
|
52
|
+
expect(result).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { exec, execSafe, run as defaultX } from "./exec.js";
|
|
2
|
+
import type { XResult } from "./exec.js";
|
|
3
|
+
|
|
4
|
+
export type XFn = (
|
|
5
|
+
cmd: string,
|
|
6
|
+
args?: string[],
|
|
7
|
+
opts?: Record<string, unknown>,
|
|
8
|
+
) => PromiseLike<XResult>;
|
|
9
|
+
|
|
10
|
+
export async function isGithubCliInstalled(execFn: XFn = defaultX): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
const proc = await execFn("gh", ["--version"]);
|
|
13
|
+
return proc.stdout.includes("https://github.com/cli/cli");
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function gh<T = unknown>(args: string[]): Promise<T> {
|
|
20
|
+
const stdout = await exec("gh", args);
|
|
21
|
+
return JSON.parse(stdout) as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function ghRaw(
|
|
25
|
+
args: string[],
|
|
26
|
+
execFn?: (cmd: string, args: string[]) => Promise<{ stdout: string; ok: boolean }>,
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const fn = execFn ?? execSafe;
|
|
29
|
+
const result = await fn("gh", args);
|
|
30
|
+
return result.stdout;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Issue {
|
|
34
|
+
labels: {
|
|
35
|
+
name: string;
|
|
36
|
+
color: string;
|
|
37
|
+
}[];
|
|
38
|
+
number: number;
|
|
39
|
+
title: string;
|
|
40
|
+
state: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function getIssues({
|
|
44
|
+
assignedToMe,
|
|
45
|
+
cwd,
|
|
46
|
+
label,
|
|
47
|
+
exec: execFn = defaultX,
|
|
48
|
+
}: {
|
|
49
|
+
assignedToMe?: boolean;
|
|
50
|
+
cwd: string;
|
|
51
|
+
exec?: XFn;
|
|
52
|
+
label?: string;
|
|
53
|
+
}): Promise<Issue[]> {
|
|
54
|
+
const args = ["issue", "list", "--json", "labels,number,title,state"];
|
|
55
|
+
|
|
56
|
+
if (assignedToMe) {
|
|
57
|
+
args.push("--assignee", "@me");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (label) {
|
|
61
|
+
args.push("--label", label);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await execFn("gh", args);
|
|
65
|
+
const stripped = Bun.stripANSI(result.stdout);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(stripped) as Issue[];
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Failed to parse GitHub CLI output as JSON. Raw output: ${stripped.slice(0, 200)}${stripped.length > 200 ? "..." : ""}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ensureDir, fileExists, readFile, writeFile } from "./fs.js";
|
|
2
|
+
export { getTerminalColumns, limitText, printWithHexColor } from "./render.js";
|
|
3
|
+
export { formatDate, generateJournalFilename, getMondayOfWeek, getWeekInfo } from "./date-utils.js";
|
|
4
|
+
export { exec, execSafe, git, run } from "./git/exec.js";
|
|
5
|
+
export type { XResult, XOptions } from "./git/exec.js";
|
|
6
|
+
export { gh, ghRaw, getIssues, isGithubCliInstalled } from "./git/gh-cli-wrapper.js";
|
|
7
|
+
export type { Issue, XFn } from "./git/gh-cli-wrapper.js";
|
|
8
|
+
export { createBranchNameFromIssue } from "./git/branch-name.js";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { printWithHexColor } from "./render";
|
|
3
|
+
|
|
4
|
+
describe("printWithHexColor", () => {
|
|
5
|
+
it("should handle hex colors with # prefix", () => {
|
|
6
|
+
const result = printWithHexColor({ msg: "test", hex: "#ff0000" });
|
|
7
|
+
expect(result).toBe("\x1B[38;2;255;0;0mtest\x1B[0m");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("should handle hex colors without # prefix", () => {
|
|
11
|
+
const result = printWithHexColor({ msg: "test", hex: "ff0000" });
|
|
12
|
+
expect(result).toBe("\x1B[38;2;255;0;0mtest\x1B[0m");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should handle green color correctly", () => {
|
|
16
|
+
const result = printWithHexColor({ msg: "green", hex: "#00ff00" });
|
|
17
|
+
expect(result).toBe("\x1B[38;2;0;255;0mgreen\x1B[0m");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should handle blue color correctly", () => {
|
|
21
|
+
const result = printWithHexColor({ msg: "blue", hex: "0000ff" });
|
|
22
|
+
expect(result).toBe("\x1B[38;2;0;0;255mblue\x1B[0m");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should handle mixed RGB values", () => {
|
|
26
|
+
const result = printWithHexColor({ msg: "purple", hex: "#800080" });
|
|
27
|
+
expect(result).toBe("\x1B[38;2;128;0;128mpurple\x1B[0m");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should handle GitHub-style label colors", () => {
|
|
31
|
+
// GitHub red label color
|
|
32
|
+
const result = printWithHexColor({ msg: "bug", hex: "d73a49" });
|
|
33
|
+
expect(result).toBe("\x1B[38;2;215;58;73mbug\x1B[0m");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should handle lowercase hex values", () => {
|
|
37
|
+
const result = printWithHexColor({ msg: "test", hex: "abc123" });
|
|
38
|
+
expect(result).toBe("\x1B[38;2;171;193;35mtest\x1B[0m");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should handle uppercase hex values", () => {
|
|
42
|
+
const result = printWithHexColor({ msg: "test", hex: "ABC123" });
|
|
43
|
+
expect(result).toBe("\x1B[38;2;171;193;35mtest\x1B[0m");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should handle empty message", () => {
|
|
47
|
+
const result = printWithHexColor({ msg: "", hex: "#ff0000" });
|
|
48
|
+
expect(result).toBe("\x1B[38;2;255;0;0m\x1B[0m");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should handle message with spaces", () => {
|
|
52
|
+
const result = printWithHexColor({ msg: "hello world", hex: "#ffffff" });
|
|
53
|
+
expect(result).toBe("\x1B[38;2;255;255;255mhello world\x1B[0m");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should handle black color (000000)", () => {
|
|
57
|
+
const result = printWithHexColor({ msg: "black", hex: "000000" });
|
|
58
|
+
expect(result).toBe("\x1B[38;2;0;0;0mblack\x1B[0m");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should handle white color (ffffff)", () => {
|
|
62
|
+
const result = printWithHexColor({ msg: "white", hex: "ffffff" });
|
|
63
|
+
expect(result).toBe("\x1B[38;2;255;255;255mwhite\x1B[0m");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should preserve message content exactly", () => {
|
|
67
|
+
const specialMessage = "special-chars_123!@#";
|
|
68
|
+
const result = printWithHexColor({ msg: specialMessage, hex: "#123456" });
|
|
69
|
+
expect(result).toBe(`\x1B[38;2;18;52;86m${specialMessage}\x1B[0m`);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
|
+
|
|
3
|
+
export function getTerminalColumns(): number {
|
|
4
|
+
return process.stdout?.columns || 80;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const limitText = (text: string, maxWidth: number): string => {
|
|
8
|
+
if (text.length <= maxWidth) return text;
|
|
9
|
+
// subtract 1 so room for the ellipsis
|
|
10
|
+
return `${text.slice(0, maxWidth - 1)}${colors.dim("…")}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert hex color to RGB values
|
|
15
|
+
*/
|
|
16
|
+
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
|
17
|
+
const cleanHex = hex.replace("#", "");
|
|
18
|
+
const r = Number.parseInt(cleanHex.slice(0, 2), 16);
|
|
19
|
+
const g = Number.parseInt(cleanHex.slice(2, 4), 16);
|
|
20
|
+
const b = Number.parseInt(cleanHex.slice(4, 6), 16);
|
|
21
|
+
return { r, g, b };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apply hex color to text using ANSI 24-bit color codes
|
|
26
|
+
*/
|
|
27
|
+
export function printWithHexColor({ msg, hex }: { msg: string; hex: string }): string {
|
|
28
|
+
const colorWithHex = hex.startsWith("#") ? hex : `#${hex}`;
|
|
29
|
+
const { r, g, b } = hexToRgb(colorWithHex);
|
|
30
|
+
|
|
31
|
+
// Use ANSI 24-bit color: \x1B[38;2;r;g;bm for foreground color
|
|
32
|
+
const colorStart = `\x1B[38;2;${r};${g};${b}m`;
|
|
33
|
+
const colorEnd = "\x1B[0m"; // Reset color
|
|
34
|
+
|
|
35
|
+
return `${colorStart}${msg}${colorEnd}`;
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@towles/tool",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.122",
|
|
4
4
|
"description": "One off quality of life scripts that I use on a daily basis.",
|
|
5
5
|
"homepage": "https://github.com/ChrisTowles/towles-tool#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -77,6 +77,9 @@
|
|
|
77
77
|
"typescript": "^5.8.3",
|
|
78
78
|
"vitest": "^4.0.17"
|
|
79
79
|
},
|
|
80
|
+
"bundledDependencies": [
|
|
81
|
+
"@towles/shared"
|
|
82
|
+
],
|
|
80
83
|
"simple-git-hooks": {
|
|
81
84
|
"pre-commit": "bun run format:check && bun run lint && bun run typecheck && claude plugin validate ."
|
|
82
85
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSignal, For, Show } from "solid-js";
|
|
1
|
+
import { createSignal, For, Show, onCleanup } from "solid-js";
|
|
2
2
|
import type { Accessor } from "solid-js";
|
|
3
3
|
import type { MouseEvent } from "@opentui/core";
|
|
4
4
|
import type { SessionData, Theme } from "@tt-agentboard/runtime";
|
|
@@ -44,6 +44,27 @@ export function buildSparkline(
|
|
|
44
44
|
.join("");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// --- Model / cache display helpers ---
|
|
48
|
+
|
|
49
|
+
function shortModel(model: string): string {
|
|
50
|
+
if (!model) return "";
|
|
51
|
+
const stripped = model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
|
|
52
|
+
return stripped;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CACHE_BAR_WIDTH = 10;
|
|
56
|
+
const CACHE_BAR_FILLED = "▰";
|
|
57
|
+
const CACHE_BAR_EMPTY = "▱";
|
|
58
|
+
|
|
59
|
+
/** Render a drain-down bar: full = freshly cached, empty = expired. */
|
|
60
|
+
function cacheBar(expiresAt: number, ttlMs: number, now: number): string {
|
|
61
|
+
const remaining = expiresAt - now;
|
|
62
|
+
if (remaining <= 0 || ttlMs <= 0) return CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH);
|
|
63
|
+
const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
|
|
64
|
+
const filled = Math.round(fraction * CACHE_BAR_WIDTH);
|
|
65
|
+
return CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
// --- Detail Panel ---
|
|
48
69
|
|
|
49
70
|
export interface DetailPanelProps {
|
|
@@ -242,6 +263,11 @@ function AgentListItem(props: AgentListItemProps) {
|
|
|
242
263
|
const SC = () => props.statusColors();
|
|
243
264
|
const [isDismissHover, setIsDismissHover] = createSignal(false);
|
|
244
265
|
const [isFlash, setIsFlash] = createSignal(false);
|
|
266
|
+
const [now, setNow] = createSignal(Date.now());
|
|
267
|
+
// Tick every second while any details.cacheExpiresAt is in the future
|
|
268
|
+
// (cheap; only runs while component is mounted)
|
|
269
|
+
const ticker = setInterval(() => setNow(Date.now()), 1000);
|
|
270
|
+
onCleanup(() => clearInterval(ticker));
|
|
245
271
|
|
|
246
272
|
const isTerminal = () => ["done", "error", "interrupted"].includes(props.agent.status);
|
|
247
273
|
const isUnseen = () => isTerminal() && props.agent.unseen === true;
|
|
@@ -346,6 +372,41 @@ function AgentListItem(props: AgentListItemProps) {
|
|
|
346
372
|
</span>
|
|
347
373
|
</text>
|
|
348
374
|
</Show>
|
|
375
|
+
|
|
376
|
+
{/* Row 3: model + cache-remaining progress bar */}
|
|
377
|
+
<Show when={props.agent.details}>
|
|
378
|
+
{(d) => {
|
|
379
|
+
const details = d();
|
|
380
|
+
const model = () => (details.model ? shortModel(details.model) : "");
|
|
381
|
+
const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
|
|
382
|
+
const bar = () =>
|
|
383
|
+
hasCache() ? cacheBar(details.cacheExpiresAt!, details.cacheTtlMs!, now()) : "";
|
|
384
|
+
const barColor = () => {
|
|
385
|
+
if (!hasCache()) return P().overlay0;
|
|
386
|
+
const remaining = details.cacheExpiresAt! - now();
|
|
387
|
+
if (remaining <= 0) return P().overlay0;
|
|
388
|
+
const fraction = remaining / details.cacheTtlMs!;
|
|
389
|
+
if (fraction > 0.5) return P().green;
|
|
390
|
+
if (fraction > 0.2) return P().yellow;
|
|
391
|
+
return P().peach;
|
|
392
|
+
};
|
|
393
|
+
return (
|
|
394
|
+
<Show when={model() || hasCache()}>
|
|
395
|
+
<text truncate>
|
|
396
|
+
<Show when={model()}>
|
|
397
|
+
<span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
|
|
398
|
+
</Show>
|
|
399
|
+
<Show when={hasCache()}>
|
|
400
|
+
<span style={{ fg: P().overlay0, attributes: DIM }}>
|
|
401
|
+
{model() ? " · cache " : "cache "}
|
|
402
|
+
</span>
|
|
403
|
+
<span style={{ fg: barColor() }}>{bar()}</span>
|
|
404
|
+
</Show>
|
|
405
|
+
</text>
|
|
406
|
+
</Show>
|
|
407
|
+
);
|
|
408
|
+
}}
|
|
409
|
+
</Show>
|
|
349
410
|
</box>
|
|
350
411
|
</box>
|
|
351
412
|
</box>
|