Skip to content

Commit 2fc8fb8

Browse files
Sync packages from monorepo (7230c2c)
1 parent 2aa7c47 commit 2fc8fb8

20 files changed

Lines changed: 1069 additions & 88 deletions

File tree

packages/cli/src/commands/game/create.ts

Lines changed: 186 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
import fs from "node:fs"
22
import path from "node:path"
3+
import { fileURLToPath } from "node:url"
34
import { createRequire } from "node:module"
45
import * as p from "@clack/prompts"
56

67
import { agentNames } from "../../agents/index.js"
78
import { detectAgent } from "../../wizard/agent-detect.js"
89
import { downloadPage } from "../../download/page-downloader.js"
910
import { runCommand, gitCommit } from "../../util/exec.js"
10-
import { runSkillsPipelineTUI } from "../../skills/runner.js"
11+
import { runSkillsPipelineTUI, runAgentWithBuildLoop } from "../../skills/runner.js"
1112
import { login } from "../login.js"
1213
import { getToken } from "../../util/config.js"
1314
import { detectRepoContext, type RepoType } from "./detectRepo.js"
1415

16+
type Strategy = "import" | "blank" | "prompt"
17+
1518
type 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 */
161230
export 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

Comments
 (0)