schub 0.1.0

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.
package/src/index.ts ADDED
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env bun
2
+ import { dirname } from "node:path";
3
+ import { spawnSync } from "bun";
4
+ import { render } from "ink";
5
+ import React from "react";
6
+ import App from "./App";
7
+ import { createChange, resolveChangeRoot } from "./changes";
8
+ import { createTask, findSchubRoot, listTasks, TASK_STATUSES, type TaskStatus } from "./features/tasks";
9
+ import { createProject } from "./project";
10
+ import { applyTerminalPrelude, applyTerminalReset } from "./terminal";
11
+
12
+ const HELP_TEXT = `schub [command]
13
+
14
+ Commands:
15
+ changes create Create a change proposal
16
+ project create Create project docs
17
+ tasks create Create task files for a change
18
+ tasks list List tasks
19
+ lint Run Biome lint checks
20
+ format Format files with Biome
21
+ ui Launch the interactive dashboard
22
+ `;
23
+
24
+ const getStartDir = (): string => process.env.SCHUB_CWD ?? process.cwd();
25
+
26
+ const getWorkspaceRoot = (): string => {
27
+ const schubDir = findSchubRoot(getStartDir());
28
+ return schubDir ? dirname(schubDir) : getStartDir();
29
+ };
30
+
31
+ const resolveSchubDir = (): string | null => findSchubRoot(getStartDir());
32
+
33
+ const printHelp = (exitCode = 0): void => {
34
+ process.stdout.write(`${HELP_TEXT}\n`);
35
+ process.exitCode = exitCode;
36
+ };
37
+
38
+ const parseStatusFilter = (value: string | undefined): TaskStatus[] => {
39
+ if (!value) {
40
+ return [...TASK_STATUSES];
41
+ }
42
+
43
+ const normalized = value
44
+ .split(",")
45
+ .map((status) => status.trim().toLowerCase())
46
+ .filter(Boolean);
47
+
48
+ const allowed = new Set(TASK_STATUSES);
49
+ const invalid = normalized.filter((status) => !allowed.has(status as TaskStatus));
50
+ if (invalid.length > 0) {
51
+ throw new Error(`Unknown status filter: ${invalid.join(", ")}`);
52
+ }
53
+
54
+ return normalized as TaskStatus[];
55
+ };
56
+
57
+ type TaskListOptions = {
58
+ statuses: TaskStatus[];
59
+ json: boolean;
60
+ };
61
+
62
+ const parseTaskListOptions = (args: string[]): TaskListOptions => {
63
+ let statusValue: string | undefined;
64
+ let json = false;
65
+ const unknown: string[] = [];
66
+
67
+ for (let index = 0; index < args.length; index += 1) {
68
+ const arg = args[index];
69
+ if (arg === "--json") {
70
+ json = true;
71
+ continue;
72
+ }
73
+ if (arg === "--status") {
74
+ statusValue = args[index + 1];
75
+ if (!statusValue) {
76
+ throw new Error("Missing value for --status.");
77
+ }
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (arg.startsWith("--status=")) {
82
+ statusValue = arg.slice("--status=".length);
83
+ continue;
84
+ }
85
+ unknown.push(arg);
86
+ }
87
+
88
+ if (unknown.length > 0) {
89
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
90
+ }
91
+
92
+ return { statuses: parseStatusFilter(statusValue), json };
93
+ };
94
+
95
+ type ChangeCreateOptions = {
96
+ changeId?: string;
97
+ title?: string;
98
+ input?: string;
99
+ overwrite: boolean;
100
+ };
101
+
102
+ type ProjectCreateOptions = {
103
+ repoRoot?: string;
104
+ projectName?: string;
105
+ overwrite: boolean;
106
+ };
107
+
108
+ const parseChangeCreateOptions = (args: string[]) => {
109
+ let changeId: string | undefined;
110
+ let title: string | undefined;
111
+ let input: string | undefined;
112
+ let overwrite = false;
113
+ const unknown: string[] = [];
114
+
115
+ const rejectUnsupported = (flag: string) => {
116
+ throw new Error(`Unsupported option: ${flag}.`);
117
+ };
118
+
119
+ for (let index = 0; index < args.length; index += 1) {
120
+ const arg = args[index];
121
+ if (arg === "--overwrite") {
122
+ overwrite = true;
123
+ continue;
124
+ }
125
+ if (arg === "--change-id") {
126
+ changeId = args[index + 1];
127
+ if (changeId === undefined) {
128
+ throw new Error("Missing value for --change-id.");
129
+ }
130
+ index += 1;
131
+ continue;
132
+ }
133
+ if (arg.startsWith("--change-id=")) {
134
+ changeId = arg.slice("--change-id=".length);
135
+ continue;
136
+ }
137
+ if (arg === "--title") {
138
+ title = args[index + 1];
139
+ if (title === undefined) {
140
+ throw new Error("Missing value for --title.");
141
+ }
142
+ index += 1;
143
+ continue;
144
+ }
145
+ if (arg.startsWith("--title=")) {
146
+ title = arg.slice("--title=".length);
147
+ continue;
148
+ }
149
+ if (arg === "--input") {
150
+ input = args[index + 1];
151
+ if (input === undefined) {
152
+ throw new Error("Missing value for --input.");
153
+ }
154
+ index += 1;
155
+ continue;
156
+ }
157
+ if (arg.startsWith("--input=")) {
158
+ input = arg.slice("--input=".length);
159
+ continue;
160
+ }
161
+ if (arg === "--schub-root" || arg === "--agent-root") {
162
+ rejectUnsupported(arg);
163
+ }
164
+ if (arg.startsWith("--schub-root=")) {
165
+ rejectUnsupported("--schub-root");
166
+ }
167
+ if (arg.startsWith("--agent-root=")) {
168
+ rejectUnsupported("--agent-root");
169
+ }
170
+ unknown.push(arg);
171
+ }
172
+
173
+ if (unknown.length > 0) {
174
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
175
+ }
176
+
177
+ const options: ChangeCreateOptions = { changeId, title, input, overwrite };
178
+ return options;
179
+ };
180
+
181
+ const parseProjectCreateOptions = (args: string[]) => {
182
+ let repoRoot: string | undefined;
183
+ let projectName: string | undefined;
184
+ let overwrite = false;
185
+ const unknown: string[] = [];
186
+
187
+ const rejectUnsupported = (flag: string) => {
188
+ throw new Error(`Unsupported option: ${flag}.`);
189
+ };
190
+
191
+ for (let index = 0; index < args.length; index += 1) {
192
+ const arg = args[index];
193
+ if (arg === "--overwrite") {
194
+ overwrite = true;
195
+ continue;
196
+ }
197
+ if (arg === "--repo-root") {
198
+ repoRoot = args[index + 1];
199
+ if (repoRoot === undefined) {
200
+ throw new Error("Missing value for --repo-root.");
201
+ }
202
+ index += 1;
203
+ continue;
204
+ }
205
+ if (arg.startsWith("--repo-root=")) {
206
+ repoRoot = arg.slice("--repo-root=".length);
207
+ continue;
208
+ }
209
+ if (arg === "--project-name") {
210
+ projectName = args[index + 1];
211
+ if (projectName === undefined) {
212
+ throw new Error("Missing value for --project-name.");
213
+ }
214
+ index += 1;
215
+ continue;
216
+ }
217
+ if (arg.startsWith("--project-name=")) {
218
+ projectName = arg.slice("--project-name=".length);
219
+ continue;
220
+ }
221
+ if (arg === "--schub-root" || arg === "--agent-root") {
222
+ rejectUnsupported(arg);
223
+ }
224
+ if (arg.startsWith("--schub-root=")) {
225
+ rejectUnsupported("--schub-root");
226
+ }
227
+ if (arg.startsWith("--agent-root=")) {
228
+ rejectUnsupported("--agent-root");
229
+ }
230
+ unknown.push(arg);
231
+ }
232
+
233
+ if (unknown.length > 0) {
234
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
235
+ }
236
+
237
+ return { repoRoot, projectName, overwrite };
238
+ };
239
+
240
+ type TaskCreateOptions = {
241
+ changeId: string;
242
+ status?: string;
243
+ titles: string[];
244
+ overwrite: boolean;
245
+ };
246
+
247
+ const parseTaskCreateOptions = (args: string[]) => {
248
+ let changeId: string | undefined;
249
+ let status: string | undefined;
250
+ let overwrite = false;
251
+ const titles: string[] = [];
252
+ const unknown: string[] = [];
253
+
254
+ const rejectUnsupported = (flag: string) => {
255
+ throw new Error(`Unsupported option: ${flag}.`);
256
+ };
257
+
258
+ for (let index = 0; index < args.length; index += 1) {
259
+ const arg = args[index];
260
+ if (arg === "--overwrite") {
261
+ overwrite = true;
262
+ continue;
263
+ }
264
+ if (arg === "--change-id") {
265
+ changeId = args[index + 1];
266
+ if (changeId === undefined) {
267
+ throw new Error("Missing value for --change-id.");
268
+ }
269
+ index += 1;
270
+ continue;
271
+ }
272
+ if (arg.startsWith("--change-id=")) {
273
+ changeId = arg.slice("--change-id=".length);
274
+ continue;
275
+ }
276
+ if (arg === "--status") {
277
+ status = args[index + 1];
278
+ if (status === undefined) {
279
+ throw new Error("Missing value for --status.");
280
+ }
281
+ index += 1;
282
+ continue;
283
+ }
284
+ if (arg.startsWith("--status=")) {
285
+ status = arg.slice("--status=".length);
286
+ continue;
287
+ }
288
+ if (arg === "--title") {
289
+ const title = args[index + 1];
290
+ if (title === undefined) {
291
+ throw new Error("Missing value for --title.");
292
+ }
293
+ titles.push(title);
294
+ index += 1;
295
+ continue;
296
+ }
297
+ if (arg.startsWith("--title=")) {
298
+ titles.push(arg.slice("--title=".length));
299
+ continue;
300
+ }
301
+ if (arg === "--schub-root" || arg === "--agent-root") {
302
+ rejectUnsupported(arg);
303
+ }
304
+ if (arg.startsWith("--schub-root=")) {
305
+ rejectUnsupported("--schub-root");
306
+ }
307
+ if (arg.startsWith("--agent-root=")) {
308
+ rejectUnsupported("--agent-root");
309
+ }
310
+ unknown.push(arg);
311
+ }
312
+
313
+ if (unknown.length > 0) {
314
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
315
+ }
316
+
317
+ if (!changeId) {
318
+ throw new Error("Provide --change-id.");
319
+ }
320
+
321
+ const options: TaskCreateOptions = { changeId, status, titles, overwrite };
322
+ return options;
323
+ };
324
+
325
+ const runTasksList = (args: string[]): void => {
326
+ const schubDir = resolveSchubDir();
327
+ if (!schubDir) {
328
+ throw new Error("No .schub directory found.");
329
+ }
330
+
331
+ const options = parseTaskListOptions(args);
332
+ const tasks = listTasks(schubDir, options.statuses);
333
+
334
+ if (options.json) {
335
+ process.stdout.write(`${JSON.stringify(tasks, null, 2)}\n`);
336
+ return;
337
+ }
338
+
339
+ const lines = tasks.map((task) => `${task.id} ${task.title} (${task.status})`);
340
+ process.stdout.write(`${lines.join("\n")}\n`);
341
+ };
342
+
343
+ const runTasksCreate = (args: string[]) => {
344
+ const options = parseTaskCreateOptions(args);
345
+ const schubDir = resolveChangeRoot(getStartDir());
346
+ const created = createTask(schubDir, options);
347
+
348
+ for (const taskPath of created) {
349
+ process.stdout.write(`[OK] Wrote task: ${taskPath}\n`);
350
+ }
351
+ };
352
+
353
+ const runBiome = (command: "lint" | "format"): void => {
354
+ const workspaceRoot = getWorkspaceRoot();
355
+ const biomeArgs = command === "lint" ? ["lint", "."] : ["format", ".", "--write"];
356
+ const result = spawnSync({
357
+ cmd: ["bunx", "@biomejs/biome", ...biomeArgs],
358
+ cwd: workspaceRoot,
359
+ env: process.env,
360
+ });
361
+
362
+ if (result.stdout && result.stdout.length > 0) {
363
+ process.stdout.write(result.stdout);
364
+ }
365
+
366
+ if (result.stderr && result.stderr.length > 0) {
367
+ process.stderr.write(result.stderr);
368
+ }
369
+
370
+ process.exitCode = result.exitCode ?? 1;
371
+ };
372
+
373
+ const runChangesCreate = (args: string[]) => {
374
+ const options = parseChangeCreateOptions(args);
375
+ const schubDir = resolveChangeRoot(getStartDir());
376
+ const proposalPath = createChange(schubDir, options);
377
+ process.stdout.write(`[OK] Wrote proposal: ${proposalPath}\n`);
378
+ };
379
+
380
+ const runProjectCreate = (args: string[]) => {
381
+ const options = parseProjectCreateOptions(args);
382
+ const outputs = createProject(getStartDir(), options);
383
+
384
+ for (const output of outputs) {
385
+ process.stdout.write(`[OK] Wrote ${output}\n`);
386
+ }
387
+ };
388
+
389
+ const registerTerminalReset = () => {
390
+ process.once("exit", () => {
391
+ applyTerminalReset();
392
+ });
393
+ };
394
+
395
+ const runUi = () => {
396
+ applyTerminalPrelude();
397
+ registerTerminalReset();
398
+ render(React.createElement(App));
399
+ };
400
+
401
+ const runCommand = (): void => {
402
+ const args = process.argv.slice(2);
403
+ if (args.length === 0) {
404
+ runUi();
405
+ return;
406
+ }
407
+
408
+ if (args.includes("--help") || args.includes("-h")) {
409
+ printHelp();
410
+ return;
411
+ }
412
+
413
+ const [primary, secondary, ...rest] = args;
414
+
415
+ switch (primary) {
416
+ case "changes":
417
+ if (secondary === "create") {
418
+ runChangesCreate(rest);
419
+ return;
420
+ }
421
+ break;
422
+ case "project":
423
+ if (secondary === "create") {
424
+ runProjectCreate(rest);
425
+ return;
426
+ }
427
+ break;
428
+ case "tasks":
429
+ if (secondary === "list") {
430
+ runTasksList(rest);
431
+ return;
432
+ }
433
+ if (secondary === "create") {
434
+ runTasksCreate(rest);
435
+ return;
436
+ }
437
+ break;
438
+ case "lint":
439
+ runBiome("lint");
440
+ return;
441
+ case "format":
442
+ runBiome("format");
443
+ return;
444
+ case "ui":
445
+ runUi();
446
+ return;
447
+ }
448
+
449
+ printHelp(1);
450
+ };
451
+
452
+ try {
453
+ runCommand();
454
+ } catch (error) {
455
+ const message = error instanceof Error ? error.message : String(error);
456
+ process.stderr.write(`${message}\n`);
457
+ process.exitCode = 1;
458
+ }
package/src/project.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ type ProjectCreateOptions = {
6
+ repoRoot?: string;
7
+ projectName?: string;
8
+ overwrite?: boolean;
9
+ };
10
+
11
+ const TEMPLATE_FILES = {
12
+ "project-overview.md": "project-overview-template.md",
13
+ "project-setup.md": "project-setup-template.md",
14
+ "project-wow.md": "project-wow-template.md",
15
+ };
16
+
17
+ const TEMPLATES_ROOT = fileURLToPath(new URL("../templates/setup-project", import.meta.url));
18
+
19
+ const isDirectory = (path: string) => {
20
+ try {
21
+ return statSync(path).isDirectory();
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+
27
+ const resolveSchubRoot = (startDir: string) => {
28
+ let current = resolve(startDir);
29
+ const fallback = join(current, ".schub");
30
+
31
+ while (true) {
32
+ if (basename(current) === ".schub" && isDirectory(current)) {
33
+ return current;
34
+ }
35
+
36
+ const candidate = join(current, ".schub");
37
+ if (isDirectory(candidate)) {
38
+ return candidate;
39
+ }
40
+
41
+ const parent = dirname(current);
42
+ if (parent === current) {
43
+ return fallback;
44
+ }
45
+ current = parent;
46
+ }
47
+ };
48
+
49
+ const readTemplate = (path: string) => {
50
+ try {
51
+ return readFileSync(path, "utf8");
52
+ } catch {
53
+ throw new Error(`[ERROR] Template not found: ${path}`);
54
+ }
55
+ };
56
+
57
+ const writeOutput = (path: string, content: string, overwrite: boolean) => {
58
+ if (existsSync(path) && !overwrite) {
59
+ throw new Error(`[ERROR] Refusing to overwrite existing file: ${path}`);
60
+ }
61
+
62
+ mkdirSync(dirname(path), { recursive: true });
63
+ writeFileSync(path, content, "utf8");
64
+ };
65
+
66
+ const deriveProjectName = (repoRoot: string) => {
67
+ const name = basename(repoRoot).trim();
68
+ return name || "Project";
69
+ };
70
+
71
+ const resolveRepoRoot = (startDir: string, schubRoot: string, repoRoot?: string) => {
72
+ if (repoRoot) {
73
+ return resolve(startDir, repoRoot);
74
+ }
75
+
76
+ return resolve(schubRoot, "..");
77
+ };
78
+
79
+ export const createProject = (startDir: string, options: ProjectCreateOptions) => {
80
+ const schubRoot = resolveSchubRoot(startDir);
81
+ const repoRoot = resolveRepoRoot(startDir, schubRoot, options.repoRoot);
82
+ const projectName = options.projectName || deriveProjectName(repoRoot);
83
+ const overwrite = options.overwrite ?? false;
84
+ const created: string[] = [];
85
+
86
+ for (const [outputName, templateName] of Object.entries(TEMPLATE_FILES)) {
87
+ const templatePath = join(TEMPLATES_ROOT, templateName);
88
+ const template = readTemplate(templatePath);
89
+ const rendered = template.split("[Project Name]").join(projectName);
90
+ const outputPath = join(schubRoot, outputName);
91
+ writeOutput(outputPath, rendered, overwrite);
92
+ created.push(outputPath);
93
+ }
94
+
95
+ return created;
96
+ };
package/src/tasks.ts ADDED
@@ -0,0 +1,15 @@
1
+ export {
2
+ buildTaskGraph,
3
+ findSchubRoot,
4
+ listTasks,
5
+ loadTaskDependencies,
6
+ renderTaskGraphLines,
7
+ TASK_STATUSES,
8
+ type TaskDependency,
9
+ type TaskGraph,
10
+ type TaskGraphLine,
11
+ type TaskGraphNode,
12
+ type TaskInfo,
13
+ type TaskStatus,
14
+ trimTaskTitle,
15
+ } from "./features/tasks";
@@ -0,0 +1,16 @@
1
+ const CLEAR_SCROLLBACK = "\u001b[3J";
2
+ const CLEAR_SCREEN = "\u001b[2J";
3
+ const CURSOR_HOME = "\u001b[H";
4
+ const BLACK_BACKGROUND = "\u001b[40m";
5
+ const RESET = "\u001b[0m";
6
+
7
+ export const terminalPrelude = `${CLEAR_SCROLLBACK}${CLEAR_SCREEN}${CURSOR_HOME}${BLACK_BACKGROUND}`;
8
+ export const terminalReset = RESET;
9
+
10
+ export const applyTerminalPrelude = () => {
11
+ process.stdout.write(terminalPrelude);
12
+ };
13
+
14
+ export const applyTerminalReset = () => {
15
+ process.stdout.write(terminalReset);
16
+ };
@@ -0,0 +1,51 @@
1
+ # ADR: [TITLE]
2
+
3
+ **Status**: [Proposed | Accepted | Deprecated | Superseded]
4
+ **Date**: [YYYY-MM-DD]
5
+ **Related**: [PR link, spec link, plan link, issue link]
6
+
7
+ > Use ADRs to capture the **WHY**, not the implementation details.
8
+ > Keep it short and specific: context → decision → rationale → consequences.
9
+
10
+ ## Context
11
+
12
+ [What problem are we solving? What constraints, requirements, or incidents led here?]
13
+
14
+ ## Decision
15
+
16
+ [What are we choosing to do? Be explicit and testable.]
17
+
18
+ ## Rationale
19
+
20
+ [Why this decision? Include tradeoffs and decision drivers.]
21
+
22
+ ## Alternatives Considered
23
+
24
+ 1. **[Alternative A]**: [why rejected]
25
+ 2. **[Alternative B]**: [why rejected]
26
+
27
+ ## Consequences
28
+
29
+ ### Positive
30
+
31
+ - [What gets better?]
32
+
33
+ ### Negative / Risks
34
+
35
+ - [What gets worse or harder?]
36
+
37
+ ## Migration / Rollout Plan
38
+
39
+ [How do we move from the current state to the decided state? Include any sequencing.]
40
+
41
+ ## Verification & Evidence
42
+
43
+ _Goal: the decision’s impact is verifiable by an LLM through execution + concrete outputs._
44
+
45
+ - **Commands to run**: [exact commands]
46
+ - **Expected evidence**: [logs, HTTP responses, screenshots/videos/traces, artifacts]
47
+ - **Where to find artifacts**: [paths, URLs, container/log sources]
48
+
49
+ ## Follow-ups
50
+
51
+ - [TODO items, owners, deadlines]
@@ -0,0 +1,51 @@
1
+ # Proposal - {{CHANGE_TITLE}}
2
+
3
+ **Change ID**: `{{CHANGE_ID}}`
4
+ **Created**: {{DATE}}
5
+ **Status**: Draft
6
+ **Input**: {{INPUT}}
7
+
8
+ ## Summary
9
+
10
+ [1–3 sentences describing the change and the user-visible outcome.]
11
+
12
+ ## Why
13
+
14
+ [Problem/opportunity + motivation. What’s broken/missing today? Why now?]
15
+
16
+ ## Goals
17
+
18
+ - [Outcome 1]
19
+ - [Outcome 2]
20
+
21
+ ### Non-Goals
22
+
23
+ - [What is explicitly not included]
24
+
25
+ ## User Scenarios & Acceptance
26
+
27
+ ### Scenario 1 — [Title] (Priority: P1)
28
+
29
+ > **Given** [state], **When** [action], **Then** [outcome]
30
+
31
+ ### Scenario 2 — [Title] (Priority: P2)
32
+
33
+ > **Given** [state], **When** [action], **Then** [outcome]
34
+
35
+ ## Implementation Outline
36
+
37
+ 1. [Phase 1]
38
+ 2. [Phase 2]
39
+
40
+ ## Change Touch Points
41
+
42
+ - [Exact paths, systems, or components touched]
43
+
44
+ ## Assumptions
45
+
46
+ - A1: [Assumption]
47
+ - A2: [Assumption]
48
+
49
+ ## Potential Issues
50
+
51
+ - [List potential issues, conflicts, discrepancies, risks]