kradle 0.6.5 → 0.6.7

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
@@ -81,17 +81,24 @@ This creates a `challenges/<challenge-name>/` folder with:
81
81
 
82
82
  ### Build Challenge
83
83
 
84
- Build challenge datapack and upload both config and datapack:
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)
89
+ kradle challenge build <challenge-name> --no-validate # Skip validation
90
+ kradle challenge build <challenge-name> --public # Upload as public
91
+ kradle challenge build --all # Build all local challenges
88
92
  ```
89
93
 
90
94
  This command:
91
- 1. Creates the challenge in the cloud (if it doesn't already exists)
92
- 2. Uploads `config.ts` to cloud (if it exists)
93
- 3. Builds the datapack by executing `challenge.ts`
94
- 4. 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
+
100
+ Use `--no-upload` to build locally only. Use `--no-validate` to skip validation.
101
+ `--public` is only valid when upload is enabled (default).
95
102
 
96
103
  ### Delete Challenge
97
104
 
@@ -132,9 +139,11 @@ Watch a challenge for changes and auto-rebuild/upload:
132
139
 
133
140
  ```bash
134
141
  kradle challenge watch <challenge-name>
142
+ kradle challenge watch <challenge-name> --no-validate # Skip validation
143
+ kradle challenge watch <challenge-name> --verbose # Verbose output
135
144
  ```
136
145
 
137
- Uses file watching with debouncing (300ms) and hash comparison to minimize unnecessary rebuilds.
146
+ Uses file watching with debouncing (1 second) and hash comparison to minimize unnecessary rebuilds. Validation runs after each build but doesn't block uploads in watch mode (errors are displayed but the upload continues).
138
147
 
139
148
  ### Run Challenge
140
149
 
@@ -470,7 +479,7 @@ Each challenge is a folder in `challenges/<slug>/` containing:
470
479
  1. `kradle challenge create <slug>` creates the folder with `challenge.ts`
471
480
  2. The create command automatically builds, uploads, and downloads the config from the cloud API
472
481
  3. The downloaded JSON is converted into a typed TypeScript `config.ts` file
473
- 4. `kradle challenge build <slug>` automatically uploads `config.ts` (if it exists) before building the datapack
482
+ 4. `kradle challenge build <slug>` builds datapacks locally and uploads `config.ts` + datapack by default (use `--no-upload` for local-only build)
474
483
  5. You can modify `config.ts` locally and run `build` to sync changes to the cloud
475
484
 
476
485
  ## Architecture
@@ -6,12 +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>;
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>;
14
16
  };
15
17
  static strict: boolean;
16
18
  run(): Promise<void>;
19
+ private requireApiKey;
17
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 and upload challenge datapack and config";
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,14 +13,31 @@ export default class Build extends Command {
13
13
  ];
14
14
  static args = {
15
15
  challengeSlug: getChallengeSlugArgument({
16
- description: "Challenge slug to build and upload. Can be used multiple times to build and upload multiple challenges at once. Incompatible with --all flag.",
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
  };
20
20
  static flags = {
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
- ...getConfigFlags("api-key", "api-url", "challenges-path"),
23
+ "no-validate": Flags.boolean({
24
+ description: "Skip datapack validation",
25
+ default: false,
26
+ }),
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"),
24
41
  };
25
42
  static strict = false;
26
43
  async run() {
@@ -28,6 +45,9 @@ export default class Build extends Command {
28
45
  if (flags.all && argv.length > 0) {
29
46
  this.error(pc.red("Cannot use --all flag with challenge slugs"));
30
47
  }
48
+ if (flags["no-upload"] && flags.public) {
49
+ this.error(pc.red("Cannot use --public with --no-upload"));
50
+ }
31
51
  if (!flags.all && argv.length === 0) {
32
52
  // Show help if no challenge slugs are provided - https://github.com/oclif/oclif/issues/183#issuecomment-1933104981
33
53
  await new (await loadHelpClass(this.config))(this.config).showHelp([Build.id]);
@@ -37,7 +57,8 @@ export default class Build extends Command {
37
57
  this.log(pc.blue("Building all challenges"));
38
58
  }
39
59
  const challengeSlugs = flags.all ? await Challenge.getLocalChallenges() : argv;
40
- const api = new ApiClient(flags["api-url"], flags["api-key"]);
60
+ const shouldUpload = !flags["no-upload"];
61
+ const api = shouldUpload ? new ApiClient(flags["api-url"], this.requireApiKey(flags["api-key"])) : null;
41
62
  for (const challengeSlug of challengeSlugs) {
42
63
  // Validate that the challenge exists locally
43
64
  const validation = await Challenge.validateForLocalOperation(challengeSlug, flags["challenges-path"]);
@@ -47,7 +68,17 @@ export default class Build extends Command {
47
68
  const challenge = new Challenge(extractShortSlug(challengeSlug), flags["challenges-path"]);
48
69
  this.log(pc.blue(`==== Building challenge: ${challenge.shortSlug} ====`));
49
70
  try {
50
- await challenge.buildAndUpload(api, flags.public);
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
+ }
51
82
  }
52
83
  catch (error) {
53
84
  this.error(pc.red(`Build failed: ${error instanceof Error ? error.message : String(error)}`));
@@ -55,4 +86,11 @@ export default class Build extends Command {
55
86
  this.log();
56
87
  }
57
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
+ }
58
96
  }
@@ -7,6 +7,7 @@ export default class Watch extends Command {
7
7
  "api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
8
  "challenges-path": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
9
  verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ "no-validate": import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  };
11
12
  static args: {
12
13
  challengeSlug: import("@oclif/core/interfaces").Arg<string>;
@@ -8,11 +8,16 @@ import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.
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";
11
+ import { formatValidationResult, ValidationService } from "../../lib/validator.js";
11
12
  export default class Watch extends Command {
12
13
  static description = "Watch a challenge for changes and auto-rebuild/upload";
13
14
  static examples = ["<%= config.bin %> <%= command.id %> my-challenge"];
14
15
  static flags = {
15
16
  verbose: Flags.boolean({ description: "Enable verbose output", default: false }),
17
+ "no-validate": Flags.boolean({
18
+ description: "Skip datapack validation",
19
+ default: false,
20
+ }),
16
21
  ...getConfigFlags("api-key", "api-url", "challenges-path"),
17
22
  };
18
23
  static args = {
@@ -31,8 +36,28 @@ export default class Watch extends Command {
31
36
  let building = false;
32
37
  // Perform the initial build and upload
33
38
  this.log(pc.blue(`Performing initial build and upload...`));
34
- let { config: lastConfig, datapackHash: lastHash } = await challenge.buildAndUpload(api, false);
39
+ let { config: lastConfig, datapackHash: lastHash } = await challenge.buildAndUpload(api, {
40
+ asPublic: false,
41
+ validate: !flags["no-validate"],
42
+ });
35
43
  this.log(pc.green(`✓ Initial build and upload complete`));
44
+ // Create a reusable validation service for watch mode (42ms per validation vs 1.8s)
45
+ // The service is initialized once and reused for all subsequent validations
46
+ let validationService = null;
47
+ if (!flags["no-validate"]) {
48
+ validationService = ValidationService.create(challenge.builtDatapackPath);
49
+ // Wait for initial initialization
50
+ await validationService.waitUntilReady();
51
+ }
52
+ // Clean up validation service on exit
53
+ const cleanup = async () => {
54
+ if (validationService) {
55
+ await validationService.close();
56
+ }
57
+ process.exit(0);
58
+ };
59
+ process.on("SIGINT", cleanup);
60
+ process.on("SIGTERM", cleanup);
36
61
  const executeRebuild = async () => {
37
62
  building = true;
38
63
  // Clear screen before subsequent rebuilds for clean output
@@ -66,8 +91,29 @@ export default class Watch extends Command {
66
91
  task.title = "Datapack built";
67
92
  },
68
93
  },
94
+ {
95
+ title: "Validating datapack",
96
+ skip: () => (flags["no-validate"] || !validationService ? "Validation skipped" : false),
97
+ task: async (ctx, task) => {
98
+ // Use the reusable validation service (42ms vs 1.8s for new service each time)
99
+ const validationResult = await validationService.validate();
100
+ ctx.validationFailed = validationResult.hasErrors;
101
+ ctx.validationOutput = formatValidationResult(validationResult);
102
+ if (validationResult.hasErrors) {
103
+ throw new Error(`${validationResult.errors.length} error${validationResult.errors.length !== 1 ? "s" : ""}`);
104
+ }
105
+ else if (validationResult.warnings.length > 0) {
106
+ task.title = `Validation complete (${validationResult.warnings.length} warning${validationResult.warnings.length !== 1 ? "s" : ""})`;
107
+ }
108
+ else {
109
+ task.title = "Validation passed";
110
+ }
111
+ },
112
+ exitOnError: false,
113
+ },
69
114
  {
70
115
  title: "Checking datapack changes",
116
+ skip: (ctx) => (ctx.validationFailed ? "Upload skipped due to validation errors" : false),
71
117
  task: async (_ctx, task) => {
72
118
  const newHash = await challenge.getDatapackHash();
73
119
  // Upload if datapack changed OR if config changed (since tarball includes source files)
@@ -87,8 +133,14 @@ export default class Watch extends Command {
87
133
  renderer: flags.verbose ? "verbose" : "default",
88
134
  });
89
135
  try {
90
- await tasks.run();
91
- this.log(pc.green(`\n✓ Rebuild complete`));
136
+ const ctx = await tasks.run();
137
+ if (ctx.validationFailed) {
138
+ this.log(`\n${ctx.validationOutput}`);
139
+ this.log(pc.red(`\n✗ Upload skipped due to validation errors`));
140
+ }
141
+ else {
142
+ this.log(pc.green(`\n✓ Rebuild complete`));
143
+ }
92
144
  }
93
145
  catch (error) {
94
146
  this.log(pc.red(`\n✗ Error: ${error instanceof Error ? error.message : String(error)}`));
@@ -1,5 +1,6 @@
1
1
  import type { ApiClient } from "./api-client.js";
2
2
  import { type ChallengeConfigSchemaType, type ChallengeSchemaType } from "./schemas.js";
3
+ import { type ValidationResult } from "./validator.js";
3
4
  export declare const SOURCE_FOLDER = ".src";
4
5
  export declare class Challenge {
5
6
  readonly shortSlug: string;
@@ -25,6 +26,16 @@ export declare class Challenge {
25
26
  * Get the path to the tarball file
26
27
  */
27
28
  get tarballPath(): string;
29
+ /**
30
+ * Get the path to the built datapack directory (inside datapackPath)
31
+ */
32
+ get builtDatapackPath(): string;
33
+ /**
34
+ * Validate the built datapack using Spyglass
35
+ * @returns ValidationResult with categorized issues
36
+ * @throws Error if datapack doesn't exist
37
+ */
38
+ validate(): Promise<ValidationResult>;
28
39
  /**
29
40
  * Make sure the challenge exists locally and is valid by ensuring the challenge.ts file and the config.ts file exist.
30
41
  */
@@ -40,13 +51,30 @@ export declare class Challenge {
40
51
  */
41
52
  build(silent?: boolean, config?: ChallengeConfigSchemaType): Promise<void>;
42
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
+ }>;
43
66
  /**
44
67
  * Build the challenge datapack and upload it to the cloud.
45
68
  * @param api - The API client to use.
46
- * @param asPublic - Whether the challenge should be uploaded as public.
69
+ * @param options - Build options
70
+ * @param options.asPublic - Whether the challenge should be uploaded as public.
71
+ * @param options.validate - Whether to validate the datapack before upload (default: true).
47
72
  * @returns The config and datapack hash.
48
73
  */
49
- buildAndUpload(api: ApiClient, asPublic: boolean): Promise<{
74
+ buildAndUpload(api: ApiClient, options?: {
75
+ asPublic: boolean;
76
+ validate?: boolean;
77
+ }): Promise<{
50
78
  config: ChallengeConfigSchemaType;
51
79
  datapackHash: string;
52
80
  }>;
@@ -7,6 +7,7 @@ import * as tar from "tar";
7
7
  import { extractShortSlug, isNamespacedSlug } from "./arguments.js";
8
8
  import { ChallengeConfigSchema } from "./schemas.js";
9
9
  import { executeTypescriptFile, findSimilarStrings, getStaticResourcePath, loadTypescriptExport, readDirSorted, } from "./utils.js";
10
+ import { formatValidationResult, ValidationService, validateDatapack } from "./validator.js";
10
11
  import { formatError } from "./zod-errors.js";
11
12
  export const SOURCE_FOLDER = ".src";
12
13
  export class Challenge {
@@ -46,6 +47,23 @@ export class Challenge {
46
47
  get tarballPath() {
47
48
  return path.join(this.kradleChallengesPath, `${this.shortSlug}.tar.gz`);
48
49
  }
50
+ /**
51
+ * Get the path to the built datapack directory (inside datapackPath)
52
+ */
53
+ get builtDatapackPath() {
54
+ return path.join(this.datapackPath, "datapack");
55
+ }
56
+ /**
57
+ * Validate the built datapack using Spyglass
58
+ * @returns ValidationResult with categorized issues
59
+ * @throws Error if datapack doesn't exist
60
+ */
61
+ async validate() {
62
+ if (!existsSync(this.builtDatapackPath)) {
63
+ throw new Error(`Datapack not found at ${this.builtDatapackPath}. Run build first.`);
64
+ }
65
+ return validateDatapack(this.builtDatapackPath);
66
+ }
49
67
  /**
50
68
  * Make sure the challenge exists locally and is valid by ensuring the challenge.ts file and the config.ts file exist.
51
69
  */
@@ -144,16 +162,58 @@ export class Challenge {
144
162
  }
145
163
  }
146
164
  /**
147
- * Build the challenge datapack and upload it to the cloud.
148
- * @param api - The API client to use.
149
- * @param asPublic - Whether the challenge should be uploaded as public.
150
- * @returns The config and datapack hash.
165
+ * Build the challenge datapack locally.
166
+ * @param options - Build options
167
+ * @param options.validate - Whether to validate the datapack after build (default: true).
168
+ * @returns The loaded config and datapack hash.
151
169
  */
152
- async buildAndUpload(api, asPublic) {
170
+ async buildLocal(options = { validate: true }) {
171
+ const { validate = true } = options;
172
+ // Start validation service initialization in parallel with build (if validation enabled)
173
+ // This overlaps the init time with the build process.
174
+ let validationService = null;
175
+ if (validate) {
176
+ validationService = ValidationService.create(this.builtDatapackPath);
177
+ }
153
178
  // Ensure challenge exists locally
154
179
  if (!this.exists()) {
180
+ await validationService?.close();
155
181
  throw new Error(`Challenge "${this.shortSlug}" does not exist locally. Make sure both the challenge.ts file and the config.ts file exist.`);
156
182
  }
183
+ const config = await this.loadConfig();
184
+ // Build datapack (validation service initializes in parallel)
185
+ console.log(pc.blue(`>> Building datapack: ${this.shortSlug}`));
186
+ await this.build(false, config);
187
+ console.log(pc.green(`✓ Datapack built\n`));
188
+ // Validate datapack after building (if enabled)
189
+ if (validate && validationService) {
190
+ console.log(pc.blue(`>> Validating datapack: ${this.shortSlug}`));
191
+ try {
192
+ const validationResult = await validationService.validate();
193
+ console.log(formatValidationResult(validationResult));
194
+ console.log();
195
+ if (validationResult.hasErrors) {
196
+ throw new Error(`Validation failed with ${validationResult.errors.length} error(s). Build aborted.`);
197
+ }
198
+ }
199
+ finally {
200
+ await validationService.close();
201
+ }
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 });
157
217
  // Ensure challenge exists in the cloud
158
218
  if (!(await api.challengeExists(this.shortSlug))) {
159
219
  console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
@@ -161,7 +221,6 @@ export class Challenge {
161
221
  await api.createChallenge(this.shortSlug);
162
222
  console.log(pc.green(`✓ Challenge created in cloud`));
163
223
  }
164
- const config = await this.loadConfig();
165
224
  // Ensure challenge's visibility is set to private - else, temporarily set it to private
166
225
  const cloudChallengeVisibility = (await api.getChallenge(this.shortSlug, ["visibility"])).visibility;
167
226
  if (cloudChallengeVisibility === "public") {
@@ -174,18 +233,15 @@ export class Challenge {
174
233
  // We have to set it to private because we cannot update public challenges
175
234
  await api.updateChallenge(this.shortSlug, config, "private");
176
235
  console.log(pc.green(`✓ Config uploaded\n`));
177
- // Build and upload datapack
178
- console.log(pc.blue(`>> Building datapack: ${this.shortSlug}`));
179
- await this.build(false, config);
236
+ // Upload datapack
180
237
  console.log(pc.blue(`>> Uploading datapack: ${this.shortSlug}`));
181
238
  await api.uploadChallengeDatapack(this.shortSlug, this.tarballPath);
182
- console.log(pc.green(`✓ Build & upload complete for ${this.shortSlug}\n`));
239
+ console.log(pc.green(`✓ Datapack uploaded\n`));
183
240
  if (asPublic) {
184
241
  console.log(pc.green(`👀 Setting challenge visibility to public...`));
185
242
  await api.updateChallengeVisibility(this.shortSlug, "public");
186
243
  console.log(pc.green(`✓ Challenge visibility set to public\n`));
187
244
  }
188
- const datapackHash = await this.getDatapackHash();
189
245
  return { config, datapackHash };
190
246
  }
191
247
  /**
@@ -0,0 +1,62 @@
1
+ import { ErrorSeverity } from "@spyglassmc/core";
2
+ export interface ValidationError {
3
+ uri: string;
4
+ filePath: string;
5
+ line: number;
6
+ character: number;
7
+ endLine: number;
8
+ endCharacter: number;
9
+ message: string;
10
+ severity: ErrorSeverity;
11
+ }
12
+ export interface ValidationResult {
13
+ errors: ValidationError[];
14
+ warnings: ValidationError[];
15
+ infos: ValidationError[];
16
+ hints: ValidationError[];
17
+ hasErrors: boolean;
18
+ totalIssues: number;
19
+ }
20
+ /**
21
+ * Format validation errors for CLI output
22
+ */
23
+ export declare function formatValidationResult(result: ValidationResult): string;
24
+ /**
25
+ * Reusable validation service that keeps Spyglass initialized.
26
+ * Use this for watch mode or when validating multiple times.
27
+ */
28
+ export declare class ValidationService {
29
+ private service;
30
+ private rootUri;
31
+ private errorMap;
32
+ private readyPromise;
33
+ private constructor();
34
+ private initialize;
35
+ /**
36
+ * Create a new validation service for a datapack.
37
+ * Returns immediately - call waitUntilReady() or validate() to wait for initialization.
38
+ */
39
+ static create(datapackPath: string): ValidationService;
40
+ /**
41
+ * Wait until the service is fully initialized.
42
+ */
43
+ waitUntilReady(): Promise<void>;
44
+ /**
45
+ * Validate all mcfunction files in the datapack.
46
+ * Waits for initialization if not already complete.
47
+ */
48
+ validate(): Promise<ValidationResult>;
49
+ /**
50
+ * Notify the service that a file has changed (for watch mode).
51
+ */
52
+ notifyFileChanged(filePath: string): Promise<void>;
53
+ /**
54
+ * Close the service and clean up resources.
55
+ */
56
+ close(): Promise<void>;
57
+ }
58
+ /**
59
+ * Validate a Minecraft datapack using Spyglass (simple one-shot API).
60
+ * For repeated validations, use ValidationService instead.
61
+ */
62
+ export declare function validateDatapack(datapackPath: string): Promise<ValidationResult>;
@@ -0,0 +1,285 @@
1
+ import { readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { ConfigService, ErrorSeverity, Logger, Service, VanillaConfig } from "@spyglassmc/core";
6
+ import { getNodeJsExternals } from "@spyglassmc/core/lib/nodejs.js";
7
+ import { initialize as initializeJavaEdition } from "@spyglassmc/java-edition";
8
+ import { initialize as initializeMcdoc } from "@spyglassmc/mcdoc";
9
+ import pc from "picocolors";
10
+ /**
11
+ * List of error messages to filter out (Spyglass false positives)
12
+ */
13
+ const FILTERED_ERROR_MESSAGES = [
14
+ "Fake names cannot be longer than 40 characters", // Actually they can be longer
15
+ "Objective names cannot be longer than 16 characters", // Actually they can be longer
16
+ "Player names cannot be longer than 16 characters", // Actually they can be longer
17
+ ];
18
+ /**
19
+ * Get a human-readable severity label with color
20
+ */
21
+ function getSeverityLabel(severity) {
22
+ switch (severity) {
23
+ case ErrorSeverity.Error:
24
+ return pc.red("error");
25
+ case ErrorSeverity.Warning:
26
+ return pc.yellow("warning");
27
+ case ErrorSeverity.Information:
28
+ return pc.blue("info");
29
+ case ErrorSeverity.Hint:
30
+ return pc.dim("hint");
31
+ default:
32
+ return pc.dim("unknown");
33
+ }
34
+ }
35
+ /**
36
+ * Read a specific line from a file
37
+ */
38
+ function getSourceLine(uri, lineNumber) {
39
+ try {
40
+ const filePath = uri.startsWith("file://") ? fileURLToPath(uri) : uri;
41
+ const content = readFileSync(filePath, "utf-8");
42
+ const lines = content.split("\n");
43
+ if (lineNumber >= 0 && lineNumber < lines.length) {
44
+ return lines[lineNumber];
45
+ }
46
+ }
47
+ catch {
48
+ // Ignore file read errors
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Format validation errors for CLI output
54
+ */
55
+ export function formatValidationResult(result) {
56
+ if (result.totalIssues === 0) {
57
+ return pc.green("No validation issues found");
58
+ }
59
+ const lines = [];
60
+ const allIssues = [...result.errors, ...result.warnings, ...result.infos, ...result.hints];
61
+ // Group by file
62
+ const byFile = new Map();
63
+ for (const issue of allIssues) {
64
+ const existing = byFile.get(issue.filePath) || [];
65
+ existing.push(issue);
66
+ byFile.set(issue.filePath, existing);
67
+ }
68
+ // Sort files and output
69
+ const sortedFiles = [...byFile.keys()].sort();
70
+ for (const filePath of sortedFiles) {
71
+ const fileIssues = byFile.get(filePath);
72
+ if (!fileIssues)
73
+ continue;
74
+ // Sort by line, then character
75
+ fileIssues.sort((a, b) => a.line - b.line || a.character - b.character);
76
+ for (const issue of fileIssues) {
77
+ // Line numbers are 0-indexed from Spyglass, display as 1-indexed
78
+ const location = pc.cyan(`${filePath}:${issue.line + 1}:${issue.character + 1}`);
79
+ const severity = getSeverityLabel(issue.severity);
80
+ lines.push(`${location} ${severity}: ${issue.message}`);
81
+ // Show source line with caret marker
82
+ const sourceLine = getSourceLine(issue.uri, issue.line);
83
+ if (sourceLine !== null) {
84
+ lines.push(pc.dim(` ${sourceLine}`));
85
+ // Add caret marker pointing to the error column
86
+ const caretPadding = " ".repeat(issue.character + 2); // +2 for the " " prefix
87
+ lines.push(pc.red(`${caretPadding}^`));
88
+ }
89
+ }
90
+ }
91
+ // Summary
92
+ const summary = [];
93
+ if (result.errors.length > 0) {
94
+ summary.push(pc.red(`${result.errors.length} error${result.errors.length !== 1 ? "s" : ""}`));
95
+ }
96
+ if (result.warnings.length > 0) {
97
+ summary.push(pc.yellow(`${result.warnings.length} warning${result.warnings.length !== 1 ? "s" : ""}`));
98
+ }
99
+ if (result.infos.length > 0) {
100
+ summary.push(pc.blue(`${result.infos.length} info`));
101
+ }
102
+ if (result.hints.length > 0) {
103
+ summary.push(pc.dim(`${result.hints.length} hint${result.hints.length !== 1 ? "s" : ""}`));
104
+ }
105
+ lines.push("");
106
+ lines.push(`Found ${summary.join(", ")}`);
107
+ return lines.join("\n");
108
+ }
109
+ /**
110
+ * Reusable validation service that keeps Spyglass initialized.
111
+ * Use this for watch mode or when validating multiple times.
112
+ */
113
+ export class ValidationService {
114
+ service;
115
+ rootUri;
116
+ errorMap = new Map();
117
+ readyPromise;
118
+ constructor(service, rootUri) {
119
+ this.service = service;
120
+ this.rootUri = rootUri;
121
+ // Listen for document errors
122
+ this.service.project.on("documentErrored", (event) => {
123
+ const fileUri = event.uri;
124
+ // Skip archive files (vanilla data)
125
+ if (fileUri.startsWith("archive://")) {
126
+ return;
127
+ }
128
+ // Convert URI back to relative path
129
+ const filePath = fileUri.startsWith(this.rootUri)
130
+ ? fileUri.slice(this.rootUri.length)
131
+ : fileUri.replace("file://", "");
132
+ for (const error of event.errors) {
133
+ const key = `${fileUri}:${error.posRange.start.line}:${error.posRange.start.character}:${error.message}`;
134
+ if (!this.errorMap.has(key)) {
135
+ this.errorMap.set(key, {
136
+ uri: fileUri,
137
+ filePath,
138
+ line: error.posRange.start.line,
139
+ character: error.posRange.start.character,
140
+ endLine: error.posRange.end.line,
141
+ endCharacter: error.posRange.end.character,
142
+ message: error.message,
143
+ severity: error.severity,
144
+ });
145
+ }
146
+ }
147
+ });
148
+ // Start initialization immediately
149
+ this.readyPromise = this.initialize();
150
+ }
151
+ async initialize() {
152
+ await this.service.project.init();
153
+ await this.service.project.ready();
154
+ }
155
+ /**
156
+ * Create a new validation service for a datapack.
157
+ * Returns immediately - call waitUntilReady() or validate() to wait for initialization.
158
+ */
159
+ static create(datapackPath) {
160
+ // Convert to file URI format required by Spyglass (must end with /)
161
+ let rootUriStr = pathToFileURL(datapackPath).href;
162
+ if (!rootUriStr.endsWith("/")) {
163
+ rootUriStr += "/";
164
+ }
165
+ const rootUri = rootUriStr;
166
+ // Cache directory for Spyglass - use temp directory (no persistent cache for now)
167
+ const cacheDir = path.join(os.tmpdir(), "kradle-spyglass-cache");
168
+ const cacheRoot = `${pathToFileURL(cacheDir).href}/`;
169
+ const service = new Service({
170
+ isDebugging: false,
171
+ logger: Logger.noop(),
172
+ project: {
173
+ defaultConfig: ConfigService.merge(VanillaConfig, {
174
+ env: {
175
+ // Only load @vanilla-mcdoc - sufficient for mcfunction syntax validation
176
+ // Skips @vanilla-datapack (6835 files) and @vanilla-resourcepack (8473 files)
177
+ dependencies: ["@vanilla-mcdoc"],
178
+ // Disable features we don't need for CLI validation
179
+ feature: {
180
+ codeActions: false,
181
+ colors: false,
182
+ completions: false,
183
+ documentHighlighting: false,
184
+ documentLinks: false,
185
+ foldingRanges: false,
186
+ formatting: false,
187
+ hover: false,
188
+ inlayHint: false,
189
+ semanticColoring: false,
190
+ selectionRanges: false,
191
+ signatures: false,
192
+ },
193
+ },
194
+ }),
195
+ cacheRoot,
196
+ externals: getNodeJsExternals({ cacheRoot }),
197
+ // IMPORTANT: mcdoc.initialize must come BEFORE je.initialize
198
+ initializers: [initializeMcdoc, initializeJavaEdition],
199
+ projectRoots: [rootUri],
200
+ },
201
+ });
202
+ return new ValidationService(service, rootUri);
203
+ }
204
+ /**
205
+ * Wait until the service is fully initialized.
206
+ */
207
+ async waitUntilReady() {
208
+ await this.readyPromise;
209
+ }
210
+ /**
211
+ * Validate all mcfunction files in the datapack.
212
+ * Waits for initialization if not already complete.
213
+ */
214
+ async validate() {
215
+ await this.readyPromise;
216
+ // Clear previous errors
217
+ this.errorMap.clear();
218
+ // Get all tracked mcfunction files in the project (not from archives)
219
+ const trackedFiles = this.service.project
220
+ .getTrackedFiles()
221
+ .filter((uri) => uri.startsWith(this.rootUri) && uri.endsWith(".mcfunction"));
222
+ // For full JSON text component validation, we need to explicitly open and check each file
223
+ for (const fileUri of trackedFiles) {
224
+ try {
225
+ const contentBytes = await this.service.project.externals.fs.readFile(fileUri);
226
+ if (contentBytes !== undefined) {
227
+ const content = new TextDecoder().decode(contentBytes);
228
+ await this.service.project.onDidOpen(fileUri, "mcfunction", 1, content);
229
+ await this.service.project.ensureClientManagedChecked(fileUri);
230
+ }
231
+ }
232
+ catch {
233
+ // Ignore file read errors
234
+ }
235
+ }
236
+ // Get all collected errors - only keep actual errors (not warnings) and filter out false positives
237
+ const errors = Array.from(this.errorMap.values()).filter((e) => e.severity === ErrorSeverity.Error && !FILTERED_ERROR_MESSAGES.includes(e.message));
238
+ return {
239
+ errors,
240
+ warnings: [],
241
+ infos: [],
242
+ hints: [],
243
+ hasErrors: errors.length > 0,
244
+ totalIssues: errors.length,
245
+ };
246
+ }
247
+ /**
248
+ * Notify the service that a file has changed (for watch mode).
249
+ */
250
+ async notifyFileChanged(filePath) {
251
+ await this.readyPromise;
252
+ const fileUri = pathToFileURL(filePath).href;
253
+ try {
254
+ const contentBytes = await this.service.project.externals.fs.readFile(fileUri);
255
+ if (contentBytes !== undefined) {
256
+ const content = new TextDecoder().decode(contentBytes);
257
+ // Increment version to signal change
258
+ await this.service.project.onDidChange(fileUri, [{ text: content }], 2);
259
+ await this.service.project.ensureClientManagedChecked(fileUri);
260
+ }
261
+ }
262
+ catch {
263
+ // Ignore file read errors
264
+ }
265
+ }
266
+ /**
267
+ * Close the service and clean up resources.
268
+ */
269
+ async close() {
270
+ await this.service.project.close();
271
+ }
272
+ }
273
+ /**
274
+ * Validate a Minecraft datapack using Spyglass (simple one-shot API).
275
+ * For repeated validations, use ValidationService instead.
276
+ */
277
+ export async function validateDatapack(datapackPath) {
278
+ const service = ValidationService.create(datapackPath);
279
+ try {
280
+ return await service.validate();
281
+ }
282
+ finally {
283
+ await service.close();
284
+ }
285
+ }
@@ -215,12 +215,12 @@
215
215
  "aliases": [],
216
216
  "args": {
217
217
  "challengeSlug": {
218
- "description": "Challenge slug to build and upload. Can be used multiple times to build and upload multiple challenges at once. Incompatible with --all flag.",
218
+ "description": "Challenge slug to build. Can be used multiple times to build multiple challenges at once. Incompatible with --all flag.",
219
219
  "name": "challengeSlug",
220
220
  "required": false
221
221
  }
222
222
  },
223
- "description": "Build and upload challenge datapack and config",
223
+ "description": "Build challenge datapack locally, with optional upload",
224
224
  "examples": [
225
225
  "<%= config.bin %> <%= command.id %> my-challenge",
226
226
  "<%= config.bin %> <%= command.id %> my-challenge my-other-challenge",
@@ -241,25 +241,35 @@
241
241
  "allowNo": false,
242
242
  "type": "boolean"
243
243
  },
244
- "api-key": {
245
- "description": "Kradle API key",
246
- "env": "KRADLE_API_KEY",
247
- "name": "api-key",
248
- "required": true,
249
- "hasDynamicHelp": false,
250
- "multiple": false,
251
- "type": "option"
244
+ "no-validate": {
245
+ "description": "Skip datapack validation",
246
+ "name": "no-validate",
247
+ "allowNo": false,
248
+ "type": "boolean"
249
+ },
250
+ "no-upload": {
251
+ "description": "Build datapack locally only (skip cloud config/datapack upload)",
252
+ "name": "no-upload",
253
+ "allowNo": false,
254
+ "type": "boolean"
252
255
  },
253
256
  "api-url": {
254
257
  "description": "Kradle Web API URL",
255
258
  "env": "KRADLE_API_URL",
256
259
  "name": "api-url",
257
- "required": true,
258
260
  "default": "https://api.kradle.ai/v0",
259
261
  "hasDynamicHelp": false,
260
262
  "multiple": false,
261
263
  "type": "option"
262
264
  },
265
+ "api-key": {
266
+ "description": "Kradle API key",
267
+ "env": "KRADLE_API_KEY",
268
+ "name": "api-key",
269
+ "hasDynamicHelp": false,
270
+ "multiple": false,
271
+ "type": "option"
272
+ },
263
273
  "challenges-path": {
264
274
  "description": "Absolute path to the challenges directory",
265
275
  "env": "KRADLE_CHALLENGES_PATH",
@@ -696,6 +706,12 @@
696
706
  "allowNo": false,
697
707
  "type": "boolean"
698
708
  },
709
+ "no-validate": {
710
+ "description": "Skip datapack validation",
711
+ "name": "no-validate",
712
+ "allowNo": false,
713
+ "type": "boolean"
714
+ },
699
715
  "api-key": {
700
716
  "description": "Kradle API key",
701
717
  "env": "KRADLE_API_KEY",
@@ -1512,5 +1528,5 @@
1512
1528
  ]
1513
1529
  }
1514
1530
  },
1515
- "version": "0.6.5"
1531
+ "version": "0.6.7"
1516
1532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kradle",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Kradle's CLI. Manage challenges, experiments, agents and more!",
5
5
  "keywords": [
6
6
  "cli"
@@ -37,6 +37,9 @@
37
37
  "@oclif/core": "^4.8.0",
38
38
  "@oclif/plugin-autocomplete": "^3.2.39",
39
39
  "@oclif/plugin-help": "^6.2.35",
40
+ "@spyglassmc/core": "^0.4.43",
41
+ "@spyglassmc/java-edition": "^0.3.55",
42
+ "@spyglassmc/mcfunction": "^0.2.46",
40
43
  "chokidar": "^4.0.3",
41
44
  "dotenv": "^17.2.3",
42
45
  "enquirer": "^2.4.1",
@@ -119,14 +119,16 @@ kradle challenge create my-first-challenge
119
119
 
120
120
  ### `kradle challenge build <name>`
121
121
 
122
- Builds a challenge datapack and uploads everything to the cloud.
122
+ Builds a challenge datapack locally, validates it, and optionally uploads to the cloud.
123
123
 
124
124
  **Usage:**
125
125
  ```bash
126
126
  kradle challenge build <challenge-name>
127
- kradle challenge build <challenge-name> --visibility public
127
+ kradle challenge build <challenge-name> --no-upload
128
+ kradle challenge build <challenge-name> --public
129
+ kradle challenge build <challenge-name> --no-validate
128
130
  kradle challenge build --all
129
- kradle challenge build --all --visibility public
131
+ kradle challenge build --all --public
130
132
  ```
131
133
 
132
134
  **Arguments:**
@@ -135,28 +137,53 @@ kradle challenge build --all --visibility public
135
137
  | `challenge-name` | Challenge to build | Yes (unless `--all`) |
136
138
 
137
139
  **Flags:**
138
- | Flag | Description | Default |
139
- |------|-------------|---------|
140
- | `--all` | Build all local challenges | false |
141
- | `--visibility` | Set visibility (`private` or `public`) | private |
140
+ | Flag | Short | Description | Default |
141
+ |------|-------|-------------|---------|
142
+ | `--all` | `-a` | Build all local challenges | false |
143
+ | `--public` | `-p` | Set visibility to public after upload | false |
144
+ | `--no-validate` | | Skip datapack validation | false |
145
+ | `--no-upload` | | Build locally only (skip cloud config/datapack upload) | false |
146
+
147
+ `--public` is incompatible with `--no-upload`.
142
148
 
143
149
  **What it does:**
144
- 1. Creates the challenge in cloud if it doesn't exist
145
- 2. Uploads `config.ts` metadata to cloud
146
- 3. Executes `challenge.ts` to generate the datapack
150
+ 1. Executes `challenge.ts` to generate the datapack
147
151
  - Passes `KRADLE_CHALLENGE_END_STATES` env var containing a JSON array of end state keys from `config.endStates` (e.g., `["victory", "defeat"]`)
148
152
  - Passes `KRADLE_CHALLENGE_ROLES` env var containing a JSON array of role names from `config.roles` (e.g., `["attacker", "defender"]`)
149
153
  - Passes `KRADLE_CHALLENGE_LOCATIONS` env var containing a JSON array of location keys from `config.challengeConfig.locations` (e.g., `["spawn", "goal"]`)
150
154
  - The `@kradle/challenges-sdk` uses these to register valid end states, roles, and locations at build time
151
- 4. Compresses and uploads datapack to cloud storage
155
+ 2. Validates the datapack using Spyglass engine (unless `--no-validate`)
156
+ - Checks `.mcfunction` files for syntax errors, invalid commands, JSON text components
157
+ - **Errors block command completion** - fix them before the build can finish
158
+ - Auto-detects Minecraft version from `pack.mcmeta` `pack_format`
159
+ 3. If upload is enabled (default), creates challenge in cloud if needed
160
+ 4. If upload is enabled (default), uploads `config.ts` metadata to cloud
161
+ 5. If upload is enabled (default), compresses and uploads datapack to cloud storage
162
+
163
+ **Validation Output:**
164
+ ```
165
+ >> Validating datapack: my-challenge
166
+ data/kradle/functions/tick.mcfunction:1:23 error: Unknown function 'kradle:undefined'
167
+ data/kradle/functions/init.mcfunction:5:1 warning: Unused objective
168
+
169
+ Found 1 error, 1 warning
170
+
171
+ Error: Validation failed with 1 error(s). Build aborted.
172
+ ```
152
173
 
153
174
  **Examples:**
154
175
  ```bash
155
- # Build single challenge
176
+ # Build single challenge (with validation)
156
177
  kradle challenge build my-challenge
157
178
 
179
+ # Build local datapack only (no cloud upload)
180
+ kradle challenge build my-challenge --no-upload
181
+
158
182
  # Build and make public
159
- kradle challenge build my-challenge --visibility public
183
+ kradle challenge build my-challenge --public
184
+
185
+ # Build without validation (for quick iteration)
186
+ kradle challenge build my-challenge --no-validate
160
187
 
161
188
  # Build all challenges
162
189
  kradle challenge build --all
@@ -289,6 +316,8 @@ Watches a challenge for file changes and automatically rebuilds/uploads.
289
316
  **Usage:**
290
317
  ```bash
291
318
  kradle challenge watch <challenge-name>
319
+ kradle challenge watch <challenge-name> --no-validate
320
+ kradle challenge watch <challenge-name> --verbose
292
321
  ```
293
322
 
294
323
  **Arguments:**
@@ -296,17 +325,29 @@ kradle challenge watch <challenge-name>
296
325
  |----------|-------------|----------|
297
326
  | `challenge-name` | Challenge to watch | Yes |
298
327
 
328
+ **Flags:**
329
+ | Flag | Description | Default |
330
+ |------|-------------|---------|
331
+ | `--no-validate` | Skip datapack validation | false |
332
+ | `--verbose` | Enable verbose output | false |
333
+
299
334
  **Behavior:**
300
335
  - Monitors `challenges/<name>/` directory for changes
301
- - Debounces rebuilds (300ms delay)
302
- - Only uploads if datapack hash changed
336
+ - Debounces rebuilds (1 second delay)
337
+ - Validates datapack after each build (unless `--no-validate`)
338
+ - In watch mode, validation errors are shown but **don't block upload** (allows iteration)
339
+ - Warnings and hints are displayed for awareness
340
+ - Only uploads if datapack hash changed or config changed
303
341
  - Press Ctrl+C to stop watching
304
342
 
305
343
  **Example:**
306
344
  ```bash
307
345
  kradle challenge watch my-challenge
308
346
  # Now edit challenges/my-challenge/challenge.ts
309
- # Changes will automatically rebuild and upload
347
+ # Changes will automatically rebuild, validate, and upload
348
+
349
+ # Skip validation for faster iteration
350
+ kradle challenge watch my-challenge --no-validate
310
351
  ```
311
352
 
312
353
  ---
@@ -1204,10 +1245,10 @@ kradle experiment recordings agent-benchmark --all
1204
1245
 
1205
1246
  ```bash
1206
1247
  # Build with public visibility
1207
- kradle challenge build my-challenge --visibility public
1248
+ kradle challenge build my-challenge --public
1208
1249
 
1209
1250
  # Or build all challenges as public
1210
- kradle challenge build --all --visibility public
1251
+ kradle challenge build --all --public
1211
1252
  ```
1212
1253
 
1213
1254
  ---
@@ -1240,7 +1281,7 @@ kradle challenge build --all --visibility public
1240
1281
  |---------|-------------|
1241
1282
  | `kradle init` | Initialize new project |
1242
1283
  | `kradle challenge create <name>` | Create new challenge |
1243
- | `kradle challenge build <name>` | Build and upload challenge |
1284
+ | `kradle challenge build <name>` | Build challenge (uploads by default) |
1244
1285
  | `kradle challenge build --all` | Build all challenges |
1245
1286
  | `kradle challenge delete <name>` | Delete challenge |
1246
1287
  | `kradle challenge list` | List all challenges |