@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.
- package/LICENSE +21 -0
- package/LICENSE.md +9 -10
- package/README.md +121 -78
- package/bin/run.ts +5 -0
- package/package.json +63 -53
- package/patches/prompts.patch +34 -0
- package/src/commands/base.ts +42 -0
- package/src/commands/config.test.ts +15 -0
- package/src/commands/config.ts +43 -0
- package/src/commands/doctor.ts +133 -0
- package/src/commands/gh/branch-clean.ts +110 -0
- package/src/commands/gh/branch.test.ts +124 -0
- package/src/commands/gh/branch.ts +132 -0
- package/src/commands/gh/pr.ts +168 -0
- package/src/commands/index.ts +55 -0
- package/src/commands/install.ts +148 -0
- package/src/commands/journal/daily-notes.ts +66 -0
- package/src/commands/journal/meeting.ts +83 -0
- package/src/commands/journal/note.ts +83 -0
- package/src/commands/journal/utils.ts +399 -0
- package/src/commands/observe/graph.test.ts +89 -0
- package/src/commands/observe/graph.ts +1640 -0
- package/src/commands/observe/report.ts +166 -0
- package/src/commands/observe/session.ts +385 -0
- package/src/commands/observe/setup.ts +180 -0
- package/src/commands/observe/status.ts +146 -0
- package/src/commands/ralph/lib/execution.ts +302 -0
- package/src/commands/ralph/lib/formatter.ts +298 -0
- package/src/commands/ralph/lib/index.ts +4 -0
- package/src/commands/ralph/lib/marker.ts +108 -0
- package/src/commands/ralph/lib/state.ts +191 -0
- package/src/commands/ralph/marker/create.ts +23 -0
- package/src/commands/ralph/plan.ts +73 -0
- package/src/commands/ralph/progress.ts +44 -0
- package/src/commands/ralph/ralph.test.ts +673 -0
- package/src/commands/ralph/run.ts +408 -0
- package/src/commands/ralph/task/add.ts +105 -0
- package/src/commands/ralph/task/done.ts +73 -0
- package/src/commands/ralph/task/list.test.ts +48 -0
- package/src/commands/ralph/task/list.ts +110 -0
- package/src/commands/ralph/task/remove.ts +62 -0
- package/src/config/context.ts +7 -0
- package/src/config/settings.ts +155 -0
- package/src/constants.ts +3 -0
- package/src/types/journal.ts +16 -0
- package/src/utils/anthropic/types.ts +158 -0
- package/src/utils/date-utils.test.ts +96 -0
- package/src/utils/date-utils.ts +54 -0
- package/src/utils/exec.ts +8 -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/git/git.ts +25 -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 -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,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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
});
|