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
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync, 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 runEject = (schubCwd: string, args: string[] = []) => {
13
+ const result = spawnSync({
14
+ cmd: ["bun", "run", "schub", "eject", ...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 createRepo = () => {
27
+ const base = mkdtempSync(join(tmpdir(), "schub-eject-"));
28
+ const repoRoot = join(base, "repo");
29
+ const cwd = join(repoRoot, "nested", "dir");
30
+ mkdirSync(cwd, { recursive: true });
31
+ return { repoRoot, cwd };
32
+ };
33
+
34
+ test("eject creates schub assets when missing", () => {
35
+ const { repoRoot } = createRepo();
36
+ const schubRoot = join(repoRoot, ".schub");
37
+
38
+ const { result } = runEject(repoRoot);
39
+
40
+ expect(result.exitCode).toBe(0);
41
+ expect(existsSync(join(schubRoot, "skills"))).toBe(true);
42
+ expect(existsSync(join(schubRoot, "templates"))).toBe(true);
43
+ expect(existsSync(join(schubRoot, "skills", "create-proposal", "SKILL.md"))).toBe(true);
44
+ });
45
+
46
+ test("eject refuses to overwrite without --force", () => {
47
+ const { repoRoot, cwd } = createRepo();
48
+ const schubRoot = join(repoRoot, ".schub");
49
+ mkdirSync(schubRoot, { recursive: true });
50
+
51
+ const first = runEject(cwd);
52
+ expect(first.result.exitCode).toBe(0);
53
+
54
+ const second = runEject(cwd);
55
+ expect(second.result.exitCode).not.toBe(0);
56
+ expect(second.stderr).toContain("--force");
57
+ });
58
+
59
+ test("eject overwrites with --force", () => {
60
+ const { repoRoot, cwd } = createRepo();
61
+ const schubRoot = join(repoRoot, ".schub");
62
+ mkdirSync(schubRoot, { recursive: true });
63
+
64
+ const first = runEject(cwd);
65
+ expect(first.result.exitCode).toBe(0);
66
+
67
+ const sentinelPath = join(schubRoot, "skills", "sentinel.txt");
68
+ writeFileSync(sentinelPath, "keep me", "utf8");
69
+ expect(existsSync(sentinelPath)).toBe(true);
70
+
71
+ const second = runEject(cwd, ["--force"]);
72
+ expect(second.result.exitCode).toBe(0);
73
+ expect(existsSync(sentinelPath)).toBe(false);
74
+ });
@@ -0,0 +1,100 @@
1
+ import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolveSchubRoot } from "../schub-root";
5
+
6
+ type EjectOptions = {
7
+ force: boolean;
8
+ };
9
+
10
+ const BUNDLED_SKILLS_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
11
+ const BUNDLED_TEMPLATES_ROOT = fileURLToPath(new URL("../../templates", import.meta.url));
12
+
13
+ const isDirectory = (path: string) => {
14
+ try {
15
+ return statSync(path).isDirectory();
16
+ } catch {
17
+ return false;
18
+ }
19
+ };
20
+
21
+ const parseEjectOptions = (args: string[]) => {
22
+ let force = false;
23
+ const unknown: string[] = [];
24
+
25
+ const rejectUnsupported = (flag: string) => {
26
+ throw new Error(`Unsupported option: ${flag}.`);
27
+ };
28
+
29
+ for (const arg of args) {
30
+ if (arg === "--force") {
31
+ force = true;
32
+ continue;
33
+ }
34
+ if (arg === "--schub-root" || arg === "--agent-root") {
35
+ rejectUnsupported(arg);
36
+ }
37
+ if (arg.startsWith("--schub-root=")) {
38
+ rejectUnsupported("--schub-root");
39
+ }
40
+ if (arg.startsWith("--agent-root=")) {
41
+ rejectUnsupported("--agent-root");
42
+ }
43
+ unknown.push(arg);
44
+ }
45
+
46
+ if (unknown.length > 0) {
47
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
48
+ }
49
+
50
+ return { force };
51
+ };
52
+
53
+ const ensureBundledDir = (path: string) => {
54
+ if (!isDirectory(path)) {
55
+ throw new Error(`[ERROR] Bundled assets missing: ${path}`);
56
+ }
57
+ };
58
+
59
+ const removeExisting = (path: string) => {
60
+ if (existsSync(path)) {
61
+ rmSync(path, { recursive: true, force: true });
62
+ }
63
+ };
64
+
65
+ const copyDirectory = (source: string, destination: string) => {
66
+ ensureBundledDir(source);
67
+ cpSync(source, destination, { recursive: true });
68
+ };
69
+
70
+ const enforceOverwrite = (paths: string[], force: boolean) => {
71
+ const existing = paths.filter((path) => existsSync(path));
72
+ if (existing.length === 0) {
73
+ return;
74
+ }
75
+
76
+ if (!force) {
77
+ const lines = existing.map((path) => ` - ${path}`).join("\n");
78
+ throw new Error(`[ERROR] Refusing to overwrite existing path(s):\n${lines}\nRe-run with --force.`);
79
+ }
80
+
81
+ for (const path of existing) {
82
+ removeExisting(path);
83
+ }
84
+ };
85
+
86
+ export const runEject = (args: string[], startDir: string) => {
87
+ const options: EjectOptions = parseEjectOptions(args);
88
+ const schubRoot = resolveSchubRoot(startDir);
89
+ mkdirSync(schubRoot, { recursive: true });
90
+
91
+ const skillsTarget = join(schubRoot, "skills");
92
+ const templatesTarget = join(schubRoot, "templates");
93
+ enforceOverwrite([skillsTarget, templatesTarget], options.force);
94
+
95
+ copyDirectory(BUNDLED_SKILLS_ROOT, skillsTarget);
96
+ copyDirectory(BUNDLED_TEMPLATES_ROOT, templatesTarget);
97
+
98
+ process.stdout.write(`[OK] Wrote ${skillsTarget}\n`);
99
+ process.stdout.write(`[OK] Wrote ${templatesTarget}\n`);
100
+ };
@@ -0,0 +1,78 @@
1
+ import { expect, test } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { installCodexSkills, resolveCodexSkillsRoot, selectProviders } from "./init";
7
+
8
+ const createRepoFixture = () => {
9
+ const base = mkdtempSync(join(tmpdir(), "schub-init-codex-"));
10
+ const repoRoot = join(base, "repo");
11
+ const startDir = join(repoRoot, "nested", "dir");
12
+ mkdirSync(startDir, { recursive: true });
13
+ return { base, repoRoot, startDir };
14
+ };
15
+
16
+ test("selectProviders handles numeric and named inputs", () => {
17
+ expect(selectProviders("1")).toEqual(["codex"]);
18
+ expect(selectProviders("codex, 1")).toEqual(["codex"]);
19
+ });
20
+
21
+ test("resolveCodexSkillsRoot prefers worktree .codex when present", () => {
22
+ const { repoRoot, startDir } = createRepoFixture();
23
+ const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
24
+
25
+ expect(gitInit.status).toBe(0);
26
+
27
+ mkdirSync(join(repoRoot, ".codex"), { recursive: true });
28
+
29
+ const destination = resolveCodexSkillsRoot(startDir);
30
+ const expectedRoot = realpathSync(repoRoot);
31
+
32
+ expect(destination).toBe(join(expectedRoot, ".codex", "skills"));
33
+ });
34
+
35
+ test("resolveCodexSkillsRoot falls back to home when worktree .codex is missing", () => {
36
+ const { repoRoot, startDir } = createRepoFixture();
37
+ const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
38
+
39
+ expect(gitInit.status).toBe(0);
40
+
41
+ const originalHome = process.env.HOME;
42
+ const homeDir = mkdtempSync(join(tmpdir(), "schub-home-"));
43
+ process.env.HOME = homeDir;
44
+
45
+ try {
46
+ const destination = resolveCodexSkillsRoot(startDir);
47
+ expect(destination).toBe(join(homeDir, ".codex", "skills"));
48
+ } finally {
49
+ process.env.HOME = originalHome;
50
+ }
51
+ });
52
+
53
+ test("resolveCodexSkillsRoot uses cwd when git is unavailable", () => {
54
+ const { startDir } = createRepoFixture();
55
+ const originalPath = process.env.PATH;
56
+ process.env.PATH = "";
57
+
58
+ try {
59
+ const destination = resolveCodexSkillsRoot(startDir);
60
+ expect(destination).toBe(join(startDir, ".codex", "skills"));
61
+ } finally {
62
+ process.env.PATH = originalPath;
63
+ }
64
+ });
65
+
66
+ test("installCodexSkills skips existing skill directories", () => {
67
+ const base = mkdtempSync(join(tmpdir(), "schub-init-install-"));
68
+ const destination = join(base, "skills");
69
+ const existingSkill = join(destination, "create-proposal");
70
+ mkdirSync(existingSkill, { recursive: true });
71
+ writeFileSync(join(existingSkill, "SKILL.md"), "existing", "utf8");
72
+
73
+ const { installed, skipped } = installCodexSkills(destination);
74
+
75
+ expect(skipped).toContain("create-proposal");
76
+ expect(installed.length).toBeGreaterThan(0);
77
+ expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
78
+ });
@@ -0,0 +1,144 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { fileURLToPath } from "node:url";
6
+ import { initSchubRoot, resolveGitRoot } from "../init";
7
+
8
+ type ProviderId = "codex";
9
+
10
+ type ProviderOption = {
11
+ id: ProviderId;
12
+ label: string;
13
+ };
14
+
15
+ const PROVIDERS: ProviderOption[] = [{ id: "codex", label: "Codex" }];
16
+ const BUNDLED_SKILLS_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
17
+
18
+ const isDirectory = (path: string) => {
19
+ try {
20
+ return statSync(path).isDirectory();
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ const readLine = (prompt: string) =>
27
+ new Promise<string>((resolveInput) => {
28
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
29
+ terminal.question(prompt, (answer) => {
30
+ terminal.close();
31
+ resolveInput(answer.trim());
32
+ });
33
+ });
34
+
35
+ const renderProviderList = (providers: ProviderOption[]) =>
36
+ providers.map((provider, index) => ` ${index + 1}) ${provider.label}`).join("\n");
37
+
38
+ export const selectProviders = (input: string, providers: ProviderOption[] = PROVIDERS) => {
39
+ const tokens = input
40
+ .split(/[, ]+/)
41
+ .map((token) => token.trim().toLowerCase())
42
+ .filter(Boolean);
43
+
44
+ const selected = new Set<ProviderId>();
45
+
46
+ for (const token of tokens) {
47
+ const index = Number.parseInt(token, 10);
48
+ if (!Number.isNaN(index) && index > 0 && index <= providers.length) {
49
+ selected.add(providers[index - 1].id);
50
+ continue;
51
+ }
52
+
53
+ const match = providers.find((provider) => provider.id === token || provider.label.toLowerCase() === token);
54
+ if (match) {
55
+ selected.add(match.id);
56
+ }
57
+ }
58
+
59
+ return Array.from(selected);
60
+ };
61
+
62
+ const promptForProviders = async () => {
63
+ const list = renderProviderList(PROVIDERS);
64
+ const answer = await readLine(`Select providers (comma separated):\n${list}\n> `);
65
+ return selectProviders(answer);
66
+ };
67
+
68
+ export const resolveCodexSkillsRoot = (startDir: string) => {
69
+ const resolvedStart = resolve(startDir);
70
+ const gitRoot = resolveGitRoot(resolvedStart);
71
+
72
+ if (!gitRoot) {
73
+ return join(resolvedStart, ".codex", "skills");
74
+ }
75
+
76
+ const localCodexRoot = join(gitRoot, ".codex");
77
+ if (isDirectory(localCodexRoot)) {
78
+ return join(localCodexRoot, "skills");
79
+ }
80
+
81
+ const home = process.env.HOME ?? homedir();
82
+ return join(home, ".codex", "skills");
83
+ };
84
+
85
+ export const installCodexSkills = (destination: string) => {
86
+ mkdirSync(destination, { recursive: true });
87
+
88
+ const entries = readdirSync(BUNDLED_SKILLS_ROOT, { withFileTypes: true });
89
+ const installed: string[] = [];
90
+ const skipped: string[] = [];
91
+
92
+ for (const entry of entries) {
93
+ if (!entry.isDirectory()) {
94
+ continue;
95
+ }
96
+
97
+ const source = join(BUNDLED_SKILLS_ROOT, entry.name);
98
+ const target = join(destination, entry.name);
99
+
100
+ if (existsSync(target)) {
101
+ skipped.push(entry.name);
102
+ continue;
103
+ }
104
+
105
+ cpSync(source, target, { recursive: true });
106
+ installed.push(entry.name);
107
+ }
108
+
109
+ return { installed, skipped };
110
+ };
111
+
112
+ const reportCodexInstall = (destination: string, installed: string[], skipped: string[]) => {
113
+ process.stdout.write(`[OK] Codex skills: ${destination}\n`);
114
+
115
+ for (const skill of installed) {
116
+ process.stdout.write(`[OK] Installed ${skill}\n`);
117
+ }
118
+
119
+ for (const skill of skipped) {
120
+ process.stdout.write(`[SKIP] ${skill}\n`);
121
+ }
122
+ };
123
+
124
+ const parseInitOptions = (args: string[]) => {
125
+ if (args.length === 0) {
126
+ return;
127
+ }
128
+
129
+ throw new Error(`Unknown option(s): ${args.join(", ")}`);
130
+ };
131
+
132
+ export const runInit = async (args: string[], startDir: string) => {
133
+ parseInitOptions(args);
134
+
135
+ const schubRoot = initSchubRoot(startDir);
136
+ process.stdout.write(`[OK] Wrote ${schubRoot}\n`);
137
+
138
+ const providers = await promptForProviders();
139
+ if (providers.includes("codex")) {
140
+ const destination = resolveCodexSkillsRoot(startDir);
141
+ const { installed, skipped } = installCodexSkills(destination);
142
+ reportCodexInstall(destination, installed, skipped);
143
+ }
144
+ };
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync } 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 templateRoot = join(cliDir, "templates", "setup-project");
11
+ const decoder = new TextDecoder();
12
+
13
+ const runProjectCreate = (schubCwd: string, args: string[] = []) => {
14
+ const result = spawnSync({
15
+ cmd: ["bun", "run", "schub", "project", "create", ...args],
16
+ cwd: cliDir,
17
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
18
+ });
19
+
20
+ return {
21
+ result,
22
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
23
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
24
+ };
25
+ };
26
+
27
+ const createRepo = (name = "repo") => {
28
+ const base = mkdtempSync(join(tmpdir(), "schub-project-"));
29
+ const repoRoot = join(base, name);
30
+ const cwd = join(repoRoot, "nested", "dir");
31
+ const schubRoot = join(repoRoot, ".schub");
32
+ mkdirSync(cwd, { recursive: true });
33
+ mkdirSync(schubRoot, { recursive: true });
34
+ return { repoRoot, cwd, schubRoot };
35
+ };
36
+
37
+ test("project create scaffolds docs with derived project name", () => {
38
+ const { cwd, schubRoot } = createRepo();
39
+ const { result } = runProjectCreate(cwd);
40
+
41
+ expect(result.exitCode).toBe(0);
42
+
43
+ const overviewPath = join(schubRoot, "project-overview.md");
44
+ const setupPath = join(schubRoot, "project-setup.md");
45
+ const wowPath = join(schubRoot, "project-wow.md");
46
+
47
+ expect(existsSync(overviewPath)).toBe(true);
48
+ expect(existsSync(setupPath)).toBe(true);
49
+ expect(existsSync(wowPath)).toBe(true);
50
+
51
+ const overview = readFileSync(overviewPath, "utf8");
52
+ const setup = readFileSync(setupPath, "utf8");
53
+ const wow = readFileSync(wowPath, "utf8");
54
+ const projectName = "repo";
55
+ const overviewTemplate = readFileSync(join(templateRoot, "project-overview-template.md"), "utf8");
56
+ const setupTemplate = readFileSync(join(templateRoot, "project-setup-template.md"), "utf8");
57
+ const wowTemplate = readFileSync(join(templateRoot, "project-wow-template.md"), "utf8");
58
+
59
+ expect(overview).toBe(overviewTemplate.split("[Project Name]").join(projectName));
60
+ expect(setup).toBe(setupTemplate.split("[Project Name]").join(projectName));
61
+ expect(wow).toBe(wowTemplate.split("[Project Name]").join(projectName));
62
+ });
63
+
64
+ test("project create supports --project-name", () => {
65
+ const { cwd, schubRoot } = createRepo();
66
+ const { result } = runProjectCreate(cwd, ["--project-name", "Acme"]);
67
+
68
+ expect(result.exitCode).toBe(0);
69
+ const overview = readFileSync(join(schubRoot, "project-overview.md"), "utf8");
70
+ expect(overview).toContain("# Acme - Overview");
71
+ });
72
+
73
+ test("project create derives project name from --repo-root", () => {
74
+ const { cwd, schubRoot } = createRepo();
75
+ const base = dirname(dirname(cwd));
76
+ const altRoot = join(base, "alt-project");
77
+ mkdirSync(altRoot, { recursive: true });
78
+
79
+ const { result } = runProjectCreate(cwd, ["--repo-root", altRoot]);
80
+
81
+ expect(result.exitCode).toBe(0);
82
+ const overview = readFileSync(join(schubRoot, "project-overview.md"), "utf8");
83
+ expect(overview).toContain("# alt-project - Overview");
84
+ });
85
+
86
+ test("project create respects overwrite", () => {
87
+ const { cwd, schubRoot } = createRepo();
88
+ const overviewPath = join(schubRoot, "project-overview.md");
89
+
90
+ const first = runProjectCreate(cwd, ["--project-name", "First"]);
91
+ expect(first.result.exitCode).toBe(0);
92
+ expect(readFileSync(overviewPath, "utf8")).toContain("# First - Overview");
93
+
94
+ const second = runProjectCreate(cwd, ["--project-name", "Second"]);
95
+ expect(second.result.exitCode).not.toBe(0);
96
+ expect(second.stderr).toContain("Refusing to overwrite");
97
+ expect(readFileSync(overviewPath, "utf8")).toContain("# First - Overview");
98
+
99
+ const third = runProjectCreate(cwd, ["--project-name", "Second", "--overwrite"]);
100
+ expect(third.result.exitCode).toBe(0);
101
+ expect(readFileSync(overviewPath, "utf8")).toContain("# Second - Overview");
102
+ });
103
+
104
+ test("project create rejects schub root flags", () => {
105
+ const { cwd } = createRepo();
106
+ const schubRoot = runProjectCreate(cwd, ["--schub-root", "/tmp"]);
107
+ expect(schubRoot.result.exitCode).not.toBe(0);
108
+ expect(schubRoot.stderr).toContain("Unsupported option: --schub-root.");
109
+
110
+ const agentRoot = runProjectCreate(cwd, ["--agent-root", "/tmp"]);
111
+ expect(agentRoot.result.exitCode).not.toBe(0);
112
+ expect(agentRoot.stderr).toContain("Unsupported option: --agent-root.");
113
+ });
@@ -0,0 +1,75 @@
1
+ import { createProject } from "../project";
2
+
3
+ type ProjectCreateOptions = {
4
+ repoRoot?: string;
5
+ projectName?: string;
6
+ overwrite: boolean;
7
+ };
8
+
9
+ const parseProjectCreateOptions = (args: string[]) => {
10
+ let repoRoot: string | undefined;
11
+ let projectName: string | undefined;
12
+ let overwrite = false;
13
+ const unknown: string[] = [];
14
+
15
+ const rejectUnsupported = (flag: string) => {
16
+ throw new Error(`Unsupported option: ${flag}.`);
17
+ };
18
+
19
+ for (let index = 0; index < args.length; index += 1) {
20
+ const arg = args[index];
21
+ if (arg === "--overwrite") {
22
+ overwrite = true;
23
+ continue;
24
+ }
25
+ if (arg === "--repo-root") {
26
+ repoRoot = args[index + 1];
27
+ if (repoRoot === undefined) {
28
+ throw new Error("Missing value for --repo-root.");
29
+ }
30
+ index += 1;
31
+ continue;
32
+ }
33
+ if (arg.startsWith("--repo-root=")) {
34
+ repoRoot = arg.slice("--repo-root=".length);
35
+ continue;
36
+ }
37
+ if (arg === "--project-name") {
38
+ projectName = args[index + 1];
39
+ if (projectName === undefined) {
40
+ throw new Error("Missing value for --project-name.");
41
+ }
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (arg.startsWith("--project-name=")) {
46
+ projectName = arg.slice("--project-name=".length);
47
+ continue;
48
+ }
49
+ if (arg === "--schub-root" || arg === "--agent-root") {
50
+ rejectUnsupported(arg);
51
+ }
52
+ if (arg.startsWith("--schub-root=")) {
53
+ rejectUnsupported("--schub-root");
54
+ }
55
+ if (arg.startsWith("--agent-root=")) {
56
+ rejectUnsupported("--agent-root");
57
+ }
58
+ unknown.push(arg);
59
+ }
60
+
61
+ if (unknown.length > 0) {
62
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
63
+ }
64
+
65
+ return { repoRoot, projectName, overwrite };
66
+ };
67
+
68
+ export const runProjectCreate = (args: string[], startDir: string) => {
69
+ const options: ProjectCreateOptions = parseProjectCreateOptions(args);
70
+ const outputs = createProject(startDir, options);
71
+
72
+ for (const output of outputs) {
73
+ process.stdout.write(`[OK] Wrote ${output}\n`);
74
+ }
75
+ };
@@ -0,0 +1,100 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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", ...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 createRepo = () => {
27
+ const base = mkdtempSync(join(tmpdir(), "schub-review-"));
28
+ const repoRoot = join(base, "repo");
29
+ const cwd = join(repoRoot, "nested", "dir");
30
+ const schubRoot = join(repoRoot, ".schub");
31
+ mkdirSync(cwd, { recursive: true });
32
+ mkdirSync(schubRoot, { recursive: true });
33
+ return { cwd, schubRoot };
34
+ };
35
+
36
+ const seedChange = (schubRoot: string, changeId: string, title: string) => {
37
+ const changeDir = join(schubRoot, "changes", changeId);
38
+ mkdirSync(changeDir, { recursive: true });
39
+ const proposal = [
40
+ `# Proposal - ${title}`,
41
+ "",
42
+ `**Change ID**: \`${changeId}\``,
43
+ "**Created**: 2024-01-01",
44
+ "**Status**: Draft",
45
+ "",
46
+ ].join("\n");
47
+ writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
48
+ };
49
+
50
+ test("review create scaffolds REVIEW_ME from template", () => {
51
+ const { cwd, schubRoot } = createRepo();
52
+ const changeId = "C001_sample-change";
53
+ const changeTitle = "Sample Change";
54
+ seedChange(schubRoot, changeId, changeTitle);
55
+
56
+ const { result } = runCli(cwd, ["review", "create", "--change-id", changeId]);
57
+ expect(result.exitCode).toBe(0);
58
+
59
+ const reviewPath = join(schubRoot, "changes", changeId, "REVIEW_ME.md");
60
+ expect(existsSync(reviewPath)).toBe(true);
61
+
62
+ const templatePath = join(cliDir, "templates", "review-proposal", "review-me-template.md");
63
+ const template = readFileSync(templatePath, "utf8");
64
+ const today = new Date().toISOString().split("T")[0];
65
+ const expected = template
66
+ .replace("{{CHANGE_TITLE}}", changeTitle)
67
+ .replace("{{CHANGE_ID}}", changeId)
68
+ .replace("{{DATE}}", today);
69
+
70
+ expect(readFileSync(reviewPath, "utf8")).toBe(expected);
71
+ });
72
+
73
+ test("review complete creates Q&A with review content", () => {
74
+ const { cwd, schubRoot } = createRepo();
75
+ const changeId = "C002_review-complete";
76
+ const changeTitle = "Review Complete";
77
+ seedChange(schubRoot, changeId, changeTitle);
78
+
79
+ const reviewPath = join(schubRoot, "changes", changeId, "REVIEW_ME.md");
80
+ const reviewContent = ["## Open Questions", "- [ ] Q1: What is the plan?", ""].join("\n");
81
+ writeFileSync(reviewPath, reviewContent, "utf8");
82
+
83
+ const { result } = runCli(cwd, ["review", "complete", "--change-id", changeId]);
84
+ expect(result.exitCode).toBe(0);
85
+
86
+ const qnaPath = join(schubRoot, "changes", changeId, "Q&A.md");
87
+ expect(existsSync(qnaPath)).toBe(true);
88
+ expect(existsSync(reviewPath)).toBe(false);
89
+
90
+ const templatePath = join(cliDir, "templates", "review-proposal", "q&a-template.md");
91
+ const template = readFileSync(templatePath, "utf8");
92
+ const today = new Date().toISOString().split("T")[0];
93
+ const expected = template
94
+ .replace("{{CHANGE_TITLE}}", changeTitle)
95
+ .replace("{{CHANGE_ID}}", changeId)
96
+ .replace("{{DATE}}", today)
97
+ .replace("{{REVIEW_CONTENT}}", reviewContent);
98
+
99
+ expect(readFileSync(qnaPath, "utf8")).toBe(expected);
100
+ });