kradle 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,18 +18,19 @@ Kradle's CLI for managing Minecraft challenges, experiments, agents, and more!
18
18
 
19
19
  ## Installation
20
20
 
21
+ Make sure you have [NodeJS 22.18 or higher](https://nodejs.org/en/download/current) installed.
22
+
21
23
  1. Install Kradle's CLI globally
22
24
  ```
23
25
  npm i -g kradle
24
26
  ```
25
- 2. Initialize a new directory to store challenges and experiments
27
+ 2. Initialize a new directory to store challenges, and other Kradle resources:
26
28
  ```
27
29
  kradle init
28
30
  ```
29
- 3. Congrats 🎉 You can now create a new challenge or a new experiment:
31
+ 3. Congrats 🎉 You can now create a new challenge:
30
32
  ```
31
33
  kradle challenge create <challenge-name>
32
- kradle experiment create <experiment-name>
33
34
  ```
34
35
 
35
36
  In addition, you can enable [autocomplete](#Autocomplete).
@@ -184,6 +185,7 @@ kradle experiment run <name> # Resume current version or c
184
185
  kradle experiment run <name> --new-version # Start a new version
185
186
  kradle experiment run <name> --max-concurrent 10 # Control parallelism (default: 5)
186
187
  kradle experiment run <name> --download-recordings # Auto-download recordings as runs complete
188
+ kradle experiment run <name> --download-logs # Auto-download logs as runs complete
187
189
  ```
188
190
 
189
191
  The run command:
@@ -207,6 +209,21 @@ kradle experiment recordings <name> <run-id> --all # Download all participants
207
209
 
208
210
  Recordings are saved to `experiments/<name>/versions/<version>/recordings/<run-id>/`.
209
211
 
212
+ ### Download Logs
213
+
214
+ Download logs and run results from completed experiment runs:
215
+
216
+ ```bash
217
+ kradle experiment logs <name> # Interactive selection of run
218
+ kradle experiment logs <name> <run-id> # Download specific run
219
+ kradle experiment logs <name> --all # Download all runs
220
+ kradle experiment logs <name> --version 2 # Download from specific version
221
+ ```
222
+
223
+ Files are saved to `experiments/<name>/versions/<version>/logs/<run-id>/`:
224
+ - `run.json` - Run result with status, end_state, and participant results
225
+ - `logs.json` - Log entries from the run
226
+
210
227
  ### List Experiments
211
228
 
212
229
  List all local experiments:
@@ -13,13 +13,14 @@ export default class List extends Command {
13
13
  const { flags } = await this.parse(List);
14
14
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
15
15
  this.log(pc.blue(">> Loading challenges..."));
16
- const [cloudChallenges, localChallenges, human] = await Promise.all([
16
+ const [cloudChallenges, kradleChallenges, localChallenges, human] = await Promise.all([
17
17
  api.listChallenges(),
18
+ api.listKradleChallenges(),
18
19
  Challenge.getLocalChallenges(),
19
20
  api.getHuman(),
20
21
  ]);
21
- // Create a map for easy lookup
22
- const cloudMap = new Map(cloudChallenges.map((c) => [c.slug, c]));
22
+ // Create a map for easy lookup (user's challenges + team-kradle challenges)
23
+ const cloudMap = new Map([...cloudChallenges, ...kradleChallenges].map((c) => [c.slug, c]));
23
24
  const allSlugs = new Set([...cloudMap.keys(), ...localChallenges.map((id) => `${human.username}:${id}`)]);
24
25
  this.log(pc.bold("\nChallenges:\n"));
25
26
  this.log(`${"Status".padEnd(15)} ${"Slug".padEnd(40)} ${"Name".padEnd(30)}`);
@@ -1,14 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import path from "node:path";
4
3
  import { Command, Flags } from "@oclif/core";
5
4
  import enquirer from "enquirer";
6
5
  import { Listr } from "listr2";
7
6
  import pc from "picocolors";
8
- import * as tar from "tar";
9
7
  import { ApiClient } from "../../lib/api-client.js";
10
8
  import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
11
- import { Challenge, SOURCE_FOLDER } from "../../lib/challenge.js";
9
+ import { Challenge } from "../../lib/challenge.js";
12
10
  import { getConfigFlags } from "../../lib/flags.js";
13
11
  export default class Pull extends Command {
14
12
  static description = "Pull a challenge from the cloud and extract source files locally";
@@ -97,67 +95,16 @@ export default class Pull extends Command {
97
95
  return;
98
96
  }
99
97
  }
100
- const tempTarballPath = path.join(flags["challenges-path"], `${challenge.shortSlug}-pull-temp.tar.gz`);
101
98
  const tasks = new Listr([
102
99
  {
103
- title: "Downloading challenge",
104
- task: async (_, task) => {
105
- const { downloadUrl } = await api.getChallengeDownloadUrl(challengeSlug);
106
- const response = await fetch(downloadUrl);
107
- if (!response.ok) {
108
- throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
109
- }
110
- const buffer = await response.arrayBuffer();
111
- await fs.mkdir(path.dirname(tempTarballPath), { recursive: true });
112
- await fs.writeFile(tempTarballPath, Buffer.from(buffer));
113
- task.title = "Downloaded challenge";
114
- },
115
- },
116
- {
117
- title: "Creating challenge directory",
100
+ title: "Downloading and extracting source files",
118
101
  task: async (_, task) => {
119
102
  await fs.mkdir(challenge.challengeDir, { recursive: true });
120
- task.title = `Created directory: ${challenge.challengeDir}`;
121
- },
122
- },
123
- {
124
- title: "Extracting source files",
125
- task: async (_, task) => {
126
- const filesToExtract = [`${SOURCE_FOLDER}/challenge.ts`, `${SOURCE_FOLDER}/config.ts`];
127
- const tempExtractDir = path.join(flags["challenges-path"], `${challenge.shortSlug}-extract-temp`);
128
- await fs.mkdir(tempExtractDir, { recursive: true });
129
- try {
130
- await tar.extract({
131
- file: tempTarballPath,
132
- cwd: tempExtractDir,
133
- filter: (entryPath) => filesToExtract.some((f) => entryPath === f),
134
- });
135
- const srcChallengeTs = path.join(tempExtractDir, SOURCE_FOLDER, "challenge.ts");
136
- const srcConfigTs = path.join(tempExtractDir, SOURCE_FOLDER, "config.ts");
137
- let extractedCount = 0;
138
- if (existsSync(srcChallengeTs)) {
139
- await fs.copyFile(srcChallengeTs, challenge.challengePath);
140
- extractedCount++;
141
- }
142
- if (existsSync(srcConfigTs)) {
143
- await fs.copyFile(srcConfigTs, challenge.configPath);
144
- extractedCount++;
145
- }
146
- if (extractedCount === 0) {
147
- throw new Error(`No source files found in tarball. The challenge may not have been built with source files.`);
148
- }
149
- task.title = `Extracted ${extractedCount} source file(s)`;
103
+ const extractedCount = await Challenge.downloadSourceFiles(api, challengeSlug, challenge.challengeDir);
104
+ if (extractedCount === 0) {
105
+ throw new Error("No source files found in tarball. The challenge may not have been built with source files.");
150
106
  }
151
- finally {
152
- await fs.rm(tempExtractDir, { recursive: true, force: true });
153
- }
154
- },
155
- },
156
- {
157
- title: "Cleaning up",
158
- task: async (_, task) => {
159
- await fs.rm(tempTarballPath, { force: true });
160
- task.title = "Cleaned up temporary files";
107
+ task.title = `Extracted ${extractedCount} source file(s)`;
161
108
  },
162
109
  },
163
110
  {
@@ -175,7 +122,6 @@ export default class Pull extends Command {
175
122
  this.log(pc.dim(` → config.ts: ${challenge.configPath}`));
176
123
  }
177
124
  catch (error) {
178
- await fs.rm(tempTarballPath, { force: true }).catch(() => { });
179
125
  this.error(pc.red(`Pull failed: ${error instanceof Error ? error.message : String(error)}`));
180
126
  }
181
127
  }
@@ -33,6 +33,9 @@ export default class Watch extends Command {
33
33
  // Clear screen before subsequent rebuilds for clean output
34
34
  clearScreen();
35
35
  this.log(pc.cyan(`\n Rebuild started...\n`));
36
+ // Track if config changed so we can force tarball re-upload
37
+ // (tarball includes source files which contain config.ts)
38
+ let configChanged = false;
36
39
  const tasks = new Listr([
37
40
  {
38
41
  title: "Checking configuration",
@@ -42,6 +45,7 @@ export default class Watch extends Command {
42
45
  task.title = "Uploading configuration";
43
46
  await api.updateChallenge(challenge.shortSlug, newConfig, "private");
44
47
  lastConfig = newConfig;
48
+ configChanged = true;
45
49
  task.title = "Configuration uploaded";
46
50
  }
47
51
  else {
@@ -61,7 +65,8 @@ export default class Watch extends Command {
61
65
  title: "Checking datapack changes",
62
66
  task: async (_ctx, task) => {
63
67
  const newHash = await challenge.getDatapackHash();
64
- if (newHash !== lastHash) {
68
+ // Upload if datapack changed OR if config changed (since tarball includes source files)
69
+ if (newHash !== lastHash || configChanged) {
65
70
  task.title = "Uploading datapack";
66
71
  await api.uploadChallengeDatapack(challenge.shortSlug, challenge.tarballPath);
67
72
  lastHash = newHash;
@@ -0,0 +1,18 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Logs extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ experimentName: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ runId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ version: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ private downloadForExperiment;
17
+ private downloadLogs;
18
+ }
@@ -0,0 +1,220 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Args, Command, Flags } from "@oclif/core";
4
+ import enquirer from "enquirer";
5
+ import { Listr } from "listr2";
6
+ import pc from "picocolors";
7
+ import { ApiClient } from "../../lib/api-client.js";
8
+ import { Experimenter } from "../../lib/experiment/experimenter.js";
9
+ import { getConfigFlags } from "../../lib/flags.js";
10
+ // Get all versions for an experiment
11
+ async function getAllVersions(experimentDir) {
12
+ const versionsDir = path.join(experimentDir, "versions");
13
+ try {
14
+ const entries = await fs.readdir(versionsDir, { withFileTypes: true });
15
+ return entries
16
+ .filter((e) => e.isDirectory())
17
+ .map((e) => parseInt(e.name, 10))
18
+ .filter((n) => !Number.isNaN(n))
19
+ .sort((a, b) => a - b);
20
+ }
21
+ catch {
22
+ return [];
23
+ }
24
+ }
25
+ export default class Logs extends Command {
26
+ static description = "Download logs from an experiment run";
27
+ static examples = [
28
+ "<%= config.bin %> <%= command.id %> my-experiment",
29
+ "<%= config.bin %> <%= command.id %> my-experiment <run-id>",
30
+ "<%= config.bin %> <%= command.id %> my-experiment --all",
31
+ "<%= config.bin %> <%= command.id %> my-experiment --version 2",
32
+ "<%= config.bin %> <%= command.id %> my-experiment --version 1 --all",
33
+ ];
34
+ static args = {
35
+ experimentName: Args.string({
36
+ description: "Experiment name",
37
+ required: true,
38
+ }),
39
+ runId: Args.string({
40
+ description: "Specific run ID to download logs from (optional)",
41
+ required: false,
42
+ }),
43
+ };
44
+ static flags = {
45
+ all: Flags.boolean({
46
+ description: "Download logs for all runs",
47
+ default: false,
48
+ }),
49
+ version: Flags.integer({
50
+ description: "Specific experiment version to download logs from (e.g., 0, 1, 2)",
51
+ required: false,
52
+ }),
53
+ ...getConfigFlags("api-key", "api-url"),
54
+ };
55
+ async run() {
56
+ const { args, flags } = await this.parse(Logs);
57
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
58
+ const { experimentName, runId } = args;
59
+ await this.downloadForExperiment(experimentName, runId, api, flags.all, flags.version);
60
+ }
61
+ async downloadForExperiment(experimentName, runId, api, all, version) {
62
+ const experimenter = new Experimenter(experimentName, "", api);
63
+ // Check if experiment exists
64
+ if (!(await experimenter.exists())) {
65
+ this.error(pc.red(`Experiment '${experimentName}' does not exist. Run 'kradle experiment list' to see available experiments.`));
66
+ }
67
+ const experimentDir = experimenter.experimentDir;
68
+ // Get all versions
69
+ const allVersions = await getAllVersions(experimentDir);
70
+ if (allVersions.length === 0) {
71
+ this.error(pc.red("No experiment versions found. Run the experiment first."));
72
+ }
73
+ // Default to latest version if not specified
74
+ let targetVersion;
75
+ if (version !== undefined) {
76
+ if (!allVersions.includes(version)) {
77
+ this.error(pc.red(`Version ${version} not found in experiment '${experimentName}'. ` +
78
+ `Available versions: ${allVersions.join(", ")}`));
79
+ }
80
+ targetVersion = version;
81
+ this.log(pc.blue(`>> Filtering to version ${version}`));
82
+ }
83
+ else {
84
+ // Default to latest version
85
+ targetVersion = Math.max(...allVersions);
86
+ }
87
+ const allRunInfos = [];
88
+ const completedStatuses = new Set(["completed", "finished", "game_over"]);
89
+ const progressPath = path.join(experimentDir, "versions", targetVersion.toString().padStart(3, "0"), "progress.json");
90
+ try {
91
+ const progressData = await fs.readFile(progressPath, "utf-8");
92
+ const progress = JSON.parse(progressData);
93
+ for (const entry of progress.entries) {
94
+ // Only include runs that are completed (exclude in-progress, queued, or error runs)
95
+ if (entry.runId && completedStatuses.has(entry.status)) {
96
+ allRunInfos.push({
97
+ version: targetVersion,
98
+ runId: entry.runId,
99
+ index: entry.index,
100
+ status: entry.status,
101
+ participantIds: entry.participantIds,
102
+ });
103
+ }
104
+ }
105
+ }
106
+ catch { }
107
+ if (allRunInfos.length === 0) {
108
+ this.error(pc.yellow("No completed runs found. Wait for runs to finish or run the experiment first."));
109
+ }
110
+ let selectedRuns;
111
+ if (all && !runId) {
112
+ // Download all runs (--all without specific run)
113
+ selectedRuns = allRunInfos;
114
+ this.log(pc.blue(`>> Downloading logs for all ${selectedRuns.length} runs`));
115
+ }
116
+ else if (runId) {
117
+ // Find specific run by ID
118
+ const matchingRun = allRunInfos.find((r) => r.runId === runId);
119
+ if (!matchingRun) {
120
+ this.error(pc.red(`Run ID '${runId}' not found in experiment '${experimentName}'. ` +
121
+ `Run 'kradle experiment logs ${experimentName}' to see available runs.`));
122
+ }
123
+ selectedRuns = [matchingRun];
124
+ this.log(pc.blue(`>> Downloading logs for run: ${runId}`));
125
+ }
126
+ else {
127
+ // Interactive run selection
128
+ const choices = [
129
+ // Only show "All runs" option if there are multiple runs
130
+ ...(allRunInfos.length > 1
131
+ ? [
132
+ {
133
+ name: "all",
134
+ message: `All runs (${allRunInfos.length} total)`,
135
+ hint: "Download all",
136
+ },
137
+ ]
138
+ : []),
139
+ ...allRunInfos.map((run) => {
140
+ const participants = run.participantIds?.join(", ") || "No participants";
141
+ return {
142
+ name: run.runId,
143
+ message: `${participants} - ${run.runId}`,
144
+ hint: run.status,
145
+ };
146
+ }),
147
+ ];
148
+ const { selectedRunId } = await enquirer.prompt({
149
+ type: "select",
150
+ name: "selectedRunId",
151
+ message: "Select a run to download logs from",
152
+ choices,
153
+ });
154
+ if (selectedRunId === "all") {
155
+ selectedRuns = allRunInfos;
156
+ this.log(pc.blue(`>> Downloading logs for all ${selectedRuns.length} runs`));
157
+ }
158
+ else {
159
+ const selectedRun = allRunInfos.find((r) => r.runId === selectedRunId);
160
+ if (!selectedRun) {
161
+ this.error(pc.red("Selected run not found."));
162
+ }
163
+ selectedRuns = [selectedRun];
164
+ }
165
+ }
166
+ // Build download targets
167
+ const downloadTargets = selectedRuns.map((run) => ({
168
+ version: run.version,
169
+ runId: run.runId,
170
+ experimentDir,
171
+ }));
172
+ this.log(pc.blue(`>> Fetching and downloading logs for ${downloadTargets.length} run(s)...`));
173
+ await this.downloadLogs(api, downloadTargets);
174
+ const logsDir = path.join(experimentDir, "versions", targetVersion.toString().padStart(3, "0"), "logs");
175
+ this.log(pc.green(`\n Downloaded logs for ${downloadTargets.length} run(s) to ${logsDir}`));
176
+ }
177
+ async downloadLogs(api, targets) {
178
+ const allTasks = [];
179
+ for (const target of targets) {
180
+ const { version, runId, experimentDir } = target;
181
+ allTasks.push({
182
+ title: `${runId}`,
183
+ task: async (_, task) => {
184
+ const runDir = path.join(experimentDir, "versions", version.toString().padStart(3, "0"), "logs", runId);
185
+ const runPath = path.join(runDir, "run.json");
186
+ const logsPath = path.join(runDir, "logs.json");
187
+ // Fetch run result
188
+ let runResult;
189
+ try {
190
+ runResult = await api.getRunResult(runId);
191
+ }
192
+ catch (error) {
193
+ throw new Error(`Failed to fetch run result: ${error instanceof Error ? error.message : String(error)}`);
194
+ }
195
+ // Fetch logs
196
+ let logs = [];
197
+ try {
198
+ logs = await api.getRunLogs(runId);
199
+ }
200
+ catch (error) {
201
+ throw new Error(`Failed to fetch logs: ${error instanceof Error ? error.message : String(error)}`);
202
+ }
203
+ // Save run result and logs
204
+ await fs.mkdir(runDir, { recursive: true });
205
+ await fs.writeFile(runPath, JSON.stringify(runResult, null, 2));
206
+ await fs.writeFile(logsPath, JSON.stringify(logs, null, 2));
207
+ task.title = `${runId} (${logs.length} log entries)`;
208
+ },
209
+ });
210
+ }
211
+ if (allTasks.length === 0) {
212
+ return;
213
+ }
214
+ const tasks = new Listr(allTasks, {
215
+ concurrent: 3,
216
+ exitOnError: false,
217
+ });
218
+ await tasks.run();
219
+ }
220
+ }
@@ -12,6 +12,7 @@ export default class Run extends Command {
12
12
  "new-version": import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
13
  "max-concurrent": import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
14
14
  "download-recordings": import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ "download-logs": import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  };
16
17
  run(): Promise<void>;
17
18
  }
@@ -33,6 +33,11 @@ export default class Run extends Command {
33
33
  description: "Automatically download recordings after each run finishes",
34
34
  default: false,
35
35
  }),
36
+ "download-logs": Flags.boolean({
37
+ char: "l",
38
+ description: "Automatically download logs after each run finishes",
39
+ default: false,
40
+ }),
36
41
  ...getConfigFlags("api-key", "api-url", "web-url"),
37
42
  };
38
43
  async run() {
@@ -57,6 +62,7 @@ export default class Run extends Command {
57
62
  maxConcurrent: flags["max-concurrent"],
58
63
  openMetabase: true,
59
64
  downloadRecordings: flags["download-recordings"],
65
+ downloadLogs: flags["download-logs"],
60
66
  });
61
67
  this.log(pc.green("\n✓ Experiment complete!"));
62
68
  }
@@ -72,8 +72,7 @@ export default class Init extends Command {
72
72
  // this.log(pc.green("Using Kradle's production environment."));
73
73
  // }
74
74
  this.log();
75
- this.log(pc.yellow("Cloud Analytics are only available in the development environment for now. Development environment will be used."));
76
- const useDev = true;
75
+ const useDev = false;
77
76
  const domain = useDev ? "dev.kradle.ai" : "kradle.ai";
78
77
  let apiKey;
79
78
  if (flags["api-key"]) {
@@ -13,12 +13,13 @@ export default class List extends Command {
13
13
  const { flags } = await this.parse(List);
14
14
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
15
15
  this.log(pc.blue(">> Loading worlds..."));
16
- const [cloudWorlds, localWorlds, human] = await Promise.all([
16
+ const [cloudWorlds, kradleWorlds, localWorlds, human] = await Promise.all([
17
17
  api.listWorlds(),
18
+ api.listKradleWorlds(),
18
19
  World.getLocalWorlds(),
19
20
  api.getHuman(),
20
21
  ]);
21
- const cloudMap = new Map(cloudWorlds.map((w) => [w.slug, w]));
22
+ const cloudMap = new Map([...cloudWorlds, ...kradleWorlds].map((w) => [w.slug, w]));
22
23
  const allSlugs = new Set([...cloudMap.keys(), ...localWorlds.map((slug) => `${human.username}:${slug}`)]);
23
24
  this.log(pc.bold("\nWorlds:\n"));
24
25
  this.log(`${"Status".padEnd(15)} ${"Slug".padEnd(40)} ${"Name".padEnd(30)}`);
@@ -1,5 +1,5 @@
1
1
  import type z from "zod";
2
- import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, type DownloadUrlResponse, HumanSchema, type RecordingMetadata, type RunStatusSchemaType, type WorldConfigSchemaType, type WorldSchemaType } from "./schemas.js";
2
+ import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, type DashboardUrlResponse, type DownloadUrlResponse, HumanSchema, type ParsedLogEntry, type RecordingMetadata, type RunResultResponse, type RunStatusSchemaType, type WorldConfigSchemaType, type WorldSchemaType } from "./schemas.js";
3
3
  export declare class ApiClient {
4
4
  private apiUrl;
5
5
  private kradleApiKey;
@@ -114,6 +114,18 @@ export declare class ApiClient {
114
114
  * @returns Download URL and expiration time.
115
115
  */
116
116
  getRecordingDownloadUrl(runId: string, participantId: string, timestamp: string): Promise<DownloadUrlResponse>;
117
+ /**
118
+ * Get logs for a run, and parse the message if it is a JSON object.
119
+ * @param runId - The ID of the run.
120
+ * @returns Array of log entries.
121
+ */
122
+ getRunLogs(runId: string): Promise<ParsedLogEntry[]>;
123
+ /**
124
+ * Get the result of a run.
125
+ * @param runId - The ID of the run.
126
+ * @returns Run result with status, end_state, and participant results.
127
+ */
128
+ getRunResult(runId: string): Promise<RunResultResponse>;
117
129
  listWorlds(): Promise<WorldSchemaType[]>;
118
130
  listKradleWorlds(): Promise<WorldSchemaType[]>;
119
131
  getWorld(slug: string): Promise<WorldSchemaType>;
@@ -130,4 +142,9 @@ export declare class ApiClient {
130
142
  getWorldUploadUrl(slug: string): Promise<string>;
131
143
  getWorldDownloadUrl(slug: string): Promise<DownloadUrlResponse>;
132
144
  uploadWorldFile(slug: string, tarballPath: string): Promise<void>;
145
+ /**
146
+ * Get the dashboard URL for the current environment.
147
+ * @returns The dashboard URL.
148
+ */
149
+ getDashboardUrl(): Promise<DashboardUrlResponse>;
133
150
  }
@@ -1,13 +1,13 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
- import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, DownloadUrlResponseSchema, HumanSchema, JobResponseSchema, RecordingsListResponseSchema, RunStatusSchema, UploadUrlResponseSchema, WorldSchema, WorldsResponseSchema, } from "./schemas.js";
3
+ import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, DashboardUrlResponseSchema, DownloadUrlResponseSchema, HumanSchema, JobResponseSchema, RecordingsListResponseSchema, RunLogsResponseSchema, RunResultResponseSchema, RunStatusSchema, UploadUrlResponseSchema, WorldSchema, WorldsResponseSchema, } from "./schemas.js";
4
4
  const DEFAULT_PAGE_SIZE = 30;
5
5
  const DEFAULT_CHALLENGE_SCHEMA = {
6
6
  slug: "",
7
7
  name: "",
8
8
  visibility: "private",
9
9
  domain: "minecraft",
10
- world: "team-kradle:colosseum",
10
+ world: "team-kradle:flat-world",
11
11
  challengeConfig: { cheat: false, datapack: true, gameMode: "survival" },
12
12
  task: ".",
13
13
  roles: { "default-role": { description: "default-role", specificTask: "do your best!" } },
@@ -277,6 +277,40 @@ export class ApiClient {
277
277
  const url = `runs/${runId}/recordings/${participantId}/downloadUrl?timestamp=${encodeURIComponent(timestamp)}`;
278
278
  return this.get(url, {}, DownloadUrlResponseSchema);
279
279
  }
280
+ /**
281
+ * Get logs for a run, and parse the message if it is a JSON object.
282
+ * @param runId - The ID of the run.
283
+ * @returns Array of log entries.
284
+ */
285
+ async getRunLogs(runId) {
286
+ const url = `runs/${runId}/logs`;
287
+ const response = await this.get(url, {}, RunLogsResponseSchema);
288
+ return response.logs.map((log) => {
289
+ const { message, ...rest } = log;
290
+ try {
291
+ const parsedMessage = JSON.parse(message);
292
+ return {
293
+ ...rest,
294
+ parsedMessage: parsedMessage,
295
+ };
296
+ }
297
+ catch {
298
+ return {
299
+ ...rest,
300
+ message: message,
301
+ };
302
+ }
303
+ });
304
+ }
305
+ /**
306
+ * Get the result of a run.
307
+ * @param runId - The ID of the run.
308
+ * @returns Run result with status, end_state, and participant results.
309
+ */
310
+ async getRunResult(runId) {
311
+ const url = `runs/${runId}`;
312
+ return this.get(url, {}, RunResultResponseSchema);
313
+ }
280
314
  async listWorlds() {
281
315
  return this.listResource("worlds", "worlds", WorldsResponseSchema);
282
316
  }
@@ -343,4 +377,11 @@ export class ApiClient {
343
377
  throw new Error(`Failed to upload world: ${response.statusText}`);
344
378
  }
345
379
  }
380
+ /**
381
+ * Get the dashboard URL for the current environment.
382
+ * @returns The dashboard URL.
383
+ */
384
+ async getDashboardUrl() {
385
+ return this.get("dashboard", {}, DashboardUrlResponseSchema);
386
+ }
346
387
  }
@@ -57,4 +57,12 @@ export declare class Challenge {
57
57
  * Note: config.ts is NOT created here - it should be generated later via `challenge config download`
58
58
  */
59
59
  static createLocal(slug: string, kradleChallengesPath: string): Promise<void>;
60
+ /**
61
+ * Download challenge source files (challenge.ts and config.ts) from the cloud
62
+ * @param api - The API client to use
63
+ * @param slug - The challenge slug to download
64
+ * @param targetDir - The directory to save the source files to
65
+ * @returns The number of files extracted
66
+ */
67
+ static downloadSourceFiles(api: ApiClient, slug: string, targetDir: string): Promise<number>;
60
68
  }
@@ -194,4 +194,50 @@ export class Challenge {
194
194
  // Copy challenge.ts template
195
195
  await fs.copyFile(challengeTemplatePath, challenge.challengePath);
196
196
  }
197
+ /**
198
+ * Download challenge source files (challenge.ts and config.ts) from the cloud
199
+ * @param api - The API client to use
200
+ * @param slug - The challenge slug to download
201
+ * @param targetDir - The directory to save the source files to
202
+ * @returns The number of files extracted
203
+ */
204
+ static async downloadSourceFiles(api, slug, targetDir) {
205
+ const { downloadUrl } = await api.getChallengeDownloadUrl(slug);
206
+ const response = await fetch(downloadUrl);
207
+ if (!response.ok) {
208
+ throw new Error(`Failed to download challenge "${slug}": ${response.status} ${response.statusText}`);
209
+ }
210
+ // Save tarball to temp location
211
+ const tempTarballPath = path.join(targetDir, `${slug}-temp.tar.gz`);
212
+ const buffer = await response.arrayBuffer();
213
+ await fs.mkdir(targetDir, { recursive: true });
214
+ await fs.writeFile(tempTarballPath, Buffer.from(buffer));
215
+ const filesToExtract = [`${SOURCE_FOLDER}/challenge.ts`, `${SOURCE_FOLDER}/config.ts`];
216
+ const tempExtractDir = path.join(targetDir, `${slug}-extract-temp`);
217
+ try {
218
+ await fs.mkdir(tempExtractDir, { recursive: true });
219
+ await tar.extract({
220
+ file: tempTarballPath,
221
+ cwd: tempExtractDir,
222
+ filter: (entryPath) => filesToExtract.some((f) => entryPath === f),
223
+ });
224
+ const srcChallengeTs = path.join(tempExtractDir, SOURCE_FOLDER, "challenge.ts");
225
+ const srcConfigTs = path.join(tempExtractDir, SOURCE_FOLDER, "config.ts");
226
+ let extractedCount = 0;
227
+ if (existsSync(srcChallengeTs)) {
228
+ await fs.copyFile(srcChallengeTs, path.join(targetDir, "challenge.ts"));
229
+ extractedCount++;
230
+ }
231
+ if (existsSync(srcConfigTs)) {
232
+ await fs.copyFile(srcConfigTs, path.join(targetDir, "config.ts"));
233
+ extractedCount++;
234
+ }
235
+ return extractedCount;
236
+ }
237
+ finally {
238
+ // Clean up temp files
239
+ await fs.rm(tempExtractDir, { recursive: true, force: true });
240
+ await fs.rm(tempTarballPath, { force: true });
241
+ }
242
+ }
197
243
  }