kradle 0.6.6 → 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.
@@ -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(),
@@ -12,6 +12,8 @@ import pc from "picocolors";
12
12
  */
13
13
  const FILTERED_ERROR_MESSAGES = [
14
14
  "Fake names cannot be longer than 40 characters", // Actually they can be longer
15
+ "Objective names cannot be longer than 16 characters", // Actually they can be longer
16
+ "Player names cannot be longer than 16 characters", // Actually they can be longer
15
17
  ];
16
18
  /**
17
19
  * Get a human-readable severity label with color
@@ -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": {
@@ -215,12 +171,12 @@
215
171
  "aliases": [],
216
172
  "args": {
217
173
  "challengeSlug": {
218
- "description": "Challenge slug to build and upload. Can be used multiple times to build and upload multiple challenges at once. Incompatible with --all flag.",
174
+ "description": "Challenge slug to build. Can be used multiple times to build multiple challenges at once. Incompatible with --all flag.",
219
175
  "name": "challengeSlug",
220
176
  "required": false
221
177
  }
222
178
  },
223
- "description": "Build and upload challenge datapack and config",
179
+ "description": "Build challenge datapack locally, with optional upload",
224
180
  "examples": [
225
181
  "<%= config.bin %> <%= command.id %> my-challenge",
226
182
  "<%= config.bin %> <%= command.id %> my-challenge my-other-challenge",
@@ -242,30 +198,34 @@
242
198
  "type": "boolean"
243
199
  },
244
200
  "no-validate": {
245
- "description": "Skip datapack validation before upload",
201
+ "description": "Skip datapack validation",
246
202
  "name": "no-validate",
247
203
  "allowNo": false,
248
204
  "type": "boolean"
249
205
  },
250
- "api-key": {
251
- "description": "Kradle API key",
252
- "env": "KRADLE_API_KEY",
253
- "name": "api-key",
254
- "required": true,
255
- "hasDynamicHelp": false,
256
- "multiple": false,
257
- "type": "option"
206
+ "no-upload": {
207
+ "description": "Build datapack locally only (skip cloud config/datapack upload)",
208
+ "name": "no-upload",
209
+ "allowNo": false,
210
+ "type": "boolean"
258
211
  },
259
212
  "api-url": {
260
213
  "description": "Kradle Web API URL",
261
214
  "env": "KRADLE_API_URL",
262
215
  "name": "api-url",
263
- "required": true,
264
216
  "default": "https://api.kradle.ai/v0",
265
217
  "hasDynamicHelp": false,
266
218
  "multiple": false,
267
219
  "type": "option"
268
220
  },
221
+ "api-key": {
222
+ "description": "Kradle API key",
223
+ "env": "KRADLE_API_KEY",
224
+ "name": "api-key",
225
+ "hasDynamicHelp": false,
226
+ "multiple": false,
227
+ "type": "option"
228
+ },
269
229
  "challenges-path": {
270
230
  "description": "Absolute path to the challenges directory",
271
231
  "env": "KRADLE_CHALLENGES_PATH",
@@ -575,7 +535,10 @@
575
535
  "<%= config.bin %> <%= command.id %> my-challenge --no-open",
576
536
  "<%= config.bin %> <%= command.id %> my-challenge --no-wait",
577
537
  "<%= config.bin %> <%= command.id %> my-challenge team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
578
- "<%= 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"
579
542
  ],
580
543
  "flags": {
581
544
  "studio": {
@@ -592,6 +555,21 @@
592
555
  "allowNo": false,
593
556
  "type": "boolean"
594
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
+ },
595
573
  "no-open": {
596
574
  "description": "Don't open the run URL in the browser",
597
575
  "name": "no-open",
@@ -664,6 +642,15 @@
664
642
  "hasDynamicHelp": false,
665
643
  "multiple": false,
666
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"
667
654
  }
668
655
  },
669
656
  "hasDynamicHelp": false,
@@ -1403,6 +1390,50 @@
1403
1390
  "push.js"
1404
1391
  ]
1405
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
+ },
1406
1437
  "challenge:runs:get": {
1407
1438
  "aliases": [],
1408
1439
  "args": {
@@ -1524,5 +1555,5 @@
1524
1555
  ]
1525
1556
  }
1526
1557
  },
1527
- "version": "0.6.6"
1558
+ "version": "0.6.8"
1528
1559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kradle",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Kradle's CLI. Manage challenges, experiments, agents and more!",
5
5
  "keywords": [
6
6
  "cli"