Skip to content

Commit 8c89e1c

Browse files
Sync packages from monorepo (945232a)
1 parent ac2fe14 commit 8c89e1c

5 files changed

Lines changed: 148 additions & 18 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export const gameCreate = async (args: string[]) => {
110110
login(opts.accessToken)
111111
}
112112

113-
// Step 7: Write .mcp.json with dev server config
113+
// Step 7: Write .gitignore
114+
fs.writeFileSync(path.join(gameDir, ".gitignore"), ["node_modules", "dist", ".DS_Store", ".yarn", ".pnp.*", ".puzzmo", ""].join("\n"))
115+
116+
// Step 8: Write .mcp.json with dev server config
114117
const token = getToken()
115118
const mcpConfig = {
116119
mcpServers: {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
4+
type McpConfig = {
5+
mcpServers: Record<string, { url: string; headers?: Record<string, string> }>
6+
}
7+
8+
/** Reads the .mcp.json config from the game directory */
9+
const readMcpConfig = (gameDir: string): McpConfig | null => {
10+
const mcpPath = path.join(gameDir, ".mcp.json")
11+
if (!fs.existsSync(mcpPath)) return null
12+
try {
13+
return JSON.parse(fs.readFileSync(mcpPath, "utf-8"))
14+
} catch {
15+
return null
16+
}
17+
}
18+
19+
/** Sends a JSON-RPC request to the MCP server and parses the SSE response */
20+
const mcpRequest = async (url: string, headers: Record<string, string>, method: string, params: any = {}): Promise<any> => {
21+
const response = await fetch(url, {
22+
method: "POST",
23+
headers: {
24+
"Content-Type": "application/json",
25+
Accept: "application/json, text/event-stream",
26+
...headers,
27+
},
28+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
29+
})
30+
31+
const text = await response.text()
32+
// Parse SSE format: lines starting with "data: " contain the JSON payload
33+
for (const line of text.split("\n")) {
34+
if (!line.startsWith("data: ")) continue
35+
try {
36+
const parsed = JSON.parse(line.slice(6))
37+
if (parsed.result) return parsed.result
38+
} catch {}
39+
}
40+
return null
41+
}
42+
43+
/** Returns the MCP server URL from .mcp.json, or null if not configured */
44+
export const getMcpUrl = (gameDir: string): string | null => {
45+
const config = readMcpConfig(gameDir)
46+
if (!config) return null
47+
const server = Object.values(config.mcpServers)[0]
48+
return server?.url ?? null
49+
}
50+
51+
/** Fetches a skill prompt's content from the MCP server configured in .mcp.json */
52+
export const fetchSkillPrompt = async (skillName: string, gameDir: string): Promise<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/get", { name: skillName })
61+
if (!result?.messages?.length) return null
62+
63+
return result.messages
64+
.map((m: any) => {
65+
if (typeof m.content === "string") return m.content
66+
if (m.content?.text) return m.content.text
67+
return ""
68+
})
69+
.join("\n")
70+
} catch {
71+
return null
72+
}
73+
}

packages/cli/src/skills/runner.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,24 @@ import { spawnSync } from "node:child_process"
22

33
import { skillsPipeline } from "./registry.js"
44
import { verifyBuild, runCommand, gitCommit } from "../util/exec.js"
5+
import { getMcpUrl } from "./mcp-client.js"
56

6-
/** Builds the agent prompt for a given skill, referencing the MCP prompt */
7-
const buildPrompt = (skillName: string): string => {
8-
return `Use the MCP prompt "${skillName}" from the dev.puzzmo.com server and follow its instructions. The game source is in the current directory.`
7+
const buildPrompt = (skillName: string, mcpUrl: string | null): string => {
8+
if (!mcpUrl) return `Run the skill "${skillName}". The game source is in the current directory.`
9+
10+
return `First, fetch the skill instructions by using WebFetch to make a POST request to:
11+
${mcpUrl}
12+
13+
With headers:
14+
- Content-Type: application/json
15+
- Accept: application/json, text/event-stream
16+
17+
And body:
18+
{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"${skillName}"}}
19+
20+
The response will be in SSE format. Parse the "data:" line to get the JSON result, which contains the skill instructions in result.messages[0].content.text.
21+
22+
Then follow those instructions. The game source is in the current directory.`
923
}
1024

1125
/** Invokes an LLM agent with a prompt (plain mode, stdio inherited) */
@@ -36,7 +50,8 @@ export const runSkillsPipeline = async (agent: string, gameDir: string) => {
3650
const step = i + 1
3751
console.log(`[${step}/${total}] Running skill: ${skill.name}${skill.optional ? " (optional)" : ""}`)
3852

39-
const prompt = buildPrompt(skill.name)
53+
const mcpUrl = getMcpUrl(gameDir)
54+
const prompt = buildPrompt(skill.name, mcpUrl)
4055
let result = invokeAgent(agent, prompt, gameDir)
4156

4257
if (!result.success) {

packages/cli/src/tui/pipeline.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from "node:path"
55
import { execSync } from "node:child_process"
66

77
import { skillsPipeline } from "../skills/registry.js"
8+
import { getMcpUrl } from "../skills/mcp-client.js"
89

910
type StepState = "pending" | "running" | "success" | "failed" | "skipped"
1011
type Phase = "agent" | "build" | "commit" | "idle"
@@ -57,24 +58,42 @@ const buildAgentCmd = (agent: string, prompt: string): { cmd: string; args: stri
5758
return { cmd: agent, args: [prompt] }
5859
}
5960

60-
const buildPrompt = (skillName: string): string => {
61-
return `Use the MCP prompt "${skillName}" from the dev.puzzmo.com server and follow its instructions. The game source is in the current directory.`
61+
const buildPrompt = (skillName: string, mcpUrl: string | null): string => {
62+
if (!mcpUrl) return `Run the skill "${skillName}". The game source is in the current directory.`
63+
64+
return `First, fetch the skill instructions by using WebFetch to make a POST request to:
65+
${mcpUrl}
66+
67+
With headers:
68+
- Content-Type: application/json
69+
- Accept: application/json, text/event-stream
70+
71+
And body:
72+
{"jsonrpc":"2.0","id":1,"method":"prompts/get","params":{"name":"${skillName}"}}
73+
74+
The response will be in SSE format. Parse the "data:" line to get the JSON result, which contains the skill instructions in result.messages[0].content.text.
75+
76+
Then follow those instructions. The game source is in the current directory.`
6277
}
6378

6479
class PipelineTUI {
6580
private states: StepState[]
6681
private currentStep = 0
6782
private outputLines: string[] = []
83+
private chatLines: string[] = []
6884
private phase: Phase = "idle"
6985
private done = false
7086
private activeProc: ChildProcess | null = null
7187
private sidebarWidth = 30
88+
private logsDir: string
7289

7390
constructor(
7491
private agent: string,
7592
private gameDir: string,
7693
) {
7794
this.states = skillsPipeline.map(() => "pending")
95+
this.logsDir = path.join(gameDir, ".puzzmo", "logs")
96+
fs.mkdirSync(this.logsDir, { recursive: true })
7897
}
7998

8099
private get rows(): number {
@@ -205,13 +224,13 @@ class PipelineTUI {
205224
for (const line of rawLines) {
206225
const parsed = this.parseStreamLine(line)
207226
if (parsed) {
208-
// Split multi-line parsed output into separate display lines
209227
for (const displayLine of parsed.split("\n")) {
210228
this.outputLines.push(displayLine)
229+
this.chatLines.push(this.stripAnsi(displayLine))
211230
}
212231
}
213232
}
214-
// Keep buffer bounded
233+
// Keep display buffer bounded, but chatLines grows unbounded per skill
215234
if (this.outputLines.length > 500) {
216235
this.outputLines = this.outputLines.slice(-this.maxOutputLines)
217236
}
@@ -389,21 +408,25 @@ class PipelineTUI {
389408
this.states[stepIndex] = "running"
390409
this.currentStep = stepIndex
391410
this.outputLines = []
411+
this.chatLines = []
392412
this.phase = "agent"
393413
this.render()
394414

395-
const prompt = buildPrompt(skill.name)
415+
const mcpUrl = getMcpUrl(this.gameDir)
416+
const prompt = buildPrompt(skill.name, mcpUrl)
396417
let success = await this.runAgent(prompt)
397418

398419
if (!success) {
399420
if (skill.optional) {
421+
this.writeSkillLog(skill.name)
400422
this.states[stepIndex] = "skipped"
401423
this.render()
402424
return true
403425
}
404426
this.appendOutput("\n--- Retrying ---\n")
405427
success = await this.runAgent(prompt)
406428
if (!success) {
429+
this.writeSkillLog(skill.name)
407430
this.states[stepIndex] = "failed"
408431
this.render()
409432
return false
@@ -423,10 +446,12 @@ class PipelineTUI {
423446
const retry = this.runCmd("npx vite build")
424447
if (!retry.success) {
425448
if (skill.optional) {
449+
this.writeSkillLog(skill.name)
426450
this.states[stepIndex] = "skipped"
427451
this.render()
428452
return true
429453
}
454+
this.writeSkillLog(skill.name)
430455
this.states[stepIndex] = "failed"
431456
this.render()
432457
return false
@@ -441,11 +466,20 @@ class PipelineTUI {
441466
if (commitResult.success) this.appendOutput("Committed.")
442467
else this.appendOutput("No changes to commit.")
443468

469+
// Write chat log for this skill
470+
this.writeSkillLog(skill.name)
471+
444472
this.states[stepIndex] = "success"
445473
this.render()
446474
return true
447475
}
448476

477+
/** Writes the collected chat lines for a skill to a log file */
478+
private writeSkillLog(skillName: string) {
479+
const logPath = path.join(this.logsDir, `${skillName}.txt`)
480+
fs.writeFileSync(logPath, this.chatLines.join("\n") + "\n")
481+
}
482+
449483
/** Set up keyboard handling (Ctrl+C to quit) */
450484
private setupInput() {
451485
if (process.stdin.isTTY) {

skills/introduce-puzzmo-sdk/SKILL.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,31 @@ Add the `@puzzmo/sdk` package and wire up the game lifecycle.
4343
sdk.gameLoaded()
4444
```
4545

46-
4. Wire up lifecycle events:
46+
4. Handle the `start` event to trigger the game start:
4747

4848
```ts
4949
sdk.on("start", () => {
50-
/* start game logic, timer auto-starts */
50+
startGame()
5151
})
52+
```
53+
54+
5. Wire up other lifecycle events if there is relevant game logic for pause/resume/retry:
55+
56+
```ts
5257
sdk.on("pause", () => {
53-
/* pause game was triggered, timer auto-pauses */
58+
/* pause game was triggered, system timer auto-pauses */
5459
})
5560
sdk.on("resume", () => {
56-
/* resume game was triggered, timer auto-resumes */
61+
/* resume game was triggered, system timer auto-resumes */
5762
})
5863
sdk.on("retry", () => {
59-
/* retry game was triggered, timer auto-resets, */
64+
/* retry game was triggered, system timer auto-resets, */
6065
})
6166
```
6267

63-
5. Wire up state saving - call `sdk.updateGameState(stateString)` whenever the game state changes so the host can save progress.
68+
6. Wire up state saving - call `sdk.updateGameState(stateString)` whenever the game state changes so the host can save progress.
6469

65-
6. Create puzzle fixture files for local testing. Make a `fixtures/puzzles/` directory and add a few different puzzle JSON files that exercise different aspects of the game:
70+
7. Create puzzle fixture files for local testing. Make a `fixtures/puzzles/` directory and add a few different puzzle JSON files that exercise different aspects of the game:
6671

6772
```
6873
fixtures/puzzles/
@@ -78,7 +83,7 @@ Add the `@puzzmo/sdk` package and wire up the game lifecycle.
7883

7984
If the original game already has a few puzzles hardcoded, use those as a starting point for creating the fixture files. Then delete the buttons which may have been used to load them, since the simulator will handle loading puzzles from the fixtures.
8085

81-
7. Set up the dev simulator for local testing. Add the Vite plugin to `vite.config.ts`:
86+
8. Set up the dev simulator for local testing. Add the Vite plugin to `vite.config.ts`:
8287

8388
```ts
8489
import { defineConfig } from "vite"

0 commit comments

Comments
 (0)