@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,399 @@
1
+ import { exec } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import consola from "consola";
6
+ import { colors } from "consola/utils";
7
+ import { DateTime } from "luxon";
8
+ import { formatDate, getMondayOfWeek, getWeekInfo } from "../../utils/date-utils.js";
9
+ import type { JournalSettings } from "../../config/settings.js";
10
+ import { JOURNAL_TYPES } from "../../types/journal.js";
11
+ import type { JournalType } from "../../types/journal.js";
12
+
13
+ // Default template file names
14
+ const TEMPLATE_FILES = {
15
+ dailyNotes: "daily-notes.md",
16
+ meeting: "meeting.md",
17
+ note: "note.md",
18
+ } as const;
19
+
20
+ const execAsync = promisify(exec);
21
+
22
+ /**
23
+ * Create journal directory if it doesn't exist
24
+ */
25
+ export function ensureDirectoryExists(folderPath: string): void {
26
+ if (!existsSync(folderPath)) {
27
+ consola.info(`Creating journal directory: ${colors.cyan(folderPath)}`);
28
+ mkdirSync(folderPath, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Load template from external file or return null if not found
34
+ */
35
+ export function loadTemplate(templateDir: string, templateFile: string): string | null {
36
+ const templatePath = path.join(templateDir, templateFile);
37
+ if (existsSync(templatePath)) {
38
+ return readFileSync(templatePath, "utf8");
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Get default template content for initial setup
45
+ */
46
+ function getDefaultDailyNotesTemplate(): string {
47
+ return `# Journal for Week {monday:yyyy-MM-dd}
48
+
49
+ ## {monday:yyyy-MM-dd} Monday
50
+
51
+ ## {tuesday:yyyy-MM-dd} Tuesday
52
+
53
+ ## {wednesday:yyyy-MM-dd} Wednesday
54
+
55
+ ## {thursday:yyyy-MM-dd} Thursday
56
+
57
+ ## {friday:yyyy-MM-dd} Friday
58
+ `;
59
+ }
60
+
61
+ function getDefaultMeetingTemplate(): string {
62
+ return `# Meeting: {title}
63
+
64
+ **Date:** {date}
65
+ **Time:** {time}
66
+ **Attendees:**
67
+
68
+ ## Agenda
69
+
70
+ -
71
+
72
+ ## Notes
73
+
74
+ ## Action Items
75
+
76
+ - [ ]
77
+
78
+ ## Follow-up
79
+ `;
80
+ }
81
+
82
+ function getDefaultNoteTemplate(): string {
83
+ return `# {title}
84
+
85
+ **Created:** {date} {time}
86
+
87
+ ## Summary
88
+
89
+ ## Details
90
+
91
+ ## References
92
+ `;
93
+ }
94
+
95
+ /**
96
+ * Initialize template directory with default templates (first run)
97
+ */
98
+ export function ensureTemplatesExist(templateDir: string): void {
99
+ ensureDirectoryExists(templateDir);
100
+
101
+ const templates = [
102
+ { file: TEMPLATE_FILES.dailyNotes, content: getDefaultDailyNotesTemplate() },
103
+ { file: TEMPLATE_FILES.meeting, content: getDefaultMeetingTemplate() },
104
+ { file: TEMPLATE_FILES.note, content: getDefaultNoteTemplate() },
105
+ ];
106
+
107
+ for (const { file, content } of templates) {
108
+ const templatePath = path.join(templateDir, file);
109
+ if (!existsSync(templatePath)) {
110
+ writeFileSync(templatePath, content, "utf8");
111
+ consola.info(`Created default template: ${colors.cyan(templatePath)}`);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Render template with variables
118
+ */
119
+ function renderTemplate(template: string, vars: Record<string, string>): string {
120
+ return template.replace(/\{([^}]+)\}/g, (match, key) => {
121
+ return vars[key] ?? match;
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Create initial journal content with date header
127
+ */
128
+ export function createJournalContent({
129
+ mondayDate,
130
+ templateDir,
131
+ }: {
132
+ mondayDate: Date;
133
+ templateDir?: string;
134
+ }): string {
135
+ const weekInfo = getWeekInfo(mondayDate);
136
+
137
+ // Try external template first
138
+ if (templateDir) {
139
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.dailyNotes);
140
+ if (externalTemplate) {
141
+ return renderTemplate(externalTemplate, {
142
+ "monday:yyyy-MM-dd": formatDate(weekInfo.mondayDate),
143
+ "tuesday:yyyy-MM-dd": formatDate(weekInfo.tuesdayDate),
144
+ "wednesday:yyyy-MM-dd": formatDate(weekInfo.wednesdayDate),
145
+ "thursday:yyyy-MM-dd": formatDate(weekInfo.thursdayDate),
146
+ "friday:yyyy-MM-dd": formatDate(weekInfo.fridayDate),
147
+ });
148
+ }
149
+ }
150
+
151
+ // Fallback to hardcoded template
152
+ const content = [`# Journal for Week ${formatDate(mondayDate)}`];
153
+ content.push(``);
154
+ content.push(`## ${formatDate(weekInfo.mondayDate)} Monday`);
155
+ content.push(``);
156
+ content.push(`## ${formatDate(weekInfo.tuesdayDate)} Tuesday`);
157
+ content.push(``);
158
+ content.push(`## ${formatDate(weekInfo.wednesdayDate)} Wednesday`);
159
+ content.push(``);
160
+ content.push(`## ${formatDate(weekInfo.thursdayDate)} Thursday`);
161
+ content.push(``);
162
+ content.push(`## ${formatDate(weekInfo.fridayDate)} Friday`);
163
+ content.push(``);
164
+
165
+ return content.join("\n");
166
+ }
167
+
168
+ /**
169
+ * Create meeting template content
170
+ */
171
+ export function createMeetingContent({
172
+ title,
173
+ date,
174
+ templateDir,
175
+ }: {
176
+ title?: string;
177
+ date: Date;
178
+ templateDir?: string;
179
+ }): string {
180
+ const dateStr = formatDate(date);
181
+ const timeStr = date.toLocaleTimeString("en-US", {
182
+ hour12: false,
183
+ hour: "2-digit",
184
+ minute: "2-digit",
185
+ });
186
+ const meetingTitle = title || "Meeting";
187
+
188
+ // Try external template first
189
+ if (templateDir) {
190
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.meeting);
191
+ if (externalTemplate) {
192
+ return renderTemplate(externalTemplate, {
193
+ title: meetingTitle,
194
+ date: dateStr,
195
+ time: timeStr,
196
+ });
197
+ }
198
+ }
199
+
200
+ // Fallback to hardcoded template
201
+ const content = [`# Meeting: ${meetingTitle}`];
202
+ content.push(``);
203
+ content.push(`**Date:** ${dateStr}`);
204
+ content.push(`**Time:** ${timeStr}`);
205
+ content.push(`**Attendees:** `);
206
+ content.push(``);
207
+ content.push(`## Agenda`);
208
+ content.push(``);
209
+ content.push(`- `);
210
+ content.push(``);
211
+ content.push(`## Notes`);
212
+ content.push(``);
213
+ content.push(`## Action Items`);
214
+ content.push(``);
215
+ content.push(`- [ ] `);
216
+ content.push(``);
217
+ content.push(`## Follow-up`);
218
+ content.push(``);
219
+
220
+ return content.join("\n");
221
+ }
222
+
223
+ /**
224
+ * Create note template content
225
+ */
226
+ export function createNoteContent({
227
+ title,
228
+ date,
229
+ templateDir,
230
+ }: {
231
+ title?: string;
232
+ date: Date;
233
+ templateDir?: string;
234
+ }): string {
235
+ const dateStr = formatDate(date);
236
+ const timeStr = date.toLocaleTimeString("en-US", {
237
+ hour12: false,
238
+ hour: "2-digit",
239
+ minute: "2-digit",
240
+ });
241
+ const noteTitle = title || "Note";
242
+
243
+ // Try external template first
244
+ if (templateDir) {
245
+ const externalTemplate = loadTemplate(templateDir, TEMPLATE_FILES.note);
246
+ if (externalTemplate) {
247
+ return renderTemplate(externalTemplate, {
248
+ title: noteTitle,
249
+ date: dateStr,
250
+ time: timeStr,
251
+ });
252
+ }
253
+ }
254
+
255
+ // Fallback to hardcoded template
256
+ const content = [`# ${noteTitle}`];
257
+ content.push(``);
258
+ content.push(`**Created:** ${dateStr} ${timeStr}`);
259
+ content.push(``);
260
+ content.push(`## Summary`);
261
+ content.push(``);
262
+ content.push(`## Details`);
263
+ content.push(``);
264
+ content.push(`## References`);
265
+ content.push(``);
266
+
267
+ return content.join("\n");
268
+ }
269
+
270
+ /**
271
+ * Open file in default editor with folder context
272
+ */
273
+ export async function openInEditor({
274
+ editor,
275
+ filePath,
276
+ folderPath,
277
+ }: {
278
+ editor: string;
279
+ filePath: string;
280
+ folderPath?: string;
281
+ }): Promise<void> {
282
+ try {
283
+ if (folderPath) {
284
+ // Open both folder and file - this works with VS Code and similar editors
285
+ // the purpose is to open the folder context for better navigation
286
+ await execAsync(`"${editor}" "${folderPath}" "${filePath}"`);
287
+ } else {
288
+ await execAsync(`"${editor}" "${filePath}"`);
289
+ }
290
+ } catch (ex) {
291
+ consola.warn(
292
+ `Could not open in editor : '${editor}'. Modify your editor in the config: examples include 'code', 'code-insiders', etc...`,
293
+ ex,
294
+ );
295
+ }
296
+ }
297
+
298
+ export function resolvePathTemplate(
299
+ template: string,
300
+ title: string,
301
+ date: Date,
302
+ mondayDate: Date,
303
+ ): string {
304
+ const dateTime = DateTime.fromJSDate(date, { zone: "utc" });
305
+
306
+ // Replace Luxon format tokens wrapped in curly braces
307
+ return template.replace(/\{([^}]+)\}/g, (match, token) => {
308
+ try {
309
+ if (token === "title") {
310
+ return title.toLowerCase().replace(/\s+/g, "-");
311
+ }
312
+
313
+ if (token.startsWith("monday:")) {
314
+ const mondayToken = token.substring(7); // Remove 'monday:' prefix
315
+ const mondayDateTime = DateTime.fromJSDate(mondayDate, { zone: "utc" });
316
+ return mondayDateTime.toFormat(mondayToken);
317
+ }
318
+
319
+ const result = dateTime.toFormat(token);
320
+ // Check if the result contains suspicious patterns that indicate invalid tokens
321
+ // This is a heuristic to detect when Luxon produces garbage output for invalid tokens
322
+ const isLikelyInvalid =
323
+ token.includes("invalid") ||
324
+ result.length > 20 || // Very long results are likely garbage
325
+ (result.length > token.length * 2 && /\d{10,}/.test(result)) || // Contains very long numbers
326
+ result.includes("UTC");
327
+
328
+ if (isLikelyInvalid) {
329
+ consola.warn(`Invalid date format token: ${token}`);
330
+ return match;
331
+ }
332
+ return result;
333
+ } catch (error) {
334
+ consola.warn(`Invalid date format token: ${token}`);
335
+ return match; // Return original token if format is invalid
336
+ }
337
+ });
338
+ }
339
+
340
+ interface GenerateJournalFileResult {
341
+ fullPath: string;
342
+ mondayDate: Date;
343
+ currentDate: Date;
344
+ }
345
+
346
+ interface GenerateJournalFileParams {
347
+ date: Date;
348
+ type: JournalType;
349
+ title: string;
350
+ journalSettings: JournalSettings;
351
+ }
352
+
353
+ /**
354
+ * Generate journal file info for different types using individual path templates
355
+ */
356
+ export function generateJournalFileInfoByType({
357
+ journalSettings,
358
+ date = new Date(),
359
+ type,
360
+ title,
361
+ }: GenerateJournalFileParams): GenerateJournalFileResult {
362
+ const currentDate = new Date(date);
363
+
364
+ let templatePath: string = "";
365
+ let mondayDate: Date = getMondayOfWeek(currentDate);
366
+
367
+ switch (type) {
368
+ case JOURNAL_TYPES.DAILY_NOTES: {
369
+ const monday = getMondayOfWeek(currentDate);
370
+ templatePath = journalSettings.dailyPathTemplate;
371
+ mondayDate = monday;
372
+ break;
373
+ }
374
+ case JOURNAL_TYPES.MEETING: {
375
+ templatePath = journalSettings.meetingPathTemplate;
376
+ mondayDate = currentDate;
377
+ break;
378
+ }
379
+ case JOURNAL_TYPES.NOTE: {
380
+ templatePath = journalSettings.notePathTemplate;
381
+ mondayDate = currentDate;
382
+ break;
383
+ }
384
+ default:
385
+ throw new Error(`Unknown JournalType: ${type}`);
386
+ }
387
+
388
+ // Resolve the path template and extract directory structure
389
+ const resolvedPath = resolvePathTemplate(templatePath, title, currentDate, mondayDate);
390
+
391
+ // Join baseFolder with the resolved path
392
+ const fullPath = path.join(journalSettings.baseFolder, resolvedPath);
393
+
394
+ return {
395
+ currentDate: currentDate,
396
+ fullPath: fullPath,
397
+ mondayDate,
398
+ } satisfies GenerateJournalFileResult;
399
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tests for observe graph command --days filtering
3
+ */
4
+ import { describe, it, expect } from "vitest";
5
+ import { calculateCutoffMs, filterByDays } from "./graph.js";
6
+
7
+ describe("observe graph --days filtering", () => {
8
+ describe("calculateCutoffMs", () => {
9
+ it("returns 0 when days <= 0", () => {
10
+ expect(calculateCutoffMs(0)).toBe(0);
11
+ expect(calculateCutoffMs(-1)).toBe(0);
12
+ });
13
+
14
+ it("returns cutoff timestamp for positive days", () => {
15
+ const now = Date.now();
16
+ const cutoff = calculateCutoffMs(7);
17
+ // Should be roughly 7 days ago (within 100ms tolerance for test execution time)
18
+ const expected = now - 7 * 24 * 60 * 60 * 1000;
19
+ expect(Math.abs(cutoff - expected)).toBeLessThan(100);
20
+ });
21
+ });
22
+
23
+ describe("filterByDays", () => {
24
+ it("filters sessions older than N days when days > 0", () => {
25
+ const now = Date.now();
26
+ const sessions = [
27
+ { mtime: now - 1 * 24 * 60 * 60 * 1000 }, // 1 day ago - included
28
+ { mtime: now - 2 * 24 * 60 * 60 * 1000 }, // 2 days ago - included
29
+ { mtime: now - 5 * 24 * 60 * 60 * 1000 }, // 5 days ago - excluded
30
+ { mtime: now - 10 * 24 * 60 * 60 * 1000 }, // 10 days ago - excluded
31
+ ];
32
+
33
+ const filtered = filterByDays(sessions, 3);
34
+ expect(filtered).toHaveLength(2);
35
+ });
36
+
37
+ it("returns all sessions when days=0", () => {
38
+ const now = Date.now();
39
+ const sessions = [
40
+ { mtime: now - 1 * 24 * 60 * 60 * 1000 },
41
+ { mtime: now - 100 * 24 * 60 * 60 * 1000 },
42
+ { mtime: now - 365 * 24 * 60 * 60 * 1000 },
43
+ ];
44
+
45
+ const filtered = filterByDays(sessions, 0);
46
+ expect(filtered).toHaveLength(3);
47
+ });
48
+
49
+ it("default 7 days filters correctly", () => {
50
+ const now = Date.now();
51
+ const sessions = [
52
+ { mtime: now - 1 * 24 * 60 * 60 * 1000 }, // 1 day ago - included
53
+ { mtime: now - 6 * 24 * 60 * 60 * 1000 }, // 6 days ago - included
54
+ { mtime: now - 8 * 24 * 60 * 60 * 1000 }, // 8 days ago - excluded
55
+ { mtime: now - 30 * 24 * 60 * 60 * 1000 }, // 30 days ago - excluded
56
+ ];
57
+
58
+ const filtered = filterByDays(sessions, 7);
59
+ expect(filtered).toHaveLength(2);
60
+ // Verify the right sessions were kept
61
+ expect(filtered[0].mtime).toBeGreaterThan(now - 7 * 24 * 60 * 60 * 1000);
62
+ expect(filtered[1].mtime).toBeGreaterThan(now - 7 * 24 * 60 * 60 * 1000);
63
+ });
64
+
65
+ it("--days 1 filters to today only", () => {
66
+ const now = Date.now();
67
+ const sessions = [
68
+ { mtime: now - 12 * 60 * 60 * 1000 }, // 12 hours ago - included
69
+ { mtime: now - 25 * 60 * 60 * 1000 }, // 25 hours ago - excluded
70
+ ];
71
+
72
+ const filtered = filterByDays(sessions, 1);
73
+ expect(filtered).toHaveLength(1);
74
+ });
75
+
76
+ it("preserves additional properties on items", () => {
77
+ const now = Date.now();
78
+ const sessions = [
79
+ { mtime: now, sessionId: "abc", tokens: 100 },
80
+ { mtime: now - 10 * 24 * 60 * 60 * 1000, sessionId: "old", tokens: 50 },
81
+ ];
82
+
83
+ const filtered = filterByDays(sessions, 7);
84
+ expect(filtered).toHaveLength(1);
85
+ expect(filtered[0].sessionId).toBe("abc");
86
+ expect(filtered[0].tokens).toBe(100);
87
+ });
88
+ });
89
+ });