@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.
Files changed (45) hide show
  1. package/{LICENSE.md → LICENSE} +1 -1
  2. package/README.md +86 -85
  3. package/bin/run.ts +5 -0
  4. package/package.json +84 -64
  5. package/patches/prompts.patch +34 -0
  6. package/src/commands/base.ts +27 -0
  7. package/src/commands/config.test.ts +15 -0
  8. package/src/commands/config.ts +44 -0
  9. package/src/commands/doctor.ts +136 -0
  10. package/src/commands/gh/branch-clean.ts +116 -0
  11. package/src/commands/gh/branch.test.ts +124 -0
  12. package/src/commands/gh/branch.ts +135 -0
  13. package/src/commands/gh/pr.ts +175 -0
  14. package/src/commands/graph-template.html +1214 -0
  15. package/src/commands/graph.test.ts +176 -0
  16. package/src/commands/graph.ts +970 -0
  17. package/src/commands/install.ts +154 -0
  18. package/src/commands/journal/daily-notes.ts +70 -0
  19. package/src/commands/journal/meeting.ts +89 -0
  20. package/src/commands/journal/note.ts +89 -0
  21. package/src/commands/ralph/plan/add.ts +75 -0
  22. package/src/commands/ralph/plan/done.ts +82 -0
  23. package/src/commands/ralph/plan/list.test.ts +48 -0
  24. package/src/commands/ralph/plan/list.ts +99 -0
  25. package/src/commands/ralph/plan/remove.ts +71 -0
  26. package/src/commands/ralph/run.test.ts +521 -0
  27. package/src/commands/ralph/run.ts +345 -0
  28. package/src/commands/ralph/show.ts +88 -0
  29. package/src/config/settings.ts +136 -0
  30. package/src/lib/journal/utils.ts +399 -0
  31. package/src/lib/ralph/execution.ts +292 -0
  32. package/src/lib/ralph/formatter.ts +238 -0
  33. package/src/lib/ralph/index.ts +4 -0
  34. package/src/lib/ralph/state.ts +166 -0
  35. package/src/types/journal.ts +16 -0
  36. package/src/utils/date-utils.test.ts +97 -0
  37. package/src/utils/date-utils.ts +54 -0
  38. package/src/utils/git/gh-cli-wrapper.test.ts +14 -0
  39. package/src/utils/git/gh-cli-wrapper.ts +54 -0
  40. package/src/utils/git/git-wrapper.test.ts +26 -0
  41. package/src/utils/git/git-wrapper.ts +15 -0
  42. package/src/utils/render.test.ts +71 -0
  43. package/src/utils/render.ts +34 -0
  44. package/dist/index.d.mts +0 -1
  45. package/dist/index.mjs +0 -805
@@ -0,0 +1,116 @@
1
+ import { Flags } from "@oclif/core";
2
+ import { colors } from "consola/utils";
3
+ import consola from "consola";
4
+ import { x } from "tinyexec";
5
+
6
+ import { BaseCommand } from "../base.js";
7
+
8
+ /**
9
+ * Clean up merged branches
10
+ */
11
+ export default class BranchClean extends BaseCommand {
12
+ static override description = "Delete local branches that have been merged into main";
13
+
14
+ static override examples = [
15
+ { description: "Clean merged branches", command: "<%= config.bin %> <%= command.id %>" },
16
+ {
17
+ description: "Preview without deleting",
18
+ command: "<%= config.bin %> <%= command.id %> --dry-run",
19
+ },
20
+ { description: "Skip confirmation", command: "<%= config.bin %> <%= command.id %> --force" },
21
+ {
22
+ description: "Check against develop",
23
+ command: "<%= config.bin %> <%= command.id %> --base develop",
24
+ },
25
+ ];
26
+
27
+ static override flags = {
28
+ ...BaseCommand.baseFlags,
29
+ force: Flags.boolean({
30
+ char: "f",
31
+ description: "Skip confirmation prompt",
32
+ default: false,
33
+ }),
34
+ "dry-run": Flags.boolean({
35
+ char: "d",
36
+ description: "Preview branches without deleting",
37
+ default: false,
38
+ }),
39
+ base: Flags.string({
40
+ char: "b",
41
+ description: "Base branch to check against",
42
+ default: "main",
43
+ }),
44
+ };
45
+
46
+ async run(): Promise<void> {
47
+ const { flags } = await this.parse(BranchClean);
48
+ const baseBranch = flags.base;
49
+ const dryRun = flags["dry-run"];
50
+
51
+ // Get current branch
52
+ const currentResult = await x("git", ["branch", "--show-current"]);
53
+ const currentBranch = currentResult.stdout.trim();
54
+
55
+ // Get merged branches
56
+ const mergedResult = await x("git", ["branch", "--merged", baseBranch]);
57
+ const allMerged = mergedResult.stdout
58
+ .split("\n")
59
+ .map((b) => b.trim().replace(/^\* /, ""))
60
+ .filter((b) => b.length > 0);
61
+
62
+ // Exclude protected branches
63
+ const protectedBranches = ["main", "master", "develop", "dev", baseBranch, currentBranch];
64
+ const toDelete = allMerged.filter((b) => !protectedBranches.includes(b));
65
+
66
+ if (toDelete.length === 0) {
67
+ consola.info(colors.green("No merged branches to clean up"));
68
+ return;
69
+ }
70
+
71
+ consola.log(colors.cyan(`Found ${toDelete.length} merged branch(es):`));
72
+ for (const branch of toDelete) {
73
+ consola.log(` - ${branch}`);
74
+ }
75
+
76
+ if (dryRun) {
77
+ consola.info(colors.yellow("Dry run - no branches deleted"));
78
+ return;
79
+ }
80
+
81
+ if (!flags.force) {
82
+ const answer = await consola.prompt(`Delete ${toDelete.length} branch(es)?`, {
83
+ type: "confirm",
84
+ initial: false,
85
+ });
86
+
87
+ if (!answer) {
88
+ consola.info(colors.dim("Canceled"));
89
+ return;
90
+ }
91
+ }
92
+
93
+ // Delete branches
94
+ let deleted = 0;
95
+ let failed = 0;
96
+
97
+ for (const branch of toDelete) {
98
+ try {
99
+ await x("git", ["branch", "-d", branch]);
100
+ consola.log(colors.green(`✓ Deleted ${branch}`));
101
+ deleted++;
102
+ } catch {
103
+ consola.log(colors.red(`✗ Failed to delete ${branch}`));
104
+ failed++;
105
+ }
106
+ }
107
+
108
+ consola.log("");
109
+ if (deleted > 0) {
110
+ consola.info(colors.green(`Deleted ${deleted} branch(es)`));
111
+ }
112
+ if (failed > 0) {
113
+ consola.warn(colors.yellow(`Failed to delete ${failed} branch(es)`));
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Issue } from "../../utils/git/gh-cli-wrapper";
3
+ import GhBranch from "./branch";
4
+
5
+ const createBranchNameFromIssue = GhBranch.createBranchNameFromIssue;
6
+
7
+ describe("gh-branch", () => {
8
+ describe("createBranchNameFromIssue", () => {
9
+ it("creates branch name from issue with basic title", () => {
10
+ const issue: Issue = {
11
+ number: 4,
12
+ title: "Long Issue Title - with a lot of words and stuff ",
13
+ state: "open",
14
+ labels: [],
15
+ };
16
+ const branchName = createBranchNameFromIssue(issue);
17
+ expect(branchName).toBe("feature/4-long-issue-title-with-a-lot-of-words-and-stuff");
18
+ });
19
+
20
+ it("handles special characters in title", () => {
21
+ const issue: Issue = {
22
+ number: 123,
23
+ title: "Fix bug: @user reported $100 issue!",
24
+ state: "open",
25
+ labels: [],
26
+ };
27
+ const branchName = createBranchNameFromIssue(issue);
28
+ expect(branchName).toBe("feature/123-fix-bug-user-reported-100-issue");
29
+ });
30
+
31
+ it("handles title with only numbers", () => {
32
+ const issue: Issue = {
33
+ number: 42,
34
+ title: "123 456",
35
+ state: "open",
36
+ labels: [],
37
+ };
38
+ const branchName = createBranchNameFromIssue(issue);
39
+ expect(branchName).toBe("feature/42-123-456");
40
+ });
41
+
42
+ it("trims trailing dashes", () => {
43
+ const issue: Issue = {
44
+ number: 7,
45
+ title: "Update docs ---",
46
+ state: "open",
47
+ labels: [],
48
+ };
49
+ const branchName = createBranchNameFromIssue(issue);
50
+ expect(branchName).toBe("feature/7-update-docs");
51
+ });
52
+
53
+ it("handles unicode characters", () => {
54
+ const issue: Issue = {
55
+ number: 99,
56
+ title: "Fix für Übersetzung",
57
+ state: "open",
58
+ labels: [],
59
+ };
60
+ const branchName = createBranchNameFromIssue(issue);
61
+ expect(branchName).toBe("feature/99-fix-f-r-bersetzung");
62
+ });
63
+
64
+ it("handles empty-ish title", () => {
65
+ const issue: Issue = {
66
+ number: 1,
67
+ title: " ",
68
+ state: "open",
69
+ labels: [],
70
+ };
71
+ const branchName = createBranchNameFromIssue(issue);
72
+ expect(branchName).toBe("feature/1-");
73
+ });
74
+
75
+ it("handles title with underscores", () => {
76
+ const issue: Issue = {
77
+ number: 50,
78
+ title: "snake_case_title",
79
+ state: "open",
80
+ labels: [],
81
+ };
82
+ const branchName = createBranchNameFromIssue(issue);
83
+ expect(branchName).toBe("feature/50-snake_case_title");
84
+ });
85
+
86
+ it("handles very long titles", () => {
87
+ const issue: Issue = {
88
+ number: 200,
89
+ title: "This is a very long issue title that goes on and on with many words",
90
+ state: "open",
91
+ labels: [],
92
+ };
93
+ const branchName = createBranchNameFromIssue(issue);
94
+ expect(branchName).toBe(
95
+ "feature/200-this-is-a-very-long-issue-title-that-goes-on-and-on-with-many-words",
96
+ );
97
+ });
98
+
99
+ it("collapses multiple consecutive dashes", () => {
100
+ const issue: Issue = {
101
+ number: 15,
102
+ title: "Fix multiple spaces",
103
+ state: "open",
104
+ labels: [],
105
+ };
106
+ const branchName = createBranchNameFromIssue(issue);
107
+ expect(branchName).toBe("feature/15-fix-multiple-spaces");
108
+ });
109
+
110
+ it("handles title with brackets and parentheses", () => {
111
+ const issue: Issue = {
112
+ number: 33,
113
+ title: "[Bug] Fix (critical) issue",
114
+ state: "open",
115
+ labels: [],
116
+ };
117
+ const branchName = createBranchNameFromIssue(issue);
118
+ expect(branchName).toBe("feature/33--bug-fix-critical-issue");
119
+ });
120
+ });
121
+
122
+ // TODO: Integration tests for githubBranchCommand require module mocking
123
+ // which works differently in bun:test vs vitest
124
+ });
@@ -0,0 +1,135 @@
1
+ import { Flags } from "@oclif/core";
2
+ import prompts from "prompts";
3
+ import type { Choice } from "prompts";
4
+ import { colors } from "consola/utils";
5
+ import { Fzf } from "fzf";
6
+ import consola from "consola";
7
+
8
+ import { BaseCommand } from "../base.js";
9
+ import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
10
+ import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
11
+ import { createBranch } from "../../utils/git/git-wrapper.js";
12
+ import { getTerminalColumns, limitText, printWithHexColor } from "../../utils/render.js";
13
+
14
+ /**
15
+ * Create a git branch from a GitHub issue
16
+ */
17
+ export default class GhBranch extends BaseCommand {
18
+ static override description = "Create a git branch from a GitHub issue";
19
+
20
+ static override examples = [
21
+ { description: "Browse all open issues", command: "<%= config.bin %> <%= command.id %>" },
22
+ {
23
+ description: "Only issues assigned to me",
24
+ command: "<%= config.bin %> <%= command.id %> --assignedToMe",
25
+ },
26
+ { description: "Short flag for assigned", command: "<%= config.bin %> <%= command.id %> -a" },
27
+ ];
28
+
29
+ static override flags = {
30
+ ...BaseCommand.baseFlags,
31
+ assignedToMe: Flags.boolean({
32
+ char: "a",
33
+ description: "Only show issues assigned to me",
34
+ default: false,
35
+ }),
36
+ };
37
+
38
+ async run(): Promise<void> {
39
+ const { flags } = await this.parse(GhBranch);
40
+
41
+ // Check prerequisites
42
+ const cliInstalled = await isGithubCliInstalled();
43
+ if (!cliInstalled) {
44
+ consola.log("Github CLI not installed");
45
+ this.exit(1);
46
+ }
47
+
48
+ consola.log("Assigned to me:", flags.assignedToMe);
49
+
50
+ const currentIssues = await getIssues({ assignedToMe: flags.assignedToMe, cwd: process.cwd() });
51
+ if (currentIssues.length === 0) {
52
+ consola.log(colors.yellow("No issues found, check assignments"));
53
+ this.exit(1);
54
+ } else {
55
+ consola.log(colors.green(`${currentIssues.length} Issues found assigned to you`));
56
+ }
57
+
58
+ // Format table with nice labels
59
+ let lineMaxLength = getTerminalColumns();
60
+ const longestNumber = Math.max(...currentIssues.map((i) => i.number.toString().length));
61
+ const longestLabels = Math.max(
62
+ ...currentIssues.map((i) => i.labels.map((x) => x.name).join(", ").length),
63
+ );
64
+
65
+ lineMaxLength = lineMaxLength > 130 ? 130 : lineMaxLength;
66
+ const descriptionLength = lineMaxLength - longestNumber - longestLabels - 15;
67
+
68
+ const choices: Choice[] = currentIssues.map((i) => {
69
+ const labelText = i.labels
70
+ .map((l) => printWithHexColor({ msg: l.name, hex: l.color }))
71
+ .join(", ");
72
+ const labelTextNoColor = i.labels.map((l) => l.name).join(", ");
73
+ const labelStartpad = longestLabels - labelTextNoColor.length;
74
+ return {
75
+ title: i.number.toString(),
76
+ value: i.number,
77
+ description: `${limitText(i.title, descriptionLength).padEnd(descriptionLength)} ${"".padStart(labelStartpad)}${labelText}`,
78
+ } as Choice;
79
+ });
80
+ choices.push({ title: "Cancel", value: "cancel" });
81
+
82
+ const fzf = new Fzf(choices, {
83
+ selector: (item) => `${item.value} ${item.description}`,
84
+ casing: "case-insensitive",
85
+ });
86
+
87
+ try {
88
+ const result = await prompts(
89
+ {
90
+ name: "issueNumber",
91
+ message: "Github issue to create branch for:",
92
+ type: "autocomplete",
93
+ choices,
94
+ async suggest(input: string, choices: Choice[]) {
95
+ consola.log(input);
96
+ const results = fzf.find(input);
97
+ return results.map((r) => choices.find((c) => c.value === r.item.value));
98
+ },
99
+ },
100
+ {
101
+ onCancel: () => {
102
+ consola.info(colors.dim("Canceled"));
103
+ this.exit(0);
104
+ },
105
+ },
106
+ );
107
+
108
+ if (result.issueNumber === "cancel") {
109
+ consola.log(colors.dim("Canceled"));
110
+ this.exit(0);
111
+ }
112
+
113
+ const selectedIssue = currentIssues.find((i) => i.number === result.issueNumber)!;
114
+ consola.log(
115
+ `Selected issue ${colors.green(selectedIssue.number)} - ${colors.green(selectedIssue.title)}`,
116
+ );
117
+
118
+ const branchName = GhBranch.createBranchNameFromIssue(selectedIssue);
119
+ createBranch({ branchName });
120
+ } catch {
121
+ this.exit(1);
122
+ }
123
+ }
124
+
125
+ static createBranchNameFromIssue(selectedIssue: Issue): string {
126
+ let slug = selectedIssue.title.toLowerCase();
127
+ slug = slug.trim();
128
+ slug = slug.replaceAll(" ", "-");
129
+ slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
130
+ slug = slug.replace(/-+/g, "-");
131
+ slug = slug.replace(/-+$/, "");
132
+
133
+ return `feature/${selectedIssue.number}-${slug}`;
134
+ }
135
+ }
@@ -0,0 +1,175 @@
1
+ import { Flags } from "@oclif/core";
2
+ import { x } from "tinyexec";
3
+ import consola from "consola";
4
+ import { colors } from "consola/utils";
5
+
6
+ import { BaseCommand } from "../base.js";
7
+ import { isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
8
+
9
+ /**
10
+ * Create a pull request from the current branch
11
+ * Note: Uses tinyexec which is safe (execFile-based, no shell injection)
12
+ */
13
+ export default class Pr extends BaseCommand {
14
+ static override description = "Create a pull request from the current branch";
15
+
16
+ static override examples = [
17
+ {
18
+ description: "Create PR from current branch",
19
+ command: "<%= config.bin %> <%= command.id %>",
20
+ },
21
+ { description: "Create draft PR", command: "<%= config.bin %> <%= command.id %> --draft" },
22
+ {
23
+ description: "PR against develop branch",
24
+ command: "<%= config.bin %> <%= command.id %> --base develop",
25
+ },
26
+ ];
27
+
28
+ static override flags = {
29
+ ...BaseCommand.baseFlags,
30
+ draft: Flags.boolean({
31
+ char: "D",
32
+ description: "Create as draft PR",
33
+ default: false,
34
+ }),
35
+ base: Flags.string({
36
+ char: "b",
37
+ description: "Base branch for the PR",
38
+ default: "main",
39
+ }),
40
+ yes: Flags.boolean({
41
+ char: "y",
42
+ description: "Skip confirmation prompt",
43
+ default: false,
44
+ }),
45
+ };
46
+
47
+ async run(): Promise<void> {
48
+ const { flags } = await this.parse(Pr);
49
+
50
+ // Check prerequisites
51
+ const cliInstalled = await isGithubCliInstalled();
52
+ if (!cliInstalled) {
53
+ consola.error("GitHub CLI not installed");
54
+ this.exit(1);
55
+ }
56
+
57
+ // Get current branch
58
+ const branchResult = await x("git", ["branch", "--show-current"]);
59
+ const currentBranch = branchResult.stdout.trim();
60
+
61
+ if (!currentBranch) {
62
+ consola.error("Not on a branch (detached HEAD?)");
63
+ this.exit(1);
64
+ }
65
+
66
+ if (currentBranch === flags.base) {
67
+ consola.error(`Already on base branch ${flags.base}`);
68
+ this.exit(1);
69
+ }
70
+
71
+ consola.info(`Current branch: ${colors.cyan(currentBranch)}`);
72
+ consola.info(`Base branch: ${colors.cyan(flags.base)}`);
73
+
74
+ // Get commits between base and current branch
75
+ const logResult = await x("git", ["log", `${flags.base}..HEAD`, "--pretty=format:%s"]);
76
+
77
+ const commits = logResult.stdout.trim().split("\n").filter(Boolean);
78
+
79
+ if (commits.length === 0) {
80
+ consola.error(`No commits between ${flags.base} and ${currentBranch}`);
81
+ this.exit(1);
82
+ }
83
+
84
+ consola.info(`Found ${colors.green(commits.length.toString())} commits`);
85
+
86
+ // Generate PR title and body
87
+ const { title, body } = this.generatePrContent(currentBranch, commits);
88
+
89
+ consola.box({
90
+ title: "PR Preview",
91
+ message: `Title: ${title}\n\n${body}`,
92
+ });
93
+
94
+ // Confirm unless --yes
95
+ if (!flags.yes) {
96
+ const confirmed = await consola.prompt("Create this PR?", {
97
+ type: "confirm",
98
+ initial: true,
99
+ });
100
+
101
+ if (!confirmed) {
102
+ consola.info(colors.dim("Canceled"));
103
+ this.exit(0);
104
+ }
105
+ }
106
+
107
+ // Push branch if needed
108
+ const statusResult = await x("git", ["status", "-sb"]);
109
+ const needsPush = !statusResult.stdout.includes("origin/");
110
+
111
+ if (needsPush) {
112
+ consola.info("Pushing branch to remote...");
113
+ await x("git", ["push", "-u", "origin", currentBranch]);
114
+ }
115
+
116
+ // Create PR
117
+ const prArgs = ["pr", "create", "--title", title, "--body", body, "--base", flags.base];
118
+
119
+ if (flags.draft) {
120
+ prArgs.push("--draft");
121
+ }
122
+
123
+ const prResult = await x("gh", prArgs);
124
+ const prUrl = prResult.stdout.trim();
125
+
126
+ consola.success(`PR created: ${colors.cyan(prUrl)}`);
127
+ }
128
+
129
+ private generatePrContent(branch: string, commits: string[]): { title: string; body: string } {
130
+ // Extract issue number from branch name if present (e.g., feature/123-some-feature)
131
+ const issueMatch = branch.match(/(\d+)/);
132
+ const issueNumber = issueMatch ? issueMatch[1] : null;
133
+
134
+ // Generate title from first commit or branch name
135
+ let title: string;
136
+ if (commits.length === 1) {
137
+ title = commits[0];
138
+ } else {
139
+ // Use branch name, cleaned up
140
+ title = branch
141
+ .replace(/^(feature|fix|bugfix|hotfix|chore|refactor)\//, "")
142
+ .replace(/^\d+-/, "")
143
+ .replace(/-/g, " ")
144
+ .replace(/\b\w/g, (c) => c.toUpperCase());
145
+ }
146
+
147
+ // Generate body
148
+ const lines: string[] = ["## Summary", ""];
149
+
150
+ if (commits.length === 1) {
151
+ lines.push(`- ${commits[0]}`);
152
+ } else {
153
+ for (const commit of commits.slice(0, 10)) {
154
+ lines.push(`- ${commit}`);
155
+ }
156
+ if (commits.length > 10) {
157
+ lines.push(`- ... and ${commits.length - 10} more commits`);
158
+ }
159
+ }
160
+
161
+ lines.push("");
162
+
163
+ if (issueNumber) {
164
+ lines.push(`Closes #${issueNumber}`);
165
+ lines.push("");
166
+ }
167
+
168
+ lines.push("## Test plan");
169
+ lines.push("");
170
+ lines.push("- [ ] Tests pass");
171
+ lines.push("- [ ] Manual testing");
172
+
173
+ return { title, body: lines.join("\n") };
174
+ }
175
+ }