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
@@ -0,0 +1,177 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "bun";
7
+
8
+ const testDir = dirname(fileURLToPath(import.meta.url));
9
+ const cliDir = resolve(testDir, "..", "..");
10
+ const decoder = new TextDecoder();
11
+
12
+ const runCli = (schubCwd: string, args: string[] = []) => {
13
+ const result = spawnSync({
14
+ cmd: ["bun", "run", "schub", "tasks", "list", ...args],
15
+ cwd: cliDir,
16
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
17
+ });
18
+
19
+ return {
20
+ result,
21
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
22
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
23
+ };
24
+ };
25
+
26
+ const createTaskRepo = () => {
27
+ const base = mkdtempSync(join(tmpdir(), "schub-tasks-"));
28
+ const repoRoot = join(base, "repo");
29
+ const cwd = join(repoRoot, "nested", "dir");
30
+ mkdirSync(cwd, { recursive: true });
31
+
32
+ const tasksRoot = join(repoRoot, ".schub", "tasks");
33
+ const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
34
+
35
+ for (const status of statuses) {
36
+ mkdirSync(join(tasksRoot, status), { recursive: true });
37
+ }
38
+
39
+ return { cwd, tasksRoot };
40
+ };
41
+
42
+ const writeTask = (tasksRoot: string, status: string, id: string, slug: string, content?: string) => {
43
+ const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
44
+ const taskContent = content ?? `# ${id} ${slug}\n`;
45
+ writeFileSync(filePath, taskContent);
46
+ return filePath;
47
+ };
48
+
49
+ test("tasks list shows tasks sorted by id", () => {
50
+ const { cwd, tasksRoot } = createTaskRepo();
51
+ writeTask(tasksRoot, "ready", "T010", "later-task");
52
+ writeTask(tasksRoot, "backlog", "T002", "middle-task");
53
+ writeTask(tasksRoot, "archived", "T003", "archived-task");
54
+ writeTask(tasksRoot, "wip", "T001", "first-task");
55
+
56
+ const { result, stdout } = runCli(cwd);
57
+
58
+ expect(result.exitCode).toBe(0);
59
+ const lines = stdout.trim().split("\n").filter(Boolean);
60
+ expect(lines).toHaveLength(4);
61
+ expect(lines[0]).toContain("T001");
62
+ expect(lines[0]).toContain("first task");
63
+ expect(lines[0]).toContain("(wip)");
64
+ expect(lines[1]).toContain("T002");
65
+ expect(lines[2]).toContain("T003");
66
+ expect(lines[3]).toContain("T010");
67
+ });
68
+
69
+ test("tasks list filters by status", () => {
70
+ const { cwd, tasksRoot } = createTaskRepo();
71
+ writeTask(tasksRoot, "ready", "T010", "later-task");
72
+ writeTask(tasksRoot, "backlog", "T002", "middle-task");
73
+ writeTask(tasksRoot, "wip", "T001", "first-task");
74
+
75
+ const { result, stdout } = runCli(cwd, ["--status", "ready,wip"]);
76
+
77
+ expect(result.exitCode).toBe(0);
78
+ const lines = stdout.trim().split("\n").filter(Boolean);
79
+ expect(lines).toHaveLength(2);
80
+ expect(lines.join("\n")).toContain("T010");
81
+ expect(lines.join("\n")).toContain("T001");
82
+ expect(lines.join("\n")).not.toContain("T002");
83
+ });
84
+
85
+ test("tasks list supports json output", () => {
86
+ const { cwd, tasksRoot } = createTaskRepo();
87
+ writeTask(tasksRoot, "ready", "T010", "later-task");
88
+ writeTask(tasksRoot, "wip", "T001", "first-task");
89
+
90
+ const { result, stdout } = runCli(cwd, ["--json"]);
91
+
92
+ expect(result.exitCode).toBe(0);
93
+ const tasks = JSON.parse(stdout) as Array<{
94
+ id: string;
95
+ title: string;
96
+ status: string;
97
+ path: string;
98
+ }>;
99
+ expect(tasks[0]).toMatchObject({
100
+ id: "T001",
101
+ title: "first task",
102
+ status: "wip",
103
+ });
104
+ expect(tasks[0].path).toContain(".schub/tasks/wip/T001_first-task.md");
105
+ });
106
+
107
+ test("tasks list shows checklist counts in text output", () => {
108
+ const { cwd, tasksRoot } = createTaskRepo();
109
+ writeTask(
110
+ tasksRoot,
111
+ "wip",
112
+ "T001",
113
+ "first-task",
114
+ [
115
+ "# Task: T001 First task",
116
+ "",
117
+ "## Steps",
118
+ "- [ ] Draft outline",
119
+ "- [x] Review outline",
120
+ "",
121
+ "## Acceptance",
122
+ "- [ ] Not counted",
123
+ "",
124
+ ].join("\n"),
125
+ );
126
+ writeTask(tasksRoot, "backlog", "T002", "second-task");
127
+
128
+ const { result, stdout } = runCli(cwd);
129
+
130
+ expect(result.exitCode).toBe(0);
131
+ const lines = stdout.trim().split("\n").filter(Boolean);
132
+ expect(lines).toHaveLength(2);
133
+ expect(lines[0]).toContain("T001");
134
+ expect(lines[0]).toContain("(wip)");
135
+ expect(lines[0]).toContain("(1/2)");
136
+ expect(lines[1]).toContain("T002");
137
+ expect(lines[1]).not.toMatch(/\(\d+\/\d+\)/);
138
+ });
139
+
140
+ test("tasks list json includes checklist counts", () => {
141
+ const { cwd, tasksRoot } = createTaskRepo();
142
+ writeTask(
143
+ tasksRoot,
144
+ "ready",
145
+ "T001",
146
+ "first-task",
147
+ ["# Task: T001 First task", "", "## Steps", "- [ ] Draft outline", "- [x] Review outline", ""].join("\n"),
148
+ );
149
+ writeTask(tasksRoot, "ready", "T002", "second-task");
150
+
151
+ const { result, stdout } = runCli(cwd, ["--json"]);
152
+
153
+ expect(result.exitCode).toBe(0);
154
+ const tasks = JSON.parse(stdout) as Array<{
155
+ id: string;
156
+ title: string;
157
+ status: string;
158
+ path: string;
159
+ checklistRemaining?: number;
160
+ checklistTotal?: number;
161
+ }>;
162
+ expect(tasks[0]).toMatchObject({
163
+ id: "T001",
164
+ checklistRemaining: 1,
165
+ checklistTotal: 2,
166
+ });
167
+ expect("checklistRemaining" in tasks[1]).toBe(false);
168
+ expect("checklistTotal" in tasks[1]).toBe(false);
169
+ });
170
+
171
+ test("tasks list errors without schub root", () => {
172
+ const base = mkdtempSync(join(tmpdir(), "schub-no-root-"));
173
+ const { result, stderr } = runCli(base);
174
+
175
+ expect(result.exitCode).not.toBe(0);
176
+ expect(stderr).toContain(".schub");
177
+ });
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, renameSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render } from "ink-testing-library";
6
+ import PlanView from "./PlanView";
7
+
8
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
9
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
10
+
11
+ const extractSection = (output: string, start: string, end?: string): string => {
12
+ const startIndex = output.indexOf(start);
13
+ if (startIndex === -1) {
14
+ return "";
15
+ }
16
+
17
+ const afterStart = output.slice(startIndex + start.length);
18
+ if (!end) {
19
+ return afterStart;
20
+ }
21
+ const endIndex = afterStart.indexOf(end);
22
+ if (endIndex === -1) {
23
+ return afterStart;
24
+ }
25
+
26
+ return afterStart.slice(0, endIndex);
27
+ };
28
+
29
+ const noopCopy = () => {};
30
+
31
+ test("plan shows backlog tasks with satisfied dependencies as ready", () => {
32
+ const originalCwd = process.cwd();
33
+ const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
34
+ const tasksDir = join(base, ".schub", "tasks", "backlog");
35
+ mkdirSync(tasksDir, { recursive: true });
36
+ writeFileSync(join(tasksDir, "T001_ready-task.md"), "# Task: T001 Ready Task\n", "utf8");
37
+
38
+ try {
39
+ process.chdir(base);
40
+ const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
41
+ const output = stripAnsi(lastFrame() ?? "");
42
+ const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
43
+
44
+ expect(readySection).toContain("T001");
45
+ expect(readySection).not.toContain("No tasks ready for implementation.");
46
+ } finally {
47
+ process.chdir(originalCwd);
48
+ }
49
+ });
50
+
51
+ test("plan view omits per-item shortcuts for ready tasks", () => {
52
+ const originalCwd = process.cwd();
53
+ const base = mkdtempSync(join(tmpdir(), "schub-plan-actions-"));
54
+ const tasksDir = join(base, ".schub", "tasks", "backlog");
55
+ mkdirSync(tasksDir, { recursive: true });
56
+ writeFileSync(join(tasksDir, "T010_quick-action.md"), "# Task: T010 Quick Action\n", "utf8");
57
+
58
+ try {
59
+ process.chdir(base);
60
+ const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
61
+ const output = stripAnsi(lastFrame() ?? "");
62
+ const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
63
+
64
+ expect(readySection).toContain("T010");
65
+ expect(readySection).not.toContain("[o open file]");
66
+ expect(readySection).not.toContain("[c copy]");
67
+ } finally {
68
+ process.chdir(originalCwd);
69
+ }
70
+ });
71
+
72
+ test("plan view refreshes ready list and dependency graph when tasks change", async () => {
73
+ const originalCwd = process.cwd();
74
+ const base = mkdtempSync(join(tmpdir(), "schub-plan-refresh-"));
75
+ const tasksDir = join(base, ".schub", "tasks", "backlog");
76
+ const doneDir = join(base, ".schub", "tasks", "done");
77
+ const refreshIntervalMs = 50;
78
+ let unmount: (() => void) | undefined;
79
+
80
+ mkdirSync(tasksDir, { recursive: true });
81
+ mkdirSync(doneDir, { recursive: true });
82
+
83
+ try {
84
+ process.chdir(base);
85
+ writeFileSync(
86
+ join(tasksDir, "T002_waiting-task.md"),
87
+ "# Task: T002 Waiting Task\n\n**Depends on**: T003\n",
88
+ "utf8",
89
+ );
90
+ writeFileSync(join(tasksDir, "T003_dependency.md"), "# Task: T003 Dependency\n", "utf8");
91
+ const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
92
+ unmount = rendered.unmount;
93
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
94
+ const initialReady = extractSection(initial, "Ready to Implement", "Dependency Plan");
95
+ expect(initialReady).not.toContain("T002");
96
+ const initialGraph = extractSection(initial, "Dependency Plan");
97
+ expect(initialGraph).toContain("T002");
98
+ expect(initialGraph).toContain("T003");
99
+
100
+ renameSync(join(tasksDir, "T003_dependency.md"), join(doneDir, "T003_dependency.md"));
101
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
102
+
103
+ const refreshed = stripAnsi(rendered.lastFrame() ?? "");
104
+ const refreshedReady = extractSection(refreshed, "Ready to Implement", "Dependency Plan");
105
+ expect(refreshedReady).toContain("T002");
106
+ const refreshedGraph = extractSection(refreshed, "Dependency Plan");
107
+ expect(refreshedGraph).toContain("T002");
108
+ expect(refreshedGraph).not.toContain("T003");
109
+ } finally {
110
+ unmount?.();
111
+ process.chdir(originalCwd);
112
+ }
113
+ });
@@ -1,4 +1,5 @@
1
- import { Box, Text } from "ink";
1
+ import { dirname } from "node:path";
2
+ import { Box, Text, useInput } from "ink";
2
3
  import React from "react";
3
4
  import {
4
5
  buildTaskGraph,
@@ -8,36 +9,96 @@ import {
8
9
  type TaskStatus,
9
10
  trimTaskTitle,
10
11
  } from "../features/tasks";
12
+ import { openInVsCode } from "../ide";
11
13
 
12
14
  const PLAN_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready", "wip", "blocked"];
13
15
  const READY_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready"];
14
16
 
15
- export default function PlanView() {
16
- const schubDir = React.useMemo(() => findSchubRoot(), []);
17
- const planData = React.useMemo(() => {
17
+ type PlanViewProps = {
18
+ refreshIntervalMs?: number;
19
+ onCopyId: (id: string) => void;
20
+ };
21
+
22
+ const DEFAULT_REFRESH_INTERVAL_MS = 1000;
23
+
24
+ const buildPlanData = (schubDir: string | null) => {
25
+ if (!schubDir) {
26
+ return { visibleTasks: [], readyTasks: [], graphLines: [] };
27
+ }
28
+
29
+ const allTasks = loadTaskDependencies(schubDir);
30
+ const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
31
+ const tasksById = new Map(allTasks.map((task) => [task.id, task]));
32
+ const readyTasks = visibleTasks.filter((task) => {
33
+ if (!READY_TASK_STATUSES.includes(task.status)) {
34
+ return false;
35
+ }
36
+
37
+ return task.dependsOn.every((dependencyId) => tasksById.get(dependencyId)?.status === "done");
38
+ });
39
+
40
+ if (visibleTasks.length === 0) {
41
+ return { visibleTasks, readyTasks, graphLines: [] };
42
+ }
43
+
44
+ const graph = buildTaskGraph(visibleTasks);
45
+ const graphLines = renderTaskGraphLines(graph);
46
+ return { visibleTasks, readyTasks, graphLines };
47
+ };
48
+
49
+ export default function PlanView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: PlanViewProps) {
50
+ const schubDir = findSchubRoot();
51
+ const [, setRefreshTick] = React.useState(0);
52
+ const planData = buildPlanData(schubDir);
53
+ const [selection, setSelection] = React.useState(0);
54
+ const totalReadyTasks = planData.readyTasks.length;
55
+ const repoRoot = schubDir ? dirname(schubDir) : "";
56
+
57
+ React.useEffect(() => {
18
58
  if (!schubDir) {
19
- return { visibleTasks: [], readyTasks: [], graphLines: [] };
59
+ return;
60
+ }
61
+
62
+ const interval = setInterval(() => {
63
+ setRefreshTick((current) => current + 1);
64
+ }, refreshIntervalMs);
65
+
66
+ return () => {
67
+ clearInterval(interval);
68
+ };
69
+ }, [refreshIntervalMs, schubDir]);
70
+
71
+ React.useEffect(() => {
72
+ if (totalReadyTasks === 0) {
73
+ setSelection(0);
74
+ return;
20
75
  }
76
+ setSelection((current) => Math.min(current, totalReadyTasks - 1));
77
+ }, [totalReadyTasks]);
21
78
 
22
- const allTasks = loadTaskDependencies(schubDir);
23
- const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
24
- const tasksById = new Map(allTasks.map((task) => [task.id, task]));
25
- const readyTasks = visibleTasks.filter((task) => {
26
- if (!READY_TASK_STATUSES.includes(task.status)) {
27
- return false;
28
- }
79
+ useInput((input, key) => {
80
+ if (totalReadyTasks === 0) {
81
+ return;
82
+ }
29
83
 
30
- return task.dependsOn.every((dependencyId) => tasksById.get(dependencyId)?.status === "done");
31
- });
84
+ if (key.downArrow) {
85
+ setSelection((current) => Math.min(current + 1, totalReadyTasks - 1));
86
+ }
32
87
 
33
- if (visibleTasks.length === 0) {
34
- return { visibleTasks, readyTasks, graphLines: [] };
88
+ if (key.upArrow) {
89
+ setSelection((current) => Math.max(current - 1, 0));
35
90
  }
36
91
 
37
- const graph = buildTaskGraph(visibleTasks);
38
- const graphLines = renderTaskGraphLines(graph);
39
- return { visibleTasks, readyTasks, graphLines };
40
- }, [schubDir]);
92
+ if (input === "o") {
93
+ const selectedTask = planData.readyTasks[selection];
94
+ openInVsCode(repoRoot, selectedTask.path);
95
+ }
96
+
97
+ if (input === "c") {
98
+ const selectedTask = planData.readyTasks[selection];
99
+ onCopyId(selectedTask.id);
100
+ }
101
+ });
41
102
 
42
103
  if (!schubDir) {
43
104
  return (
@@ -66,12 +127,20 @@ export default function PlanView() {
66
127
  {planData.readyTasks.length === 0 ? (
67
128
  <Text color="gray">No tasks ready for implementation.</Text>
68
129
  ) : (
69
- planData.readyTasks.map((task) => (
70
- <Box key={task.id}>
71
- <Text color="white">{`${task.id}`}</Text>
72
- <Text color="gray"> {trimTaskTitle(task.title)}</Text>
73
- </Box>
74
- ))
130
+ planData.readyTasks.map((task, index) => {
131
+ const selected = index === selection;
132
+ return (
133
+ <Box key={task.id} marginLeft={1}>
134
+ <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
135
+ <Box marginLeft={1}>
136
+ <Text color="white" bold={selected}>
137
+ {task.id}
138
+ </Text>
139
+ <Text color="gray"> {trimTaskTitle(task.title)}</Text>
140
+ </Box>
141
+ </Box>
142
+ );
143
+ })
75
144
  )}
76
145
  </Box>
77
146
  <Box flexDirection="column" marginTop={2}>