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.
- package/README.md +44 -8
- package/dist/commands/challenge/build.d.ts +4 -2
- package/dist/commands/challenge/build.js +40 -9
- 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/challenge.d.ts +12 -0
- package/dist/lib/challenge.js +29 -18
- 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/dist/lib/validator.js +2 -0
- package/oclif.manifest.json +89 -58
- package/package.json +1 -1
- package/static/ai_docs/LLM_CLI_REFERENCE.md +53 -11
package/README.md
CHANGED
|
@@ -81,23 +81,24 @@ This creates a `challenges/<challenge-name>/` folder with:
|
|
|
81
81
|
|
|
82
82
|
### Build Challenge
|
|
83
83
|
|
|
84
|
-
Build
|
|
84
|
+
Build and validate challenge datapacks locally, with optional upload:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
kradle challenge build <challenge-name>
|
|
88
|
+
kradle challenge build <challenge-name> --no-upload # Build locally only (no cloud upload)
|
|
88
89
|
kradle challenge build <challenge-name> --no-validate # Skip validation
|
|
89
90
|
kradle challenge build <challenge-name> --public # Upload as public
|
|
90
91
|
kradle challenge build --all # Build all local challenges
|
|
91
92
|
```
|
|
92
93
|
|
|
93
94
|
This command:
|
|
94
|
-
1.
|
|
95
|
-
2.
|
|
96
|
-
3.
|
|
97
|
-
4.
|
|
98
|
-
5. Uploads the datapack to GCS
|
|
95
|
+
1. Builds the datapack by executing `challenge.ts`
|
|
96
|
+
2. Validates the datapack using Spyglass (unless `--no-validate`)
|
|
97
|
+
3. If upload is enabled (default), creates the challenge in the cloud if needed
|
|
98
|
+
4. If upload is enabled (default), uploads `config.ts` and datapack
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
Use `--no-upload` to build locally only. Use `--no-validate` to skip validation.
|
|
101
|
+
`--public` is only valid when upload is enabled (default).
|
|
101
102
|
|
|
102
103
|
### Delete Challenge
|
|
103
104
|
|
|
@@ -176,6 +177,39 @@ When no agents are specified, the command enters interactive mode, fetching the
|
|
|
176
177
|
|
|
177
178
|
By default, the command opens the run URL in your browser and polls until the run completes, then displays the outcome.
|
|
178
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
|
+
|
|
179
213
|
### List Runs
|
|
180
214
|
|
|
181
215
|
List your recent runs:
|
|
@@ -478,7 +512,7 @@ Each challenge is a folder in `challenges/<slug>/` containing:
|
|
|
478
512
|
1. `kradle challenge create <slug>` creates the folder with `challenge.ts`
|
|
479
513
|
2. The create command automatically builds, uploads, and downloads the config from the cloud API
|
|
480
514
|
3. The downloaded JSON is converted into a typed TypeScript `config.ts` file
|
|
481
|
-
4. `kradle challenge build <slug>`
|
|
515
|
+
4. `kradle challenge build <slug>` builds datapacks locally and uploads `config.ts` + datapack by default (use `--no-upload` for local-only build)
|
|
482
516
|
5. You can modify `config.ts` locally and run `build` to sync changes to the cloud
|
|
483
517
|
|
|
484
518
|
## Architecture
|
|
@@ -507,6 +541,8 @@ kradle-cli/
|
|
|
507
541
|
│ │ │ └── runs/ # Run listing and logs commands
|
|
508
542
|
│ │ ├── experiment/ # Experiment commands
|
|
509
543
|
│ │ └── world/ # World management commands
|
|
544
|
+
│ ├── config/
|
|
545
|
+
│ │ └── releases/ # Release tags (updated by CI)
|
|
510
546
|
│ └── lib/ # Core libraries
|
|
511
547
|
│ └── experiment/ # Experiment system
|
|
512
548
|
├── tests/ # Integration tests
|
|
@@ -6,13 +6,15 @@ export default class Build extends Command {
|
|
|
6
6
|
challengeSlug: import("@oclif/core/interfaces").Arg<string | undefined>;
|
|
7
7
|
};
|
|
8
8
|
static flags: {
|
|
9
|
-
"api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
-
"api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
9
|
"challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
10
|
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
11
|
public: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
12
|
"no-validate": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
"no-upload": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
"api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
"api-key": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
16
|
};
|
|
16
17
|
static strict: boolean;
|
|
17
18
|
run(): Promise<void>;
|
|
19
|
+
private requireApiKey;
|
|
18
20
|
}
|
|
@@ -5,7 +5,7 @@ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.
|
|
|
5
5
|
import { Challenge } from "../../lib/challenge.js";
|
|
6
6
|
import { getConfigFlags } from "../../lib/flags.js";
|
|
7
7
|
export default class Build extends Command {
|
|
8
|
-
static description = "Build
|
|
8
|
+
static description = "Build challenge datapack locally, with optional upload";
|
|
9
9
|
static examples = [
|
|
10
10
|
"<%= config.bin %> <%= command.id %> my-challenge",
|
|
11
11
|
"<%= config.bin %> <%= command.id %> my-challenge my-other-challenge",
|
|
@@ -13,7 +13,7 @@ export default class Build extends Command {
|
|
|
13
13
|
];
|
|
14
14
|
static args = {
|
|
15
15
|
challengeSlug: getChallengeSlugArgument({
|
|
16
|
-
description: "Challenge slug to build
|
|
16
|
+
description: "Challenge slug to build. Can be used multiple times to build multiple challenges at once. Incompatible with --all flag.",
|
|
17
17
|
required: false,
|
|
18
18
|
}),
|
|
19
19
|
};
|
|
@@ -21,10 +21,23 @@ export default class Build extends Command {
|
|
|
21
21
|
all: Flags.boolean({ char: "a", description: "Build all challenges in the challenges directory", default: false }),
|
|
22
22
|
public: Flags.boolean({ char: "p", description: "Upload challenges as public.", default: false }),
|
|
23
23
|
"no-validate": Flags.boolean({
|
|
24
|
-
description: "Skip datapack validation
|
|
24
|
+
description: "Skip datapack validation",
|
|
25
25
|
default: false,
|
|
26
26
|
}),
|
|
27
|
-
|
|
27
|
+
"no-upload": Flags.boolean({
|
|
28
|
+
description: "Build datapack locally only (skip cloud config/datapack upload)",
|
|
29
|
+
default: false,
|
|
30
|
+
}),
|
|
31
|
+
"api-url": Flags.string({
|
|
32
|
+
description: "Kradle Web API URL",
|
|
33
|
+
env: "KRADLE_API_URL",
|
|
34
|
+
default: "https://api.kradle.ai/v0",
|
|
35
|
+
}),
|
|
36
|
+
"api-key": Flags.string({
|
|
37
|
+
description: "Kradle API key",
|
|
38
|
+
env: "KRADLE_API_KEY",
|
|
39
|
+
}),
|
|
40
|
+
...getConfigFlags("challenges-path"),
|
|
28
41
|
};
|
|
29
42
|
static strict = false;
|
|
30
43
|
async run() {
|
|
@@ -32,6 +45,9 @@ export default class Build extends Command {
|
|
|
32
45
|
if (flags.all && argv.length > 0) {
|
|
33
46
|
this.error(pc.red("Cannot use --all flag with challenge slugs"));
|
|
34
47
|
}
|
|
48
|
+
if (flags["no-upload"] && flags.public) {
|
|
49
|
+
this.error(pc.red("Cannot use --public with --no-upload"));
|
|
50
|
+
}
|
|
35
51
|
if (!flags.all && argv.length === 0) {
|
|
36
52
|
// Show help if no challenge slugs are provided - https://github.com/oclif/oclif/issues/183#issuecomment-1933104981
|
|
37
53
|
await new (await loadHelpClass(this.config))(this.config).showHelp([Build.id]);
|
|
@@ -41,7 +57,8 @@ export default class Build extends Command {
|
|
|
41
57
|
this.log(pc.blue("Building all challenges"));
|
|
42
58
|
}
|
|
43
59
|
const challengeSlugs = flags.all ? await Challenge.getLocalChallenges() : argv;
|
|
44
|
-
const
|
|
60
|
+
const shouldUpload = !flags["no-upload"];
|
|
61
|
+
const api = shouldUpload ? new ApiClient(flags["api-url"], this.requireApiKey(flags["api-key"])) : null;
|
|
45
62
|
for (const challengeSlug of challengeSlugs) {
|
|
46
63
|
// Validate that the challenge exists locally
|
|
47
64
|
const validation = await Challenge.validateForLocalOperation(challengeSlug, flags["challenges-path"]);
|
|
@@ -51,10 +68,17 @@ export default class Build extends Command {
|
|
|
51
68
|
const challenge = new Challenge(extractShortSlug(challengeSlug), flags["challenges-path"]);
|
|
52
69
|
this.log(pc.blue(`==== Building challenge: ${challenge.shortSlug} ====`));
|
|
53
70
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
if (shouldUpload && api) {
|
|
72
|
+
await challenge.buildAndUpload(api, {
|
|
73
|
+
asPublic: flags.public,
|
|
74
|
+
validate: !flags["no-validate"],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
await challenge.buildLocal({
|
|
79
|
+
validate: !flags["no-validate"],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
58
82
|
}
|
|
59
83
|
catch (error) {
|
|
60
84
|
this.error(pc.red(`Build failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
@@ -62,4 +86,11 @@ export default class Build extends Command {
|
|
|
62
86
|
this.log();
|
|
63
87
|
}
|
|
64
88
|
}
|
|
89
|
+
requireApiKey(apiKey) {
|
|
90
|
+
if (apiKey) {
|
|
91
|
+
return apiKey;
|
|
92
|
+
}
|
|
93
|
+
this.error(pc.red("Missing required flag --api-key (or KRADLE_API_KEY) unless using --no-upload"));
|
|
94
|
+
throw new Error("Unreachable");
|
|
95
|
+
}
|
|
65
96
|
}
|
|
@@ -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);
|
package/dist/lib/challenge.d.ts
CHANGED
|
@@ -51,6 +51,18 @@ export declare class Challenge {
|
|
|
51
51
|
*/
|
|
52
52
|
build(silent?: boolean, config?: ChallengeConfigSchemaType): Promise<void>;
|
|
53
53
|
loadConfig(): Promise<ChallengeConfigSchemaType>;
|
|
54
|
+
/**
|
|
55
|
+
* Build the challenge datapack locally.
|
|
56
|
+
* @param options - Build options
|
|
57
|
+
* @param options.validate - Whether to validate the datapack after build (default: true).
|
|
58
|
+
* @returns The loaded config and datapack hash.
|
|
59
|
+
*/
|
|
60
|
+
buildLocal(options?: {
|
|
61
|
+
validate?: boolean;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
config: ChallengeConfigSchemaType;
|
|
64
|
+
datapackHash: string;
|
|
65
|
+
}>;
|
|
54
66
|
/**
|
|
55
67
|
* Build the challenge datapack and upload it to the cloud.
|
|
56
68
|
* @param api - The API client to use.
|
package/dist/lib/challenge.js
CHANGED
|
@@ -162,17 +162,15 @@ export class Challenge {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
/**
|
|
165
|
-
* Build the challenge datapack
|
|
166
|
-
* @param api - The API client to use.
|
|
165
|
+
* Build the challenge datapack locally.
|
|
167
166
|
* @param options - Build options
|
|
168
|
-
* @param options.
|
|
169
|
-
* @
|
|
170
|
-
* @returns The config and datapack hash.
|
|
167
|
+
* @param options.validate - Whether to validate the datapack after build (default: true).
|
|
168
|
+
* @returns The loaded config and datapack hash.
|
|
171
169
|
*/
|
|
172
|
-
async
|
|
173
|
-
const {
|
|
170
|
+
async buildLocal(options = { validate: true }) {
|
|
171
|
+
const { validate = true } = options;
|
|
174
172
|
// Start validation service initialization in parallel with build (if validation enabled)
|
|
175
|
-
// This overlaps the
|
|
173
|
+
// This overlaps the init time with the build process.
|
|
176
174
|
let validationService = null;
|
|
177
175
|
if (validate) {
|
|
178
176
|
validationService = ValidationService.create(this.builtDatapackPath);
|
|
@@ -182,19 +180,12 @@ export class Challenge {
|
|
|
182
180
|
await validationService?.close();
|
|
183
181
|
throw new Error(`Challenge "${this.shortSlug}" does not exist locally. Make sure both the challenge.ts file and the config.ts file exist.`);
|
|
184
182
|
}
|
|
185
|
-
// Ensure challenge exists in the cloud
|
|
186
|
-
if (!(await api.challengeExists(this.shortSlug))) {
|
|
187
|
-
console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
|
|
188
|
-
console.log(pc.yellow(`Creating challenge: ${this.shortSlug}`));
|
|
189
|
-
await api.createChallenge(this.shortSlug);
|
|
190
|
-
console.log(pc.green(`✓ Challenge created in cloud`));
|
|
191
|
-
}
|
|
192
183
|
const config = await this.loadConfig();
|
|
193
184
|
// Build datapack (validation service initializes in parallel)
|
|
194
185
|
console.log(pc.blue(`>> Building datapack: ${this.shortSlug}`));
|
|
195
186
|
await this.build(false, config);
|
|
196
187
|
console.log(pc.green(`✓ Datapack built\n`));
|
|
197
|
-
// Validate datapack
|
|
188
|
+
// Validate datapack after building (if enabled)
|
|
198
189
|
if (validate && validationService) {
|
|
199
190
|
console.log(pc.blue(`>> Validating datapack: ${this.shortSlug}`));
|
|
200
191
|
try {
|
|
@@ -202,13 +193,34 @@ export class Challenge {
|
|
|
202
193
|
console.log(formatValidationResult(validationResult));
|
|
203
194
|
console.log();
|
|
204
195
|
if (validationResult.hasErrors) {
|
|
205
|
-
throw new Error(`Validation failed with ${validationResult.errors.length} error(s).
|
|
196
|
+
throw new Error(`Validation failed with ${validationResult.errors.length} error(s). Build aborted.`);
|
|
206
197
|
}
|
|
207
198
|
}
|
|
208
199
|
finally {
|
|
209
200
|
await validationService.close();
|
|
210
201
|
}
|
|
211
202
|
}
|
|
203
|
+
const datapackHash = await this.getDatapackHash();
|
|
204
|
+
return { config, datapackHash };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build the challenge datapack and upload it to the cloud.
|
|
208
|
+
* @param api - The API client to use.
|
|
209
|
+
* @param options - Build options
|
|
210
|
+
* @param options.asPublic - Whether the challenge should be uploaded as public.
|
|
211
|
+
* @param options.validate - Whether to validate the datapack before upload (default: true).
|
|
212
|
+
* @returns The config and datapack hash.
|
|
213
|
+
*/
|
|
214
|
+
async buildAndUpload(api, options = { asPublic: false, validate: true }) {
|
|
215
|
+
const { asPublic, validate = true } = options;
|
|
216
|
+
const { config, datapackHash } = await this.buildLocal({ validate });
|
|
217
|
+
// Ensure challenge exists in the cloud
|
|
218
|
+
if (!(await api.challengeExists(this.shortSlug))) {
|
|
219
|
+
console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
|
|
220
|
+
console.log(pc.yellow(`Creating challenge: ${this.shortSlug}`));
|
|
221
|
+
await api.createChallenge(this.shortSlug);
|
|
222
|
+
console.log(pc.green(`✓ Challenge created in cloud`));
|
|
223
|
+
}
|
|
212
224
|
// Ensure challenge's visibility is set to private - else, temporarily set it to private
|
|
213
225
|
const cloudChallengeVisibility = (await api.getChallenge(this.shortSlug, ["visibility"])).visibility;
|
|
214
226
|
if (cloudChallengeVisibility === "public") {
|
|
@@ -230,7 +242,6 @@ export class Challenge {
|
|
|
230
242
|
await api.updateChallengeVisibility(this.shortSlug, "public");
|
|
231
243
|
console.log(pc.green(`✓ Challenge visibility set to public\n`));
|
|
232
244
|
}
|
|
233
|
-
const datapackHash = await this.getDatapackHash();
|
|
234
245
|
return { config, datapackHash };
|
|
235
246
|
}
|
|
236
247
|
/**
|
|
@@ -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 {};
|