schub 0.1.0 → 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 (50) hide show
  1. package/README.md +68 -0
  2. package/dist/index.js +1573 -597
  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 +62 -10
  11. package/src/changes.ts +86 -28
  12. package/src/clipboard.ts +5 -0
  13. package/src/commands/adr.test.ts +69 -0
  14. package/src/commands/adr.ts +107 -0
  15. package/src/commands/changes.test.ts +171 -0
  16. package/src/commands/changes.ts +163 -0
  17. package/src/commands/cookbook.test.ts +71 -0
  18. package/src/commands/cookbook.ts +95 -0
  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/project.ts +75 -0
  25. package/src/commands/review.test.ts +100 -0
  26. package/src/commands/review.ts +231 -0
  27. package/src/commands/tasks-create.test.ts +172 -0
  28. package/src/commands/tasks-list.test.ts +177 -0
  29. package/src/commands/tasks.ts +172 -0
  30. package/src/components/PlanView.test.tsx +113 -0
  31. package/src/components/PlanView.tsx +95 -26
  32. package/src/components/StatusView.test.tsx +380 -0
  33. package/src/components/StatusView.tsx +233 -83
  34. package/src/features/tasks/constants.ts +2 -0
  35. package/src/features/tasks/create.ts +15 -7
  36. package/src/features/tasks/filesystem.test.ts +78 -0
  37. package/src/features/tasks/filesystem.ts +61 -7
  38. package/src/ide.ts +7 -0
  39. package/src/index.test.ts +23 -0
  40. package/src/index.ts +60 -383
  41. package/src/init.test.ts +43 -0
  42. package/src/init.ts +27 -0
  43. package/src/project.ts +5 -32
  44. package/src/schub-root.ts +33 -0
  45. package/src/templates.ts +18 -0
  46. package/src/terminal.test.ts +46 -0
  47. package/templates/create-proposal/cookbook-template.md +37 -0
  48. package/templates/review-proposal/q&a-template.md +5 -1
  49. package/templates/templates-parity.test.ts +45 -0
  50. package/templates/setup-project/review-me-template.md +0 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "schub",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "schub": "./src/index.ts"
@@ -8,10 +8,12 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "src",
11
+ "skills",
11
12
  "templates"
12
13
  ],
13
14
  "scripts": {
14
15
  "schub": "bun ./src/index.ts",
16
+ "prepublishOnly": "npm run build",
15
17
  "build": "bun build ./src/index.ts --outdir dist --target node",
16
18
  "lint": "bunx @biomejs/biome lint .",
17
19
  "format": "bunx @biomejs/biome format --write .",
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: create-proposal
3
+ description: "Create or update proposals and review requirements with the user. Use this when asked to write a proposal, introduce changes that add features, large refactors, introduce breaking API or schema changes, modify architecture or design patterns, update security patterns, or after creating a plan, to save a plan as proposal. Do not create proposals for bug fixes that restore intended behavior, typos or formatting/comment-only changes, non-breaking dependency updates, configuration-only changes, or tests that validate existing behavior."
4
+ ---
5
+
6
+ ## User Input
7
+
8
+ ```text
9
+ $ARGUMENTS
10
+ ```
11
+
12
+ ## Workflow
13
+
14
+ 1. Derive a concise, verb-led `change-id` suffix from the request (kebab-case: `add-`, `update-`, `remove-`, `refactor-`, `fix-`) and prefix it with `C` + the next 3-digit sequence + `_` (e.g., `C001_add-user-auth`).
15
+ 2. Run `npx schub changes create --change-id "<change-id>" --title "<title>" --input "<user prompt verbatim>"` to scaffold the proposal template.
16
+ - Supported flags: `--change-id`, `--title`, `--input`, `--overwrite`.
17
+ 3. Update the generated proposal with concrete, testable statements.
18
+ 4. Identify the touch points throughout the project and track them in the proposal.
19
+ 5. Track missing information with [MISSING INFORMATION] tags in the proposal.
20
+ 6. (OPTIONAL) If the change affects a public surface, e.g. an api / sdk / cli. Run `npx schub cookbook create --change-id "<change-id>"` to scaffold `cookbook.md`.
21
+ 7. (OPTIONAL) If implementing the change requires knowledge of an api / db schema etc. encode this in`contracts.md` or `schemas.md` files
22
+ 8. (OPTIONAL) For complex tasks requiring deep understanding of the system, track relevant additional information in `research.md`
23
+ 9. (OPTIONAL) For decisions with lasting architectural impact or important tradeoffs, run `npx schub adr create --change-id "<change-id>" --title "<title>"`.
24
+ 10. Proceed with the review-proposal skill (even without user request).
25
+
26
+ ## Output Locations
27
+
28
+ - Proposal: `.schub/changes/<change-id>/proposal.md`
29
+ - (OPTIONAL) Cookbook: `.schub/changes/<change-id>/cookbook.md`
30
+ - (OPTIONAL) Schemas: `.schub/changes/<change-id>/schemas.md`
31
+ - (OPTIONAL) Contracts: `.schub/changes/<change-id>/contracts.md`
32
+ - (OPTIONAL) Research: `.schub/changes/<change-id>/research.md`
33
+ - (OPTIONAL) ADR: `.schub/changes/<change-id>/adr.md`
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: create-tasks
3
+ description: "Break an accepted proposal into actionable task files under `.schub/tasks/` using the task template. Use when asked to create tasks for a change-id."
4
+ ---
5
+
6
+ ## User Input (should contain change-id or shorthand `C###`)
7
+
8
+ ```text
9
+ $ARGUMENTS
10
+ ```
11
+
12
+ ## Workflow
13
+
14
+ 1. If the user provides shorthand `C###`, resolve it to the matching `C###_<suffix>` change folder (error if ambiguous). Confirm `.schub/changes/<change-id>/proposal.md` exists and that it is marked accepted. If it is missing, ask the user to run the create-proposal skill first. If it is still a draft, ask the user to review it first.
15
+ 2. Read the proposal and other files in the <change-id> folder.
16
+ 3. Derive tasks, each task should be:
17
+ - Small enough to implement in one sitting.
18
+ - Independently testable.
19
+ - Explicit about file paths and affected components.
20
+ 4. Assign a unique task id using `T###` (ex: `T001`) and avoid collisions with existing tasks.
21
+ 5. Run `npx schub tasks create --change-id "<change-id>" --title "<task title>"` to scaffold tasks.
22
+ - Repeat `--title` for multiple tasks.
23
+ - Optional flags: `--status "<status>"` (default: `backlog`), `--overwrite`.
24
+ 6. Fill the template fields with concrete details
25
+ - Priority (P1/P2/P3)
26
+ - Parallelizable (yes/no)
27
+ - Goal, scope, steps, acceptance, and evidence
28
+ 7. When defining the acceptance, include relevant tests, e2e commands and documentation updates.
29
+ 8. Resolve blockers by check all existing tasks that are not done. If another task is a blocker, add it to **Depends on**.
30
+ 9. Stop after the task files are created. Do not implement code or modify the plan or supporting artifacts.
31
+
32
+ ## Output Locations
33
+
34
+ - `.schub/tasks/backlog/<task-id>_<task-name>.md`
35
+
36
+ ## Task Notes
37
+
38
+ - Use the plan's acceptance alignment to define acceptance criteria.
39
+ - Split tasks that span multiple systems or large scopes.
40
+ - Record assumptions in the task body if key details are missing.
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: implement-task
3
+ description: "Implement a single task end-to-end: locate the task file by id, move it across status folders (`ready`, `wip`, `blocked`, `done`). Use when asked to implement or complete a task."
4
+ ---
5
+
6
+ ## User Input
7
+
8
+ ```text
9
+ $ARGUMENTS
10
+ ```
11
+
12
+ ## Workflow
13
+
14
+ 1. **Identify the task**
15
+ - Accept: task id, full filename, or `next` / `continue`.
16
+ - If unclear or missing, ask for **one**: task id (kebab-case), exact filename, or `next`.
17
+
18
+ 2. **Find the task file**
19
+ - If `next` / `continue`: sort `.schub/tasks/ready/`, pick the first file.
20
+ - Otherwise search `.schub/tasks/{ready,backlog,wip,done,blocked}/` for `<task-id>_*.md`.
21
+ - If multiple matches, ask the user to choose.
22
+ - If none found, ask the user to confirm the task id.
23
+
24
+ 3. **Move the task before work**
25
+ - If starting work: move to `.schub/tasks/wip/` (create folder if needed).
26
+ - If blocked: move to `.schub/tasks/blocked/` and record the blocker in the task file.
27
+ - If complete: move to `.schub/tasks/done/` after confirming all checklists are checked.
28
+ - Keep the task in **exactly one** status folder.
29
+
30
+ 4. **TDD loop**
31
+ - **Write tests first (Red)**
32
+ - Add/extend tests based on `## Acceptance` (and key `## Steps`).
33
+ - Run tests and confirm they fail for the right reason.
34
+ - **Implement the smallest change (Green)**
35
+ - Write minimal code to make the failing test pass.
36
+ - Re-run tests until green.
37
+ - **Refactor (Refactor)**
38
+ - Clean up code and tests (names, structure, duplication) without changing behavior.
39
+ - Re-run tests to confirm still green.
40
+ - Repeat until all acceptance criteria are covered by tests and passing.
41
+
42
+ 6. **Update task checklists as you go**
43
+ - Check off `## Steps` only when:
44
+ - A test exists that covers it, and
45
+ - Tests pass, and
46
+ - Any required refactor is done.
47
+ - Check off `## Acceptance` only when:
48
+ - There’s test coverage for it, and
49
+ - The full test suite is green.
50
+
51
+ 7. **Evidence**
52
+ - Update the task file `## Evidence` section to reference generated artifacts.
53
+ - If tests/commands can’t be run, record why in `## Evidence`.
54
+
55
+ 8. **Finish**
56
+ - Confirm everything in `## Steps` and `## Acceptance` is checked.
57
+ - If the task is not completed due to errors, move the task file to `.schub/tasks/blocked/`.
58
+ - If the task is completed, move the task file to `.schub/tasks/done/`.
59
+ - Report completed files and artifacts.
60
+
61
+ ## Validation
62
+
63
+ To be considered "done", a task should provide Validation Artifacts. Validation Artifacts are **verifiable outputs** produced while doing the task.
64
+ e.g. by running `some_command &> some-logs.txt`
65
+
66
+ Agents should dump and review artifacts, including:
67
+
68
+ - Test, Build and Run outputs
69
+ - Walkthroughs
70
+ - Screenshots or screen recordings (UI / E2E)
71
+ - `curl` responses
72
+ - Any files needed to prove the task is implemented correctly
73
+
74
+ Artifacts **must** be:
75
+
76
+ - Concrete
77
+ - Inspectable
78
+ - Reproducible
79
+ - Referenced as evidence in the task
80
+
81
+ ## Output Locations
82
+
83
+ - Tasks: `.schub/tasks/{ready,backlog,wip,done,blocked}/<task-id>_<task-name>.md`
84
+ - Validation Artifacts: `.schub/artifacts/<task-id>/`
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: review-proposal
3
+ description: "Run a review session for a proposal by creating a list of open questions, updating the proposal, adding a Q&A section, and deleting REVIEW_ME.md on completion. Use when asked to review proposals."
4
+ ---
5
+
6
+ ## User Input (change-id or shorthand `C###`)
7
+
8
+ ```text
9
+ $ARGUMENTS
10
+ ```
11
+
12
+ ## Workflow
13
+
14
+ 0. If the user provides shorthand `C###`, resolve it to the matching `C###_<suffix>` change folder (error if ambiguous). Mark the proposal Status as "In Review"
15
+ 1. Review the proposal by checking the [MISSING INFORMATION] tags and the potential issues listed.
16
+ 2. If there are issues, or missing information, run `npx schub review create --change-id "<change-id>"` to scaffold `.schub/changes/<change-id>/REVIEW_ME.md`.
17
+ 3. Triage each item:
18
+ - **High-stakes** (scope, risks, dependencies, security, performance): create a bullet point item in the review checklist.
19
+ - **Minute details** (naming, copy, formatting, low-impact defaults): make a decision update the proposal and mark as an assumption in the proposal.
20
+ 4. Once the review checklist is completed, gather unchecked items (`- [ ]`) in order.
21
+ 5. Start the review loop (process unchecked items in order), ask each item as a question. If unclear, ask a quick follow-up and do not advance.
22
+ - Update the review checklist accordingly inline with the answers.
23
+ 6. When no unchecked items remain, update the plan with the new information.
24
+ 7. When no unchecked items remain, run `npx schub review complete --change-id "<change-id>"` to create `.schub/changes/<change-id>/Q&A.md`.
25
+ - Ask the LLM to migrate the TODO block into the Q&A sections and remove the TODO block.
26
+ 8. Mark the proposal Status as "Accepted".
27
+
28
+ ## Output Locations
29
+
30
+ - Proposal: `.schub/changes/<change-id>/proposal.md`
31
+ - (OPTIONAL) Review In Progress: `.schub/changes/<change-id>/REVIEW_ME.md`
32
+ - (OPTIONAL) Review Completed: `.schub/changes/<change-id>/Q&A.md`
33
+
34
+ ## Notes
35
+
36
+ - Avoid expanding scope beyond missing information in the proposal.
37
+ - Focus review on high-stakes issues; place details into assumptions instead of asking.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: setup-project
3
+ description: "Set up or refresh project-level reference files in `.schub/`: project-overview.md, project-setup.md, and project-wow.md. Use when initializing a project or when the overview/setup/ways-of-working docs need to be created or updated."
4
+ ---
5
+
6
+ ## User Input
7
+
8
+ ```text
9
+ $ARGUMENTS
10
+ ```
11
+
12
+ ## Workflow
13
+
14
+ 1. Run `npx schub project create --project-name "<name>"` to copy templates and insert the project name.
15
+ - Optional flags: `--project-name`, `--repo-root`, `--overwrite`.
16
+ 2. If the files already exist, ask whether to overwrite or update in place.
17
+ 3. Explore the repo to gather as much information as possible to complete the templates.
18
+ - Use existing project sources (README, package manifests, docs, infra configs) to fill in details.
19
+ 4. Replace remaining placeholders with concrete project details.
20
+ - mark unresolved items with `[MISSING INFORMATION]`.
21
+ 5. Ensure `project-setup.md` reflects the desired repository layout and current tech stack.
22
+ 6. Gather all `[MISSING INFORMATION]` into one `REVIEW_ME.md` file by running `npx schub review create --change-id "project-setup" --title "<project name>" --output "REVIEW_ME.md"`.
23
+ 7. Stop after the three files are created or updated.
24
+
25
+ ## Output Locations
26
+
27
+ - `.schub/project-overview.md`
28
+ - `.schub/project-setup.md`
29
+ - `.schub/project-wow.md`
@@ -0,0 +1,93 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render } from "ink-testing-library";
6
+ import packageJson from "../package.json";
7
+ import App from "./App";
8
+ import { findSchubRoot } from "./features/tasks";
9
+
10
+ const schubDir = findSchubRoot(process.env.SCHUB_CWD ?? process.cwd())!;
11
+ const homeDir = homedir();
12
+ const displaySchubDir =
13
+ schubDir === homeDir ? "~" : schubDir.startsWith(`${homeDir}/`) ? `~${schubDir.slice(homeDir.length)}` : schubDir;
14
+
15
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
16
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
17
+
18
+ test("renders tabs with header and footer details", () => {
19
+ const { lastFrame } = render(<App />);
20
+ const output = stripAnsi(lastFrame() || "");
21
+ expect(output).toContain("Status");
22
+ expect(output).toContain("Plan");
23
+ expect(output).toContain("switch mode");
24
+ expect(output).toContain("[o open file]");
25
+ expect(output).toContain("[c copy]");
26
+ expect(output).toContain(displaySchubDir);
27
+ expect(output).toContain(packageJson.version);
28
+ });
29
+
30
+ test("selected tab shows a blue left indicator", () => {
31
+ const originalForceColor = process.env.FORCE_COLOR;
32
+ process.env.FORCE_COLOR = "1";
33
+
34
+ try {
35
+ const { lastFrame } = render(<App />);
36
+ const output = lastFrame() || "";
37
+ const statusLine = output.split("\n").find((line) => line.includes("Status")) ?? "";
38
+ expect(statusLine).toContain("Status");
39
+ expect(statusLine).not.toContain("│");
40
+ } finally {
41
+ if (originalForceColor === undefined) {
42
+ delete process.env.FORCE_COLOR;
43
+ } else {
44
+ process.env.FORCE_COLOR = originalForceColor;
45
+ }
46
+ }
47
+ });
48
+
49
+ test("shows a copy banner after copying an item", async () => {
50
+ const originalCwd = process.cwd();
51
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-copy-"));
52
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
53
+ let copied = "";
54
+ const recordCopy = (value: string) => {
55
+ copied = value;
56
+ };
57
+
58
+ mkdirSync(readyRoot, { recursive: true });
59
+ writeFileSync(join(readyRoot, "T900_copy-task.md"), "# Task: T900 Copy Task\n", "utf8");
60
+
61
+ try {
62
+ process.chdir(baseDir);
63
+ const rendered = render(<App copyToClipboard={recordCopy} />);
64
+ rendered.stdin.write("c");
65
+ await new Promise((resolve) => setTimeout(resolve, 0));
66
+ const output = rendered.lastFrame() ?? "";
67
+ expect(copied).toBe("T900");
68
+ expect(output).toContain("Copied to clipboard !");
69
+ } finally {
70
+ process.chdir(originalCwd);
71
+ }
72
+ });
73
+
74
+ test("renders when no .schub directory is found", () => {
75
+ const originalCwd = process.cwd();
76
+ const originalSchubCwd = process.env.SCHUB_CWD;
77
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-"));
78
+ process.env.SCHUB_CWD = baseDir;
79
+
80
+ try {
81
+ process.chdir(baseDir);
82
+ const { lastFrame } = render(<App />);
83
+ const output = lastFrame() ?? "";
84
+ expect(output).toContain("No .schub directory found.");
85
+ } finally {
86
+ process.chdir(originalCwd);
87
+ if (originalSchubCwd === undefined) {
88
+ delete process.env.SCHUB_CWD;
89
+ } else {
90
+ process.env.SCHUB_CWD = originalSchubCwd;
91
+ }
92
+ }
93
+ });
package/src/App.tsx CHANGED
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { Box, Text, useInput, useStdout } from "ink";
3
3
  import React from "react";
4
4
  import packageJson from "../package.json";
5
+ import { copyToClipboard as copyToClipboardDefault } from "./clipboard";
5
6
  import PlanView from "./components/PlanView";
6
7
  import StatusView from "./components/StatusView";
7
8
  import { findSchubRoot } from "./features/tasks";
@@ -18,18 +19,49 @@ const tabs: TabDefinition[] = [
18
19
  { id: "plan", label: "Plan" },
19
20
  ];
20
21
 
21
- export default function App() {
22
+ type AppProps = {
23
+ copyToClipboard?: (value: string) => void;
24
+ };
25
+
26
+ const COPY_BANNER_TEXT = "Copied to clipboard !";
27
+ const COPY_BANNER_TIMEOUT_MS = 1500;
28
+
29
+ export default function App({ copyToClipboard = copyToClipboardDefault }: AppProps) {
22
30
  const [mode, setMode] = React.useState<Mode>("status");
23
31
  const { stdout } = useStdout();
24
32
  const [dimensions, setDimensions] = React.useState(() => ({
25
33
  columns: stdout.columns,
26
34
  rows: stdout.rows,
27
35
  }));
36
+ const [copyBanner, setCopyBanner] = React.useState<string | null>(null);
28
37
  const versionLabel = `${packageJson.version}`;
29
38
  const homeDir = homedir();
30
- const schubDir = findSchubRoot(process.env.SCHUB_CWD ?? process.cwd())!;
31
- const displaySchubDir =
32
- schubDir === homeDir ? "~" : schubDir.startsWith(`${homeDir}/`) ? `~${schubDir.slice(homeDir.length)}` : schubDir;
39
+ const startDir = process.env.SCHUB_CWD ?? process.cwd();
40
+ const schubDir = findSchubRoot(startDir);
41
+ const displaySchubDir = (() => {
42
+ const targetDir = schubDir ?? startDir;
43
+ if (targetDir === homeDir) {
44
+ return "~";
45
+ }
46
+ if (targetDir.startsWith(`${homeDir}/`)) {
47
+ return `~${targetDir.slice(homeDir.length)}`;
48
+ }
49
+ return targetDir;
50
+ })();
51
+
52
+ React.useEffect(() => {
53
+ if (!copyBanner) {
54
+ return;
55
+ }
56
+
57
+ const timeout = setTimeout(() => {
58
+ setCopyBanner(null);
59
+ }, COPY_BANNER_TIMEOUT_MS);
60
+
61
+ return () => {
62
+ clearTimeout(timeout);
63
+ };
64
+ }, [copyBanner]);
33
65
 
34
66
  React.useEffect(() => {
35
67
  const handleResize = () => {
@@ -49,6 +81,16 @@ export default function App() {
49
81
  }
50
82
  });
51
83
 
84
+ const handleCopyId = (value: string) => {
85
+ copyToClipboard(value);
86
+ setCopyBanner(COPY_BANNER_TEXT);
87
+ };
88
+
89
+ const shortcuts = [
90
+ { keyLabel: "o", label: "open file" },
91
+ { keyLabel: "c", label: "copy" },
92
+ ];
93
+
52
94
  return (
53
95
  <Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
54
96
  <Box flexDirection="column" paddingX={2} paddingY={1} flexShrink={0}>
@@ -77,21 +119,31 @@ export default function App() {
77
119
  );
78
120
  })}
79
121
  </Box>
122
+ {copyBanner ? (
123
+ <Box backgroundColor="green" paddingX={1}>
124
+ <Text color="black">{copyBanner}</Text>
125
+ </Box>
126
+ ) : null}
80
127
  </Box>
81
128
  </Box>
82
129
  <Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
83
- {mode === "status" ? <StatusView /> : <PlanView />}
130
+ {mode === "status" ? <StatusView onCopyId={handleCopyId} /> : <PlanView onCopyId={handleCopyId} />}
84
131
  </Box>
85
132
  <Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
86
- <Box justifyContent="flex-end">
133
+ <Box justifyContent="space-between">
134
+ <Box flexDirection="row">
135
+ {shortcuts.map((shortcut) => (
136
+ <Box key={shortcut.keyLabel} marginRight={2}>
137
+ <Text color="gray">[</Text>
138
+ <Text color="white">{shortcut.keyLabel}</Text>
139
+ <Text color="gray"> {shortcut.label}]</Text>
140
+ </Box>
141
+ ))}
142
+ </Box>
87
143
  <Box marginRight={2}>
88
144
  <Text>tab</Text>
89
145
  <Text color="gray"> switch mode</Text>
90
146
  </Box>
91
- <Box>
92
- <Text>ctrl+p</Text>
93
- <Text color="gray"> commands</Text>
94
- </Box>
95
147
  </Box>
96
148
  <Box justifyContent="space-between" marginTop={1}>
97
149
  <Text color="gray">{displaySchubDir}</Text>
package/src/changes.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
2
+ import { dirname, join, relative } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { resolveSchubRoot } from "./schub-root";
5
+ import { resolveTemplatePath } from "./templates";
4
6
 
5
7
  export type ChangeInfo = {
6
8
  id: string;
@@ -9,6 +11,8 @@ export type ChangeInfo = {
9
11
  path: string;
10
12
  };
11
13
 
14
+ const CHANGE_ID_PATTERN = /^(?:[Cc]\d{3}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
+
12
16
  const isDirectory = (path: string) => {
13
17
  try {
14
18
  return statSync(path).isDirectory();
@@ -27,6 +31,76 @@ const parseProposal = (content: string, changeId: string) => {
27
31
  };
28
32
  };
29
33
 
34
+ export const normalizeChangeId = (value: string) => {
35
+ const trimmed = value.trim();
36
+ const match = trimmed.match(/^([Cc])(\d{3})_(.+)$/);
37
+ if (match) {
38
+ return `C${match[2]}_${match[3]}`;
39
+ }
40
+ return trimmed;
41
+ };
42
+
43
+ export const isValidChangeId = (value: string) => CHANGE_ID_PATTERN.test(value.trim());
44
+
45
+ export const readChangeSummary = (schubDir: string, changeId: string) => {
46
+ const trimmed = changeId.trim();
47
+ if (!trimmed) {
48
+ throw new Error("Provide --change-id.");
49
+ }
50
+
51
+ if (!isValidChangeId(trimmed)) {
52
+ throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`);
53
+ }
54
+
55
+ const normalized = normalizeChangeId(trimmed);
56
+ const changeDir = join(schubDir, "changes", normalized);
57
+ const proposalPath = join(changeDir, "proposal.md");
58
+
59
+ if (!existsSync(proposalPath)) {
60
+ throw new Error(
61
+ `Required change files missing:\n - ${proposalPath}\nCreate the change proposal before scaffolding docs.`,
62
+ );
63
+ }
64
+
65
+ const content = readFileSync(proposalPath, "utf8");
66
+ const parsed = parseProposal(content, normalized);
67
+
68
+ return {
69
+ changeId: normalized,
70
+ changeTitle: parsed.title,
71
+ changeDir,
72
+ proposalPath,
73
+ };
74
+ };
75
+
76
+ export const updateChangeStatus = (schubDir: string, changeId: string, status: string) => {
77
+ const nextStatus = status.trim();
78
+ if (!nextStatus) {
79
+ throw new Error("Provide --status.");
80
+ }
81
+
82
+ const summary = readChangeSummary(schubDir, changeId);
83
+ const content = readFileSync(summary.proposalPath, "utf8");
84
+ const statusMatch = content.match(/^\*\*Status\*\*:\s*(.+)$/m);
85
+ const previousStatus = statusMatch?.[1]?.trim();
86
+
87
+ if (!previousStatus) {
88
+ throw new Error(
89
+ `Proposal status not found in ${summary.proposalPath}.\nAdd a '**Status**: <value>' line before updating status.`,
90
+ );
91
+ }
92
+
93
+ const updated = content.replace(/^\*\*Status\*\*:\s*(.+)$/m, `**Status**: ${nextStatus}`);
94
+ writeFileSync(summary.proposalPath, updated, "utf8");
95
+
96
+ return {
97
+ changeId: summary.changeId,
98
+ proposalPath: summary.proposalPath,
99
+ previousStatus,
100
+ status: nextStatus,
101
+ };
102
+ };
103
+
30
104
  const changeNumber = (id: string) => {
31
105
  const match = id.match(/\d+/);
32
106
  return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
@@ -113,15 +187,20 @@ const nextChangePrefix = (schubDir: string) => {
113
187
 
114
188
  const CHANGE_PREFIX = "C";
115
189
 
116
- const PROPOSAL_TEMPLATE_PATH = fileURLToPath(
190
+ const BUNDLED_PROPOSAL_TEMPLATE_PATH = fileURLToPath(
117
191
  new URL("../templates/create-proposal/proposal-template.md", import.meta.url),
118
192
  );
119
193
 
120
- const readProposalTemplate = () => {
194
+ const readProposalTemplate = (schubDir: string) => {
195
+ const templatePath = resolveTemplatePath(
196
+ schubDir,
197
+ join("create-proposal", "proposal-template.md"),
198
+ BUNDLED_PROPOSAL_TEMPLATE_PATH,
199
+ );
121
200
  try {
122
- return readFileSync(PROPOSAL_TEMPLATE_PATH, "utf8");
201
+ return readFileSync(templatePath, "utf8");
123
202
  } catch {
124
- throw new Error(`[ERROR] Template not found: ${PROPOSAL_TEMPLATE_PATH}`);
203
+ throw new Error(`[ERROR] Template not found: ${templatePath}`);
125
204
  }
126
205
  };
127
206
 
@@ -147,28 +226,7 @@ const changeExists = (schubDir: string, changeId: string) => {
147
226
  return false;
148
227
  };
149
228
 
150
- export const resolveChangeRoot = (startDir: string = process.cwd()) => {
151
- const start = resolve(startDir);
152
- const fallback = join(start, ".schub");
153
- let current = start;
154
-
155
- while (true) {
156
- if (basename(current) === ".schub" && isDirectory(current)) {
157
- return current;
158
- }
159
-
160
- const candidate = join(current, ".schub");
161
- if (isDirectory(candidate)) {
162
- return candidate;
163
- }
164
-
165
- const parent = dirname(current);
166
- if (parent === current) {
167
- return fallback;
168
- }
169
- current = parent;
170
- }
171
- };
229
+ export const resolveChangeRoot = resolveSchubRoot;
172
230
 
173
231
  export const createChange = (
174
232
  schubDir: string,
@@ -217,7 +275,7 @@ export const createChange = (
217
275
  throw new Error(`Change '${changeId}' already exists under ${schubDir}. Choose a unique id or pass --overwrite.`);
218
276
  }
219
277
 
220
- const template = readProposalTemplate();
278
+ const template = readProposalTemplate(schubDir);
221
279
  const today = new Date().toISOString().split("T")[0];
222
280
  const rendered = template
223
281
  .replace("{{CHANGE_TITLE}}", title)
@@ -0,0 +1,5 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export const copyToClipboard = (value: string) => {
4
+ spawnSync("pbcopy", [], { input: value });
5
+ };