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 +35 -0
- package/dist/commands/challenge/run.d.ts +7 -0
- package/dist/commands/challenge/run.js +112 -4
- package/dist/config/releases/arena-minecraft.d.ts +1 -0
- package/dist/config/releases/arena-minecraft.js +2 -0
- package/dist/lib/api-client.d.ts +13 -0
- package/dist/lib/api-client.js +12 -0
- package/dist/lib/local-runner.d.ts +62 -0
- package/dist/lib/local-runner.js +432 -0
- package/dist/lib/schemas.d.ts +1 -0
- package/dist/lib/schemas.js +1 -0
- package/oclif.manifest.json +73 -46
- package/package.json +1 -1
- package/static/ai_docs/LLM_CLI_REFERENCE.md +36 -1
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";
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/lib/schemas.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/schemas.js
CHANGED
|
@@ -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(),
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
1558
|
+
"version": "0.6.8"
|
|
1532
1559
|
}
|
package/package.json
CHANGED
|
@@ -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 |
|