Skip to content

Commit 9902ebb

Browse files
Sync packages from monorepo (28d1253)
1 parent 8c89e1c commit 9902ebb

7 files changed

Lines changed: 145 additions & 93 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as p from "@clack/prompts"
2+
3+
import { listMcpPrompts } from "../skills/mcp-client.js"
4+
5+
/** Lists available migration skills from the MCP server */
6+
export const migrate = async () => {
7+
p.intro("Puzzmo Migration Skills")
8+
9+
const gameDir = process.cwd()
10+
11+
const s = p.spinner()
12+
s.start("Fetching available skills from MCP server...")
13+
const prompts = await listMcpPrompts(gameDir)
14+
s.stop("Done.")
15+
16+
if (!prompts || prompts.length === 0) {
17+
p.log.error("No skills found. Make sure .mcp.json is configured in the current directory.")
18+
process.exit(1)
19+
}
20+
21+
const selected = await p.select({
22+
message: "Which skill would you like to run?",
23+
options: prompts.map((prompt) => ({
24+
value: prompt.name,
25+
label: prompt.name,
26+
hint: prompt.description,
27+
})),
28+
})
29+
30+
if (p.isCancel(selected)) process.exit(0)
31+
32+
p.log.success(`Selected: ${selected}`)
33+
p.outro(`Run this skill with your agent using the prompt name "${selected}"`)
34+
}

packages/cli/src/download/page-downloader.ts

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,51 @@ export const extractTitle = (html: string): string | undefined => {
4040
return match?.[1]?.trim() || undefined
4141
}
4242

43+
/** Download a single asset ref, returning true if successful */
44+
const downloadAsset = async (
45+
ref: string,
46+
baseURL: URL,
47+
srcDir: string,
48+
downloaded: Set<string>,
49+
content: Map<string, string>,
50+
optional: boolean,
51+
): Promise<boolean> => {
52+
if (downloaded.has(ref)) return false
53+
if (ref.startsWith("data:") || ref.startsWith("#") || ref.startsWith("javascript:")) return false
54+
55+
try {
56+
const assetURL = new URL(ref, baseURL)
57+
// Only download same-origin assets
58+
if (assetURL.origin !== baseURL.origin) return false
59+
60+
const assetPath = assetURL.pathname.replace(/^\//, "")
61+
if (!assetPath) return false
62+
const localPath = path.join(srcDir, assetPath)
63+
64+
fs.mkdirSync(path.dirname(localPath), { recursive: true })
65+
66+
const assetResponse = await fetchPage(assetURL.href)
67+
if (!assetResponse.ok) {
68+
if (optional) console.warn(` Warning: could not download optional asset ${ref} (${assetResponse.status})`)
69+
return false
70+
}
71+
72+
const ab = await assetResponse.arrayBuffer()
73+
fs.writeFileSync(localPath, new Uint8Array(ab))
74+
75+
// Cache text content for JS/CSS so we can scan them for more refs
76+
if (/\.(js|css|mjs)(\?.*)?$/i.test(assetPath)) {
77+
content.set(ref, Buffer.from(ab).toString("utf-8"))
78+
}
79+
80+
downloaded.add(ref)
81+
return true
82+
} catch {
83+
if (optional) console.warn(` Warning: failed to download optional asset ${ref}`)
84+
return false
85+
}
86+
}
87+
4388
/** Downloads an HTML page and its referenced assets into a src/ directory */
4489
export const downloadPage = async (url: string, outputDir: string): Promise<{ title?: string }> => {
4590
const srcDir = path.join(outputDir, "src")
@@ -52,36 +97,30 @@ export const downloadPage = async (url: string, outputDir: string): Promise<{ ti
5297

5398
const baseURL = new URL(url)
5499
const downloaded = new Set<string>()
100+
const assetContent = new Map<string, string>()
55101

56102
// Find referenced assets (src, href, url())
57103
const assetRefs = extractAssetRefs(html)
58104

59105
for (const ref of assetRefs) {
60-
if (downloaded.has(ref)) continue
61-
if (ref.startsWith("data:") || ref.startsWith("#") || ref.startsWith("javascript:")) continue
62-
63-
try {
64-
const assetURL = new URL(ref, baseURL)
65-
// Only download same-origin assets
66-
if (assetURL.origin !== baseURL.origin) continue
67-
68-
const assetPath = assetURL.pathname.replace(/^\//, "")
69-
if (!assetPath) continue
70-
const localPath = path.join(srcDir, assetPath)
71-
72-
fs.mkdirSync(path.dirname(localPath), { recursive: true })
73-
74-
const assetResponse = await fetchPage(assetURL.href)
75-
if (!assetResponse.ok) continue
106+
const ok = await downloadAsset(ref, baseURL, srcDir, downloaded, assetContent, false)
107+
if (ok) html = html.replaceAll(ref, new URL(ref, baseURL).pathname.replace(/^\//, ""))
108+
}
76109

77-
const ab = await assetResponse.arrayBuffer()
78-
fs.writeFileSync(localPath, new Uint8Array(ab))
110+
// Second pass: scan downloaded JS/CSS files for string literals referencing
111+
// paths with known asset extensions (images, fonts, audio, video).
112+
// These are optional — the game may still work without them.
113+
const jsAssetRefs = new Set<string>()
114+
for (const [, text] of assetContent) {
115+
for (const ref of extractAssetFileRefs(text)) {
116+
if (!downloaded.has(ref)) jsAssetRefs.add(ref)
117+
}
118+
}
79119

80-
// Rewrite reference in HTML to local path
81-
html = html.replaceAll(ref, assetPath)
82-
downloaded.add(ref)
83-
} catch {
84-
// Skip assets that fail to download
120+
if (jsAssetRefs.size > 0) {
121+
console.log(` Found ${jsAssetRefs.size} additional asset reference${jsAssetRefs.size === 1 ? "" : "s"} in JS/CSS files`)
122+
for (const ref of jsAssetRefs) {
123+
await downloadAsset(ref, baseURL, srcDir, downloaded, assetContent, true)
85124
}
86125
}
87126

@@ -112,3 +151,18 @@ const extractAssetRefs = (html: string): string[] => {
112151

113152
return [...new Set(refs)]
114153
}
154+
155+
/** Known asset file extensions to look for in JS/CSS string literals */
156+
const ASSET_EXTENSIONS = "webp|png|jpe?g|gif|svg|ico|mp3|ogg|wav|mp4|webm|woff2?|ttf|eot"
157+
158+
/** Extracts paths with known asset extensions from JS/CSS string literals */
159+
const extractAssetFileRefs = (source: string): string[] => {
160+
const refs: string[] = []
161+
162+
const regex = new RegExp(`["'\`](/[^"'\`\\s]*\\.(?:${ASSET_EXTENSIONS}))(?:\\?[^"'\`\\s]*)?["'\`]`, "gi")
163+
let match
164+
while ((match = regex.exec(source)) !== null) {
165+
if (match[1]) refs.push(match[1])
166+
}
167+
return [...new Set(refs)]
168+
}

packages/cli/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
import { login } from "./commands/login.js"
44
import { upload } from "./commands/upload.js"
55
import { gameCreate } from "./commands/game/create.js"
6+
import { migrate } from "./commands/migrate.js"
67

78
const [command, ...args] = process.argv.slice(2)
89

910
const printUsage = () => {
1011
console.log(`Usage:
1112
puzzmo login <token> Save a CLI auth token
13+
puzzmo game create [token] Create a new Puzzmo game project
14+
1215
puzzmo upload <slug> <dir> Upload game build from <dir>
13-
puzzmo game create [token] Create a new Puzzmo game project`)
16+
puzzmo migrate List and select migration skills from dev.puzzmo.com`)
1417
}
1518

1619
const run = async () => {
@@ -44,6 +47,10 @@ const run = async () => {
4447
}
4548
break
4649
}
50+
case "migrate": {
51+
await migrate()
52+
break
53+
}
4754
default:
4855
printUsage()
4956
break

packages/cli/src/skills/mcp-client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,23 @@ export const getMcpUrl = (gameDir: string): string | null => {
4848
return server?.url ?? null
4949
}
5050

51+
/** Lists all available prompts from the MCP server configured in .mcp.json */
52+
export const listMcpPrompts = async (gameDir: string): Promise<{ name: string; description?: string }[] | null> => {
53+
const config = readMcpConfig(gameDir)
54+
if (!config) return null
55+
56+
const server = Object.values(config.mcpServers)[0]
57+
if (!server) return null
58+
59+
try {
60+
const result = await mcpRequest(server.url, server.headers ?? {}, "prompts/list", {})
61+
if (!result?.prompts) return null
62+
return result.prompts.map((p: any) => ({ name: p.name, description: p.description }))
63+
} catch {
64+
return null
65+
}
66+
}
67+
5168
/** Fetches a skill prompt's content from the MCP server configured in .mcp.json */
5269
export const fetchSkillPrompt = async (skillName: string, gameDir: string): Promise<string | null> => {
5370
const config = readMcpConfig(gameDir)
Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,14 @@
1-
import { spawnSync } from "node:child_process"
2-
31
export type SkillDefinition = {
42
name: string
5-
optional: boolean
63
}
74

85
/** Ordered list of skills for the migration pipeline */
96
export const skillsPipeline: SkillDefinition[] = [
10-
{ name: "convert-to-vite", optional: false },
11-
{ name: "introduce-puzzmo-sdk", optional: false },
12-
{ name: "game-completion", optional: false },
13-
{ name: "puzzmo-theme", optional: true },
14-
{ name: "add-deeds", optional: false },
15-
{ name: "setup-augmentations", optional: false },
16-
{ name: "create-app-bundle", optional: false },
17-
{ name: "setup-deploy", optional: false },
7+
{ name: "convert-to-vite" },
8+
{ name: "introduce-puzzmo-sdk" },
9+
{ name: "game-completion" },
10+
{ name: "add-deeds" },
11+
{ name: "setup-augmentations" },
12+
{ name: "create-app-bundle" },
13+
{ name: "setup-deploy" },
1814
]
19-
20-
/** Maps our agent binary names to the skills CLI agent identifiers */
21-
const toSkillsAgent = (agent: string): string => {
22-
const map: Record<string, string> = {
23-
claude: "claude-code",
24-
gemini: "gemini-cli",
25-
}
26-
return map[agent] ?? agent
27-
}
28-
29-
/** Returns the skills directory for a given agent inside the game dir */
30-
export const agentSkillsDir = (agent: string): string => {
31-
if (agent === "claude") return ".claude/skills"
32-
if (agent === "codex") return ".codex/skills"
33-
if (agent === "gemini") return ".gemini/skills"
34-
return `.${agent}/skills`
35-
}
36-
37-
/** Installs all Puzzmo skills from the OSS repo using `npx skills add` */
38-
export const installSkills = (agent: string, gameDir: string): number => {
39-
const skillsAgent = toSkillsAgent(agent)
40-
const result = spawnSync("npx", ["skills", "add", "puzzmo-com/oss", "--agent", skillsAgent, "--skill", "*", "--copy", "-y"], {
41-
cwd: gameDir,
42-
stdio: "inherit",
43-
encoding: "utf-8",
44-
})
45-
46-
if (result.status !== 0) {
47-
console.error("Failed to install skills from puzzmo-com/oss")
48-
process.exit(1)
49-
}
50-
51-
return skillsPipeline.length
52-
}

packages/cli/src/skills/runner.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,13 @@ export const runSkillsPipeline = async (agent: string, gameDir: string) => {
4848
for (let i = 0; i < total; i++) {
4949
const skill = skillsPipeline[i]
5050
const step = i + 1
51-
console.log(`[${step}/${total}] Running skill: ${skill.name}${skill.optional ? " (optional)" : ""}`)
51+
console.log(`[${step}/${total}] Running skill: ${skill.name}`)
5252

5353
const mcpUrl = getMcpUrl(gameDir)
5454
const prompt = buildPrompt(skill.name, mcpUrl)
5555
let result = invokeAgent(agent, prompt, gameDir)
5656

5757
if (!result.success) {
58-
if (skill.optional) {
59-
console.log(` Skipped (optional skill failed)`)
60-
continue
61-
}
6258
console.log(` Agent failed, retrying...`)
6359
result = invokeAgent(agent, prompt, gameDir)
6460
if (!result.success) {
@@ -76,10 +72,6 @@ export const runSkillsPipeline = async (agent: string, gameDir: string) => {
7672

7773
const retryBuild = verifyBuild(gameDir)
7874
if (!retryBuild.success) {
79-
if (skill.optional) {
80-
console.log(` Skipped (build still failing after fix attempt)`)
81-
continue
82-
}
8375
console.error(` Build still failing after fix attempt. Stopping.`)
8476
process.exit(1)
8577
}

packages/cli/src/tui/pipeline.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class PipelineTUI {
151151
const isActive = skillIndex === this.currentStep && !this.done
152152
const icon = stateIcon[state]
153153
const colorFn = stateColor[state]
154-
const label = `${icon} ${skill.name}${skill.optional ? " *" : ""}`
154+
const label = `${icon} ${skill.name}`
155155
const styled = isActive ? bold(colorFn(label)) : colorFn(label)
156156
const rawLen = this.stripAnsi(styled).length
157157
buf += "│ " + styled + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│"
@@ -166,8 +166,6 @@ class PipelineTUI {
166166
: dim(`${completedCount}/${skillsPipeline.length} done`)
167167
const rawLen = this.stripAnsi(status).length
168168
buf += "│ " + status + " ".repeat(Math.max(0, sidebarWidth - rawLen - 3)) + "│"
169-
} else if (skillIndex === skillsPipeline.length + 2) {
170-
buf += "│ " + dim("* = optional") + " ".repeat(Math.max(0, sidebarWidth - 15)) + "│"
171169
} else {
172170
buf += "│" + " ".repeat(sidebarWidth - 2) + "│"
173171
}
@@ -417,12 +415,6 @@ class PipelineTUI {
417415
let success = await this.runAgent(prompt)
418416

419417
if (!success) {
420-
if (skill.optional) {
421-
this.writeSkillLog(skill.name)
422-
this.states[stepIndex] = "skipped"
423-
this.render()
424-
return true
425-
}
426418
this.appendOutput("\n--- Retrying ---\n")
427419
success = await this.runAgent(prompt)
428420
if (!success) {
@@ -445,12 +437,6 @@ class PipelineTUI {
445437

446438
const retry = this.runCmd("npx vite build")
447439
if (!retry.success) {
448-
if (skill.optional) {
449-
this.writeSkillLog(skill.name)
450-
this.states[stepIndex] = "skipped"
451-
this.render()
452-
return true
453-
}
454440
this.writeSkillLog(skill.name)
455441
this.states[stepIndex] = "failed"
456442
this.render()

0 commit comments

Comments
 (0)