kradle 0.4.3 → 0.6.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
@@ -22,7 +22,7 @@ Make sure you have [NodeJS 22.18 or higher](https://nodejs.org/en/download/curre
22
22
 
23
23
  1. Install Kradle's CLI globally
24
24
  ```
25
- npm i -g kradle
25
+ npm i -g kradle@latest
26
26
  ```
27
27
  2. Initialize a new directory to store challenges, and other Kradle resources:
28
28
  ```
@@ -138,16 +138,34 @@ Uses file watching with debouncing (300ms) and hash comparison to minimize unnec
138
138
 
139
139
  ### Run Challenge
140
140
 
141
- Run a challenge and wait for completion:
141
+ Run a challenge with agents specified inline or via interactive selection:
142
142
 
143
143
  ```bash
144
+ # Interactive mode - prompts for agent selection
144
145
  kradle challenge run <challenge-name>
146
+
147
+ # Inline agent specification
148
+ kradle challenge run <challenge-name> team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast
149
+
150
+ # Team-based challenge with roles
151
+ kradle challenge run capture-the-flag \
152
+ red=team-kradle:gemini-3-flash \
153
+ blue=team-kradle:claude-sonnet-4
154
+
155
+ # Other options
145
156
  kradle challenge run <challenge-name> --studio # Run in local studio environment
146
157
  kradle challenge run <team-name>:<challenge-name> # Run a public challenge from another team
147
158
  kradle challenge run <challenge-name> --no-open # Don't open browser
148
159
  kradle challenge run <challenge-name> --no-wait # Fire and forget (don't wait for completion)
149
160
  ```
150
161
 
162
+ **Inline Agent Syntax:**
163
+ - `agent1,agent2` - Assign agents to the "default" role
164
+ - `role=agent1,agent2` - Assign agents to a specific role
165
+ - Same role can be specified multiple times and agents are merged
166
+
167
+ When no agents are specified, the command enters interactive mode, fetching the challenge configuration and prompting for agent selection for each role.
168
+
151
169
  By default, the command opens the run URL in your browser and polls until the run completes, then displays the outcome.
152
170
 
153
171
  ### List Runs
@@ -165,12 +183,14 @@ Get details and logs for a specific run:
165
183
 
166
184
  ```bash
167
185
  kradle challenge runs get <run-id>
168
- kradle challenge runs get <run-id> --no-logs # Skip fetching logs
186
+ kradle challenge runs get <run-id> --no-logs # Skip fetching logs
187
+ kradle challenge runs get <run-id> --no-summary # Skip AI summary
169
188
  ```
170
189
 
171
190
  This displays:
172
191
  - Run metadata (status, duration, end state)
173
192
  - Participant results (agent, winner status, score)
193
+ - AI-generated summary (unless `--no-summary` is used)
174
194
  - Log entries with timestamps and levels (unless `--no-logs` is used)
175
195
 
176
196
  ## Experiment Commands
@@ -1,7 +1,7 @@
1
1
  import { Command, Flags, loadHelpClass } from "@oclif/core";
2
2
  import pc from "picocolors";
3
3
  import { ApiClient } from "../../lib/api-client.js";
4
- import { getChallengeSlugArgument } from "../../lib/arguments.js";
4
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
5
5
  import { Challenge } from "../../lib/challenge.js";
6
6
  import { getConfigFlags } from "../../lib/flags.js";
7
7
  export default class Build extends Command {
@@ -39,7 +39,12 @@ export default class Build extends Command {
39
39
  const challengeSlugs = flags.all ? await Challenge.getLocalChallenges() : argv;
40
40
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
41
41
  for (const challengeSlug of challengeSlugs) {
42
- const challenge = new Challenge(challengeSlug, flags["challenges-path"]);
42
+ // Validate that the challenge exists locally
43
+ const validation = await Challenge.validateForLocalOperation(challengeSlug, flags["challenges-path"]);
44
+ if (!validation.isValid && validation.error) {
45
+ this.error(pc.red(validation.error));
46
+ }
47
+ const challenge = new Challenge(extractShortSlug(challengeSlug), flags["challenges-path"]);
43
48
  this.log(pc.blue(`==== Building challenge: ${challenge.shortSlug} ====`));
44
49
  try {
45
50
  await challenge.buildAndUpload(api, flags.public);
@@ -11,7 +11,7 @@ export default class Create extends Command {
11
11
  static description = "Create a new challenge locally and in the cloud";
12
12
  static examples = ["<%= config.bin %> <%= command.id %> my-challenge"];
13
13
  static args = {
14
- challengeSlug: getChallengeSlugArgument({ description: "Challenge slug to create" }),
14
+ challengeSlug: getChallengeSlugArgument({ description: "Challenge slug to create", strictLocalSlug: true }),
15
15
  };
16
16
  static flags = {
17
17
  verbose: Flags.boolean({ char: "v", description: "Verbose output", default: false }),
@@ -3,7 +3,7 @@ import { Command, Flags } from "@oclif/core";
3
3
  import enquirer from "enquirer";
4
4
  import pc from "picocolors";
5
5
  import { ApiClient } from "../../lib/api-client.js";
6
- import { getChallengeSlugArgument } from "../../lib/arguments.js";
6
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
7
7
  import { Challenge } from "../../lib/challenge.js";
8
8
  import { getConfigFlags } from "../../lib/flags.js";
9
9
  export default class Delete extends Command {
@@ -22,17 +22,18 @@ export default class Delete extends Command {
22
22
  async run() {
23
23
  const { args, flags } = await this.parse(Delete);
24
24
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
25
- const challenge = new Challenge(args.challengeSlug, flags["challenges-path"]);
26
- // Check if challenge exists locally
25
+ const shortSlug = extractShortSlug(args.challengeSlug);
26
+ const challenge = new Challenge(shortSlug, flags["challenges-path"]);
27
+ // Check if challenge exists locally (always uses short slug)
27
28
  const existsLocally = challenge.exists();
28
- // Check if challenge exists in cloud
29
- const existsInCloud = await api.challengeExists(challenge.shortSlug);
29
+ // Check if challenge exists in cloud (use full slug to support namespaced challenges)
30
+ const existsInCloud = await api.challengeExists(args.challengeSlug);
30
31
  // If challenge doesn't exist anywhere, inform user and exit
31
32
  if (!existsLocally && !existsInCloud) {
32
- this.error(pc.red(`Challenge "${challenge.shortSlug}" does not exist locally or in the cloud.`));
33
+ this.error(pc.red(`Challenge "${args.challengeSlug}" does not exist locally or in the cloud.`));
33
34
  }
34
35
  // Show what will be deleted
35
- this.log(pc.bold(`\nChallenge: ${pc.cyan(challenge.shortSlug)}`));
36
+ this.log(pc.bold(`\nChallenge: ${pc.cyan(args.challengeSlug)}`));
36
37
  this.log(` 💻 Local: ${existsLocally ? pc.green("✓ exists") : pc.dim("✗ not found")}`);
37
38
  this.log(` ☁️ Cloud: ${existsInCloud ? pc.green("✓ exists") : pc.dim("✗ not found")}`);
38
39
  this.log("");
@@ -58,7 +59,7 @@ export default class Delete extends Command {
58
59
  }
59
60
  try {
60
61
  this.log(pc.blue(">> Deleting from cloud..."));
61
- await api.deleteChallenge(challenge.shortSlug);
62
+ await api.deleteChallenge(args.challengeSlug);
62
63
  this.log(pc.green("✓ Deleted from cloud"));
63
64
  }
64
65
  catch (error) {
@@ -94,6 +95,6 @@ export default class Delete extends Command {
94
95
  this.error(pc.red(`Failed to delete local folder: ${error instanceof Error ? error.message : String(error)}`));
95
96
  }
96
97
  }
97
- this.log(pc.green(`\n✓ Challenge "${challenge.shortSlug}" deleted successfully!`));
98
+ this.log(pc.green(`\n✓ Challenge "${args.challengeSlug}" deleted successfully!`));
98
99
  }
99
100
  }
@@ -20,7 +20,6 @@ export default class Pull extends Command {
20
20
  challengeSlug: getChallengeSlugArgument({
21
21
  description: "Challenge slug to pull (interactive selection if omitted)",
22
22
  required: false,
23
- allowTeam: true,
24
23
  }),
25
24
  };
26
25
  static flags = {
@@ -1,6 +1,7 @@
1
1
  import { Command } from "@oclif/core";
2
2
  export default class Run extends Command {
3
3
  static description: string;
4
+ static strict: boolean;
4
5
  static examples: string[];
5
6
  static args: {
6
7
  challengeSlug: import("@oclif/core/interfaces").Arg<string>;
@@ -18,5 +19,18 @@ export default class Run extends Command {
18
19
  };
19
20
  private pollForCompletion;
20
21
  private displayRunResult;
22
+ /**
23
+ * Parse inline agent arguments into participants.
24
+ * Supports formats:
25
+ * - team=agent1,agent2 -> assigns agents to a role/team
26
+ * - agent1,agent2 -> assigns agents to "default" role
27
+ * Same team can be specified multiple times and agents are merged.
28
+ */
29
+ private parseInlineAgents;
30
+ /**
31
+ * Interactive agent selection when no agents are specified inline.
32
+ * Fetches challenge config from cloud and prompts user to select agents for each role.
33
+ */
34
+ private selectAgentsInteractively;
21
35
  run(): Promise<void>;
22
36
  }
@@ -1,25 +1,29 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
+ import enquirer from "enquirer";
2
3
  import pc from "picocolors";
3
4
  import { ApiClient } from "../../lib/api-client.js";
4
5
  import { getChallengeSlugArgument } from "../../lib/arguments.js";
5
6
  import { getConfigFlags } from "../../lib/flags.js";
6
- import { formatDuration, getRunStatusDisplay, loadTemplateRun, openInBrowser } from "../../lib/utils.js";
7
+ import { clearScreen, formatDuration, fuzzyHighlight, getRunStatusDisplay, openInBrowser } from "../../lib/utils.js";
7
8
  const POLL_INTERVAL_MS = 2000;
8
9
  const MAX_POLL_TIME_MS = 30 * 60 * 1000; // 30 minutes
9
10
  const TERMINAL_STATUSES = ["finished", "game_over", "error", "completed", "cancelled", "timeout", "failed"];
10
11
  export default class Run extends Command {
11
12
  static description = "Run a challenge";
13
+ // Allow variadic arguments for inline agent specification
14
+ static strict = false;
12
15
  static examples = [
13
16
  "<%= config.bin %> <%= command.id %> my-challenge",
14
17
  "<%= config.bin %> <%= command.id %> my-challenge --studio",
15
18
  "<%= config.bin %> <%= command.id %> team-name:my-challenge",
16
19
  "<%= config.bin %> <%= command.id %> my-challenge --no-open",
17
20
  "<%= config.bin %> <%= command.id %> my-challenge --no-wait",
21
+ "<%= config.bin %> <%= command.id %> my-challenge team-kradle:gemini-3-flash,team-kradle:grok-4-1-fast",
22
+ "<%= config.bin %> <%= command.id %> capture-the-flag red=team-kradle:gemini-3-flash blue=team-kradle:grok-4-1-fast",
18
23
  ];
19
24
  static args = {
20
25
  challengeSlug: getChallengeSlugArgument({
21
26
  description: "Challenge slug to run (e.g., 'my-challenge' or 'team-name:my-challenge')",
22
- allowTeam: true,
23
27
  }),
24
28
  };
25
29
  static flags = {
@@ -101,15 +105,178 @@ export default class Run extends Command {
101
105
  this.log(result.summary);
102
106
  }
103
107
  }
108
+ /**
109
+ * Parse inline agent arguments into participants.
110
+ * Supports formats:
111
+ * - team=agent1,agent2 -> assigns agents to a role/team
112
+ * - agent1,agent2 -> assigns agents to "default" role
113
+ * Same team can be specified multiple times and agents are merged.
114
+ */
115
+ parseInlineAgents(agentArgs) {
116
+ const roleAgents = new Map();
117
+ for (const arg of agentArgs) {
118
+ let role;
119
+ let agentsStr;
120
+ if (arg.includes("=")) {
121
+ // Format: team=agent1,agent2
122
+ const eqIndex = arg.indexOf("=");
123
+ role = arg.slice(0, eqIndex);
124
+ agentsStr = arg.slice(eqIndex + 1);
125
+ }
126
+ else {
127
+ // Format: agent1,agent2 (no team specified, use "default")
128
+ role = "default";
129
+ agentsStr = arg;
130
+ }
131
+ const agents = agentsStr.split(",").filter((a) => a.length > 0);
132
+ // Merge with existing agents for this role
133
+ const existing = roleAgents.get(role) || [];
134
+ roleAgents.set(role, [...existing, ...agents]);
135
+ }
136
+ // Convert to participants array
137
+ const participants = [];
138
+ for (const [role, agents] of roleAgents) {
139
+ for (const agent of agents) {
140
+ participants.push({ agent, role });
141
+ }
142
+ }
143
+ return participants;
144
+ }
145
+ /**
146
+ * Interactive agent selection when no agents are specified inline.
147
+ * Fetches challenge config from cloud and prompts user to select agents for each role.
148
+ */
149
+ async selectAgentsInteractively(challenge, availableAgents) {
150
+ const participants = [];
151
+ const roles = challenge.roles;
152
+ const roleNames = Object.keys(roles);
153
+ // All selected agents across all roles - this is used to display already-selected agents at the top of the prompt
154
+ const allSelectedAgents = [];
155
+ // Build agent choices for the prompt
156
+ const agentChoices = availableAgents
157
+ .filter((a) => a.username !== undefined)
158
+ .map((a) => ({
159
+ name: a.username,
160
+ message: a.username,
161
+ }));
162
+ if (agentChoices.length === 0) {
163
+ this.error("No agents available. Please check your API key and permissions.");
164
+ }
165
+ const COLORS = [pc.yellow, pc.magenta, pc.cyan, pc.green, pc.blue, pc.red];
166
+ for (let i = 0; i < roleNames.length; i++) {
167
+ const roleName = roleNames[i];
168
+ const color = COLORS[i % COLORS.length];
169
+ const roleConfig = roles[roleName];
170
+ const min = roleConfig.minParticipants;
171
+ const max = roleConfig.maxParticipants;
172
+ // Build prompt message
173
+ let message = `Select agents for role "${pc.bold(color(roleName))}"`;
174
+ if (min !== undefined && max !== undefined) {
175
+ message += ` (min: ${min}, max: ${max})`;
176
+ }
177
+ else if (min !== undefined) {
178
+ message += ` (min: ${min})`;
179
+ }
180
+ else if (max !== undefined) {
181
+ message += ` (max: ${max})`;
182
+ }
183
+ // Use autocomplete for searchable selection with multiple selections
184
+ const selectedAgents = [];
185
+ let selecting = true;
186
+ while (selecting) {
187
+ clearScreen();
188
+ agentChoices.sort((a, b) => {
189
+ // First, display already-selected agents at the top
190
+ if (allSelectedAgents.includes(a.name))
191
+ return -1;
192
+ if (allSelectedAgents.includes(b.name))
193
+ return 1;
194
+ // Then sort by name
195
+ return a.message.localeCompare(b.message);
196
+ });
197
+ const response = await enquirer.prompt({
198
+ type: "autocomplete",
199
+ name: "agent",
200
+ message: selectedAgents.length > 0 ? `${message} [${selectedAgents.length} selected]` : message,
201
+ // @ts-expect-error - somehow this is not typed correctly
202
+ limit: 10,
203
+ choices: [
204
+ ...(selectedAgents.length >= (min ?? 1)
205
+ ? [{ name: "__done__", message: pc.green("✓ Done selecting") }]
206
+ : []),
207
+ ...agentChoices,
208
+ ],
209
+ suggest: (input, choices) => {
210
+ if (!input)
211
+ return choices;
212
+ return choices.flatMap((choice) => {
213
+ if (choice.name === "__done__")
214
+ return [choice];
215
+ const highlighted = fuzzyHighlight(input, choice.message, pc.red);
216
+ return highlighted ? [{ ...choice, message: highlighted }] : [];
217
+ });
218
+ },
219
+ footer: () => {
220
+ let currentlySelectedString = "";
221
+ if (selectedAgents.length > 0) {
222
+ const groupedAgents = Object.groupBy(selectedAgents, (agent) => agent);
223
+ currentlySelectedString = `\nCurrently selected: ${Object.entries(groupedAgents)
224
+ .map(([agent, arr]) => (arr?.length === 1 ? agent : `${agent} x${arr?.length}`))
225
+ .join(", ")}`;
226
+ }
227
+ return pc.dim(`Type to search for agents, press Enter to select, or press Ctrl+C to cancel.${currentlySelectedString}`);
228
+ },
229
+ });
230
+ if (response.agent === "__done__") {
231
+ selecting = false;
232
+ }
233
+ else {
234
+ selectedAgents.push(response.agent);
235
+ allSelectedAgents.push(response.agent);
236
+ // If max is defined and reached, stop selecting
237
+ if (max !== undefined && selectedAgents.length >= max) {
238
+ selecting = false;
239
+ }
240
+ }
241
+ }
242
+ // Validate minimum
243
+ if (min !== undefined && selectedAgents.length < min) {
244
+ this.error(`Role "${roleName}" requires at least ${min} agents, but only ${selectedAgents.length} selected.`);
245
+ }
246
+ // Add participants for this role
247
+ for (const agent of selectedAgents) {
248
+ participants.push({ agent, role: roleName });
249
+ }
250
+ }
251
+ return participants;
252
+ }
104
253
  async run() {
105
- const { args, flags } = await this.parse(Run);
254
+ const { args, flags, argv } = await this.parse(Run);
106
255
  const apiUrl = flags.studio ? flags["studio-api-url"] : flags["api-url"];
107
- const studioApi = new ApiClient(apiUrl, flags["api-key"]);
256
+ const api = new ApiClient(apiUrl, flags["api-key"]);
108
257
  const challengeSlug = args.challengeSlug;
258
+ // Get extra arguments (after the challenge slug) for inline agent specification
259
+ const agentArgs = argv.filter((arg) => arg !== challengeSlug);
260
+ let participants;
261
+ if (agentArgs.length > 0) {
262
+ // Parse inline agent specification
263
+ participants = this.parseInlineAgents(agentArgs);
264
+ }
265
+ else {
266
+ // Interactive mode: fetch challenge config and prompt for agents
267
+ this.log(pc.blue(`>> Fetching challenge configuration...`));
268
+ const [challenge, availableAgents] = await Promise.all([api.getChallenge(challengeSlug), api.listKradleAgents()]);
269
+ this.log(pc.dim(`Challenge: ${challenge.name}`));
270
+ this.log(pc.dim(`Roles: ${Object.keys(challenge.roles).join(", ")}`));
271
+ this.log("");
272
+ participants = await this.selectAgentsInteractively(challenge, availableAgents);
273
+ }
274
+ if (participants.length === 0) {
275
+ this.error("No participants specified. Use inline syntax or select agents interactively.");
276
+ }
109
277
  try {
110
- const { participants } = (await loadTemplateRun());
111
- this.log(pc.blue(`>> Running challenge: ${challengeSlug}${flags.studio ? " (studio)" : ""}...`));
112
- const response = await studioApi.runChallenge({
278
+ this.log(pc.blue(`\n>> Running challenge: ${challengeSlug}${flags.studio ? " (studio)" : ""}...`));
279
+ const response = await api.runChallenge({
113
280
  challenge: challengeSlug,
114
281
  participants,
115
282
  jobType: "foreground",
@@ -118,7 +285,7 @@ export default class Run extends Command {
118
285
  const runId = response.runIds[0];
119
286
  const baseUrl = flags.studio ? flags["studio-url"] : flags["web-url"];
120
287
  const runUrl = `${baseUrl}/runs/${runId}`;
121
- this.log(pc.green("\n\u2713 Challenge started!"));
288
+ this.log(pc.green("\n Challenge started!"));
122
289
  this.log(pc.dim(`Run ID: ${runId}`));
123
290
  this.log(pc.dim(`Run URL: ${runUrl}`));
124
291
  if (!flags["no-open"]) {
@@ -126,11 +293,11 @@ export default class Run extends Command {
126
293
  }
127
294
  if (!flags["no-wait"]) {
128
295
  this.log(pc.blue("\n>> Waiting for run to complete...\n"));
129
- await this.pollForCompletion(studioApi, runId, !flags["no-summary"]);
296
+ await this.pollForCompletion(api, runId, !flags["no-summary"]);
130
297
  }
131
298
  }
132
299
  else {
133
- this.log(pc.yellow("\u26a0 Challenge started but no run ID returned"));
300
+ this.log(pc.yellow(" Challenge started but no run ID returned"));
134
301
  }
135
302
  }
136
303
  catch (error) {
@@ -9,6 +9,7 @@ export default class GetRun extends Command {
9
9
  "api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  "no-logs": import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ "no-summary": import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  };
13
14
  run(): Promise<void>;
14
15
  }
@@ -29,6 +29,7 @@ export default class GetRun extends Command {
29
29
  static examples = [
30
30
  "<%= config.bin %> <%= command.id %> abc123",
31
31
  "<%= config.bin %> <%= command.id %> abc123 --no-logs",
32
+ "<%= config.bin %> <%= command.id %> abc123 --no-summary",
32
33
  ];
33
34
  static args = {
34
35
  runId: Args.string({
@@ -41,12 +42,17 @@ export default class GetRun extends Command {
41
42
  description: "Skip fetching and displaying logs",
42
43
  default: false,
43
44
  }),
45
+ "no-summary": Flags.boolean({
46
+ description: "Skip displaying the AI-generated summary",
47
+ default: false,
48
+ }),
44
49
  ...getConfigFlags("api-key", "api-url"),
45
50
  };
46
51
  async run() {
47
52
  const { args, flags } = await this.parse(GetRun);
48
53
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
49
54
  const showLogs = !flags["no-logs"];
55
+ const showSummary = !flags["no-summary"];
50
56
  this.log(pc.blue(`>> Loading run ${args.runId}...`));
51
57
  try {
52
58
  const [runResult, logs] = await Promise.all([
@@ -88,6 +94,11 @@ export default class GetRun extends Command {
88
94
  this.log(`${participantId.padEnd(widths[0])} ${agentPadded} ${winner} ${score.padEnd(widths[3])} ${timeToSuccess}`);
89
95
  }
90
96
  }
97
+ // AI Summary
98
+ if (showSummary && runResult.summary) {
99
+ this.log(pc.bold("\n=== Summary ===\n"));
100
+ this.log(runResult.summary);
101
+ }
91
102
  // Logs
92
103
  if (showLogs) {
93
104
  if (logs.length > 0) {
@@ -4,7 +4,7 @@ import chokidar from "chokidar";
4
4
  import { Listr } from "listr2";
5
5
  import pc from "picocolors";
6
6
  import { ApiClient } from "../../lib/api-client.js";
7
- import { getChallengeSlugArgument } from "../../lib/arguments.js";
7
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
8
8
  import { Challenge, SOURCE_FOLDER } from "../../lib/challenge.js";
9
9
  import { getConfigFlags } from "../../lib/flags.js";
10
10
  import { clearScreen, debounced } from "../../lib/utils.js";
@@ -20,7 +20,12 @@ export default class Watch extends Command {
20
20
  };
21
21
  async run() {
22
22
  const { args, flags } = await this.parse(Watch);
23
- const challenge = new Challenge(args.challengeSlug, flags["challenges-path"]);
23
+ // Validate that the challenge exists locally
24
+ const validation = await Challenge.validateForLocalOperation(args.challengeSlug, flags["challenges-path"]);
25
+ if (!validation.isValid && validation.error) {
26
+ this.error(pc.red(validation.error));
27
+ }
28
+ const challenge = new Challenge(extractShortSlug(args.challengeSlug), flags["challenges-path"]);
24
29
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
25
30
  const debounceSeconds = 1;
26
31
  let building = false;
@@ -57,7 +62,7 @@ export default class Watch extends Command {
57
62
  {
58
63
  title: "Building datapack",
59
64
  task: async (_ctx, task) => {
60
- await challenge.build(!flags.verbose);
65
+ await challenge.build(!flags.verbose, lastConfig);
61
66
  task.title = "Datapack built";
62
67
  },
63
68
  },
@@ -59,6 +59,8 @@ export default class Import extends Command {
59
59
  this.log(`World "${slug}" already exists. Re-import will update the tarball but preserve config.ts.`);
60
60
  }
61
61
  const api = new ApiClient(flags["api-url"], flags["api-key"]);
62
+ // Context to share data between tasks
63
+ const ctx = {};
62
64
  const tasks = new Listr([
63
65
  {
64
66
  title: "Creating world directory",
@@ -82,26 +84,32 @@ export default class Import extends Command {
82
84
  },
83
85
  },
84
86
  {
85
- title: "Creating world in cloud",
86
- enabled: () => !isReimport,
87
+ title: "Ensuring world exists in cloud",
87
88
  task: async (_ctx, task) => {
88
89
  const exists = await api.worldExists(slug);
89
- if (exists) {
90
- task.title = "World already exists in cloud";
91
- return;
90
+ if (!exists) {
91
+ const config = {
92
+ name: slug,
93
+ domain: "minecraft",
94
+ };
95
+ const cloudWorld = await api.createWorld(slug, config);
96
+ ctx.worldId = cloudWorld.id;
97
+ task.title = "Created world in cloud";
98
+ }
99
+ else {
100
+ const cloudWorld = await api.getWorld(slug);
101
+ ctx.worldId = cloudWorld.id;
102
+ task.title = "World exists in cloud";
92
103
  }
93
- const config = {
94
- name: slug,
95
- domain: "minecraft",
96
- };
97
- await api.createWorld(slug, config);
98
- task.title = "Created world in cloud";
99
104
  },
100
105
  },
101
106
  {
102
107
  title: "Uploading world to cloud",
103
108
  task: async (_ctx, task) => {
104
- await api.uploadWorldFile(slug, world.tarballPath);
109
+ if (!ctx.worldId) {
110
+ throw new Error("Missing world ID");
111
+ }
112
+ await api.uploadWorldFile(slug, sourcePath, ctx.worldId);
105
113
  task.title = "Uploaded world to cloud";
106
114
  },
107
115
  },
@@ -20,7 +20,6 @@ export default class Pull extends Command {
20
20
  worldSlug: getWorldSlugArgument({
21
21
  description: "World slug to pull (interactive selection if omitted)",
22
22
  required: false,
23
- allowTeam: true,
24
23
  }),
25
24
  };
26
25
  static flags = {
@@ -111,6 +110,13 @@ export default class Pull extends Command {
111
110
  task.title = "Downloaded world";
112
111
  },
113
112
  },
113
+ {
114
+ title: "Normalizing tarball structure",
115
+ task: async (_ctx, task) => {
116
+ await world.normalizeDownloadedTarball();
117
+ task.title = "Tarball structure normalized";
118
+ },
119
+ },
114
120
  {
115
121
  title: "Creating config.ts",
116
122
  task: async (_ctx, task) => {
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs/promises";
1
2
  import { Command, Flags, loadHelpClass } from "@oclif/core";
2
3
  import { Listr } from "listr2";
3
4
  import pc from "picocolors";
@@ -65,6 +66,8 @@ export default class Push extends Command {
65
66
  continue;
66
67
  }
67
68
  const config = await world.loadConfig();
69
+ // Context to share data between tasks
70
+ const ctx = {};
68
71
  const tasks = new Listr([
69
72
  {
70
73
  title: "Ensuring world exists in cloud",
@@ -83,6 +86,7 @@ export default class Push extends Command {
83
86
  title: "Checking world visibility",
84
87
  task: async (_ctx, task) => {
85
88
  const cloudWorld = await api.getWorld(worldSlug);
89
+ ctx.worldId = cloudWorld.id;
86
90
  if (cloudWorld.visibility === "public") {
87
91
  task.title = "World is public, setting to private for update...";
88
92
  await api.updateWorldVisibility(worldSlug, "private");
@@ -100,10 +104,20 @@ export default class Push extends Command {
100
104
  task.title = "Config uploaded";
101
105
  },
102
106
  },
107
+ {
108
+ title: "Extracting world tarball",
109
+ task: async (_ctx, task) => {
110
+ ctx.extractedDir = await world.extractToTempDir();
111
+ task.title = "World tarball extracted";
112
+ },
113
+ },
103
114
  {
104
115
  title: "Uploading world",
105
116
  task: async (_ctx, task) => {
106
- await api.uploadWorldFile(worldSlug, world.tarballPath);
117
+ if (!ctx.worldId || !ctx.extractedDir) {
118
+ throw new Error("Missing world ID or extracted directory");
119
+ }
120
+ await api.uploadWorldFile(worldSlug, ctx.extractedDir, ctx.worldId);
107
121
  task.title = "World uploaded";
108
122
  },
109
123
  },
@@ -116,7 +130,15 @@ export default class Push extends Command {
116
130
  },
117
131
  },
118
132
  ]);
119
- await tasks.run();
133
+ try {
134
+ await tasks.run();
135
+ }
136
+ finally {
137
+ // Clean up extracted directory
138
+ if (ctx.extractedDir) {
139
+ await fs.rm(ctx.extractedDir, { recursive: true, force: true });
140
+ }
141
+ }
120
142
  this.log(pc.green(`✓ World pushed: ${worldSlug}`));
121
143
  this.log();
122
144
  }
@@ -150,7 +150,15 @@ export declare class ApiClient {
150
150
  deleteWorld(slug: string): Promise<void>;
151
151
  getWorldUploadUrl(slug: string): Promise<string>;
152
152
  getWorldDownloadUrl(slug: string): Promise<DownloadUrlResponse>;
153
- uploadWorldFile(slug: string, tarballPath: string): Promise<void>;
153
+ /**
154
+ * Upload a world folder to Google Cloud Storage.
155
+ * The folder must contain level.dat at its root.
156
+ * The tarball will be created with the world ID as the root folder (e.g., {worldId}/level.dat).
157
+ * @param slug - The slug of the world (used to get the upload URL).
158
+ * @param worldFolderPath - Path to the world folder containing level.dat at root.
159
+ * @param worldId - The world ID to use as the root folder in the tarball.
160
+ */
161
+ uploadWorldFile(slug: string, worldFolderPath: string, worldId: string): Promise<void>;
154
162
  /**
155
163
  * Get the dashboard URL for the current environment.
156
164
  * @returns The dashboard URL.