kradle 0.6.7 → 0.6.8

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.
package/README.md CHANGED
@@ -177,6 +177,39 @@ When no agents are specified, the command enters interactive mode, fetching the
177
177
 
178
178
  By default, the command opens the run URL in your browser and polls until the run completes, then displays the outcome.
179
179
 
180
+ ### Run Challenge Locally
181
+
182
+ Run a challenge locally using Docker (requires Docker Desktop):
183
+
184
+ ```bash
185
+ # Run locally with inline agents
186
+ kradle challenge run <challenge-name> --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast
187
+
188
+ # Run locally with interactive agent selection
189
+ kradle challenge run <challenge-name> --local
190
+
191
+ # Use a dev/staging arena image instead of production
192
+ kradle challenge run <challenge-name> --local \
193
+ --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234
194
+ ```
195
+
196
+ The `--local` flag spins up a Minecraft server and arena-minecraft as Docker containers on your machine. This is useful for testing challenges during development without going through the cloud backend.
197
+
198
+ **Requirements:**
199
+ - Docker Desktop must be running
200
+ - First run will pull container images (may take a few minutes)
201
+ - The challenge must exist locally (with `challenge.ts` and `config.ts`)
202
+
203
+ **What happens:**
204
+ 1. Builds the challenge datapack locally
205
+ 2. Downloads the world from the cloud
206
+ 3. Starts a Minecraft server container (port 25565)
207
+ 4. Starts an arena-minecraft container
208
+ 5. Streams arena logs to your terminal
209
+ 6. Press Ctrl+C for graceful shutdown
210
+
211
+ **Arena Image Override:** By default, the production arena image is used. Set `--arena-image` or the `KRADLE_ARENA_IMAGE` environment variable to test dev or staging builds.
212
+
180
213
  ### List Runs
181
214
 
182
215
  List your recent runs:
@@ -508,6 +541,8 @@ kradle-cli/
508
541
  │ │ │ └── runs/ # Run listing and logs commands
509
542
  │ │ ├── experiment/ # Experiment commands
510
543
  │ │ └── world/ # World management commands
544
+ │ ├── config/
545
+ │ │ └── releases/ # Release tags (updated by CI)
511
546
  │ └── lib/ # Core libraries
512
547
  │ └── experiment/ # Experiment system
513
548
  ├── tests/ # Integration tests
@@ -12,8 +12,11 @@ export default class Run extends Command {
12
12
  "web-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
13
  "studio-api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
14
  "studio-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
16
  studio: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  record: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
+ "arena-image": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
20
  "no-open": import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
21
  "no-wait": import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
22
  "no-summary": import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -34,5 +37,9 @@ export default class Run extends Command {
34
37
  * Fetches challenge config from cloud and prompts user to select agents for each role.
35
38
  */
36
39
  private selectAgentsInteractively;
40
+ /**
41
+ * Run a challenge locally using Docker (MC server + arena-minecraft).
42
+ */
43
+ private runLocally;
37
44
  run(): Promise<void>;
38
45
  }
@@ -2,7 +2,8 @@ import { Command, Flags } from "@oclif/core";
2
2
  import enquirer from "enquirer";
3
3
  import pc from "picocolors";
4
4
  import { ApiClient } from "../../lib/api-client.js";
5
- import { getChallengeSlugArgument } from "../../lib/arguments.js";
5
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
6
+ import { Challenge } from "../../lib/challenge.js";
6
7
  import { getConfigFlags } from "../../lib/flags.js";
7
8
  import { clearScreen, formatDuration, fuzzyHighlight, getRunStatusDisplay, openInBrowser } from "../../lib/utils.js";
8
9
  const POLL_INTERVAL_MS = 2000;
@@ -21,6 +22,9 @@ export default class Run extends Command {
21
22
  "<%= config.bin %> <%= command.id %> my-challenge --no-wait",
22
23
  "<%= config.bin %> <%= command.id %> my-challenge team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
23
24
  "<%= config.bin %> <%= command.id %> capture-the-flag red=team-kradle:gemini-3-flash blue=team-kradle:grok-4-1-fast",
25
+ "<%= config.bin %> <%= command.id %> my-challenge --local",
26
+ "<%= config.bin %> <%= command.id %> my-challenge --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
27
+ "<%= config.bin %> <%= command.id %> my-challenge --local --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234",
24
28
  ];
25
29
  static args = {
26
30
  challengeSlug: getChallengeSlugArgument({
@@ -30,6 +34,15 @@ export default class Run extends Command {
30
34
  static flags = {
31
35
  studio: Flags.boolean({ char: "s", description: "Run in studio environment", default: false }),
32
36
  record: Flags.boolean({ char: "r", description: "Record the run (enables video recording)", default: false }),
37
+ local: Flags.boolean({
38
+ char: "l",
39
+ description: "Run the challenge locally using Docker (spins up MC server + arena-minecraft)",
40
+ default: false,
41
+ }),
42
+ "arena-image": Flags.string({
43
+ description: "Override arena-minecraft Docker image URL (for testing dev/staging builds)",
44
+ env: "KRADLE_ARENA_IMAGE",
45
+ }),
33
46
  "no-open": Flags.boolean({
34
47
  description: "Don't open the run URL in the browser",
35
48
  default: false,
@@ -46,7 +59,7 @@ export default class Run extends Command {
46
59
  description: "Open the run URL with screenshots mode enabled",
47
60
  default: false,
48
61
  }),
49
- ...getConfigFlags("api-key", "api-url", "web-url", "studio-url", "studio-api-url"),
62
+ ...getConfigFlags("api-key", "api-url", "web-url", "studio-url", "studio-api-url", "challenges-path"),
50
63
  };
51
64
  async pollForCompletion(api, runId, waitForSummary) {
52
65
  let lastStatus = "";
@@ -256,13 +269,108 @@ export default class Run extends Command {
256
269
  }
257
270
  return participants;
258
271
  }
272
+ /**
273
+ * Run a challenge locally using Docker (MC server + arena-minecraft).
274
+ */
275
+ async runLocally(challengeSlug,
276
+ // biome-ignore lint/suspicious/noExplicitAny: oclif flag types are complex
277
+ flags, agentArgs) {
278
+ // Validate incompatible flags
279
+ const incompatible = ["studio", "no-open", "no-wait", "screenshots", "record"];
280
+ for (const flag of incompatible) {
281
+ if (flags[flag]) {
282
+ this.error(`--${flag} is not compatible with --local`);
283
+ }
284
+ }
285
+ const apiUrl = flags["api-url"];
286
+ const api = new ApiClient(apiUrl, flags["api-key"]);
287
+ // Parse participants
288
+ let participants;
289
+ if (agentArgs.length > 0) {
290
+ participants = this.parseInlineAgents(agentArgs);
291
+ }
292
+ else {
293
+ this.log(pc.blue(">> Fetching challenge configuration..."));
294
+ const [challenge, availableAgents] = await Promise.all([api.getChallenge(challengeSlug), api.listKradleAgents()]);
295
+ this.log(pc.dim(`Challenge: ${challenge.name}`));
296
+ this.log(pc.dim(`Roles: ${Object.keys(challenge.roles).join(", ")}`));
297
+ this.log("");
298
+ participants = await this.selectAgentsInteractively(challenge, availableAgents);
299
+ }
300
+ if (participants.length === 0) {
301
+ this.error("No participants specified. Use inline syntax or select agents interactively.");
302
+ }
303
+ // Fetch challenge data from API
304
+ this.log(pc.blue(">> Fetching challenge configuration..."));
305
+ const challengeData = await api.getChallenge(challengeSlug);
306
+ // Build datapack locally
307
+ this.log(pc.blue(">> Building challenge datapack..."));
308
+ const shortSlug = extractShortSlug(challengeSlug);
309
+ const challenge = new Challenge(shortSlug, flags["challenges-path"]);
310
+ const { config } = await challenge.buildLocal({ validate: true });
311
+ // Create a real job in the backend (env: "studio" skips remote arena creation)
312
+ // Get the FULL raw response — it's passed directly to the arena (same as Studio)
313
+ this.log(pc.blue(">> Creating run in backend..."));
314
+ const jobPayload = await api.runChallengeRaw({
315
+ challenge: challengeSlug,
316
+ participants,
317
+ jobType: "foreground",
318
+ env: "studio",
319
+ });
320
+ const jobId = jobPayload.id;
321
+ const runIds = jobPayload.runIds;
322
+ if (!jobId || !runIds?.length) {
323
+ this.error("Backend did not return job ID or run IDs. Check your API key and challenge slug.");
324
+ }
325
+ const runId = runIds[0];
326
+ this.log(pc.dim(`Job: ${jobId}`));
327
+ this.log(pc.dim(`Run: ${runId}`));
328
+ // Lazy import to avoid Docker dependency when not using --local
329
+ const { LocalRunner } = await import("../../lib/local-runner.js");
330
+ const runner = new LocalRunner({
331
+ challengeData,
332
+ challengeConfig: config,
333
+ datapackPath: challenge.builtDatapackPath,
334
+ arenaImageOverride: flags["arena-image"],
335
+ apiUrl,
336
+ apiKey: flags["api-key"],
337
+ jobPayload,
338
+ jobId,
339
+ runId,
340
+ });
341
+ // Handle graceful shutdown
342
+ const cleanup = async () => {
343
+ this.log(pc.yellow("\n>> Shutting down..."));
344
+ await runner.cleanup();
345
+ process.exit(0);
346
+ };
347
+ process.on("SIGINT", cleanup);
348
+ process.on("SIGTERM", cleanup);
349
+ try {
350
+ this.log(pc.blue(`\n>> Running challenge locally: ${challengeSlug}\n`));
351
+ await runner.run((msg) => this.log(msg));
352
+ this.log(pc.green("\n=== Local run complete ==="));
353
+ }
354
+ catch (error) {
355
+ this.error(pc.red(`Local run failed: ${error instanceof Error ? error.message : String(error)}`));
356
+ }
357
+ finally {
358
+ process.removeListener("SIGINT", cleanup);
359
+ process.removeListener("SIGTERM", cleanup);
360
+ await runner.cleanup();
361
+ }
362
+ }
259
363
  async run() {
260
364
  const { args, flags, argv } = await this.parse(Run);
261
- const apiUrl = flags.studio ? flags["studio-api-url"] : flags["api-url"];
262
- const api = new ApiClient(apiUrl, flags["api-key"]);
263
365
  const challengeSlug = args.challengeSlug;
264
366
  // Get extra arguments (after the challenge slug) for inline agent specification
265
367
  const agentArgs = argv.filter((arg) => arg !== challengeSlug);
368
+ // Local mode: run via Docker
369
+ if (flags.local) {
370
+ return this.runLocally(challengeSlug, flags, agentArgs);
371
+ }
372
+ const apiUrl = flags.studio ? flags["studio-api-url"] : flags["api-url"];
373
+ const api = new ApiClient(apiUrl, flags["api-key"]);
266
374
  let participants;
267
375
  if (agentArgs.length > 0) {
268
376
  // Parse inline agent specification
@@ -0,0 +1 @@
1
+ export declare const MINECRAFT_ARENA_MANAGER_TAG = "8534933";
@@ -0,0 +1,2 @@
1
+ // Managed by https://github.com/Kradle-ai/arena-minecraft/actions/workflows/release.yaml
2
+ export const MINECRAFT_ARENA_MANAGER_TAG = "8534933";
@@ -76,6 +76,7 @@ export declare class ApiClient {
76
76
  challenge: string;
77
77
  participants: unknown[];
78
78
  jobType: "background" | "foreground" | "foreground_with_recording";
79
+ env?: "cloud" | "studio";
79
80
  }): Promise<{
80
81
  runIds?: string[] | undefined;
81
82
  participants?: Record<string, {
@@ -84,7 +85,19 @@ export declare class ApiClient {
84
85
  inputOrder: number;
85
86
  }> | undefined;
86
87
  id?: string | undefined;
88
+ jobApiKey?: string | undefined;
87
89
  }>;
90
+ /**
91
+ * Create a job and return the full unvalidated response.
92
+ * Used for local runs where the full job object is passed directly to the arena container
93
+ * (same approach as Studio).
94
+ */
95
+ runChallengeRaw(runData: {
96
+ challenge: string;
97
+ participants: unknown[];
98
+ jobType: "background" | "foreground" | "foreground_with_recording";
99
+ env?: "cloud" | "studio";
100
+ }): Promise<Record<string, unknown>>;
88
101
  deleteChallenge(challengeId: string): Promise<void>;
89
102
  /**
90
103
  * Get the status of a run.
@@ -247,6 +247,18 @@ export class ApiClient {
247
247
  body: JSON.stringify(runData),
248
248
  }, JobResponseSchema);
249
249
  }
250
+ /**
251
+ * Create a job and return the full unvalidated response.
252
+ * Used for local runs where the full job object is passed directly to the arena container
253
+ * (same approach as Studio).
254
+ */
255
+ async runChallengeRaw(runData) {
256
+ const response = await this.request("jobs", {
257
+ method: "POST",
258
+ body: JSON.stringify(runData),
259
+ });
260
+ return response.json();
261
+ }
250
262
  async deleteChallenge(challengeId) {
251
263
  const url = `challenges/${challengeId}`;
252
264
  await this.delete(url);
@@ -0,0 +1,62 @@
1
+ import type { ChallengeConfigSchemaType, ChallengeSchemaType } from "./schemas.js";
2
+ export interface LocalRunnerConfig {
3
+ challengeData: ChallengeSchemaType;
4
+ challengeConfig: ChallengeConfigSchemaType;
5
+ datapackPath: string;
6
+ arenaImageOverride?: string;
7
+ apiUrl: string;
8
+ apiKey: string;
9
+ /** Full job response from backend — passed directly to the arena (same as Studio) */
10
+ jobPayload: Record<string, unknown>;
11
+ jobId: string;
12
+ runId: string;
13
+ }
14
+ type LogFn = (msg: string) => void;
15
+ export declare class LocalRunner {
16
+ private config;
17
+ private mcContainerId;
18
+ private arenaContainerId;
19
+ private logProcess;
20
+ private tempDir;
21
+ private shutdownInProgress;
22
+ constructor(config: LocalRunnerConfig);
23
+ /**
24
+ * Main orchestration — starts MC server + arena, streams logs, waits for completion.
25
+ */
26
+ run(log: LogFn): Promise<void>;
27
+ /**
28
+ * Gracefully shut down all containers and clean up temp files.
29
+ */
30
+ cleanup(): Promise<void>;
31
+ private ensureDockerAvailable;
32
+ private resolveArenaImage;
33
+ private pullImageIfNeeded;
34
+ private downloadWorld;
35
+ private findLevelDat;
36
+ private startMinecraftServer;
37
+ private waitForServerReady;
38
+ private startArena;
39
+ /**
40
+ * Write a small datapack that auto-ops any player who joins.
41
+ * Uses a tick function with function-permission-level=4 to run /op on new players.
42
+ */
43
+ private writeAutoOpDatapack;
44
+ /**
45
+ * Generate an offline-mode UUID from a Minecraft username.
46
+ * Replicates the Java algorithm: UUID.nameUUIDFromBytes("OfflinePlayer:" + name)
47
+ */
48
+ private generateOfflineUUID;
49
+ private streamLogs;
50
+ private waitForCompletion;
51
+ private stopContainer;
52
+ /**
53
+ * Find and remove orphaned containers from previous local runs.
54
+ */
55
+ static cleanupOrphans(log?: LogFn): void;
56
+ /**
57
+ * Run a Docker command with PATH augmented for macOS Docker Desktop.
58
+ */
59
+ private docker;
60
+ private getDockerEnv;
61
+ }
62
+ export {};
@@ -0,0 +1,432 @@
1
+ import { execFileSync, execSync, spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import pc from "picocolors";
8
+ import * as tar from "tar";
9
+ import { MINECRAFT_ARENA_MANAGER_TAG } from "../config/releases/arena-minecraft.js";
10
+ const MC_SERVER_IMAGE = "marctv/minecraft-papermc-server:1.20.4";
11
+ const DEFAULT_ARENA_IMAGE_TEMPLATE = "us-central1-docker.pkg.dev/kradle-prod-449119/prod-arenas/minecraft:{SHA}";
12
+ const MC_PORT = 25565;
13
+ const SERVER_READY_TIMEOUT_MS = 120_000;
14
+ const SERVER_READY_POLL_MS = 3_000;
15
+ const CONTAINER_NAME_PREFIX_MC = "kradle-mc-server";
16
+ const CONTAINER_NAME_PREFIX_ARENA = "kradle-arena";
17
+ export class LocalRunner {
18
+ config;
19
+ mcContainerId = null;
20
+ arenaContainerId = null;
21
+ logProcess = null;
22
+ tempDir = null;
23
+ shutdownInProgress = false;
24
+ constructor(config) {
25
+ this.config = config;
26
+ }
27
+ /**
28
+ * Main orchestration — starts MC server + arena, streams logs, waits for completion.
29
+ */
30
+ async run(log) {
31
+ this.ensureDockerAvailable();
32
+ const arenaImage = this.resolveArenaImage();
33
+ log(`Arena image: ${arenaImage}`);
34
+ // Pull images
35
+ log("Pulling Docker images (this may take a while on first run)...");
36
+ this.pullImageIfNeeded(MC_SERVER_IMAGE, log);
37
+ this.pullImageIfNeeded(arenaImage, log);
38
+ // Clean up orphaned containers from previous runs
39
+ LocalRunner.cleanupOrphans(log);
40
+ // Prepare server directory (marctv image mounts entire /data)
41
+ this.tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "kradle-local-run-"));
42
+ const serverDir = path.join(this.tempDir, "server");
43
+ const worldDir = path.join(serverDir, "world");
44
+ await fs.mkdir(worldDir, { recursive: true });
45
+ // Download world
46
+ const worldSlug = this.config.challengeData.world;
47
+ if (worldSlug) {
48
+ log(`Downloading world: ${worldSlug}...`);
49
+ await this.downloadWorld(worldDir);
50
+ }
51
+ else {
52
+ log("No world specified, using empty world.");
53
+ }
54
+ // Start Minecraft server
55
+ log("Starting Minecraft server...");
56
+ this.mcContainerId = await this.startMinecraftServer(serverDir, worldDir);
57
+ log(`MC server container: ${this.mcContainerId.slice(0, 12)}`);
58
+ // Wait for server to be ready
59
+ log("Waiting for Minecraft server to be ready...");
60
+ await this.waitForServerReady(log);
61
+ log("");
62
+ log(pc.green("========================================"));
63
+ log(pc.green(" Minecraft server is ready!"));
64
+ log(pc.green(` Connect to localhost:${MC_PORT} to join the live game`));
65
+ log(pc.green(" You will be automatically OP'd on join"));
66
+ log(pc.green("========================================"));
67
+ log("");
68
+ // Start arena-minecraft
69
+ log("Starting arena-minecraft...");
70
+ this.arenaContainerId = this.startArena(arenaImage);
71
+ log(`Arena container: ${this.arenaContainerId.slice(0, 12)}`);
72
+ // Stream arena logs
73
+ log("\n--- Arena logs ---\n");
74
+ this.logProcess = this.streamLogs(this.arenaContainerId, log);
75
+ // Wait for arena to finish
76
+ await this.waitForCompletion();
77
+ log("\n--- Arena finished ---\n");
78
+ }
79
+ /**
80
+ * Gracefully shut down all containers and clean up temp files.
81
+ */
82
+ async cleanup() {
83
+ if (this.shutdownInProgress)
84
+ return;
85
+ this.shutdownInProgress = true;
86
+ // Kill log streaming
87
+ if (this.logProcess) {
88
+ this.logProcess.kill();
89
+ this.logProcess = null;
90
+ }
91
+ // Graceful arena shutdown
92
+ if (this.arenaContainerId) {
93
+ try {
94
+ await fetch(`http://localhost:3002/shutdown`, {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ reason: "CLI shutdown" }),
98
+ signal: AbortSignal.timeout(10_000),
99
+ });
100
+ await new Promise((r) => setTimeout(r, 3_000));
101
+ }
102
+ catch {
103
+ // Arena may already be stopped
104
+ }
105
+ this.stopContainer(this.arenaContainerId);
106
+ this.arenaContainerId = null;
107
+ }
108
+ // Stop MC server
109
+ if (this.mcContainerId) {
110
+ this.stopContainer(this.mcContainerId);
111
+ this.mcContainerId = null;
112
+ }
113
+ // Clean up temp directory
114
+ if (this.tempDir) {
115
+ await fs.rm(this.tempDir, { recursive: true, force: true });
116
+ this.tempDir = null;
117
+ }
118
+ }
119
+ // --- Private methods ---
120
+ ensureDockerAvailable() {
121
+ try {
122
+ this.docker("info", { stdio: "ignore" });
123
+ }
124
+ catch {
125
+ throw new Error("Docker is required for --local runs but is not available.\n" +
126
+ "Make sure Docker Desktop is running, or install it from https://docker.com");
127
+ }
128
+ }
129
+ resolveArenaImage() {
130
+ if (this.config.arenaImageOverride) {
131
+ return this.config.arenaImageOverride;
132
+ }
133
+ return DEFAULT_ARENA_IMAGE_TEMPLATE.replace("{SHA}", MINECRAFT_ARENA_MANAGER_TAG);
134
+ }
135
+ pullImageIfNeeded(image, log) {
136
+ try {
137
+ this.docker(`inspect --type=image ${image}`, { stdio: "ignore" });
138
+ }
139
+ catch {
140
+ log(`Pulling ${image}...`);
141
+ this.docker(`pull ${image}`, { stdio: "inherit" });
142
+ }
143
+ }
144
+ async downloadWorld(worldDir) {
145
+ const worldSlug = this.config.challengeData.world;
146
+ if (!worldSlug)
147
+ return;
148
+ const api = new (await import("./api-client.js")).ApiClient(this.config.apiUrl, this.config.apiKey);
149
+ const { downloadUrl } = await api.getWorldDownloadUrl(worldSlug);
150
+ const response = await fetch(downloadUrl);
151
+ if (!response.ok) {
152
+ throw new Error(`Failed to download world "${worldSlug}": ${response.status} ${response.statusText}`);
153
+ }
154
+ // Save and extract tarball
155
+ const tarballPath = path.join(worldDir, "world.tar.gz");
156
+ const buffer = await response.arrayBuffer();
157
+ await fs.writeFile(tarballPath, Buffer.from(buffer));
158
+ // Extract to temp, then find level.dat root
159
+ const extractDir = path.join(worldDir, "_extract");
160
+ await fs.mkdir(extractDir, { recursive: true });
161
+ await tar.extract({ file: tarballPath, cwd: extractDir });
162
+ // Find the directory containing level.dat (may be nested under an ID folder)
163
+ const levelDatDir = await this.findLevelDat(extractDir);
164
+ // Move world files to worldDir root
165
+ const entries = await fs.readdir(levelDatDir, { withFileTypes: true });
166
+ for (const entry of entries) {
167
+ const src = path.join(levelDatDir, entry.name);
168
+ const dst = path.join(worldDir, entry.name);
169
+ await fs.rename(src, dst);
170
+ }
171
+ // Clean up extraction artifacts
172
+ await fs.rm(extractDir, { recursive: true, force: true });
173
+ await fs.rm(tarballPath, { force: true });
174
+ }
175
+ async findLevelDat(dir) {
176
+ if (existsSync(path.join(dir, "level.dat"))) {
177
+ return dir;
178
+ }
179
+ const entries = await fs.readdir(dir, { withFileTypes: true });
180
+ for (const entry of entries) {
181
+ if (entry.isDirectory()) {
182
+ const sub = path.join(dir, entry.name);
183
+ if (existsSync(path.join(sub, "level.dat"))) {
184
+ return sub;
185
+ }
186
+ }
187
+ }
188
+ throw new Error(`Could not find level.dat in downloaded world`);
189
+ }
190
+ async startMinecraftServer(serverDir, worldDir) {
191
+ const containerName = `${CONTAINER_NAME_PREFIX_MC}-${crypto.randomUUID().slice(0, 8)}`;
192
+ const gameMode = this.config.challengeConfig.challengeConfig?.gameMode ?? "survival";
193
+ // Write server.properties (marctv image has no env vars for these)
194
+ const serverProperties = [
195
+ `server-port=${MC_PORT}`,
196
+ `max-players=10`,
197
+ `gamemode=${gameMode}`,
198
+ `difficulty=normal`,
199
+ `spawn-protection=0`,
200
+ `online-mode=false`,
201
+ `enable-command-block=false`,
202
+ `enable-rcon=true`,
203
+ `rcon.password=minecraft`,
204
+ `rcon.port=25575`,
205
+ `level-name=world`,
206
+ `function-permission-level=4`,
207
+ ].join("\n");
208
+ await fs.writeFile(path.join(serverDir, "server.properties"), serverProperties);
209
+ // Write ops.json with offline-mode UUIDs for watcher/viewer
210
+ const operators = [
211
+ { name: "watcher", uuid: this.generateOfflineUUID("watcher") },
212
+ { name: "KradleWebViewer", uuid: this.generateOfflineUUID("KradleWebViewer") },
213
+ ];
214
+ const opsJson = operators.map((op) => ({
215
+ uuid: op.uuid,
216
+ name: op.name,
217
+ level: 4,
218
+ bypassesPlayerLimit: false,
219
+ }));
220
+ await fs.writeFile(path.join(serverDir, "ops.json"), JSON.stringify(opsJson, null, 2));
221
+ // Copy datapack into world dir
222
+ const datapackDst = path.join(worldDir, "datapacks", "kradle");
223
+ if (existsSync(this.config.datapackPath)) {
224
+ execSync(`cp -r "${this.config.datapackPath}" "${datapackDst}"`);
225
+ }
226
+ // Add auto-op datapack — ops any player who joins (local dev convenience)
227
+ await this.writeAutoOpDatapack(path.join(worldDir, "datapacks", "kradle-local-ops"));
228
+ const args = [
229
+ "run",
230
+ "-d",
231
+ "--name",
232
+ containerName,
233
+ "-p",
234
+ `${MC_PORT}:${MC_PORT}`,
235
+ "-v",
236
+ `${serverDir}:/data`,
237
+ "-e",
238
+ "MEMORYSIZE=2G",
239
+ MC_SERVER_IMAGE,
240
+ ];
241
+ return this.docker(args.join(" ")).trim();
242
+ }
243
+ async waitForServerReady(log) {
244
+ const start = Date.now();
245
+ while (Date.now() - start < SERVER_READY_TIMEOUT_MS) {
246
+ try {
247
+ const logs = this.docker(`logs ${this.mcContainerId}`).toString();
248
+ if (logs.includes("Done (")) {
249
+ return;
250
+ }
251
+ }
252
+ catch {
253
+ // Container may not be ready yet
254
+ }
255
+ const elapsed = Math.round((Date.now() - start) / 1000);
256
+ log(` Waiting... (${elapsed}s)`);
257
+ await new Promise((r) => setTimeout(r, SERVER_READY_POLL_MS));
258
+ }
259
+ throw new Error(`Minecraft server did not become ready within ${SERVER_READY_TIMEOUT_MS / 1000}s`);
260
+ }
261
+ startArena(arenaImage) {
262
+ const containerName = `${CONTAINER_NAME_PREFIX_ARENA}-${crypto.randomUUID().slice(0, 8)}`;
263
+ // Pass the full backend job response directly to the arena — same as Studio.
264
+ const jobPayload = JSON.stringify(this.config.jobPayload);
265
+ // Arena prepends /v0 to all API paths, so strip it from our URL to avoid /v0/v0
266
+ const arenaApiUrl = this.config.apiUrl.replace(/\/v0\/?$/, "");
267
+ // Derive agent URL from API URL (dev-api → dev-agents, api → agents, etc.)
268
+ const agentBaseUrl = arenaApiUrl.replace("api.kradle.ai", "agents.kradle.ai");
269
+ const args = [
270
+ "run",
271
+ "-d",
272
+ "--name",
273
+ containerName,
274
+ `--net=container:${this.mcContainerId}`,
275
+ "-e",
276
+ "PORT=3002",
277
+ "-e",
278
+ `KRADLE_API_URL=${arenaApiUrl}`,
279
+ "-e",
280
+ `PROMPT_AGENT_BASE_URL=${agentBaseUrl}`,
281
+ "-e",
282
+ "MAX_IDLE_TIME=-1",
283
+ arenaImage,
284
+ `--job=${jobPayload}`,
285
+ ];
286
+ // Use execFileSync to bypass shell — the JSON payload contains quotes,
287
+ // parentheses, and other characters that break shell parsing.
288
+ const result = execFileSync("docker", args, {
289
+ encoding: "utf-8",
290
+ env: this.getDockerEnv(),
291
+ });
292
+ return (result ?? "").trim();
293
+ }
294
+ /**
295
+ * Write a small datapack that auto-ops any player who joins.
296
+ * Uses a tick function with function-permission-level=4 to run /op on new players.
297
+ */
298
+ async writeAutoOpDatapack(datapackDir) {
299
+ const functionsDir = path.join(datapackDir, "data", "kradle_local", "functions");
300
+ const tagsDir = path.join(datapackDir, "data", "minecraft", "tags", "functions");
301
+ await fs.mkdir(functionsDir, { recursive: true });
302
+ await fs.mkdir(tagsDir, { recursive: true });
303
+ await fs.writeFile(path.join(datapackDir, "pack.mcmeta"), JSON.stringify({ pack: { pack_format: 26, description: "Auto-OP for local dev" } }));
304
+ // Tick function: op any player not yet tagged, then tag them so it only runs once
305
+ await fs.writeFile(path.join(functionsDir, "auto_op.mcfunction"), "execute as @a[tag=!kradle_opped] run op @s[type=player]\nexecute as @a[tag=!kradle_opped] run tag @s add kradle_opped\n");
306
+ // Register as a tick function
307
+ await fs.writeFile(path.join(tagsDir, "tick.json"), JSON.stringify({ values: ["kradle_local:auto_op"] }));
308
+ }
309
+ /**
310
+ * Generate an offline-mode UUID from a Minecraft username.
311
+ * Replicates the Java algorithm: UUID.nameUUIDFromBytes("OfflinePlayer:" + name)
312
+ */
313
+ generateOfflineUUID(username) {
314
+ const md5 = crypto.createHash("md5").update(`OfflinePlayer:${username}`).digest();
315
+ const bytes = [...md5];
316
+ bytes[6] = (bytes[6] & 0x0f) | 0x30; // version 3
317
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 2
318
+ const hex = bytes.map((b) => b.toString(16).padStart(2, "0"));
319
+ return [
320
+ hex.slice(0, 4).join(""),
321
+ hex.slice(4, 6).join(""),
322
+ hex.slice(6, 8).join(""),
323
+ hex.slice(8, 10).join(""),
324
+ hex.slice(10, 16).join(""),
325
+ ].join("-");
326
+ }
327
+ streamLogs(containerId, log) {
328
+ const proc = spawn("docker", ["logs", "-f", containerId], {
329
+ stdio: ["ignore", "pipe", "pipe"],
330
+ env: this.getDockerEnv(),
331
+ });
332
+ proc.stdout?.on("data", (data) => {
333
+ for (const line of data.toString().split("\n")) {
334
+ if (line.trim())
335
+ log(line);
336
+ }
337
+ });
338
+ proc.stderr?.on("data", (data) => {
339
+ for (const line of data.toString().split("\n")) {
340
+ if (line.trim())
341
+ log(line);
342
+ }
343
+ });
344
+ return proc;
345
+ }
346
+ async waitForCompletion() {
347
+ // Wait for the arena container to exit
348
+ return new Promise((resolve) => {
349
+ const check = () => {
350
+ try {
351
+ const result = this.docker(`inspect --format={{.State.Running}} ${this.arenaContainerId}`).trim();
352
+ if (result === "false") {
353
+ resolve();
354
+ return;
355
+ }
356
+ }
357
+ catch {
358
+ // Container removed or not found — treat as finished
359
+ resolve();
360
+ return;
361
+ }
362
+ setTimeout(check, 3_000);
363
+ };
364
+ // Start checking after a brief delay
365
+ setTimeout(check, 5_000);
366
+ });
367
+ }
368
+ stopContainer(containerId) {
369
+ try {
370
+ this.docker(`stop ${containerId}`, { timeout: 30_000 });
371
+ }
372
+ catch {
373
+ // May already be stopped
374
+ }
375
+ try {
376
+ this.docker(`rm -f ${containerId}`);
377
+ }
378
+ catch {
379
+ // May already be removed
380
+ }
381
+ }
382
+ /**
383
+ * Find and remove orphaned containers from previous local runs.
384
+ */
385
+ static cleanupOrphans(log) {
386
+ const env = { ...process.env, PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin` };
387
+ for (const prefix of [CONTAINER_NAME_PREFIX_MC, CONTAINER_NAME_PREFIX_ARENA]) {
388
+ try {
389
+ const output = execSync(`docker ps -a --filter "name=${prefix}" --format "{{.ID}}"`, {
390
+ encoding: "utf-8",
391
+ env,
392
+ }).trim();
393
+ if (output) {
394
+ for (const id of output.split("\n")) {
395
+ if (id.trim()) {
396
+ log?.(`Cleaning up orphaned container: ${id.trim().slice(0, 12)}`);
397
+ try {
398
+ execSync(`docker rm -f ${id.trim()}`, { env, stdio: "ignore" });
399
+ }
400
+ catch {
401
+ // Ignore
402
+ }
403
+ }
404
+ }
405
+ }
406
+ }
407
+ catch {
408
+ // Docker not available or no containers found
409
+ }
410
+ }
411
+ }
412
+ /**
413
+ * Run a Docker command with PATH augmented for macOS Docker Desktop.
414
+ */
415
+ docker(args, options) {
416
+ const env = this.getDockerEnv();
417
+ const stdio = options?.stdio ?? "pipe";
418
+ const result = execSync(`docker ${args}`, {
419
+ encoding: "utf-8",
420
+ env,
421
+ stdio,
422
+ timeout: options?.timeout,
423
+ });
424
+ return result ?? "";
425
+ }
426
+ getDockerEnv() {
427
+ return {
428
+ ...process.env,
429
+ PATH: `${process.env.PATH}:/opt/homebrew/bin:/usr/local/bin`,
430
+ };
431
+ }
432
+ }
@@ -192,6 +192,7 @@ export declare const JobResponseSchema: z.ZodObject<{
192
192
  inputOrder: z.ZodNumber;
193
193
  }, z.core.$strip>>>;
194
194
  id: z.ZodOptional<z.ZodString>;
195
+ jobApiKey: z.ZodOptional<z.ZodString>;
195
196
  }, z.core.$strip>;
196
197
  export declare const RunStatusSchema: z.ZodObject<{
197
198
  id: z.ZodString;
@@ -73,6 +73,7 @@ export const JobResponseSchema = z.object({
73
73
  runIds: z.array(z.string()).optional(),
74
74
  participants: z.record(z.string(), RunParticipantSchema).optional(),
75
75
  id: z.string().optional(),
76
+ jobApiKey: z.string().optional(),
76
77
  });
77
78
  export const RunStatusSchema = z.object({
78
79
  id: z.string(),
@@ -87,50 +87,6 @@
87
87
  "update.js"
88
88
  ]
89
89
  },
90
- "agent:list": {
91
- "aliases": [],
92
- "args": {},
93
- "description": "List all agents",
94
- "examples": [
95
- "<%= config.bin %> <%= command.id %>"
96
- ],
97
- "flags": {
98
- "api-key": {
99
- "description": "Kradle API key",
100
- "env": "KRADLE_API_KEY",
101
- "name": "api-key",
102
- "required": true,
103
- "hasDynamicHelp": false,
104
- "multiple": false,
105
- "type": "option"
106
- },
107
- "api-url": {
108
- "description": "Kradle Web API URL",
109
- "env": "KRADLE_API_URL",
110
- "name": "api-url",
111
- "required": true,
112
- "default": "https://api.kradle.ai/v0",
113
- "hasDynamicHelp": false,
114
- "multiple": false,
115
- "type": "option"
116
- }
117
- },
118
- "hasDynamicHelp": false,
119
- "hiddenAliases": [],
120
- "id": "agent:list",
121
- "pluginAlias": "kradle",
122
- "pluginName": "kradle",
123
- "pluginType": "core",
124
- "strict": true,
125
- "enableJsonFlag": false,
126
- "isESM": true,
127
- "relativePath": [
128
- "dist",
129
- "commands",
130
- "agent",
131
- "list.js"
132
- ]
133
- },
134
90
  "ai-docs:challenges-sdk": {
135
91
  "aliases": [],
136
92
  "args": {
@@ -579,7 +535,10 @@
579
535
  "<%= config.bin %> <%= command.id %> my-challenge --no-open",
580
536
  "<%= config.bin %> <%= command.id %> my-challenge --no-wait",
581
537
  "<%= config.bin %> <%= command.id %> my-challenge team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
582
- "<%= config.bin %> <%= command.id %> capture-the-flag red=team-kradle:gemini-3-flash blue=team-kradle:grok-4-1-fast"
538
+ "<%= config.bin %> <%= command.id %> capture-the-flag red=team-kradle:gemini-3-flash blue=team-kradle:grok-4-1-fast",
539
+ "<%= config.bin %> <%= command.id %> my-challenge --local",
540
+ "<%= config.bin %> <%= command.id %> my-challenge --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
541
+ "<%= config.bin %> <%= command.id %> my-challenge --local --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234"
583
542
  ],
584
543
  "flags": {
585
544
  "studio": {
@@ -596,6 +555,21 @@
596
555
  "allowNo": false,
597
556
  "type": "boolean"
598
557
  },
558
+ "local": {
559
+ "char": "l",
560
+ "description": "Run the challenge locally using Docker (spins up MC server + arena-minecraft)",
561
+ "name": "local",
562
+ "allowNo": false,
563
+ "type": "boolean"
564
+ },
565
+ "arena-image": {
566
+ "description": "Override arena-minecraft Docker image URL (for testing dev/staging builds)",
567
+ "env": "KRADLE_ARENA_IMAGE",
568
+ "name": "arena-image",
569
+ "hasDynamicHelp": false,
570
+ "multiple": false,
571
+ "type": "option"
572
+ },
599
573
  "no-open": {
600
574
  "description": "Don't open the run URL in the browser",
601
575
  "name": "no-open",
@@ -668,6 +642,15 @@
668
642
  "hasDynamicHelp": false,
669
643
  "multiple": false,
670
644
  "type": "option"
645
+ },
646
+ "challenges-path": {
647
+ "description": "Absolute path to the challenges directory",
648
+ "env": "KRADLE_CHALLENGES_PATH",
649
+ "name": "challenges-path",
650
+ "default": "~/Documents/kradle-studio/challenges",
651
+ "hasDynamicHelp": false,
652
+ "multiple": false,
653
+ "type": "option"
671
654
  }
672
655
  },
673
656
  "hasDynamicHelp": false,
@@ -1407,6 +1390,50 @@
1407
1390
  "push.js"
1408
1391
  ]
1409
1392
  },
1393
+ "agent:list": {
1394
+ "aliases": [],
1395
+ "args": {},
1396
+ "description": "List all agents",
1397
+ "examples": [
1398
+ "<%= config.bin %> <%= command.id %>"
1399
+ ],
1400
+ "flags": {
1401
+ "api-key": {
1402
+ "description": "Kradle API key",
1403
+ "env": "KRADLE_API_KEY",
1404
+ "name": "api-key",
1405
+ "required": true,
1406
+ "hasDynamicHelp": false,
1407
+ "multiple": false,
1408
+ "type": "option"
1409
+ },
1410
+ "api-url": {
1411
+ "description": "Kradle Web API URL",
1412
+ "env": "KRADLE_API_URL",
1413
+ "name": "api-url",
1414
+ "required": true,
1415
+ "default": "https://api.kradle.ai/v0",
1416
+ "hasDynamicHelp": false,
1417
+ "multiple": false,
1418
+ "type": "option"
1419
+ }
1420
+ },
1421
+ "hasDynamicHelp": false,
1422
+ "hiddenAliases": [],
1423
+ "id": "agent:list",
1424
+ "pluginAlias": "kradle",
1425
+ "pluginName": "kradle",
1426
+ "pluginType": "core",
1427
+ "strict": true,
1428
+ "enableJsonFlag": false,
1429
+ "isESM": true,
1430
+ "relativePath": [
1431
+ "dist",
1432
+ "commands",
1433
+ "agent",
1434
+ "list.js"
1435
+ ]
1436
+ },
1410
1437
  "challenge:runs:get": {
1411
1438
  "aliases": [],
1412
1439
  "args": {
@@ -1528,5 +1555,5 @@
1528
1555
  ]
1529
1556
  }
1530
1557
  },
1531
- "version": "0.6.7"
1558
+ "version": "0.6.8"
1532
1559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kradle",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Kradle's CLI. Manage challenges, experiments, agents and more!",
5
5
  "keywords": [
6
6
  "cli"
@@ -417,11 +417,13 @@ When no agents are specified, the command enters interactive mode:
417
417
  | Flag | Short | Description |
418
418
  |------|-------|-------------|
419
419
  | `--studio` | `-s` | Run in local studio environment instead of production |
420
+ | `--local` | `-l` | Run locally using Docker (spins up MC server + arena-minecraft containers) |
421
+ | `--arena-image` | | Override arena-minecraft Docker image URL (env: `KRADLE_ARENA_IMAGE`) |
420
422
  | `--no-open` | | Don't open the run URL in the browser |
421
423
  | `--no-wait` | | Don't wait for completion (fire and forget) |
422
424
  | `--no-summary` | | Don't wait for the AI-generated summary |
423
425
 
424
- **Behavior:**
426
+ **Behavior (remote, default):**
425
427
  1. Parses inline agents or enters interactive mode for agent selection
426
428
  2. Creates a run with the challenge and specified participants
427
429
  3. Opens the run URL in the browser (unless `--no-open`)
@@ -430,6 +432,31 @@ When no agents are specified, the command enters interactive mode:
430
432
 
431
433
  **Terminal states:** The polling stops when the run reaches: `finished`, `game_over`, `error`, `completed`, `cancelled`, `timeout`, or `failed`.
432
434
 
435
+ **Behavior (local, with `--local`):**
436
+ 1. Parses inline agents or enters interactive mode for agent selection
437
+ 2. Fetches challenge config from the API
438
+ 3. Builds the challenge datapack locally (with validation)
439
+ 4. Creates a real backend job (`env: "studio"`) — returns full job object with agent configs, participant IDs, etc.
440
+ 5. Downloads the challenge world from the cloud
441
+ 6. Starts a Minecraft server Docker container (`marctv/minecraft-papermc-server:1.20.4`)
442
+ 7. Starts an arena-minecraft Docker container (shares MC server network), passing the full backend job response
443
+ 8. All players who join the MC server are automatically OP'd (via an auto-op datapack)
444
+ 9. Streams arena logs to stdout until the run completes
445
+ 10. Ctrl+C triggers graceful shutdown (arena `/shutdown` endpoint, then container stop/remove)
446
+
447
+ **Local mode requirements:**
448
+ - Docker Desktop must be running (Docker is only checked when `--local` is used)
449
+ - The challenge must exist locally (with `challenge.ts` and `config.ts`)
450
+ - First run pulls images which may take several minutes
451
+ - The agent URL is derived from the API URL (e.g., `dev-api.kradle.ai` → `dev-agents.kradle.ai`)
452
+
453
+ **Local mode incompatibilities:** `--local` cannot be combined with `--studio`, `--no-open`, `--no-wait`, `--screenshots`, or `--record`.
454
+
455
+ **Arena image override:** The `--arena-image` flag (or `KRADLE_ARENA_IMAGE` env var) allows testing dev or staging arena builds:
456
+ - Dev: `us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:<sha>`
457
+ - Staging: `us-central1-docker.pkg.dev/kradle-staging/staging-arenas/minecraft:<sha>`
458
+ - Prod (default): `us-central1-docker.pkg.dev/kradle-prod-449119/prod-arenas/minecraft:<sha>`
459
+
433
460
  **Examples:**
434
461
  ```bash
435
462
  # Interactive mode - prompts for agent selection
@@ -454,6 +481,13 @@ kradle challenge run my-challenge team-kradle:gemini-3-flash --no-open
454
481
 
455
482
  # Fire and forget (don't wait for completion)
456
483
  kradle challenge run my-challenge team-kradle:gemini-3-flash --no-wait
484
+
485
+ # Run locally with Docker
486
+ kradle challenge run my-challenge --local team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast
487
+
488
+ # Run locally with a dev arena image
489
+ kradle challenge run my-challenge --local \
490
+ --arena-image us-central1-docker.pkg.dev/mckradle-3c267/dev-arenas/minecraft:abc1234
457
491
  ```
458
492
 
459
493
  ---
@@ -1288,6 +1322,7 @@ kradle challenge build --all --public
1288
1322
  | `kradle challenge pull [name]` | Pull challenge from cloud |
1289
1323
  | `kradle challenge watch <name>` | Watch and auto-rebuild |
1290
1324
  | `kradle challenge run <name>` | Run challenge and wait for completion |
1325
+ | `kradle challenge run <name> --local` | Run challenge locally using Docker |
1291
1326
  | `kradle challenge runs list` | List recent runs |
1292
1327
  | `kradle challenge runs get <run-id>` | Get details and logs for a run |
1293
1328
  | `kradle experiment create <name>` | Create new experiment |