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.
- package/README.md +68 -0
- package/dist/index.js +1573 -597
- package/package.json +3 -1
- package/skills/create-proposal/SKILL.md +33 -0
- package/skills/create-tasks/SKILL.md +40 -0
- package/skills/implement-task/SKILL.md +84 -0
- package/skills/review-proposal/SKILL.md +37 -0
- package/skills/setup-project/SKILL.md +29 -0
- package/src/App.test.tsx +93 -0
- package/src/App.tsx +62 -10
- package/src/changes.ts +86 -28
- package/src/clipboard.ts +5 -0
- package/src/commands/adr.test.ts +69 -0
- package/src/commands/adr.ts +107 -0
- package/src/commands/changes.test.ts +171 -0
- package/src/commands/changes.ts +163 -0
- package/src/commands/cookbook.test.ts +71 -0
- package/src/commands/cookbook.ts +95 -0
- package/src/commands/eject.test.ts +74 -0
- package/src/commands/eject.ts +100 -0
- package/src/commands/init.test.ts +78 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/project.test.ts +113 -0
- package/src/commands/project.ts +75 -0
- package/src/commands/review.test.ts +100 -0
- package/src/commands/review.ts +231 -0
- package/src/commands/tasks-create.test.ts +172 -0
- package/src/commands/tasks-list.test.ts +177 -0
- package/src/commands/tasks.ts +172 -0
- package/src/components/PlanView.test.tsx +113 -0
- package/src/components/PlanView.tsx +95 -26
- package/src/components/StatusView.test.tsx +380 -0
- package/src/components/StatusView.tsx +233 -83
- package/src/features/tasks/constants.ts +2 -0
- package/src/features/tasks/create.ts +15 -7
- package/src/features/tasks/filesystem.test.ts +78 -0
- package/src/features/tasks/filesystem.ts +61 -7
- package/src/ide.ts +7 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +60 -383
- package/src/init.test.ts +43 -0
- package/src/init.ts +27 -0
- package/src/project.ts +5 -32
- package/src/schub-root.ts +33 -0
- package/src/templates.ts +18 -0
- package/src/terminal.test.ts +46 -0
- package/templates/create-proposal/cookbook-template.md +37 -0
- package/templates/review-proposal/q&a-template.md +5 -1
- package/templates/templates-parity.test.ts +45 -0
- 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.
|
|
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`
|
package/src/App.test.tsx
ADDED
|
@@ -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
|
-
|
|
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
|
|
31
|
-
const
|
|
32
|
-
|
|
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="
|
|
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 {
|
|
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
|
|
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(
|
|
201
|
+
return readFileSync(templatePath, "utf8");
|
|
123
202
|
} catch {
|
|
124
|
-
throw new Error(`[ERROR] Template not found: ${
|
|
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 =
|
|
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)
|