11import fs from "node:fs"
22import path from "node:path"
3+ import { fileURLToPath } from "node:url"
34import { createRequire } from "node:module"
45import * as p from "@clack/prompts"
56
67import { agentNames } from "../../agents/index.js"
78import { detectAgent } from "../../wizard/agent-detect.js"
89import { downloadPage } from "../../download/page-downloader.js"
910import { runCommand , gitCommit } from "../../util/exec.js"
10- import { runSkillsPipelineTUI } from "../../skills/runner.js"
11+ import { runSkillsPipelineTUI , runAgentWithBuildLoop } from "../../skills/runner.js"
1112import { login } from "../login.js"
1213import { getToken } from "../../util/config.js"
1314import { detectRepoContext , type RepoType } from "./detectRepo.js"
1415
16+ type Strategy = "import" | "blank" | "prompt"
17+
1518type CreateOptions = {
1619 name ?: string
1720 url ?: string
1821 agent ?: string
1922 accessToken ?: string
2023 pm ?: string
24+ strategy ?: Strategy
25+ prompt ?: string
2126}
2227
2328/** Parses CLI args into CreateOptions */
@@ -27,16 +32,15 @@ const parseArgs = (args: string[]): CreateOptions => {
2732
2833 while ( i < args . length ) {
2934 const arg = args [ i ]
30- if ( arg === "--name" && args [ i + 1 ] ) {
31- opts . name = args [ ++ i ]
32- } else if ( arg === "--url" && args [ i + 1 ] ) {
33- opts . url = args [ ++ i ]
34- } else if ( arg === "--agent" && args [ i + 1 ] ) {
35- opts . agent = args [ ++ i ]
36- } else if ( arg === "--token" && args [ i + 1 ] ) {
37- opts . accessToken = args [ ++ i ]
38- } else if ( arg === "--pm" && args [ i + 1 ] ) {
39- opts . pm = args [ ++ i ]
35+ if ( arg === "--name" && args [ i + 1 ] ) opts . name = args [ ++ i ]
36+ else if ( arg === "--url" && args [ i + 1 ] ) opts . url = args [ ++ i ]
37+ else if ( arg === "--agent" && args [ i + 1 ] ) opts . agent = args [ ++ i ]
38+ else if ( arg === "--token" && args [ i + 1 ] ) opts . accessToken = args [ ++ i ]
39+ else if ( arg === "--pm" && args [ i + 1 ] ) opts . pm = args [ ++ i ]
40+ else if ( arg === "--prompt" && args [ i + 1 ] ) opts . prompt = args [ ++ i ]
41+ else if ( arg === "--strategy" && args [ i + 1 ] ) {
42+ const v = args [ ++ i ]
43+ if ( v === "import" || v === "blank" || v === "prompt" ) opts . strategy = v
4044 }
4145 i ++
4246 }
@@ -49,7 +53,7 @@ const slugify = (text: string) =>
4953 text
5054 . toString ( )
5155 . normalize ( "NFD" )
52- . replace ( / [ \u0300 - \u036f ] / g, "" )
56+ . replace ( / [ ̀ - ͯ ] / g, "" )
5357 . toLowerCase ( )
5458 . trim ( )
5559 . replace ( / \s + / g, "-" )
@@ -157,6 +161,71 @@ const convertToMultiGame = (repoRoot: string): string => {
157161 return existingName
158162}
159163
164+ /** Resolves the bundled template directory by name. Templates ship at packages/cli/templates/<name>/ */
165+ const resolveTemplateDir = ( name : string ) : string => {
166+ const here = fileURLToPath ( import . meta. url )
167+ // here = .../packages/cli/lib/commands/game/create.js → ../../../templates/<name>
168+ return path . resolve ( here , ".." , ".." , ".." , ".." , "templates" , name )
169+ }
170+
171+ /** File extensions where we apply template variable substitution */
172+ const textExtension = new Set ( [ ".md" , ".json" , ".html" , ".css" , ".ts" , ".tsx" , ".js" , ".jsx" , ".mjs" ] )
173+
174+ /** Recursively copies a template directory, substituting placeholders in text files. */
175+ const copyTemplate = ( sourceDir : string , targetDir : string , replacements : Record < string , string > ) => {
176+ fs . mkdirSync ( targetDir , { recursive : true } )
177+ for ( const entry of fs . readdirSync ( sourceDir , { withFileTypes : true } ) ) {
178+ const src = path . join ( sourceDir , entry . name )
179+ const dst = path . join ( targetDir , entry . name )
180+ if ( entry . isDirectory ( ) ) {
181+ copyTemplate ( src , dst , replacements )
182+ } else if ( entry . isFile ( ) ) {
183+ const ext = path . extname ( entry . name )
184+ if ( textExtension . has ( ext ) || entry . name === ".gitignore" ) {
185+ let content = fs . readFileSync ( src , "utf-8" )
186+ for ( const [ key , value ] of Object . entries ( replacements ) ) content = content . replaceAll ( key , value )
187+ fs . writeFileSync ( dst , content )
188+ } else {
189+ fs . copyFileSync ( src , dst )
190+ }
191+ }
192+ }
193+ }
194+
195+ /** Picks an agent interactively, returning the agent id or "none" */
196+ const pickAgent = async ( preselected ?: string ) : Promise < string > => {
197+ const supported = new Set ( agentNames ( ) )
198+ const agents = detectAgent ( ) . filter ( ( a ) => supported . has ( a . id ) )
199+ if ( preselected ) return preselected
200+ if ( agents . length === 0 ) {
201+ p . log . info ( `No supported LLM agents detected (checked: ${ agentNames ( ) . join ( ", " ) } )` )
202+ return "none"
203+ }
204+ const choices = [
205+ ...agents . map ( ( a ) => ( { value : a . id , label : `${ a . displayName } (${ a . path } )` } ) ) ,
206+ { value : "none" , label : "None - I'll run the steps manually" } ,
207+ ]
208+ const result = ( await p . select ( { message : "Which LLM agent do you use?" , options : choices } ) ) as string
209+ if ( p . isCancel ( result ) ) process . exit ( 0 )
210+ return result
211+ }
212+
213+ /** Composes the prompt sent to the agent for the "from prompt" strategy */
214+ const buildPromptStrategyMessage = ( userPrompt : string , displayName : string ) : string =>
215+ [
216+ `You are scaffolding a Puzzmo game called "${ displayName } " in the current directory.` ,
217+ `The starter is a working Vite + Puzzmo SDK Minesweeper. Replace the gameplay with the game described below.` ,
218+ `` ,
219+ `Game description:` ,
220+ userPrompt ,
221+ `` ,
222+ `Constraints:` ,
223+ `- Keep puzzmo.json valid (do not remove the slug or displayName fields).` ,
224+ `- Use the Puzzmo SDK lifecycle: gameReady → gameLoaded → on("start"|"retry") → updateGameState → gameCompleted.` ,
225+ `- Replace fixtures in fixtures/puzzles/ with puzzles for the new game.` ,
226+ `- Keep the build green (\`npx vite build\` should succeed).` ,
227+ ] . join ( "\n" )
228+
160229/** Main game create wizard */
161230export const gameCreate = async ( args : string [ ] ) => {
162231 const opts = parseArgs ( args )
@@ -165,56 +234,105 @@ export const gameCreate = async (args: string[]) => {
165234 const { version } = require ( "../../../package.json" )
166235 p . intro ( `Puzzmo Game Creator v${ version } ` )
167236
168- // Step 1: Detect repo context and choose mode
237+ // Step 1: Pick strategy
238+ let strategy : Strategy
239+ if ( opts . strategy ) {
240+ strategy = opts . strategy
241+ } else {
242+ const choice = await p . select ( {
243+ message : "How would you like to create your game?" ,
244+ options : [
245+ { value : "blank" as const , label : "Blank game (Minesweeper template)" } ,
246+ { value : "import" as const , label : "Import from an existing URL (uses an LLM to migrate)" } ,
247+ { value : "prompt" as const , label : "From a prompt (Builds on template + LLM customizations)" } ,
248+ ] ,
249+ } )
250+ if ( p . isCancel ( choice ) ) process . exit ( 0 )
251+ strategy = choice as Strategy
252+ }
253+
254+ // Step 2: Detect repo and pick placement
169255 const repo = detectRepoContext ( )
170256 const canAddToRepo = repo . repoType === "multi-game" || repo . repoType === "workspace-monorepo" || repo . repoType === "standalone"
171257
172- const modeOptions = [
258+ const placementOptions = [
173259 { value : "new-repo" as const , label : "Create game in a new repo" } ,
174260 ...( canAddToRepo ? [ { value : "add-to-repo" as const , label : "Add a game to this repo" } ] : [ ] ) ,
175261 ]
176- if ( canAddToRepo && repo . repoType !== "none" ) modeOptions . reverse ( )
262+ if ( canAddToRepo && repo . repoType !== "none" ) placementOptions . reverse ( )
177263
178- const mode = await p . select ( {
179- message : "How would you like to create your game?" ,
180- options : modeOptions ,
181- } )
182-
183- if ( p . isCancel ( mode ) ) process . exit ( 0 )
184-
185- // Step 2: Source URL
186- let url = opts . url
187- if ( ! url ) {
188- url = ( await p . text ( { message : "Source URL to import" , validate : ( v ) => ( ! v ? "URL is required" : undefined ) } ) ) as string
189- if ( p . isCancel ( url ) ) process . exit ( 0 )
264+ let mode : "new-repo" | "add-to-repo"
265+ if ( placementOptions . length === 1 ) {
266+ mode = placementOptions [ 0 ] . value
267+ } else {
268+ const selected = await p . select ( { message : "Where should the game live?" , options : placementOptions } )
269+ if ( p . isCancel ( selected ) ) process . exit ( 0 )
270+ mode = selected as "new-repo" | "add-to-repo"
190271 }
191272
192- // Step 3: Download page to a temp dir, extract title
273+ // Step 3: Acquire source content into tmpDir
193274 const tmpDir = path . resolve ( ".puzzmo-import-tmp" )
194275 if ( fs . existsSync ( tmpDir ) ) fs . rmSync ( tmpDir , { recursive : true } )
195- const s = p . spinner ( )
196- s . start ( `Downloading ${ url } ` )
197- const { title } = await downloadPage ( url , tmpDir )
198- s . stop ( "Download complete." )
276+
277+ let importedTitle : string | undefined
278+ let userPrompt : string | undefined
279+
280+ if ( strategy === "import" ) {
281+ let url = opts . url
282+ if ( ! url ) {
283+ url = ( await p . text ( { message : "Source URL to import" , validate : ( v ) => ( ! v ? "URL is required" : undefined ) } ) ) as string
284+ if ( p . isCancel ( url ) ) process . exit ( 0 )
285+ }
286+ const s = p . spinner ( )
287+ s . start ( `Downloading ${ url } ` )
288+ const { title } = await downloadPage ( url , tmpDir )
289+ s . stop ( "Download complete." )
290+ importedTitle = title
291+ } else if ( strategy === "prompt" ) {
292+ userPrompt = opts . prompt
293+ if ( ! userPrompt ) {
294+ userPrompt = ( await p . text ( {
295+ message : "Describe the game you want to build" ,
296+ placeholder : "A word search where letters appear in waves..." ,
297+ validate : ( v ) => ( ! v ? "Prompt is required" : undefined ) ,
298+ } ) ) as string
299+ if ( p . isCancel ( userPrompt ) ) process . exit ( 0 )
300+ }
301+ }
199302
200303 // Step 4: Game name
201- const defaultName = opts . name || title
202- const name = ( await p . text ( {
203- message : "Game name" ,
204- initialValue : defaultName ,
205- validate : ( v ) => ( ! v ? "Game name is required" : undefined ) ,
206- } ) ) as string
207- if ( p . isCancel ( name ) ) process . exit ( 0 )
304+ let name : string
305+ if ( opts . name ) {
306+ name = opts . name
307+ } else {
308+ const result = ( await p . text ( {
309+ message : "Game name" ,
310+ initialValue : importedTitle || "My Game" ,
311+ validate : ( v ) => ( ! v ? "Game name is required" : undefined ) ,
312+ } ) ) as string
313+ if ( p . isCancel ( result ) ) process . exit ( 0 )
314+ name = result
315+ }
208316
209317 const slug = slugify ( name )
210318
319+ // Materialize template content for blank/prompt strategies (after we know the slug/name)
320+ if ( strategy !== "import" ) {
321+ const templateDir = resolveTemplateDir ( "minesweeper" )
322+ if ( ! fs . existsSync ( templateDir ) ) {
323+ p . log . error ( `Bundled template not found at ${ templateDir } ` )
324+ process . exit ( 1 )
325+ }
326+ copyTemplate ( templateDir , tmpDir , { __SLUG__ : slug , __DISPLAY_NAME__ : name } )
327+ }
328+
211329 // Step 5: Login if token provided
212330 if ( opts . accessToken ) {
213331 p . log . step ( "Logging in..." )
214332 login ( opts . accessToken )
215333 }
216334
217- // Step 6: Diamond — set up repo or add to existing
335+ // Step 6: Place files
218336 let gameDir : string
219337 let repoType : RepoType = repo . repoType
220338
@@ -230,14 +348,12 @@ export const gameCreate = async (args: string[]) => {
230348 process . exit ( 1 )
231349 }
232350
233- // Standalone repo → convert to multi-game first
234351 if ( repo . repoType === "standalone" ) {
235352 const existingGame = convertToMultiGame ( repo . repoRoot )
236353 p . log . step ( `Moved existing game to games/${ existingGame } /` )
237354 repoType = "multi-game"
238355 }
239356
240- // Determine which folder to place the game in
241357 let parentFolder : string
242358 if ( repoType === "multi-game" ) {
243359 parentFolder = "games"
@@ -261,38 +377,34 @@ export const gameCreate = async (args: string[]) => {
261377 gameDir = setupRepoGame ( tmpDir , slug , repo . repoRoot , parentFolder )
262378 }
263379
264- // Step 7: Detect agent and run skills pipeline. Only offer CLIs we have an adapter for.
265- const supported = new Set ( agentNames ( ) )
266- const agents = detectAgent ( ) . filter ( ( a ) => supported . has ( a . id ) )
267- const agentChoices = [
268- ...agents . map ( ( a ) => ( { value : a . id , label : `${ a . displayName } (${ a . path } )` } ) ) ,
269- { value : "none" , label : "None - I'll run the steps manually" } ,
270- ]
271-
272- let selectedAgent : string
273- if ( opts . agent ) {
274- selectedAgent = opts . agent
275- } else if ( agents . length > 0 ) {
276- selectedAgent = ( await p . select ( { message : "Which LLM agent do you use?" , options : agentChoices } ) ) as string
277- if ( p . isCancel ( selectedAgent ) ) process . exit ( 0 )
278- } else {
279- p . log . info ( `No supported LLM agents detected (checked: ${ agentNames ( ) . join ( ", " ) } )` )
280- selectedAgent = "none"
281- }
282-
283- if ( selectedAgent !== "none" ) {
284- const repoContextLines = [
285- `Repo type: ${ repoType } ` ,
286- `Package manager: ${ repo . packageManager } (use this instead of npm/npx when running commands or adding dependencies)` ,
287- ]
288- if ( repoType === "multi-game" )
289- repoContextLines . push (
290- "This is a multi-game repo with a shared root package.json. Do not create a per-game package.json or vite config." ,
291- )
292- if ( repoType === "workspace-monorepo" ) repoContextLines . push ( "This is a workspace monorepo. The game has its own package.json." )
293-
294- p . log . step ( "Running Puzzmo migration pipeline..." )
295- await runSkillsPipelineTUI ( selectedAgent , gameDir , repoContextLines . join ( "\n" ) )
380+ // Step 7: Strategy-specific agent work
381+ if ( strategy === "import" ) {
382+ const selectedAgent = await pickAgent ( opts . agent )
383+ if ( selectedAgent !== "none" ) {
384+ const repoContextLines = [
385+ `Repo type: ${ repoType } ` ,
386+ `Package manager: ${ repo . packageManager } (use this instead of npm/npx when running commands or adding dependencies)` ,
387+ ]
388+ if ( repoType === "multi-game" )
389+ repoContextLines . push (
390+ "This is a multi-game repo with a shared root package.json. Do not create a per-game package.json or vite config." ,
391+ )
392+ if ( repoType === "workspace-monorepo" ) repoContextLines . push ( "This is a workspace monorepo. The game has its own package.json." )
393+
394+ p . log . step ( "Running Puzzmo migration pipeline..." )
395+ await runSkillsPipelineTUI ( selectedAgent , gameDir , repoContextLines . join ( "\n" ) )
396+ }
397+ } else if ( strategy === "prompt" ) {
398+ const selectedAgent = await pickAgent ( opts . agent )
399+ const message = buildPromptStrategyMessage ( userPrompt ! , name )
400+ if ( selectedAgent === "none" ) {
401+ p . log . warn ( "No agent selected; skipping LLM customization." )
402+ p . note ( message , "Paste this prompt into your LLM agent:" )
403+ } else {
404+ p . log . step ( "Running agent with your prompt..." )
405+ const ok = runAgentWithBuildLoop ( selectedAgent , message , gameDir , "from-prompt" )
406+ if ( ! ok ) p . log . warn ( "Agent step did not complete cleanly. The Minesweeper starter is still in place." )
407+ }
296408 }
297409
298410 // Done
0 commit comments