create-skaff 0.0.5 → 0.0.7

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/skaff.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.tsx";
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "create-skaff",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Interactive Next.js project scaffolder: Tailwind, shadcn/ui, Motion, Lucide, Oxlint, Oxfmt, Ultracite, Claude and Codex config",
5
5
  "type": "module",
6
6
  "bin": {
7
- "create-skaff": "./src/index.tsx",
8
- "skaff": "./src/index.tsx"
7
+ "create-skaff": "bin/skaff.js",
8
+ "skaff": "bin/skaff.js"
9
9
  },
10
10
  "files": [
11
+ "bin",
11
12
  "src"
12
13
  ],
13
14
  "scripts": {
@@ -14,9 +14,11 @@ export function ProgressTimeline({ config, onExit }: ProgressTimelineProps) {
14
14
  });
15
15
  return (
16
16
  <box flexDirection="column">
17
- {steps.map((step) => (
18
- <StepRow key={step.id} step={step} />
19
- ))}
17
+ {steps
18
+ .filter((step) => step.status !== "pending")
19
+ .map((step) => (
20
+ <StepRow key={step.id} step={step} />
21
+ ))}
20
22
  {error ? (
21
23
  <box flexDirection="column">
22
24
  <text>
@@ -0,0 +1,50 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { useState } from "react";
3
+
4
+ export type RadioOption<T extends string> = { value: T; label: string; description?: string };
5
+
6
+ type RadioPromptProps<T extends string> = {
7
+ title: string;
8
+ options: RadioOption<T>[];
9
+ initialValue: T;
10
+ onSelect: (value: T) => void;
11
+ };
12
+
13
+ export function RadioPrompt<T extends string>({ title, options, initialValue, onSelect }: RadioPromptProps<T>) {
14
+ const [index, setIndex] = useState(
15
+ Math.max(
16
+ 0,
17
+ options.findIndex((option) => option.value === initialValue),
18
+ ),
19
+ );
20
+
21
+ useKeyboard((key) => {
22
+ if (key.name === "up" || key.name === "k") {
23
+ setIndex((current) => (current + options.length - 1) % options.length);
24
+ } else if (key.name === "down" || key.name === "j") {
25
+ setIndex((current) => (current + 1) % options.length);
26
+ } else if (key.name === "return") {
27
+ onSelect(options[index].value);
28
+ }
29
+ });
30
+
31
+ return (
32
+ <box flexDirection="column">
33
+ <text>
34
+ <span fg="#a78bfa">◆</span> <strong>{title}</strong>
35
+ </text>
36
+ {options.map((option, i) => (
37
+ <text key={option.value}>
38
+ <span fg="#a78bfa">│ </span>
39
+ {i === index ? <span fg="#4ade80">◉ </span> : <span fg="#555555">○ </span>}
40
+ <span fg={i === index ? "#ffffff" : "#888888"}>{option.label}</span>
41
+ {option.description ? <span fg="#666666">{` ${option.description}`}</span> : null}
42
+ </text>
43
+ ))}
44
+ <text>
45
+ <span fg="#a78bfa">│ </span>
46
+ <span fg="#666666">↑↓ to move · Enter to continue · Esc to go back</span>
47
+ </text>
48
+ </box>
49
+ );
50
+ }
@@ -1,16 +1,17 @@
1
1
  import { useKeyboard, useRenderer } from "@opentui/react";
2
2
  import { useState } from "react";
3
3
  import { allFeatureIds, allFeatures, type FeatureId } from "../lib/features";
4
- import type { PackageManager } from "../lib/package-manager";
4
+ import { type PackageManager, packageManagers } from "../lib/package-manager";
5
+ import { type ShadcnPreset, shadcnPresets } from "../lib/shadcn-presets";
5
6
  import type { ScaffoldConfig } from "../lib/scaffold-steps";
6
7
  import { AnsweredRow } from "./answered-row";
7
8
  import { ConfirmPrompt } from "./confirm-prompt";
8
9
  import { FeaturesPrompt } from "./features-prompt";
9
10
  import { NamePrompt } from "./name-prompt";
10
- import { PackageManagerPrompt } from "./package-manager-prompt";
11
11
  import { ProgressTimeline } from "./progress-timeline";
12
+ import { RadioPrompt } from "./radio-prompt";
12
13
 
13
- const screens = ["name", "packageManager", "features", "confirm", "progress"] as const;
14
+ const screens = ["name", "packageManager", "features", "shadcnPreset", "confirm", "progress"] as const;
14
15
 
15
16
  type Screen = (typeof screens)[number];
16
17
 
@@ -22,6 +23,7 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
22
23
  const [name, setName] = useState(initialName);
23
24
  const [packageManager, setPackageManager] = useState<PackageManager>("pnpm");
24
25
  const [features, setFeatures] = useState<FeatureId[]>(allFeatureIds);
26
+ const [shadcnPreset, setShadcnPreset] = useState<ShadcnPreset>("nova");
25
27
  const [config, setConfig] = useState<ScaffoldConfig | null>(null);
26
28
 
27
29
  const exit = () => {
@@ -32,17 +34,21 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
32
34
  const step = screens.indexOf(screen);
33
35
  const answered = (target: Screen) => step > screens.indexOf(target);
34
36
 
37
+ const usesShadcn = features.includes("shadcn");
38
+
35
39
  useKeyboard((key) => {
36
40
  if (key.name === "escape") {
37
41
  if (screen === "name") {
38
42
  exit();
43
+ } else if (screen === "confirm" && !usesShadcn) {
44
+ setScreen("features");
39
45
  } else if (screen !== "progress") {
40
46
  setScreen(screens[step - 1]);
41
47
  }
42
48
  return;
43
49
  }
44
50
  if (screen === "confirm" && key.name === "return") {
45
- setConfig({ name, packageManager, cwd, features, dryRun });
51
+ setConfig({ name, packageManager, cwd, features, shadcnPreset, dryRun });
46
52
  setScreen("progress");
47
53
  }
48
54
  });
@@ -64,6 +70,7 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
64
70
  {answered("name") ? <AnsweredRow label="Project name" value={name} /> : null}
65
71
  {answered("packageManager") ? <AnsweredRow label="Package manager" value={packageManager} /> : null}
66
72
  {answered("features") ? <AnsweredRow label="What to set up" value={featureSummary} /> : null}
73
+ {answered("shadcnPreset") && usesShadcn ? <AnsweredRow label="shadcn/ui preset" value={shadcnPreset} /> : null}
67
74
  {screen === "name" ? (
68
75
  <NamePrompt
69
76
  initialValue={name}
@@ -74,7 +81,9 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
74
81
  />
75
82
  ) : null}
76
83
  {screen === "packageManager" ? (
77
- <PackageManagerPrompt
84
+ <RadioPrompt
85
+ title="Package manager"
86
+ options={packageManagers.map((value) => ({ value, label: value }))}
78
87
  initialValue={packageManager}
79
88
  onSelect={(value) => {
80
89
  setPackageManager(value);
@@ -87,11 +96,22 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
87
96
  initialValue={features}
88
97
  onSubmit={(value) => {
89
98
  setFeatures(value);
99
+ setScreen(value.includes("shadcn") ? "shadcnPreset" : "confirm");
100
+ }}
101
+ />
102
+ ) : null}
103
+ {screen === "shadcnPreset" ? (
104
+ <RadioPrompt
105
+ title="shadcn/ui preset"
106
+ options={shadcnPresets}
107
+ initialValue={shadcnPreset}
108
+ onSelect={(value) => {
109
+ setShadcnPreset(value);
90
110
  setScreen("confirm");
91
111
  }}
92
112
  />
93
113
  ) : null}
94
- {screen === "confirm" ? <ConfirmPrompt config={{ name, packageManager, cwd, features, dryRun }} /> : null}
114
+ {screen === "confirm" ? <ConfirmPrompt config={{ name, packageManager, cwd, features, shadcnPreset, dryRun }} /> : null}
95
115
  {screen === "progress" && config ? <ProgressTimeline config={config} onExit={exit} /> : null}
96
116
  </box>
97
117
  );
package/src/index.tsx CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env bun
2
1
  import { createCliRenderer } from "@opentui/core";
3
2
  import { createRoot } from "@opentui/react";
4
3
  import { WizardApp } from "./components/wizard-app";
@@ -1,4 +1,17 @@
1
- export type FeatureId = "typography" | "shadcn" | "motion" | "lucide" | "ultracite" | "claude" | "codex";
1
+ export type FeatureId =
2
+ | "typography"
3
+ | "shadcn"
4
+ | "motion"
5
+ | "lucide"
6
+ | "hugeicons"
7
+ | "tanstackQuery"
8
+ | "ultracite"
9
+ | "claude"
10
+ | "codex"
11
+ | "skillNextExperimental"
12
+ | "skillVercelComposition"
13
+ | "skillVercelReact"
14
+ | "skillShadcn";
2
15
 
3
16
  export type Feature = { id: FeatureId; label: string };
4
17
 
@@ -9,7 +22,7 @@ export const featureGroups: FeatureGroup[] = [
9
22
  title: "Styling",
10
23
  features: [
11
24
  { id: "typography", label: "Tailwind Typography" },
12
- { id: "shadcn", label: "shadcn/ui (all components)" },
25
+ { id: "shadcn", label: "shadcn/ui (base, all components)" },
13
26
  ],
14
27
  },
15
28
  {
@@ -17,6 +30,8 @@ export const featureGroups: FeatureGroup[] = [
17
30
  features: [
18
31
  { id: "motion", label: "Motion (framer-motion)" },
19
32
  { id: "lucide", label: "Lucide icons" },
33
+ { id: "hugeicons", label: "Hugeicons (free)" },
34
+ { id: "tanstackQuery", label: "TanStack Query (provider + devtools)" },
20
35
  ],
21
36
  },
22
37
  {
@@ -30,6 +45,15 @@ export const featureGroups: FeatureGroup[] = [
30
45
  { id: "codex", label: "Codex config" },
31
46
  ],
32
47
  },
48
+ {
49
+ title: "AI skills (skills.sh)",
50
+ features: [
51
+ { id: "skillNextExperimental", label: "vercel-labs/next.js-experimental" },
52
+ { id: "skillVercelComposition", label: "vercel-composition-patterns" },
53
+ { id: "skillVercelReact", label: "vercel-react-best-practices" },
54
+ { id: "skillShadcn", label: "shadcn" },
55
+ ],
56
+ },
33
57
  ];
34
58
 
35
59
  export const allFeatures: Feature[] = featureGroups.flatMap((group) => group.features);
@@ -1,7 +1,10 @@
1
1
  import { join } from "node:path";
2
2
  import type { FeatureId } from "./features";
3
3
  import { type PackageManager, packageManagerCommands } from "./package-manager";
4
+ import type { ShadcnPreset } from "./shadcn-presets";
5
+ import { addQueryProvider } from "./utils/add-query-provider";
4
6
  import { addTypographyPlugin } from "./utils/add-typography-plugin";
7
+ import { extendGitignore } from "./utils/extend-gitignore";
5
8
  import { type CommandResult, runCommand } from "./utils/run-command";
6
9
  import { writeAgentConfig } from "./utils/write-agent-config";
7
10
 
@@ -10,6 +13,7 @@ export type ScaffoldConfig = {
10
13
  packageManager: PackageManager;
11
14
  cwd: string;
12
15
  features: FeatureId[];
16
+ shadcnPreset: ShadcnPreset;
13
17
  dryRun: boolean;
14
18
  };
15
19
 
@@ -30,83 +34,119 @@ const command = (
30
34
  run: (config, onOutput) => runCommand(build(config), inProject ? projectDir(config) : config.cwd, onOutput),
31
35
  });
32
36
 
33
- const nextStep: ScaffoldStep = {
37
+ const createNextApp = command(
38
+ ({ name, packageManager }) => [
39
+ ...packageManagerCommands[packageManager].dlx,
40
+ "create-next-app@latest",
41
+ name,
42
+ "--ts",
43
+ "--tailwind",
44
+ "--app",
45
+ "--no-src-dir",
46
+ "--import-alias",
47
+ "@/*",
48
+ "--yes",
49
+ packageManagerCommands[packageManager].createNextFlag,
50
+ ],
51
+ false,
52
+ );
53
+
54
+ const typographyInstall = command(({ packageManager }) => [
55
+ ...packageManagerCommands[packageManager].addDev,
56
+ "@tailwindcss/typography",
57
+ ]);
58
+
59
+ const nextStep = (withTypography: boolean): ScaffoldStep => ({
34
60
  id: "next",
35
- label: "Next.js + Tailwind (create-next-app)",
36
- ...command(
37
- ({ name, packageManager }) => [
38
- ...packageManagerCommands[packageManager].dlx,
39
- "create-next-app@latest",
40
- name,
41
- "--ts",
42
- "--tailwind",
43
- "--app",
44
- "--no-src-dir",
45
- "--import-alias",
46
- "@/*",
47
- "--yes",
48
- packageManagerCommands[packageManager].createNextFlag,
49
- ],
50
- false,
51
- ),
61
+ label: `Setting up Next.js + Tailwind${withTypography ? " + Typography" : ""}`,
62
+ describe: (config) =>
63
+ [
64
+ createNextApp.describe(config),
65
+ "extend .gitignore",
66
+ ...(withTypography ? [`${typographyInstall.describe(config)}, add @plugin to app/globals.css`] : []),
67
+ ].join(", then "),
68
+ run: async (config, onOutput) => {
69
+ onOutput("create-next-app");
70
+ const created = await createNextApp.run(config, onOutput);
71
+ if (!created.ok) {
72
+ return created;
73
+ }
74
+ await extendGitignore(projectDir(config));
75
+ if (!withTypography) {
76
+ return created;
77
+ }
78
+ onOutput("adding @tailwindcss/typography");
79
+ const typography = await typographyInstall.run(config, onOutput);
80
+ if (typography.ok) {
81
+ await addTypographyPlugin(projectDir(config));
82
+ }
83
+ return typography;
84
+ },
85
+ });
86
+
87
+ const shadcnInit = command(({ packageManager, shadcnPreset }) => [
88
+ ...packageManagerCommands[packageManager].dlx,
89
+ "shadcn@latest",
90
+ "init",
91
+ "-y",
92
+ "-b",
93
+ "base",
94
+ "-p",
95
+ shadcnPreset,
96
+ "--silent",
97
+ ]);
98
+
99
+ const shadcnAddAll = command(({ packageManager }) => [
100
+ ...packageManagerCommands[packageManager].dlx,
101
+ "shadcn@latest",
102
+ "add",
103
+ "--all",
104
+ "-y",
105
+ "--silent",
106
+ ]);
107
+
108
+ const shadcnStep: ScaffoldStep = {
109
+ id: "shadcn",
110
+ label: "Setting up shadcn/ui",
111
+ describe: (config) => `${shadcnInit.describe(config)}, then ${shadcnAddAll.describe(config)}`,
112
+ run: async (config, onOutput) => {
113
+ onOutput("init");
114
+ const init = await shadcnInit.run(config, onOutput);
115
+ if (!init.ok) {
116
+ return init;
117
+ }
118
+ onOutput("adding all components");
119
+ return shadcnAddAll.run(config, onOutput);
120
+ },
52
121
  };
53
122
 
54
- const typographyStep: ScaffoldStep = {
55
- id: "typography",
56
- label: "Tailwind Typography plugin",
123
+ const tanstackQueryStep: ScaffoldStep = {
124
+ id: "tanstack-query",
125
+ label: "TanStack Query",
57
126
  describe: (config) =>
58
- `$ ${[...packageManagerCommands[config.packageManager].addDev, "@tailwindcss/typography"].join(" ")}, then add @plugin to app/globals.css`,
127
+ `$ ${[...packageManagerCommands[config.packageManager].add, "@tanstack/react-query", "@tanstack/react-query-devtools"].join(" ")}, then write components/query-provider.tsx and wrap app/layout.tsx`,
59
128
  run: async (config, onOutput) => {
60
129
  const result = await runCommand(
61
- [...packageManagerCommands[config.packageManager].addDev, "@tailwindcss/typography"],
130
+ [...packageManagerCommands[config.packageManager].add, "@tanstack/react-query", "@tanstack/react-query-devtools"],
62
131
  projectDir(config),
63
132
  onOutput,
64
133
  );
65
134
  if (result.ok) {
66
- await addTypographyPlugin(projectDir(config));
135
+ await addQueryProvider(projectDir(config));
67
136
  }
68
137
  return result;
69
138
  },
70
139
  };
71
140
 
72
- const shadcnInitStep: ScaffoldStep = {
73
- id: "shadcn-init",
74
- label: "shadcn/ui init (radix, vega preset)",
75
- ...command(({ packageManager }) => [
76
- ...packageManagerCommands[packageManager].dlx,
77
- "shadcn@latest",
78
- "init",
79
- "-y",
80
- "-b",
81
- "radix",
82
- "-p",
83
- "vega",
84
- "--silent",
85
- ]),
86
- };
87
-
88
- const shadcnAllStep: ScaffoldStep = {
89
- id: "shadcn-all",
90
- label: "shadcn/ui add --all",
91
- ...command(({ packageManager }) => [
92
- ...packageManagerCommands[packageManager].dlx,
93
- "shadcn@latest",
94
- "add",
95
- "--all",
96
- "-y",
97
- "--silent",
98
- ]),
99
- };
100
-
101
141
  const librariesStep = (packages: string[]): ScaffoldStep => ({
102
142
  id: "libraries",
103
143
  label: packages.join(" + "),
104
144
  ...command(({ packageManager }) => [...packageManagerCommands[packageManager].add, ...packages]),
105
145
  });
106
146
 
107
- const ultraciteStep = (agents: FeatureId[]): ScaffoldStep => ({
147
+ const ultraciteStep = (withShadcn: boolean): ScaffoldStep => ({
108
148
  id: "ultracite",
109
- label: agents.length > 0 ? `Ultracite (Oxlint, Oxfmt, ${agents.join(" + ")} config)` : "Ultracite (Oxlint, Oxfmt)",
149
+ label: `Ultracite (Oxlint${withShadcn ? " + shadcn rules" : ""}, Oxfmt)`,
110
150
  ...command(({ packageManager }) => [
111
151
  ...packageManagerCommands[packageManager].dlx,
112
152
  "ultracite@latest",
@@ -115,9 +155,9 @@ const ultraciteStep = (agents: FeatureId[]): ScaffoldStep => ({
115
155
  packageManager,
116
156
  "--linter",
117
157
  "oxlint",
118
- ...(agents.length > 0 ? ["--agents", ...agents] : []),
119
158
  "--frameworks",
120
159
  "next",
160
+ ...(withShadcn ? ["shadcn", "--js-plugins", "@shadcn/lint"] : []),
121
161
  "--editors",
122
162
  "vscode",
123
163
  "--quiet",
@@ -127,22 +167,62 @@ const ultraciteStep = (agents: FeatureId[]): ScaffoldStep => ({
127
167
  const agentConfigStep = (agents: FeatureId[]): ScaffoldStep => ({
128
168
  id: "agents",
129
169
  label: `${agents.join(" + ")} config`,
130
- describe: () => `write ${agents.map((agent) => (agent === "claude" ? "CLAUDE.md" : "AGENTS.md")).join(" and ")}`,
170
+ describe: () => `append project rules to AGENTS.md${agents.includes("claude") ? ", ensure CLAUDE.md points at it" : ""}`,
131
171
  run: async (config) => {
132
- await writeAgentConfig(projectDir(config), agents);
172
+ await writeAgentConfig(projectDir(config), config);
133
173
  return { ok: true, output: "" };
134
174
  },
135
175
  });
136
176
 
177
+ type SkillPackage = { id: string; repo: string; skills: string[] };
178
+
179
+ const skillsStep = (pkg: SkillPackage, agents: string[]): ScaffoldStep => ({
180
+ id: `skills-${pkg.id}`,
181
+ label: `AI skills: ${pkg.repo}${pkg.skills[0] === "*" ? "" : ` (${pkg.skills.join(", ")})`}`,
182
+ ...command(({ packageManager }) => [
183
+ ...packageManagerCommands[packageManager].dlx,
184
+ "skills@latest",
185
+ "add",
186
+ pkg.repo,
187
+ "--skill",
188
+ ...pkg.skills,
189
+ "--agent",
190
+ ...agents,
191
+ "-y",
192
+ ]),
193
+ });
194
+
195
+ const skillPackages = (features: FeatureId[]): SkillPackage[] => {
196
+ const has = (id: FeatureId) => features.includes(id);
197
+ const vercel = [
198
+ ...(has("skillVercelComposition") ? ["vercel-composition-patterns"] : []),
199
+ ...(has("skillVercelReact") ? ["vercel-react-best-practices"] : []),
200
+ ];
201
+ return [
202
+ ...(has("skillNextExperimental")
203
+ ? [{ id: "next-experimental", repo: "vercel-labs/next.js-experimental", skills: ["*"] }]
204
+ : []),
205
+ ...(vercel.length > 0 ? [{ id: "vercel", repo: "vercel-labs/agent-skills", skills: vercel }] : []),
206
+ ...(has("skillShadcn") ? [{ id: "shadcn", repo: "shadcn-ui/ui", skills: ["shadcn"] }] : []),
207
+ ];
208
+ };
209
+
137
210
  export function buildScaffoldSteps(features: FeatureId[]): ScaffoldStep[] {
138
211
  const has = (id: FeatureId) => features.includes(id);
139
- const libraries = [...(has("motion") ? ["motion"] : []), ...(has("lucide") ? ["lucide-react"] : [])];
212
+ const libraries = [
213
+ ...(has("motion") ? ["motion"] : []),
214
+ ...(has("lucide") ? ["lucide-react"] : []),
215
+ ...(has("hugeicons") ? ["@hugeicons/react", "@hugeicons/core-free-icons"] : []),
216
+ ];
140
217
  const agents = features.filter((id): id is "claude" | "codex" => id === "claude" || id === "codex");
218
+ const skillAgents = agents.length > 0 ? agents.map((agent) => (agent === "claude" ? "claude-code" : "codex")) : ["claude-code", "codex"];
141
219
  return [
142
- nextStep,
143
- ...(has("typography") ? [typographyStep] : []),
144
- ...(has("shadcn") ? [shadcnInitStep, shadcnAllStep] : []),
220
+ nextStep(has("typography")),
221
+ ...(has("shadcn") ? [shadcnStep] : []),
145
222
  ...(libraries.length > 0 ? [librariesStep(libraries)] : []),
146
- ...(has("ultracite") ? [ultraciteStep(agents)] : agents.length > 0 ? [agentConfigStep(agents)] : []),
223
+ ...(has("tanstackQuery") ? [tanstackQueryStep] : []),
224
+ ...(has("ultracite") ? [ultraciteStep(has("shadcn"))] : []),
225
+ ...(agents.length > 0 ? [agentConfigStep(agents)] : []),
226
+ ...skillPackages(features).map((pkg) => skillsStep(pkg, skillAgents)),
147
227
  ];
148
228
  }
@@ -0,0 +1,12 @@
1
+ export type ShadcnPreset = "nova" | "vega" | "maia" | "lyra" | "mira" | "luma" | "sera" | "rhea";
2
+
3
+ export const shadcnPresets: { value: ShadcnPreset; label: string; description: string }[] = [
4
+ { value: "nova", label: "Nova", description: "Lucide / Geist" },
5
+ { value: "vega", label: "Vega", description: "Lucide / Inter" },
6
+ { value: "maia", label: "Maia", description: "Hugeicons / Figtree" },
7
+ { value: "lyra", label: "Lyra", description: "Phosphor / JetBrains Mono" },
8
+ { value: "mira", label: "Mira", description: "Hugeicons / Inter" },
9
+ { value: "luma", label: "Luma", description: "Lucide / Inter" },
10
+ { value: "sera", label: "Sera", description: "Lucide / Noto Sans + Playfair Display" },
11
+ { value: "rhea", label: "Rhea", description: "Lucide / Inter" },
12
+ ];
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const providerSource = `"use client";
5
+
6
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
7
+ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
8
+ import { type ReactNode, useState } from "react";
9
+
10
+ type QueryProviderProps = { children: ReactNode };
11
+
12
+ export function QueryProvider({ children }: QueryProviderProps) {
13
+ const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }));
14
+ return (
15
+ <QueryClientProvider client={client}>
16
+ {children}
17
+ <ReactQueryDevtools initialIsOpen={false} />
18
+ </QueryClientProvider>
19
+ );
20
+ }
21
+ `;
22
+
23
+ const importLine = 'import { QueryProvider } from "@/components/query-provider";';
24
+
25
+ export async function addQueryProvider(projectDir: string): Promise<void> {
26
+ await mkdir(join(projectDir, "components"), { recursive: true });
27
+ await writeFile(join(projectDir, "components", "query-provider.tsx"), providerSource);
28
+
29
+ const layoutPath = join(projectDir, "app", "layout.tsx");
30
+ const layout = await readFile(layoutPath, "utf8");
31
+ if (layout.includes(importLine)) {
32
+ return;
33
+ }
34
+ await writeFile(
35
+ layoutPath,
36
+ `${importLine}\n${layout.replace("{children}", "<QueryProvider>{children}</QueryProvider>")}`,
37
+ );
38
+ }
@@ -0,0 +1,15 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const extraSections = [["# playwright mcp", "/.playwright-mcp/"]];
5
+
6
+ export async function extendGitignore(projectDir: string): Promise<void> {
7
+ const path = join(projectDir, ".gitignore");
8
+ const current = await readFile(path, "utf8").catch(() => "");
9
+ const missing = extraSections.filter((section) => !current.includes(section[1]));
10
+ if (missing.length === 0) {
11
+ return;
12
+ }
13
+ const body = missing.map((section) => section.join("\n")).join("\n\n");
14
+ await writeFile(path, `${current.trimEnd()}\n\n${body}\n`);
15
+ }
@@ -1,19 +1,32 @@
1
- import { writeFile } from "node:fs/promises";
1
+ import { access, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type { FeatureId } from "../features";
3
+ import { packageManagerCommands } from "../package-manager";
4
+ import type { ScaffoldConfig } from "../scaffold-steps";
4
5
 
5
- const agentsMarkdown = `# Project notes for coding agents
6
+ const rulesPath = new URL("../../templates/agent-rules.md", import.meta.url);
6
7
 
7
- - Next.js App Router with TypeScript and Tailwind CSS v4.
8
- - Run \`dev\` for the local server and \`build\` before shipping.
9
- - Keep components in \`components/\`, shared helpers in \`lib/\`.
10
- `;
8
+ const exists = (path: string) =>
9
+ access(path).then(
10
+ () => true,
11
+ () => false,
12
+ );
11
13
 
12
- export async function writeAgentConfig(projectDir: string, features: FeatureId[]): Promise<void> {
13
- if (features.includes("codex")) {
14
- await writeFile(join(projectDir, "AGENTS.md"), agentsMarkdown);
14
+ export async function writeAgentConfig(projectDir: string, config: ScaffoldConfig): Promise<void> {
15
+ const { features } = config;
16
+ const rules = (await readFile(rulesPath, "utf8"))
17
+ .replaceAll("{{dlx}}", packageManagerCommands[config.packageManager].dlx.join(" "))
18
+ .replaceAll("{{shadcnStyle}}", `base-${config.shadcnPreset}`);
19
+ const agentsPath = join(projectDir, "AGENTS.md");
20
+ const claudePath = join(projectDir, "CLAUDE.md");
21
+ const wantsAgents = features.includes("codex") || features.includes("claude");
22
+
23
+ if (wantsAgents) {
24
+ const current = (await exists(agentsPath)) ? await readFile(agentsPath, "utf8") : "";
25
+ if (!current.includes(rules.trim())) {
26
+ await writeFile(agentsPath, current ? `${current.trimEnd()}\n\n${rules}` : rules);
27
+ }
15
28
  }
16
- if (features.includes("claude")) {
17
- await writeFile(join(projectDir, "CLAUDE.md"), features.includes("codex") ? "@AGENTS.md\n" : agentsMarkdown);
29
+ if (features.includes("claude") && !(await exists(claudePath))) {
30
+ await writeFile(claudePath, "@AGENTS.md\n");
18
31
  }
19
32
  }
@@ -0,0 +1,155 @@
1
+ # Project rules
2
+
3
+ ## Working style
4
+
5
+ ### Do only what was asked
6
+
7
+ Implement, confirm it works, report in a few lines, stop.
8
+
9
+ - No unrequested extras — no extra files, formats, variants, fallbacks. One ask = one deliverable.
10
+ - Verify privately (typecheck, lint, load the page). No comparison renders, previews, pixel-diffs, or scratch harnesses unless asked.
11
+ - Mechanical edits (find/replace, rename, move) get no ceremony: no pre-survey, no occurrence counting, no re-grepping to confirm. Edit tools error on failure — that's the confirmation. Investigate first only when the change depends on it, and say why.
12
+ - Don't narrate process, restate what you did, or list what you skipped.
13
+ - If the request looks wrong, say so in a sentence and ask. Never implement both options or one-while-pitching-the-other.
14
+
15
+ ### Stop investigating and write the code
16
+
17
+ Look up only what the edit can't be written without, then write it.
18
+
19
+ - Never reverse-engineer a live system: no scraping the deployed site, grepping its bundles, fetching extra pages, or diffing responses. One call to the pointed-at endpoint to see its shape is the whole budget.
20
+ - Missing detail? Pick the obvious mapping or omit the field, note it in one line, let the user decide. Don't hunt.
21
+ - A one-line-of-UI detail (label, fallback, nice-to-have) is not worth a single extra request.
22
+ - No background/parallel commands for questions the task doesn't hinge on.
23
+ - Several turns of reading without editing = off track. Write the code and ask what's unclear.
24
+
25
+ ### Check the API before building behaviour
26
+
27
+ `components/ui` wraps Base UI. Before adding/changing behaviour (open/close, hover/focus, positioning, delays, keyboard nav, typeahead, portals, animation state, form wiring), read `node_modules/@base-ui/react/docs/react/components/` and `.../handbook/` — it's almost always an existing prop, part, or data attribute. Same for Next.js: read `node_modules/next/dist/docs/` before hand-rolling navigation, caching, data loading, metadata, redirects, image/font handling. Don't trust memory of either API — read the file. Custom implementations only after docs prove the built-in can't, noted in one line.
28
+
29
+ ### Verification never writes into the repo
30
+
31
+ All check by-products (screenshots, snapshots, logs, scratch scripts, sample payloads) go in a temp dir outside the tree — never repo root, `.playwright-mcp/`, or `screenshots/`. Always pass tools an absolute path outside the repo. Delete artifacts when done (`.gitignore` is not cleanup). Only requested files remain.
32
+
33
+ ## Git
34
+
35
+ ### Never discard unrelated working tree changes
36
+
37
+ The user edits files while agents run. Uncommitted changes are real work.
38
+
39
+ - Never `git checkout`/`restore`/`reset --hard`/`stash`/`clean` on changes you didn't make. Revert your own edits line-by-line only.
40
+ - Don't "tidy" others' modifications, even if they look stale or half-finished.
41
+ - Re-read files before editing — they may have changed. Never write from a stale copy.
42
+ - Collision with someone else's change: stop and ask.
43
+ - Stage specific paths only. Never `git add -A` / `git add .`.
44
+ - Never create a branch unless asked in that request. "Commit this" = commit on the checked-out branch, including `main`.
45
+
46
+ ### Prove a reference is dead before deleting
47
+
48
+ `rg` / `rg --files` first, for any file, export, dependency, registry entry, test, or doc you're about to remove — this is the one deletion that earns a pre-survey. Remove every stale reference in the same pass.
49
+
50
+ ## Files and structure
51
+
52
+ ### One component per file
53
+
54
+ Each component gets its own file named after it (`BrandThumbnail` → `brand-thumbnail.tsx`). No second component in a file, even a small private one — split it out, export/import by name. Exception: `components/ui` keeps whatever the registry shipped.
55
+
56
+ ### Filenames are `<domain>-<role>`
57
+
58
+ Component directories are flat, so the first token is the only grouping there is. Spend it on the domain, put the role last: `brand-card`, `product-list-carousel`, `search-filter-section`. Common roles — `card`, `carousel`, `chip`, `dialog`, `filter`, `form`, `group`, `header`, `item`, `list`, `menu`, `nav`, `panel`, `selector`, `skeleton`, `table`, `thumbnail`, `tree`. Reuse one before inventing a word.
59
+
60
+ **The domain prefix is always singular**, whatever the component holds: `brand-nav` (takes `Brand[]`), `category-cloud-list`, `product-list-carousel`. Plurality is the suffix's job. One domain sorts as one contiguous block — `brands-nav` next to `brand-card` is the failure this prevents.
61
+
62
+ ### Dialogs and forms live in their own directories
63
+
64
+ A `*-dialog.tsx` file goes in `components/dialogs`, a `*-form.tsx` file in `components/forms`, imported as `@/components/dialogs/cart-quote-dialog` and `@/components/forms/address-form`. Never create either anywhere else, and move any you find. Everything else here still applies: one component per file, `<domain>-<role>` filenames, singular domain prefix.
65
+
66
+ A dialog or form that fetches splits the same way a section does, and both halves stay in the same directory. Its skeleton follows it too: `account-profile-form-skeleton` sits beside `account-profile-form` in `components/forms`.
67
+
68
+ ### A section that fetches splits in two
69
+
70
+ When the render half needs `'use client'`, or is shared by more than one section, it moves to its own file. The `section-*` parent does the `await`; the child is named for **what it renders**, never a prefix-only variation of its parent:
71
+
72
+ | parent (fetches) | child (renders) |
73
+ | --- | --- |
74
+ | `section-faq` | `faq-accordion` |
75
+ | `section-hero` | `hero-carousel` |
76
+ | `section-related-products` | `product-list-carousel` |
77
+
78
+ `section-faq.tsx` beside `faqs.tsx` is the anti-pattern: nothing in the name says which one holds the `await`.
79
+
80
+ A Suspense fallback occupying a section's band is itself a section — `section-product-about-skeleton` sorts next to `section-product-about`.
81
+
82
+ ### Custom hooks live in lib/hooks
83
+
84
+ One file per hook, named after it (`useHeaderPopup` → `lib/hooks/use-header-popup.ts`). Never define hooks elsewhere; move any found. Exception: hooks shipped with shadcn components in `components/ui`.
85
+
86
+ ### Helpers live in lib/utils
87
+
88
+ One file per helper, named after it (`cn` → `lib/utils/cn.ts`). Never define helpers elsewhere; move any found. Read `lib/utils` before writing a new one — reuse or extend beats adding. Exception: `components/ui`.
89
+
90
+ Most helpers should never be written. A helper earns its place by holding a decision — branching, a rule, a non-obvious transform, a thought-through constant. One-expression bodies get inlined. Don't write:
91
+
92
+ - Wrappers around one call (`formatPrice` = `n.toFixed(2)`, `isEmpty` = `!arr.length`).
93
+ - Aliases (another function with reordered args or a filled default).
94
+ - Single-use one-liners — put them at the call site.
95
+ - Speculative utils — write it when the second caller appears.
96
+ - What the platform/deps already do — check `Intl`, `URL`, `URLSearchParams`, array/string methods, and `package.json` libs first.
97
+
98
+ `lib/utils` is shared surface: genuinely reusable, named for what it returns, correct beyond the prompting call site. Can't name it without `handle`/`process`/`data`/`helper`/bare `format`? Don't write it. When touching code that calls a bad helper, inline it and delete the file.
99
+
100
+ ### Types live in /types
101
+
102
+ Every `type`/`interface` goes in a domain file (`Product` → `types/product.ts`); move any found elsewhere. Import via `import type { … } from '@/types/<domain>'`. Exception: `components/ui`. Inline annotations aren't declarations and stay put.
103
+
104
+ ## Types
105
+
106
+ ### No escape-hatch types
107
+
108
+ Every value has a shape — write it. Banned everywhere (declarations, annotations, generics, casts, returns): `unknown`, `any`, `Record<string, unknown|any>`, `{ [key: string]: unknown }`, `object`, `{}`, `Function`, `unknown[]`, `any[]`, and laundering casts (`data as Product`, `as unknown as`).
109
+
110
+ If the shape isn't obvious, find it: Schema, API response, library types in `node_modules`, or the producing code. Untyped boundary data gets a declared type plus a parse/narrow step. Retype anything from shadcn/registries/codegen before committing. Generic params are fine (`<T>(items: T[]) => T[]`); constrain when the body needs a property. `unknown` is allowed only in `catch`, narrowed before use.
111
+
112
+ ### Empty array / null is still a real field
113
+
114
+ Empty samples prove nothing about a field's shape. Type fields for the data they'll hold: `tierPrices: TierPrice[] | null`, never `never[]`, `[]`, `null`, or dropped.
115
+
116
+ - Don't sample record after record hoping a field fills in, or report "empty in N responses" as evidence.
117
+ - Unobserved element shape: infer from the field's name and siblings, declare in `/types`, flag as unconfirmed in one line. Mock data is never the fallback — fetch a different record.
118
+
119
+ ## UI
120
+
121
+ ### Use the registry before writing a primitive
122
+
123
+ Never handwrite a primitive. Stop at the first hit:
124
+
125
+ 1. Already in `components/ui` → use it (e.g. shadcn `Button`, not raw `<button>`).
126
+ 2. In the official shadcn registry → `{{dlx}} shadcn@latest add <component>`. Check the registry, don't trust memory — it's grown, and `components.json` (`{{shadcnStyle}}`) pulls Base UI variants. Never hand-copy source from docs or rebuild what the registry ships.
127
+ 3. Nothing fits → write it, and say in one line what you searched and why nothing worked.
128
+
129
+ ### Size comes from the size prop
130
+
131
+ Never resize a `components/ui` component with utilities — no `size-8`/`h-9`/`w-12`, no padding/text-size overrides, no inline width/height. Use the closest existing `size` variant (read the component file for the current list). Leave icons unsized — variants set them. No new variants, no widening existing ones, no overrides for in-between pixel values. `className` keeps margins, alignment, grid placement, colour.
132
+
133
+ ### Text sizes come from the scale
134
+
135
+ No arbitrary font sizes — no `text-[13px]`, `text-[0.8rem]`, no inline `fontSize`. Use the scale: `text-xs`, `text-sm`, `text-base`, `text-lg`, up. If `app/globals.css` retunes any `--text-*` token, read those values first; the matching class may not be the one its name suggests. If a size is genuinely missing, add a `--text-*` token in `globals.css`, never inline it. Applies to shadcn/registry/Figma imports.
136
+
137
+ ### Conditional classes use object style
138
+
139
+ In `cn()`, conditionals are objects keyed by class string — never `&&` or ternaries:
140
+
141
+ ```tsx
142
+ cn('flex flex-col gap-3', { 'items-center text-center': align === 'center' }, className)
143
+ ```
144
+
145
+ A ternary becomes two entries (`{ 'bg-brand': reached, 'bg-border': !reached }`) — don't lean on tailwind-merge for conflicts a condition can state. Unconditional classes stay plain strings; passed-through `className` stays last so it wins merges.
146
+
147
+ ### Underline pairs with underline-offset-4
148
+
149
+ Every `underline` gets `underline-offset-4`, including variants (`hover:underline underline-offset-4` — offset stays unprefixed). Fix anything pulled in that underlines without it.
150
+
151
+ ## Comments
152
+
153
+ ### Comments default to zero
154
+
155
+ A comment is earned only by an external fact the code can't carry: an API/service/tool quirk, an upstream-bug workaround, or why a non-obvious constant has its value. Never narrate code, label sections, restate names in JSDoc, mark changes, record refactor history, or address the reviewer. Delete commented-out code; when unsure, delete; prefer renaming over explaining. Machine directives are not comments and stay: `oxlint-disable*`, `ts-expect-error`, `AUTO-GENERATED` banners.
@@ -1,38 +0,0 @@
1
- import { useKeyboard } from "@opentui/react";
2
- import { useState } from "react";
3
- import { type PackageManager, packageManagers } from "../lib/package-manager";
4
-
5
- type PackageManagerPromptProps = { initialValue: PackageManager; onSelect: (packageManager: PackageManager) => void };
6
-
7
- export function PackageManagerPrompt({ initialValue, onSelect }: PackageManagerPromptProps) {
8
- const [index, setIndex] = useState(Math.max(0, packageManagers.indexOf(initialValue)));
9
-
10
- useKeyboard((key) => {
11
- if (key.name === "up" || key.name === "k") {
12
- setIndex((current) => (current + packageManagers.length - 1) % packageManagers.length);
13
- } else if (key.name === "down" || key.name === "j") {
14
- setIndex((current) => (current + 1) % packageManagers.length);
15
- } else if (key.name === "return") {
16
- onSelect(packageManagers[index]);
17
- }
18
- });
19
-
20
- return (
21
- <box flexDirection="column">
22
- <text>
23
- <span fg="#a78bfa">◆</span> <strong>Package manager</strong>
24
- </text>
25
- {packageManagers.map((value, i) => (
26
- <text key={value}>
27
- <span fg="#a78bfa">│ </span>
28
- {i === index ? <span fg="#4ade80">◉ </span> : <span fg="#555555">○ </span>}
29
- <span fg={i === index ? "#ffffff" : "#888888"}>{value}</span>
30
- </text>
31
- ))}
32
- <text>
33
- <span fg="#a78bfa">│ </span>
34
- <span fg="#666666">↑↓ to move · Enter to continue · Esc to go back</span>
35
- </text>
36
- </box>
37
- );
38
- }