@towles/tool 0.0.20 → 0.0.48
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/{LICENSE.md → LICENSE} +1 -1
- package/README.md +86 -85
- package/bin/run.ts +5 -0
- package/package.json +84 -64
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +27 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +44 -0
- package/src/commands/doctor.ts +136 -0
- package/src/commands/gh/branch-clean.ts +116 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +135 -0
- package/src/commands/gh/pr.ts +175 -0
- package/src/commands/graph-template.html +1214 -0
- package/src/commands/graph.test.ts +176 -0
- package/src/commands/graph.ts +970 -0
- package/src/commands/install.ts +154 -0
- package/src/commands/journal/daily-notes.ts +70 -0
- package/src/commands/journal/meeting.ts +89 -0
- package/src/commands/journal/note.ts +89 -0
- package/src/commands/ralph/plan/add.ts +75 -0
- package/src/commands/ralph/plan/done.ts +82 -0
- package/src/commands/ralph/plan/list.test.ts +48 -0
- package/src/commands/ralph/plan/list.ts +99 -0
- package/src/commands/ralph/plan/remove.ts +71 -0
- package/src/commands/ralph/run.test.ts +521 -0
- package/src/commands/ralph/run.ts +345 -0
- package/src/commands/ralph/show.ts +88 -0
- package/src/config/settings.ts +136 -0
- package/src/lib/journal/utils.ts +399 -0
- package/src/lib/ralph/execution.ts +292 -0
- package/src/lib/ralph/formatter.ts +238 -0
- package/src/lib/ralph/index.ts +4 -0
- package/src/lib/ralph/state.ts +166 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/date-utils.test.ts +97 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
- package/src/utils/git/gh-cli-wrapper.ts +54 -0
- package/src/utils/git/git-wrapper.test.ts +26 -0
- package/src/utils/git/git-wrapper.ts +15 -0
- package/src/utils/render.test.ts +71 -0
- package/src/utils/render.ts +34 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.mjs +0 -805
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import type { RalphPlan, PlanStatus, RalphState } from "./state.js";
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Clipboard Utility
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
export function copyToClipboard(text: string): boolean {
|
|
9
|
+
try {
|
|
10
|
+
const platform = process.platform;
|
|
11
|
+
if (platform === "darwin") {
|
|
12
|
+
execFileSync("pbcopy", [], { input: text });
|
|
13
|
+
} else if (platform === "linux") {
|
|
14
|
+
// Try xclip first, then xsel
|
|
15
|
+
try {
|
|
16
|
+
execFileSync("xclip", ["-selection", "clipboard"], { input: text });
|
|
17
|
+
} catch {
|
|
18
|
+
execFileSync("xsel", ["--clipboard", "--input"], { input: text });
|
|
19
|
+
}
|
|
20
|
+
} else if (platform === "win32") {
|
|
21
|
+
execFileSync("clip", [], { input: text });
|
|
22
|
+
} else {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format plans as markdown with checkboxes and status badges.
|
|
33
|
+
*/
|
|
34
|
+
export function formatPlansAsMarkdown(plans: RalphPlan[]): string {
|
|
35
|
+
if (plans.length === 0) {
|
|
36
|
+
return "# Plans\n\nNo plans.\n";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const statusBadge = (status: PlanStatus): string => {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case "done":
|
|
42
|
+
return "`✓ done`";
|
|
43
|
+
case "ready":
|
|
44
|
+
return "`○ ready`";
|
|
45
|
+
case "blocked":
|
|
46
|
+
return "`⏸ blocked`";
|
|
47
|
+
case "cancelled":
|
|
48
|
+
return "`✗ cancelled`";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ready = plans.filter((p) => p.status === "ready");
|
|
53
|
+
const done = plans.filter((p) => p.status === "done");
|
|
54
|
+
|
|
55
|
+
const lines: string[] = ["# Plans", ""];
|
|
56
|
+
lines.push(
|
|
57
|
+
`**Total:** ${plans.length} | **Done:** ${done.length} | **Ready:** ${ready.length}`,
|
|
58
|
+
"",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (ready.length > 0) {
|
|
62
|
+
lines.push("## Ready", "");
|
|
63
|
+
for (const p of ready) {
|
|
64
|
+
lines.push(`- [ ] **#${p.id}** ${p.description} ${statusBadge(p.status)}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (done.length > 0) {
|
|
70
|
+
lines.push("## Done", "");
|
|
71
|
+
for (const p of done) {
|
|
72
|
+
lines.push(`- [x] **#${p.id}** ${p.description} ${statusBadge(p.status)}`);
|
|
73
|
+
}
|
|
74
|
+
lines.push("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Format plans with markdown and optional mermaid graph.
|
|
82
|
+
*/
|
|
83
|
+
export function formatPlanAsMarkdown(plans: RalphPlan[], state: RalphState): string {
|
|
84
|
+
const lines: string[] = ["# Ralph Plan", ""];
|
|
85
|
+
|
|
86
|
+
// Summary section
|
|
87
|
+
const ready = plans.filter((p) => p.status === "ready").length;
|
|
88
|
+
const done = plans.filter((p) => p.status === "done").length;
|
|
89
|
+
|
|
90
|
+
lines.push("## Summary", "");
|
|
91
|
+
lines.push(`- **Status:** ${state.status}`);
|
|
92
|
+
lines.push(`- **Total:** ${plans.length}`);
|
|
93
|
+
lines.push(`- **Done:** ${done} | **Ready:** ${ready}`);
|
|
94
|
+
lines.push("");
|
|
95
|
+
|
|
96
|
+
// Plans section with checkboxes
|
|
97
|
+
lines.push("## Plans", "");
|
|
98
|
+
for (const p of plans) {
|
|
99
|
+
const checkbox = p.status === "done" ? "[x]" : "[ ]";
|
|
100
|
+
const status = p.status === "done" ? "`done`" : "`ready`";
|
|
101
|
+
lines.push(`- ${checkbox} **#${p.id}** ${p.description} ${status}`);
|
|
102
|
+
}
|
|
103
|
+
lines.push("");
|
|
104
|
+
|
|
105
|
+
// Mermaid graph section
|
|
106
|
+
lines.push("## Progress Graph", "");
|
|
107
|
+
lines.push("```mermaid");
|
|
108
|
+
lines.push("graph LR");
|
|
109
|
+
lines.push(` subgraph Progress["Plans: ${done}/${plans.length} done"]`);
|
|
110
|
+
|
|
111
|
+
for (const p of plans) {
|
|
112
|
+
const shortDesc =
|
|
113
|
+
p.description.length > 30 ? p.description.slice(0, 27) + "..." : p.description;
|
|
114
|
+
// Escape quotes in descriptions
|
|
115
|
+
const safeDesc = shortDesc.replace(/"/g, "'");
|
|
116
|
+
const nodeId = `P${p.id}`;
|
|
117
|
+
|
|
118
|
+
if (p.status === "done") {
|
|
119
|
+
lines.push(` ${nodeId}["#${p.id}: ${safeDesc}"]:::done`);
|
|
120
|
+
} else {
|
|
121
|
+
lines.push(` ${nodeId}["#${p.id}: ${safeDesc}"]:::ready`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(" end");
|
|
126
|
+
lines.push(" classDef done fill:#22c55e,color:#fff");
|
|
127
|
+
lines.push(" classDef ready fill:#94a3b8,color:#000");
|
|
128
|
+
lines.push("```");
|
|
129
|
+
lines.push("");
|
|
130
|
+
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format plans as JSON for programmatic consumption.
|
|
136
|
+
*/
|
|
137
|
+
export function formatPlanAsJson(plans: RalphPlan[], state: RalphState): string {
|
|
138
|
+
return JSON.stringify(
|
|
139
|
+
{
|
|
140
|
+
status: state.status,
|
|
141
|
+
summary: {
|
|
142
|
+
total: plans.length,
|
|
143
|
+
done: plans.filter((p) => p.status === "done").length,
|
|
144
|
+
ready: plans.filter((p) => p.status === "ready").length,
|
|
145
|
+
},
|
|
146
|
+
plans: plans.map((p) => ({
|
|
147
|
+
id: p.id,
|
|
148
|
+
description: p.description,
|
|
149
|
+
status: p.status,
|
|
150
|
+
addedAt: p.addedAt,
|
|
151
|
+
completedAt: p.completedAt,
|
|
152
|
+
})),
|
|
153
|
+
},
|
|
154
|
+
null,
|
|
155
|
+
2,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// Duration Formatting
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
export function formatDuration(ms: number): string {
|
|
164
|
+
const seconds = Math.floor(ms / 1000);
|
|
165
|
+
const minutes = Math.floor(seconds / 60);
|
|
166
|
+
const hours = Math.floor(minutes / 60);
|
|
167
|
+
|
|
168
|
+
if (hours > 0) {
|
|
169
|
+
const remainingMins = minutes % 60;
|
|
170
|
+
return `${hours}h ${remainingMins}m`;
|
|
171
|
+
}
|
|
172
|
+
if (minutes > 0) {
|
|
173
|
+
const remainingSecs = seconds % 60;
|
|
174
|
+
return `${minutes}m ${remainingSecs}s`;
|
|
175
|
+
}
|
|
176
|
+
return `${seconds}s`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// Output Summary
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
export function extractOutputSummary(output: string, maxLength: number = 2000): string {
|
|
184
|
+
const lines = output
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter((l) => l.trim())
|
|
187
|
+
.slice(-5);
|
|
188
|
+
let summary = lines.join(" ").trim();
|
|
189
|
+
|
|
190
|
+
if (summary.length > maxLength) {
|
|
191
|
+
summary = summary.substring(0, maxLength) + "...";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return summary || "(no output)";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================================
|
|
198
|
+
// Prompt Building
|
|
199
|
+
// ============================================================================
|
|
200
|
+
|
|
201
|
+
export interface BuildPromptOptions {
|
|
202
|
+
completionMarker: string;
|
|
203
|
+
plan: RalphPlan;
|
|
204
|
+
skipCommit?: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function buildIterationPrompt({
|
|
208
|
+
completionMarker,
|
|
209
|
+
plan,
|
|
210
|
+
skipCommit = false,
|
|
211
|
+
}: BuildPromptOptions): string {
|
|
212
|
+
let step = 1;
|
|
213
|
+
|
|
214
|
+
const prompt = `
|
|
215
|
+
<plan>
|
|
216
|
+
#${plan.id}: ${plan.description}
|
|
217
|
+
</plan>
|
|
218
|
+
|
|
219
|
+
<instructions>
|
|
220
|
+
${step++}. Work on the plan above.
|
|
221
|
+
${step++}. Run type checks and tests.
|
|
222
|
+
${step++}. Mark done: \`tt ralph plan done ${plan.id}\`
|
|
223
|
+
${skipCommit ? "" : `${step++}. Make a git commit.`}
|
|
224
|
+
|
|
225
|
+
**Before ending:** Run \`tt ralph plan list\` to check remaining plans.
|
|
226
|
+
**ONLY if ALL PLANS are done** then Output: <promise>${completionMarker}</promise>
|
|
227
|
+
</instructions>
|
|
228
|
+
`;
|
|
229
|
+
return prompt.trim();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================================================
|
|
233
|
+
// Marker Detection
|
|
234
|
+
// ============================================================================
|
|
235
|
+
|
|
236
|
+
export function detectCompletionMarker(output: string, marker: string): boolean {
|
|
237
|
+
return output.includes(marker);
|
|
238
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import type { RalphSettings } from "../../config/settings.js";
|
|
6
|
+
import { RalphSettingsSchema } from "../../config/settings.js";
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Constants
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MAX_ITERATIONS = 10;
|
|
13
|
+
export const DEFAULT_STATE_DIR = "./.claude/.ralph";
|
|
14
|
+
export const DEFAULT_COMPLETION_MARKER = "RALPH_DONE";
|
|
15
|
+
|
|
16
|
+
// File names within stateDir
|
|
17
|
+
const STATE_FILE_NAME = "ralph-state.local.json";
|
|
18
|
+
const LOG_FILE_NAME = "ralph-log.local.md";
|
|
19
|
+
const HISTORY_FILE_NAME = "ralph-history.local.log";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Path Helpers (use stateDir from settings)
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export function getRalphPaths(settings?: RalphSettings) {
|
|
26
|
+
const stateDir = settings?.stateDir ?? RalphSettingsSchema.parse({}).stateDir;
|
|
27
|
+
return {
|
|
28
|
+
stateFile: path.join(stateDir, STATE_FILE_NAME),
|
|
29
|
+
logFile: path.join(stateDir, LOG_FILE_NAME),
|
|
30
|
+
historyFile: path.join(stateDir, HISTORY_FILE_NAME),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Defaults used in flag descriptions
|
|
35
|
+
export const DEFAULT_STATE_FILE = `${DEFAULT_STATE_DIR}/${STATE_FILE_NAME}`;
|
|
36
|
+
export const DEFAULT_LOG_FILE = `${DEFAULT_STATE_DIR}/${LOG_FILE_NAME}`;
|
|
37
|
+
export const DEFAULT_HISTORY_FILE = `${DEFAULT_STATE_DIR}/${HISTORY_FILE_NAME}`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve ralph file path - uses flag value if provided, otherwise computes from settings
|
|
41
|
+
*/
|
|
42
|
+
export function resolveRalphPath(
|
|
43
|
+
flagValue: string | undefined,
|
|
44
|
+
pathType: "stateFile" | "logFile" | "historyFile",
|
|
45
|
+
settings?: RalphSettings,
|
|
46
|
+
): string {
|
|
47
|
+
if (flagValue !== undefined) {
|
|
48
|
+
return flagValue;
|
|
49
|
+
}
|
|
50
|
+
const paths = getRalphPaths(settings);
|
|
51
|
+
return paths[pathType];
|
|
52
|
+
}
|
|
53
|
+
export const CLAUDE_DEFAULT_ARGS = [
|
|
54
|
+
"--print",
|
|
55
|
+
"--verbose",
|
|
56
|
+
"--output-format",
|
|
57
|
+
"stream-json",
|
|
58
|
+
"--permission-mode",
|
|
59
|
+
"bypassPermissions",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// State Validation Schemas
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
const PlanStatusSchema = z.enum(["ready", "done", "blocked", "cancelled"]);
|
|
67
|
+
|
|
68
|
+
const RalphPlanSchema = z.object({
|
|
69
|
+
id: z.number(),
|
|
70
|
+
description: z.string(),
|
|
71
|
+
status: PlanStatusSchema,
|
|
72
|
+
addedAt: z.string(),
|
|
73
|
+
completedAt: z.string().optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const RalphStateSchema = z.object({
|
|
77
|
+
version: z.number(),
|
|
78
|
+
plans: z.array(RalphPlanSchema),
|
|
79
|
+
startedAt: z.string(),
|
|
80
|
+
status: z.enum(["running", "completed", "max_iterations_reached", "error"]),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Types (derived from Zod schemas)
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export interface IterationHistory {
|
|
88
|
+
iteration: number;
|
|
89
|
+
startedAt: string;
|
|
90
|
+
completedAt: string;
|
|
91
|
+
durationMs: number;
|
|
92
|
+
durationHuman: string;
|
|
93
|
+
outputSummary: string;
|
|
94
|
+
markerFound: boolean;
|
|
95
|
+
contextUsedPercent?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type PlanStatus = z.infer<typeof PlanStatusSchema>;
|
|
99
|
+
export type RalphPlan = z.infer<typeof RalphPlanSchema>;
|
|
100
|
+
export type RalphState = z.infer<typeof RalphStateSchema>;
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// State Management
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
export function createInitialState(): RalphState {
|
|
107
|
+
return {
|
|
108
|
+
version: 1,
|
|
109
|
+
plans: [],
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
status: "running",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Append iteration history as a JSON line to the history log file.
|
|
117
|
+
* Each line is a complete JSON object for easy parsing.
|
|
118
|
+
*/
|
|
119
|
+
export function appendHistory(
|
|
120
|
+
history: IterationHistory,
|
|
121
|
+
historyFile: string = DEFAULT_HISTORY_FILE,
|
|
122
|
+
): void {
|
|
123
|
+
fs.mkdirSync(path.dirname(historyFile), { recursive: true });
|
|
124
|
+
const line = JSON.stringify(history) + "\n";
|
|
125
|
+
fs.appendFileSync(historyFile, line);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function saveState(state: RalphState, stateFile: string): void {
|
|
129
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
130
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function loadState(stateFile: string): RalphState | null {
|
|
134
|
+
try {
|
|
135
|
+
if (!fs.existsSync(stateFile)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const content = fs.readFileSync(stateFile, "utf-8");
|
|
139
|
+
const parsed = JSON.parse(content);
|
|
140
|
+
|
|
141
|
+
const result = RalphStateSchema.safeParse(parsed);
|
|
142
|
+
if (!result.success) {
|
|
143
|
+
const errors = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
144
|
+
console.warn(pc.yellow(`Warning: Invalid state file ${stateFile}: ${errors}`));
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return result.data;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.warn(pc.yellow(`Warning: Failed to load state file ${stateFile}: ${err}`));
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function addPlanToState(state: RalphState, description: string): RalphPlan {
|
|
155
|
+
const nextId = state.plans.length > 0 ? Math.max(...state.plans.map((p) => p.id)) + 1 : 1;
|
|
156
|
+
|
|
157
|
+
const newPlan: RalphPlan = {
|
|
158
|
+
id: nextId,
|
|
159
|
+
description,
|
|
160
|
+
status: "ready",
|
|
161
|
+
addedAt: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
state.plans.push(newPlan);
|
|
165
|
+
return newPlan;
|
|
166
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal types and constants
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const JOURNAL_TYPES = {
|
|
6
|
+
DAILY_NOTES: "daily-notes",
|
|
7
|
+
MEETING: "meeting",
|
|
8
|
+
NOTE: "note",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type JournalType = (typeof JOURNAL_TYPES)[keyof typeof JOURNAL_TYPES];
|
|
12
|
+
|
|
13
|
+
export interface JournalArgs {
|
|
14
|
+
title?: string;
|
|
15
|
+
journalType: JournalType;
|
|
16
|
+
}
|
|
@@ -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,14 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getIssues, isGithubCliInstalled } from "./gh-cli-wrapper";
|
|
3
|
+
|
|
4
|
+
describe.skipIf(!!process.env.CI)("gh-cli-wrapper", () => {
|
|
5
|
+
it("should return true if gh is installed", async () => {
|
|
6
|
+
const result = await isGithubCliInstalled();
|
|
7
|
+
expect(result).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("get issues", async () => {
|
|
11
|
+
const issues = await getIssues({ assignedToMe: false, cwd: "." });
|
|
12
|
+
expect(issues.length).toBeGreaterThan(0);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import stripAnsi from "strip-ansi";
|
|
2
|
+
import { x } from "tinyexec";
|
|
3
|
+
|
|
4
|
+
export const isGithubCliInstalled = async (): Promise<boolean> => {
|
|
5
|
+
try {
|
|
6
|
+
const proc = await x(`gh`, ["--version"]);
|
|
7
|
+
return proc.stdout.indexOf("https://github.com/cli/cli") > 0;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface Issue {
|
|
14
|
+
labels: {
|
|
15
|
+
name: string;
|
|
16
|
+
color: string;
|
|
17
|
+
}[];
|
|
18
|
+
number: number;
|
|
19
|
+
title: string;
|
|
20
|
+
state: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const getIssues = async ({
|
|
24
|
+
assignedToMe,
|
|
25
|
+
cwd,
|
|
26
|
+
}: {
|
|
27
|
+
assignedToMe: boolean;
|
|
28
|
+
cwd: string;
|
|
29
|
+
}): Promise<Issue[]> => {
|
|
30
|
+
let issues: Issue[] = [];
|
|
31
|
+
|
|
32
|
+
const flags = ["issue", "list", "--json", "labels,number,title,state"];
|
|
33
|
+
|
|
34
|
+
if (assignedToMe) {
|
|
35
|
+
flags.push("--assignee");
|
|
36
|
+
flags.push("@me");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//console.log('Current working directory:', cwd.stdout.trim())
|
|
40
|
+
|
|
41
|
+
const result = await x(`gh`, flags);
|
|
42
|
+
// Setting NO_COLOR=1 didn't remove colors so had to use stripAnsi
|
|
43
|
+
const stripped = stripAnsi(result.stdout);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
issues = JSON.parse(stripped);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Failed to parse GitHub CLI output as JSON. Raw output: ${stripped.slice(0, 200)}${stripped.length > 200 ? "..." : ""}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return issues;
|
|
54
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isGitDirectory } from "./git-wrapper";
|
|
3
|
+
|
|
4
|
+
describe.skip("git-wrapper", () => {
|
|
5
|
+
it("isGitDirectory", async () => {
|
|
6
|
+
const result = await isGitDirectory();
|
|
7
|
+
expect(result).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// it('getMergedBranches', async () => {
|
|
11
|
+
// const result = await getMergedBranches()
|
|
12
|
+
|
|
13
|
+
// if (result.length > 0) // may not have branches to cleanup so just check for no errors
|
|
14
|
+
// expect(result[0].trim()).toBe(result[0])
|
|
15
|
+
// })
|
|
16
|
+
|
|
17
|
+
// it('getLocalBranchNames', async () => {
|
|
18
|
+
// const result = await getLocalBranchNames()
|
|
19
|
+
// expect(result.includes('main')).toBeDefined()
|
|
20
|
+
// })
|
|
21
|
+
|
|
22
|
+
// it('getDefaultMainBranchName', async () => {
|
|
23
|
+
// const result = await getDefaultMainBranchName()
|
|
24
|
+
// expect(result).toBe('main')
|
|
25
|
+
// })
|
|
26
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { exec } from "tinyexec";
|
|
2
|
+
|
|
3
|
+
export const isGitDirectory = async (): Promise<boolean> => {
|
|
4
|
+
try {
|
|
5
|
+
const result = await exec(`git`, ["status"]);
|
|
6
|
+
return result.stdout.includes("On branch");
|
|
7
|
+
} catch (e) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const createBranch = async ({ branchName }: { branchName: string }): Promise<string> => {
|
|
13
|
+
const result = await exec(`git`, ["checkout", "-b", branchName]);
|
|
14
|
+
return result.stdout;
|
|
15
|
+
};
|