@puzzmo/cli 1.0.32 → 1.0.34

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.
Files changed (58) hide show
  1. package/lib/agents/claude.d.ts +5 -0
  2. package/lib/agents/claude.js +69 -0
  3. package/lib/agents/claude.js.map +1 -0
  4. package/lib/agents/codex.d.ts +4 -0
  5. package/lib/agents/codex.js +90 -0
  6. package/lib/agents/codex.js.map +1 -0
  7. package/lib/agents/copilot.d.ts +4 -0
  8. package/lib/agents/copilot.js +123 -0
  9. package/lib/agents/copilot.js.map +1 -0
  10. package/lib/agents/gemini.d.ts +5 -0
  11. package/lib/agents/gemini.js +69 -0
  12. package/lib/agents/gemini.js.map +1 -0
  13. package/lib/agents/index.d.ts +5 -0
  14. package/lib/agents/index.js +15 -0
  15. package/lib/agents/index.js.map +1 -0
  16. package/lib/agents/opencode.d.ts +5 -0
  17. package/lib/agents/opencode.js +159 -0
  18. package/lib/agents/opencode.js.map +1 -0
  19. package/lib/agents/stream-json-cli.d.ts +12 -0
  20. package/lib/agents/stream-json-cli.js +75 -0
  21. package/lib/agents/stream-json-cli.js.map +1 -0
  22. package/lib/agents/types.d.ts +37 -0
  23. package/lib/agents/types.js +2 -0
  24. package/lib/agents/types.js.map +1 -0
  25. package/lib/commands/agent-test.d.ts +3 -0
  26. package/lib/commands/agent-test.js +79 -0
  27. package/lib/commands/agent-test.js.map +1 -0
  28. package/lib/commands/game/create.js +6 -4
  29. package/lib/commands/game/create.js.map +1 -1
  30. package/lib/commands/upload.d.ts +5 -1
  31. package/lib/commands/upload.js +41 -40
  32. package/lib/commands/upload.js.map +1 -1
  33. package/lib/index.js +10 -4
  34. package/lib/index.js.map +1 -1
  35. package/lib/tui/pipeline.d.ts +1 -2
  36. package/lib/tui/pipeline.js +160 -267
  37. package/lib/tui/pipeline.js.map +1 -1
  38. package/lib/util/api.d.ts +5 -1
  39. package/lib/util/api.js +29 -16
  40. package/lib/util/api.js.map +1 -1
  41. package/lib/util/validatePuzzmoFile.js +31 -6
  42. package/lib/util/validatePuzzmoFile.js.map +1 -1
  43. package/package.json +4 -1
  44. package/src/agents/claude.ts +72 -0
  45. package/src/agents/codex.ts +82 -0
  46. package/src/agents/copilot.ts +118 -0
  47. package/src/agents/gemini.ts +74 -0
  48. package/src/agents/index.ts +20 -0
  49. package/src/agents/opencode.ts +154 -0
  50. package/src/agents/stream-json-cli.ts +86 -0
  51. package/src/agents/types.ts +22 -0
  52. package/src/commands/agent-test.ts +81 -0
  53. package/src/commands/game/create.ts +6 -4
  54. package/src/commands/upload.ts +53 -39
  55. package/src/index.ts +10 -4
  56. package/src/tui/pipeline.ts +151 -268
  57. package/src/util/api.ts +29 -13
  58. package/src/util/validatePuzzmoFile.ts +30 -7
@@ -0,0 +1,74 @@
1
+ import { runStreamJsonCli } from "./stream-json-cli.js"
2
+ import type { Agent, AgentEvent } from "./types.js"
3
+
4
+ /** Gemini adapter — wraps the local `gemini` CLI in subprocess + stream-json
5
+ * mode. Auth delegates to the user's `gemini` CLI login (Google account /
6
+ * Code Assist) or falls back to GEMINI_API_KEY. */
7
+ export const geminiAgent: Agent = {
8
+ name: "gemini",
9
+ run: (input) =>
10
+ runStreamJsonCli(
11
+ {
12
+ cmd: "gemini",
13
+ buildArgs: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
14
+ parseLine: parseGeminiLine,
15
+ },
16
+ input,
17
+ ),
18
+ }
19
+
20
+ /** Map one stream-json line from gemini to an AgentEvent (or null to skip).
21
+ * Event union per `@google/gemini-cli-core`'s `JsonStreamEvent`:
22
+ * init / message / tool_use / tool_result / error / result. */
23
+ const parseGeminiLine = (line: string): AgentEvent | null => {
24
+ let obj: any
25
+ try {
26
+ obj = JSON.parse(line)
27
+ } catch {
28
+ return { type: "system", text: line }
29
+ }
30
+
31
+ if (obj.type === "init") return { type: "system", text: "Session started" }
32
+
33
+ if (obj.type === "message") {
34
+ // Streaming deltas flow inline; complete `content` chunks get a trailing newline
35
+ // so consecutive messages don't glue together in the rendered output.
36
+ if (typeof obj.delta === "string") {
37
+ return obj.role === "thinking" ? { type: "thinking", text: obj.delta } : { type: "text", text: obj.delta }
38
+ }
39
+ const text = obj.content ?? ""
40
+ if (!text) return null
41
+ const withNL = text.endsWith("\n") ? text : text + "\n"
42
+ return obj.role === "thinking" ? { type: "thinking", text: withNL } : { type: "text", text: withNL }
43
+ }
44
+
45
+ if (obj.type === "tool_use") {
46
+ const name = obj.tool_name ?? "tool"
47
+ return { type: "tool_use", name, summary: summarizeToolInput(name, obj.parameters ?? {}) }
48
+ }
49
+
50
+ if (obj.type === "tool_result") {
51
+ const ok = obj.status === "success" || obj.status === "ok"
52
+ const summary = String(obj.output ?? obj.error ?? "").slice(0, 200)
53
+ return { type: "tool_result", ok, summary }
54
+ }
55
+
56
+ if (obj.type === "error") return { type: "error", message: obj.message ?? "" }
57
+
58
+ if (obj.type === "result") {
59
+ const ok = obj.status === "success" || obj.status === "ok"
60
+ const cost = typeof obj.stats?.cost_usd === "number" ? obj.stats.cost_usd : undefined
61
+ return { type: "result", ok, costUSD: cost }
62
+ }
63
+
64
+ return null
65
+ }
66
+
67
+ const summarizeToolInput = (name: string, input: any): string => {
68
+ if (!input || typeof input !== "object") return ""
69
+ if (typeof input.command === "string") return input.command.slice(0, 200)
70
+ if (typeof input.file_path === "string") return input.file_path
71
+ if (typeof input.path === "string") return input.path
72
+ if (typeof input.pattern === "string") return input.pattern
73
+ return name
74
+ }
@@ -0,0 +1,20 @@
1
+ import { claudeAgent } from "./claude.js"
2
+ import { codexAgent } from "./codex.js"
3
+ import { copilotAgent } from "./copilot.js"
4
+ import { geminiAgent } from "./gemini.js"
5
+ import { opencodeAgent } from "./opencode.js"
6
+ import type { Agent } from "./types.js"
7
+
8
+ export type { Agent, AgentEvent, RunInput } from "./types.js"
9
+
10
+ export const AGENTS: Record<string, Agent> = {
11
+ claude: claudeAgent,
12
+ codex: codexAgent,
13
+ copilot: copilotAgent,
14
+ gemini: geminiAgent,
15
+ opencode: opencodeAgent,
16
+ }
17
+
18
+ export const getAgent = (name: string): Agent | undefined => AGENTS[name]
19
+
20
+ export const agentNames = (): string[] => Object.keys(AGENTS)
@@ -0,0 +1,154 @@
1
+ import { createOpencodeClient, createOpencodeServer } from "@opencode-ai/sdk"
2
+
3
+ import type { Agent, AgentEvent, RunInput } from "./types.js"
4
+
5
+ /** OpenCode adapter — spawns a local opencode server, opens an SSE stream
6
+ * against it, creates a session, and sends a prompt. Auth lives in the
7
+ * user's `opencode` CLI config / `client.auth.set()`. */
8
+ export const opencodeAgent: Agent = {
9
+ name: "opencode",
10
+ run(input) {
11
+ return runOpencode(input)
12
+ },
13
+ }
14
+
15
+ const runOpencode = async function* (input: RunInput): AsyncIterable<AgentEvent> {
16
+ const cwd = input.cwd ?? process.cwd()
17
+
18
+ let server
19
+ try {
20
+ server = await createOpencodeServer({ signal: input.signal, config: { project: cwd } as any })
21
+ } catch (e: any) {
22
+ yield { type: "error", message: `Opencode server failed to start: ${e.message}` }
23
+ return
24
+ }
25
+
26
+ const client = createOpencodeClient({ baseUrl: server.url as `${string}://${string}` })
27
+
28
+ // Buffered event queue + waiter, so the SSE consumer pushes and the generator yields.
29
+ const events: AgentEvent[] = []
30
+ let waiting: (() => void) | null = null
31
+ const wake = () => {
32
+ waiting?.()
33
+ waiting = null
34
+ }
35
+ const push = (e: AgentEvent) => {
36
+ events.push(e)
37
+ wake()
38
+ }
39
+
40
+ let done = false
41
+ let sessionID: string | null = null
42
+
43
+ // Start SSE before sending a prompt so we don't lose early events.
44
+ const sseTask = (async () => {
45
+ try {
46
+ const sub = await client.event.subscribe()
47
+ for await (const event of sub.stream as AsyncIterable<any>) {
48
+ if (event.type === "session.idle" && event.properties?.sessionID && event.properties.sessionID === sessionID) {
49
+ push({ type: "result", ok: true })
50
+ done = true
51
+ wake()
52
+ return
53
+ }
54
+ const mapped = mapOpencodeEvent(event, sessionID)
55
+ for (const m of mapped) push(m)
56
+ }
57
+ } catch (e: any) {
58
+ push({ type: "error", message: `SSE stream error: ${e.message}` })
59
+ done = true
60
+ wake()
61
+ }
62
+ })()
63
+
64
+ // Create the session, then fire the prompt. We use promptAsync so we don't block
65
+ // on completion — the SSE stream's session.idle event is our signal to stop.
66
+ let session
67
+ try {
68
+ const created = await client.session.create({ body: { title: "puzzmo-cli" }, query: { directory: cwd } })
69
+ session = created.data
70
+ if (!session?.id) throw new Error("session.create returned no id")
71
+ sessionID = session.id
72
+ } catch (e: any) {
73
+ push({ type: "error", message: `Session creation failed: ${e.message}` })
74
+ done = true
75
+ wake()
76
+ }
77
+
78
+ if (sessionID) {
79
+ client.session
80
+ .promptAsync({
81
+ path: { id: sessionID },
82
+ body: { parts: [{ type: "text", text: input.prompt }] as any },
83
+ query: { directory: cwd },
84
+ })
85
+ .catch((e: Error) => {
86
+ push({ type: "error", message: `Prompt failed: ${e.message}` })
87
+ done = true
88
+ wake()
89
+ })
90
+ }
91
+
92
+ if (input.signal)
93
+ input.signal.addEventListener("abort", () => {
94
+ done = true
95
+ wake()
96
+ })
97
+
98
+ try {
99
+ while (true) {
100
+ if (events.length > 0) {
101
+ yield events.shift()!
102
+ continue
103
+ }
104
+ if (done) return
105
+ await new Promise<void>((r) => (waiting = r))
106
+ }
107
+ } finally {
108
+ server.close()
109
+ await sseTask.catch(() => {})
110
+ }
111
+ }
112
+
113
+ const mapOpencodeEvent = (event: any, sessionID: string | null): AgentEvent[] => {
114
+ if (event.type === "message.part.updated") {
115
+ const part = event.properties?.part
116
+ const delta = event.properties?.delta as string | undefined
117
+ if (!part) return []
118
+ // Deltas flow inline; complete-snapshot text gets a trailing newline so consecutive messages don't run together.
119
+ if (part.type === "text") {
120
+ if (delta != null) return [{ type: "text", text: delta }]
121
+ const text = part.text ?? ""
122
+ if (!text) return []
123
+ return [{ type: "text", text: text.endsWith("\n") ? text : text + "\n" }]
124
+ }
125
+ if (part.type === "reasoning") {
126
+ if (delta != null) return [{ type: "thinking", text: delta }]
127
+ const text = part.text ?? ""
128
+ if (!text) return []
129
+ return [{ type: "thinking", text: text.endsWith("\n") ? text : text + "\n" }]
130
+ }
131
+ if (part.type === "tool") {
132
+ const state = part.state?.status ?? part.state
133
+ if (state === "completed" || state === "error")
134
+ return [{ type: "tool_result", ok: state === "completed", summary: summarizeToolPart(part) }]
135
+ return [{ type: "tool_use", name: part.tool ?? "tool", summary: summarizeToolPart(part) }]
136
+ }
137
+ return []
138
+ }
139
+ if (event.type === "file.edited") return [{ type: "tool_result", ok: true, summary: `Edited ${event.properties?.file ?? ""}` }]
140
+ if (event.type === "command.executed") return [{ type: "tool_use", name: "Bash", summary: event.properties?.arguments ?? "" }]
141
+ if (event.type === "session.error" && (!sessionID || event.properties?.sessionID === sessionID))
142
+ return [{ type: "error", message: event.properties?.error?.message ?? "session error" }]
143
+ return []
144
+ }
145
+
146
+ const summarizeToolPart = (part: any): string => {
147
+ const input = part.state?.input ?? part.input
148
+ if (!input || typeof input !== "object") return part.tool ?? ""
149
+ if (typeof input.command === "string") return input.command.slice(0, 200)
150
+ if (typeof input.file_path === "string") return input.file_path
151
+ if (typeof input.path === "string") return input.path
152
+ if (typeof input.pattern === "string") return input.pattern
153
+ return part.tool ?? ""
154
+ }
@@ -0,0 +1,86 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+
3
+ import type { AgentEvent, RunInput } from "./types.js"
4
+
5
+ /** Shared driver for any CLI that emits NDJSON over stdout when given a prompt
6
+ * in print/headless mode. Used by both the claude and gemini adapters. */
7
+ export type StreamJsonCliConfig = {
8
+ cmd: string
9
+ buildArgs: (prompt: string) => string[]
10
+ /** Map one parsed JSON line to an AgentEvent (or null to drop). */
11
+ parseLine: (line: string) => AgentEvent | null
12
+ /** Optional env mutator. Receives a copy of process.env. */
13
+ env?: (base: NodeJS.ProcessEnv) => NodeJS.ProcessEnv
14
+ }
15
+
16
+ export const runStreamJsonCli = async function* (config: StreamJsonCliConfig, input: RunInput): AsyncIterable<AgentEvent> {
17
+ const args = config.buildArgs(input.prompt)
18
+ const env = config.env ? config.env({ ...process.env }) : { ...process.env }
19
+
20
+ let proc: ChildProcess
21
+ try {
22
+ proc = spawn(config.cmd, args, {
23
+ cwd: input.cwd ?? process.cwd(),
24
+ env,
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ })
27
+ } catch (e: any) {
28
+ yield { type: "error", message: `Failed to spawn ${config.cmd}: ${e.message}` }
29
+ return
30
+ }
31
+
32
+ if (input.signal) input.signal.addEventListener("abort", () => proc.kill())
33
+
34
+ // Bridge node streams + child events into an async generator.
35
+ const queue: AgentEvent[] = []
36
+ let done = false
37
+ let resolveWaiting: (() => void) | null = null
38
+ let buf = ""
39
+ let sawResult = false
40
+
41
+ const push = (e: AgentEvent) => {
42
+ if (e.type === "result") sawResult = true
43
+ queue.push(e)
44
+ resolveWaiting?.()
45
+ resolveWaiting = null
46
+ }
47
+
48
+ const flushLine = (line: string) => {
49
+ if (!line.trim()) return
50
+ const event = config.parseLine(line)
51
+ if (event) push(event)
52
+ }
53
+
54
+ proc.stdout?.on("data", (chunk: Buffer) => {
55
+ buf += chunk.toString("utf8")
56
+ const lines = buf.split("\n")
57
+ buf = lines.pop() ?? ""
58
+ for (const line of lines) flushLine(line)
59
+ })
60
+
61
+ proc.stderr?.on("data", (chunk: Buffer) => {
62
+ const text = chunk.toString("utf8").trim()
63
+ if (text) push({ type: "error", message: text })
64
+ })
65
+
66
+ proc.on("close", (code) => {
67
+ if (buf.trim()) flushLine(buf)
68
+ if (code !== 0 && !sawResult) push({ type: "result", ok: false })
69
+ done = true
70
+ resolveWaiting?.()
71
+ resolveWaiting = null
72
+ })
73
+
74
+ proc.on("error", (err) => {
75
+ push({ type: "error", message: err.message })
76
+ done = true
77
+ resolveWaiting?.()
78
+ resolveWaiting = null
79
+ })
80
+
81
+ while (true) {
82
+ if (queue.length > 0) yield queue.shift()!
83
+ else if (done) return
84
+ else await new Promise<void>((r) => (resolveWaiting = r))
85
+ }
86
+ }
@@ -0,0 +1,22 @@
1
+ /** Normalized event union emitted by every agent adapter. Keep this small —
2
+ * adapters fold their richer native event shapes into one of these. */
3
+ export type AgentEvent =
4
+ | { type: "text"; text: string } // assistant text (delta or full chunk)
5
+ | { type: "tool_use"; name: string; summary: string } // tool starting
6
+ | { type: "tool_result"; ok: boolean; summary: string } // tool finished
7
+ | { type: "thinking"; text: string }
8
+ | { type: "system"; text: string }
9
+ | { type: "result"; ok: boolean; costUSD?: number }
10
+ | { type: "error"; message: string }
11
+
12
+ export type RunInput = {
13
+ prompt: string
14
+ cwd?: string
15
+ signal?: AbortSignal
16
+ }
17
+
18
+ export interface Agent {
19
+ readonly name: string
20
+ /** Yield events as the agent runs. Should complete (or throw) when the run finishes. */
21
+ run(input: RunInput): AsyncIterable<AgentEvent>
22
+ }
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs"
2
+
3
+ import * as p from "@clack/prompts"
4
+
5
+ import { agentNames, getAgent } from "../agents/index.js"
6
+
7
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
8
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
9
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`
10
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`
11
+
12
+ /** Undocumented: send "say hello world" to a chosen agent through the meta-library
13
+ * and stream events to stdout. Use this to verify each adapter end-to-end. */
14
+ export const agentTest = async () => {
15
+ p.intro("Agent SDK Test")
16
+
17
+ const choice = await p.select({
18
+ message: "Which agent?",
19
+ options: agentNames().map((name) => ({ value: name, label: name })),
20
+ initialValue: "claude",
21
+ })
22
+ if (p.isCancel(choice)) process.exit(0)
23
+ const agent = getAgent(String(choice))
24
+ if (!agent) {
25
+ p.log.error(`Unknown agent: ${choice}`)
26
+ process.exit(1)
27
+ }
28
+
29
+ const prompt = "say hello world"
30
+ const cwd = process.cwd()
31
+ const debugPath = "/tmp/puzzmo-agent-test.log"
32
+ fs.writeFileSync(debugPath, "")
33
+ const log = (msg: string) => fs.appendFileSync(debugPath, `[${new Date().toISOString()}] ${msg}\n`)
34
+
35
+ log(`agent=${agent.name} cwd=${cwd}`)
36
+ log(`prompt=${JSON.stringify(prompt)}`)
37
+
38
+ process.stderr.write(`\n${dim("[agent]")} ${agent.name}\n`)
39
+ process.stderr.write(`${dim("[cwd]")} ${cwd}\n`)
40
+ process.stderr.write(`${dim("[log]")} ${debugPath}\n`)
41
+ process.stderr.write(`${dim("---- events below (Ctrl+C to abort) ----")}\n\n`)
42
+
43
+ const controller = new AbortController()
44
+ const onSig = () => {
45
+ process.stderr.write(`\n${dim("[interrupt]")}\n`)
46
+ controller.abort()
47
+ }
48
+ process.on("SIGINT", onSig)
49
+
50
+ const start = Date.now()
51
+ let count = 0
52
+ try {
53
+ for await (const event of agent.run({ prompt, cwd, signal: controller.signal })) {
54
+ count++
55
+ const elapsed = ((Date.now() - start) / 1000).toFixed(3)
56
+ log(`+${elapsed}s ${event.type} ${JSON.stringify(event).slice(0, 400)}`)
57
+ printEvent(event)
58
+ }
59
+ } catch (e: any) {
60
+ process.stderr.write(`\n${red("[run threw]")} ${e.message ?? e}\n`)
61
+ log(`THROW ${e.message ?? e}`)
62
+ process.exit(1)
63
+ } finally {
64
+ process.off("SIGINT", onSig)
65
+ }
66
+
67
+ const elapsed = ((Date.now() - start) / 1000).toFixed(3)
68
+ process.stderr.write(`\n${dim(`[+${elapsed}s done]`)} events=${count}\n`)
69
+ log(`done events=${count} elapsed=${elapsed}s`)
70
+ }
71
+
72
+ const printEvent = (e: { type: string } & Record<string, any>) => {
73
+ if (e.type === "text") process.stdout.write(e.text)
74
+ else if (e.type === "thinking") process.stdout.write(dim(e.text))
75
+ else if (e.type === "tool_use") process.stdout.write(`\n${yellow(`[${e.name}]`)} ${dim(e.summary ?? "")}\n`)
76
+ else if (e.type === "tool_result") process.stdout.write(`${e.ok ? green(" ✓") : red(" ✗")} ${dim(e.summary ?? "")}\n`)
77
+ else if (e.type === "system") process.stdout.write(dim(`\n[${e.text}]\n`))
78
+ else if (e.type === "result")
79
+ process.stdout.write(`\n${e.ok ? green("== Done") : red("== Failed")}${e.costUSD ? ` ($${e.costUSD.toFixed(4)})` : ""}\n`)
80
+ else if (e.type === "error") process.stdout.write(red(`\n[error] ${e.message}\n`))
81
+ }
@@ -3,6 +3,7 @@ import path from "node:path"
3
3
  import { createRequire } from "node:module"
4
4
  import * as p from "@clack/prompts"
5
5
 
6
+ import { agentNames } from "../../agents/index.js"
6
7
  import { detectAgent } from "../../wizard/agent-detect.js"
7
8
  import { downloadPage } from "../../download/page-downloader.js"
8
9
  import { runCommand, gitCommit } from "../../util/exec.js"
@@ -260,10 +261,11 @@ export const gameCreate = async (args: string[]) => {
260
261
  gameDir = setupRepoGame(tmpDir, slug, repo.repoRoot, parentFolder)
261
262
  }
262
263
 
263
- // Step 7: Detect agent and run skills pipeline
264
- const agents = detectAgent()
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))
265
267
  const agentChoices = [
266
- ...agents.map((a) => ({ value: a.binary, label: `${a.displayName} (${a.path})` })),
268
+ ...agents.map((a) => ({ value: a.id, label: `${a.displayName} (${a.path})` })),
267
269
  { value: "none", label: "None - I'll run the steps manually" },
268
270
  ]
269
271
 
@@ -274,7 +276,7 @@ export const gameCreate = async (args: string[]) => {
274
276
  selectedAgent = (await p.select({ message: "Which LLM agent do you use?", options: agentChoices })) as string
275
277
  if (p.isCancel(selectedAgent)) process.exit(0)
276
278
  } else {
277
- p.log.info("No LLM agents detected (checked: claude, codex)")
279
+ p.log.info(`No supported LLM agents detected (checked: ${agentNames().join(", ")})`)
278
280
  selectedAgent = "none"
279
281
  }
280
282
 
@@ -7,45 +7,13 @@ import { uploadFiles } from "../util/api.js"
7
7
  import { getAPIURL, getToken } from "../util/config.js"
8
8
  import { validatePuzzmoJson } from "../util/validatePuzzmoFile.js"
9
9
 
10
- /** Collects all files in a directory recursively */
11
- const collectFiles = (dir: string): string[] => {
12
- const entries = fs.readdirSync(dir, { withFileTypes: true })
13
- const files: string[] = []
14
- for (const entry of entries) {
15
- const full = path.join(dir, entry.name)
16
- if (entry.isDirectory()) files.push(...collectFiles(full))
17
- else files.push(full)
18
- }
19
- return files
20
- }
21
-
22
- /** Tries to get the shortest unique git SHA, returns null if not in a git repo */
23
- const getGitSHA = (): string | null => {
24
- try {
25
- return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim()
26
- } catch {
27
- return null
28
- }
29
- }
30
-
31
- /** Hashes all file contents to produce a deterministic SHA */
32
- const hashFiles = (filePaths: string[]): string => {
33
- const hash = crypto.createHash("sha256")
34
- for (const fp of filePaths.sort()) {
35
- hash.update(fs.readFileSync(fp))
36
- }
37
- return hash.digest("hex").slice(0, 12)
38
- }
39
-
40
- /** Formats a byte count as a human-readable string */
41
- const formatBytes = (bytes: number): string => {
42
- if (bytes < 1024) return `${bytes} B`
43
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
44
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
10
+ type UploadOptions = {
11
+ verbose?: boolean
45
12
  }
46
13
 
47
14
  /** Uploads game build artifacts to Puzzmo */
48
- export const upload = async (dir: string) => {
15
+ export const upload = async (dir: string, options: UploadOptions = {}) => {
16
+ const { verbose = false } = options
49
17
  const token = getToken()
50
18
  if (!token) {
51
19
  console.error("Not logged in. Run `puzzmo login <token>` or set PUZZMO_TOKEN.")
@@ -111,10 +79,56 @@ export const upload = async (dir: string) => {
111
79
  if (apiURL !== defaultURL) console.log(`Uploading to ${apiURL}...`)
112
80
  else console.log("Uploading...")
113
81
 
114
- const result = await uploadFiles(token, gameSlug, sha, files, distDir, puzzmoFile, (batch, totalBatches, uploaded) => {
115
- console.log(` Batch ${batch}/${totalBatches} done (${uploaded} file(s) uploaded)`)
116
- })
82
+ const result = await uploadFiles(
83
+ token,
84
+ gameSlug,
85
+ sha,
86
+ files,
87
+ distDir,
88
+ puzzmoFile,
89
+ (batch, totalBatches, uploaded) => {
90
+ console.log(` Batch ${batch}/${totalBatches} done (${uploaded} file(s) uploaded)`)
91
+ },
92
+ { verbose },
93
+ )
117
94
 
118
95
  console.log(`\nDone - ${result.versionID}`)
119
96
  console.log(`Assets: ${result.assetsBase}`)
120
97
  }
98
+
99
+ /** Collects all files in a directory recursively */
100
+ const collectFiles = (dir: string): string[] => {
101
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
102
+ const files: string[] = []
103
+ for (const entry of entries) {
104
+ const full = path.join(dir, entry.name)
105
+ if (entry.isDirectory()) files.push(...collectFiles(full))
106
+ else files.push(full)
107
+ }
108
+ return files
109
+ }
110
+
111
+ /** Tries to get the shortest unique git SHA, returns null if not in a git repo */
112
+ const getGitSHA = (): string | null => {
113
+ try {
114
+ return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim()
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** Hashes all file contents to produce a deterministic SHA */
121
+ const hashFiles = (filePaths: string[]): string => {
122
+ const hash = crypto.createHash("sha256")
123
+ for (const fp of filePaths.sort()) {
124
+ hash.update(fs.readFileSync(fp))
125
+ }
126
+ return hash.digest("hex").slice(0, 12)
127
+ }
128
+
129
+ /** Formats a byte count as a human-readable string */
130
+ const formatBytes = (bytes: number): string => {
131
+ if (bytes < 1024) return `${bytes} B`
132
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
133
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
134
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { agentTest } from "./commands/agent-test.js"
3
4
  import { login } from "./commands/login.js"
4
5
  import { upload } from "./commands/upload.js"
5
6
  import { validate } from "./commands/validate.js"
@@ -13,7 +14,7 @@ const printUsage = () => {
13
14
  puzzmo login <token> Save a CLI auth token
14
15
  puzzmo game create [token] Create a new Puzzmo game project
15
16
 
16
- puzzmo upload <dir> Upload game build from <dir> (slug from puzzmo.json)
17
+ puzzmo upload <dir> [-v] Upload game build from <dir> (slug from puzzmo.json). -v/--verbose prints request URLs and full error bodies.
17
18
  puzzmo validate [dir] Validate puzzmo.json in a directory (default: .)
18
19
  puzzmo migrate List and select migration skills from dev.puzzmo.com`)
19
20
  }
@@ -30,12 +31,13 @@ const run = async () => {
30
31
  break
31
32
  }
32
33
  case "upload": {
33
- const dir = args[0]
34
+ const verbose = args.includes("--verbose") || args.includes("-v")
35
+ const dir = args.find((a) => !a.startsWith("-"))
34
36
  if (!dir) {
35
- console.error("Usage: puzzmo upload <dir>")
37
+ console.error("Usage: puzzmo upload <dir> [-v|--verbose]")
36
38
  process.exit(1)
37
39
  }
38
- await upload(dir)
40
+ await upload(dir, { verbose })
39
41
  break
40
42
  }
41
43
  case "game": {
@@ -56,6 +58,10 @@ const run = async () => {
56
58
  await migrate()
57
59
  break
58
60
  }
61
+ case "agent-test": {
62
+ await agentTest()
63
+ break
64
+ }
59
65
  default:
60
66
  printUsage()
61
67
  break