@hustle-together/api-dev-tools 3.12.3 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.claude/adr-requests/.gitkeep +10 -0
  2. package/.claude/agents/adr-researcher.md +109 -0
  3. package/.claude/agents/visual-analyzer.md +183 -0
  4. package/.claude/api-dev-state.json +7 -463
  5. package/.claude/documentation-audit.json +114 -0
  6. package/.claude/registry.json +289 -0
  7. package/.claude/settings.json +45 -1
  8. package/.claude/workflow-logs/None.json +49 -0
  9. package/.claude/workflow-logs/session-20251230-143727.json +106 -0
  10. package/.skills/adr-deep-research/SKILL.md +351 -0
  11. package/.skills/api-create/SKILL.md +116 -17
  12. package/.skills/api-research/SKILL.md +130 -0
  13. package/.skills/docs-sync/SKILL.md +260 -0
  14. package/.skills/docs-update/SKILL.md +205 -0
  15. package/.skills/hustle-brand/SKILL.md +368 -0
  16. package/.skills/hustle-build/SKILL.md +786 -0
  17. package/.skills/hustle-build-review/SKILL.md +518 -0
  18. package/.skills/parallel-spawn/SKILL.md +212 -0
  19. package/.skills/ralph-continue/SKILL.md +151 -0
  20. package/.skills/ralph-loop/SKILL.md +341 -0
  21. package/.skills/ralph-status/SKILL.md +87 -0
  22. package/.skills/refactor/SKILL.md +59 -0
  23. package/.skills/shadcn/SKILL.md +522 -0
  24. package/.skills/test-all/SKILL.md +210 -0
  25. package/.skills/test-builds/SKILL.md +208 -0
  26. package/.skills/test-debug/SKILL.md +212 -0
  27. package/.skills/test-e2e/SKILL.md +168 -0
  28. package/.skills/test-review/SKILL.md +707 -0
  29. package/.skills/test-unit/SKILL.md +143 -0
  30. package/.skills/test-visual/SKILL.md +301 -0
  31. package/.skills/token-report/SKILL.md +132 -0
  32. package/CHANGELOG.md +575 -0
  33. package/README.md +426 -56
  34. package/bin/cli.js +1538 -88
  35. package/commands/hustle-api-create.md +22 -0
  36. package/commands/hustle-build.md +259 -0
  37. package/commands/hustle-combine.md +81 -2
  38. package/commands/hustle-ui-create-page.md +84 -2
  39. package/commands/hustle-ui-create.md +82 -2
  40. package/hooks/__pycache__/api-workflow-check.cpython-314.pyc +0 -0
  41. package/hooks/__pycache__/auto-answer.cpython-314.pyc +0 -0
  42. package/hooks/__pycache__/cache-research.cpython-314.pyc +0 -0
  43. package/hooks/__pycache__/check-api-routes.cpython-314.pyc +0 -0
  44. package/hooks/__pycache__/check-playwright-setup.cpython-314.pyc +0 -0
  45. package/hooks/__pycache__/check-storybook-setup.cpython-314.pyc +0 -0
  46. package/hooks/__pycache__/check-update.cpython-314.pyc +0 -0
  47. package/hooks/__pycache__/completion-promise-detector.cpython-314.pyc +0 -0
  48. package/hooks/__pycache__/context-capacity-warning.cpython-314.pyc +0 -0
  49. package/hooks/__pycache__/detect-interruption.cpython-314.pyc +0 -0
  50. package/hooks/__pycache__/docs-update-check.cpython-314.pyc +0 -0
  51. package/hooks/__pycache__/enforce-a11y-audit.cpython-314.pyc +0 -0
  52. package/hooks/__pycache__/enforce-brand-guide.cpython-314.pyc +0 -0
  53. package/hooks/__pycache__/enforce-component-type-confirm.cpython-314.pyc +0 -0
  54. package/hooks/__pycache__/enforce-deep-research.cpython-314.pyc +0 -0
  55. package/hooks/__pycache__/enforce-disambiguation.cpython-314.pyc +0 -0
  56. package/hooks/__pycache__/enforce-documentation.cpython-314.pyc +0 -0
  57. package/hooks/__pycache__/enforce-dry-run.cpython-314.pyc +0 -0
  58. package/hooks/__pycache__/enforce-environment.cpython-314.pyc +0 -0
  59. package/hooks/__pycache__/enforce-external-research.cpython-314.pyc +0 -0
  60. package/hooks/__pycache__/enforce-freshness.cpython-314.pyc +0 -0
  61. package/hooks/__pycache__/enforce-interview.cpython-314.pyc +0 -0
  62. package/hooks/__pycache__/enforce-page-components.cpython-314.pyc +0 -0
  63. package/hooks/__pycache__/enforce-page-data-schema.cpython-314.pyc +0 -0
  64. package/hooks/__pycache__/enforce-questions-sourced.cpython-314.pyc +0 -0
  65. package/hooks/__pycache__/enforce-refactor.cpython-314.pyc +0 -0
  66. package/hooks/__pycache__/enforce-research.cpython-314.pyc +0 -0
  67. package/hooks/__pycache__/enforce-schema-from-interview.cpython-314.pyc +0 -0
  68. package/hooks/__pycache__/enforce-schema.cpython-314.pyc +0 -0
  69. package/hooks/__pycache__/enforce-scope.cpython-314.pyc +0 -0
  70. package/hooks/__pycache__/enforce-tdd-red.cpython-314.pyc +0 -0
  71. package/hooks/__pycache__/enforce-ui-disambiguation.cpython-314.pyc +0 -0
  72. package/hooks/__pycache__/enforce-ui-interview.cpython-314.pyc +0 -0
  73. package/hooks/__pycache__/enforce-verify.cpython-314.pyc +0 -0
  74. package/hooks/__pycache__/generate-adr-options.cpython-314.pyc +0 -0
  75. package/hooks/__pycache__/generate-manifest-entry.cpython-314.pyc +0 -0
  76. package/hooks/__pycache__/hook_utils.cpython-314.pyc +0 -0
  77. package/hooks/__pycache__/notify-input-needed.cpython-314.pyc +0 -0
  78. package/hooks/__pycache__/notify-phase-complete.cpython-314.pyc +0 -0
  79. package/hooks/__pycache__/ntfy-on-question.cpython-314.pyc +0 -0
  80. package/hooks/__pycache__/orchestrator-completion.cpython-314.pyc +0 -0
  81. package/hooks/__pycache__/orchestrator-handoff.cpython-314.pyc +0 -0
  82. package/hooks/__pycache__/orchestrator-session-startup.cpython-314.pyc +0 -0
  83. package/hooks/__pycache__/parallel-orchestrator.cpython-314.pyc +0 -0
  84. package/hooks/__pycache__/periodic-reground.cpython-314.pyc +0 -0
  85. package/hooks/__pycache__/project-document-prompt.cpython-314.pyc +0 -0
  86. package/hooks/__pycache__/remote-question-proxy.cpython-314.pyc +0 -0
  87. package/hooks/__pycache__/remote-question-server.cpython-314.pyc +0 -0
  88. package/hooks/__pycache__/run-code-review.cpython-314.pyc +0 -0
  89. package/hooks/__pycache__/run-visual-qa.cpython-314.pyc +0 -0
  90. package/hooks/__pycache__/session-logger.cpython-314.pyc +0 -0
  91. package/hooks/__pycache__/session-startup.cpython-314.pyc +0 -0
  92. package/hooks/__pycache__/track-scope-coverage.cpython-314.pyc +0 -0
  93. package/hooks/__pycache__/track-token-usage.cpython-314.pyc +0 -0
  94. package/hooks/__pycache__/track-tool-use.cpython-314.pyc +0 -0
  95. package/hooks/__pycache__/update-adr-decision.cpython-314.pyc +0 -0
  96. package/hooks/__pycache__/update-api-showcase.cpython-314.pyc +0 -0
  97. package/hooks/__pycache__/update-registry.cpython-314.pyc +0 -0
  98. package/hooks/__pycache__/update-ui-showcase.cpython-314.pyc +0 -0
  99. package/hooks/__pycache__/verify-after-green.cpython-314.pyc +0 -0
  100. package/hooks/__pycache__/verify-implementation.cpython-314.pyc +0 -0
  101. package/hooks/api-workflow-check.py +34 -0
  102. package/hooks/auto-answer.py +305 -0
  103. package/hooks/check-update.py +132 -0
  104. package/hooks/completion-promise-detector.py +293 -0
  105. package/hooks/context-capacity-warning.py +171 -0
  106. package/hooks/docs-update-check.py +120 -0
  107. package/hooks/enforce-dry-run.py +134 -0
  108. package/hooks/enforce-external-research.py +25 -0
  109. package/hooks/enforce-interview.py +20 -0
  110. package/hooks/generate-adr-options.py +282 -0
  111. package/hooks/hook_utils.py +609 -0
  112. package/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
  113. package/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
  114. package/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
  115. package/hooks/ntfy-on-question.py +240 -0
  116. package/hooks/orchestrator-completion.py +313 -0
  117. package/hooks/orchestrator-handoff.py +267 -0
  118. package/hooks/orchestrator-session-startup.py +146 -0
  119. package/hooks/parallel-orchestrator.py +451 -0
  120. package/hooks/periodic-reground.py +270 -67
  121. package/hooks/project-document-prompt.py +302 -0
  122. package/hooks/remote-question-proxy.py +284 -0
  123. package/hooks/remote-question-server.py +1224 -0
  124. package/hooks/run-code-review.py +176 -29
  125. package/hooks/run-visual-qa.py +338 -0
  126. package/hooks/session-logger.py +27 -1
  127. package/hooks/session-startup.py +113 -0
  128. package/hooks/update-adr-decision.py +236 -0
  129. package/hooks/update-api-showcase.py +13 -1
  130. package/hooks/update-testing-checklist.py +195 -0
  131. package/hooks/update-ui-showcase.py +13 -1
  132. package/package.json +7 -3
  133. package/scripts/extract-schema-docs.cjs +322 -0
  134. package/templates/.skills/hustle-interview/SKILL.md +174 -0
  135. package/templates/CLAUDE-SECTION.md +89 -64
  136. package/templates/adr-viewer/_components/ADRViewer.tsx +326 -0
  137. package/templates/api-dev-state.json +33 -1
  138. package/templates/api-showcase/_components/APIModal.tsx +100 -8
  139. package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
  140. package/templates/api-showcase/_components/APITester.tsx +367 -58
  141. package/templates/brand-page/page.tsx +645 -0
  142. package/templates/component/Component.visual.spec.ts +30 -24
  143. package/templates/docs/page.tsx +230 -0
  144. package/templates/eslint-plugin-zod-schema/index.js +446 -0
  145. package/templates/eslint-plugin-zod-schema/package.json +26 -0
  146. package/templates/github-workflows/security.yml +274 -0
  147. package/templates/hustle-build-defaults.json +136 -0
  148. package/templates/hustle-dev-dashboard/page.tsx +365 -0
  149. package/templates/page/page.e2e.test.ts +30 -26
  150. package/templates/performance-budgets.json +63 -5
  151. package/templates/playwright-report/page.tsx +258 -0
  152. package/templates/registry.json +279 -3
  153. package/templates/review-dashboard/page.tsx +510 -0
  154. package/templates/settings.json +155 -7
  155. package/templates/test-results/page.tsx +237 -0
  156. package/templates/typedoc.json +19 -0
  157. package/templates/ui-showcase/_components/UIShowcase.tsx +48 -1
  158. package/templates/ui-showcase/_components/VisualTestingDashboard.tsx +579 -0
  159. package/templates/ui-showcase/page.tsx +1 -1
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,513 @@ 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
+
467
+ // ═══════════════════════════════════════════════════════════════════════════
468
+ // Brandfetch API Integration
469
+ // ═══════════════════════════════════════════════════════════════════════════
470
+
471
+ /**
472
+ * Fetch brand data from Brandfetch API
473
+ * @param {string} domain - Company domain (e.g., "stripe.com")
474
+ * @param {string} apiKey - Brandfetch API key
475
+ * @returns {Promise<object|null>} Brand data or null on error
476
+ */
477
+ async function fetchBrandData(domain, apiKey) {
478
+ const https = require("https");
479
+
480
+ return new Promise((resolve) => {
481
+ const options = {
482
+ hostname: "api.brandfetch.io",
483
+ path: `/v2/brands/${encodeURIComponent(domain)}`,
484
+ method: "GET",
485
+ headers: {
486
+ "Authorization": `Bearer ${apiKey}`,
487
+ "Content-Type": "application/json",
488
+ },
489
+ };
490
+
491
+ const req = https.request(options, (res) => {
492
+ let data = "";
493
+
494
+ res.on("data", (chunk) => {
495
+ data += chunk;
496
+ });
497
+
498
+ res.on("end", () => {
499
+ if (res.statusCode === 200) {
500
+ try {
501
+ const brand = JSON.parse(data);
502
+ resolve(brand);
503
+ } catch (e) {
504
+ resolve(null);
505
+ }
506
+ } else {
507
+ resolve(null);
508
+ }
509
+ });
510
+ });
511
+
512
+ req.on("error", () => {
513
+ resolve(null);
514
+ });
515
+
516
+ req.setTimeout(10000, () => {
517
+ req.destroy();
518
+ resolve(null);
519
+ });
520
+
521
+ req.end();
522
+ });
523
+ }
524
+
525
+ /**
526
+ * Parse Brandfetch API response into config-compatible format
527
+ * @param {object} brandData - Raw Brandfetch API response
528
+ * @returns {object} Parsed brand config
529
+ */
530
+ function parseBrandData(brandData) {
531
+ const result = {
532
+ brandName: brandData.name || brandData.domain || "Brand",
533
+ description: brandData.description || "",
534
+ primaryColor: "#E11D48",
535
+ secondaryColor: "#1E40AF",
536
+ accentColor: "#8B5CF6",
537
+ fontFamily: "Inter",
538
+ headingFont: "Inter",
539
+ logoUrl: null,
540
+ logoSvg: null,
541
+ iconUrl: null,
542
+ };
543
+
544
+ // Extract colors by type
545
+ if (brandData.colors && brandData.colors.length > 0) {
546
+ // Sort by type priority: brand > accent > dark > light
547
+ const brandColor = brandData.colors.find(c => c.type === "brand");
548
+ const accentColor = brandData.colors.find(c => c.type === "accent");
549
+ const darkColor = brandData.colors.find(c => c.type === "dark");
550
+ const lightColor = brandData.colors.find(c => c.type === "light");
551
+
552
+ // Primary = brand color or first color
553
+ if (brandColor) {
554
+ result.primaryColor = brandColor.hex.toUpperCase();
555
+ } else if (brandData.colors[0]) {
556
+ result.primaryColor = brandData.colors[0].hex.toUpperCase();
557
+ }
558
+
559
+ // Secondary = dark color or second color
560
+ if (darkColor) {
561
+ result.secondaryColor = darkColor.hex.toUpperCase();
562
+ } else if (brandData.colors[1]) {
563
+ result.secondaryColor = brandData.colors[1].hex.toUpperCase();
564
+ }
565
+
566
+ // Accent = accent color or third color
567
+ if (accentColor) {
568
+ result.accentColor = accentColor.hex.toUpperCase();
569
+ } else if (lightColor) {
570
+ result.accentColor = lightColor.hex.toUpperCase();
571
+ } else if (brandData.colors[2]) {
572
+ result.accentColor = brandData.colors[2].hex.toUpperCase();
573
+ }
574
+ }
575
+
576
+ // Extract fonts
577
+ if (brandData.fonts && brandData.fonts.length > 0) {
578
+ const titleFont = brandData.fonts.find(f => f.type === "title");
579
+ const bodyFont = brandData.fonts.find(f => f.type === "body");
580
+
581
+ if (bodyFont && bodyFont.name) {
582
+ result.fontFamily = bodyFont.name;
583
+ } else if (brandData.fonts[0] && brandData.fonts[0].name) {
584
+ result.fontFamily = brandData.fonts[0].name;
585
+ }
586
+
587
+ if (titleFont && titleFont.name) {
588
+ result.headingFont = titleFont.name;
589
+ } else {
590
+ result.headingFont = result.fontFamily;
591
+ }
592
+ }
593
+
594
+ // Extract logos - prefer SVG, then PNG
595
+ if (brandData.logos && brandData.logos.length > 0) {
596
+ // Find primary logo (type: "logo")
597
+ const primaryLogo = brandData.logos.find(l => l.type === "logo") || brandData.logos[0];
598
+ const icon = brandData.logos.find(l => l.type === "icon" || l.type === "symbol");
599
+
600
+ if (primaryLogo && primaryLogo.formats) {
601
+ // Prefer SVG
602
+ const svg = primaryLogo.formats.find(f => f.format === "svg");
603
+ const png = primaryLogo.formats.find(f => f.format === "png");
604
+
605
+ if (svg) {
606
+ result.logoSvg = svg.src;
607
+ result.logoUrl = svg.src;
608
+ } else if (png) {
609
+ result.logoUrl = png.src;
610
+ }
611
+ }
612
+
613
+ if (icon && icon.formats) {
614
+ const iconSvg = icon.formats.find(f => f.format === "svg");
615
+ const iconPng = icon.formats.find(f => f.format === "png");
616
+ result.iconUrl = iconSvg?.src || iconPng?.src || null;
617
+ }
618
+ }
619
+
620
+ return result;
621
+ }
622
+
115
623
  // ═══════════════════════════════════════════════════════════════════════════
116
624
  // File Copy Utilities
117
625
  // ═══════════════════════════════════════════════════════════════════════════
@@ -119,8 +627,10 @@ function logError(message) {
119
627
  function copyDir(src, dest, options = {}) {
120
628
  const { filter = () => true, overwrite = false } = options;
121
629
  let copied = 0;
630
+ let skipped = 0;
631
+ let total = 0;
122
632
 
123
- if (!fs.existsSync(src)) return copied;
633
+ if (!fs.existsSync(src)) return { copied: 0, skipped: 0, total: 0 };
124
634
 
125
635
  if (!fs.existsSync(dest)) {
126
636
  fs.mkdirSync(dest, { recursive: true });
@@ -135,16 +645,22 @@ function copyDir(src, dest, options = {}) {
135
645
  if (!filter(entry.name, srcPath)) continue;
136
646
 
137
647
  if (entry.isDirectory()) {
138
- copied += copyDir(srcPath, destPath, options);
648
+ const result = copyDir(srcPath, destPath, options);
649
+ copied += result.copied;
650
+ skipped += result.skipped;
651
+ total += result.total;
139
652
  } else {
653
+ total++;
140
654
  if (!fs.existsSync(destPath) || overwrite) {
141
655
  fs.copyFileSync(srcPath, destPath);
142
656
  copied++;
657
+ } else {
658
+ skipped++;
143
659
  }
144
660
  }
145
661
  }
146
662
 
147
- return copied;
663
+ return { copied, skipped, total };
148
664
  }
149
665
 
150
666
  function copyFile(src, dest, options = {}) {
@@ -205,10 +721,8 @@ function checkNode() {
205
721
  async function main() {
206
722
  // Parse arguments
207
723
  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
724
  const silent = args.includes("--silent") || args.includes("-s");
725
+ const quickMode = args.includes("--quick") || args.includes("-q");
212
726
 
213
727
  // Show banner
214
728
  if (!silent) {
@@ -222,7 +736,377 @@ async function main() {
222
736
  const claudeDir = path.join(targetDir, ".claude");
223
737
  const hooksDir = path.join(claudeDir, "hooks");
224
738
 
225
- const totalSteps = 8;
739
+ // ─────────────────────────────────────────────────────────────────────────
740
+ // WIZARD STEP 1: Choose Setup Mode
741
+ // ─────────────────────────────────────────────────────────────────────────
742
+
743
+ let config = {
744
+ withStorybook: true,
745
+ withPlaywright: true,
746
+ withSandpack: true,
747
+ githubToken: "",
748
+ greptileApiKey: "",
749
+ brandfetchApiKey: "",
750
+ ntfyEnabled: false,
751
+ ntfyTopic: "",
752
+ ntfyServer: "ntfy.sh",
753
+ createBrandGuide: true,
754
+ brandSource: "manual", // "brandfetch" or "manual"
755
+ brandDomain: "",
756
+ brandName: path.basename(targetDir),
757
+ primaryColor: "#E11D48",
758
+ secondaryColor: "#1E40AF",
759
+ accentColor: "#8B5CF6",
760
+ successColor: "#10B981",
761
+ warningColor: "#F59E0B",
762
+ errorColor: "#EF4444",
763
+ fontFamily: "Inter",
764
+ headingFont: "Inter",
765
+ monoFont: "JetBrains Mono",
766
+ borderRadius: "8px",
767
+ darkMode: true,
768
+ imageStyle: "photography", // photography, illustration, abstract, minimal
769
+ iconStyle: "outline", // outline, solid, duotone
770
+ buttonStyle: "rounded", // sharp, subtle, rounded, pill
771
+ cardStyle: "elevated", // flat, bordered, elevated
772
+ animationLevel: "subtle", // none, subtle, moderate, expressive
773
+ };
774
+
775
+ if (!silent && !quickMode) {
776
+ const setupMode = await selectOne("Choose installation mode", [
777
+ { label: "Quick Setup (recommended defaults)", value: "quick" },
778
+ { label: "Custom Setup (configure each option)", value: "custom" },
779
+ ]);
780
+
781
+ if (setupMode === "custom") {
782
+ // ─────────────────────────────────────────────────────────────────────
783
+ // WIZARD STEP 2: API Keys
784
+ // ─────────────────────────────────────────────────────────────────────
785
+
786
+ log(`\n${c.red}━━━ API Keys ━━━${c.reset}`);
787
+ log(`${c.dim}These keys enable advanced features like code review and brand fetching${c.reset}\n`);
788
+ log(`${c.bold}Get your keys (all have free tiers):${c.reset}\n`);
789
+ log(` ${c.white}GITHUB_TOKEN${c.reset}`);
790
+ log(` ${c.dim}→${c.reset} https://github.com/settings/tokens`);
791
+ log(` ${c.dim}Purpose: Create issues, PRs, search code${c.reset}`);
792
+ log(` ${c.dim}Scope needed: 'repo' for private repos${c.reset}\n`);
793
+ log(` ${c.white}GREPTILE_API_KEY${c.reset}`);
794
+ log(` ${c.dim}→${c.reset} https://app.greptile.com/settings/api`);
795
+ log(` ${c.dim}Purpose: AI code review in Phase 11${c.reset}`);
796
+ log(` ${c.dim}Free tier: 100 reviews/month${c.reset}\n`);
797
+ log(` ${c.white}BRANDFETCH_API_KEY${c.reset}`);
798
+ log(` ${c.dim}→${c.reset} https://brandfetch.com/developers`);
799
+ log(` ${c.dim}Purpose: Auto-fetch logos, colors, fonts from domains${c.reset}`);
800
+ log(` ${c.dim}Free tier: 50 requests/month (basic assets)${c.reset}\n`);
801
+
802
+ const configureKeys = await selectOne("Configure API keys now?", [
803
+ { label: "Yes, enter them now", value: true },
804
+ { label: "No, I'll add to .env later", value: false },
805
+ ]);
806
+
807
+ if (configureKeys) {
808
+ config.githubToken = await textInput("GITHUB_TOKEN", {
809
+ default: process.env.GITHUB_TOKEN || "",
810
+ mask: true,
811
+ });
812
+ config.greptileApiKey = await textInput("GREPTILE_API_KEY", {
813
+ default: process.env.GREPTILE_API_KEY || "",
814
+ mask: true,
815
+ });
816
+ config.brandfetchApiKey = await textInput("BRANDFETCH_API_KEY (optional)", {
817
+ default: process.env.BRANDFETCH_API_KEY || "",
818
+ mask: true,
819
+ });
820
+ }
821
+
822
+ // ─────────────────────────────────────────────────────────────────────
823
+ // WIZARD STEP 3: Testing Tools
824
+ // ─────────────────────────────────────────────────────────────────────
825
+
826
+ log(`\n${c.red}━━━ Testing Tools ━━━${c.reset}`);
827
+ log(`${c.dim}Required for component development and E2E testing${c.reset}\n`);
828
+ log(`${c.bold}What each tool does:${c.reset}`);
829
+ log(` ${c.white}Playwright${c.reset} → E2E browser testing (required for /hustle-ui-create-page)`);
830
+ log(` ${c.white}Storybook${c.reset} → Component development & visual testing (required for /hustle-ui-create)`);
831
+ log(` ${c.white}Sandpack${c.reset} → Live code preview in browser (optional)\n`);
832
+
833
+ const selectedTools = await selectMany("Select testing tools to install", [
834
+ { label: "Playwright (E2E testing)", value: "playwright", checked: true },
835
+ { label: "Storybook (component development)", value: "storybook", checked: true },
836
+ { label: "Sandpack (live code preview)", value: "sandpack", checked: true },
837
+ ]);
838
+
839
+ config.withPlaywright = selectedTools.includes("playwright");
840
+ config.withStorybook = selectedTools.includes("storybook");
841
+ config.withSandpack = selectedTools.includes("sandpack");
842
+
843
+ // ─────────────────────────────────────────────────────────────────────
844
+ // WIZARD STEP 4: NTFY Notifications
845
+ // ─────────────────────────────────────────────────────────────────────
846
+
847
+ log(`\n${c.red}━━━ Push Notifications (NTFY) ━━━${c.reset}`);
848
+ log(`${c.dim}Get notified on your phone when long tasks complete${c.reset}\n`);
849
+ log(`${c.bold}How it works:${c.reset}`);
850
+ log(` 1. Install NTFY app: ${c.white}iOS${c.reset} App Store / ${c.white}Android${c.reset} Play Store`);
851
+ log(` 2. Subscribe to your topic in the app`);
852
+ log(` 3. Receive push notifications when builds/tests finish\n`);
853
+ log(`${c.dim}Free service, no account required. Learn more: https://ntfy.sh${c.reset}\n`);
854
+
855
+ config.ntfyEnabled = await confirm("Enable NTFY push notifications?", false);
856
+
857
+ if (config.ntfyEnabled) {
858
+ log(`\n${c.dim}Topic name = channel for your notifications (must match the app)${c.reset}`);
859
+ config.ntfyTopic = await textInput("NTFY Topic name", {
860
+ default: path.basename(targetDir) + "-alerts",
861
+ });
862
+ log(`${c.dim}Server URL = ntfy.sh (public) or your self-hosted server${c.reset}`);
863
+ config.ntfyServer = await textInput("NTFY Server URL", {
864
+ default: "ntfy.sh",
865
+ });
866
+ }
867
+
868
+ // ─────────────────────────────────────────────────────────────────────
869
+ // WIZARD STEP 5: Brand Guide
870
+ // ─────────────────────────────────────────────────────────────────────
871
+
872
+ log(`\n${c.red}━━━ Brand Guide ━━━${c.reset}`);
873
+ log(`${c.dim}Design system that enforces consistent UI across all components${c.reset}\n`);
874
+ log(`${c.bold}Why you need a brand guide:${c.reset}`);
875
+ log(` • Consistent look across all pages and components`);
876
+ log(` • Faster development (no color/font decisions each time)`);
877
+ log(` • Enforced by hooks during /hustle-ui-create`);
878
+ log(` • Professional, cohesive user experience\n`);
879
+
880
+ config.createBrandGuide = await confirm("Create brand guide?", true);
881
+
882
+ if (config.createBrandGuide) {
883
+ // Brand source selection
884
+ log(`\n${c.bold}How would you like to create your brand guide?${c.reset}\n`);
885
+
886
+ config.brandSource = await selectOne("Brand guide source", [
887
+ { label: "Manual Interview - Answer questions about your brand preferences", value: "manual" },
888
+ { label: "Brandfetch - Auto-fetch from company domain (requires API key)", value: "brandfetch" },
889
+ ]);
890
+
891
+ // Default values (may be overridden by Brandfetch)
892
+ let brandDefaults = {
893
+ brandName: path.basename(targetDir),
894
+ primaryColor: "#E11D48",
895
+ secondaryColor: "#1E40AF",
896
+ accentColor: "#8B5CF6",
897
+ fontFamily: "Inter",
898
+ headingFont: "Inter",
899
+ logoUrl: null,
900
+ iconUrl: null,
901
+ };
902
+
903
+ if (config.brandSource === "brandfetch") {
904
+ log(`\n${c.bold}Brandfetch Integration${c.reset}`);
905
+ log(`${c.dim}Fetch brand assets to pre-populate the interview${c.reset}\n`);
906
+
907
+ if (!config.brandfetchApiKey) {
908
+ log(`${c.white}Get your free API key:${c.reset} https://brandfetch.com/developers`);
909
+ log(`${c.dim}Free tier includes: 50 requests/month, basic brand assets${c.reset}\n`);
910
+ config.brandfetchApiKey = await textInput("BRANDFETCH_API_KEY", {
911
+ default: process.env.BRANDFETCH_API_KEY || "",
912
+ mask: true,
913
+ });
914
+ }
915
+
916
+ config.brandDomain = await textInput("Company domain to fetch brand from (e.g., stripe.com)", {
917
+ default: "",
918
+ });
919
+
920
+ if (config.brandDomain && config.brandfetchApiKey) {
921
+ // Actually fetch brand data!
922
+ startSpinner(`Fetching brand data from ${config.brandDomain}...`);
923
+ const brandData = await fetchBrandData(config.brandDomain, config.brandfetchApiKey);
924
+
925
+ if (brandData) {
926
+ const parsed = parseBrandData(brandData);
927
+ stopSpinner(true, `Fetched brand data from ${config.brandDomain}`);
928
+
929
+ // Update defaults with fetched data
930
+ brandDefaults = { ...brandDefaults, ...parsed };
931
+
932
+ log(`\n${c.bold}Brand Data Retrieved:${c.reset}`);
933
+ log(` ${c.white}Name:${c.reset} ${parsed.brandName}`);
934
+ log(` ${c.white}Primary:${c.reset} ${parsed.primaryColor}`);
935
+ log(` ${c.white}Secondary:${c.reset} ${parsed.secondaryColor}`);
936
+ log(` ${c.white}Accent:${c.reset} ${parsed.accentColor}`);
937
+ log(` ${c.white}Body Font:${c.reset} ${parsed.fontFamily}`);
938
+ log(` ${c.white}Heading Font:${c.reset} ${parsed.headingFont}`);
939
+ if (parsed.logoUrl) log(` ${c.white}Logo:${c.reset} ${c.dim}${parsed.logoUrl.substring(0, 50)}...${c.reset}`);
940
+
941
+ // Store logo URLs for brand guide
942
+ config.logoUrl = parsed.logoUrl;
943
+ config.iconUrl = parsed.iconUrl;
944
+
945
+ log(`\n${c.dim}These values will be used as defaults in the interview below.${c.reset}`);
946
+ log(`${c.dim}Press Enter to accept or type a different value.${c.reset}\n`);
947
+ } else {
948
+ stopSpinner(false, `Could not fetch brand data from ${config.brandDomain}`);
949
+ log(`${c.dim}Continuing with manual defaults...${c.reset}\n`);
950
+ }
951
+ } else if (!config.brandDomain) {
952
+ log(`\n${c.dim}No domain provided - using manual defaults${c.reset}`);
953
+ }
954
+ }
955
+
956
+ // Full Brand Interview (with Brandfetch data as defaults if available)
957
+ log(`\n${c.bold}━━━ Brand Interview ━━━${c.reset}`);
958
+ if (config.brandSource === "brandfetch" && config.brandDomain) {
959
+ log(`${c.dim}Confirm or customize the fetched brand values${c.reset}\n`);
960
+ } else {
961
+ log(`${c.dim}Let's define your brand's visual identity${c.reset}\n`);
962
+ }
963
+
964
+ // Basic identity
965
+ config.brandName = await textInput("Brand/Project name", {
966
+ default: brandDefaults.brandName,
967
+ });
968
+
969
+ // Color palette
970
+ log(`\n${c.bold}Color Palette${c.reset}`);
971
+ log(`${c.dim}Define colors that represent your brand${c.reset}`);
972
+ log(`${c.dim}Enter hex (#E11D48) or color name (red, blue, coral, navy, etc.)${c.reset}\n`);
973
+
974
+ let primaryInput = await textInput("Primary color (main CTAs, links)", {
975
+ default: brandDefaults.primaryColor,
976
+ hint: "hex or name",
977
+ });
978
+ config.primaryColor = parseColor(primaryInput, brandDefaults.primaryColor);
979
+ if (primaryInput && primaryInput !== config.primaryColor) {
980
+ log(` ${c.dim}→ Resolved to ${config.primaryColor}${c.reset}`);
981
+ }
982
+
983
+ let secondaryInput = await textInput("Secondary color (accents)", {
984
+ default: brandDefaults.secondaryColor,
985
+ hint: "hex or name",
986
+ });
987
+ config.secondaryColor = parseColor(secondaryInput, brandDefaults.secondaryColor);
988
+ if (secondaryInput && secondaryInput !== config.secondaryColor) {
989
+ log(` ${c.dim}→ Resolved to ${config.secondaryColor}${c.reset}`);
990
+ }
991
+
992
+ let accentInput = await textInput("Accent color (highlights, badges)", {
993
+ default: brandDefaults.accentColor,
994
+ hint: "hex or name",
995
+ });
996
+ config.accentColor = parseColor(accentInput, brandDefaults.accentColor);
997
+ if (accentInput && accentInput !== config.accentColor) {
998
+ log(` ${c.dim}→ Resolved to ${config.accentColor}${c.reset}`);
999
+ }
1000
+
1001
+ // Typography
1002
+ log(`\n${c.bold}Typography${c.reset}`);
1003
+ log(`${c.dim}Fonts define your brand's personality${c.reset}`);
1004
+ log(`${c.dim}Select from presets or describe what you want (e.g., "elegant serif", "modern sans")${c.reset}\n`);
1005
+
1006
+ // Build font options with fetched font as first option if available
1007
+ const fontOptions = [];
1008
+ if (brandDefaults.fontFamily && brandDefaults.fontFamily !== "Inter") {
1009
+ fontOptions.push({ label: `${brandDefaults.fontFamily} - From Brandfetch (Recommended)`, value: brandDefaults.fontFamily });
1010
+ }
1011
+ fontOptions.push(
1012
+ { label: "Inter - Clean, modern, highly readable", value: "Inter" },
1013
+ { label: "Geist - GitHub/Vercel aesthetic", value: "Geist" },
1014
+ { label: "Plus Jakarta Sans - Friendly, approachable", value: "Plus Jakarta Sans" },
1015
+ { label: "DM Sans - Geometric, professional", value: "DM Sans" },
1016
+ { label: "IBM Plex Sans - Technical, serious", value: "IBM Plex Sans" },
1017
+ { label: "Other - Describe or enter font name", value: "custom" },
1018
+ );
1019
+
1020
+ config.fontFamily = await selectOne("Primary body font", fontOptions);
1021
+
1022
+ if (config.fontFamily === "custom") {
1023
+ let fontInput = await textInput("Font name or description", {
1024
+ default: brandDefaults.fontFamily,
1025
+ hint: 'e.g., "Playfair" or "elegant serif"',
1026
+ });
1027
+ config.fontFamily = parseFont(fontInput, brandDefaults.fontFamily);
1028
+ log(` ${c.dim}→ Using: ${config.fontFamily}${c.reset}`);
1029
+ }
1030
+
1031
+ // Build heading font options with fetched font as first option if available
1032
+ const headingOptions = [];
1033
+ if (brandDefaults.headingFont && brandDefaults.headingFont !== config.fontFamily) {
1034
+ headingOptions.push({ label: `${brandDefaults.headingFont} - From Brandfetch (Recommended)`, value: brandDefaults.headingFont });
1035
+ }
1036
+ headingOptions.push(
1037
+ { label: "Same as body font", value: config.fontFamily },
1038
+ { label: "Playfair Display - Elegant, sophisticated", value: "Playfair Display" },
1039
+ { label: "Cal Sans - Bold, impactful", value: "Cal Sans" },
1040
+ { label: "Clash Display - Modern, striking", value: "Clash Display" },
1041
+ { label: "Other - Describe or enter font name", value: "custom" },
1042
+ );
1043
+
1044
+ config.headingFont = await selectOne("Heading font", headingOptions);
1045
+
1046
+ if (config.headingFont === "custom") {
1047
+ let headingInput = await textInput("Heading font name or description", {
1048
+ default: brandDefaults.headingFont,
1049
+ hint: 'e.g., "sans-serif that pairs nicely"',
1050
+ });
1051
+ config.headingFont = parseFont(headingInput, brandDefaults.headingFont);
1052
+ log(` ${c.dim}→ Using: ${config.headingFont}${c.reset}`);
1053
+ }
1054
+
1055
+ // UI Style preferences
1056
+ log(`\n${c.bold}UI Style Preferences${c.reset}`);
1057
+ log(`${c.dim}Define the overall look and feel${c.reset}\n`);
1058
+
1059
+ config.buttonStyle = await selectOne("Button style", [
1060
+ { label: "Sharp (0px) - Modern, minimal tech aesthetic", value: "sharp" },
1061
+ { label: "Subtle (4px) - Professional, slightly softened", value: "subtle" },
1062
+ { label: "Rounded (8px) - Friendly, approachable", value: "rounded" },
1063
+ { label: "Pill (9999px) - Playful, fully rounded", value: "pill" },
1064
+ ]);
1065
+
1066
+ // Map button style to border radius
1067
+ const radiusMap = { sharp: "0", subtle: "4px", rounded: "8px", pill: "9999px" };
1068
+ config.borderRadius = radiusMap[config.buttonStyle];
1069
+
1070
+ config.cardStyle = await selectOne("Card style", [
1071
+ { label: "Flat - Minimal, no depth", value: "flat" },
1072
+ { label: "Bordered - Subtle outline separation", value: "bordered" },
1073
+ { label: "Elevated - Shadow for depth", value: "elevated" },
1074
+ ]);
1075
+
1076
+ // Visual content style
1077
+ log(`\n${c.bold}Visual Content${c.reset}`);
1078
+ log(`${c.dim}Preferences for images and icons${c.reset}\n`);
1079
+
1080
+ config.imageStyle = await selectOne("Preferred image style", [
1081
+ { label: "Photography - Real photos, authentic feel", value: "photography" },
1082
+ { label: "Illustrations - Custom drawn, unique personality", value: "illustration" },
1083
+ { label: "Abstract - Shapes, gradients, patterns", value: "abstract" },
1084
+ { label: "Minimal - Clean, simple graphics", value: "minimal" },
1085
+ ]);
1086
+
1087
+ config.iconStyle = await selectOne("Icon style", [
1088
+ { label: "Outline - Light, modern (Lucide, Heroicons)", value: "outline" },
1089
+ { label: "Solid - Bold, impactful (Phosphor filled)", value: "solid" },
1090
+ { label: "Duotone - Two-tone, distinctive", value: "duotone" },
1091
+ ]);
1092
+
1093
+ // Animation preferences
1094
+ config.animationLevel = await selectOne("Animation level", [
1095
+ { label: "None - Static UI, pure function", value: "none" },
1096
+ { label: "Subtle - Micro-interactions, fade-ins", value: "subtle" },
1097
+ { label: "Moderate - Page transitions, hovers", value: "moderate" },
1098
+ { label: "Expressive - Bold animations, personality", value: "expressive" },
1099
+ ]);
1100
+
1101
+ // Dark mode
1102
+ config.darkMode = await confirm("Include dark mode support?", true);
1103
+ }
1104
+ }
1105
+
1106
+ log(`\n${c.red}━━━ Installing ━━━${c.reset}\n`);
1107
+ }
1108
+
1109
+ const totalSteps = 10;
226
1110
  let currentStep = 0;
227
1111
 
228
1112
  // ─────────────────────────────────────────────────────────────────────────
@@ -230,6 +1114,7 @@ async function main() {
230
1114
  // ─────────────────────────────────────────────────────────────────────────
231
1115
 
232
1116
  logStep(++currentStep, totalSteps, "Checking prerequisites");
1117
+ log(` ${c.dim}Verifying Node.js 18+ and Python 3 are installed${c.reset}`);
233
1118
 
234
1119
  const node = checkNode();
235
1120
  if (node.ok) {
@@ -252,6 +1137,7 @@ async function main() {
252
1137
  // ─────────────────────────────────────────────────────────────────────────
253
1138
 
254
1139
  logStep(++currentStep, totalSteps, "Installing slash commands");
1140
+ log(` ${c.dim}Copying 29 commands including /hustle-api-create, /hustle-ui-create${c.reset}`);
255
1141
 
256
1142
  const commandsDir = path.join(claudeDir, "commands");
257
1143
  const sourceCommandsDir = path.join(packageDir, "commands");
@@ -260,17 +1146,26 @@ async function main() {
260
1146
  fs.mkdirSync(commandsDir, { recursive: true });
261
1147
  }
262
1148
 
263
- const commandsCopied = copyDir(sourceCommandsDir, commandsDir, {
1149
+ const commandsResult = copyDir(sourceCommandsDir, commandsDir, {
264
1150
  filter: (name) => name.endsWith(".md"),
265
1151
  });
266
1152
 
267
- logSuccess(`${commandsCopied} commands installed to .claude/commands/`);
1153
+ if (commandsResult.copied > 0) {
1154
+ logSuccess(`${commandsResult.copied} new commands installed to .claude/commands/`);
1155
+ }
1156
+ if (commandsResult.skipped > 0) {
1157
+ logInfo(`${commandsResult.skipped} commands already exist (preserved)`);
1158
+ }
1159
+ if (commandsResult.total === 0) {
1160
+ logWarn("No commands found in package");
1161
+ }
268
1162
 
269
1163
  // ─────────────────────────────────────────────────────────────────────────
270
1164
  // Step 3: Install Hooks
271
1165
  // ─────────────────────────────────────────────────────────────────────────
272
1166
 
273
1167
  logStep(++currentStep, totalSteps, "Installing enforcement hooks");
1168
+ log(` ${c.dim}Python hooks that enforce TDD workflow and prevent skipping phases${c.reset}`);
274
1169
 
275
1170
  const sourceHooksDir = path.join(packageDir, "hooks");
276
1171
 
@@ -291,11 +1186,15 @@ async function main() {
291
1186
 
292
1187
  // Copy Python hook files
293
1188
  let hooksCopied = 0;
1189
+ let hooksSkipped = 0;
1190
+ let hooksTotal = 0;
294
1191
  if (fs.existsSync(sourceHooksDir)) {
295
1192
  const hookFiles = fs
296
1193
  .readdirSync(sourceHooksDir)
297
1194
  .filter((f) => f.endsWith(".py"));
298
1195
 
1196
+ hooksTotal = hookFiles.length;
1197
+
299
1198
  for (const file of hookFiles) {
300
1199
  const src = path.join(sourceHooksDir, file);
301
1200
  const dest = path.join(hooksDir, file);
@@ -304,11 +1203,18 @@ async function main() {
304
1203
  fs.copyFileSync(src, dest);
305
1204
  fs.chmodSync(dest, "755");
306
1205
  hooksCopied++;
1206
+ } else {
1207
+ hooksSkipped++;
307
1208
  }
308
1209
  }
309
1210
  }
310
1211
 
311
- logSuccess(`${hooksCopied} hooks installed to .claude/hooks/`);
1212
+ if (hooksCopied > 0) {
1213
+ logSuccess(`${hooksCopied} new hooks installed to .claude/hooks/`);
1214
+ }
1215
+ if (hooksSkipped > 0) {
1216
+ logInfo(`${hooksSkipped} hooks already exist (preserved)`);
1217
+ }
312
1218
  logInfo("Includes: enforce-*, notify-*, track-token-usage.py");
313
1219
 
314
1220
  // ─────────────────────────────────────────────────────────────────────────
@@ -316,6 +1222,7 @@ async function main() {
316
1222
  // ─────────────────────────────────────────────────────────────────────────
317
1223
 
318
1224
  logStep(++currentStep, totalSteps, "Installing subagents");
1225
+ log(` ${c.dim}AI agents for parallel research, schema generation, code review${c.reset}`);
319
1226
 
320
1227
  const agentsDir = path.join(claudeDir, "agents");
321
1228
  const sourceAgentsDir = path.join(packageDir, ".claude", "agents");
@@ -324,11 +1231,16 @@ async function main() {
324
1231
  fs.mkdirSync(agentsDir, { recursive: true });
325
1232
  }
326
1233
 
327
- const agentsCopied = copyDir(sourceAgentsDir, agentsDir, {
1234
+ const agentsResult = copyDir(sourceAgentsDir, agentsDir, {
328
1235
  filter: (name) => name.endsWith(".md"),
329
1236
  });
330
1237
 
331
- logSuccess(`${agentsCopied} subagents installed to .claude/agents/`);
1238
+ if (agentsResult.copied > 0) {
1239
+ logSuccess(`${agentsResult.copied} new subagents installed to .claude/agents/`);
1240
+ }
1241
+ if (agentsResult.skipped > 0) {
1242
+ logInfo(`${agentsResult.skipped} subagents already exist (preserved)`);
1243
+ }
332
1244
  logInfo("Haiku: parallel-researcher, research-validator, docs-generator");
333
1245
  logInfo(
334
1246
  "Sonnet: schema-generator, test-writer, implementation-reviewer, code-reviewer",
@@ -339,6 +1251,7 @@ async function main() {
339
1251
  // ─────────────────────────────────────────────────────────────────────────
340
1252
 
341
1253
  logStep(++currentStep, totalSteps, "Setting up configuration");
1254
+ log(` ${c.dim}Creating settings.json, state tracking, and research cache${c.reset}`);
342
1255
 
343
1256
  const sourceTemplatesDir = path.join(packageDir, "templates");
344
1257
 
@@ -384,6 +1297,15 @@ async function main() {
384
1297
  logSuccess("Created registry.json");
385
1298
  }
386
1299
 
1300
+ // Hustle-build defaults (for --auto mode)
1301
+ const defaultsSource = path.join(sourceTemplatesDir, "hustle-build-defaults.json");
1302
+ const defaultsDest = path.join(claudeDir, "hustle-build-defaults.json");
1303
+
1304
+ if (fs.existsSync(defaultsSource) && !fs.existsSync(defaultsDest)) {
1305
+ copyFile(defaultsSource, defaultsDest);
1306
+ logSuccess("Created hustle-build-defaults.json");
1307
+ }
1308
+
387
1309
  // Research cache
388
1310
  const researchDir = path.join(claudeDir, "research");
389
1311
  if (!fs.existsSync(researchDir)) {
@@ -400,6 +1322,7 @@ async function main() {
400
1322
  // ─────────────────────────────────────────────────────────────────────────
401
1323
 
402
1324
  logStep(++currentStep, totalSteps, "Setting up environment");
1325
+ log(` ${c.dim}Creating .env.example template for API keys${c.reset}`);
403
1326
 
404
1327
  const templatesDestDir = path.join(targetDir, "templates");
405
1328
  if (!fs.existsSync(templatesDestDir)) {
@@ -415,19 +1338,54 @@ async function main() {
415
1338
  logInfo("Copy to .env and configure your API keys");
416
1339
  }
417
1340
 
1341
+ // Copy showcase template directories (for update-api-showcase.py and update-ui-showcase.py hooks)
1342
+ const showcaseDirs = ["api-showcase", "ui-showcase", "shared"];
1343
+ for (const dir of showcaseDirs) {
1344
+ const srcDir = path.join(sourceTemplatesDir, dir);
1345
+ const destDir = path.join(templatesDestDir, dir);
1346
+ if (fs.existsSync(srcDir)) {
1347
+ const result = copyDir(srcDir, destDir);
1348
+ if (result.copied > 0) {
1349
+ logSuccess(`Copied ${dir} templates (${result.copied} files)`);
1350
+ } else if (result.skipped > 0) {
1351
+ logInfo(`${dir} templates already exist (${result.skipped} files preserved)`);
1352
+ }
1353
+ }
1354
+ }
1355
+
418
1356
  // ─────────────────────────────────────────────────────────────────────────
419
1357
  // Step 7: Configure MCP Servers
420
1358
  // ─────────────────────────────────────────────────────────────────────────
421
1359
 
422
1360
  logStep(++currentStep, totalSteps, "Configuring MCP servers");
1361
+ log(` ${c.dim}Setting up AI-powered integrations for research and code review${c.reset}`);
423
1362
 
424
1363
  const mcpServers = [
425
- { name: "context7", cmd: "npx -y @upstash/context7-mcp" },
426
- { name: "github", cmd: "npx -y @modelcontextprotocol/server-github" },
1364
+ {
1365
+ name: "context7",
1366
+ cmd: "npx -y @upstash/context7-mcp",
1367
+ description: "Live documentation lookup (npm, APIs, frameworks)",
1368
+ required: true,
1369
+ },
1370
+ {
1371
+ name: "github",
1372
+ cmd: "npx -y @modelcontextprotocol/server-github",
1373
+ description: "GitHub integration (issues, PRs, code search)",
1374
+ required: true,
1375
+ },
427
1376
  {
428
1377
  name: "greptile",
429
1378
  cmd: "npx -y @anthropics/mcp-greptile",
1379
+ description: "AI code review for Phase 11 verification",
430
1380
  optional: true,
1381
+ requiresKey: "GREPTILE_API_KEY",
1382
+ },
1383
+ {
1384
+ name: "brandfetch",
1385
+ cmd: "npx -y @anthropics/mcp-brandfetch",
1386
+ description: "Auto-fetch brand assets (logos, colors, fonts)",
1387
+ optional: true,
1388
+ requiresKey: "BRANDFETCH_API_KEY",
431
1389
  },
432
1390
  ];
433
1391
 
@@ -438,6 +1396,7 @@ async function main() {
438
1396
  stdio: ["pipe", "pipe", "pipe"],
439
1397
  });
440
1398
  logInfo(`${server.name} already configured`);
1399
+ log(` ${c.dim}${server.description}${c.reset}`);
441
1400
  } catch (e) {
442
1401
  try {
443
1402
  execSync(`claude mcp add ${server.name} -- ${server.cmd}`, {
@@ -445,11 +1404,11 @@ async function main() {
445
1404
  stdio: ["pipe", "pipe", "pipe"],
446
1405
  });
447
1406
  logSuccess(`Added ${server.name}`);
1407
+ log(` ${c.dim}${server.description}${c.reset}`);
448
1408
  } catch (addErr) {
449
1409
  if (server.optional) {
450
- logInfo(
451
- `${server.name} (optional) - configure with GREPTILE_API_KEY`,
452
- );
1410
+ logInfo(`${server.name} (optional) - requires ${server.requiresKey}`);
1411
+ log(` ${c.dim}${server.description}${c.reset}`);
453
1412
  } else {
454
1413
  logWarn(`Could not add ${server.name} - add manually`);
455
1414
  }
@@ -457,99 +1416,590 @@ async function main() {
457
1416
  }
458
1417
  }
459
1418
 
460
- logInfo("Greptile requires GREPTILE_API_KEY + GITHUB_TOKEN for Phase 14");
1419
+ log(`\n ${c.bold}MCP Server Benefits:${c.reset}`);
1420
+ log(` ${c.dim}• Context7: Always get latest docs, no hallucinated APIs${c.reset}`);
1421
+ log(` ${c.dim}• GitHub: Create issues/PRs directly from Claude${c.reset}`);
1422
+ log(` ${c.dim}• Greptile: AI-powered code review catches bugs before merge${c.reset}`);
1423
+ log(` ${c.dim}• Brandfetch: Auto-generate brand guide from company domain${c.reset}`);
461
1424
 
462
1425
  // ─────────────────────────────────────────────────────────────────────────
463
- // Step 8: Optional Tools
1426
+ // Step 8: Create .env file with API keys (if provided)
464
1427
  // ─────────────────────────────────────────────────────────────────────────
465
1428
 
466
- logStep(++currentStep, totalSteps, "Optional tools");
1429
+ logStep(++currentStep, totalSteps, "Configuring environment");
1430
+ log(` ${c.dim}Writing API keys and notification settings to .env${c.reset}`);
467
1431
 
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
- }
1432
+ const envPath = path.join(targetDir, ".env");
1433
+ let envContent = "";
1434
+
1435
+ if (fs.existsSync(envPath)) {
1436
+ envContent = fs.readFileSync(envPath, "utf8");
1437
+ }
1438
+
1439
+ let envUpdated = false;
1440
+
1441
+ if (config.githubToken && !envContent.includes("GITHUB_TOKEN=")) {
1442
+ envContent += `\nGITHUB_TOKEN=${config.githubToken}`;
1443
+ envUpdated = true;
1444
+ }
1445
+
1446
+ if (config.greptileApiKey && !envContent.includes("GREPTILE_API_KEY=")) {
1447
+ envContent += `\nGREPTILE_API_KEY=${config.greptileApiKey}`;
1448
+ envUpdated = true;
1449
+ }
1450
+
1451
+ if (config.brandfetchApiKey && !envContent.includes("BRANDFETCH_API_KEY=")) {
1452
+ envContent += `\nBRANDFETCH_API_KEY=${config.brandfetchApiKey}`;
1453
+ envUpdated = true;
1454
+ }
1455
+
1456
+ if (config.ntfyEnabled) {
1457
+ if (!envContent.includes("NTFY_TOPIC=")) {
1458
+ envContent += `\nNTFY_TOPIC=${config.ntfyTopic}`;
1459
+ envUpdated = true;
1460
+ }
1461
+ if (!envContent.includes("NTFY_SERVER=")) {
1462
+ envContent += `\nNTFY_SERVER=${config.ntfyServer}`;
1463
+ envUpdated = true;
483
1464
  }
1465
+ }
484
1466
 
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
- );
499
- }
1467
+ if (envUpdated) {
1468
+ fs.writeFileSync(envPath, envContent.trim() + "\n");
1469
+ logSuccess("Updated .env with API keys");
1470
+ } else {
1471
+ logInfo("No API keys to add (configure in .env later)");
1472
+ }
1473
+
1474
+ // ─────────────────────────────────────────────────────────────────────────
1475
+ // Step 9: Create Brand Guide (if enabled)
1476
+ // ─────────────────────────────────────────────────────────────────────────
1477
+
1478
+ logStep(++currentStep, totalSteps, "Brand guide");
1479
+ log(` ${c.dim}Creating comprehensive design system documentation${c.reset}`);
1480
+
1481
+ const brandGuidePath = path.join(claudeDir, "BRAND_GUIDE.md");
1482
+
1483
+ if (config.createBrandGuide && !fs.existsSync(brandGuidePath)) {
1484
+ const darkModeSection = config.darkMode
1485
+ ? `
1486
+
1487
+ ## Dark Mode
1488
+
1489
+ ### Dark Theme Colors
1490
+ - **Background**: #0F172A
1491
+ - **Surface**: #1E293B
1492
+ - **Border**: #334155
1493
+ - **Text**: #F8FAFC
1494
+ - **Muted**: #94A3B8
1495
+
1496
+ ### Implementation
1497
+ \`\`\`css
1498
+ @media (prefers-color-scheme: dark) {
1499
+ :root {
1500
+ --bg: #0F172A;
1501
+ --surface: #1E293B;
1502
+ --border: #334155;
1503
+ --text: #F8FAFC;
1504
+ --muted: #94A3B8;
1505
+ }
1506
+ }
1507
+ \`\`\`
1508
+ `
1509
+ : "";
1510
+
1511
+ // Build brandfetch note if using domain
1512
+ const brandfetchNote = config.brandSource === "brandfetch" && config.brandDomain
1513
+ ? `\n> **Source**: Auto-fetched from ${config.brandDomain} via Brandfetch API\n> Run \`/hustle-brand-refresh\` to update from latest brand assets`
1514
+ : "";
1515
+
1516
+ // Build logo section if logos were fetched
1517
+ const logoSection = config.logoUrl
1518
+ ? `
1519
+ ### Logo Assets
1520
+ ${config.logoUrl ? `- **Primary Logo**: ${config.logoUrl}` : ""}
1521
+ ${config.iconUrl ? `- **Icon/Symbol**: ${config.iconUrl}` : ""}
1522
+
1523
+ > Download and place logos in \`/public/logo.svg\` and \`/public/icon.svg\`
1524
+ `
1525
+ : "";
1526
+
1527
+ const brandGuideContent = `# ${config.brandName} Brand Guide
1528
+
1529
+ > Auto-generated by HUSTLE API Dev Tools v3.12.7
1530
+ > This guide ensures consistent UI across all components and pages.${brandfetchNote}
1531
+
1532
+ ---
1533
+
1534
+ ## Brand Identity
1535
+
1536
+ ### Brand Name
1537
+ **${config.brandName}**
1538
+ ${logoSection}
1539
+
1540
+ ### Brand Colors
1541
+ | Role | Color | Hex | Usage |
1542
+ |------|-------|-----|-------|
1543
+ | Primary | 🔴 | \`${config.primaryColor}\` | CTAs, links, focus states |
1544
+ | Secondary | 🔵 | \`${config.secondaryColor}\` | Secondary actions, accents |
1545
+ | Accent | 🟣 | \`${config.accentColor}\` | Highlights, badges, special elements |
1546
+ | Success | 🟢 | \`${config.successColor}\` | Success states, confirmations |
1547
+ | Warning | 🟡 | \`${config.warningColor}\` | Warnings, pending states |
1548
+ | Error | 🔴 | \`${config.errorColor}\` | Errors, destructive actions |
1549
+
1550
+ ---
1551
+
1552
+ ## Color Palette
1553
+
1554
+ ### Primary Colors
1555
+ \`\`\`css
1556
+ --primary: ${config.primaryColor};
1557
+ --primary-light: ${config.primaryColor}20; /* 20% opacity */
1558
+ --primary-dark: ${config.primaryColor};
1559
+ \`\`\`
1560
+
1561
+ ### Secondary Colors
1562
+ \`\`\`css
1563
+ --secondary: ${config.secondaryColor};
1564
+ --secondary-light: ${config.secondaryColor}20;
1565
+ --secondary-dark: ${config.secondaryColor};
1566
+ \`\`\`
1567
+
1568
+ ### Accent Colors
1569
+ \`\`\`css
1570
+ --accent: ${config.accentColor};
1571
+ --accent-light: ${config.accentColor}20;
1572
+ --accent-dark: ${config.accentColor};
1573
+ \`\`\`
1574
+
1575
+ ### Neutral Colors (Light Theme)
1576
+ \`\`\`css
1577
+ --background: #FFFFFF;
1578
+ --surface: #F9FAFB;
1579
+ --border: #E5E7EB;
1580
+ --text: #111827;
1581
+ --text-muted: #6B7280;
1582
+ --text-light: #9CA3AF;
1583
+ \`\`\`
1584
+
1585
+ ### Semantic Colors
1586
+ \`\`\`css
1587
+ --success: ${config.successColor};
1588
+ --success-light: ${config.successColor}20;
1589
+ --warning: ${config.warningColor};
1590
+ --warning-light: ${config.warningColor}20;
1591
+ --error: ${config.errorColor};
1592
+ --error-light: ${config.errorColor}20;
1593
+ --info: #3B82F6;
1594
+ --info-light: #DBEAFE;
1595
+ \`\`\`
1596
+ ${darkModeSection}
1597
+ ---
1598
+
1599
+ ## Typography
1600
+
1601
+ ### Font Families
1602
+ \`\`\`css
1603
+ --font-primary: "${config.fontFamily}", system-ui, -apple-system, sans-serif;
1604
+ --font-heading: "${config.headingFont}", system-ui, -apple-system, sans-serif;
1605
+ --font-mono: "${config.monoFont}", "Fira Code", monospace;
1606
+ \`\`\`
1607
+
1608
+ ### Font Scale
1609
+ | Name | Size | Line Height | Usage |
1610
+ |------|------|-------------|-------|
1611
+ | xs | 0.75rem (12px) | 1rem | Labels, captions |
1612
+ | sm | 0.875rem (14px) | 1.25rem | Secondary text |
1613
+ | base | 1rem (16px) | 1.5rem | Body text |
1614
+ | lg | 1.125rem (18px) | 1.75rem | Lead paragraphs |
1615
+ | xl | 1.25rem (20px) | 1.75rem | H4 |
1616
+ | 2xl | 1.5rem (24px) | 2rem | H3 |
1617
+ | 3xl | 1.875rem (30px) | 2.25rem | H2 |
1618
+ | 4xl | 2.25rem (36px) | 2.5rem | H1 |
1619
+
1620
+ ### Font Weights
1621
+ - **Regular**: 400 - Body text
1622
+ - **Medium**: 500 - Emphasis
1623
+ - **Semibold**: 600 - Headings
1624
+ - **Bold**: 700 - Strong emphasis
1625
+
1626
+ ---
1627
+
1628
+ ## Spacing
1629
+
1630
+ ### Spacing Scale (Tailwind-compatible)
1631
+ | Token | Value | Pixels | Usage |
1632
+ |-------|-------|--------|-------|
1633
+ | 1 | 0.25rem | 4px | Tight spacing |
1634
+ | 2 | 0.5rem | 8px | Default gap |
1635
+ | 3 | 0.75rem | 12px | - |
1636
+ | 4 | 1rem | 16px | Section padding |
1637
+ | 6 | 1.5rem | 24px | Card padding |
1638
+ | 8 | 2rem | 32px | Section gaps |
1639
+ | 12 | 3rem | 48px | Large sections |
1640
+ | 16 | 4rem | 64px | Page sections |
1641
+
1642
+ ---
1643
+
1644
+ ## Border Radius
1645
+
1646
+ ### Radius Scale
1647
+ \`\`\`css
1648
+ --radius-none: 0;
1649
+ --radius-sm: 2px;
1650
+ --radius-default: ${config.borderRadius};
1651
+ --radius-md: 6px;
1652
+ --radius-lg: 8px;
1653
+ --radius-xl: 12px;
1654
+ --radius-2xl: 16px;
1655
+ --radius-full: 9999px;
1656
+ \`\`\`
1657
+
1658
+ ### Usage Guidelines
1659
+ - **Buttons**: Use \`--radius-default\` (${config.borderRadius})
1660
+ - **Cards**: Use \`--radius-lg\` or \`--radius-xl\`
1661
+ - **Inputs**: Use \`--radius-default\`
1662
+ - **Avatars**: Use \`--radius-full\`
1663
+ - **Modals**: Use \`--radius-xl\`
1664
+
1665
+ ---
1666
+
1667
+ ## Shadows
1668
+
1669
+ ### Shadow Scale
1670
+ \`\`\`css
1671
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
1672
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
1673
+ --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
1674
+ --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
1675
+ --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
1676
+ \`\`\`
1677
+
1678
+ ---
1679
+
1680
+ ## Component Patterns
1681
+
1682
+ ### Buttons
1683
+
1684
+ #### Primary Button
1685
+ \`\`\`jsx
1686
+ <button className="bg-primary text-white px-4 py-2 rounded-[${config.borderRadius}] font-medium
1687
+ hover:opacity-90 focus:ring-2 focus:ring-primary focus:ring-offset-2
1688
+ disabled:opacity-50 disabled:cursor-not-allowed">
1689
+ Primary Action
1690
+ </button>
1691
+ \`\`\`
1692
+
1693
+ #### Secondary Button
1694
+ \`\`\`jsx
1695
+ <button className="bg-surface border border-border text-text px-4 py-2 rounded-[${config.borderRadius}]
1696
+ hover:bg-gray-100 focus:ring-2 focus:ring-gray-200">
1697
+ Secondary
1698
+ </button>
1699
+ \`\`\`
1700
+
1701
+ #### Ghost Button
1702
+ \`\`\`jsx
1703
+ <button className="text-text px-4 py-2 rounded-[${config.borderRadius}]
1704
+ hover:bg-surface focus:ring-2 focus:ring-gray-200">
1705
+ Ghost
1706
+ </button>
1707
+ \`\`\`
1708
+
1709
+ #### Destructive Button
1710
+ \`\`\`jsx
1711
+ <button className="bg-error text-white px-4 py-2 rounded-[${config.borderRadius}]
1712
+ hover:bg-red-600 focus:ring-2 focus:ring-error">
1713
+ Delete
1714
+ </button>
1715
+ \`\`\`
1716
+
1717
+ ### Cards
1718
+
1719
+ \`\`\`jsx
1720
+ <div className="bg-white border border-border rounded-xl p-6 shadow-sm">
1721
+ <h3 className="text-lg font-semibold text-text">Card Title</h3>
1722
+ <p className="text-muted text-sm mt-2">Card description text.</p>
1723
+ </div>
1724
+ \`\`\`
1725
+
1726
+ ### Form Inputs
1727
+
1728
+ \`\`\`jsx
1729
+ <div>
1730
+ <label className="text-sm font-medium text-text mb-1 block">
1731
+ Label
1732
+ </label>
1733
+ <input
1734
+ type="text"
1735
+ className="w-full border border-border rounded-[${config.borderRadius}] px-3 py-2
1736
+ focus:ring-2 focus:ring-primary focus:border-primary
1737
+ placeholder:text-muted"
1738
+ placeholder="Enter value..."
1739
+ />
1740
+ <p className="text-error text-sm mt-1">Error message</p>
1741
+ </div>
1742
+ \`\`\`
1743
+
1744
+ ### Badges
1745
+
1746
+ \`\`\`jsx
1747
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
1748
+ bg-primary-light text-primary">
1749
+ Badge
1750
+ </span>
1751
+ \`\`\`
1752
+
1753
+ ---
1754
+
1755
+ ## Accessibility
1756
+
1757
+ ### Color Contrast
1758
+ - Text on background: minimum 4.5:1 ratio
1759
+ - Large text (18px+): minimum 3:1 ratio
1760
+ - Interactive elements: minimum 3:1 ratio
1761
+
1762
+ ### Focus States
1763
+ All interactive elements must have visible focus states:
1764
+ \`\`\`css
1765
+ :focus-visible {
1766
+ outline: 2px solid ${config.primaryColor};
1767
+ outline-offset: 2px;
1768
+ }
1769
+ \`\`\`
1770
+
1771
+ ### Touch Targets
1772
+ - Minimum size: 44x44 pixels
1773
+ - Minimum spacing: 8px between targets
1774
+
1775
+ ---
1776
+
1777
+ ## Icons
1778
+
1779
+ ### Icon Style: ${config.iconStyle.charAt(0).toUpperCase() + config.iconStyle.slice(1)}
1780
+ ${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." : ""}
1781
+
1782
+ ### Recommended Icon Libraries
1783
+ ${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" : ""}
1784
+
1785
+ ### Icon Sizes
1786
+ | Size | Pixels | Usage |
1787
+ |------|--------|-------|
1788
+ | xs | 12px | Inline with small text |
1789
+ | sm | 16px | Buttons, inline |
1790
+ | md | 20px | Default |
1791
+ | lg | 24px | Headers, standalone |
1792
+ | xl | 32px | Hero sections |
1793
+
1794
+ ---
1795
+
1796
+ ## Visual Style
1797
+
1798
+ ### Image Style: ${config.imageStyle.charAt(0).toUpperCase() + config.imageStyle.slice(1)}
1799
+ ${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." : ""}
1800
+
1801
+ ### Card Style: ${config.cardStyle.charAt(0).toUpperCase() + config.cardStyle.slice(1)}
1802
+ \`\`\`jsx
1803
+ // ${config.cardStyle} card pattern
1804
+ <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">
1805
+ <h3 className="text-lg font-semibold">Card Title</h3>
1806
+ <p className="text-muted text-sm mt-2">Card content</p>
1807
+ </div>
1808
+ \`\`\`
1809
+
1810
+ ---
1811
+
1812
+ ## Animation
1813
+
1814
+ ### Animation Level: ${config.animationLevel.charAt(0).toUpperCase() + config.animationLevel.slice(1)}
1815
+ ${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." : ""}
1816
+
1817
+ ### Timing
1818
+ \`\`\`css
1819
+ --duration-fast: 150ms;
1820
+ --duration-normal: 200ms;
1821
+ --duration-slow: 300ms;
1822
+ --easing: cubic-bezier(0.4, 0, 0.2, 1);
1823
+ \`\`\`
1824
+
1825
+ ${config.animationLevel !== "none" ? `### Common Animations
1826
+ \`\`\`css
1827
+ .fade-in { animation: fadeIn var(--duration-normal) var(--easing); }
1828
+ .slide-up { animation: slideUp var(--duration-normal) var(--easing); }
1829
+ .scale-in { animation: scaleIn var(--duration-fast) var(--easing); }
1830
+ \`\`\`` : "### No Animations\nAll animations are disabled. Use CSS \\`transition: none\\` globally."}
1831
+
1832
+ ---
1833
+
1834
+ ## Tailwind Config
1835
+
1836
+ \`\`\`js
1837
+ // tailwind.config.js
1838
+ module.exports = {
1839
+ theme: {
1840
+ extend: {
1841
+ colors: {
1842
+ primary: '${config.primaryColor}',
1843
+ secondary: '${config.secondaryColor}',
1844
+ accent: '${config.accentColor}',
1845
+ success: '${config.successColor}',
1846
+ warning: '${config.warningColor}',
1847
+ error: '${config.errorColor}',
1848
+ },
1849
+ fontFamily: {
1850
+ sans: ['${config.fontFamily}', 'system-ui', 'sans-serif'],
1851
+ heading: ['${config.headingFont}', 'system-ui', 'sans-serif'],
1852
+ mono: ['${config.monoFont}', 'Fira Code', 'monospace'],
1853
+ },
1854
+ borderRadius: {
1855
+ DEFAULT: '${config.borderRadius}',
1856
+ },
1857
+ },
1858
+ },
1859
+ }
1860
+ \`\`\`
1861
+
1862
+ ---
1863
+
1864
+ *Last updated: ${new Date().toISOString().split("T")[0]}*
1865
+ `;
1866
+ fs.writeFileSync(brandGuidePath, brandGuideContent);
1867
+ logSuccess("Created .claude/BRAND_GUIDE.md");
1868
+ } else if (fs.existsSync(brandGuidePath)) {
1869
+ logInfo("Brand guide already exists");
1870
+ } else {
1871
+ logInfo("Skipped brand guide creation");
1872
+ }
1873
+
1874
+ // ─────────────────────────────────────────────────────────────────────────
1875
+ // Step 10: Optional Tools
1876
+ // ─────────────────────────────────────────────────────────────────────────
1877
+
1878
+ logStep(++currentStep, totalSteps, "Testing tools");
1879
+
1880
+ // Check prerequisites
1881
+ const hasPackageJson = fs.existsSync(path.join(targetDir, "package.json"));
1882
+ const hasPnpm = (() => {
1883
+ try {
1884
+ execSync("pnpm --version", { stdio: ["pipe", "pipe", "pipe"] });
1885
+ return true;
1886
+ } catch {
1887
+ return false;
500
1888
  }
1889
+ })();
1890
+
1891
+ if (!hasPackageJson) {
1892
+ log(` ${c.dim}No package.json found - skipping npm package installations${c.reset}`);
1893
+ logInfo("Run 'pnpm init' first, then re-run installer to add testing tools");
1894
+ } else if (!hasPnpm) {
1895
+ log(` ${c.dim}pnpm not found - skipping installations${c.reset}`);
1896
+ logInfo("Install pnpm: npm install -g pnpm");
1897
+ } else {
1898
+ log(` ${c.dim}Installing Playwright, Storybook, and Sandpack${c.reset}`);
1899
+
1900
+ if (config.withStorybook || config.withPlaywright || config.withSandpack) {
1901
+ if (config.withSandpack) {
1902
+ startSpinner("Installing Sandpack (live code preview)...");
1903
+ try {
1904
+ execSync("pnpm add @codesandbox/sandpack-react 2>&1", {
1905
+ cwd: targetDir,
1906
+ stdio: ["pipe", "pipe", "pipe"],
1907
+ });
1908
+ stopSpinner(true, "Sandpack installed - enables live UI previews");
1909
+ } catch (e) {
1910
+ stopSpinner(false, "Sandpack failed");
1911
+ logInfo(" Run manually: pnpm add @codesandbox/sandpack-react");
1912
+ }
1913
+ }
501
1914
 
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
- );
1915
+ if (config.withStorybook) {
1916
+ // Check if Storybook is already initialized
1917
+ const hasStorybook = fs.existsSync(path.join(targetDir, ".storybook"));
1918
+ if (hasStorybook) {
1919
+ logInfo("Storybook already configured");
1920
+ } else {
1921
+ startSpinner("Initializing Storybook (component development)...");
1922
+ try {
1923
+ execSync("pnpm dlx storybook@latest init --yes 2>&1", {
1924
+ cwd: targetDir,
1925
+ stdio: ["pipe", "pipe", "pipe"],
1926
+ timeout: 300000,
1927
+ });
1928
+ stopSpinner(true, "Storybook initialized - run 'pnpm storybook' to start");
1929
+ } catch (e) {
1930
+ stopSpinner(false, "Storybook failed");
1931
+ logInfo(" Run manually: pnpm dlx storybook@latest init");
1932
+ logInfo(" Requires: React/Vue/Angular/Svelte project");
1933
+ }
1934
+ }
1935
+ }
1936
+
1937
+ if (config.withPlaywright) {
1938
+ // Check if Playwright is already initialized
1939
+ const hasPlaywright = fs.existsSync(path.join(targetDir, "playwright.config.ts")) ||
1940
+ fs.existsSync(path.join(targetDir, "playwright.config.js"));
1941
+ if (hasPlaywright) {
1942
+ logInfo("Playwright already configured");
1943
+ } else {
1944
+ startSpinner("Initializing Playwright (E2E testing)...");
1945
+ try {
1946
+ execSync("pnpm create playwright --yes 2>&1", {
1947
+ cwd: targetDir,
1948
+ stdio: ["pipe", "pipe", "pipe"],
1949
+ timeout: 300000,
1950
+ });
1951
+ stopSpinner(true, "Playwright initialized - run 'pnpm exec playwright test' to start");
1952
+ } catch (e) {
1953
+ stopSpinner(false, "Playwright failed");
1954
+ logInfo(" Run manually: pnpm create playwright");
1955
+ }
1956
+ }
516
1957
  }
1958
+ } else {
1959
+ logInfo("None selected");
1960
+ logInfo("Run wizard again with Custom Setup to add testing tools");
517
1961
  }
518
- } else {
519
- logInfo("None selected");
520
- logInfo(
521
- "Add later with: --with-storybook --with-playwright --with-sandpack",
522
- );
523
1962
  }
524
1963
 
525
1964
  // ─────────────────────────────────────────────────────────────────────────
526
1965
  // Summary
527
1966
  // ─────────────────────────────────────────────────────────────────────────
528
1967
 
1968
+ const check = `${c.white}✓${c.reset}`;
1969
+ const cross = `${c.dim}○${c.reset}`;
1970
+
529
1971
  log(`
530
1972
  ${c.red}═══════════════════════════════════════════════════════════════${c.reset}
1973
+ ${c.red}${c.bold} HUSTLE${c.reset}
531
1974
  ${c.bold} Installation Complete${c.reset}
532
1975
  ${c.red}═══════════════════════════════════════════════════════════════${c.reset}
533
1976
 
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}
1977
+ ${c.bold}Core Components:${c.reset}
1978
+ ${check} Commands .claude/commands/ (29 slash commands)
1979
+ ${check} Hooks .claude/hooks/ (enforcement hooks)
1980
+ ${check} Subagents .claude/agents/ (parallel processing)
1981
+ ${check} Config .claude/ (settings, state, registry)
1982
+
1983
+ ${c.bold}Configuration:${c.reset}
1984
+ ${config.githubToken ? check : cross} GITHUB_TOKEN ${config.githubToken ? "configured" : "not set"}
1985
+ ${config.greptileApiKey ? check : cross} GREPTILE_API_KEY ${config.greptileApiKey ? "configured" : "not set"}
1986
+ ${config.brandfetchApiKey ? check : cross} BRANDFETCH_API_KEY ${config.brandfetchApiKey ? "configured" : "not set"}
1987
+ ${config.ntfyEnabled ? check : cross} NTFY Notifications ${config.ntfyEnabled ? config.ntfyTopic : "disabled"}
1988
+ ${config.createBrandGuide ? check : cross} Brand Guide ${config.createBrandGuide ? (config.brandSource === "brandfetch" ? `from ${config.brandDomain}` : "manual") : "skipped"}
1989
+
1990
+ ${c.bold}Testing Tools:${c.reset}
1991
+ ${config.withPlaywright ? check : cross} Playwright ${config.withPlaywright ? "installed" : "not installed"}
1992
+ ${config.withStorybook ? check : cross} Storybook ${config.withStorybook ? "installed" : "not installed"}
1993
+ ${config.withSandpack ? check : cross} Sandpack ${config.withSandpack ? "installed" : "not installed"}
1994
+
1995
+ ${c.bold}Ready to Use:${c.reset}
1996
+ ${c.gray}$${c.reset} /hustle-api-create [endpoint] ${c.dim}# Build API endpoint${c.reset}
1997
+ ${c.gray}$${c.reset} /hustle-ui-create [component] ${c.dim}# Build component${c.reset}
1998
+ ${c.gray}$${c.reset} /hustle-ui-create-page [page] ${c.dim}# Build page${c.reset}
1999
+ ${c.gray}$${c.reset} /hustle-combine [apis] ${c.dim}# Orchestrate APIs${c.reset}
546
2000
 
547
2001
  ${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
2002
+ ${!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
2003
 
554
2004
  ${c.dim}Documentation: https://github.com/hustle-together/api-dev-tools${c.reset}
555
2005
  `);