@hustle-together/api-dev-tools 3.12.2 → 3.12.10

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 CHANGED
@@ -51,9 +51,10 @@ ${c.red} ╔═════════════════════
51
51
  ║ ║
52
52
  ╚═══════════════════════════════════════════════════════════════╝${c.reset}
53
53
 
54
- ${c.bold} API Development Tools for Claude Code${c.reset}
54
+ ${c.red}${c.bold} HUSTLE${c.reset}
55
+ ${c.bold} API Dev Tools${c.reset}
55
56
  ${c.dim} Interview-driven, research-first API development${c.reset}
56
- ${c.gray}v1.0.0${c.reset}
57
+ ${c.gray}v3.12.7${c.reset}
57
58
  `;
58
59
 
59
60
  // ═══════════════════════════════════════════════════════════════════════════
@@ -112,6 +113,357 @@ function logError(message) {
112
113
  console.log(` ${c.red}✗${c.reset} ${message}`);
113
114
  }
114
115
 
116
+ // ═══════════════════════════════════════════════════════════════════════════
117
+ // Interactive Prompt Utilities
118
+ // ═══════════════════════════════════════════════════════════════════════════
119
+
120
+ /**
121
+ * Single-select with arrow keys
122
+ * @param {string} question - The question to ask
123
+ * @param {Array<{label: string, value: any}>} options - Options to choose from
124
+ * @returns {Promise<any>} - The selected value
125
+ */
126
+ async function selectOne(question, options) {
127
+ return new Promise((resolve) => {
128
+ let selected = 0;
129
+
130
+ const renderOptions = () => {
131
+ process.stdout.write("\x1B[?25l"); // Hide cursor
132
+ console.log(`\n${c.bold}${question}${c.reset}`);
133
+ options.forEach((opt, i) => {
134
+ const prefix = i === selected ? `${c.red}❯${c.reset}` : " ";
135
+ const label =
136
+ i === selected ? `${c.bold}${opt.label}${c.reset}` : `${c.dim}${opt.label}${c.reset}`;
137
+ console.log(` ${prefix} ${label}`);
138
+ });
139
+ };
140
+
141
+ const clearOptions = () => {
142
+ process.stdout.write(`\x1B[${options.length + 2}A`); // Move up
143
+ for (let i = 0; i < options.length + 2; i++) {
144
+ process.stdout.write("\x1B[2K\n"); // Clear line
145
+ }
146
+ process.stdout.write(`\x1B[${options.length + 2}A`); // Move back up
147
+ };
148
+
149
+ renderOptions();
150
+
151
+ process.stdin.setRawMode(true);
152
+ process.stdin.resume();
153
+ process.stdin.setEncoding("utf8");
154
+
155
+ const onKeypress = (key) => {
156
+ if (key === "\u001B[A") {
157
+ // Up arrow
158
+ selected = selected > 0 ? selected - 1 : options.length - 1;
159
+ clearOptions();
160
+ renderOptions();
161
+ } else if (key === "\u001B[B") {
162
+ // Down arrow
163
+ selected = selected < options.length - 1 ? selected + 1 : 0;
164
+ clearOptions();
165
+ renderOptions();
166
+ } else if (key === "\r" || key === "\n") {
167
+ // Enter
168
+ process.stdin.setRawMode(false);
169
+ process.stdin.pause();
170
+ process.stdin.removeListener("data", onKeypress);
171
+ process.stdout.write("\x1B[?25h"); // Show cursor
172
+ clearOptions();
173
+ console.log(
174
+ `\n${c.bold}${question}${c.reset} ${c.white}${options[selected].label}${c.reset}`,
175
+ );
176
+ resolve(options[selected].value);
177
+ } else if (key === "\u0003") {
178
+ // Ctrl+C
179
+ process.stdout.write("\x1B[?25h");
180
+ process.exit(0);
181
+ }
182
+ };
183
+
184
+ process.stdin.on("data", onKeypress);
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Multi-select with checkboxes
190
+ * @param {string} question - The question to ask
191
+ * @param {Array<{label: string, value: any, checked?: boolean}>} options - Options to choose from
192
+ * @returns {Promise<Array<any>>} - Array of selected values
193
+ */
194
+ async function selectMany(question, options) {
195
+ return new Promise((resolve) => {
196
+ let cursor = 0;
197
+ const checked = options.map((opt) => opt.checked || false);
198
+
199
+ const renderOptions = () => {
200
+ process.stdout.write("\x1B[?25l");
201
+ console.log(`\n${c.bold}${question}${c.reset} ${c.dim}(space to toggle, enter to confirm)${c.reset}`);
202
+ options.forEach((opt, i) => {
203
+ const pointer = i === cursor ? `${c.red}❯${c.reset}` : " ";
204
+ const checkbox = checked[i] ? `${c.white}◉${c.reset}` : `${c.dim}○${c.reset}`;
205
+ const label = i === cursor ? `${c.bold}${opt.label}${c.reset}` : opt.label;
206
+ console.log(` ${pointer} ${checkbox} ${label}`);
207
+ });
208
+ };
209
+
210
+ const clearOptions = () => {
211
+ process.stdout.write(`\x1B[${options.length + 2}A`);
212
+ for (let i = 0; i < options.length + 2; i++) {
213
+ process.stdout.write("\x1B[2K\n");
214
+ }
215
+ process.stdout.write(`\x1B[${options.length + 2}A`);
216
+ };
217
+
218
+ renderOptions();
219
+
220
+ process.stdin.setRawMode(true);
221
+ process.stdin.resume();
222
+ process.stdin.setEncoding("utf8");
223
+
224
+ const onKeypress = (key) => {
225
+ if (key === "\u001B[A") {
226
+ cursor = cursor > 0 ? cursor - 1 : options.length - 1;
227
+ clearOptions();
228
+ renderOptions();
229
+ } else if (key === "\u001B[B") {
230
+ cursor = cursor < options.length - 1 ? cursor + 1 : 0;
231
+ clearOptions();
232
+ renderOptions();
233
+ } else if (key === " ") {
234
+ checked[cursor] = !checked[cursor];
235
+ clearOptions();
236
+ renderOptions();
237
+ } else if (key === "\r" || key === "\n") {
238
+ process.stdin.setRawMode(false);
239
+ process.stdin.pause();
240
+ process.stdin.removeListener("data", onKeypress);
241
+ process.stdout.write("\x1B[?25h");
242
+ clearOptions();
243
+ const selectedLabels = options
244
+ .filter((_, i) => checked[i])
245
+ .map((o) => o.label)
246
+ .join(", ");
247
+ console.log(`\n${c.bold}${question}${c.reset} ${c.white}${selectedLabels || "None"}${c.reset}`);
248
+ resolve(options.filter((_, i) => checked[i]).map((o) => o.value));
249
+ } else if (key === "\u0003") {
250
+ process.stdout.write("\x1B[?25h");
251
+ process.exit(0);
252
+ }
253
+ };
254
+
255
+ process.stdin.on("data", onKeypress);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Text input prompt
261
+ * @param {string} question - The question to ask
262
+ * @param {Object} options - Options
263
+ * @param {string} options.default - Default value
264
+ * @param {boolean} options.mask - Mask the echoed value (for API keys)
265
+ * @param {string} options.hint - Additional hint text
266
+ * @returns {Promise<string>} - The entered text
267
+ */
268
+ async function textInput(question, options = {}) {
269
+ const rl = readline.createInterface({
270
+ input: process.stdin,
271
+ output: process.stdout,
272
+ });
273
+
274
+ return new Promise((resolve) => {
275
+ const defaultHint = options.default
276
+ ? ` ${c.dim}(${options.mask ? maskSecret(options.default) : options.default})${c.reset}`
277
+ : "";
278
+ const extraHint = options.hint ? ` ${c.dim}${options.hint}${c.reset}` : "";
279
+ rl.question(`${c.bold}${question}${c.reset}${defaultHint}${extraHint}: `, (answer) => {
280
+ rl.close();
281
+ const result = answer.trim() || options.default || "";
282
+
283
+ // If masked, show confirmation with masked value
284
+ if (options.mask && result) {
285
+ // Move cursor up and rewrite line with masked value
286
+ process.stdout.write(`\x1B[1A\x1B[2K`);
287
+ console.log(`${c.bold}${question}${c.reset}: ${c.dim}${maskSecret(result)}${c.reset}`);
288
+ }
289
+
290
+ resolve(result);
291
+ });
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Yes/No confirmation
297
+ * @param {string} question - The question to ask
298
+ * @param {boolean} defaultYes - Default to yes
299
+ * @returns {Promise<boolean>}
300
+ */
301
+ async function confirm(question, defaultYes = true) {
302
+ const hint = defaultYes ? `${c.dim}(Y/n)${c.reset}` : `${c.dim}(y/N)${c.reset}`;
303
+ const answer = await textInput(`${question} ${hint}`, {});
304
+ if (!answer) return defaultYes;
305
+ return answer.toLowerCase().startsWith("y");
306
+ }
307
+
308
+ /**
309
+ * Progress bar
310
+ * @param {number} current - Current value
311
+ * @param {number} total - Total value
312
+ * @param {string} label - Label to show
313
+ */
314
+ function progressBar(current, total, label = "") {
315
+ const width = 40;
316
+ const percent = Math.round((current / total) * 100);
317
+ const filled = Math.round((current / total) * width);
318
+ const empty = width - filled;
319
+ const bar = `${c.red}${"█".repeat(filled)}${c.gray}${"░".repeat(empty)}${c.reset}`;
320
+ process.stdout.write(`\r ${bar} ${percent}% ${label}`);
321
+ if (current === total) console.log();
322
+ }
323
+
324
+ // ═══════════════════════════════════════════════════════════════════════════
325
+ // Validation Utilities
326
+ // ═══════════════════════════════════════════════════════════════════════════
327
+
328
+ /**
329
+ * Mask sensitive values (show only last 4 chars)
330
+ */
331
+ function maskSecret(value) {
332
+ if (!value || value.length < 8) return "****";
333
+ return "****" + value.slice(-4);
334
+ }
335
+
336
+ /**
337
+ * Validate hex color format
338
+ */
339
+ function isValidHex(color) {
340
+ return /^#[0-9A-Fa-f]{3,8}$/.test(color);
341
+ }
342
+
343
+ /**
344
+ * Color presets for quick selection
345
+ */
346
+ const COLOR_PRESETS = {
347
+ // Reds
348
+ red: "#EF4444",
349
+ rose: "#F43F5E",
350
+ pink: "#EC4899",
351
+ // Blues
352
+ blue: "#3B82F6",
353
+ sky: "#0EA5E9",
354
+ cyan: "#06B6D4",
355
+ indigo: "#6366F1",
356
+ // Greens
357
+ green: "#22C55E",
358
+ emerald: "#10B981",
359
+ teal: "#14B8A6",
360
+ // Yellows/Oranges
361
+ yellow: "#EAB308",
362
+ amber: "#F59E0B",
363
+ orange: "#F97316",
364
+ // Purples
365
+ purple: "#A855F7",
366
+ violet: "#8B5CF6",
367
+ fuchsia: "#D946EF",
368
+ // Neutrals
369
+ slate: "#64748B",
370
+ gray: "#6B7280",
371
+ zinc: "#71717A",
372
+ stone: "#78716C",
373
+ // Special
374
+ black: "#000000",
375
+ white: "#FFFFFF",
376
+ beige: "#F5F5DC",
377
+ cream: "#FFFDD0",
378
+ navy: "#1E3A5A",
379
+ maroon: "#800000",
380
+ coral: "#FF7F50",
381
+ gold: "#FFD700",
382
+ };
383
+
384
+ /**
385
+ * Parse color input - accepts hex or color name
386
+ */
387
+ function parseColor(input, defaultColor) {
388
+ if (!input) return defaultColor;
389
+ const trimmed = input.trim().toLowerCase();
390
+
391
+ // Check if it's a valid hex
392
+ if (isValidHex(input.trim())) {
393
+ return input.trim().toUpperCase();
394
+ }
395
+
396
+ // Check presets
397
+ if (COLOR_PRESETS[trimmed]) {
398
+ return COLOR_PRESETS[trimmed];
399
+ }
400
+
401
+ // Return default if invalid
402
+ return defaultColor;
403
+ }
404
+
405
+ /**
406
+ * Font presets with descriptions
407
+ */
408
+ const FONT_PRESETS = {
409
+ // Sans-serif
410
+ inter: { name: "Inter", type: "sans-serif", desc: "Clean, modern" },
411
+ geist: { name: "Geist", type: "sans-serif", desc: "GitHub/Vercel style" },
412
+ "plus jakarta sans": { name: "Plus Jakarta Sans", type: "sans-serif", desc: "Friendly" },
413
+ "dm sans": { name: "DM Sans", type: "sans-serif", desc: "Geometric" },
414
+ "ibm plex sans": { name: "IBM Plex Sans", type: "sans-serif", desc: "Technical" },
415
+ poppins: { name: "Poppins", type: "sans-serif", desc: "Geometric, modern" },
416
+ montserrat: { name: "Montserrat", type: "sans-serif", desc: "Clean, elegant" },
417
+ // Serif
418
+ playfair: { name: "Playfair Display", type: "serif", desc: "Elegant, sophisticated" },
419
+ lora: { name: "Lora", type: "serif", desc: "Well-balanced" },
420
+ merriweather: { name: "Merriweather", type: "serif", desc: "Readable, warm" },
421
+ georgia: { name: "Georgia", type: "serif", desc: "Classic web serif" },
422
+ "source serif": { name: "Source Serif Pro", type: "serif", desc: "Modern serif" },
423
+ crimson: { name: "Crimson Text", type: "serif", desc: "Book-style elegance" },
424
+ // Monospace
425
+ "jetbrains mono": { name: "JetBrains Mono", type: "mono", desc: "Developer favorite" },
426
+ "fira code": { name: "Fira Code", type: "mono", desc: "Ligatures, readable" },
427
+ "source code": { name: "Source Code Pro", type: "mono", desc: "Adobe, clean" },
428
+ };
429
+
430
+ /**
431
+ * Parse font input - accepts font name or description
432
+ */
433
+ function parseFont(input, defaultFont) {
434
+ if (!input) return defaultFont;
435
+ const trimmed = input.trim().toLowerCase();
436
+
437
+ // Check exact match in presets
438
+ if (FONT_PRESETS[trimmed]) {
439
+ return FONT_PRESETS[trimmed].name;
440
+ }
441
+
442
+ // Check if input contains keywords
443
+ if (trimmed.includes("serif") && !trimmed.includes("sans")) {
444
+ // User wants a serif font
445
+ if (trimmed.includes("sophist") || trimmed.includes("elegant")) {
446
+ return "Playfair Display";
447
+ }
448
+ return "Crimson Text";
449
+ }
450
+
451
+ if (trimmed.includes("sans") || trimmed.includes("clean") || trimmed.includes("modern")) {
452
+ return "Inter";
453
+ }
454
+
455
+ if (trimmed.includes("mono") || trimmed.includes("code")) {
456
+ return "JetBrains Mono";
457
+ }
458
+
459
+ // If it looks like a font name (capitalized words), use it directly
460
+ if (/^[A-Z][a-z]+(\s[A-Z][a-z]+)*$/.test(input.trim())) {
461
+ return input.trim();
462
+ }
463
+
464
+ return defaultFont;
465
+ }
466
+
115
467
  // ═══════════════════════════════════════════════════════════════════════════
116
468
  // File Copy Utilities
117
469
  // ═══════════════════════════════════════════════════════════════════════════
@@ -119,8 +471,10 @@ function logError(message) {
119
471
  function copyDir(src, dest, options = {}) {
120
472
  const { filter = () => true, overwrite = false } = options;
121
473
  let copied = 0;
474
+ let skipped = 0;
475
+ let total = 0;
122
476
 
123
- if (!fs.existsSync(src)) return copied;
477
+ if (!fs.existsSync(src)) return { copied: 0, skipped: 0, total: 0 };
124
478
 
125
479
  if (!fs.existsSync(dest)) {
126
480
  fs.mkdirSync(dest, { recursive: true });
@@ -135,16 +489,22 @@ function copyDir(src, dest, options = {}) {
135
489
  if (!filter(entry.name, srcPath)) continue;
136
490
 
137
491
  if (entry.isDirectory()) {
138
- copied += copyDir(srcPath, destPath, options);
492
+ const result = copyDir(srcPath, destPath, options);
493
+ copied += result.copied;
494
+ skipped += result.skipped;
495
+ total += result.total;
139
496
  } else {
497
+ total++;
140
498
  if (!fs.existsSync(destPath) || overwrite) {
141
499
  fs.copyFileSync(srcPath, destPath);
142
500
  copied++;
501
+ } else {
502
+ skipped++;
143
503
  }
144
504
  }
145
505
  }
146
506
 
147
- return copied;
507
+ return { copied, skipped, total };
148
508
  }
149
509
 
150
510
  function copyFile(src, dest, options = {}) {
@@ -205,10 +565,8 @@ function checkNode() {
205
565
  async function main() {
206
566
  // Parse arguments
207
567
  const args = process.argv.slice(2);
208
- const withStorybook = args.includes("--with-storybook");
209
- const withPlaywright = args.includes("--with-playwright");
210
- const withSandpack = args.includes("--with-sandpack");
211
568
  const silent = args.includes("--silent") || args.includes("-s");
569
+ const quickMode = args.includes("--quick") || args.includes("-q");
212
570
 
213
571
  // Show banner
214
572
  if (!silent) {
@@ -222,7 +580,318 @@ async function main() {
222
580
  const claudeDir = path.join(targetDir, ".claude");
223
581
  const hooksDir = path.join(claudeDir, "hooks");
224
582
 
225
- const totalSteps = 8;
583
+ // ─────────────────────────────────────────────────────────────────────────
584
+ // WIZARD STEP 1: Choose Setup Mode
585
+ // ─────────────────────────────────────────────────────────────────────────
586
+
587
+ let config = {
588
+ withStorybook: true,
589
+ withPlaywright: true,
590
+ withSandpack: true,
591
+ githubToken: "",
592
+ greptileApiKey: "",
593
+ brandfetchApiKey: "",
594
+ ntfyEnabled: false,
595
+ ntfyTopic: "",
596
+ ntfyServer: "ntfy.sh",
597
+ createBrandGuide: true,
598
+ brandSource: "manual", // "brandfetch" or "manual"
599
+ brandDomain: "",
600
+ brandName: path.basename(targetDir),
601
+ primaryColor: "#E11D48",
602
+ secondaryColor: "#1E40AF",
603
+ accentColor: "#8B5CF6",
604
+ successColor: "#10B981",
605
+ warningColor: "#F59E0B",
606
+ errorColor: "#EF4444",
607
+ fontFamily: "Inter",
608
+ headingFont: "Inter",
609
+ monoFont: "JetBrains Mono",
610
+ borderRadius: "8px",
611
+ darkMode: true,
612
+ imageStyle: "photography", // photography, illustration, abstract, minimal
613
+ iconStyle: "outline", // outline, solid, duotone
614
+ buttonStyle: "rounded", // sharp, subtle, rounded, pill
615
+ cardStyle: "elevated", // flat, bordered, elevated
616
+ animationLevel: "subtle", // none, subtle, moderate, expressive
617
+ };
618
+
619
+ if (!silent && !quickMode) {
620
+ const setupMode = await selectOne("Choose installation mode", [
621
+ { label: "Quick Setup (recommended defaults)", value: "quick" },
622
+ { label: "Custom Setup (configure each option)", value: "custom" },
623
+ ]);
624
+
625
+ if (setupMode === "custom") {
626
+ // ─────────────────────────────────────────────────────────────────────
627
+ // WIZARD STEP 2: API Keys
628
+ // ─────────────────────────────────────────────────────────────────────
629
+
630
+ log(`\n${c.red}━━━ API Keys ━━━${c.reset}`);
631
+ log(`${c.dim}These keys enable advanced features like code review and brand fetching${c.reset}\n`);
632
+ log(`${c.bold}Get your keys (all have free tiers):${c.reset}\n`);
633
+ log(` ${c.white}GITHUB_TOKEN${c.reset}`);
634
+ log(` ${c.dim}→${c.reset} https://github.com/settings/tokens`);
635
+ log(` ${c.dim}Purpose: Create issues, PRs, search code${c.reset}`);
636
+ log(` ${c.dim}Scope needed: 'repo' for private repos${c.reset}\n`);
637
+ log(` ${c.white}GREPTILE_API_KEY${c.reset}`);
638
+ log(` ${c.dim}→${c.reset} https://app.greptile.com/settings/api`);
639
+ log(` ${c.dim}Purpose: AI code review in Phase 11${c.reset}`);
640
+ log(` ${c.dim}Free tier: 100 reviews/month${c.reset}\n`);
641
+ log(` ${c.white}BRANDFETCH_API_KEY${c.reset}`);
642
+ log(` ${c.dim}→${c.reset} https://brandfetch.com/developers`);
643
+ log(` ${c.dim}Purpose: Auto-fetch logos, colors, fonts from domains${c.reset}`);
644
+ log(` ${c.dim}Free tier: 50 requests/month (basic assets)${c.reset}\n`);
645
+
646
+ const configureKeys = await selectOne("Configure API keys now?", [
647
+ { label: "Yes, enter them now", value: true },
648
+ { label: "No, I'll add to .env later", value: false },
649
+ ]);
650
+
651
+ if (configureKeys) {
652
+ config.githubToken = await textInput("GITHUB_TOKEN", {
653
+ default: process.env.GITHUB_TOKEN || "",
654
+ mask: true,
655
+ });
656
+ config.greptileApiKey = await textInput("GREPTILE_API_KEY", {
657
+ default: process.env.GREPTILE_API_KEY || "",
658
+ mask: true,
659
+ });
660
+ config.brandfetchApiKey = await textInput("BRANDFETCH_API_KEY (optional)", {
661
+ default: process.env.BRANDFETCH_API_KEY || "",
662
+ mask: true,
663
+ });
664
+ }
665
+
666
+ // ─────────────────────────────────────────────────────────────────────
667
+ // WIZARD STEP 3: Testing Tools
668
+ // ─────────────────────────────────────────────────────────────────────
669
+
670
+ log(`\n${c.red}━━━ Testing Tools ━━━${c.reset}`);
671
+ log(`${c.dim}Required for component development and E2E testing${c.reset}\n`);
672
+ log(`${c.bold}What each tool does:${c.reset}`);
673
+ log(` ${c.white}Playwright${c.reset} → E2E browser testing (required for /hustle-ui-create-page)`);
674
+ log(` ${c.white}Storybook${c.reset} → Component development & visual testing (required for /hustle-ui-create)`);
675
+ log(` ${c.white}Sandpack${c.reset} → Live code preview in browser (optional)\n`);
676
+
677
+ const selectedTools = await selectMany("Select testing tools to install", [
678
+ { label: "Playwright (E2E testing)", value: "playwright", checked: true },
679
+ { label: "Storybook (component development)", value: "storybook", checked: true },
680
+ { label: "Sandpack (live code preview)", value: "sandpack", checked: true },
681
+ ]);
682
+
683
+ config.withPlaywright = selectedTools.includes("playwright");
684
+ config.withStorybook = selectedTools.includes("storybook");
685
+ config.withSandpack = selectedTools.includes("sandpack");
686
+
687
+ // ─────────────────────────────────────────────────────────────────────
688
+ // WIZARD STEP 4: NTFY Notifications
689
+ // ─────────────────────────────────────────────────────────────────────
690
+
691
+ log(`\n${c.red}━━━ Push Notifications (NTFY) ━━━${c.reset}`);
692
+ log(`${c.dim}Get notified on your phone when long tasks complete${c.reset}\n`);
693
+ log(`${c.bold}How it works:${c.reset}`);
694
+ log(` 1. Install NTFY app: ${c.white}iOS${c.reset} App Store / ${c.white}Android${c.reset} Play Store`);
695
+ log(` 2. Subscribe to your topic in the app`);
696
+ log(` 3. Receive push notifications when builds/tests finish\n`);
697
+ log(`${c.dim}Free service, no account required. Learn more: https://ntfy.sh${c.reset}\n`);
698
+
699
+ config.ntfyEnabled = await confirm("Enable NTFY push notifications?", false);
700
+
701
+ if (config.ntfyEnabled) {
702
+ log(`\n${c.dim}Topic name = channel for your notifications (must match the app)${c.reset}`);
703
+ config.ntfyTopic = await textInput("NTFY Topic name", {
704
+ default: path.basename(targetDir) + "-alerts",
705
+ });
706
+ log(`${c.dim}Server URL = ntfy.sh (public) or your self-hosted server${c.reset}`);
707
+ config.ntfyServer = await textInput("NTFY Server URL", {
708
+ default: "ntfy.sh",
709
+ });
710
+ }
711
+
712
+ // ─────────────────────────────────────────────────────────────────────
713
+ // WIZARD STEP 5: Brand Guide
714
+ // ─────────────────────────────────────────────────────────────────────
715
+
716
+ log(`\n${c.red}━━━ Brand Guide ━━━${c.reset}`);
717
+ log(`${c.dim}Design system that enforces consistent UI across all components${c.reset}\n`);
718
+ log(`${c.bold}Why you need a brand guide:${c.reset}`);
719
+ log(` • Consistent look across all pages and components`);
720
+ log(` • Faster development (no color/font decisions each time)`);
721
+ log(` • Enforced by hooks during /hustle-ui-create`);
722
+ log(` • Professional, cohesive user experience\n`);
723
+
724
+ config.createBrandGuide = await confirm("Create brand guide?", true);
725
+
726
+ if (config.createBrandGuide) {
727
+ // Brand source selection
728
+ log(`\n${c.bold}How would you like to create your brand guide?${c.reset}\n`);
729
+
730
+ config.brandSource = await selectOne("Brand guide source", [
731
+ { label: "Manual Interview - Answer questions about your brand preferences", value: "manual" },
732
+ { label: "Brandfetch - Auto-fetch from company domain (requires API key)", value: "brandfetch" },
733
+ ]);
734
+
735
+ if (config.brandSource === "brandfetch") {
736
+ log(`\n${c.bold}Brandfetch Integration${c.reset}`);
737
+ log(`${c.dim}Automatically pulls logos, colors, and fonts from any company domain${c.reset}\n`);
738
+
739
+ if (!config.brandfetchApiKey) {
740
+ log(`${c.white}Get your free API key:${c.reset} https://brandfetch.com/developers`);
741
+ log(`${c.dim}Free tier includes: 50 requests/month, basic brand assets${c.reset}\n`);
742
+ config.brandfetchApiKey = await textInput("BRANDFETCH_API_KEY", {
743
+ default: process.env.BRANDFETCH_API_KEY || "",
744
+ mask: true,
745
+ });
746
+ }
747
+
748
+ config.brandDomain = await textInput("Company domain to fetch brand from (e.g., stripe.com)", {
749
+ default: "",
750
+ });
751
+
752
+ if (!config.brandDomain) {
753
+ log(`\n${c.dim}No domain provided - falling back to manual interview${c.reset}`);
754
+ config.brandSource = "manual";
755
+ }
756
+ }
757
+
758
+ if (config.brandSource === "manual") {
759
+ log(`\n${c.bold}━━━ Brand Interview ━━━${c.reset}`);
760
+ log(`${c.dim}Let's define your brand's visual identity${c.reset}\n`);
761
+
762
+ // Basic identity
763
+ config.brandName = await textInput("Brand/Project name", {
764
+ default: path.basename(targetDir),
765
+ });
766
+
767
+ // Color palette
768
+ log(`\n${c.bold}Color Palette${c.reset}`);
769
+ log(`${c.dim}Define colors that represent your brand${c.reset}`);
770
+ log(`${c.dim}Enter hex (#E11D48) or color name (red, blue, coral, navy, etc.)${c.reset}\n`);
771
+
772
+ let primaryInput = await textInput("Primary color (main CTAs, links)", {
773
+ default: "#E11D48",
774
+ hint: "hex or name",
775
+ });
776
+ config.primaryColor = parseColor(primaryInput, "#E11D48");
777
+ if (primaryInput && primaryInput !== config.primaryColor) {
778
+ log(` ${c.dim}→ Resolved to ${config.primaryColor}${c.reset}`);
779
+ }
780
+
781
+ let secondaryInput = await textInput("Secondary color (accents)", {
782
+ default: "#1E40AF",
783
+ hint: "hex or name",
784
+ });
785
+ config.secondaryColor = parseColor(secondaryInput, "#1E40AF");
786
+ if (secondaryInput && secondaryInput !== config.secondaryColor) {
787
+ log(` ${c.dim}→ Resolved to ${config.secondaryColor}${c.reset}`);
788
+ }
789
+
790
+ let accentInput = await textInput("Accent color (highlights, badges)", {
791
+ default: "#8B5CF6",
792
+ hint: "hex or name",
793
+ });
794
+ config.accentColor = parseColor(accentInput, "#8B5CF6");
795
+ if (accentInput && accentInput !== config.accentColor) {
796
+ log(` ${c.dim}→ Resolved to ${config.accentColor}${c.reset}`);
797
+ }
798
+
799
+ // Typography
800
+ log(`\n${c.bold}Typography${c.reset}`);
801
+ log(`${c.dim}Fonts define your brand's personality${c.reset}`);
802
+ log(`${c.dim}Select from presets or describe what you want (e.g., "elegant serif", "modern sans")${c.reset}\n`);
803
+
804
+ config.fontFamily = await selectOne("Primary body font", [
805
+ { label: "Inter - Clean, modern, highly readable", value: "Inter" },
806
+ { label: "Geist - GitHub/Vercel aesthetic", value: "Geist" },
807
+ { label: "Plus Jakarta Sans - Friendly, approachable", value: "Plus Jakarta Sans" },
808
+ { label: "DM Sans - Geometric, professional", value: "DM Sans" },
809
+ { label: "IBM Plex Sans - Technical, serious", value: "IBM Plex Sans" },
810
+ { label: "Other - Describe or enter font name", value: "custom" },
811
+ ]);
812
+
813
+ if (config.fontFamily === "custom") {
814
+ let fontInput = await textInput("Font name or description", {
815
+ default: "Inter",
816
+ hint: 'e.g., "Playfair" or "elegant serif"',
817
+ });
818
+ config.fontFamily = parseFont(fontInput, "Inter");
819
+ log(` ${c.dim}→ Using: ${config.fontFamily}${c.reset}`);
820
+ }
821
+
822
+ config.headingFont = await selectOne("Heading font", [
823
+ { label: "Same as body font", value: config.fontFamily },
824
+ { label: "Playfair Display - Elegant, sophisticated", value: "Playfair Display" },
825
+ { label: "Cal Sans - Bold, impactful", value: "Cal Sans" },
826
+ { label: "Clash Display - Modern, striking", value: "Clash Display" },
827
+ { label: "Other - Describe or enter font name", value: "custom" },
828
+ ]);
829
+
830
+ if (config.headingFont === "custom") {
831
+ let headingInput = await textInput("Heading font name or description", {
832
+ default: config.fontFamily,
833
+ hint: 'e.g., "sans-serif that pairs nicely"',
834
+ });
835
+ config.headingFont = parseFont(headingInput, config.fontFamily);
836
+ log(` ${c.dim}→ Using: ${config.headingFont}${c.reset}`);
837
+ }
838
+
839
+ // UI Style preferences
840
+ log(`\n${c.bold}UI Style Preferences${c.reset}`);
841
+ log(`${c.dim}Define the overall look and feel${c.reset}\n`);
842
+
843
+ config.buttonStyle = await selectOne("Button style", [
844
+ { label: "Sharp (0px) - Modern, minimal tech aesthetic", value: "sharp" },
845
+ { label: "Subtle (4px) - Professional, slightly softened", value: "subtle" },
846
+ { label: "Rounded (8px) - Friendly, approachable", value: "rounded" },
847
+ { label: "Pill (9999px) - Playful, fully rounded", value: "pill" },
848
+ ]);
849
+
850
+ // Map button style to border radius
851
+ const radiusMap = { sharp: "0", subtle: "4px", rounded: "8px", pill: "9999px" };
852
+ config.borderRadius = radiusMap[config.buttonStyle];
853
+
854
+ config.cardStyle = await selectOne("Card style", [
855
+ { label: "Flat - Minimal, no depth", value: "flat" },
856
+ { label: "Bordered - Subtle outline separation", value: "bordered" },
857
+ { label: "Elevated - Shadow for depth", value: "elevated" },
858
+ ]);
859
+
860
+ // Visual content style
861
+ log(`\n${c.bold}Visual Content${c.reset}`);
862
+ log(`${c.dim}Preferences for images and icons${c.reset}\n`);
863
+
864
+ config.imageStyle = await selectOne("Preferred image style", [
865
+ { label: "Photography - Real photos, authentic feel", value: "photography" },
866
+ { label: "Illustrations - Custom drawn, unique personality", value: "illustration" },
867
+ { label: "Abstract - Shapes, gradients, patterns", value: "abstract" },
868
+ { label: "Minimal - Clean, simple graphics", value: "minimal" },
869
+ ]);
870
+
871
+ config.iconStyle = await selectOne("Icon style", [
872
+ { label: "Outline - Light, modern (Lucide, Heroicons)", value: "outline" },
873
+ { label: "Solid - Bold, impactful (Phosphor filled)", value: "solid" },
874
+ { label: "Duotone - Two-tone, distinctive", value: "duotone" },
875
+ ]);
876
+
877
+ // Animation preferences
878
+ config.animationLevel = await selectOne("Animation level", [
879
+ { label: "None - Static UI, pure function", value: "none" },
880
+ { label: "Subtle - Micro-interactions, fade-ins", value: "subtle" },
881
+ { label: "Moderate - Page transitions, hovers", value: "moderate" },
882
+ { label: "Expressive - Bold animations, personality", value: "expressive" },
883
+ ]);
884
+
885
+ // Dark mode
886
+ config.darkMode = await confirm("Include dark mode support?", true);
887
+ }
888
+ }
889
+ }
890
+
891
+ log(`\n${c.red}━━━ Installing ━━━${c.reset}\n`);
892
+ }
893
+
894
+ const totalSteps = 10;
226
895
  let currentStep = 0;
227
896
 
228
897
  // ─────────────────────────────────────────────────────────────────────────
@@ -230,6 +899,7 @@ async function main() {
230
899
  // ─────────────────────────────────────────────────────────────────────────
231
900
 
232
901
  logStep(++currentStep, totalSteps, "Checking prerequisites");
902
+ log(` ${c.dim}Verifying Node.js 18+ and Python 3 are installed${c.reset}`);
233
903
 
234
904
  const node = checkNode();
235
905
  if (node.ok) {
@@ -252,25 +922,35 @@ async function main() {
252
922
  // ─────────────────────────────────────────────────────────────────────────
253
923
 
254
924
  logStep(++currentStep, totalSteps, "Installing slash commands");
925
+ log(` ${c.dim}Copying 29 commands including /hustle-api-create, /hustle-ui-create${c.reset}`);
255
926
 
256
927
  const commandsDir = path.join(claudeDir, "commands");
257
- const sourceCommandsDir = path.join(packageDir, ".claude", "commands");
928
+ const sourceCommandsDir = path.join(packageDir, "commands");
258
929
 
259
930
  if (!fs.existsSync(commandsDir)) {
260
931
  fs.mkdirSync(commandsDir, { recursive: true });
261
932
  }
262
933
 
263
- const commandsCopied = copyDir(sourceCommandsDir, commandsDir, {
934
+ const commandsResult = copyDir(sourceCommandsDir, commandsDir, {
264
935
  filter: (name) => name.endsWith(".md"),
265
936
  });
266
937
 
267
- logSuccess(`${commandsCopied} commands installed to .claude/commands/`);
938
+ if (commandsResult.copied > 0) {
939
+ logSuccess(`${commandsResult.copied} new commands installed to .claude/commands/`);
940
+ }
941
+ if (commandsResult.skipped > 0) {
942
+ logInfo(`${commandsResult.skipped} commands already exist (preserved)`);
943
+ }
944
+ if (commandsResult.total === 0) {
945
+ logWarn("No commands found in package");
946
+ }
268
947
 
269
948
  // ─────────────────────────────────────────────────────────────────────────
270
949
  // Step 3: Install Hooks
271
950
  // ─────────────────────────────────────────────────────────────────────────
272
951
 
273
952
  logStep(++currentStep, totalSteps, "Installing enforcement hooks");
953
+ log(` ${c.dim}Python hooks that enforce TDD workflow and prevent skipping phases${c.reset}`);
274
954
 
275
955
  const sourceHooksDir = path.join(packageDir, "hooks");
276
956
 
@@ -291,11 +971,15 @@ async function main() {
291
971
 
292
972
  // Copy Python hook files
293
973
  let hooksCopied = 0;
974
+ let hooksSkipped = 0;
975
+ let hooksTotal = 0;
294
976
  if (fs.existsSync(sourceHooksDir)) {
295
977
  const hookFiles = fs
296
978
  .readdirSync(sourceHooksDir)
297
979
  .filter((f) => f.endsWith(".py"));
298
980
 
981
+ hooksTotal = hookFiles.length;
982
+
299
983
  for (const file of hookFiles) {
300
984
  const src = path.join(sourceHooksDir, file);
301
985
  const dest = path.join(hooksDir, file);
@@ -304,11 +988,18 @@ async function main() {
304
988
  fs.copyFileSync(src, dest);
305
989
  fs.chmodSync(dest, "755");
306
990
  hooksCopied++;
991
+ } else {
992
+ hooksSkipped++;
307
993
  }
308
994
  }
309
995
  }
310
996
 
311
- logSuccess(`${hooksCopied} hooks installed to .claude/hooks/`);
997
+ if (hooksCopied > 0) {
998
+ logSuccess(`${hooksCopied} new hooks installed to .claude/hooks/`);
999
+ }
1000
+ if (hooksSkipped > 0) {
1001
+ logInfo(`${hooksSkipped} hooks already exist (preserved)`);
1002
+ }
312
1003
  logInfo("Includes: enforce-*, notify-*, track-token-usage.py");
313
1004
 
314
1005
  // ─────────────────────────────────────────────────────────────────────────
@@ -316,6 +1007,7 @@ async function main() {
316
1007
  // ─────────────────────────────────────────────────────────────────────────
317
1008
 
318
1009
  logStep(++currentStep, totalSteps, "Installing subagents");
1010
+ log(` ${c.dim}AI agents for parallel research, schema generation, code review${c.reset}`);
319
1011
 
320
1012
  const agentsDir = path.join(claudeDir, "agents");
321
1013
  const sourceAgentsDir = path.join(packageDir, ".claude", "agents");
@@ -324,11 +1016,16 @@ async function main() {
324
1016
  fs.mkdirSync(agentsDir, { recursive: true });
325
1017
  }
326
1018
 
327
- const agentsCopied = copyDir(sourceAgentsDir, agentsDir, {
1019
+ const agentsResult = copyDir(sourceAgentsDir, agentsDir, {
328
1020
  filter: (name) => name.endsWith(".md"),
329
1021
  });
330
1022
 
331
- logSuccess(`${agentsCopied} subagents installed to .claude/agents/`);
1023
+ if (agentsResult.copied > 0) {
1024
+ logSuccess(`${agentsResult.copied} new subagents installed to .claude/agents/`);
1025
+ }
1026
+ if (agentsResult.skipped > 0) {
1027
+ logInfo(`${agentsResult.skipped} subagents already exist (preserved)`);
1028
+ }
332
1029
  logInfo("Haiku: parallel-researcher, research-validator, docs-generator");
333
1030
  logInfo(
334
1031
  "Sonnet: schema-generator, test-writer, implementation-reviewer, code-reviewer",
@@ -339,6 +1036,7 @@ async function main() {
339
1036
  // ─────────────────────────────────────────────────────────────────────────
340
1037
 
341
1038
  logStep(++currentStep, totalSteps, "Setting up configuration");
1039
+ log(` ${c.dim}Creating settings.json, state tracking, and research cache${c.reset}`);
342
1040
 
343
1041
  const sourceTemplatesDir = path.join(packageDir, "templates");
344
1042
 
@@ -400,6 +1098,7 @@ async function main() {
400
1098
  // ─────────────────────────────────────────────────────────────────────────
401
1099
 
402
1100
  logStep(++currentStep, totalSteps, "Setting up environment");
1101
+ log(` ${c.dim}Creating .env.example template for API keys${c.reset}`);
403
1102
 
404
1103
  const templatesDestDir = path.join(targetDir, "templates");
405
1104
  if (!fs.existsSync(templatesDestDir)) {
@@ -415,19 +1114,54 @@ async function main() {
415
1114
  logInfo("Copy to .env and configure your API keys");
416
1115
  }
417
1116
 
1117
+ // Copy showcase template directories (for update-api-showcase.py and update-ui-showcase.py hooks)
1118
+ const showcaseDirs = ["api-showcase", "ui-showcase", "shared"];
1119
+ for (const dir of showcaseDirs) {
1120
+ const srcDir = path.join(sourceTemplatesDir, dir);
1121
+ const destDir = path.join(templatesDestDir, dir);
1122
+ if (fs.existsSync(srcDir)) {
1123
+ const result = copyDir(srcDir, destDir);
1124
+ if (result.copied > 0) {
1125
+ logSuccess(`Copied ${dir} templates (${result.copied} files)`);
1126
+ } else if (result.skipped > 0) {
1127
+ logInfo(`${dir} templates already exist (${result.skipped} files preserved)`);
1128
+ }
1129
+ }
1130
+ }
1131
+
418
1132
  // ─────────────────────────────────────────────────────────────────────────
419
1133
  // Step 7: Configure MCP Servers
420
1134
  // ─────────────────────────────────────────────────────────────────────────
421
1135
 
422
1136
  logStep(++currentStep, totalSteps, "Configuring MCP servers");
1137
+ log(` ${c.dim}Setting up AI-powered integrations for research and code review${c.reset}`);
423
1138
 
424
1139
  const mcpServers = [
425
- { name: "context7", cmd: "npx -y @upstash/context7-mcp" },
426
- { name: "github", cmd: "npx -y @modelcontextprotocol/server-github" },
1140
+ {
1141
+ name: "context7",
1142
+ cmd: "npx -y @upstash/context7-mcp",
1143
+ description: "Live documentation lookup (npm, APIs, frameworks)",
1144
+ required: true,
1145
+ },
1146
+ {
1147
+ name: "github",
1148
+ cmd: "npx -y @modelcontextprotocol/server-github",
1149
+ description: "GitHub integration (issues, PRs, code search)",
1150
+ required: true,
1151
+ },
427
1152
  {
428
1153
  name: "greptile",
429
1154
  cmd: "npx -y @anthropics/mcp-greptile",
1155
+ description: "AI code review for Phase 11 verification",
1156
+ optional: true,
1157
+ requiresKey: "GREPTILE_API_KEY",
1158
+ },
1159
+ {
1160
+ name: "brandfetch",
1161
+ cmd: "npx -y @anthropics/mcp-brandfetch",
1162
+ description: "Auto-fetch brand assets (logos, colors, fonts)",
430
1163
  optional: true,
1164
+ requiresKey: "BRANDFETCH_API_KEY",
431
1165
  },
432
1166
  ];
433
1167
 
@@ -438,6 +1172,7 @@ async function main() {
438
1172
  stdio: ["pipe", "pipe", "pipe"],
439
1173
  });
440
1174
  logInfo(`${server.name} already configured`);
1175
+ log(` ${c.dim}${server.description}${c.reset}`);
441
1176
  } catch (e) {
442
1177
  try {
443
1178
  execSync(`claude mcp add ${server.name} -- ${server.cmd}`, {
@@ -445,11 +1180,11 @@ async function main() {
445
1180
  stdio: ["pipe", "pipe", "pipe"],
446
1181
  });
447
1182
  logSuccess(`Added ${server.name}`);
1183
+ log(` ${c.dim}${server.description}${c.reset}`);
448
1184
  } catch (addErr) {
449
1185
  if (server.optional) {
450
- logInfo(
451
- `${server.name} (optional) - configure with GREPTILE_API_KEY`,
452
- );
1186
+ logInfo(`${server.name} (optional) - requires ${server.requiresKey}`);
1187
+ log(` ${c.dim}${server.description}${c.reset}`);
453
1188
  } else {
454
1189
  logWarn(`Could not add ${server.name} - add manually`);
455
1190
  }
@@ -457,99 +1192,578 @@ async function main() {
457
1192
  }
458
1193
  }
459
1194
 
460
- logInfo("Greptile requires GREPTILE_API_KEY + GITHUB_TOKEN for Phase 14");
1195
+ log(`\n ${c.bold}MCP Server Benefits:${c.reset}`);
1196
+ log(` ${c.dim}• Context7: Always get latest docs, no hallucinated APIs${c.reset}`);
1197
+ log(` ${c.dim}• GitHub: Create issues/PRs directly from Claude${c.reset}`);
1198
+ log(` ${c.dim}• Greptile: AI-powered code review catches bugs before merge${c.reset}`);
1199
+ log(` ${c.dim}• Brandfetch: Auto-generate brand guide from company domain${c.reset}`);
461
1200
 
462
1201
  // ─────────────────────────────────────────────────────────────────────────
463
- // Step 8: Optional Tools
1202
+ // Step 8: Create .env file with API keys (if provided)
464
1203
  // ─────────────────────────────────────────────────────────────────────────
465
1204
 
466
- logStep(++currentStep, totalSteps, "Optional tools");
1205
+ logStep(++currentStep, totalSteps, "Configuring environment");
1206
+ log(` ${c.dim}Writing API keys and notification settings to .env${c.reset}`);
467
1207
 
468
- if (withStorybook || withPlaywright || withSandpack) {
469
- if (withSandpack) {
470
- startSpinner("Installing Sandpack...");
471
- try {
472
- execSync("npm install @codesandbox/sandpack-react 2>&1", {
473
- cwd: targetDir,
474
- stdio: ["pipe", "pipe", "pipe"],
475
- });
476
- stopSpinner(true, "Sandpack installed");
477
- } catch (e) {
478
- stopSpinner(
479
- false,
480
- "Sandpack install failed - run: npm install @codesandbox/sandpack-react",
481
- );
482
- }
1208
+ const envPath = path.join(targetDir, ".env");
1209
+ let envContent = "";
1210
+
1211
+ if (fs.existsSync(envPath)) {
1212
+ envContent = fs.readFileSync(envPath, "utf8");
1213
+ }
1214
+
1215
+ let envUpdated = false;
1216
+
1217
+ if (config.githubToken && !envContent.includes("GITHUB_TOKEN=")) {
1218
+ envContent += `\nGITHUB_TOKEN=${config.githubToken}`;
1219
+ envUpdated = true;
1220
+ }
1221
+
1222
+ if (config.greptileApiKey && !envContent.includes("GREPTILE_API_KEY=")) {
1223
+ envContent += `\nGREPTILE_API_KEY=${config.greptileApiKey}`;
1224
+ envUpdated = true;
1225
+ }
1226
+
1227
+ if (config.brandfetchApiKey && !envContent.includes("BRANDFETCH_API_KEY=")) {
1228
+ envContent += `\nBRANDFETCH_API_KEY=${config.brandfetchApiKey}`;
1229
+ envUpdated = true;
1230
+ }
1231
+
1232
+ if (config.ntfyEnabled) {
1233
+ if (!envContent.includes("NTFY_TOPIC=")) {
1234
+ envContent += `\nNTFY_TOPIC=${config.ntfyTopic}`;
1235
+ envUpdated = true;
1236
+ }
1237
+ if (!envContent.includes("NTFY_SERVER=")) {
1238
+ envContent += `\nNTFY_SERVER=${config.ntfyServer}`;
1239
+ envUpdated = true;
1240
+ }
1241
+ }
1242
+
1243
+ if (envUpdated) {
1244
+ fs.writeFileSync(envPath, envContent.trim() + "\n");
1245
+ logSuccess("Updated .env with API keys");
1246
+ } else {
1247
+ logInfo("No API keys to add (configure in .env later)");
1248
+ }
1249
+
1250
+ // ─────────────────────────────────────────────────────────────────────────
1251
+ // Step 9: Create Brand Guide (if enabled)
1252
+ // ─────────────────────────────────────────────────────────────────────────
1253
+
1254
+ logStep(++currentStep, totalSteps, "Brand guide");
1255
+ log(` ${c.dim}Creating comprehensive design system documentation${c.reset}`);
1256
+
1257
+ const brandGuidePath = path.join(claudeDir, "BRAND_GUIDE.md");
1258
+
1259
+ if (config.createBrandGuide && !fs.existsSync(brandGuidePath)) {
1260
+ const darkModeSection = config.darkMode
1261
+ ? `
1262
+
1263
+ ## Dark Mode
1264
+
1265
+ ### Dark Theme Colors
1266
+ - **Background**: #0F172A
1267
+ - **Surface**: #1E293B
1268
+ - **Border**: #334155
1269
+ - **Text**: #F8FAFC
1270
+ - **Muted**: #94A3B8
1271
+
1272
+ ### Implementation
1273
+ \`\`\`css
1274
+ @media (prefers-color-scheme: dark) {
1275
+ :root {
1276
+ --bg: #0F172A;
1277
+ --surface: #1E293B;
1278
+ --border: #334155;
1279
+ --text: #F8FAFC;
1280
+ --muted: #94A3B8;
1281
+ }
1282
+ }
1283
+ \`\`\`
1284
+ `
1285
+ : "";
1286
+
1287
+ // Build brandfetch note if using domain
1288
+ const brandfetchNote = config.brandSource === "brandfetch" && config.brandDomain
1289
+ ? `\n> **Source**: Auto-fetched from ${config.brandDomain} via Brandfetch API\n> Run \`/hustle-brand-refresh\` to update from latest brand assets`
1290
+ : "";
1291
+
1292
+ const brandGuideContent = `# ${config.brandName} Brand Guide
1293
+
1294
+ > Auto-generated by HUSTLE API Dev Tools v3.12.7
1295
+ > This guide ensures consistent UI across all components and pages.${brandfetchNote}
1296
+
1297
+ ---
1298
+
1299
+ ## Brand Identity
1300
+
1301
+ ### Brand Name
1302
+ **${config.brandName}**
1303
+
1304
+ ### Brand Colors
1305
+ | Role | Color | Hex | Usage |
1306
+ |------|-------|-----|-------|
1307
+ | Primary | 🔴 | \`${config.primaryColor}\` | CTAs, links, focus states |
1308
+ | Secondary | 🔵 | \`${config.secondaryColor}\` | Secondary actions, accents |
1309
+ | Accent | 🟣 | \`${config.accentColor}\` | Highlights, badges, special elements |
1310
+ | Success | 🟢 | \`${config.successColor}\` | Success states, confirmations |
1311
+ | Warning | 🟡 | \`${config.warningColor}\` | Warnings, pending states |
1312
+ | Error | 🔴 | \`${config.errorColor}\` | Errors, destructive actions |
1313
+
1314
+ ---
1315
+
1316
+ ## Color Palette
1317
+
1318
+ ### Primary Colors
1319
+ \`\`\`css
1320
+ --primary: ${config.primaryColor};
1321
+ --primary-light: ${config.primaryColor}20; /* 20% opacity */
1322
+ --primary-dark: ${config.primaryColor};
1323
+ \`\`\`
1324
+
1325
+ ### Secondary Colors
1326
+ \`\`\`css
1327
+ --secondary: ${config.secondaryColor};
1328
+ --secondary-light: ${config.secondaryColor}20;
1329
+ --secondary-dark: ${config.secondaryColor};
1330
+ \`\`\`
1331
+
1332
+ ### Accent Colors
1333
+ \`\`\`css
1334
+ --accent: ${config.accentColor};
1335
+ --accent-light: ${config.accentColor}20;
1336
+ --accent-dark: ${config.accentColor};
1337
+ \`\`\`
1338
+
1339
+ ### Neutral Colors (Light Theme)
1340
+ \`\`\`css
1341
+ --background: #FFFFFF;
1342
+ --surface: #F9FAFB;
1343
+ --border: #E5E7EB;
1344
+ --text: #111827;
1345
+ --text-muted: #6B7280;
1346
+ --text-light: #9CA3AF;
1347
+ \`\`\`
1348
+
1349
+ ### Semantic Colors
1350
+ \`\`\`css
1351
+ --success: ${config.successColor};
1352
+ --success-light: ${config.successColor}20;
1353
+ --warning: ${config.warningColor};
1354
+ --warning-light: ${config.warningColor}20;
1355
+ --error: ${config.errorColor};
1356
+ --error-light: ${config.errorColor}20;
1357
+ --info: #3B82F6;
1358
+ --info-light: #DBEAFE;
1359
+ \`\`\`
1360
+ ${darkModeSection}
1361
+ ---
1362
+
1363
+ ## Typography
1364
+
1365
+ ### Font Families
1366
+ \`\`\`css
1367
+ --font-primary: "${config.fontFamily}", system-ui, -apple-system, sans-serif;
1368
+ --font-heading: "${config.headingFont}", system-ui, -apple-system, sans-serif;
1369
+ --font-mono: "${config.monoFont}", "Fira Code", monospace;
1370
+ \`\`\`
1371
+
1372
+ ### Font Scale
1373
+ | Name | Size | Line Height | Usage |
1374
+ |------|------|-------------|-------|
1375
+ | xs | 0.75rem (12px) | 1rem | Labels, captions |
1376
+ | sm | 0.875rem (14px) | 1.25rem | Secondary text |
1377
+ | base | 1rem (16px) | 1.5rem | Body text |
1378
+ | lg | 1.125rem (18px) | 1.75rem | Lead paragraphs |
1379
+ | xl | 1.25rem (20px) | 1.75rem | H4 |
1380
+ | 2xl | 1.5rem (24px) | 2rem | H3 |
1381
+ | 3xl | 1.875rem (30px) | 2.25rem | H2 |
1382
+ | 4xl | 2.25rem (36px) | 2.5rem | H1 |
1383
+
1384
+ ### Font Weights
1385
+ - **Regular**: 400 - Body text
1386
+ - **Medium**: 500 - Emphasis
1387
+ - **Semibold**: 600 - Headings
1388
+ - **Bold**: 700 - Strong emphasis
1389
+
1390
+ ---
1391
+
1392
+ ## Spacing
1393
+
1394
+ ### Spacing Scale (Tailwind-compatible)
1395
+ | Token | Value | Pixels | Usage |
1396
+ |-------|-------|--------|-------|
1397
+ | 1 | 0.25rem | 4px | Tight spacing |
1398
+ | 2 | 0.5rem | 8px | Default gap |
1399
+ | 3 | 0.75rem | 12px | - |
1400
+ | 4 | 1rem | 16px | Section padding |
1401
+ | 6 | 1.5rem | 24px | Card padding |
1402
+ | 8 | 2rem | 32px | Section gaps |
1403
+ | 12 | 3rem | 48px | Large sections |
1404
+ | 16 | 4rem | 64px | Page sections |
1405
+
1406
+ ---
1407
+
1408
+ ## Border Radius
1409
+
1410
+ ### Radius Scale
1411
+ \`\`\`css
1412
+ --radius-none: 0;
1413
+ --radius-sm: 2px;
1414
+ --radius-default: ${config.borderRadius};
1415
+ --radius-md: 6px;
1416
+ --radius-lg: 8px;
1417
+ --radius-xl: 12px;
1418
+ --radius-2xl: 16px;
1419
+ --radius-full: 9999px;
1420
+ \`\`\`
1421
+
1422
+ ### Usage Guidelines
1423
+ - **Buttons**: Use \`--radius-default\` (${config.borderRadius})
1424
+ - **Cards**: Use \`--radius-lg\` or \`--radius-xl\`
1425
+ - **Inputs**: Use \`--radius-default\`
1426
+ - **Avatars**: Use \`--radius-full\`
1427
+ - **Modals**: Use \`--radius-xl\`
1428
+
1429
+ ---
1430
+
1431
+ ## Shadows
1432
+
1433
+ ### Shadow Scale
1434
+ \`\`\`css
1435
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
1436
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
1437
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
1438
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
1439
+ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
1440
+ \`\`\`
1441
+
1442
+ ---
1443
+
1444
+ ## Component Patterns
1445
+
1446
+ ### Buttons
1447
+
1448
+ #### Primary Button
1449
+ \`\`\`jsx
1450
+ <button className="bg-primary text-white px-4 py-2 rounded-[${config.borderRadius}] font-medium
1451
+ hover:opacity-90 focus:ring-2 focus:ring-primary focus:ring-offset-2
1452
+ disabled:opacity-50 disabled:cursor-not-allowed">
1453
+ Primary Action
1454
+ </button>
1455
+ \`\`\`
1456
+
1457
+ #### Secondary Button
1458
+ \`\`\`jsx
1459
+ <button className="bg-surface border border-border text-text px-4 py-2 rounded-[${config.borderRadius}]
1460
+ hover:bg-gray-100 focus:ring-2 focus:ring-gray-200">
1461
+ Secondary
1462
+ </button>
1463
+ \`\`\`
1464
+
1465
+ #### Ghost Button
1466
+ \`\`\`jsx
1467
+ <button className="text-text px-4 py-2 rounded-[${config.borderRadius}]
1468
+ hover:bg-surface focus:ring-2 focus:ring-gray-200">
1469
+ Ghost
1470
+ </button>
1471
+ \`\`\`
1472
+
1473
+ #### Destructive Button
1474
+ \`\`\`jsx
1475
+ <button className="bg-error text-white px-4 py-2 rounded-[${config.borderRadius}]
1476
+ hover:bg-red-600 focus:ring-2 focus:ring-error">
1477
+ Delete
1478
+ </button>
1479
+ \`\`\`
1480
+
1481
+ ### Cards
1482
+
1483
+ \`\`\`jsx
1484
+ <div className="bg-white border border-border rounded-xl p-6 shadow-sm">
1485
+ <h3 className="text-lg font-semibold text-text">Card Title</h3>
1486
+ <p className="text-muted text-sm mt-2">Card description text.</p>
1487
+ </div>
1488
+ \`\`\`
1489
+
1490
+ ### Form Inputs
1491
+
1492
+ \`\`\`jsx
1493
+ <div>
1494
+ <label className="text-sm font-medium text-text mb-1 block">
1495
+ Label
1496
+ </label>
1497
+ <input
1498
+ type="text"
1499
+ className="w-full border border-border rounded-[${config.borderRadius}] px-3 py-2
1500
+ focus:ring-2 focus:ring-primary focus:border-primary
1501
+ placeholder:text-muted"
1502
+ placeholder="Enter value..."
1503
+ />
1504
+ <p className="text-error text-sm mt-1">Error message</p>
1505
+ </div>
1506
+ \`\`\`
1507
+
1508
+ ### Badges
1509
+
1510
+ \`\`\`jsx
1511
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
1512
+ bg-primary-light text-primary">
1513
+ Badge
1514
+ </span>
1515
+ \`\`\`
1516
+
1517
+ ---
1518
+
1519
+ ## Accessibility
1520
+
1521
+ ### Color Contrast
1522
+ - Text on background: minimum 4.5:1 ratio
1523
+ - Large text (18px+): minimum 3:1 ratio
1524
+ - Interactive elements: minimum 3:1 ratio
1525
+
1526
+ ### Focus States
1527
+ All interactive elements must have visible focus states:
1528
+ \`\`\`css
1529
+ :focus-visible {
1530
+ outline: 2px solid ${config.primaryColor};
1531
+ outline-offset: 2px;
1532
+ }
1533
+ \`\`\`
1534
+
1535
+ ### Touch Targets
1536
+ - Minimum size: 44x44 pixels
1537
+ - Minimum spacing: 8px between targets
1538
+
1539
+ ---
1540
+
1541
+ ## Icons
1542
+
1543
+ ### Icon Style: ${config.iconStyle.charAt(0).toUpperCase() + config.iconStyle.slice(1)}
1544
+ ${config.iconStyle === "outline" ? "Light, modern icons with stroke-only design. Best for clean, minimal interfaces." : ""}${config.iconStyle === "solid" ? "Bold, filled icons for high impact and strong visual hierarchy." : ""}${config.iconStyle === "duotone" ? "Two-tone icons with primary and secondary colors for distinctive branding." : ""}
1545
+
1546
+ ### Recommended Icon Libraries
1547
+ ${config.iconStyle === "outline" ? "- **Lucide React** (recommended) - Consistent, stroke-based\n- **Heroicons Outline** - Tailwind-compatible" : ""}${config.iconStyle === "solid" ? "- **Heroicons Solid** (recommended) - Bold, filled\n- **Phosphor Bold** - Flexible weights" : ""}${config.iconStyle === "duotone" ? "- **Phosphor Duotone** (recommended) - Two-tone design\n- **Font Awesome Duotone** - Wide selection" : ""}
1548
+
1549
+ ### Icon Sizes
1550
+ | Size | Pixels | Usage |
1551
+ |------|--------|-------|
1552
+ | xs | 12px | Inline with small text |
1553
+ | sm | 16px | Buttons, inline |
1554
+ | md | 20px | Default |
1555
+ | lg | 24px | Headers, standalone |
1556
+ | xl | 32px | Hero sections |
1557
+
1558
+ ---
1559
+
1560
+ ## Visual Style
1561
+
1562
+ ### Image Style: ${config.imageStyle.charAt(0).toUpperCase() + config.imageStyle.slice(1)}
1563
+ ${config.imageStyle === "photography" ? "Real photographs for authentic, relatable content. Use high-quality, diverse imagery." : ""}${config.imageStyle === "illustration" ? "Custom illustrations for unique brand personality. Maintain consistent style across all graphics." : ""}${config.imageStyle === "abstract" ? "Geometric shapes, gradients, and patterns. Modern, tech-forward aesthetic." : ""}${config.imageStyle === "minimal" ? "Simple, clean graphics with minimal detail. Focus on whitespace and clarity." : ""}
1564
+
1565
+ ### Card Style: ${config.cardStyle.charAt(0).toUpperCase() + config.cardStyle.slice(1)}
1566
+ \`\`\`jsx
1567
+ // ${config.cardStyle} card pattern
1568
+ <div className="${config.cardStyle === "flat" ? "bg-surface" : ""}${config.cardStyle === "bordered" ? "bg-white border border-border" : ""}${config.cardStyle === "elevated" ? "bg-white shadow-md" : ""} rounded-xl p-6">
1569
+ <h3 className="text-lg font-semibold">Card Title</h3>
1570
+ <p className="text-muted text-sm mt-2">Card content</p>
1571
+ </div>
1572
+ \`\`\`
1573
+
1574
+ ---
1575
+
1576
+ ## Animation
1577
+
1578
+ ### Animation Level: ${config.animationLevel.charAt(0).toUpperCase() + config.animationLevel.slice(1)}
1579
+ ${config.animationLevel === "none" ? "No animations. Static UI focused purely on function." : ""}${config.animationLevel === "subtle" ? "Micro-interactions only. Fade-ins, button states, minimal movement." : ""}${config.animationLevel === "moderate" ? "Page transitions, hover effects, loading states. Balanced motion." : ""}${config.animationLevel === "expressive" ? "Bold animations, personality-driven motion, delightful interactions." : ""}
1580
+
1581
+ ### Timing
1582
+ \`\`\`css
1583
+ --duration-fast: 150ms;
1584
+ --duration-normal: 200ms;
1585
+ --duration-slow: 300ms;
1586
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
1587
+ \`\`\`
1588
+
1589
+ ${config.animationLevel !== "none" ? `### Common Animations
1590
+ \`\`\`css
1591
+ .fade-in { animation: fadeIn var(--duration-normal) var(--easing); }
1592
+ .slide-up { animation: slideUp var(--duration-normal) var(--easing); }
1593
+ .scale-in { animation: scaleIn var(--duration-fast) var(--easing); }
1594
+ \`\`\`` : "### No Animations\nAll animations are disabled. Use CSS \\`transition: none\\` globally."}
1595
+
1596
+ ---
1597
+
1598
+ ## Tailwind Config
1599
+
1600
+ \`\`\`js
1601
+ // tailwind.config.js
1602
+ module.exports = {
1603
+ theme: {
1604
+ extend: {
1605
+ colors: {
1606
+ primary: '${config.primaryColor}',
1607
+ secondary: '${config.secondaryColor}',
1608
+ accent: '${config.accentColor}',
1609
+ success: '${config.successColor}',
1610
+ warning: '${config.warningColor}',
1611
+ error: '${config.errorColor}',
1612
+ },
1613
+ fontFamily: {
1614
+ sans: ['${config.fontFamily}', 'system-ui', 'sans-serif'],
1615
+ heading: ['${config.headingFont}', 'system-ui', 'sans-serif'],
1616
+ mono: ['${config.monoFont}', 'Fira Code', 'monospace'],
1617
+ },
1618
+ borderRadius: {
1619
+ DEFAULT: '${config.borderRadius}',
1620
+ },
1621
+ },
1622
+ },
1623
+ }
1624
+ \`\`\`
1625
+
1626
+ ---
1627
+
1628
+ *Last updated: ${new Date().toISOString().split("T")[0]}*
1629
+ `;
1630
+ fs.writeFileSync(brandGuidePath, brandGuideContent);
1631
+ logSuccess("Created .claude/BRAND_GUIDE.md");
1632
+ } else if (fs.existsSync(brandGuidePath)) {
1633
+ logInfo("Brand guide already exists");
1634
+ } else {
1635
+ logInfo("Skipped brand guide creation");
1636
+ }
1637
+
1638
+ // ─────────────────────────────────────────────────────────────────────────
1639
+ // Step 10: Optional Tools
1640
+ // ─────────────────────────────────────────────────────────────────────────
1641
+
1642
+ logStep(++currentStep, totalSteps, "Testing tools");
1643
+
1644
+ // Check prerequisites
1645
+ const hasPackageJson = fs.existsSync(path.join(targetDir, "package.json"));
1646
+ const hasPnpm = (() => {
1647
+ try {
1648
+ execSync("pnpm --version", { stdio: ["pipe", "pipe", "pipe"] });
1649
+ return true;
1650
+ } catch {
1651
+ return false;
483
1652
  }
1653
+ })();
1654
+
1655
+ if (!hasPackageJson) {
1656
+ log(` ${c.dim}No package.json found - skipping npm package installations${c.reset}`);
1657
+ logInfo("Run 'pnpm init' first, then re-run installer to add testing tools");
1658
+ } else if (!hasPnpm) {
1659
+ log(` ${c.dim}pnpm not found - skipping installations${c.reset}`);
1660
+ logInfo("Install pnpm: npm install -g pnpm");
1661
+ } else {
1662
+ log(` ${c.dim}Installing Playwright, Storybook, and Sandpack${c.reset}`);
1663
+
1664
+ if (config.withStorybook || config.withPlaywright || config.withSandpack) {
1665
+ if (config.withSandpack) {
1666
+ startSpinner("Installing Sandpack (live code preview)...");
1667
+ try {
1668
+ execSync("pnpm add @codesandbox/sandpack-react 2>&1", {
1669
+ cwd: targetDir,
1670
+ stdio: ["pipe", "pipe", "pipe"],
1671
+ });
1672
+ stopSpinner(true, "Sandpack installed - enables live UI previews");
1673
+ } catch (e) {
1674
+ stopSpinner(false, "Sandpack failed");
1675
+ logInfo(" Run manually: pnpm add @codesandbox/sandpack-react");
1676
+ }
1677
+ }
484
1678
 
485
- if (withStorybook) {
486
- startSpinner("Initializing Storybook (this takes a moment)...");
487
- try {
488
- execSync("npx storybook@latest init --yes 2>&1", {
489
- cwd: targetDir,
490
- stdio: ["pipe", "pipe", "pipe"],
491
- timeout: 300000,
492
- });
493
- stopSpinner(true, "Storybook initialized");
494
- } catch (e) {
495
- stopSpinner(
496
- false,
497
- "Storybook init failed - run: npx storybook@latest init",
498
- );
1679
+ if (config.withStorybook) {
1680
+ // Check if Storybook is already initialized
1681
+ const hasStorybook = fs.existsSync(path.join(targetDir, ".storybook"));
1682
+ if (hasStorybook) {
1683
+ logInfo("Storybook already configured");
1684
+ } else {
1685
+ startSpinner("Initializing Storybook (component development)...");
1686
+ try {
1687
+ execSync("pnpm dlx storybook@latest init --yes 2>&1", {
1688
+ cwd: targetDir,
1689
+ stdio: ["pipe", "pipe", "pipe"],
1690
+ timeout: 300000,
1691
+ });
1692
+ stopSpinner(true, "Storybook initialized - run 'pnpm storybook' to start");
1693
+ } catch (e) {
1694
+ stopSpinner(false, "Storybook failed");
1695
+ logInfo(" Run manually: pnpm dlx storybook@latest init");
1696
+ logInfo(" Requires: React/Vue/Angular/Svelte project");
1697
+ }
1698
+ }
499
1699
  }
500
- }
501
1700
 
502
- if (withPlaywright) {
503
- startSpinner("Initializing Playwright (this takes a moment)...");
504
- try {
505
- execSync("npm init playwright@latest -- --yes 2>&1", {
506
- cwd: targetDir,
507
- stdio: ["pipe", "pipe", "pipe"],
508
- timeout: 300000,
509
- });
510
- stopSpinner(true, "Playwright initialized");
511
- } catch (e) {
512
- stopSpinner(
513
- false,
514
- "Playwright init failed - run: npm init playwright@latest",
515
- );
1701
+ if (config.withPlaywright) {
1702
+ // Check if Playwright is already initialized
1703
+ const hasPlaywright = fs.existsSync(path.join(targetDir, "playwright.config.ts")) ||
1704
+ fs.existsSync(path.join(targetDir, "playwright.config.js"));
1705
+ if (hasPlaywright) {
1706
+ logInfo("Playwright already configured");
1707
+ } else {
1708
+ startSpinner("Initializing Playwright (E2E testing)...");
1709
+ try {
1710
+ execSync("pnpm create playwright --yes 2>&1", {
1711
+ cwd: targetDir,
1712
+ stdio: ["pipe", "pipe", "pipe"],
1713
+ timeout: 300000,
1714
+ });
1715
+ stopSpinner(true, "Playwright initialized - run 'pnpm exec playwright test' to start");
1716
+ } catch (e) {
1717
+ stopSpinner(false, "Playwright failed");
1718
+ logInfo(" Run manually: pnpm create playwright");
1719
+ }
1720
+ }
516
1721
  }
1722
+ } else {
1723
+ logInfo("None selected");
1724
+ logInfo("Run wizard again with Custom Setup to add testing tools");
517
1725
  }
518
- } else {
519
- logInfo("None selected");
520
- logInfo(
521
- "Add later with: --with-storybook --with-playwright --with-sandpack",
522
- );
523
1726
  }
524
1727
 
525
1728
  // ─────────────────────────────────────────────────────────────────────────
526
1729
  // Summary
527
1730
  // ─────────────────────────────────────────────────────────────────────────
528
1731
 
1732
+ const check = `${c.white}✓${c.reset}`;
1733
+ const cross = `${c.dim}○${c.reset}`;
1734
+
529
1735
  log(`
530
1736
  ${c.red}═══════════════════════════════════════════════════════════════${c.reset}
1737
+ ${c.red}${c.bold} HUSTLE${c.reset}
531
1738
  ${c.bold} Installation Complete${c.reset}
532
1739
  ${c.red}═══════════════════════════════════════════════════════════════${c.reset}
533
1740
 
534
- ${c.bold}Installed:${c.reset}
535
- Commands .claude/commands/ (slash commands)
536
- Hooks .claude/hooks/ (enforcement)
537
- Subagents .claude/agents/ (parallel processing)
538
- Config .claude/ (settings, state, registry)
539
- ● Templates templates/ (.env.example)
540
-
541
- ${c.bold}Quick Start:${c.reset}
542
- ${c.gray}$${c.reset} /api-create my-endpoint ${c.dim}# Build API endpoint${c.reset}
543
- ${c.gray}$${c.reset} /hustle-ui-create Button ${c.dim}# Build component${c.reset}
544
- ${c.gray}$${c.reset} /hustle-ui-create-page Home ${c.dim}# Build page${c.reset}
545
- ${c.gray}$${c.reset} /hustle-combine api ${c.dim}# Orchestrate APIs${c.reset}
1741
+ ${c.bold}Core Components:${c.reset}
1742
+ ${check} Commands .claude/commands/ (29 slash commands)
1743
+ ${check} Hooks .claude/hooks/ (enforcement hooks)
1744
+ ${check} Subagents .claude/agents/ (parallel processing)
1745
+ ${check} Config .claude/ (settings, state, registry)
1746
+
1747
+ ${c.bold}Configuration:${c.reset}
1748
+ ${config.githubToken ? check : cross} GITHUB_TOKEN ${config.githubToken ? "configured" : "not set"}
1749
+ ${config.greptileApiKey ? check : cross} GREPTILE_API_KEY ${config.greptileApiKey ? "configured" : "not set"}
1750
+ ${config.brandfetchApiKey ? check : cross} BRANDFETCH_API_KEY ${config.brandfetchApiKey ? "configured" : "not set"}
1751
+ ${config.ntfyEnabled ? check : cross} NTFY Notifications ${config.ntfyEnabled ? config.ntfyTopic : "disabled"}
1752
+ ${config.createBrandGuide ? check : cross} Brand Guide ${config.createBrandGuide ? (config.brandSource === "brandfetch" ? `from ${config.brandDomain}` : "manual") : "skipped"}
1753
+
1754
+ ${c.bold}Testing Tools:${c.reset}
1755
+ ${config.withPlaywright ? check : cross} Playwright ${config.withPlaywright ? "installed" : "not installed"}
1756
+ ${config.withStorybook ? check : cross} Storybook ${config.withStorybook ? "installed" : "not installed"}
1757
+ ${config.withSandpack ? check : cross} Sandpack ${config.withSandpack ? "installed" : "not installed"}
1758
+
1759
+ ${c.bold}Ready to Use:${c.reset}
1760
+ ${c.gray}$${c.reset} /hustle-api-create [endpoint] ${c.dim}# Build API endpoint${c.reset}
1761
+ ${c.gray}$${c.reset} /hustle-ui-create [component] ${c.dim}# Build component${c.reset}
1762
+ ${c.gray}$${c.reset} /hustle-ui-create-page [page] ${c.dim}# Build page${c.reset}
1763
+ ${c.gray}$${c.reset} /hustle-combine [apis] ${c.dim}# Orchestrate APIs${c.reset}
546
1764
 
547
1765
  ${c.bold}Next Steps:${c.reset}
548
- 1. ${c.white}cp templates/.env.example .env${c.reset}
549
- 2. ${c.white}Configure your API keys in .env${c.reset}
550
- 3. ${c.white}Add GREPTILE_API_KEY + GITHUB_TOKEN${c.reset} for Phase 14 code review
551
- 4. ${c.white}/ntfy-setup${c.reset} for push notifications (optional)
552
- 5. ${c.white}Restart Claude Code${c.reset} for MCP tools
1766
+ ${!config.githubToken ? `${c.white}→ Add GITHUB_TOKEN to .env${c.reset} (https://github.com/settings/tokens)\n ` : ""}${!config.greptileApiKey ? `${c.white} Add GREPTILE_API_KEY to .env${c.reset} (https://app.greptile.com/settings/api)\n ` : ""}${!config.brandfetchApiKey && config.brandSource === "brandfetch" ? `${c.white}→ Add BRANDFETCH_API_KEY to .env${c.reset} (https://brandfetch.com/developers)\n ` : ""}${c.white}→ Restart Claude Code${c.reset} to load MCP servers
553
1767
 
554
1768
  ${c.dim}Documentation: https://github.com/hustle-together/api-dev-tools${c.reset}
555
1769
  `);