@towles/tool 0.0.18 → 0.0.41

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE.md +9 -10
  3. package/README.md +121 -78
  4. package/bin/run.ts +5 -0
  5. package/package.json +63 -53
  6. package/patches/prompts.patch +34 -0
  7. package/src/commands/base.ts +42 -0
  8. package/src/commands/config.test.ts +15 -0
  9. package/src/commands/config.ts +43 -0
  10. package/src/commands/doctor.ts +133 -0
  11. package/src/commands/gh/branch-clean.ts +110 -0
  12. package/src/commands/gh/branch.test.ts +124 -0
  13. package/src/commands/gh/branch.ts +132 -0
  14. package/src/commands/gh/pr.ts +168 -0
  15. package/src/commands/index.ts +55 -0
  16. package/src/commands/install.ts +148 -0
  17. package/src/commands/journal/daily-notes.ts +66 -0
  18. package/src/commands/journal/meeting.ts +83 -0
  19. package/src/commands/journal/note.ts +83 -0
  20. package/src/commands/journal/utils.ts +399 -0
  21. package/src/commands/observe/graph.test.ts +89 -0
  22. package/src/commands/observe/graph.ts +1640 -0
  23. package/src/commands/observe/report.ts +166 -0
  24. package/src/commands/observe/session.ts +385 -0
  25. package/src/commands/observe/setup.ts +180 -0
  26. package/src/commands/observe/status.ts +146 -0
  27. package/src/commands/ralph/lib/execution.ts +302 -0
  28. package/src/commands/ralph/lib/formatter.ts +298 -0
  29. package/src/commands/ralph/lib/index.ts +4 -0
  30. package/src/commands/ralph/lib/marker.ts +108 -0
  31. package/src/commands/ralph/lib/state.ts +191 -0
  32. package/src/commands/ralph/marker/create.ts +23 -0
  33. package/src/commands/ralph/plan.ts +73 -0
  34. package/src/commands/ralph/progress.ts +44 -0
  35. package/src/commands/ralph/ralph.test.ts +673 -0
  36. package/src/commands/ralph/run.ts +408 -0
  37. package/src/commands/ralph/task/add.ts +105 -0
  38. package/src/commands/ralph/task/done.ts +73 -0
  39. package/src/commands/ralph/task/list.test.ts +48 -0
  40. package/src/commands/ralph/task/list.ts +110 -0
  41. package/src/commands/ralph/task/remove.ts +62 -0
  42. package/src/config/context.ts +7 -0
  43. package/src/config/settings.ts +155 -0
  44. package/src/constants.ts +3 -0
  45. package/src/types/journal.ts +16 -0
  46. package/src/utils/anthropic/types.ts +158 -0
  47. package/src/utils/date-utils.test.ts +96 -0
  48. package/src/utils/date-utils.ts +54 -0
  49. package/src/utils/exec.ts +8 -0
  50. package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
  51. package/src/utils/git/gh-cli-wrapper.ts +54 -0
  52. package/src/utils/git/git-wrapper.test.ts +26 -0
  53. package/src/utils/git/git-wrapper.ts +15 -0
  54. package/src/utils/git/git.ts +25 -0
  55. package/src/utils/render.test.ts +71 -0
  56. package/src/utils/render.ts +34 -0
  57. package/dist/index.d.mts +0 -1
  58. package/dist/index.mjs +0 -794
@@ -0,0 +1,110 @@
1
+ import { Flags } from "@oclif/core";
2
+ import pc from "picocolors";
3
+ import { BaseCommand } from "../../base.js";
4
+ import { DEFAULT_STATE_FILE, loadState, resolveRalphPath } from "../lib/state.js";
5
+ import { formatTasksAsMarkdown } from "../lib/formatter.js";
6
+
7
+ /**
8
+ * List all ralph tasks
9
+ */
10
+ export default class TaskList extends BaseCommand {
11
+ static override description = "List all tasks";
12
+
13
+ static override examples = [
14
+ "<%= config.bin %> ralph task list",
15
+ "<%= config.bin %> ralph task list --format markdown",
16
+ "<%= config.bin %> ralph task list --label backend",
17
+ ];
18
+
19
+ static override flags = {
20
+ ...BaseCommand.baseFlags,
21
+ stateFile: Flags.string({
22
+ char: "s",
23
+ description: `State file path (default: ${DEFAULT_STATE_FILE})`,
24
+ }),
25
+ format: Flags.string({
26
+ char: "f",
27
+ description: "Output format: default, markdown",
28
+ default: "default",
29
+ options: ["default", "markdown"],
30
+ }),
31
+ label: Flags.string({
32
+ char: "l",
33
+ description: "Filter tasks by label",
34
+ }),
35
+ };
36
+
37
+ async run(): Promise<void> {
38
+ const { flags } = await this.parse(TaskList);
39
+ const ralphSettings = this.settings.settingsFile.settings.ralphSettings;
40
+ const stateFile = resolveRalphPath(flags.stateFile, "stateFile", ralphSettings);
41
+
42
+ const state = loadState(stateFile);
43
+
44
+ if (!state) {
45
+ this.log(pc.yellow(`No state file found at: ${stateFile}`));
46
+ return;
47
+ }
48
+
49
+ // Filter by label if specified
50
+ let tasks = state.tasks;
51
+ if (flags.label) {
52
+ tasks = tasks.filter((t) => t.label === flags.label);
53
+ if (tasks.length === 0) {
54
+ this.log(pc.yellow(`No tasks with label: ${flags.label}`));
55
+ return;
56
+ }
57
+ }
58
+
59
+ if (tasks.length === 0) {
60
+ this.log(pc.yellow("No tasks in state file."));
61
+ this.log(pc.dim('Use: tt ralph task add "description"'));
62
+ return;
63
+ }
64
+
65
+ if (flags.format === "markdown") {
66
+ this.log(formatTasksAsMarkdown(tasks));
67
+ return;
68
+ }
69
+
70
+ // Default format output - compact with truncation
71
+ const ready = tasks.filter((t) => t.status === "ready");
72
+ const done = tasks.filter((t) => t.status === "done");
73
+
74
+ const truncate = (s: string, len: number) => (s.length > len ? s.slice(0, len - 1) + "…" : s);
75
+ const termWidth = process.stdout.columns || 120;
76
+
77
+ // Summary header
78
+ const labelInfo = flags.label ? ` [${flags.label}]` : "";
79
+ this.log(
80
+ pc.bold(`\nTasks${labelInfo}: `) +
81
+ pc.green(`${done.length} done`) +
82
+ pc.dim(" / ") +
83
+ pc.yellow(`${ready.length} ready`),
84
+ );
85
+ this.log();
86
+
87
+ // Show ready tasks first (these are actionable)
88
+ // Reserve ~10 chars for " ○ #XX " prefix
89
+ const descWidth = Math.max(40, termWidth - 12);
90
+
91
+ if (ready.length > 0) {
92
+ for (const task of ready) {
93
+ const icon = pc.dim("○");
94
+ const id = pc.cyan(`#${task.id}`);
95
+ const desc = truncate(task.description, descWidth);
96
+ this.log(` ${icon} ${id} ${desc}`);
97
+ }
98
+ }
99
+
100
+ // Show done tasks collapsed
101
+ if (done.length > 0) {
102
+ this.log(pc.dim(` ─── ${done.length} completed ───`));
103
+ for (const task of done) {
104
+ const desc = truncate(task.description, descWidth - 5);
105
+ this.log(pc.dim(` ✓ #${task.id} ${desc}`));
106
+ }
107
+ }
108
+ this.log();
109
+ }
110
+ }
@@ -0,0 +1,62 @@
1
+ import { Args, Flags } from "@oclif/core";
2
+ import pc from "picocolors";
3
+ import { BaseCommand } from "../../base.js";
4
+ import { DEFAULT_STATE_FILE, loadState, saveState, resolveRalphPath } from "../lib/state.js";
5
+
6
+ /**
7
+ * Remove a ralph task by ID
8
+ */
9
+ export default class TaskRemove extends BaseCommand {
10
+ static override description = "Remove a task by ID";
11
+
12
+ static override examples = [
13
+ "<%= config.bin %> ralph task remove 1",
14
+ "<%= config.bin %> ralph task remove 5 --stateFile custom-state.json",
15
+ ];
16
+
17
+ static override args = {
18
+ id: Args.integer({
19
+ description: "Task ID to remove",
20
+ required: true,
21
+ }),
22
+ };
23
+
24
+ static override flags = {
25
+ ...BaseCommand.baseFlags,
26
+ stateFile: Flags.string({
27
+ char: "s",
28
+ description: `State file path (default: ${DEFAULT_STATE_FILE})`,
29
+ }),
30
+ };
31
+
32
+ async run(): Promise<void> {
33
+ const { args, flags } = await this.parse(TaskRemove);
34
+ const ralphSettings = this.settings.settingsFile.settings.ralphSettings;
35
+ const stateFile = resolveRalphPath(flags.stateFile, "stateFile", ralphSettings);
36
+
37
+ const taskId = args.id;
38
+
39
+ if (taskId < 1) {
40
+ this.error("Invalid task ID");
41
+ }
42
+
43
+ const state = loadState(stateFile);
44
+
45
+ if (!state) {
46
+ this.error(`No state file found at: ${stateFile}`);
47
+ }
48
+
49
+ const taskIndex = state.tasks.findIndex((t) => t.id === taskId);
50
+
51
+ if (taskIndex === -1) {
52
+ this.error(`Task #${taskId} not found. Use: tt ralph task list`);
53
+ }
54
+
55
+ const removedTask = state.tasks[taskIndex];
56
+ state.tasks.splice(taskIndex, 1);
57
+ saveState(state, stateFile);
58
+
59
+ console.log(pc.green(`✓ Removed task #${taskId}: ${removedTask.description}`));
60
+ console.log(pc.dim(`Remaining tasks: ${state.tasks.length}`));
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ import type { SettingsFile } from "./settings";
2
+
3
+ export interface Context {
4
+ cwd: string;
5
+ settingsFile: SettingsFile;
6
+ debug: boolean;
7
+ }
@@ -0,0 +1,155 @@
1
+ import { z } from "zod/v4";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { AppInfo } from "../constants";
6
+ import consola from "consola";
7
+ import { colors } from "consola/utils";
8
+
9
+ /** Default config directory */
10
+ export const DEFAULT_CONFIG_DIR = path.join(homedir(), ".config", AppInfo.toolName);
11
+
12
+ /** User settings file path */
13
+ export const USER_SETTINGS_PATH = path.join(
14
+ DEFAULT_CONFIG_DIR,
15
+ `${AppInfo.toolName}.settings.json`,
16
+ );
17
+
18
+ /** Settings filename used within configDir */
19
+ export const SETTINGS_FILENAME = `${AppInfo.toolName}.settings.json`;
20
+
21
+ /** Get settings file path for a given configDir */
22
+ export function getSettingsPath(configDir: string): string {
23
+ return path.join(configDir, SETTINGS_FILENAME);
24
+ }
25
+
26
+ export const RalphSettingsSchema = z.object({
27
+ // Base directory for ralph files (relative to cwd or absolute)
28
+ stateDir: z.string().default("./.claude/.ralph"),
29
+ });
30
+
31
+ export type RalphSettings = z.infer<typeof RalphSettingsSchema>;
32
+
33
+ export const JournalSettingsSchema = z.object({
34
+ // Base folder where all journal files are stored
35
+ baseFolder: z.string().default(path.join(homedir())),
36
+ // https://moment.github.io/luxon/#/formatting?id=table-of-tokens
37
+ dailyPathTemplate: z
38
+ .string()
39
+ .default(
40
+ path.join(
41
+ "journal/{monday:yyyy}/{monday:MM}/daily-notes/{monday:yyyy}-{monday:MM}-{monday:dd}-daily-notes.md",
42
+ ),
43
+ ),
44
+ meetingPathTemplate: z
45
+ .string()
46
+ .default(path.join("journal/{yyyy}/{MM}/meetings/{yyyy}-{MM}-{dd}-{title}.md")),
47
+ notePathTemplate: z
48
+ .string()
49
+ .default(path.join("journal/{yyyy}/{MM}/notes/{yyyy}-{MM}-{dd}-{title}.md")),
50
+ // Directory for external templates (fallback to hardcoded if not found)
51
+ templateDir: z.string().default(path.join(homedir(), ".config", AppInfo.toolName, "templates")),
52
+ });
53
+
54
+ export type JournalSettings = z.infer<typeof JournalSettingsSchema>;
55
+
56
+ export const UserSettingsSchema = z.object({
57
+ preferredEditor: z.string().default("code"),
58
+ journalSettings: JournalSettingsSchema.optional().transform(
59
+ (v) => v ?? JournalSettingsSchema.parse({}),
60
+ ),
61
+ ralphSettings: RalphSettingsSchema.optional().transform(
62
+ (v) => v ?? RalphSettingsSchema.parse({}),
63
+ ),
64
+ });
65
+
66
+ type UserSettings = z.infer<typeof UserSettingsSchema>;
67
+
68
+ export interface SettingsFile {
69
+ settings: UserSettings;
70
+ path: string;
71
+ }
72
+
73
+ // TODO refactor this.
74
+ export class LoadedSettings {
75
+ constructor(settingsFile: SettingsFile) {
76
+ this.settingsFile = settingsFile;
77
+ }
78
+
79
+ readonly settingsFile: SettingsFile;
80
+ }
81
+
82
+ function createDefaultSettings(): UserSettings {
83
+ return UserSettingsSchema.parse({});
84
+ }
85
+
86
+ function createAndSaveDefaultSettings(): UserSettings {
87
+ const userSettings = createDefaultSettings();
88
+ saveSettings({
89
+ path: USER_SETTINGS_PATH,
90
+ settings: userSettings,
91
+ });
92
+ return userSettings;
93
+ }
94
+
95
+ export function saveSettings(settingsFile: SettingsFile): void {
96
+ try {
97
+ // Ensure the directory exists
98
+ const dirPath = path.dirname(settingsFile.path);
99
+ if (!fs.existsSync(dirPath)) {
100
+ fs.mkdirSync(dirPath, { recursive: true });
101
+ }
102
+
103
+ fs.writeFileSync(settingsFile.path, JSON.stringify(settingsFile.settings, null, 2), "utf-8");
104
+ } catch (error) {
105
+ consola.error("Error saving user settings file:", error);
106
+ }
107
+ }
108
+
109
+ export async function loadSettings(): Promise<LoadedSettings> {
110
+ let userSettings: UserSettings | null = null;
111
+
112
+ // Load user settings
113
+ if (fs.existsSync(USER_SETTINGS_PATH)) {
114
+ const userContent = fs.readFileSync(USER_SETTINGS_PATH, "utf-8");
115
+ const parsedUserSettings: unknown = JSON.parse(userContent);
116
+
117
+ userSettings = UserSettingsSchema.parse(parsedUserSettings);
118
+ // made add a save here if the default values differ from the current values
119
+ if (JSON.stringify(parsedUserSettings) !== JSON.stringify(userSettings)) {
120
+ consola.warn(`Settings file ${USER_SETTINGS_PATH} has been updated with default values.`);
121
+ const tempSettingsFile: SettingsFile = {
122
+ path: USER_SETTINGS_PATH,
123
+ settings: userSettings,
124
+ };
125
+
126
+ saveSettings(tempSettingsFile);
127
+ }
128
+ } else {
129
+ // Settings file doesn't exist
130
+ const isNonInteractive = process.env.CI || !process.stdout.isTTY;
131
+
132
+ if (isNonInteractive) {
133
+ // Auto-create in CI/non-TTY environments
134
+ consola.info(`Creating settings file: ${USER_SETTINGS_PATH}`);
135
+ userSettings = createAndSaveDefaultSettings();
136
+ } else {
137
+ // Interactive: ask user if they want to create it
138
+ const confirmed = await consola.prompt(
139
+ `Settings file not found. Create ${colors.cyan(USER_SETTINGS_PATH)}?`,
140
+ {
141
+ type: "confirm",
142
+ },
143
+ );
144
+ if (!confirmed) {
145
+ throw new Error(`Settings file not found and user chose not to create it.`);
146
+ }
147
+ userSettings = createAndSaveDefaultSettings();
148
+ }
149
+ }
150
+
151
+ return new LoadedSettings({
152
+ path: USER_SETTINGS_PATH,
153
+ settings: userSettings!,
154
+ });
155
+ }
@@ -0,0 +1,3 @@
1
+ export const AppInfo = {
2
+ toolName: "towles-tool",
3
+ } as const;
@@ -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,158 @@
1
+ import type {
2
+ ContentBlock,
3
+ ContentBlockParam,
4
+ Message,
5
+ MessageParam,
6
+ } from "@anthropic-ai/sdk/resources"; // only used for types
7
+ import { z } from "zod/v4";
8
+
9
+ // This file defines types and interfaces for Claude SDK messages and related structures.
10
+ // copied from https://github.com/tuanemuy/cc-jsonl/blob/main/src/core/domain/claude/types.ts
11
+ // which is also using the Anthropic claude-code package.
12
+
13
+ export const sendMessageInputSchema = z.object({
14
+ message: z.string().min(1),
15
+ sessionId: z.string().optional(),
16
+ cwd: z.string().optional(),
17
+ allowedTools: z.array(z.string()).optional(),
18
+ bypassPermissions: z.boolean().optional(),
19
+ });
20
+ export type SendMessageInput = z.infer<typeof sendMessageInputSchema>;
21
+
22
+ export type AssistantContent = ContentBlock[];
23
+ export type UserContent = string | ContentBlockParam[];
24
+
25
+ export interface AssistantMessage {
26
+ type: "assistant";
27
+ message: Message; // From Anthropic SDK
28
+ session_id: string;
29
+ }
30
+
31
+ export function isAssistantMessage(message: SDKMessage): message is AssistantMessage {
32
+ return message.type === "assistant";
33
+ }
34
+
35
+ export interface UserMessage {
36
+ type: "user";
37
+ message: MessageParam; // Anthropic SDK
38
+ session_id: string;
39
+ }
40
+
41
+ export function isUserMessage(message: SDKMessage): message is UserMessage {
42
+ return message.type === "user";
43
+ }
44
+
45
+ export interface ResultMessage {
46
+ type: "result";
47
+ subtype: "success" | "error_max_turns" | "error_during_execution";
48
+ duration_ms: number;
49
+ duration_api_ms: number;
50
+ is_error: boolean;
51
+ num_turns: number;
52
+ result?: string; // Only on success
53
+ session_id: string;
54
+ total_cost_usd: number;
55
+ }
56
+
57
+ export function isResultMessage(message: SDKMessage): message is ResultMessage {
58
+ return message.type === "result";
59
+ }
60
+
61
+ export interface SystemMessage {
62
+ type: "system";
63
+ subtype: "init";
64
+ apiKeySource: string;
65
+ cwd: string;
66
+ session_id: string;
67
+ tools: string[];
68
+ mcp_servers: {
69
+ name: string;
70
+ status: string;
71
+ }[];
72
+ model: string;
73
+ permissionMode: "default" | "acceptEdits" | "bypassPermissions" | "plan";
74
+ }
75
+
76
+ export function isSystemMessage(message: SDKMessage): message is SystemMessage {
77
+ return message.type === "system";
78
+ }
79
+
80
+ export type SDKMessage = AssistantMessage | UserMessage | ResultMessage | SystemMessage;
81
+
82
+ // ChunkData is simply an SDKMessage
83
+ // The SDK already provides messages in the appropriate granularity for streaming
84
+ export type ChunkData = SDKMessage;
85
+
86
+ // Tool result type for handling tool execution results
87
+ export interface ToolResult {
88
+ type: "tool_result";
89
+ tool_use_id: string;
90
+ content?: string | Record<string, unknown>[];
91
+ is_error?: boolean;
92
+ }
93
+
94
+ export function isToolResult(obj: unknown): obj is ToolResult {
95
+ return (
96
+ typeof obj === "object" &&
97
+ obj !== null &&
98
+ "type" in obj &&
99
+ obj.type === "tool_result" &&
100
+ "tool_use_id" in obj &&
101
+ typeof (obj as ToolResult).tool_use_id === "string"
102
+ );
103
+ }
104
+
105
+ // Type guard helpers for parseSDKMessage - use unknown input to allow type narrowing
106
+ function isValidAssistantMessage(obj: unknown): obj is AssistantMessage {
107
+ if (typeof obj !== "object" || obj === null) return false;
108
+ const o = obj as Record<string, unknown>;
109
+ return (
110
+ o.type === "assistant" &&
111
+ o.message !== null &&
112
+ typeof o.message === "object" &&
113
+ typeof o.session_id === "string"
114
+ );
115
+ }
116
+
117
+ function isValidUserMessage(obj: unknown): obj is UserMessage {
118
+ if (typeof obj !== "object" || obj === null) return false;
119
+ const o = obj as Record<string, unknown>;
120
+ return (
121
+ o.type === "user" &&
122
+ o.message !== null &&
123
+ typeof o.message === "object" &&
124
+ typeof o.session_id === "string"
125
+ );
126
+ }
127
+
128
+ function isValidResultMessage(obj: unknown): obj is ResultMessage {
129
+ if (typeof obj !== "object" || obj === null) return false;
130
+ const o = obj as Record<string, unknown>;
131
+ return (
132
+ o.type === "result" &&
133
+ typeof o.session_id === "string" &&
134
+ typeof o.subtype === "string" &&
135
+ typeof o.duration_ms === "number" &&
136
+ typeof o.is_error === "boolean"
137
+ );
138
+ }
139
+
140
+ function isValidSystemMessage(obj: unknown): obj is SystemMessage {
141
+ if (typeof obj !== "object" || obj === null) return false;
142
+ const o = obj as Record<string, unknown>;
143
+ return (
144
+ o.type === "system" &&
145
+ typeof o.session_id === "string" &&
146
+ typeof o.subtype === "string" &&
147
+ typeof o.cwd === "string"
148
+ );
149
+ }
150
+
151
+ // Helper function to safely parse SDKMessage
152
+ export function parseSDKMessage(data: unknown): SDKMessage | null {
153
+ if (isValidAssistantMessage(data)) return data;
154
+ if (isValidUserMessage(data)) return data;
155
+ if (isValidResultMessage(data)) return data;
156
+ if (isValidSystemMessage(data)) return data;
157
+ return null;
158
+ }
@@ -0,0 +1,96 @@
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
+ const date = new Date("2025-07-07");
40
+ expect(formatDate(date)).toBe("2025-07-07");
41
+ });
42
+
43
+ it("should get week info correctly", () => {
44
+ // Test with Monday July 7, 2025
45
+ const monday = new Date(2025, 6, 7);
46
+ const weekInfo = getWeekInfo(monday);
47
+
48
+ expect(formatDate(weekInfo.mondayDate)).toBe("2025-07-07");
49
+ expect(formatDate(weekInfo.tuesdayDate)).toBe("2025-07-08");
50
+ expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-07-09");
51
+ expect(formatDate(weekInfo.thursdayDate)).toBe("2025-07-10");
52
+ expect(formatDate(weekInfo.fridayDate)).toBe("2025-07-11");
53
+ });
54
+
55
+ it("should handle edge cases correctly", () => {
56
+ // Test with year boundary - Monday December 30, 2024
57
+ const mondayEndOfYear = new Date(2024, 11, 30);
58
+ const weekInfo = getWeekInfo(mondayEndOfYear);
59
+
60
+ expect(formatDate(weekInfo.mondayDate)).toBe("2024-12-30");
61
+ expect(formatDate(weekInfo.tuesdayDate)).toBe("2024-12-31");
62
+ expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-01-01");
63
+ expect(formatDate(weekInfo.thursdayDate)).toBe("2025-01-02");
64
+ expect(formatDate(weekInfo.fridayDate)).toBe("2025-01-03");
65
+ });
66
+
67
+ it("should handle month boundary correctly", () => {
68
+ // Test with month boundary - Monday January 29, 2025
69
+ const mondayEndOfMonth = new Date(2025, 0, 27);
70
+ const weekInfo = getWeekInfo(mondayEndOfMonth);
71
+
72
+ expect(formatDate(weekInfo.mondayDate)).toBe("2025-01-27");
73
+ expect(formatDate(weekInfo.tuesdayDate)).toBe("2025-01-28");
74
+ expect(formatDate(weekInfo.wednesdayDate)).toBe("2025-01-29");
75
+ expect(formatDate(weekInfo.thursdayDate)).toBe("2025-01-30");
76
+ expect(formatDate(weekInfo.fridayDate)).toBe("2025-01-31");
77
+ });
78
+
79
+ it("should handle getMondayOfWeek with different timezones", () => {
80
+ // Test with a specific time to ensure hours are reset
81
+ const dateWithTime = new Date(2025, 6, 9, 15, 30, 45); // July 9, 2025 at 3:30:45 PM
82
+ const monday = getMondayOfWeek(dateWithTime);
83
+
84
+ expect(formatDate(monday)).toBe("2025-07-07");
85
+ expect(monday.getHours()).toBe(0);
86
+ expect(monday.getMinutes()).toBe(0);
87
+ expect(monday.getSeconds()).toBe(0);
88
+ expect(monday.getMilliseconds()).toBe(0);
89
+ });
90
+
91
+ it("should handle formatDate with different times", () => {
92
+ // Test that formatDate only considers the date part
93
+ const dateWithTime = new Date(2025, 6, 7, 10, 30, 45); // July 7, 2025 at 10:30:45 AM
94
+ expect(formatDate(dateWithTime)).toBe("2025-07-07");
95
+ });
96
+ });
@@ -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
42
+ */
43
+ export function formatDate(date: Date): string {
44
+ return date.toISOString().split("T")[0];
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,8 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ // TODO change to use tinyexec or similar for better error handling
4
+ export function execCommand(cmd: string, cwd?: string) {
5
+ // Note about execSync, if the command fails or times out, it might not throw an error,
6
+ // if the child process intercepts the SIGTERM signal, we might not get an error.
7
+ return execSync(cmd, { encoding: "utf8", cwd }).trim();
8
+ }
@@ -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
+ });