schub 0.1.2 → 0.1.4

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 (80) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +12830 -3057
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +5 -1
  5. package/skills/create-tasks/SKILL.md +5 -4
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +3 -2
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +166 -0
  10. package/src/changes.ts +159 -54
  11. package/src/commands/adr.test.ts +6 -5
  12. package/src/commands/changes.test.ts +136 -14
  13. package/src/commands/changes.ts +102 -1
  14. package/src/commands/cookbook.test.ts +6 -5
  15. package/src/commands/init.test.ts +69 -2
  16. package/src/commands/init.ts +48 -5
  17. package/src/commands/review.test.ts +7 -6
  18. package/src/commands/review.ts +1 -1
  19. package/src/commands/roadmap.test.ts +84 -0
  20. package/src/commands/roadmap.ts +84 -0
  21. package/src/commands/tasks-create.test.ts +22 -22
  22. package/src/commands/tasks-implement.test.ts +253 -0
  23. package/src/commands/tasks-implement.ts +121 -0
  24. package/src/commands/tasks-list.test.ts +27 -27
  25. package/src/commands/tasks-update.test.ts +92 -0
  26. package/src/commands/tasks.ts +98 -1
  27. package/src/features/roadmap/index.ts +230 -0
  28. package/src/features/roadmap/roadmap.test.ts +77 -0
  29. package/src/features/tasks/constants.ts +1 -0
  30. package/src/features/tasks/create.ts +10 -8
  31. package/src/features/tasks/filesystem.test.ts +285 -18
  32. package/src/features/tasks/filesystem.ts +152 -39
  33. package/src/features/tasks/graph.ts +18 -3
  34. package/src/features/tasks/index.ts +10 -1
  35. package/src/features/tasks/worktree.ts +48 -0
  36. package/src/frontmatter.ts +115 -0
  37. package/src/index.test.ts +42 -6
  38. package/src/index.ts +226 -109
  39. package/src/opencode.test.ts +53 -0
  40. package/src/opencode.ts +74 -0
  41. package/src/tasks.ts +2 -0
  42. package/src/tui/App.test.tsx +418 -0
  43. package/src/tui/App.tsx +343 -0
  44. package/src/tui/components/PlanView.test.tsx +101 -0
  45. package/src/tui/components/PlanView.tsx +89 -0
  46. package/src/tui/components/PreviewPage.test.tsx +69 -0
  47. package/src/tui/components/PreviewPage.tsx +87 -0
  48. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  49. package/src/tui/components/ProposalDetailView.tsx +166 -0
  50. package/src/tui/components/RoadmapView.test.tsx +85 -0
  51. package/src/tui/components/RoadmapView.tsx +369 -0
  52. package/src/tui/components/StatusView.test.tsx +1351 -0
  53. package/src/tui/components/StatusView.tsx +519 -0
  54. package/src/tui/components/markdown-renderer.test.ts +46 -0
  55. package/src/tui/components/markdown-renderer.ts +89 -0
  56. package/src/tui/components/status-view-data.ts +322 -0
  57. package/src/tui/components/status-view-render.tsx +329 -0
  58. package/src/tui/index.ts +16 -0
  59. package/templates/create-proposal/adr-template.md +6 -4
  60. package/templates/create-proposal/cookbook-template.md +5 -3
  61. package/templates/create-proposal/proposal-template.md +8 -6
  62. package/templates/create-roadmap/roadmap.md +5 -0
  63. package/templates/create-tasks/task-template.md +9 -4
  64. package/templates/review-proposal/q&a-template.md +8 -3
  65. package/templates/review-proposal/review-me-template.md +6 -4
  66. package/templates/setup-project/project-overview-template.md +5 -0
  67. package/templates/setup-project/project-setup-template.md +5 -0
  68. package/templates/setup-project/project-wow-template.md +5 -0
  69. package/src/App.test.tsx +0 -93
  70. package/src/App.tsx +0 -155
  71. package/src/components/PlanView.test.tsx +0 -113
  72. package/src/components/PlanView.tsx +0 -160
  73. package/src/components/StatusView.test.tsx +0 -380
  74. package/src/components/StatusView.tsx +0 -367
  75. package/src/ide.ts +0 -7
  76. package/templates/templates-parity.test.ts +0 -45
  77. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  78. /package/src/{components → tui/components}/statusColor.ts +0 -0
  79. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  80. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { resolveGitRoot } from "../../init";
5
+
6
+ type TaskWorktreeOptions = {
7
+ repoRoot: string;
8
+ taskId: string;
9
+ worktreeRoot?: string;
10
+ };
11
+
12
+ const runGit = (gitRoot: string, args: string[]) => {
13
+ return spawnSync("git", args, {
14
+ cwd: gitRoot,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ });
18
+ };
19
+
20
+ export const createTaskWorktree = ({ repoRoot, taskId, worktreeRoot }: TaskWorktreeOptions) => {
21
+ const gitRoot = resolveGitRoot(repoRoot);
22
+ if (!gitRoot) {
23
+ return null;
24
+ }
25
+
26
+ const branchName = `task/${taskId}`;
27
+ const resolvedRoot = worktreeRoot ? resolve(gitRoot, worktreeRoot) : join(gitRoot, ".schub", "worktrees");
28
+ const worktreePath = join(resolvedRoot, taskId);
29
+
30
+ if (existsSync(worktreePath)) {
31
+ throw new Error(`Worktree path already exists: ${worktreePath}`);
32
+ }
33
+
34
+ const branchCheck = runGit(gitRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`]);
35
+ if (branchCheck.status === 0) {
36
+ throw new Error(`Branch ${branchName} already exists.`);
37
+ }
38
+
39
+ mkdirSync(resolvedRoot, { recursive: true });
40
+
41
+ const worktreeResult = runGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath]);
42
+ if (worktreeResult.status !== 0) {
43
+ const message = worktreeResult.stderr?.trim();
44
+ throw new Error(message || "Failed to create worktree.");
45
+ }
46
+
47
+ return { worktreePath, branchName, gitRoot };
48
+ };
@@ -0,0 +1,115 @@
1
+ export type FrontmatterValue = string | string[];
2
+ export type FrontmatterData = Record<string, FrontmatterValue>;
3
+
4
+ type FrontmatterReadResult = {
5
+ data: FrontmatterData;
6
+ body: string;
7
+ };
8
+
9
+ const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/;
10
+
11
+ const unquote = (value: string) => {
12
+ const trimmed = value.trim();
13
+ if (trimmed.length < 2) {
14
+ return trimmed;
15
+ }
16
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
17
+ return trimmed.slice(1, -1);
18
+ }
19
+ return trimmed;
20
+ };
21
+
22
+ const parseInlineList = (value: string) => {
23
+ const trimmed = value.trim();
24
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
25
+ return null;
26
+ }
27
+ const inner = trimmed.slice(1, -1).trim();
28
+ if (!inner) {
29
+ return [];
30
+ }
31
+ return inner
32
+ .split(",")
33
+ .map((item) => unquote(item))
34
+ .map((item) => item.trim())
35
+ .filter(Boolean);
36
+ };
37
+
38
+ const parseFrontmatterLines = (lines: string[]) => {
39
+ const data: FrontmatterData = {};
40
+ let activeKey: string | null = null;
41
+
42
+ for (const line of lines) {
43
+ const trimmed = line.trim();
44
+ if (!trimmed) {
45
+ continue;
46
+ }
47
+
48
+ const keyMatch = trimmed.match(/^([a-z0-9_]+):\s*(.*)$/);
49
+ if (keyMatch) {
50
+ const key = keyMatch[1];
51
+ const rawValue = keyMatch[2].trim();
52
+ const inlineList = parseInlineList(rawValue);
53
+ if (inlineList) {
54
+ data[key] = inlineList;
55
+ activeKey = null;
56
+ continue;
57
+ }
58
+ if (!rawValue) {
59
+ data[key] = [];
60
+ activeKey = key;
61
+ continue;
62
+ }
63
+ if (rawValue === "[]") {
64
+ data[key] = [];
65
+ activeKey = null;
66
+ continue;
67
+ }
68
+ data[key] = unquote(rawValue);
69
+ activeKey = null;
70
+ continue;
71
+ }
72
+
73
+ const listMatch = trimmed.match(/^-+\s*(.+)$/);
74
+ if (listMatch && activeKey) {
75
+ const current = data[activeKey];
76
+ if (Array.isArray(current)) {
77
+ current.push(unquote(listMatch[1]).trim());
78
+ }
79
+ }
80
+ }
81
+
82
+ return data;
83
+ };
84
+
85
+ export const readFrontmatter = (content: string): FrontmatterReadResult => {
86
+ const match = content.match(FRONTMATTER_PATTERN);
87
+ if (!match) {
88
+ return { data: {}, body: content };
89
+ }
90
+ const lines = match[1].split(/\r?\n/);
91
+ const data = parseFrontmatterLines(lines);
92
+ const body = content.slice(match[0].length);
93
+ return { data, body };
94
+ };
95
+
96
+ export const updateFrontmatterValue = (content: string, key: string, value: string) => {
97
+ const match = content.match(FRONTMATTER_PATTERN);
98
+ if (!match) {
99
+ throw new Error("Frontmatter block not found.");
100
+ }
101
+ const lines = match[1].split(/\r?\n/);
102
+ let found = false;
103
+ const updatedLines = lines.map((line) => {
104
+ const keyMatch = line.match(/^([a-z0-9_]+):/);
105
+ if (keyMatch?.[1] === key) {
106
+ found = true;
107
+ return `${key}: ${value}`;
108
+ }
109
+ return line;
110
+ });
111
+ if (!found) {
112
+ throw new Error(`Frontmatter field '${key}' not found.`);
113
+ }
114
+ return `---\n${updatedLines.join("\n")}\n---\n${content.slice(match[0].length)}`;
115
+ };
package/src/index.test.ts CHANGED
@@ -3,7 +3,9 @@ import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync } from "bun";
5
5
 
6
- test("schub help lists tasks list", () => {
6
+ const packageData = await Bun.file(new URL("../package.json", import.meta.url)).json();
7
+
8
+ test("schub help lists top-level commands", () => {
7
9
  const testDir = dirname(fileURLToPath(import.meta.url));
8
10
  const cliDir = resolve(testDir, "..");
9
11
  const result = spawnSync({
@@ -13,11 +15,45 @@ test("schub help lists tasks list", () => {
13
15
 
14
16
  expect(result.exitCode).toBe(0);
15
17
  const stdout = new TextDecoder().decode(result.stdout ?? new Uint8Array());
16
- expect(stdout).toContain("changes create");
17
- expect(stdout).toContain("eject");
18
- expect(stdout).toContain("init");
19
- expect(stdout).toContain("tasks list");
20
- expect(stdout).toContain("review complete");
18
+ expect(stdout).toContain("schub changes");
19
+ expect(stdout).toContain("schub tasks");
20
+ expect(stdout).toContain("schub roadmap");
21
+ expect(stdout).toContain("schub review");
22
+ expect(stdout).toContain("schub init");
23
+ expect(stdout).toContain("schub eject");
24
+ expect(stdout).toContain("schub ui");
25
+ expect(stdout).toContain("--version");
21
26
  expect(stdout).not.toContain("lint");
22
27
  expect(stdout).not.toContain("format");
23
28
  });
29
+
30
+ test("schub version prints package version", () => {
31
+ const testDir = dirname(fileURLToPath(import.meta.url));
32
+ const cliDir = resolve(testDir, "..");
33
+ const result = spawnSync({
34
+ cmd: ["bun", "run", "schub", "--version"],
35
+ cwd: cliDir,
36
+ });
37
+
38
+ expect(result.exitCode).toBe(0);
39
+ const stdout = new TextDecoder().decode(result.stdout ?? new Uint8Array());
40
+ expect(stdout.trim()).toBe(packageData.version);
41
+ });
42
+
43
+ test("schub changes help is scoped to changes commands", () => {
44
+ const testDir = dirname(fileURLToPath(import.meta.url));
45
+ const cliDir = resolve(testDir, "..");
46
+ const result = spawnSync({
47
+ cmd: ["bun", "run", "schub", "changes", "--help"],
48
+ cwd: cliDir,
49
+ });
50
+
51
+ expect(result.exitCode).toBe(0);
52
+ const stdout = new TextDecoder().decode(result.stdout ?? new Uint8Array());
53
+ expect(stdout).toContain("create");
54
+ expect(stdout).toContain("status");
55
+ expect(stdout).toContain("archive");
56
+ expect(stdout).toContain("list");
57
+ expect(stdout).not.toContain("project create");
58
+ expect(stdout).not.toContain("tasks list");
59
+ });
package/src/index.ts CHANGED
@@ -1,131 +1,248 @@
1
1
  #!/usr/bin/env bun
2
- import { render } from "ink";
3
- import React from "react";
4
- import App from "./App";
2
+ import yargs from "yargs";
3
+ import { hideBin } from "yargs/helpers";
5
4
  import { runAdrCreate } from "./commands/adr";
6
- import { runChangesCreate, runChangesStatus } from "./commands/changes";
5
+ import { runChangesArchive, runChangesCreate, runChangesList, runChangesStatus } from "./commands/changes";
7
6
  import { runCookbookCreate } from "./commands/cookbook";
8
7
  import { runEject } from "./commands/eject";
9
8
  import { runInit } from "./commands/init";
10
9
  import { runProjectCreate } from "./commands/project";
11
10
  import { runReviewComplete, runReviewCreate } from "./commands/review";
12
- import { runTasksCreate, runTasksList } from "./commands/tasks";
11
+ import { runRoadmapAdd, runRoadmapList, runRoadmapPropose } from "./commands/roadmap";
12
+ import { runTasksCreate, runTasksImplement, runTasksList, runTasksUpdate } from "./commands/tasks";
13
13
  import { findSchubRoot } from "./features/tasks";
14
- import { applyTerminalPrelude, applyTerminalReset } from "./terminal";
14
+ import { runTui } from "./tui";
15
15
 
16
- const HELP_TEXT = `schub [command]
17
-
18
- Commands:
19
- changes create Create a change proposal
20
- changes status Update change proposal status
21
- project create Create project docs
22
- tasks create Create task files for a change
23
- tasks list List tasks
24
- review create Create REVIEW_ME for a change
25
- review complete Create Q&A from REVIEW_ME
26
- adr create Create an ADR for a change
27
- cookbook create Create a cookbook for a change
28
- init Initialize .schub and install Codex skills
29
- eject Copy bundled skills and templates into .schub
30
- ui Launch the interactive dashboard
31
- `;
16
+ const packageData = await Bun.file(new URL("../package.json", import.meta.url)).json();
32
17
 
33
18
  const getStartDir = () => process.env.SCHUB_CWD ?? process.cwd();
34
19
 
35
20
  const resolveSchubDir = () => findSchubRoot(getStartDir());
36
21
 
37
- const printHelp = (exitCode = 0) => {
38
- process.stdout.write(`${HELP_TEXT}\n`);
39
- process.exitCode = exitCode;
40
- };
41
-
42
- const registerTerminalReset = () => {
43
- process.once("exit", () => {
44
- applyTerminalReset();
45
- });
46
- };
47
-
48
- const runUi = () => {
49
- applyTerminalPrelude();
50
- registerTerminalReset();
51
- render(React.createElement(App));
52
- };
53
-
54
22
  const runCommand = async () => {
55
- const args = process.argv.slice(2);
56
- if (args.length === 0) {
57
- runUi();
58
- return;
59
- }
60
-
61
- if (args.includes("--help") || args.includes("-h")) {
62
- printHelp();
23
+ const rawArgs = process.argv.slice(2);
24
+ if (rawArgs.length === 0) {
25
+ runTui();
63
26
  return;
64
27
  }
65
28
 
66
- const [primary, secondary, ...rest] = args;
67
-
68
- switch (primary) {
69
- case "changes":
70
- if (secondary === "create") {
71
- runChangesCreate(rest, getStartDir());
72
- return;
73
- }
74
- if (secondary === "status") {
75
- runChangesStatus(rest, getStartDir());
76
- return;
77
- }
78
- break;
79
- case "project":
80
- if (secondary === "create") {
81
- runProjectCreate(rest, getStartDir());
82
- return;
83
- }
84
- break;
85
- case "tasks":
86
- if (secondary === "list") {
87
- runTasksList(resolveSchubDir(), rest);
88
- return;
89
- }
90
- if (secondary === "create") {
91
- runTasksCreate(rest, getStartDir());
92
- return;
93
- }
94
- break;
95
- case "review":
96
- if (secondary === "create") {
97
- runReviewCreate(rest, getStartDir());
98
- return;
99
- }
100
- if (secondary === "complete") {
101
- runReviewComplete(rest, getStartDir());
102
- return;
103
- }
104
- break;
105
- case "adr":
106
- if (secondary === "create") {
107
- runAdrCreate(rest, getStartDir());
108
- return;
109
- }
110
- break;
111
- case "cookbook":
112
- if (secondary === "create") {
113
- runCookbookCreate(rest, getStartDir());
114
- return;
115
- }
116
- break;
117
- case "eject":
118
- runEject(args.slice(1), getStartDir());
119
- return;
120
- case "init":
121
- await runInit(args.slice(1), getStartDir());
122
- return;
123
- case "ui":
124
- runUi();
125
- return;
126
- }
29
+ const startDir = getStartDir();
30
+ const cli = yargs(hideBin(process.argv))
31
+ .scriptName("schub")
32
+ .usage("schub [command]")
33
+ .command(
34
+ "changes",
35
+ "Change proposal commands",
36
+ (command) =>
37
+ command
38
+ .command(
39
+ "create [args..]",
40
+ "Create a change proposal",
41
+ () => {},
42
+ () => {
43
+ runChangesCreate(rawArgs.slice(2), startDir);
44
+ },
45
+ )
46
+ .command(
47
+ "status [args..]",
48
+ "Update change proposal status",
49
+ () => {},
50
+ () => {
51
+ runChangesStatus(rawArgs.slice(2), startDir);
52
+ },
53
+ )
54
+ .command(
55
+ "archive [args..]",
56
+ "Archive a change proposal",
57
+ () => {},
58
+ () => {
59
+ runChangesArchive(rawArgs.slice(2), startDir);
60
+ },
61
+ )
62
+ .command(
63
+ "list [args..]",
64
+ "List change proposals",
65
+ () => {},
66
+ () => {
67
+ runChangesList(rawArgs.slice(2), startDir);
68
+ },
69
+ )
70
+ .demandCommand(1, "Provide a changes command."),
71
+ () => {},
72
+ )
73
+ .command(
74
+ "project",
75
+ "Project documentation commands",
76
+ (command) =>
77
+ command
78
+ .command(
79
+ "create [args..]",
80
+ "Create project docs",
81
+ () => {},
82
+ () => {
83
+ runProjectCreate(rawArgs.slice(2), startDir);
84
+ },
85
+ )
86
+ .demandCommand(1, "Provide a project command."),
87
+ () => {},
88
+ )
89
+ .command(
90
+ "tasks",
91
+ "Task commands",
92
+ (command) =>
93
+ command
94
+ .command(
95
+ "create [args..]",
96
+ "Create task files for a change",
97
+ () => {},
98
+ () => {
99
+ runTasksCreate(rawArgs.slice(2), startDir);
100
+ },
101
+ )
102
+ .command(
103
+ "list [args..]",
104
+ "List tasks",
105
+ () => {},
106
+ () => {
107
+ runTasksList(resolveSchubDir(), rawArgs.slice(2));
108
+ },
109
+ )
110
+ .command(
111
+ "update [args..]",
112
+ "Update backlog task status",
113
+ () => {},
114
+ () => {
115
+ runTasksUpdate(resolveSchubDir(), rawArgs.slice(2));
116
+ },
117
+ )
118
+ .command(
119
+ "implement [args..]",
120
+ "Assign a task for implementation",
121
+ () => {},
122
+ () => {
123
+ runTasksImplement(resolveSchubDir(), rawArgs.slice(2));
124
+ },
125
+ )
126
+ .demandCommand(1, "Provide a tasks command."),
127
+ () => {},
128
+ )
129
+ .command(
130
+ "roadmap",
131
+ "Roadmap commands",
132
+ (command) =>
133
+ command
134
+ .command(
135
+ "add [args..]",
136
+ "Add a roadmap item",
137
+ () => {},
138
+ () => {
139
+ runRoadmapAdd(startDir, rawArgs.slice(2));
140
+ },
141
+ )
142
+ .command(
143
+ "list [args..]",
144
+ "List roadmap items",
145
+ () => {},
146
+ () => {
147
+ runRoadmapList(startDir, rawArgs.slice(2));
148
+ },
149
+ )
150
+ .command(
151
+ "propose [args..]",
152
+ "Create proposal from a roadmap item",
153
+ () => {},
154
+ () => {
155
+ runRoadmapPropose(startDir, rawArgs.slice(2));
156
+ },
157
+ )
158
+ .demandCommand(1, "Provide a roadmap command."),
159
+ () => {},
160
+ )
161
+ .command(
162
+ "review",
163
+ "Review commands",
164
+ (command) =>
165
+ command
166
+ .command(
167
+ "create [args..]",
168
+ "Create REVIEW_ME for a change",
169
+ () => {},
170
+ () => {
171
+ runReviewCreate(rawArgs.slice(2), startDir);
172
+ },
173
+ )
174
+ .command(
175
+ "complete [args..]",
176
+ "Create Q&A from REVIEW_ME",
177
+ () => {},
178
+ () => {
179
+ runReviewComplete(rawArgs.slice(2), startDir);
180
+ },
181
+ )
182
+ .demandCommand(1, "Provide a review command."),
183
+ () => {},
184
+ )
185
+ .command(
186
+ "adr",
187
+ "ADR commands",
188
+ (command) =>
189
+ command
190
+ .command(
191
+ "create [args..]",
192
+ "Create an ADR for a change",
193
+ () => {},
194
+ () => {
195
+ runAdrCreate(rawArgs.slice(2), startDir);
196
+ },
197
+ )
198
+ .demandCommand(1, "Provide an adr command."),
199
+ () => {},
200
+ )
201
+ .command(
202
+ "cookbook",
203
+ "Cookbook commands",
204
+ (command) =>
205
+ command
206
+ .command(
207
+ "create [args..]",
208
+ "Create a cookbook for a change",
209
+ () => {},
210
+ () => {
211
+ runCookbookCreate(rawArgs.slice(2), startDir);
212
+ },
213
+ )
214
+ .demandCommand(1, "Provide a cookbook command."),
215
+ () => {},
216
+ )
217
+ .command(
218
+ "init [args..]",
219
+ "Initialize .schub and install Codex skills",
220
+ () => {},
221
+ async () => {
222
+ await runInit(rawArgs.slice(1), startDir);
223
+ },
224
+ )
225
+ .command(
226
+ "eject [args..]",
227
+ "Copy bundled skills and templates into .schub",
228
+ () => {},
229
+ () => {
230
+ runEject(rawArgs.slice(1), startDir);
231
+ },
232
+ )
233
+ .command(
234
+ "ui",
235
+ "Launch the interactive dashboard",
236
+ () => {},
237
+ () => {
238
+ runTui();
239
+ },
240
+ )
241
+ .help()
242
+ .version(packageData.version)
243
+ .strictCommands();
127
244
 
128
- printHelp(1);
245
+ await cli.parseAsync();
129
246
  };
130
247
 
131
248
  runCommand().catch((error) => {
@@ -0,0 +1,53 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildImplementCommand, buildReviewCommand, launchOpencodeImplement } from "./opencode";
3
+
4
+ type SpawnCall = {
5
+ command: string;
6
+ args: string[];
7
+ options: { stdio: "ignore"; detached: true; env?: NodeJS.ProcessEnv; cwd?: string };
8
+ };
9
+
10
+ const repoRoot = "/repo/schub";
11
+
12
+ const createSpawnRecorder = () => {
13
+ const calls: SpawnCall[] = [];
14
+
15
+ const spawner = (
16
+ command: string,
17
+ args: readonly string[],
18
+ options: { stdio: "ignore"; detached: true; env?: NodeJS.ProcessEnv; cwd?: string },
19
+ ) => {
20
+ calls.push({ command, args: [...args], options });
21
+ return { unref: () => {} };
22
+ };
23
+
24
+ return { calls, spawner };
25
+ };
26
+
27
+ test("buildReviewCommand uses opencode run review with title", () => {
28
+ expect(buildReviewCommand("C0001", repoRoot)).toEqual({
29
+ command: "opencode",
30
+ args: ["run", "review", "C0001", "--title", "(schub) Review C0001"],
31
+ });
32
+ });
33
+
34
+ test("buildImplementCommand uses opencode run implement with title", () => {
35
+ expect(buildImplementCommand("T0001", repoRoot, "Task Title")).toEqual({
36
+ command: "opencode",
37
+ args: ["run", "implement", "T0001", "--title", "(schub) Implement T0001 Task Title"],
38
+ });
39
+ });
40
+
41
+ test("launchOpencodeImplement spawns implement command", () => {
42
+ const { calls, spawner } = createSpawnRecorder();
43
+
44
+ launchOpencodeImplement("T0001", repoRoot, "Task Title", undefined, spawner);
45
+
46
+ expect(calls).toEqual([
47
+ {
48
+ command: "opencode",
49
+ args: ["run", "implement", "T0001", "--title", "(schub) Implement T0001 Task Title"],
50
+ options: { stdio: "ignore", detached: true },
51
+ },
52
+ ]);
53
+ });