ystack 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/bin/cli.js ADDED
@@ -0,0 +1,973 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync, statSync } from "node:fs";
4
+ import { join, resolve, dirname } from "node:path";
5
+ import { execSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createHash } from "node:crypto";
8
+ import * as p from "@clack/prompts";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const YSTACK_ROOT = resolve(__dirname, "..");
13
+
14
+ // Colors
15
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
16
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
17
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
18
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
19
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
20
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
21
+
22
+ const args = process.argv.slice(2);
23
+ const command = args[0];
24
+ const flags = args.filter((a) => a.startsWith("--"));
25
+
26
+ // --- Prompt helpers ---
27
+
28
+ function handleCancel(value) {
29
+ if (p.isCancel(value)) {
30
+ p.cancel("Cancelled.");
31
+ process.exit(0);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ // --- File helpers ---
37
+
38
+ function hashFile(path) {
39
+ if (!existsSync(path)) return null;
40
+ const content = readFileSync(path, "utf-8");
41
+ return createHash("sha256").update(content).digest("hex").slice(0, 12);
42
+ }
43
+
44
+ function commandExists(cmd) {
45
+ try {
46
+ execSync(`which ${cmd}`, { stdio: "ignore" });
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function detectProjectName() {
54
+ // Try package.json
55
+ if (existsSync("package.json")) {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
58
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
59
+ } catch { /* ignore */ }
60
+ }
61
+ // Fall back to directory name
62
+ return process.cwd().split("/").pop();
63
+ }
64
+
65
+ function detectDocsFramework() {
66
+ if (existsSync("docs/src/content/_meta.ts")) return { framework: "nextra", root: "docs/src/content" };
67
+ if (existsSync("content/docs/meta.json")) return { framework: "fumadocs", root: "content/docs" };
68
+ if (existsSync("docs/_meta.ts")) return { framework: "nextra", root: "docs" };
69
+ return null;
70
+ }
71
+
72
+ function detectMonorepo() {
73
+ if (existsSync("turbo.json")) return "turborepo";
74
+ if (existsSync("pnpm-workspace.yaml")) return "pnpm";
75
+ if (existsSync("lerna.json")) return "lerna";
76
+ if (existsSync("nx.json")) return "nx";
77
+ return null;
78
+ }
79
+
80
+ // --- Skill installation ---
81
+
82
+ function copySkills(projectRoot, ystackRoot, silent = false) {
83
+ const skillsDir = join(ystackRoot, "skills");
84
+ const targetDir = join(projectRoot, ".claude", "skills");
85
+ const skills = readdirSync(skillsDir).filter((d) =>
86
+ statSync(join(skillsDir, d)).isDirectory(),
87
+ );
88
+
89
+ let installed = 0;
90
+ let skipped = 0;
91
+
92
+ for (const skill of skills) {
93
+ const src = join(skillsDir, skill);
94
+ const dst = join(targetDir, skill);
95
+ const srcSkill = join(src, "SKILL.md");
96
+ const dstSkill = join(dst, "SKILL.md");
97
+
98
+ if (!existsSync(srcSkill)) continue;
99
+
100
+ if (existsSync(dstSkill)) {
101
+ const srcHash = hashFile(srcSkill);
102
+ const dstHash = hashFile(dstSkill);
103
+ if (srcHash !== dstHash) {
104
+ if (!silent) console.log(yellow(` ⚠ ${skill}/ — customized, skipping`));
105
+ skipped++;
106
+ continue;
107
+ }
108
+ }
109
+
110
+ mkdirSync(dst, { recursive: true });
111
+ cpSync(src, dst, { recursive: true });
112
+ installed++;
113
+ if (!silent) console.log(green(` ✓ ${skill}/`));
114
+ }
115
+
116
+ return { installed, skipped, total: skills.length };
117
+ }
118
+
119
+ function installHooks(projectRoot, ystackRoot) {
120
+ const settingsPath = join(projectRoot, ".claude", "settings.json");
121
+ let settings = {};
122
+
123
+ if (existsSync(settingsPath)) {
124
+ try {
125
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
126
+ } catch { /* create new */ }
127
+ }
128
+
129
+ if (!settings.hooks) settings.hooks = {};
130
+
131
+ // PostToolUse — context monitor
132
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
133
+ const hasContextMonitor = settings.hooks.PostToolUse.some(
134
+ (h) => h.hooks?.some((hh) => hh.command?.includes("context-monitor")),
135
+ );
136
+ if (!hasContextMonitor) {
137
+ settings.hooks.PostToolUse.push({
138
+ matcher: "*",
139
+ hooks: [{
140
+ type: "command",
141
+ command: `node "${join(projectRoot, ".claude", "hooks", "context-monitor.js")}"`,
142
+ timeout: 5,
143
+ }],
144
+ });
145
+ }
146
+
147
+ // PreToolUse — workflow nudge
148
+ if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
149
+ const hasWorkflowNudge = settings.hooks.PreToolUse.some(
150
+ (h) => h.hooks?.some((hh) => hh.command?.includes("workflow-nudge")),
151
+ );
152
+ if (!hasWorkflowNudge) {
153
+ settings.hooks.PreToolUse.push({
154
+ matcher: "Edit|Write",
155
+ hooks: [{
156
+ type: "command",
157
+ command: `node "${join(projectRoot, ".claude", "hooks", "workflow-nudge.js")}"`,
158
+ timeout: 5,
159
+ }],
160
+ });
161
+ }
162
+
163
+ mkdirSync(dirname(settingsPath), { recursive: true });
164
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
165
+
166
+ // Copy hook files
167
+ const hooksDir = join(ystackRoot, "hooks");
168
+ const targetHooksDir = join(projectRoot, ".claude", "hooks");
169
+ mkdirSync(targetHooksDir, { recursive: true });
170
+
171
+ if (existsSync(hooksDir)) {
172
+ for (const file of readdirSync(hooksDir)) {
173
+ cpSync(join(hooksDir, file), join(targetHooksDir, file));
174
+ }
175
+ }
176
+ }
177
+
178
+ function removeSkills(projectRoot) {
179
+ const ystackSkills = join(YSTACK_ROOT, "skills");
180
+ if (!existsSync(ystackSkills)) return;
181
+
182
+ const skillsDir = join(projectRoot, ".claude", "skills");
183
+ for (const skill of readdirSync(ystackSkills)) {
184
+ const target = join(skillsDir, skill);
185
+ if (existsSync(target) && statSync(join(ystackSkills, skill)).isDirectory()) {
186
+ rmSync(target, { recursive: true });
187
+ console.log(dim(` removed ${skill}/`));
188
+ }
189
+ }
190
+ }
191
+
192
+ function removeHooks(projectRoot) {
193
+ const settingsPath = join(projectRoot, ".claude", "settings.json");
194
+ if (!existsSync(settingsPath)) return;
195
+
196
+ try {
197
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
198
+ if (settings.hooks?.PostToolUse) {
199
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
200
+ (h) => !h.hooks?.some((hh) => hh.command?.includes("context-monitor")),
201
+ );
202
+ }
203
+ if (settings.hooks?.PreToolUse) {
204
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
205
+ (h) => !h.hooks?.some((hh) => hh.command?.includes("workflow-nudge")),
206
+ );
207
+ }
208
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
209
+ } catch { /* ignore */ }
210
+
211
+ const hooksDir = join(projectRoot, ".claude", "hooks");
212
+ for (const file of ["context-monitor.js", "session-start.sh", "workflow-nudge.js"]) {
213
+ const target = join(hooksDir, file);
214
+ if (existsSync(target)) rmSync(target);
215
+ }
216
+ console.log(dim(" removed hooks"));
217
+ }
218
+
219
+ function ensureGitignore(projectRoot) {
220
+ const gitignorePath = join(projectRoot, ".gitignore");
221
+ if (!existsSync(gitignorePath)) return;
222
+
223
+ const content = readFileSync(gitignorePath, "utf-8");
224
+ if (!content.includes(".context/")) {
225
+ writeFileSync(gitignorePath, content.trimEnd() + "\n.context/\n");
226
+ }
227
+ }
228
+
229
+ // --- Commands ---
230
+
231
+ async function cmdInit() {
232
+ const projectRoot = process.cwd();
233
+ const skillsOnly = flags.includes("--skills-only");
234
+
235
+ p.intro("ystack init — agent harness for doc-driven development");
236
+
237
+ // --- Step 1: Project name ---
238
+ const detectedName = detectProjectName();
239
+ const projectName = handleCancel(await p.text({
240
+ message: "Project name:",
241
+ placeholder: detectedName,
242
+ defaultValue: detectedName,
243
+ }));
244
+
245
+ // --- Step 2: Docs framework ---
246
+ const detectedDocs = detectDocsFramework();
247
+ let docsFramework;
248
+ let docsRoot;
249
+
250
+ if (detectedDocs) {
251
+ const keepDetected = handleCancel(await p.confirm({
252
+ message: `Detected ${detectedDocs.framework} at ${detectedDocs.root}. Use it?`,
253
+ }));
254
+ if (keepDetected) {
255
+ docsFramework = detectedDocs.framework;
256
+ docsRoot = detectedDocs.root;
257
+ }
258
+ }
259
+
260
+ if (!docsFramework) {
261
+ docsFramework = handleCancel(await p.select({
262
+ message: "Docs framework:",
263
+ options: [
264
+ { label: "Nextra", value: "nextra" },
265
+ { label: "Fumadocs", value: "fumadocs" },
266
+ { label: "None — I'll set up docs later", value: "none" },
267
+ ],
268
+ }));
269
+
270
+ if (docsFramework === "nextra") {
271
+ docsRoot = handleCancel(await p.text({
272
+ message: "Docs root:",
273
+ placeholder: "docs/src/content",
274
+ defaultValue: "docs/src/content",
275
+ }));
276
+ } else if (docsFramework === "fumadocs") {
277
+ docsRoot = handleCancel(await p.text({
278
+ message: "Docs root:",
279
+ placeholder: "content/docs",
280
+ defaultValue: "content/docs",
281
+ }));
282
+ } else {
283
+ docsRoot = null;
284
+ }
285
+ }
286
+
287
+ // --- Step 3: Beads ---
288
+ let initBeads = false;
289
+ if (!skillsOnly) {
290
+ const hasBeads = existsSync(join(projectRoot, ".beads"));
291
+ const hasBdCli = commandExists("bd");
292
+
293
+ if (hasBeads) {
294
+ p.log.info("Beads already initialized");
295
+ } else if (hasBdCli) {
296
+ initBeads = handleCancel(await p.confirm({
297
+ message: "Initialize Beads for persistent memory?",
298
+ }));
299
+ } else {
300
+ const installBeads = handleCancel(await p.select({
301
+ message: "Beads (persistent memory for agents):",
302
+ options: [
303
+ { label: "Install Beads (brew install beads)", value: "install" },
304
+ { label: "Skip — I'll set up Beads later", value: "skip" },
305
+ ],
306
+ }));
307
+
308
+ if (installBeads === "install") {
309
+ p.log.step("Installing Beads...");
310
+ try {
311
+ execSync("brew install beads", { stdio: "inherit" });
312
+ p.log.success("Beads installed");
313
+ initBeads = true;
314
+ } catch {
315
+ p.log.warn("Install failed. Try manually: brew install beads");
316
+ p.log.warn("or: npm install -g @beads/bd");
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ // --- Step 4: Runtime ---
323
+ const runtime = handleCancel(await p.select({
324
+ message: "Runtime:",
325
+ options: [
326
+ { label: "Claude Code", value: "claude-code" },
327
+ { label: "Claude Code (skills only, no hooks)", value: "claude-code-minimal" },
328
+ ],
329
+ }));
330
+
331
+ // --- Step 5: Hooks ---
332
+ let installHooksFlag = true;
333
+ if (!skillsOnly && runtime === "claude-code") {
334
+ installHooksFlag = handleCancel(await p.confirm({
335
+ message: "Install agent linting hooks?",
336
+ }));
337
+ } else if (runtime === "claude-code-minimal") {
338
+ installHooksFlag = false;
339
+ }
340
+
341
+ // --- Execute ---
342
+ const s = p.spinner();
343
+ s.start("Setting up...");
344
+
345
+ // Skills
346
+ mkdirSync(join(projectRoot, ".claude", "skills"), { recursive: true });
347
+ const result = copySkills(projectRoot, YSTACK_ROOT, true);
348
+
349
+ // Hooks
350
+ if (installHooksFlag) {
351
+ installHooks(projectRoot, YSTACK_ROOT);
352
+ }
353
+
354
+ // Config
355
+ const configPath = join(projectRoot, "ystack.config.json");
356
+ const configExisted = existsSync(configPath);
357
+ if (!configExisted) {
358
+ const monorepo = detectMonorepo();
359
+ const config = {
360
+ project: projectName,
361
+ docs: {
362
+ root: docsRoot,
363
+ framework: docsFramework === "none" ? null : docsFramework,
364
+ },
365
+ monorepo: {
366
+ enabled: !!monorepo,
367
+ ...(monorepo ? { tool: monorepo } : {}),
368
+ },
369
+ modules: {},
370
+ workflow: {
371
+ plan_checker: true,
372
+ fresh_context_per_task: true,
373
+ auto_docs_check: true,
374
+ },
375
+ };
376
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
377
+ }
378
+
379
+ ensureGitignore(projectRoot);
380
+
381
+ // Beads
382
+ if (initBeads) {
383
+ try {
384
+ execSync("bd init", { stdio: "pipe", cwd: projectRoot });
385
+ } catch { /* handled below */ }
386
+ }
387
+
388
+ s.stop("Setup complete");
389
+
390
+ // Summary
391
+ p.log.success(`Skills: ${result.installed} installed, ${result.skipped} skipped`);
392
+ if (installHooksFlag) {
393
+ p.log.success("Hooks: context-monitor, workflow-nudge");
394
+ }
395
+ if (configExisted) {
396
+ p.log.info("Config: ystack.config.json preserved");
397
+ } else {
398
+ p.log.success("Config: created ystack.config.json");
399
+ }
400
+ if (initBeads) {
401
+ p.log.success("Beads initialized");
402
+ }
403
+
404
+ p.note(
405
+ [
406
+ `${cyan("/import")} — scan codebase and populate module registry`,
407
+ `${cyan("/build")} — plan a feature`,
408
+ `${cyan("/scaffold")} — scaffold docs from a plan`,
409
+ ].join("\n"),
410
+ "Next steps",
411
+ );
412
+
413
+ p.outro("Done!");
414
+ }
415
+
416
+ async function cmdUpdate() {
417
+ const projectRoot = process.cwd();
418
+
419
+ p.intro("ystack update");
420
+
421
+ const s = p.spinner();
422
+ s.start("Updating skills...");
423
+ const result = copySkills(projectRoot, YSTACK_ROOT, true);
424
+ s.stop(`Skills: ${result.installed} updated, ${result.skipped} customized (preserved)`);
425
+
426
+ const hooksDir = join(YSTACK_ROOT, "hooks");
427
+ const targetHooksDir = join(projectRoot, ".claude", "hooks");
428
+ if (existsSync(hooksDir) && existsSync(targetHooksDir)) {
429
+ for (const file of readdirSync(hooksDir)) {
430
+ cpSync(join(hooksDir, file), join(targetHooksDir, file));
431
+ }
432
+ p.log.success("Hook files updated");
433
+ } else {
434
+ p.log.info("No hooks to update");
435
+ }
436
+
437
+ p.outro("Done!");
438
+ }
439
+
440
+ async function cmdRemove() {
441
+ const projectRoot = process.cwd();
442
+
443
+ p.intro("ystack remove");
444
+
445
+ const proceed = handleCancel(await p.confirm({
446
+ message: "Remove ystack skills and hooks? (keeps config, beads, docs)",
447
+ }));
448
+ if (!proceed) {
449
+ p.outro("Cancelled.");
450
+ return;
451
+ }
452
+
453
+ const s = p.spinner();
454
+ s.start("Removing skills and hooks...");
455
+ removeSkills(projectRoot);
456
+ removeHooks(projectRoot);
457
+ s.stop("Removed skills and hooks");
458
+
459
+ p.log.info("Kept: ystack.config.json, .beads/, docs/");
460
+ p.outro("Done!");
461
+ }
462
+
463
+ async function cmdCreate() {
464
+ const name = args[1];
465
+ if (!name) {
466
+ console.log(red("\nUsage: ystack create <project-name>\n"));
467
+ process.exit(1);
468
+ }
469
+
470
+ // Parse flags
471
+ const docsFlag = flags.find((f) => f.startsWith("--docs"));
472
+ let docsFramework = "nextra";
473
+ if (docsFlag) {
474
+ const idx = args.indexOf(docsFlag);
475
+ const val = docsFlag.includes("=") ? docsFlag.split("=")[1] : args[idx + 1];
476
+ if (val === "fumadocs" || val === "nextra") {
477
+ docsFramework = val;
478
+ } else {
479
+ console.log(red(`\nUnknown docs framework: ${val}. Use "nextra" or "fumadocs".\n`));
480
+ process.exit(1);
481
+ }
482
+ }
483
+
484
+ const fromFlag = flags.find((f) => f.startsWith("--from"));
485
+ if (fromFlag) {
486
+ console.log(yellow("\nScaffold integration from plan files is coming soon."));
487
+ console.log(yellow("Creating base project without plan integration.\n"));
488
+ }
489
+
490
+ const projectDir = resolve(process.cwd(), name);
491
+
492
+ if (existsSync(projectDir)) {
493
+ console.log(red(`\nDirectory "${name}" already exists.\n`));
494
+ process.exit(1);
495
+ }
496
+
497
+ p.intro(`ystack create — scaffolding ${name}`);
498
+
499
+ // --- Create directory structure ---
500
+ console.log(bold("Creating directories..."));
501
+ const dirs = [
502
+ "apps",
503
+ "packages",
504
+ "docs/src/content",
505
+ ".claude/skills",
506
+ ".context",
507
+ ];
508
+ for (const dir of dirs) {
509
+ mkdirSync(join(projectDir, dir), { recursive: true });
510
+ }
511
+ console.log(green(" ✓ apps/, packages/, docs/, .claude/, .context/\n"));
512
+
513
+ // --- Generate files ---
514
+ console.log(bold("Generating config files..."));
515
+
516
+ // Root package.json
517
+ const rootPkg = {
518
+ name,
519
+ version: "0.0.1",
520
+ private: true,
521
+ type: "module",
522
+ scripts: {
523
+ dev: "turbo dev",
524
+ build: "turbo build",
525
+ typecheck: "turbo typecheck",
526
+ check: "ultracite check",
527
+ fix: "ultracite fix",
528
+ clean: "turbo clean",
529
+ },
530
+ devDependencies: {
531
+ turbo: "latest",
532
+ typescript: "^5.8.0",
533
+ ultracite: "latest",
534
+ },
535
+ };
536
+ writeFileSync(join(projectDir, "package.json"), JSON.stringify(rootPkg, null, 2) + "\n");
537
+ console.log(green(" ✓ package.json"));
538
+
539
+ // turbo.json
540
+ const turboConfig = {
541
+ $schema: "https://turbo.build/schema.json",
542
+ tasks: {
543
+ dev: { persistent: true, cache: false },
544
+ build: { dependsOn: ["^build"] },
545
+ typecheck: { dependsOn: ["^build"] },
546
+ clean: { cache: false },
547
+ },
548
+ };
549
+ writeFileSync(join(projectDir, "turbo.json"), JSON.stringify(turboConfig, null, 2) + "\n");
550
+ console.log(green(" ✓ turbo.json"));
551
+
552
+ // pnpm-workspace.yaml
553
+ const pnpmWorkspace = `packages:
554
+ - "apps/*"
555
+ - "packages/*"
556
+ - "docs"
557
+ `;
558
+ writeFileSync(join(projectDir, "pnpm-workspace.yaml"), pnpmWorkspace);
559
+ console.log(green(" ✓ pnpm-workspace.yaml"));
560
+
561
+ // tsconfig.json
562
+ const tsconfig = {
563
+ compilerOptions: {
564
+ target: "ES2022",
565
+ module: "ES2022",
566
+ moduleResolution: "bundler",
567
+ lib: ["ES2022"],
568
+ strict: true,
569
+ esModuleInterop: true,
570
+ skipLibCheck: true,
571
+ forceConsistentCasingInFileNames: true,
572
+ resolveJsonModule: true,
573
+ isolatedModules: true,
574
+ declaration: true,
575
+ declarationMap: true,
576
+ sourceMap: true,
577
+ outDir: "dist",
578
+ },
579
+ exclude: ["node_modules", "dist", ".turbo"],
580
+ };
581
+ writeFileSync(join(projectDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
582
+ console.log(green(" ✓ tsconfig.json"));
583
+
584
+ // biome.json
585
+ const biomeConfig = {
586
+ $schema: "https://biomejs.dev/schemas/1.9.4/schema.json",
587
+ extends: ["ultracite"],
588
+ };
589
+ writeFileSync(join(projectDir, "biome.json"), JSON.stringify(biomeConfig, null, 2) + "\n");
590
+ console.log(green(" ✓ biome.json"));
591
+
592
+ // ystack.config.json
593
+ const docsRoot = docsFramework === "fumadocs" ? "content/docs" : "docs/src/content";
594
+ const ystackConfig = {
595
+ project: name,
596
+ docs: {
597
+ root: docsRoot,
598
+ framework: docsFramework,
599
+ },
600
+ monorepo: {
601
+ enabled: true,
602
+ tool: "turborepo",
603
+ },
604
+ modules: {},
605
+ workflow: {
606
+ plan_checker: true,
607
+ fresh_context_per_task: true,
608
+ auto_docs_check: true,
609
+ },
610
+ };
611
+ writeFileSync(join(projectDir, "ystack.config.json"), JSON.stringify(ystackConfig, null, 2) + "\n");
612
+ console.log(green(" ✓ ystack.config.json"));
613
+
614
+ // CLAUDE.md
615
+ const claudeMd = `# ${name}
616
+
617
+ This project uses [ystack](https://github.com/yulonghe97/ystack) for doc-driven development.
618
+
619
+ ## Structure
620
+
621
+ - \`apps/\` — Application packages
622
+ - \`packages/\` — Shared library packages
623
+ - \`docs/\` — Documentation site (${docsFramework})
624
+
625
+ ## Module Registry
626
+
627
+ Modules are defined in \`ystack.config.json\`. Each module maps code directories to documentation pages.
628
+
629
+ ## Available Commands
630
+
631
+ | Command | Description |
632
+ |---------|-------------|
633
+ | \`/import\` | Scan codebase and populate module registry |
634
+ | \`/build <feature>\` | Plan a feature (reads docs + code, surfaces assumptions) |
635
+ | \`/go\` | Execute the plan with fresh subagents |
636
+ | \`/review\` | Code review + goal-backward verification |
637
+ | \`/docs\` | Update documentation for completed work |
638
+ | \`/pr\` | Verify, docs check, create PR |
639
+
640
+ ## Scripts
641
+
642
+ - \`pnpm dev\` — Start dev servers
643
+ - \`pnpm build\` — Build all packages
644
+ - \`pnpm typecheck\` — Type-check all packages
645
+ - \`pnpm check\` — Lint (ultracite/biome)
646
+ - \`pnpm fix\` — Auto-fix lint issues
647
+ - \`pnpm clean\` — Clean build artifacts
648
+ `;
649
+ writeFileSync(join(projectDir, "CLAUDE.md"), claudeMd);
650
+ console.log(green(" ✓ CLAUDE.md"));
651
+
652
+ // AGENTS.md — runtime-agnostic context for non-Claude agents
653
+ const agentsMd = `# ${name}
654
+
655
+ This project uses [ystack](https://github.com/yulonghe97/ystack) for doc-driven development.
656
+
657
+ ## Structure
658
+
659
+ - \`apps/\` — Application packages
660
+ - \`packages/\` — Shared library packages
661
+ - \`docs/\` — Documentation site (${docsFramework})
662
+
663
+ ## Module Registry
664
+
665
+ Modules are defined in \`ystack.config.json\`. Each module maps code directories to documentation pages.
666
+
667
+ ## Workflow
668
+
669
+ 1. Read the relevant doc page before making changes
670
+ 2. Plan before executing — break work into small, verifiable tasks
671
+ 3. Verify against success criteria after implementation
672
+ 4. Update docs when done — only document completed, verified work
673
+
674
+ ## Scripts
675
+
676
+ - \`pnpm dev\` — Start dev servers
677
+ - \`pnpm build\` — Build all packages
678
+ - \`pnpm typecheck\` — Type-check all packages
679
+ - \`pnpm check\` — Lint (ultracite/biome)
680
+ - \`pnpm fix\` — Auto-fix lint issues
681
+ - \`pnpm clean\` — Clean build artifacts
682
+ `;
683
+ writeFileSync(join(projectDir, "AGENTS.md"), agentsMd);
684
+ console.log(green(" ✓ AGENTS.md"));
685
+
686
+ // .gitignore
687
+ const gitignore = `node_modules/
688
+ dist/
689
+ .turbo/
690
+ .next/
691
+ .context/
692
+ .env
693
+ .env.local
694
+ *.tsbuildinfo
695
+ `;
696
+ writeFileSync(join(projectDir, ".gitignore"), gitignore);
697
+ console.log(green(" ✓ .gitignore"));
698
+
699
+ // .env.example
700
+ writeFileSync(join(projectDir, ".env.example"), "");
701
+ console.log(green(" ✓ .env.example"));
702
+ console.log();
703
+
704
+ // --- Docs app ---
705
+ console.log(bold("Setting up docs app..."));
706
+
707
+ if (docsFramework === "nextra") {
708
+ // docs/package.json
709
+ const docsPkg = {
710
+ name: `${name}-docs`,
711
+ version: "0.0.1",
712
+ private: true,
713
+ type: "module",
714
+ scripts: {
715
+ dev: "next dev",
716
+ build: "next build",
717
+ },
718
+ dependencies: {
719
+ next: "^15.0.0",
720
+ nextra: "^4.0.0",
721
+ "nextra-theme-docs": "^4.0.0",
722
+ react: "^19.0.0",
723
+ "react-dom": "^19.0.0",
724
+ },
725
+ devDependencies: {
726
+ typescript: "^5.8.0",
727
+ "@types/react": "^19.0.0",
728
+ },
729
+ };
730
+ writeFileSync(join(projectDir, "docs/package.json"), JSON.stringify(docsPkg, null, 2) + "\n");
731
+ console.log(green(" ✓ docs/package.json"));
732
+
733
+ // docs/next.config.ts
734
+ const nextConfig = `import nextra from "nextra";
735
+
736
+ const withNextra = nextra({});
737
+
738
+ export default withNextra({
739
+ output: "export",
740
+ images: { unoptimized: true },
741
+ });
742
+ `;
743
+ writeFileSync(join(projectDir, "docs/next.config.ts"), nextConfig);
744
+ console.log(green(" ✓ docs/next.config.ts"));
745
+
746
+ // docs/tsconfig.json
747
+ const docsTsconfig = {
748
+ extends: "../tsconfig.json",
749
+ compilerOptions: {
750
+ jsx: "preserve",
751
+ outDir: "dist",
752
+ noEmit: true,
753
+ },
754
+ include: ["src", "next.config.ts", "next-env.d.ts"],
755
+ exclude: ["node_modules", ".next"],
756
+ };
757
+ writeFileSync(join(projectDir, "docs/tsconfig.json"), JSON.stringify(docsTsconfig, null, 2) + "\n");
758
+ console.log(green(" ✓ docs/tsconfig.json"));
759
+
760
+ // docs/src/content/_meta.ts
761
+ const metaTs = `export default {
762
+ index: "Overview",
763
+ };
764
+ `;
765
+ writeFileSync(join(projectDir, "docs/src/content/_meta.ts"), metaTs);
766
+ console.log(green(" ✓ docs/src/content/_meta.ts"));
767
+
768
+ // docs/src/content/index.mdx
769
+ const indexMdx = `---
770
+ title: ${name}
771
+ ---
772
+
773
+ # ${name}
774
+
775
+ Welcome to the ${name} documentation.
776
+
777
+ ## Getting Started
778
+
779
+ This project is set up as a monorepo using Turborepo and pnpm workspaces.
780
+
781
+ \`\`\`bash
782
+ pnpm install
783
+ pnpm dev
784
+ \`\`\`
785
+
786
+ ## Project Structure
787
+
788
+ - \`apps/\` — Application packages
789
+ - \`packages/\` — Shared library packages
790
+ - \`docs/\` — This documentation site
791
+ `;
792
+ writeFileSync(join(projectDir, "docs/src/content/index.mdx"), indexMdx);
793
+ console.log(green(" ✓ docs/src/content/index.mdx"));
794
+ } else {
795
+ // Fumadocs setup
796
+ // docs/package.json
797
+ const docsPkg = {
798
+ name: `${name}-docs`,
799
+ version: "0.0.1",
800
+ private: true,
801
+ type: "module",
802
+ scripts: {
803
+ dev: "next dev",
804
+ build: "next build",
805
+ },
806
+ dependencies: {
807
+ next: "^15.0.0",
808
+ "fumadocs-core": "latest",
809
+ "fumadocs-ui": "latest",
810
+ "fumadocs-mdx": "latest",
811
+ react: "^19.0.0",
812
+ "react-dom": "^19.0.0",
813
+ },
814
+ devDependencies: {
815
+ typescript: "^5.8.0",
816
+ "@types/react": "^19.0.0",
817
+ },
818
+ };
819
+ writeFileSync(join(projectDir, "docs/package.json"), JSON.stringify(docsPkg, null, 2) + "\n");
820
+ console.log(green(" ✓ docs/package.json"));
821
+
822
+ // docs/next.config.ts
823
+ const nextConfig = `import { createMDX } from "fumadocs-mdx/next";
824
+
825
+ const withMDX = createMDX();
826
+
827
+ export default withMDX({});
828
+ `;
829
+ writeFileSync(join(projectDir, "docs/next.config.ts"), nextConfig);
830
+ console.log(green(" ✓ docs/next.config.ts"));
831
+
832
+ // docs/tsconfig.json
833
+ const docsTsconfig = {
834
+ extends: "../tsconfig.json",
835
+ compilerOptions: {
836
+ jsx: "preserve",
837
+ outDir: "dist",
838
+ noEmit: true,
839
+ },
840
+ include: ["src", "next.config.ts", "next-env.d.ts", "content"],
841
+ exclude: ["node_modules", ".next"],
842
+ };
843
+ writeFileSync(join(projectDir, "docs/tsconfig.json"), JSON.stringify(docsTsconfig, null, 2) + "\n");
844
+ console.log(green(" ✓ docs/tsconfig.json"));
845
+
846
+ // For fumadocs, docs content goes under content/docs/ at project root
847
+ mkdirSync(join(projectDir, "content/docs"), { recursive: true });
848
+
849
+ // content/docs/meta.json
850
+ const metaJson = {
851
+ title: name,
852
+ pages: ["index"],
853
+ };
854
+ writeFileSync(join(projectDir, "content/docs/meta.json"), JSON.stringify(metaJson, null, 2) + "\n");
855
+ console.log(green(" ✓ content/docs/meta.json"));
856
+
857
+ // content/docs/index.mdx
858
+ const indexMdx = `---
859
+ title: ${name}
860
+ ---
861
+
862
+ # ${name}
863
+
864
+ Welcome to the ${name} documentation.
865
+
866
+ ## Getting Started
867
+
868
+ This project is set up as a monorepo using Turborepo and pnpm workspaces.
869
+
870
+ \`\`\`bash
871
+ pnpm install
872
+ pnpm dev
873
+ \`\`\`
874
+
875
+ ## Project Structure
876
+
877
+ - \`apps/\` — Application packages
878
+ - \`packages/\` — Shared library packages
879
+ - \`docs/\` — This documentation site
880
+ `;
881
+ writeFileSync(join(projectDir, "content/docs/index.mdx"), indexMdx);
882
+ console.log(green(" ✓ content/docs/index.mdx"));
883
+ }
884
+ console.log();
885
+
886
+ // --- Skills & Hooks ---
887
+ console.log(bold("Installing skills..."));
888
+ const skillsResult = copySkills(projectDir, YSTACK_ROOT);
889
+ console.log(dim(` ${skillsResult.installed} installed, ${skillsResult.skipped} skipped\n`));
890
+
891
+ console.log(bold("Installing hooks..."));
892
+ installHooks(projectDir, YSTACK_ROOT);
893
+ console.log(green(" ✓ context-monitor (PostToolUse)"));
894
+ console.log(green(" ✓ workflow-nudge (PreToolUse on Edit)\n"));
895
+
896
+ // --- Git init ---
897
+ console.log(bold("Initializing git..."));
898
+ try {
899
+ execSync("git init", { cwd: projectDir, stdio: "ignore" });
900
+ console.log(green(" ✓ git init\n"));
901
+ } catch {
902
+ console.log(yellow(" ⚠ git init failed — initialize manually\n"));
903
+ }
904
+
905
+ // --- Summary ---
906
+ p.note(
907
+ [
908
+ `Monorepo: Turborepo + pnpm workspaces`,
909
+ `Linting: Ultracite (Biome)`,
910
+ `Docs: ${docsFramework === "nextra" ? "Nextra 4" : "Fumadocs"}`,
911
+ `TypeScript: Strict mode, ES2022`,
912
+ `Skills: ${skillsResult.installed} ystack skills`,
913
+ `Hooks: context-monitor, workflow-nudge`,
914
+ ].join("\n"),
915
+ `Created ${name}`,
916
+ );
917
+
918
+ p.note(
919
+ [
920
+ `cd ${name}`,
921
+ "pnpm install",
922
+ "pnpm dev",
923
+ ].join("\n"),
924
+ "Next steps",
925
+ );
926
+
927
+ p.outro("Done!");
928
+ }
929
+
930
+ function usage() {
931
+ console.log(`
932
+ ${bold("ystack")} — agent harness for doc-driven development
933
+
934
+ ${bold("Commands:")}
935
+ ${green("init")} Interactive setup — configure docs, beads, runtime, hooks
936
+ ${green("init --skills-only")} Install skills only, skip everything else
937
+ ${green("update")} Update skills and hooks to latest version
938
+ ${green("remove")} Remove ystack skills and hooks (keeps data)
939
+ ${green("create <name>")} Scaffold a new project with opinionated defaults
940
+ ${dim(" --docs nextra|fumadocs")} Choose docs framework (default: nextra)
941
+ ${dim(" --from plan.md")} Scaffold integration (coming soon)
942
+
943
+ ${bold("Docs:")}
944
+ https://github.com/yulonghe97/ystack
945
+ `);
946
+ }
947
+
948
+ // --- Router ---
949
+
950
+ switch (command) {
951
+ case "init":
952
+ cmdInit();
953
+ break;
954
+ case "update":
955
+ cmdUpdate();
956
+ break;
957
+ case "remove":
958
+ cmdRemove();
959
+ break;
960
+ case "create":
961
+ cmdCreate();
962
+ break;
963
+ case "--help":
964
+ case "-h":
965
+ case "help":
966
+ case undefined:
967
+ usage();
968
+ break;
969
+ default:
970
+ console.log(red(`Unknown command: ${command}\n`));
971
+ usage();
972
+ process.exit(1);
973
+ }