@kradle/cli 0.2.3 → 0.2.5

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
@@ -111,6 +111,19 @@ List all challenges (local and cloud):
111
111
  kradle challenge list
112
112
  ```
113
113
 
114
+ ### Pull Challenge
115
+
116
+ Download a challenge from the cloud and extract source files locally:
117
+
118
+ ```bash
119
+ kradle challenge pull # Interactive selection
120
+ kradle challenge pull <challenge-name> # Pull your own challenge
121
+ kradle challenge pull <team-name>:<challenge-name> # Pull a public challenge from another team
122
+ kradle challenge pull <challenge-name> --yes # Skip confirmation when overwriting
123
+ ```
124
+
125
+ This downloads the challenge tarball, extracts `challenge.ts` and `config.ts`, and builds the datapack locally.
126
+
114
127
  ### Watch Challenge
115
128
 
116
129
  Watch a challenge for changes and auto-rebuild/upload:
@@ -0,0 +1,15 @@
1
+ import { Command } from "@oclif/core";
2
+ export default class Pull extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static args: {
6
+ challengeSlug: import("@oclif/core/interfaces").Arg<string | undefined>;
7
+ };
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
+ "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<void>;
15
+ }
@@ -0,0 +1,182 @@
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Command, Flags } from "@oclif/core";
5
+ import enquirer from "enquirer";
6
+ import { Listr } from "listr2";
7
+ import pc from "picocolors";
8
+ import * as tar from "tar";
9
+ import { ApiClient } from "../../lib/api-client.js";
10
+ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
11
+ import { Challenge, SOURCE_FOLDER } from "../../lib/challenge.js";
12
+ import { getConfigFlags } from "../../lib/flags.js";
13
+ export default class Pull extends Command {
14
+ static description = "Pull a challenge from the cloud and extract source files locally";
15
+ static examples = [
16
+ "<%= config.bin %> <%= command.id %>",
17
+ "<%= config.bin %> <%= command.id %> my-challenge",
18
+ "<%= config.bin %> <%= command.id %> username:my-challenge",
19
+ "<%= config.bin %> <%= command.id %> my-challenge --yes",
20
+ ];
21
+ static args = {
22
+ challengeSlug: getChallengeSlugArgument({
23
+ description: "Challenge slug to pull (interactive selection if omitted)",
24
+ required: false,
25
+ allowTeam: true,
26
+ }),
27
+ };
28
+ static flags = {
29
+ yes: Flags.boolean({ char: "y", description: "Skip confirmation prompts", default: false }),
30
+ ...getConfigFlags("api-key", "api-url", "challenges-path"),
31
+ };
32
+ async run() {
33
+ const { args, flags } = await this.parse(Pull);
34
+ const api = new ApiClient(flags["api-url"], flags["api-key"]);
35
+ let challengeSlug = args.challengeSlug;
36
+ if (!challengeSlug) {
37
+ const [kradleChallenges, cloudChallenges, localChallenges] = await Promise.all([
38
+ api.listKradleChallenges(),
39
+ api.listChallenges(),
40
+ Challenge.getLocalChallenges(),
41
+ ]);
42
+ const allChallenges = [...kradleChallenges, ...cloudChallenges];
43
+ if (allChallenges.length === 0) {
44
+ this.error(pc.red("No challenges found in the cloud."));
45
+ }
46
+ const localSet = new Set(localChallenges);
47
+ const slugs = allChallenges.map((c) => c.slug).sort();
48
+ const choices = slugs.map((slug) => {
49
+ const shortSlug = extractShortSlug(slug);
50
+ const isLocal = localSet.has(shortSlug);
51
+ const status = isLocal ? pc.yellow(" (local)") : "";
52
+ return {
53
+ name: slug,
54
+ message: `${slug}${status}`,
55
+ };
56
+ });
57
+ const response = await enquirer.prompt({
58
+ type: "select",
59
+ name: "challenge",
60
+ message: "Select a challenge to pull",
61
+ choices,
62
+ });
63
+ challengeSlug = response.challenge;
64
+ }
65
+ const shortSlug = extractShortSlug(challengeSlug);
66
+ const challenge = new Challenge(shortSlug, flags["challenges-path"]);
67
+ const existsInCloud = await api.challengeExists(challengeSlug);
68
+ if (!existsInCloud) {
69
+ this.error(pc.red(`Challenge "${challengeSlug}" does not exist in the cloud.`));
70
+ }
71
+ const existsLocally = existsSync(challenge.challengeDir);
72
+ const hasLocalFiles = existsLocally && (existsSync(challenge.challengePath) || existsSync(challenge.configPath));
73
+ if (hasLocalFiles && !flags.yes) {
74
+ this.log(pc.bold(`\nChallenge: ${pc.cyan(challenge.shortSlug)}`));
75
+ this.log(` Local folder exists: ${pc.yellow(challenge.challengeDir)}`);
76
+ if (existsSync(challenge.challengePath)) {
77
+ this.log(` challenge.ts: ${pc.yellow("exists (will be overwritten)")}`);
78
+ }
79
+ if (existsSync(challenge.configPath)) {
80
+ this.log(` config.ts: ${pc.yellow("exists (will be overwritten)")}`);
81
+ }
82
+ this.log("");
83
+ try {
84
+ const response = await enquirer.prompt({
85
+ type: "confirm",
86
+ name: "confirm",
87
+ message: `Overwrite local challenge files? ${pc.red("This cannot be undone.")}`,
88
+ initial: false,
89
+ });
90
+ if (!response.confirm) {
91
+ this.log(pc.yellow("Pull cancelled"));
92
+ return;
93
+ }
94
+ }
95
+ catch {
96
+ this.log(pc.yellow("\nPull cancelled"));
97
+ return;
98
+ }
99
+ }
100
+ const tempTarballPath = path.join(flags["challenges-path"], `${challenge.shortSlug}-pull-temp.tar.gz`);
101
+ const tasks = new Listr([
102
+ {
103
+ title: "Downloading challenge tarball",
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 tarball";
114
+ },
115
+ },
116
+ {
117
+ title: "Creating challenge directory",
118
+ task: async (_, task) => {
119
+ 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)`;
150
+ }
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";
161
+ },
162
+ },
163
+ {
164
+ title: "Building datapack",
165
+ task: async (_, task) => {
166
+ await challenge.build(true);
167
+ task.title = "Built datapack";
168
+ },
169
+ },
170
+ ]);
171
+ try {
172
+ await tasks.run();
173
+ this.log(pc.green(`\n✓ Challenge pulled: ${challenge.shortSlug}`));
174
+ this.log(pc.dim(` → challenge.ts: ${challenge.challengePath}`));
175
+ this.log(pc.dim(` → config.ts: ${challenge.configPath}`));
176
+ }
177
+ catch (error) {
178
+ await fs.rm(tempTarballPath, { force: true }).catch(() => { });
179
+ this.error(pc.red(`Pull failed: ${error instanceof Error ? error.message : String(error)}`));
180
+ }
181
+ }
182
+ }
@@ -66,6 +66,12 @@ export declare class ApiClient {
66
66
  * @returns The upload URL.
67
67
  */
68
68
  getChallengeUploadUrl(slug: string): Promise<string>;
69
+ /**
70
+ * Get the download URL for a challenge datapack.
71
+ * @param slug - The slug of the challenge.
72
+ * @returns The download URL and expiration time.
73
+ */
74
+ getChallengeDownloadUrl(slug: string): Promise<RecordingDownloadUrlResponse>;
69
75
  runChallenge(runData: {
70
76
  challenge: string;
71
77
  participants: unknown[];
@@ -210,6 +210,14 @@ export class ApiClient {
210
210
  const response = await this.get(`challenges/${slug}/datapackUploadUrl`, {}, UploadUrlResponseSchema);
211
211
  return response.uploadUrl;
212
212
  }
213
+ /**
214
+ * Get the download URL for a challenge datapack.
215
+ * @param slug - The slug of the challenge.
216
+ * @returns The download URL and expiration time.
217
+ */
218
+ async getChallengeDownloadUrl(slug) {
219
+ return this.get(`challenges/${slug}/datapackDownloadUrl`, {}, RecordingDownloadUrlResponseSchema);
220
+ }
213
221
  async runChallenge(runData) {
214
222
  const url = "jobs";
215
223
  const payload = this.isStudio ? runData : { ...runData, jobType: "background" };
@@ -1,10 +1,5 @@
1
1
  import type { Arg } from "@oclif/core/interfaces";
2
- /**
3
- * Returns a "challenge slug" argument, validating it to be a valid challenge slug.
4
- * @param description - Description for the argument
5
- * @param required - Whether the argument is required (default: true)
6
- * @param allowTeam - Whether to allow namespaced slugs like "team-name:my-challenge" (default: false)
7
- */
2
+ export declare function extractShortSlug(slug: string): string;
8
3
  export declare function getChallengeSlugArgument<R extends boolean = true>({ description, required, allowTeam, }: {
9
4
  description: string;
10
5
  required?: R;
@@ -1,16 +1,13 @@
1
1
  import { Args } from "@oclif/core";
2
- // Base pattern for a slug segment (lowercase alphanumeric with hyphens, no leading/trailing hyphens)
3
2
  const SLUG_SEGMENT = "[a-z0-9]+(?:-[a-z0-9]+)*";
4
- // Local challenge slug pattern: just the challenge name (no namespace)
5
3
  const LOCAL_SLUG_REGEX = new RegExp(`^${SLUG_SEGMENT}$`);
6
- // Full challenge slug pattern: optional namespace prefix (e.g., "team-name:") followed by the challenge slug
7
4
  const NAMESPACED_SLUG_REGEX = new RegExp(`^(?:${SLUG_SEGMENT}:)?${SLUG_SEGMENT}$`);
8
- /**
9
- * Returns a "challenge slug" argument, validating it to be a valid challenge slug.
10
- * @param description - Description for the argument
11
- * @param required - Whether the argument is required (default: true)
12
- * @param allowTeam - Whether to allow namespaced slugs like "team-name:my-challenge" (default: false)
13
- */
5
+ export function extractShortSlug(slug) {
6
+ if (slug.includes(":")) {
7
+ return slug.split(":")[1];
8
+ }
9
+ return slug;
10
+ }
14
11
  export function getChallengeSlugArgument({ description, required, allowTeam = false, }) {
15
12
  const regex = allowTeam ? NAMESPACED_SLUG_REGEX : LOCAL_SLUG_REGEX;
16
13
  const errorMessage = allowTeam
@@ -86,6 +86,54 @@
86
86
  "list.js"
87
87
  ]
88
88
  },
89
+ "ai-docs:api": {
90
+ "aliases": [],
91
+ "args": {},
92
+ "description": "Output the Kradle API reference documentation for LLMs",
93
+ "examples": [
94
+ "<%= config.bin %> <%= command.id %>"
95
+ ],
96
+ "flags": {},
97
+ "hasDynamicHelp": false,
98
+ "hiddenAliases": [],
99
+ "id": "ai-docs:api",
100
+ "pluginAlias": "@kradle/cli",
101
+ "pluginName": "@kradle/cli",
102
+ "pluginType": "core",
103
+ "strict": true,
104
+ "enableJsonFlag": false,
105
+ "isESM": true,
106
+ "relativePath": [
107
+ "dist",
108
+ "commands",
109
+ "ai-docs",
110
+ "api.js"
111
+ ]
112
+ },
113
+ "ai-docs:cli": {
114
+ "aliases": [],
115
+ "args": {},
116
+ "description": "Output the Kradle CLI reference documentation for LLMs",
117
+ "examples": [
118
+ "<%= config.bin %> <%= command.id %>"
119
+ ],
120
+ "flags": {},
121
+ "hasDynamicHelp": false,
122
+ "hiddenAliases": [],
123
+ "id": "ai-docs:cli",
124
+ "pluginAlias": "@kradle/cli",
125
+ "pluginName": "@kradle/cli",
126
+ "pluginType": "core",
127
+ "strict": true,
128
+ "enableJsonFlag": false,
129
+ "isESM": true,
130
+ "relativePath": [
131
+ "dist",
132
+ "commands",
133
+ "ai-docs",
134
+ "cli.js"
135
+ ]
136
+ },
89
137
  "challenge:build": {
90
138
  "aliases": [],
91
139
  "args": {
@@ -357,6 +405,75 @@
357
405
  "list.js"
358
406
  ]
359
407
  },
408
+ "challenge:pull": {
409
+ "aliases": [],
410
+ "args": {
411
+ "challengeSlug": {
412
+ "description": "Challenge slug to pull (interactive selection if omitted)",
413
+ "name": "challengeSlug",
414
+ "required": false
415
+ }
416
+ },
417
+ "description": "Pull a challenge from the cloud and extract source files locally",
418
+ "examples": [
419
+ "<%= config.bin %> <%= command.id %>",
420
+ "<%= config.bin %> <%= command.id %> my-challenge",
421
+ "<%= config.bin %> <%= command.id %> username:my-challenge",
422
+ "<%= config.bin %> <%= command.id %> my-challenge --yes"
423
+ ],
424
+ "flags": {
425
+ "yes": {
426
+ "char": "y",
427
+ "description": "Skip confirmation prompts",
428
+ "name": "yes",
429
+ "allowNo": false,
430
+ "type": "boolean"
431
+ },
432
+ "api-key": {
433
+ "description": "Kradle API key",
434
+ "env": "KRADLE_API_KEY",
435
+ "name": "api-key",
436
+ "required": true,
437
+ "hasDynamicHelp": false,
438
+ "multiple": false,
439
+ "type": "option"
440
+ },
441
+ "api-url": {
442
+ "description": "Kradle Web API URL",
443
+ "env": "KRADLE_API_URL",
444
+ "name": "api-url",
445
+ "required": true,
446
+ "default": "https://api.kradle.ai/v0",
447
+ "hasDynamicHelp": false,
448
+ "multiple": false,
449
+ "type": "option"
450
+ },
451
+ "challenges-path": {
452
+ "description": "Absolute path to the challenges directory",
453
+ "env": "KRADLE_CHALLENGES_PATH",
454
+ "name": "challenges-path",
455
+ "default": "~/Documents/kradle-studio/challenges",
456
+ "hasDynamicHelp": false,
457
+ "multiple": false,
458
+ "type": "option"
459
+ }
460
+ },
461
+ "hasDynamicHelp": false,
462
+ "hiddenAliases": [],
463
+ "id": "challenge:pull",
464
+ "pluginAlias": "@kradle/cli",
465
+ "pluginName": "@kradle/cli",
466
+ "pluginType": "core",
467
+ "strict": true,
468
+ "enableJsonFlag": false,
469
+ "isESM": true,
470
+ "relativePath": [
471
+ "dist",
472
+ "commands",
473
+ "challenge",
474
+ "pull.js"
475
+ ]
476
+ },
360
477
  "challenge:run": {
361
478
  "aliases": [],
362
479
  "args": {
@@ -518,54 +635,6 @@
518
635
  "watch.js"
519
636
  ]
520
637
  },
521
- "ai-docs:api": {
522
- "aliases": [],
523
- "args": {},
524
- "description": "Output the Kradle API reference documentation for LLMs",
525
- "examples": [
526
- "<%= config.bin %> <%= command.id %>"
527
- ],
528
- "flags": {},
529
- "hasDynamicHelp": false,
530
- "hiddenAliases": [],
531
- "id": "ai-docs:api",
532
- "pluginAlias": "@kradle/cli",
533
- "pluginName": "@kradle/cli",
534
- "pluginType": "core",
535
- "strict": true,
536
- "enableJsonFlag": false,
537
- "isESM": true,
538
- "relativePath": [
539
- "dist",
540
- "commands",
541
- "ai-docs",
542
- "api.js"
543
- ]
544
- },
545
- "ai-docs:cli": {
546
- "aliases": [],
547
- "args": {},
548
- "description": "Output the Kradle CLI reference documentation for LLMs",
549
- "examples": [
550
- "<%= config.bin %> <%= command.id %>"
551
- ],
552
- "flags": {},
553
- "hasDynamicHelp": false,
554
- "hiddenAliases": [],
555
- "id": "ai-docs:cli",
556
- "pluginAlias": "@kradle/cli",
557
- "pluginName": "@kradle/cli",
558
- "pluginType": "core",
559
- "strict": true,
560
- "enableJsonFlag": false,
561
- "isESM": true,
562
- "relativePath": [
563
- "dist",
564
- "commands",
565
- "ai-docs",
566
- "cli.js"
567
- ]
568
- },
569
638
  "experiment:create": {
570
639
  "aliases": [],
571
640
  "args": {
@@ -800,5 +869,5 @@
800
869
  ]
801
870
  }
802
871
  },
803
- "version": "0.2.3"
872
+ "version": "0.2.5"
804
873
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kradle/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Kradle's CLI. Manage challenges, experiments, agents and more!",
5
5
  "keywords": [
6
6
  "cli"
@@ -261,7 +261,7 @@ current_y_position: {
261
261
  type: "individual",
262
262
  objective_type: "dummy",
263
263
  updater: (value) => {
264
- execute.as("@s").store.result.score(value).run.data.get.entity("@s", "Pos[1]");
264
+ value.set(Actions.getCurrentPlayerPosition().y)
265
265
  },
266
266
  }
267
267
  ```
@@ -323,6 +323,8 @@ score.lowerOrEqualThan(number | Score);
323
323
 
324
324
  ### Lifecycle Events
325
325
 
326
+ All lifecycle events run globally (not per-player).
327
+
326
328
  #### `start_challenge`
327
329
 
328
330
  Runs once when the challenge starts. Use for global setup.
@@ -337,19 +339,19 @@ start_challenge: () => {
337
339
 
338
340
  #### `init_participants`
339
341
 
340
- Runs once per player after start (delayed 1 second). Use for player setup.
342
+ Runs 1 second after start_challenge. Use for player setup. It still runs globally.
341
343
 
342
344
  ```typescript
343
345
  init_participants: () => {
344
- Actions.give({ target: "self", item: "minecraft:diamond_sword", count: 1 });
345
- Actions.setAttribute({ target: "self", attribute_: "generic.max_health", value: 40 });
346
- Actions.teleport({ target: "self", x: 0, y: 100, z: 0, absolute: true });
346
+ Actions.give({ target: "all", item: "minecraft:diamond_sword", count: 1 });
347
+ Actions.setAttribute({ target: "all", attribute_: "generic.max_health", value: 40 });
348
+ Actions.teleport({ target: "all", x: 0, y: 100, z: 0, absolute: true });
347
349
  }
348
350
  ```
349
351
 
350
352
  #### `on_tick`
351
353
 
352
- Runs every tick for each player. Use sparingly for performance.
354
+ Runs every tick. Use sparingly for performance.
353
355
 
354
356
  ```typescript
355
357
  on_tick: () => {
@@ -402,7 +404,7 @@ Score events watch a variable and trigger when it reaches a specified target val
402
404
  ```typescript
403
405
  {
404
406
  score: variables.death_count,
405
- mode: "fire_once", // Triggers once when death_count changes from 0
407
+ mode: "fire_once", // Triggers once when death_count changes away from initial value
406
408
  actions: () => {
407
409
  Actions.announce({ message: "First death!" });
408
410
  },
@@ -417,6 +419,8 @@ Score events watch a variable and trigger when it reaches a specified target val
417
419
 
418
420
  Advancement events trigger when a Minecraft advancement criterion is met. Internally, an advancement is created that grants when the criterion triggers, which then fires the event. The `criteria` array follows the [Minecraft Advancement JSON format](https://minecraft.fandom.com/wiki/Advancement/JSON_format).
419
421
 
422
+ Advancement-based events always fire per-player.
423
+
420
424
  **Parameters:**
421
425
  - `criteria` (required): Array of advancement trigger objects with optional conditions
422
426
  - `mode` (required): `"fire_once"` or `"repeatable"`
@@ -476,6 +480,13 @@ See the [Minecraft Wiki](https://minecraft.fandom.com/wiki/Advancement/JSON_form
476
480
 
477
481
  ## Actions
478
482
 
483
+ Actions are higher-level functions that wrap common Minecraft operations. They provide:
484
+ - Automatic target mapping (`"all"`, `"self"`, team names → proper selectors)
485
+ - Integration with Kradle's interface (e.g., `Actions.announce` messages appear in Kradle)
486
+ - Consistent API for common operations
487
+
488
+ For advanced use cases not covered by Actions, you can fall back to Sandstone's lower-level functions directly (`give`, `tellraw`, `effect`, `kill`, `execute`, etc.). See [Sandstone Integration](#sandstone-integration).
489
+
479
490
  All actions are called via the `Actions` object:
480
491
 
481
492
  ```typescript
@@ -497,28 +508,44 @@ Broadcast message to all players with KRADLE tag.
497
508
 
498
509
  ```typescript
499
510
  Actions.announce({
500
- message: string; // Message text
511
+ message: JSONTextComponent; // Message (string or formatted object)
512
+ });
513
+
514
+ // Simple string:
515
+ Actions.announce({ message: "Game starting!" });
516
+
517
+ // Formatted JSONTextComponent:
518
+ Actions.announce({
519
+ message: [
520
+ { text: "Player ", color: "white" },
521
+ { selector: "@s", color: "gold", bold: true },
522
+ { text: " won the game!", color: "green" }
523
+ ]
501
524
  });
502
525
  ```
503
526
 
504
527
  #### `Actions.tellraw(params)`
505
528
 
506
- Send formatted message to specific target.
529
+ Send formatted message to specific target. **Note:** These messages are only visible to players in-game and will not appear in Kradle's interface. Use `Actions.announce` for messages that should be visible in Kradle.
507
530
 
508
531
  ```typescript
509
532
  Actions.tellraw({
510
- target: TargetNames; // "all", "self", or any selector
511
- message: string[]; // Message array
533
+ target: TargetNames; // "all", "self", or any selector
534
+ message: JSONTextComponent; // Message (string or formatted object)
512
535
  });
513
536
 
514
- // Example:
537
+ // Examples:
515
538
  Actions.tellraw({
516
539
  target: "all",
517
540
  message: ["Hello, ", { text: "world!", color: "gold", bold: true }]
518
541
  });
519
542
  Actions.tellraw({
520
543
  target: "self",
521
- message: ["You won!"]
544
+ message: "You won!"
545
+ });
546
+ Actions.tellraw({
547
+ target: "self",
548
+ message: { text: "Critical hit!", color: "red", bold: true }
522
549
  });
523
550
  ```
524
551
 
@@ -611,6 +638,39 @@ variables.my_diamond_count.set(count);
611
638
 
612
639
  **Note:** This action creates a temporary variable internally using `Variable()` and uses `execute.store.result.score` with `clear` command (count 0) to count items without removing them from the inventory.
613
640
 
641
+ #### `Actions.getCurrentPlayerPosition()`
642
+
643
+ Get the current player's position as x, y, z Score variables. Must be called in a player context (e.g., inside `forEveryPlayer`, individual variables updaters, or when `@s` is a player). This is the prefered way of checking a player's position.
644
+
645
+ ```typescript
646
+ Actions.getCurrentPlayerPosition(): { x: Score; y: Score; z: Score }
647
+
648
+ // Example - Check if player is above Y=100:
649
+ const pos = Actions.getCurrentPlayerPosition();
650
+ _.if(pos.y.greaterThan(100), () => {
651
+ Actions.announce({ message: "You reached the sky!" });
652
+ });
653
+
654
+ // Example - Store position in custom variables:
655
+ const { x, y, z } = Actions.getCurrentPlayerPosition();
656
+ variables.player_x.set(x);
657
+ variables.player_y.set(y);
658
+ variables.player_z.set(z);
659
+
660
+ // Example - Check if player is in a specific area:
661
+ const pos = Actions.getCurrentPlayerPosition();
662
+ _.if(_.and(
663
+ pos.x.greaterThan(0),
664
+ pos.x.lowerThan(100),
665
+ pos.z.greaterThan(0),
666
+ pos.z.lowerThan(100)
667
+ ), () => {
668
+ Actions.announce({ message: "You're in the zone!" });
669
+ });
670
+ ```
671
+
672
+ **Note:** This returns integer coordinates (block position). The values are truncated from the player's exact floating-point position.
673
+
614
674
  ### Entities
615
675
 
616
676
  #### `Actions.summonMultiple(params)`
@@ -1045,7 +1105,7 @@ createChallenge({
1045
1105
  Actions.announce({ message: "First to kill 2 pigs wins!" });
1046
1106
  },
1047
1107
  init_participants: () => {
1048
- Actions.give({ target: "self", item: "minecraft:iron_sword", count: 1 });
1108
+ Actions.give({ target: "all", item: "minecraft:iron_sword", count: 1 });
1049
1109
  },
1050
1110
  }))
1051
1111
  .custom_events(({ pigs_farmed }) => [
@@ -1080,7 +1140,7 @@ createChallenge({
1080
1140
  type: "individual",
1081
1141
  objective_type: "dummy",
1082
1142
  updater: (value) => {
1083
- execute.as("@s").store.result.score(value).run.data.get.entity("@s", "Pos[1]");
1143
+ value.set(Actions.getCurrentPlayerPosition().y)
1084
1144
  },
1085
1145
  },
1086
1146
  max_height: {
@@ -1124,8 +1184,8 @@ createChallenge({
1124
1184
  Actions.announce({ message: "Climb as high as you can!" });
1125
1185
  },
1126
1186
  init_participants: () => {
1127
- Actions.give({ target: "self", item: "minecraft:cobblestone", count: 64 });
1128
- Actions.give({ target: "self", item: "minecraft:cobblestone", count: 64 });
1187
+ Actions.give({ target: "all", item: "minecraft:cobblestone", count: 64 });
1188
+ Actions.give({ target: "all", item: "minecraft:cobblestone", count: 64 });
1129
1189
  },
1130
1190
  }))
1131
1191
  .custom_events(() => [])
@@ -1176,9 +1236,9 @@ createChallenge({
1176
1236
  Actions.announce({ message: "Last player standing wins!" });
1177
1237
  },
1178
1238
  init_participants: () => {
1179
- Actions.give({ target: "self", item: "minecraft:stone_sword", count: 1 });
1180
- Actions.give({ target: "self", item: "minecraft:leather_chestplate", count: 1 });
1181
- Actions.give({ target: "self", item: "minecraft:cooked_beef", count: 10 });
1239
+ Actions.give({ target: "all", item: "minecraft:stone_sword", count: 1 });
1240
+ Actions.give({ target: "all", item: "minecraft:leather_chestplate", count: 1 });
1241
+ Actions.give({ target: "all", item: "minecraft:cooked_beef", count: 10 });
1182
1242
  },
1183
1243
  }))
1184
1244
  .custom_events(({ kills }) => [
@@ -1237,7 +1297,7 @@ createChallenge({
1237
1297
  },
1238
1298
  init_participants: () => {
1239
1299
  // Different items based on role would be set via role-specific logic
1240
- Actions.give({ target: "self", item: "minecraft:wooden_sword", count: 1 });
1300
+ Actions.give({ target: "all", item: "minecraft:wooden_sword", count: 1 });
1241
1301
  },
1242
1302
  }))
1243
1303
  .custom_events(() => [])
@@ -1301,7 +1361,7 @@ createChallenge({
1301
1361
  Actions.announce({ message: "Capture the enemy flag and return to base!" });
1302
1362
  },
1303
1363
  init_participants: () => {
1304
- Actions.give({ target: "self", item: "minecraft:iron_sword", count: 1 });
1364
+ Actions.give({ target: "all", item: "minecraft:iron_sword", count: 1 });
1305
1365
  },
1306
1366
  }))
1307
1367
  .custom_events(({ captured_flag }) => [
@@ -1378,6 +1438,10 @@ current_y: {
1378
1438
  type: "individual",
1379
1439
  objective_type: "dummy",
1380
1440
  updater: (value) => {
1441
+ // Prefered way: using the dedicated Action
1442
+ value.set(Actions.getCurrentPlayerPosition().y)
1443
+
1444
+ // Alternative way: using execute.store + data.get
1381
1445
  execute.as("@s").store.result.score(value).run.data.get.entity("@s", "Pos[1]");
1382
1446
  },
1383
1447
  }
@@ -223,6 +223,61 @@ kradle challenge list
223
223
 
224
224
  ---
225
225
 
226
+ ### `kradle challenge pull [name]`
227
+
228
+ Downloads a challenge from the cloud and extracts source files locally.
229
+
230
+ **Usage:**
231
+ ```bash
232
+ kradle challenge pull
233
+ kradle challenge pull <challenge-name>
234
+ kradle challenge pull <team-name>:<challenge-name>
235
+ kradle challenge pull <challenge-name> --yes
236
+ ```
237
+
238
+ **Arguments:**
239
+ | Argument | Description | Required |
240
+ |----------|-------------|----------|
241
+ | `challenge-name` | Challenge slug to pull. Can be a short slug (e.g., `my-challenge`) or include a team/user namespace (e.g., `team-name:my-challenge`). If omitted, shows interactive selection. | No |
242
+
243
+ **Flags:**
244
+ | Flag | Short | Description | Default |
245
+ |------|-------|-------------|---------|
246
+ | `--yes` | `-y` | Skip confirmation prompts when overwriting local files | false |
247
+
248
+ **What it does:**
249
+ 1. Downloads the challenge tarball from cloud storage
250
+ 2. Extracts `challenge.ts` and `config.ts` from the `.src` folder in the tarball
251
+ 3. Places files in `challenges/<short-slug>/` directory
252
+ 4. Builds the datapack locally
253
+
254
+ **Interactive mode (no slug argument):**
255
+ - Shows list of all cloud challenges (your own + team-kradle)
256
+ - Marks challenges that exist locally with "(local)" indicator
257
+ - Select a challenge to pull
258
+
259
+ **Confirmation prompt:**
260
+ When pulling over existing local files (without `--yes`), shows:
261
+ - Which files will be overwritten
262
+ - Asks for confirmation before proceeding
263
+
264
+ **Examples:**
265
+ ```bash
266
+ # Interactive selection from all cloud challenges
267
+ kradle challenge pull
268
+
269
+ # Pull your own challenge
270
+ kradle challenge pull my-challenge
271
+
272
+ # Pull a public challenge from another team
273
+ kradle challenge pull team-kradle:example-challenge
274
+
275
+ # Pull without confirmation prompt
276
+ kradle challenge pull my-challenge --yes
277
+ ```
278
+
279
+ ---
280
+
226
281
  ### `kradle challenge watch <name>`
227
282
 
228
283
  Watches a challenge for file changes and automatically rebuilds/uploads.
@@ -663,6 +718,7 @@ kradle challenge build --all --visibility public
663
718
  | `kradle challenge build --all` | Build all challenges |
664
719
  | `kradle challenge delete <name>` | Delete challenge |
665
720
  | `kradle challenge list` | List all challenges |
721
+ | `kradle challenge pull [name]` | Pull challenge from cloud |
666
722
  | `kradle challenge watch <name>` | Watch and auto-rebuild |
667
723
  | `kradle challenge run <name>` | Run challenge |
668
724
  | `kradle experiment create <name>` | Create new experiment |