schub 0.1.1 → 0.1.2

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 (44) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +836 -269
  3. package/package.json +3 -1
  4. package/skills/create-proposal/SKILL.md +33 -0
  5. package/skills/create-tasks/SKILL.md +40 -0
  6. package/skills/implement-task/SKILL.md +84 -0
  7. package/skills/review-proposal/SKILL.md +37 -0
  8. package/skills/setup-project/SKILL.md +29 -0
  9. package/src/App.test.tsx +93 -0
  10. package/src/App.tsx +50 -7
  11. package/src/changes.ts +42 -28
  12. package/src/clipboard.ts +5 -0
  13. package/src/commands/adr.test.ts +69 -0
  14. package/src/commands/adr.ts +10 -2
  15. package/src/commands/changes.test.ts +171 -0
  16. package/src/commands/changes.ts +76 -1
  17. package/src/commands/cookbook.test.ts +71 -0
  18. package/src/commands/cookbook.ts +8 -2
  19. package/src/commands/eject.test.ts +74 -0
  20. package/src/commands/eject.ts +100 -0
  21. package/src/commands/init.test.ts +78 -0
  22. package/src/commands/init.ts +144 -0
  23. package/src/commands/project.test.ts +113 -0
  24. package/src/commands/review.test.ts +100 -0
  25. package/src/commands/review.ts +17 -4
  26. package/src/commands/tasks-create.test.ts +172 -0
  27. package/src/commands/tasks-list.test.ts +177 -0
  28. package/src/components/PlanView.test.tsx +113 -0
  29. package/src/components/PlanView.tsx +95 -26
  30. package/src/components/StatusView.test.tsx +380 -0
  31. package/src/components/StatusView.tsx +175 -34
  32. package/src/features/tasks/create.ts +15 -7
  33. package/src/features/tasks/filesystem.test.ts +78 -0
  34. package/src/features/tasks/filesystem.ts +4 -8
  35. package/src/ide.ts +7 -0
  36. package/src/index.test.ts +23 -0
  37. package/src/index.ts +19 -6
  38. package/src/init.test.ts +43 -0
  39. package/src/init.ts +27 -0
  40. package/src/project.ts +5 -32
  41. package/src/schub-root.ts +33 -0
  42. package/src/templates.ts +18 -0
  43. package/src/terminal.test.ts +46 -0
  44. package/templates/templates-parity.test.ts +45 -0
@@ -1,8 +1,9 @@
1
+ import { dirname, normalize, sep } from "node:path";
1
2
  import { Box, Text, useInput } from "ink";
2
- import { normalize, sep } from "node:path";
3
3
  import React from "react";
4
- import { type ChangeInfo, listChanges } from "../changes";
4
+ import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
5
5
  import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
6
+ import { openInVsCode } from "../ide";
6
7
 
7
8
  type StatusSortable = {
8
9
  id: string;
@@ -10,6 +11,22 @@ type StatusSortable = {
10
11
  status: string;
11
12
  };
12
13
 
14
+ type StatusViewProps = {
15
+ refreshIntervalMs?: number;
16
+ onCopyId: (id: string) => void;
17
+ };
18
+
19
+ const DEFAULT_REFRESH_INTERVAL_MS = 1000;
20
+ const ACTIVE_TASK_STATUSES = ["blocked", "wip", "ready", "backlog"] as const;
21
+ const CHANGE_TASK_STATUSES = [...ACTIVE_TASK_STATUSES, "done"] as const;
22
+
23
+ type ChangeTaskCount = {
24
+ completed: number;
25
+ total: number;
26
+ };
27
+
28
+ const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
29
+
13
30
  const compareText = (left: string, right: string): number =>
14
31
  left.localeCompare(right, undefined, { sensitivity: "base" });
15
32
 
@@ -32,20 +49,59 @@ const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
32
49
  return parts.includes("tasks");
33
50
  };
34
51
 
52
+ const formatChangeId = (value: string) => {
53
+ const match = value.match(/^([Cc]\d{3})_/);
54
+ return match ? match[1].toUpperCase() : value;
55
+ };
56
+
57
+ const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
58
+
59
+ const autoMarkChangesDone = (schubDir: string) => {
60
+ const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
61
+ const changeTaskCounts = new Map<string, ChangeTaskCount>();
62
+
63
+ for (const task of allTasks) {
64
+ if (!task.changeId) {
65
+ continue;
66
+ }
67
+
68
+ const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
69
+ const completed = current.completed + (task.status === "done" ? 1 : 0);
70
+ changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
71
+ }
72
+
73
+ if (changeTaskCounts.size === 0) {
74
+ return;
75
+ }
76
+
77
+ for (const change of listChanges(schubDir)) {
78
+ const counts = changeTaskCounts.get(change.id);
79
+ if (!counts || counts.total === 0 || counts.completed !== counts.total) {
80
+ continue;
81
+ }
82
+
83
+ if (canAutoMarkChange(change.status)) {
84
+ updateChangeStatus(schubDir, change.id, "Done");
85
+ }
86
+ }
87
+ };
88
+
35
89
  const buildStatusData = (schubDir: string | null) => {
36
90
  if (!schubDir) {
37
91
  return {
38
92
  pendingReview: [],
39
93
  pendingImplementation: [],
94
+ pendingImplementationNoTasks: [],
40
95
  drafts: [],
41
96
  blocked: [],
42
97
  wip: [],
43
98
  ready: [],
44
99
  backlog: [],
100
+ pendingImplementationCounts: new Map<string, string>(),
45
101
  };
46
102
  }
47
103
 
48
- const allTasks = loadTaskDependencies(schubDir, ["blocked", "wip", "ready", "backlog"]);
104
+ const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
49
105
 
50
106
  const blockedTasks: TaskInfo[] = [];
51
107
  const wipTasks: TaskInfo[] = [];
@@ -53,17 +109,23 @@ const buildStatusData = (schubDir: string | null) => {
53
109
  const backlogTasks: TaskInfo[] = [];
54
110
 
55
111
  const activeChangeIds = new Set<string>();
112
+ const changeTaskCounts = new Map<string, ChangeTaskCount>();
56
113
 
57
114
  for (const task of allTasks) {
58
- const s = task.status.toLowerCase();
59
- if (s === "blocked") blockedTasks.push(task);
60
- else if (s === "wip") wipTasks.push(task);
61
- else if (s === "ready") readyTasks.push(task);
62
- else if (s === "backlog") backlogTasks.push(task);
115
+ if (task.status === "blocked") blockedTasks.push(task);
116
+ else if (task.status === "wip") wipTasks.push(task);
117
+ else if (task.status === "ready") readyTasks.push(task);
118
+ else if (task.status === "backlog") backlogTasks.push(task);
63
119
 
64
- if (task.changeId) {
120
+ if (task.changeId && task.status !== "done") {
65
121
  activeChangeIds.add(task.changeId);
66
122
  }
123
+
124
+ if (task.changeId) {
125
+ const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
126
+ const completed = current.completed + (task.status === "done" ? 1 : 0);
127
+ changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
128
+ }
67
129
  }
68
130
 
69
131
  const allChanges = listChanges(schubDir);
@@ -75,37 +137,99 @@ const buildStatusData = (schubDir: string | null) => {
75
137
  const normalized = change.status.toLowerCase();
76
138
  if (normalized.includes("review")) {
77
139
  pendingReviewChanges.push(change);
78
- } else if (
79
- activeChangeIds.has(change.id) ||
80
- normalized.includes("implementing") ||
81
- normalized.includes("accepted")
82
- ) {
140
+ continue;
141
+ }
142
+
143
+ const counts = changeTaskCounts.get(change.id);
144
+ const allTasksCompleted = Boolean(counts && counts.total > 0 && counts.completed === counts.total);
145
+
146
+ if (activeChangeIds.has(change.id)) {
83
147
  pendingImplementationChanges.push(change);
84
- } else if (normalized.includes("draft")) {
148
+ continue;
149
+ }
150
+
151
+ if ((normalized.includes("implementing") || normalized.includes("accepted")) && !allTasksCompleted) {
152
+ pendingImplementationChanges.push(change);
153
+ continue;
154
+ }
155
+
156
+ if (normalized.includes("draft")) {
85
157
  draftChanges.push(change);
86
158
  }
87
159
  }
88
160
 
161
+ const pendingImplementationWithTasks: ChangeInfo[] = [];
162
+ const pendingImplementationNoTasks: ChangeInfo[] = [];
163
+ const pendingImplementationCounts = new Map<string, string>();
164
+
165
+ for (const change of pendingImplementationChanges.sort(sortByStatusThenTitle)) {
166
+ const counts = changeTaskCounts.get(change.id);
167
+ if (counts && counts.total > 0) {
168
+ pendingImplementationWithTasks.push(change);
169
+ pendingImplementationCounts.set(change.id, `${counts.completed}/${counts.total}`);
170
+ } else {
171
+ pendingImplementationNoTasks.push(change);
172
+ }
173
+ }
174
+
89
175
  return {
90
176
  pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
91
- pendingImplementation: pendingImplementationChanges.sort(sortByStatusThenTitle),
177
+ pendingImplementation: pendingImplementationWithTasks,
178
+ pendingImplementationNoTasks,
92
179
  drafts: draftChanges.sort(sortByStatusThenTitle),
93
180
  blocked: blockedTasks.sort(sortByStatusThenTitle),
94
181
  wip: wipTasks.sort(sortByStatusThenTitle),
95
182
  ready: readyTasks.sort(sortByStatusThenTitle),
96
183
  backlog: backlogTasks.sort(sortByStatusThenTitle),
184
+ pendingImplementationCounts,
97
185
  };
98
186
  };
99
187
 
100
- export default function StatusView() {
188
+ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: StatusViewProps) {
101
189
  const schubDir = findSchubRoot();
102
- const { pendingReview, pendingImplementation, drafts, blocked, wip, ready, backlog } = buildStatusData(schubDir);
103
-
104
- const allItems = [...pendingReview, ...pendingImplementation, ...drafts, ...blocked, ...wip, ...ready, ...backlog];
190
+ const [, setRefreshTick] = React.useState(0);
191
+ const {
192
+ pendingReview,
193
+ pendingImplementation,
194
+ pendingImplementationNoTasks,
195
+ drafts,
196
+ blocked,
197
+ wip,
198
+ ready,
199
+ backlog,
200
+ pendingImplementationCounts,
201
+ } = buildStatusData(schubDir);
202
+ const repoRoot = schubDir ? dirname(schubDir) : "";
203
+
204
+ const allItems = [
205
+ ...pendingReview,
206
+ ...pendingImplementation,
207
+ ...pendingImplementationNoTasks,
208
+ ...drafts,
209
+ ...blocked,
210
+ ...wip,
211
+ ...ready,
212
+ ...backlog,
213
+ ];
105
214
 
106
215
  const [selection, setSelection] = React.useState(0);
107
216
  const totalItems = allItems.length;
108
217
 
218
+ React.useEffect(() => {
219
+ if (!schubDir) {
220
+ return;
221
+ }
222
+
223
+ const interval = setInterval(() => {
224
+ autoMarkChangesDone(schubDir);
225
+ setRefreshTick((current) => current + 1);
226
+ }, refreshIntervalMs);
227
+
228
+ return () => {
229
+ clearInterval(interval);
230
+ };
231
+ }, [refreshIntervalMs, schubDir]);
232
+
109
233
  React.useEffect(() => {
110
234
  if (totalItems === 0) {
111
235
  setSelection(0);
@@ -114,7 +238,7 @@ export default function StatusView() {
114
238
  setSelection((current) => Math.min(current, totalItems - 1));
115
239
  }, [totalItems]);
116
240
 
117
- useInput((_input, key) => {
241
+ useInput((input, key) => {
118
242
  if (totalItems === 0) {
119
243
  return;
120
244
  }
@@ -126,6 +250,16 @@ export default function StatusView() {
126
250
  if (key.upArrow) {
127
251
  setSelection((current) => Math.max(current - 1, 0));
128
252
  }
253
+
254
+ if (input === "o") {
255
+ const selectedItem = allItems[selection];
256
+ openInVsCode(repoRoot, selectedItem.path);
257
+ }
258
+
259
+ if (input === "c") {
260
+ const selectedItem = allItems[selection];
261
+ onCopyId(selectedItem.id);
262
+ }
129
263
  });
130
264
 
131
265
  if (!schubDir) {
@@ -145,7 +279,7 @@ export default function StatusView() {
145
279
  );
146
280
  }
147
281
 
148
- const renderRow = (item: ChangeInfo | TaskInfo, index: number) => {
282
+ const renderRow = (item: ChangeInfo | TaskInfo, index: number, detail?: string) => {
149
283
  const selected = index === selection;
150
284
  const task = isTaskItem(item) ? item : null;
151
285
  const title = task ? trimTaskTitle(task.title) : "";
@@ -153,14 +287,17 @@ export default function StatusView() {
153
287
  task && task.status === "wip" && typeof task.checklistTotal === "number"
154
288
  ? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
155
289
  : "";
156
- const displayTitle = title ? `${title}${checklistIndicator}` : "";
290
+ const changeTitle = item.title;
291
+ const changeDetail = detail ? ` ${detail}` : "";
292
+ const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
293
+ const displayId = task ? item.id : formatChangeId(item.id);
157
294
 
158
295
  return (
159
296
  <Box key={item.id} marginLeft={1}>
160
297
  <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
161
298
  <Box marginLeft={1}>
162
299
  <Text color="white" bold={selected}>
163
- {item.id}
300
+ {displayId}
164
301
  </Text>
165
302
  {displayTitle ? <Text color="gray"> {displayTitle}</Text> : null}
166
303
  </Box>
@@ -170,11 +307,11 @@ export default function StatusView() {
170
307
 
171
308
  let currentIndex = 0;
172
309
 
173
- const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[]) => {
310
+ const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[], detailById?: Map<string, string>) => {
174
311
  if (items.length === 0) return null;
175
312
 
176
313
  const sectionStartIndex = currentIndex;
177
- const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i));
314
+ const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i, detailById?.get(item.id)));
178
315
  currentIndex += items.length;
179
316
 
180
317
  return (
@@ -191,23 +328,27 @@ export default function StatusView() {
191
328
  <Box flexDirection="column">
192
329
  <Box flexDirection="column" marginBottom={1}>
193
330
  <Box marginBottom={1}>
194
- <Text bold color="white" underline>
331
+ <Text bold color="white">
195
332
  Change Proposals
196
333
  </Text>
197
334
  </Box>
198
335
  {renderSection("Pending Review", pendingReview)}
199
- {renderSection("Pending Implementation", pendingImplementation)}
336
+ {renderSection("Pending Implementation", pendingImplementation, pendingImplementationCounts)}
337
+ {renderSection("No Tasks Defined", pendingImplementationNoTasks)}
200
338
  {renderSection("Drafts", drafts)}
201
- {pendingReview.length === 0 && pendingImplementation.length === 0 && drafts.length === 0 && (
202
- <Box marginLeft={1}>
203
- <Text color="gray">No active proposals.</Text>
204
- </Box>
205
- )}
339
+ {pendingReview.length === 0 &&
340
+ pendingImplementation.length === 0 &&
341
+ pendingImplementationNoTasks.length === 0 &&
342
+ drafts.length === 0 && (
343
+ <Box marginLeft={1}>
344
+ <Text color="gray">No active proposals.</Text>
345
+ </Box>
346
+ )}
206
347
  </Box>
207
348
 
208
349
  <Box flexDirection="column">
209
350
  <Box marginBottom={1}>
210
- <Text bold color="white" underline>
351
+ <Text bold color="white">
211
352
  Tasks
212
353
  </Text>
213
354
  </Box>
@@ -1,14 +1,22 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
-
5
- const TASK_TEMPLATE_PATH = fileURLToPath(new URL("../../../templates/create-tasks/task-template.md", import.meta.url));
6
-
7
- const readTaskTemplate = () => {
4
+ import { resolveTemplatePath } from "../../templates";
5
+
6
+ const BUNDLED_TASK_TEMPLATE_PATH = fileURLToPath(
7
+ new URL("../../../templates/create-tasks/task-template.md", import.meta.url),
8
+ );
9
+
10
+ const readTaskTemplate = (schubDir: string) => {
11
+ const templatePath = resolveTemplatePath(
12
+ schubDir,
13
+ join("create-tasks", "task-template.md"),
14
+ BUNDLED_TASK_TEMPLATE_PATH,
15
+ );
8
16
  try {
9
- return readFileSync(TASK_TEMPLATE_PATH, "utf8");
17
+ return readFileSync(templatePath, "utf8");
10
18
  } catch {
11
- throw new Error(`[ERROR] Template not found: ${TASK_TEMPLATE_PATH}`);
19
+ throw new Error(`[ERROR] Template not found: ${templatePath}`);
12
20
  }
13
21
  };
14
22
 
@@ -89,7 +97,7 @@ export const createTask = (
89
97
  let nextNumber = existingNumbers.size > 0 ? Math.max(...existingNumbers) + 1 : 1;
90
98
  const statusDir = join(tasksRoot, status);
91
99
  mkdirSync(statusDir, { recursive: true });
92
- const template = readTaskTemplate();
100
+ const template = readTaskTemplate(schubDir);
93
101
 
94
102
  const createdPaths: string[] = [];
95
103
 
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { listTasks, loadTaskDependencies } from "./filesystem";
6
+
7
+ const setupChecklistRepo = () => {
8
+ const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
9
+ const schubDir = join(base, ".schub");
10
+ const tasksRoot = join(schubDir, "tasks", "backlog");
11
+ mkdirSync(tasksRoot, { recursive: true });
12
+
13
+ const tasks = [
14
+ {
15
+ id: "T001",
16
+ slug: "unchecked-only",
17
+ body: `# Task: T001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
18
+ },
19
+ {
20
+ id: "T002",
21
+ slug: "checked-only",
22
+ body: `# Task: T002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
23
+ },
24
+ {
25
+ id: "T003",
26
+ slug: "mixed-checklist",
27
+ body: `# Task: T003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
28
+ },
29
+ {
30
+ id: "T004",
31
+ slug: "missing-checklist",
32
+ body: `# Task: T004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
33
+ },
34
+ ];
35
+
36
+ for (const task of tasks) {
37
+ const filePath = join(tasksRoot, `${task.id}_${task.slug}.md`);
38
+ writeFileSync(filePath, task.body, "utf8");
39
+ }
40
+
41
+ return schubDir;
42
+ };
43
+
44
+ const getTaskById = <T extends { id: string }>(tasks: T[], id: string) => {
45
+ return tasks.find((task) => task.id === id);
46
+ };
47
+
48
+ test("listTasks includes Steps-only checklist counts", () => {
49
+ const schubDir = setupChecklistRepo();
50
+ const tasks = listTasks(schubDir);
51
+
52
+ const unchecked = getTaskById(tasks, "T001");
53
+ const checked = getTaskById(tasks, "T002");
54
+ const mixed = getTaskById(tasks, "T003");
55
+ const missing = getTaskById(tasks, "T004");
56
+
57
+ expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
58
+ expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
59
+ expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
60
+ expect(missing?.checklistTotal).toBeUndefined();
61
+ expect(missing?.checklistRemaining).toBeUndefined();
62
+ });
63
+
64
+ test("loadTaskDependencies includes Steps-only checklist counts", () => {
65
+ const schubDir = setupChecklistRepo();
66
+ const tasks = loadTaskDependencies(schubDir);
67
+
68
+ const unchecked = getTaskById(tasks, "T001");
69
+ const checked = getTaskById(tasks, "T002");
70
+ const mixed = getTaskById(tasks, "T003");
71
+ const missing = getTaskById(tasks, "T004");
72
+
73
+ expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
74
+ expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
75
+ expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
76
+ expect(missing?.checklistTotal).toBeUndefined();
77
+ expect(missing?.checklistRemaining).toBeUndefined();
78
+ });
@@ -104,10 +104,6 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
104
104
  return { ...fallback, dependsOn: [] };
105
105
  }
106
106
 
107
- const headerMatch = content.match(/^#\s*Task:\s*(\S+)\s+(.+)$/m);
108
- const id = headerMatch?.[1] ?? fallback.id;
109
- const title = headerMatch?.[2]?.trim() ?? fallback.title;
110
-
111
107
  const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
112
108
  const changeId = changeIdMatch?.[1]?.trim();
113
109
 
@@ -126,8 +122,8 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
126
122
  const checklistCounts = parseChecklistCounts(content);
127
123
 
128
124
  return {
129
- id,
130
- title,
125
+ id: fallback.id,
126
+ title: fallback.title,
131
127
  dependsOn,
132
128
  changeId,
133
129
  ...(checklistCounts ?? {}),
@@ -220,8 +216,8 @@ export const loadTaskDependencies = (
220
216
  const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
221
217
 
222
218
  tasks.push({
223
- id: parsedFile.id,
224
- title: parsedFile.title,
219
+ id: parsed.id,
220
+ title: parsed.title,
225
221
  status,
226
222
  path: relative(repoRoot, filePath),
227
223
  dependsOn,
package/src/ide.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { spawn } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+
4
+ export const openInVsCode = (repoRoot: string, relativePath: string) => {
5
+ const targetPath = resolve(repoRoot, relativePath);
6
+ spawn("code", ["-g", targetPath], { stdio: "ignore" }).unref();
7
+ };
@@ -0,0 +1,23 @@
1
+ import { expect, test } from "bun:test";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "bun";
5
+
6
+ test("schub help lists tasks list", () => {
7
+ const testDir = dirname(fileURLToPath(import.meta.url));
8
+ const cliDir = resolve(testDir, "..");
9
+ const result = spawnSync({
10
+ cmd: ["bun", "run", "schub", "--help"],
11
+ cwd: cliDir,
12
+ });
13
+
14
+ expect(result.exitCode).toBe(0);
15
+ 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");
21
+ expect(stdout).not.toContain("lint");
22
+ expect(stdout).not.toContain("format");
23
+ });
package/src/index.ts CHANGED
@@ -3,8 +3,10 @@ import { render } from "ink";
3
3
  import React from "react";
4
4
  import App from "./App";
5
5
  import { runAdrCreate } from "./commands/adr";
6
- import { runChangesCreate } from "./commands/changes";
6
+ import { runChangesCreate, runChangesStatus } from "./commands/changes";
7
7
  import { runCookbookCreate } from "./commands/cookbook";
8
+ import { runEject } from "./commands/eject";
9
+ import { runInit } from "./commands/init";
8
10
  import { runProjectCreate } from "./commands/project";
9
11
  import { runReviewComplete, runReviewCreate } from "./commands/review";
10
12
  import { runTasksCreate, runTasksList } from "./commands/tasks";
@@ -15,6 +17,7 @@ const HELP_TEXT = `schub [command]
15
17
 
16
18
  Commands:
17
19
  changes create Create a change proposal
20
+ changes status Update change proposal status
18
21
  project create Create project docs
19
22
  tasks create Create task files for a change
20
23
  tasks list List tasks
@@ -22,6 +25,8 @@ Commands:
22
25
  review complete Create Q&A from REVIEW_ME
23
26
  adr create Create an ADR for a change
24
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
25
30
  ui Launch the interactive dashboard
26
31
  `;
27
32
 
@@ -46,7 +51,7 @@ const runUi = () => {
46
51
  render(React.createElement(App));
47
52
  };
48
53
 
49
- const runCommand = () => {
54
+ const runCommand = async () => {
50
55
  const args = process.argv.slice(2);
51
56
  if (args.length === 0) {
52
57
  runUi();
@@ -66,6 +71,10 @@ const runCommand = () => {
66
71
  runChangesCreate(rest, getStartDir());
67
72
  return;
68
73
  }
74
+ if (secondary === "status") {
75
+ runChangesStatus(rest, getStartDir());
76
+ return;
77
+ }
69
78
  break;
70
79
  case "project":
71
80
  if (secondary === "create") {
@@ -105,6 +114,12 @@ const runCommand = () => {
105
114
  return;
106
115
  }
107
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;
108
123
  case "ui":
109
124
  runUi();
110
125
  return;
@@ -113,10 +128,8 @@ const runCommand = () => {
113
128
  printHelp(1);
114
129
  };
115
130
 
116
- try {
117
- runCommand();
118
- } catch (error) {
131
+ runCommand().catch((error) => {
119
132
  const message = error instanceof Error ? error.message : String(error);
120
133
  process.stderr.write(`${message}\n`);
121
134
  process.exitCode = 1;
122
- }
135
+ });
@@ -0,0 +1,43 @@
1
+ import { expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, statSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { initSchubRoot } from "./init";
7
+
8
+ const createRepoFixture = () => {
9
+ const base = mkdtempSync(join(tmpdir(), "schub-init-root-"));
10
+ const repoRoot = join(base, "repo");
11
+ const startDir = join(repoRoot, "nested", "dir");
12
+ mkdirSync(startDir, { recursive: true });
13
+ return { base, repoRoot, startDir };
14
+ };
15
+
16
+ test("initSchubRoot resolves git worktree root and creates .schub", () => {
17
+ const { repoRoot, startDir } = createRepoFixture();
18
+ const result = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
19
+
20
+ expect(result.status).toBe(0);
21
+
22
+ const schubRoot = initSchubRoot(startDir);
23
+
24
+ expect(realpathSync(schubRoot)).toBe(realpathSync(join(repoRoot, ".schub")));
25
+ expect(existsSync(schubRoot)).toBe(true);
26
+ expect(statSync(schubRoot).isDirectory()).toBe(true);
27
+ });
28
+
29
+ test("initSchubRoot falls back to startDir when git is unavailable", () => {
30
+ const { startDir } = createRepoFixture();
31
+ const originalPath = process.env.PATH;
32
+ process.env.PATH = "";
33
+
34
+ try {
35
+ const schubRoot = initSchubRoot(startDir);
36
+
37
+ expect(schubRoot).toBe(join(startDir, ".schub"));
38
+ expect(existsSync(schubRoot)).toBe(true);
39
+ expect(statSync(schubRoot).isDirectory()).toBe(true);
40
+ } finally {
41
+ process.env.PATH = originalPath;
42
+ }
43
+ });
package/src/init.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export const resolveGitRoot = (startDir: string) => {
6
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
7
+ cwd: startDir,
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "ignore"],
10
+ });
11
+
12
+ if (result.status !== 0) {
13
+ return null;
14
+ }
15
+
16
+ const output = result.stdout?.trim();
17
+ return output ? resolve(output) : null;
18
+ };
19
+
20
+ export const initSchubRoot = (startDir: string = process.cwd()) => {
21
+ const resolvedStart = resolve(startDir);
22
+ const gitRoot = resolveGitRoot(resolvedStart);
23
+ const root = gitRoot ?? resolvedStart;
24
+ const schubRoot = join(root, ".schub");
25
+ mkdirSync(schubRoot, { recursive: true });
26
+ return schubRoot;
27
+ };