@towles/tool 0.0.20 → 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 -805
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { x } from "tinyexec";
4
+ import pc from "picocolors";
5
+ import { BaseCommand } from "./base.js";
6
+
7
+ interface CheckResult {
8
+ name: string;
9
+ version: string | null;
10
+ ok: boolean;
11
+ warning?: string;
12
+ }
13
+
14
+ /**
15
+ * Check system dependencies and environment
16
+ */
17
+ export default class Doctor extends BaseCommand {
18
+ static override description = "Check system dependencies and environment";
19
+
20
+ static override examples = ["<%= config.bin %> <%= command.id %>"];
21
+
22
+ async run(): Promise<void> {
23
+ await this.parse(Doctor);
24
+
25
+ this.log("Checking dependencies...\n");
26
+
27
+ const checks: CheckResult[] = await Promise.all([
28
+ this.checkCommand("git", ["--version"], /git version ([\d.]+)/),
29
+ this.checkCommand("gh", ["--version"], /gh version ([\d.]+)/),
30
+ this.checkCommand("node", ["--version"], /v?([\d.]+)/),
31
+ this.checkCommand("bun", ["--version"], /([\d.]+)/),
32
+ ]);
33
+
34
+ // Display results
35
+ for (const check of checks) {
36
+ const icon = check.ok ? pc.green("✓") : pc.red("✗");
37
+ const version = check.version ?? "not found";
38
+ this.log(`${icon} ${check.name}: ${version}`);
39
+ if (check.warning) {
40
+ this.log(` ${pc.yellow("⚠")} ${check.warning}`);
41
+ }
42
+ }
43
+
44
+ // Check gh auth
45
+ this.log("");
46
+ const ghAuth = await this.checkGhAuth();
47
+ const authIcon = ghAuth.ok ? pc.green("✓") : pc.yellow("⚠");
48
+ this.log(`${authIcon} gh auth: ${ghAuth.ok ? "authenticated" : "not authenticated"}`);
49
+ if (!ghAuth.ok) {
50
+ this.log(` ${pc.dim("Run: gh auth login")}`);
51
+ }
52
+
53
+ // Node version check
54
+ const nodeCheck = checks.find((c) => c.name === "node");
55
+ if (nodeCheck?.version) {
56
+ const major = Number.parseInt(nodeCheck.version.split(".")[0], 10);
57
+ if (major < 18) {
58
+ this.log("");
59
+ this.log(`${pc.yellow("⚠")} Node.js 18+ recommended (found ${nodeCheck.version})`);
60
+ }
61
+ }
62
+
63
+ // Check ralph files in .gitignore
64
+ this.log("");
65
+ const gitignoreCheck = this.checkRalphGitignore();
66
+ const gitignoreIcon = gitignoreCheck.ok ? pc.green("✓") : pc.yellow("⚠");
67
+ this.log(
68
+ `${gitignoreIcon} .gitignore: ${gitignoreCheck.ok ? "ralph-* excluded" : "ralph-* NOT excluded"}`,
69
+ );
70
+ if (!gitignoreCheck.ok) {
71
+ this.log(` ${pc.dim('Add "ralph-*" to .gitignore to exclude local ralph state files')}`);
72
+ }
73
+
74
+ // Summary
75
+ const allOk = checks.every((c) => c.ok) && ghAuth.ok && gitignoreCheck.ok;
76
+ this.log("");
77
+ if (allOk) {
78
+ this.log(pc.green("All checks passed!"));
79
+ } else {
80
+ this.log(pc.yellow("Some checks failed. See above for details."));
81
+ }
82
+ }
83
+
84
+ private async checkCommand(
85
+ name: string,
86
+ args: string[],
87
+ versionPattern: RegExp,
88
+ ): Promise<CheckResult> {
89
+ try {
90
+ // tinyexec is safe - uses execFile internally, no shell injection risk
91
+ const result = await x(name, args);
92
+ const output = result.stdout + result.stderr;
93
+ const match = output.match(versionPattern);
94
+ return {
95
+ name,
96
+ version: match?.[1] ?? output.trim().slice(0, 20),
97
+ ok: true,
98
+ };
99
+ } catch {
100
+ return { name, version: null, ok: false };
101
+ }
102
+ }
103
+
104
+ private async checkGhAuth(): Promise<{ ok: boolean }> {
105
+ try {
106
+ // tinyexec is safe - uses execFile internally, no shell injection risk
107
+ const result = await x("gh", ["auth", "status"]);
108
+ return { ok: result.exitCode === 0 };
109
+ } catch {
110
+ return { ok: false };
111
+ }
112
+ }
113
+
114
+ private checkRalphGitignore(): { ok: boolean } {
115
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
116
+ try {
117
+ if (!fs.existsSync(gitignorePath)) {
118
+ return { ok: false };
119
+ }
120
+ const content = fs.readFileSync(gitignorePath, "utf-8");
121
+ // Check for ralph-* pattern or specific ralph files
122
+ const hasRalphPattern = content.split("\n").some((line) => {
123
+ const trimmed = line.trim();
124
+ return (
125
+ trimmed === "ralph-*" || trimmed === "ralph-*.json" || trimmed === "ralph-state.json"
126
+ );
127
+ });
128
+ return { ok: hasRalphPattern };
129
+ } catch {
130
+ return { ok: false };
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,110 @@
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
+ "<%= config.bin %> gh branch-clean",
16
+ "<%= config.bin %> gh branch-clean --dry-run",
17
+ "<%= config.bin %> gh branch-clean --force",
18
+ "<%= config.bin %> gh branch-clean --base develop",
19
+ ];
20
+
21
+ static override flags = {
22
+ ...BaseCommand.baseFlags,
23
+ force: Flags.boolean({
24
+ char: "f",
25
+ description: "Skip confirmation prompt",
26
+ default: false,
27
+ }),
28
+ "dry-run": Flags.boolean({
29
+ char: "d",
30
+ description: "Preview branches without deleting",
31
+ default: false,
32
+ }),
33
+ base: Flags.string({
34
+ char: "b",
35
+ description: "Base branch to check against",
36
+ default: "main",
37
+ }),
38
+ };
39
+
40
+ async run(): Promise<void> {
41
+ const { flags } = await this.parse(BranchClean);
42
+ const baseBranch = flags.base;
43
+ const dryRun = flags["dry-run"];
44
+
45
+ // Get current branch
46
+ const currentResult = await x("git", ["branch", "--show-current"]);
47
+ const currentBranch = currentResult.stdout.trim();
48
+
49
+ // Get merged branches
50
+ const mergedResult = await x("git", ["branch", "--merged", baseBranch]);
51
+ const allMerged = mergedResult.stdout
52
+ .split("\n")
53
+ .map((b) => b.trim().replace(/^\* /, ""))
54
+ .filter((b) => b.length > 0);
55
+
56
+ // Exclude protected branches
57
+ const protectedBranches = ["main", "master", "develop", "dev", baseBranch, currentBranch];
58
+ const toDelete = allMerged.filter((b) => !protectedBranches.includes(b));
59
+
60
+ if (toDelete.length === 0) {
61
+ consola.info(colors.green("No merged branches to clean up"));
62
+ return;
63
+ }
64
+
65
+ consola.log(colors.cyan(`Found ${toDelete.length} merged branch(es):`));
66
+ for (const branch of toDelete) {
67
+ consola.log(` - ${branch}`);
68
+ }
69
+
70
+ if (dryRun) {
71
+ consola.info(colors.yellow("Dry run - no branches deleted"));
72
+ return;
73
+ }
74
+
75
+ if (!flags.force) {
76
+ const answer = await consola.prompt(`Delete ${toDelete.length} branch(es)?`, {
77
+ type: "confirm",
78
+ initial: false,
79
+ });
80
+
81
+ if (!answer) {
82
+ consola.info(colors.dim("Canceled"));
83
+ return;
84
+ }
85
+ }
86
+
87
+ // Delete branches
88
+ let deleted = 0;
89
+ let failed = 0;
90
+
91
+ for (const branch of toDelete) {
92
+ try {
93
+ await x("git", ["branch", "-d", branch]);
94
+ consola.log(colors.green(`✓ Deleted ${branch}`));
95
+ deleted++;
96
+ } catch {
97
+ consola.log(colors.red(`✗ Failed to delete ${branch}`));
98
+ failed++;
99
+ }
100
+ }
101
+
102
+ consola.log("");
103
+ if (deleted > 0) {
104
+ consola.info(colors.green(`Deleted ${deleted} branch(es)`));
105
+ }
106
+ if (failed > 0) {
107
+ consola.warn(colors.yellow(`Failed to delete ${failed} branch(es)`));
108
+ }
109
+ }
110
+ }
@@ -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,132 @@
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
+ "<%= config.bin %> gh branch",
22
+ "<%= config.bin %> gh branch --assignedToMe",
23
+ "<%= config.bin %> gh branch -a",
24
+ ];
25
+
26
+ static override flags = {
27
+ ...BaseCommand.baseFlags,
28
+ assignedToMe: Flags.boolean({
29
+ char: "a",
30
+ description: "Only show issues assigned to me",
31
+ default: false,
32
+ }),
33
+ };
34
+
35
+ async run(): Promise<void> {
36
+ const { flags } = await this.parse(GhBranch);
37
+
38
+ // Check prerequisites
39
+ const cliInstalled = await isGithubCliInstalled();
40
+ if (!cliInstalled) {
41
+ consola.log("Github CLI not installed");
42
+ this.exit(1);
43
+ }
44
+
45
+ consola.log("Assigned to me:", flags.assignedToMe);
46
+
47
+ const currentIssues = await getIssues({ assignedToMe: flags.assignedToMe, cwd: process.cwd() });
48
+ if (currentIssues.length === 0) {
49
+ consola.log(colors.yellow("No issues found, check assignments"));
50
+ this.exit(1);
51
+ } else {
52
+ consola.log(colors.green(`${currentIssues.length} Issues found assigned to you`));
53
+ }
54
+
55
+ // Format table with nice labels
56
+ let lineMaxLength = getTerminalColumns();
57
+ const longestNumber = Math.max(...currentIssues.map((i) => i.number.toString().length));
58
+ const longestLabels = Math.max(
59
+ ...currentIssues.map((i) => i.labels.map((x) => x.name).join(", ").length),
60
+ );
61
+
62
+ lineMaxLength = lineMaxLength > 130 ? 130 : lineMaxLength;
63
+ const descriptionLength = lineMaxLength - longestNumber - longestLabels - 15;
64
+
65
+ const choices: Choice[] = currentIssues.map((i) => {
66
+ const labelText = i.labels
67
+ .map((l) => printWithHexColor({ msg: l.name, hex: l.color }))
68
+ .join(", ");
69
+ const labelTextNoColor = i.labels.map((l) => l.name).join(", ");
70
+ const labelStartpad = longestLabels - labelTextNoColor.length;
71
+ return {
72
+ title: i.number.toString(),
73
+ value: i.number,
74
+ description: `${limitText(i.title, descriptionLength).padEnd(descriptionLength)} ${"".padStart(labelStartpad)}${labelText}`,
75
+ } as Choice;
76
+ });
77
+ choices.push({ title: "Cancel", value: "cancel" });
78
+
79
+ const fzf = new Fzf(choices, {
80
+ selector: (item) => `${item.value} ${item.description}`,
81
+ casing: "case-insensitive",
82
+ });
83
+
84
+ try {
85
+ const result = await prompts(
86
+ {
87
+ name: "issueNumber",
88
+ message: "Github issue to create branch for:",
89
+ type: "autocomplete",
90
+ choices,
91
+ async suggest(input: string, choices: Choice[]) {
92
+ consola.log(input);
93
+ const results = fzf.find(input);
94
+ return results.map((r) => choices.find((c) => c.value === r.item.value));
95
+ },
96
+ },
97
+ {
98
+ onCancel: () => {
99
+ consola.info(colors.dim("Canceled"));
100
+ this.exit(0);
101
+ },
102
+ },
103
+ );
104
+
105
+ if (result.issueNumber === "cancel") {
106
+ consola.log(colors.dim("Canceled"));
107
+ this.exit(0);
108
+ }
109
+
110
+ const selectedIssue = currentIssues.find((i) => i.number === result.issueNumber)!;
111
+ consola.log(
112
+ `Selected issue ${colors.green(selectedIssue.number)} - ${colors.green(selectedIssue.title)}`,
113
+ );
114
+
115
+ const branchName = GhBranch.createBranchNameFromIssue(selectedIssue);
116
+ createBranch({ branchName });
117
+ } catch {
118
+ this.exit(1);
119
+ }
120
+ }
121
+
122
+ static createBranchNameFromIssue(selectedIssue: Issue): string {
123
+ let slug = selectedIssue.title.toLowerCase();
124
+ slug = slug.trim();
125
+ slug = slug.replaceAll(" ", "-");
126
+ slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
127
+ slug = slug.replace(/-+/g, "-");
128
+ slug = slug.replace(/-+$/, "");
129
+
130
+ return `feature/${selectedIssue.number}-${slug}`;
131
+ }
132
+ }
@@ -0,0 +1,168 @@
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
+ "<%= config.bin %> gh pr",
18
+ "<%= config.bin %> gh pr --draft",
19
+ "<%= config.bin %> gh pr --base develop",
20
+ ];
21
+
22
+ static override flags = {
23
+ ...BaseCommand.baseFlags,
24
+ draft: Flags.boolean({
25
+ description: "Create as draft PR",
26
+ default: false,
27
+ }),
28
+ base: Flags.string({
29
+ char: "b",
30
+ description: "Base branch for the PR",
31
+ default: "main",
32
+ }),
33
+ yes: Flags.boolean({
34
+ char: "y",
35
+ description: "Skip confirmation prompt",
36
+ default: false,
37
+ }),
38
+ };
39
+
40
+ async run(): Promise<void> {
41
+ const { flags } = await this.parse(Pr);
42
+
43
+ // Check prerequisites
44
+ const cliInstalled = await isGithubCliInstalled();
45
+ if (!cliInstalled) {
46
+ consola.error("GitHub CLI not installed");
47
+ this.exit(1);
48
+ }
49
+
50
+ // Get current branch
51
+ const branchResult = await x("git", ["branch", "--show-current"]);
52
+ const currentBranch = branchResult.stdout.trim();
53
+
54
+ if (!currentBranch) {
55
+ consola.error("Not on a branch (detached HEAD?)");
56
+ this.exit(1);
57
+ }
58
+
59
+ if (currentBranch === flags.base) {
60
+ consola.error(`Already on base branch ${flags.base}`);
61
+ this.exit(1);
62
+ }
63
+
64
+ consola.info(`Current branch: ${colors.cyan(currentBranch)}`);
65
+ consola.info(`Base branch: ${colors.cyan(flags.base)}`);
66
+
67
+ // Get commits between base and current branch
68
+ const logResult = await x("git", ["log", `${flags.base}..HEAD`, "--pretty=format:%s"]);
69
+
70
+ const commits = logResult.stdout.trim().split("\n").filter(Boolean);
71
+
72
+ if (commits.length === 0) {
73
+ consola.error(`No commits between ${flags.base} and ${currentBranch}`);
74
+ this.exit(1);
75
+ }
76
+
77
+ consola.info(`Found ${colors.green(commits.length.toString())} commits`);
78
+
79
+ // Generate PR title and body
80
+ const { title, body } = this.generatePrContent(currentBranch, commits);
81
+
82
+ consola.box({
83
+ title: "PR Preview",
84
+ message: `Title: ${title}\n\n${body}`,
85
+ });
86
+
87
+ // Confirm unless --yes
88
+ if (!flags.yes) {
89
+ const confirmed = await consola.prompt("Create this PR?", {
90
+ type: "confirm",
91
+ initial: true,
92
+ });
93
+
94
+ if (!confirmed) {
95
+ consola.info(colors.dim("Canceled"));
96
+ this.exit(0);
97
+ }
98
+ }
99
+
100
+ // Push branch if needed
101
+ const statusResult = await x("git", ["status", "-sb"]);
102
+ const needsPush = !statusResult.stdout.includes("origin/");
103
+
104
+ if (needsPush) {
105
+ consola.info("Pushing branch to remote...");
106
+ await x("git", ["push", "-u", "origin", currentBranch]);
107
+ }
108
+
109
+ // Create PR
110
+ const prArgs = ["pr", "create", "--title", title, "--body", body, "--base", flags.base];
111
+
112
+ if (flags.draft) {
113
+ prArgs.push("--draft");
114
+ }
115
+
116
+ const prResult = await x("gh", prArgs);
117
+ const prUrl = prResult.stdout.trim();
118
+
119
+ consola.success(`PR created: ${colors.cyan(prUrl)}`);
120
+ }
121
+
122
+ private generatePrContent(branch: string, commits: string[]): { title: string; body: string } {
123
+ // Extract issue number from branch name if present (e.g., feature/123-some-feature)
124
+ const issueMatch = branch.match(/(\d+)/);
125
+ const issueNumber = issueMatch ? issueMatch[1] : null;
126
+
127
+ // Generate title from first commit or branch name
128
+ let title: string;
129
+ if (commits.length === 1) {
130
+ title = commits[0];
131
+ } else {
132
+ // Use branch name, cleaned up
133
+ title = branch
134
+ .replace(/^(feature|fix|bugfix|hotfix|chore|refactor)\//, "")
135
+ .replace(/^\d+-/, "")
136
+ .replace(/-/g, " ")
137
+ .replace(/\b\w/g, (c) => c.toUpperCase());
138
+ }
139
+
140
+ // Generate body
141
+ const lines: string[] = ["## Summary", ""];
142
+
143
+ if (commits.length === 1) {
144
+ lines.push(`- ${commits[0]}`);
145
+ } else {
146
+ for (const commit of commits.slice(0, 10)) {
147
+ lines.push(`- ${commit}`);
148
+ }
149
+ if (commits.length > 10) {
150
+ lines.push(`- ... and ${commits.length - 10} more commits`);
151
+ }
152
+ }
153
+
154
+ lines.push("");
155
+
156
+ if (issueNumber) {
157
+ lines.push(`Closes #${issueNumber}`);
158
+ lines.push("");
159
+ }
160
+
161
+ lines.push("## Test plan");
162
+ lines.push("");
163
+ lines.push("- [ ] Tests pass");
164
+ lines.push("- [ ] Manual testing");
165
+
166
+ return { title, body: lines.join("\n") };
167
+ }
168
+ }