@kradle/cli 0.0.17 → 0.2.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.
Files changed (62) hide show
  1. package/README.md +93 -65
  2. package/dist/commands/agent/list.d.ts +4 -0
  3. package/dist/commands/agent/list.js +6 -4
  4. package/dist/commands/challenge/build.d.ts +9 -1
  5. package/dist/commands/challenge/build.js +40 -12
  6. package/dist/commands/challenge/create.d.ts +5 -1
  7. package/dist/commands/challenge/create.js +17 -18
  8. package/dist/commands/challenge/delete.d.ts +4 -1
  9. package/dist/commands/challenge/delete.js +5 -5
  10. package/dist/commands/challenge/list.d.ts +5 -0
  11. package/dist/commands/challenge/list.js +11 -10
  12. package/dist/commands/challenge/run.d.ts +8 -1
  13. package/dist/commands/challenge/run.js +13 -8
  14. package/dist/commands/challenge/watch.d.ts +4 -1
  15. package/dist/commands/challenge/watch.js +8 -8
  16. package/dist/commands/{evaluation → experiment}/create.d.ts +4 -0
  17. package/dist/commands/{evaluation → experiment}/create.js +22 -21
  18. package/dist/commands/{evaluation → experiment}/list.js +17 -19
  19. package/dist/commands/experiment/recordings.d.ts +19 -0
  20. package/dist/commands/experiment/recordings.js +416 -0
  21. package/dist/commands/experiment/run.d.ts +17 -0
  22. package/dist/commands/experiment/run.js +67 -0
  23. package/dist/commands/init.js +2 -2
  24. package/dist/lib/api-client.d.ts +51 -10
  25. package/dist/lib/api-client.js +108 -39
  26. package/dist/lib/arguments.d.ts +3 -2
  27. package/dist/lib/arguments.js +5 -3
  28. package/dist/lib/challenge.d.ts +13 -18
  29. package/dist/lib/challenge.js +58 -62
  30. package/dist/lib/experiment/experimenter.d.ts +92 -0
  31. package/dist/lib/experiment/experimenter.js +368 -0
  32. package/dist/lib/{evaluation → experiment}/index.d.ts +1 -1
  33. package/dist/lib/{evaluation → experiment}/index.js +1 -1
  34. package/dist/lib/{evaluation → experiment}/runner.d.ts +2 -0
  35. package/dist/lib/{evaluation → experiment}/runner.js +21 -2
  36. package/dist/lib/{evaluation → experiment}/tui.d.ts +1 -1
  37. package/dist/lib/{evaluation → experiment}/tui.js +3 -3
  38. package/dist/lib/{evaluation → experiment}/types.d.ts +10 -4
  39. package/dist/lib/{evaluation → experiment}/types.js +5 -3
  40. package/dist/lib/flags.d.ts +47 -0
  41. package/dist/lib/flags.js +63 -0
  42. package/dist/lib/schemas.d.ts +63 -2
  43. package/dist/lib/schemas.js +27 -1
  44. package/dist/lib/utils.d.ts +9 -10
  45. package/dist/lib/utils.js +12 -12
  46. package/oclif.manifest.json +423 -64
  47. package/package.json +11 -8
  48. package/static/challenge.ts +12 -13
  49. package/static/experiment_template.ts +114 -0
  50. package/static/project_template/dev.env +5 -5
  51. package/static/project_template/prod.env +4 -4
  52. package/static/project_template/tsconfig.json +1 -1
  53. package/dist/commands/challenge/multi-upload.d.ts +0 -6
  54. package/dist/commands/challenge/multi-upload.js +0 -80
  55. package/dist/commands/evaluation/run.d.ts +0 -13
  56. package/dist/commands/evaluation/run.js +0 -61
  57. package/dist/lib/config.d.ts +0 -12
  58. package/dist/lib/config.js +0 -49
  59. package/dist/lib/evaluation/evaluator.d.ts +0 -88
  60. package/dist/lib/evaluation/evaluator.js +0 -268
  61. package/static/evaluation_template.ts +0 -69
  62. /package/dist/commands/{evaluation → experiment}/list.d.ts +0 -0
@@ -1,4 +1,6 @@
1
- import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, HumanSchema, RunResponseSchema, RunStatusSchema, UploadUrlResponseSchema, } from "./schemas.js";
1
+ import { existsSync } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, HumanSchema, JobResponseSchema, RecordingDownloadUrlResponseSchema, RecordingsListResponseSchema, RunStatusSchema, UploadUrlResponseSchema, } from "./schemas.js";
2
4
  const DEFAULT_PAGE_SIZE = 30;
3
5
  const DEFAULT_CHALLENGE_SCHEMA = {
4
6
  slug: "",
@@ -21,37 +23,40 @@ const DEFAULT_CHALLENGE_SCHEMA = {
21
23
  },*/,
22
24
  };
23
25
  export class ApiClient {
24
- config;
25
- constructor(config) {
26
- this.config = config;
26
+ apiUrl;
27
+ kradleApiKey;
28
+ isStudio;
29
+ constructor(apiUrl, kradleApiKey, isStudio = false) {
30
+ this.apiUrl = apiUrl;
31
+ this.kradleApiKey = kradleApiKey;
32
+ this.isStudio = isStudio;
27
33
  }
28
- async request(target, url, options) {
29
- const baseUrl = target === "web" ? this.config.WEB_API_URL : this.config.STUDIO_API_URL;
30
- const fullUrl = `${baseUrl}/${url}`;
34
+ async request(endpoint, options) {
35
+ const fullUrl = `${this.apiUrl}/${endpoint}`;
31
36
  const response = await fetch(fullUrl, {
32
37
  ...options,
33
38
  headers: {
34
- Authorization: `Bearer ${this.config.KRADLE_API_KEY}`,
39
+ Authorization: `Bearer ${this.kradleApiKey}`,
35
40
  "Content-Type": "application/json",
36
41
  ...options.headers,
37
42
  },
38
43
  });
39
44
  if (!response.ok) {
40
45
  const text = await response.text();
41
- throw new Error(`API call failed: ${url} - ${response.status} ${response.statusText}\n${text}`);
46
+ throw new Error(`API call failed: ${fullUrl} - ${response.status} ${response.statusText}\n${text}`);
42
47
  }
43
48
  return response;
44
49
  }
45
- async get(target, url, options = {}, schema) {
46
- const response = await this.request(target, url, {
50
+ async get(url, options = {}, schema) {
51
+ const response = await this.request(url, {
47
52
  method: "GET",
48
53
  ...options,
49
54
  });
50
55
  const data = await response.json();
51
56
  return schema ? schema.parse(data) : data;
52
57
  }
53
- async post(target, url, options = {}, schema) {
54
- const response = await this.request(target, url, {
58
+ async post(url, options = {}, schema) {
59
+ const response = await this.request(url, {
55
60
  method: "POST",
56
61
  ...options,
57
62
  });
@@ -62,14 +67,14 @@ export class ApiClient {
62
67
  const data = JSON.parse(text);
63
68
  return schema ? schema.parse(data) : data;
64
69
  }
65
- async put(target, url, options = {}) {
66
- return await this.request(target, url, {
70
+ async put(url, options = {}) {
71
+ return await this.request(url, {
67
72
  method: "PUT",
68
73
  ...options,
69
74
  });
70
75
  }
71
- async delete(target, url, options = {}) {
72
- return await this.request(target, url, {
76
+ async delete(url, options = {}) {
77
+ return await this.request(url, {
73
78
  method: "DELETE",
74
79
  ...options,
75
80
  });
@@ -97,7 +102,7 @@ export class ApiClient {
97
102
  if (pageToken)
98
103
  params.set("page_token", pageToken);
99
104
  params.set("page_size", String(pageSize));
100
- const response = await this.get("web", `${url}?${params}`, {
105
+ const response = await this.get(`${url}?${params}`, {
101
106
  headers: {
102
107
  "Content-Type": "application/json",
103
108
  },
@@ -109,7 +114,7 @@ export class ApiClient {
109
114
  }
110
115
  async getHuman() {
111
116
  const url = "human";
112
- return this.get("web", url, {}, HumanSchema);
117
+ return this.get(url, {}, HumanSchema);
113
118
  }
114
119
  async listChallenges() {
115
120
  return this.listResource("challenges", "challenges", ChallengesResponseSchema);
@@ -122,7 +127,7 @@ export class ApiClient {
122
127
  }
123
128
  async getChallenge(challengeId) {
124
129
  const url = `challenges/${challengeId}`;
125
- return this.get("web", url, {}, ChallengeSchema);
130
+ return this.get(url, {}, ChallengeSchema);
126
131
  }
127
132
  /**
128
133
  * Check if a challenge exists in the cloud.
@@ -132,7 +137,7 @@ export class ApiClient {
132
137
  async challengeExists(slug) {
133
138
  const url = `challenges/${slug}`;
134
139
  try {
135
- const res = await this.get("web", url, {});
140
+ const res = await this.get(url, {});
136
141
  return res !== null && res !== undefined;
137
142
  }
138
143
  catch (error) {
@@ -142,37 +147,79 @@ export class ApiClient {
142
147
  async createChallenge(slug) {
143
148
  const challenge = { ...DEFAULT_CHALLENGE_SCHEMA, slug: slug, name: slug };
144
149
  const url = "challenges";
145
- return this.post("web", url, {
150
+ return this.post(url, {
146
151
  body: JSON.stringify(challenge),
147
152
  });
148
153
  }
149
154
  /**
150
155
  * Update a challenge definition in the cloud.
151
- * @param challenge - The challenge to update.
152
- * @param challengeConfig - The challenge config to upload. If not provided, the config will be loaded from the challenge.
156
+ * @param challengeSlug - The slug of the challenge.
157
+ * @param challengeConfig - The challenge config to upload.
158
+ * @param visibility - The visibility to set.
153
159
  * @returns The updated challenge.
154
160
  */
155
- async updateChallenge(challenge, challengeConfig) {
156
- const url = `challenges/${challenge.shortSlug}`;
157
- console.log(url);
158
- const config = challengeConfig ?? (await challenge.loadConfig());
159
- await this.put("web", url, {
160
- body: JSON.stringify(config),
161
+ async updateChallenge(challengeSlug, challengeConfig, visibility) {
162
+ const url = `challenges/${challengeSlug}`;
163
+ const fullConfig = { ...challengeConfig, slug: challengeSlug, visibility };
164
+ await this.put(url, {
165
+ body: JSON.stringify(fullConfig),
161
166
  });
162
167
  }
163
- async getChallengeUploadUrl(challenge) {
164
- const response = await this.get("web", `challenges/${challenge.shortSlug}/datapackUploadUrl`, {}, UploadUrlResponseSchema);
168
+ /**
169
+ * Update the visibility of a challenge.
170
+ * @param challengeSlug - The slug of the challenge.
171
+ * @param visibility - The visibility to set.
172
+ * @returns The updated challenge.
173
+ */
174
+ async updateChallengeVisibility(challengeSlug, visibility) {
175
+ const url = `challenges/${challengeSlug}/visibility`;
176
+ await this.post(url, {
177
+ body: JSON.stringify({ visibility }),
178
+ });
179
+ }
180
+ /**
181
+ * Upload a challenge datapack to Google Cloud Storage.
182
+ * @param slug - The slug of the challenge.
183
+ * @param tarballPath - The path to the tarball file.
184
+ * @returns The upload URL.
185
+ */
186
+ async uploadChallengeDatapack(slug, tarballPath) {
187
+ const uploadUrl = await this.getChallengeUploadUrl(slug);
188
+ if (!existsSync(tarballPath)) {
189
+ throw new Error(`Tarball not found at ${tarballPath}`);
190
+ }
191
+ const fileBuffer = await fs.readFile(tarballPath);
192
+ const response = await fetch(uploadUrl, {
193
+ method: "PUT",
194
+ headers: {
195
+ "Content-Type": "application/gzip",
196
+ "Content-Length": fileBuffer.length.toString(),
197
+ },
198
+ body: fileBuffer,
199
+ });
200
+ if (!response.ok) {
201
+ throw new Error(`Failed to upload datapack: ${response.statusText}`);
202
+ }
203
+ }
204
+ /**
205
+ * Get the upload URL for a challenge datapack.
206
+ * @param slug - The slug of the challenge.
207
+ * @returns The upload URL.
208
+ */
209
+ async getChallengeUploadUrl(slug) {
210
+ const response = await this.get(`challenges/${slug}/datapackUploadUrl`, {}, UploadUrlResponseSchema);
165
211
  return response.uploadUrl;
166
212
  }
167
- async runChallenge(runData, studio = false) {
213
+ async runChallenge(runData) {
168
214
  const url = "jobs";
169
- return this.post(studio ? "studio" : "web", url, {
170
- body: JSON.stringify(runData),
171
- }, RunResponseSchema);
215
+ const payload = this.isStudio ? runData : { ...runData, jobType: "background" };
216
+ return this.post(url, {
217
+ body: JSON.stringify(payload),
218
+ }, JobResponseSchema);
172
219
  }
173
220
  async deleteChallenge(challengeId) {
174
221
  const url = `challenges/${challengeId}`;
175
- await this.delete("web", url);
222
+ await this.delete(url);
176
223
  }
177
224
  /**
178
225
  * Get the status of a run.
@@ -181,7 +228,7 @@ export class ApiClient {
181
228
  */
182
229
  async getRunStatus(runId) {
183
230
  const url = `runs/${runId}`;
184
- return this.get("web", url, {}, RunStatusSchema);
231
+ return this.get(url, {}, RunStatusSchema);
185
232
  }
186
233
  /**
187
234
  * Add a tag to a run.
@@ -191,8 +238,30 @@ export class ApiClient {
191
238
  */
192
239
  async tagRun(runId, tag) {
193
240
  const url = `runs/${runId}/tag`;
194
- await this.post("web", url, {
241
+ await this.post(url, {
195
242
  body: JSON.stringify({ tag }),
196
243
  });
197
244
  }
245
+ /**
246
+ * Get recordings for a specific participant in a run.
247
+ * @param runId - The ID of the run.
248
+ * @param participantId - The ID of the participant.
249
+ * @returns Array of recording metadata.
250
+ */
251
+ async getRunRecordings(runId, participantId) {
252
+ const url = `runs/${runId}/recordings/${participantId}`;
253
+ const response = await this.get(url, {}, RecordingsListResponseSchema);
254
+ return response.recordings;
255
+ }
256
+ /**
257
+ * Get a signed download URL for a specific recording.
258
+ * @param runId - The ID of the run.
259
+ * @param participantId - The ID of the participant.
260
+ * @param timestamp - The timestamp of the recording.
261
+ * @returns Download URL and expiration time.
262
+ */
263
+ async getRecordingDownloadUrl(runId, participantId, timestamp) {
264
+ const url = `runs/${runId}/recordings/${participantId}/downloadUrl?timestamp=${encodeURIComponent(timestamp)}`;
265
+ return this.get(url, {}, RecordingDownloadUrlResponseSchema);
266
+ }
198
267
  }
@@ -2,6 +2,7 @@ import type { Arg } from "@oclif/core/interfaces";
2
2
  /**
3
3
  * Returns a "challenge slug" argument, and validates it to be a valid challenge slug.
4
4
  */
5
- export declare function getChallengeSlugArgument({ description }: {
5
+ export declare function getChallengeSlugArgument<R extends boolean = true>({ description, required, }: {
6
6
  description: string;
7
- }): Arg<string>;
7
+ required?: R;
8
+ }): Arg<R extends true ? string : string | undefined>;
@@ -3,10 +3,10 @@ const CHALLENGE_SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3
3
  /**
4
4
  * Returns a "challenge slug" argument, and validates it to be a valid challenge slug.
5
5
  */
6
- export function getChallengeSlugArgument({ description }) {
7
- return Args.string({
6
+ export function getChallengeSlugArgument({ description, required, }) {
7
+ const arg = Args.string({
8
8
  description,
9
- required: true,
9
+ required: required ?? true,
10
10
  parse: async (input) => {
11
11
  if (!CHALLENGE_SLUG_REGEX.test(input)) {
12
12
  throw new Error(`Invalid challenge slug: ${input}. Challenge slugs must be lowercase alphanumeric characters and hyphens, and must not start or end with a hyphen.`);
@@ -14,4 +14,6 @@ export function getChallengeSlugArgument({ description }) {
14
14
  return input;
15
15
  },
16
16
  });
17
+ // biome-ignore lint/suspicious/noExplicitAny: Typescript can't handle the conditional "required"
18
+ return arg;
17
19
  }
@@ -1,17 +1,10 @@
1
1
  import type { ApiClient } from "./api-client.js";
2
- import type { Config } from "./config.js";
3
- import { type ChallengeSchemaType } from "./schemas.js";
2
+ import { type ChallengeConfigSchemaType } from "./schemas.js";
4
3
  export declare const SOURCE_FOLDER = ".src";
5
4
  export declare class Challenge {
6
- readonly name: string;
7
- private readonly config;
8
- constructor(name: string, config: Config);
9
- private get kradleChallengesPath();
10
- /**
11
- * Get the short slug without username prefix
12
- * e.g., "florianernst:challenge" -> "challenge"
13
- */
14
- get shortSlug(): string;
5
+ readonly shortSlug: string;
6
+ private readonly kradleChallengesPath;
7
+ constructor(shortSlug: string, kradleChallengesPath: string);
15
8
  /**
16
9
  * Get the path to the challenge directory
17
10
  */
@@ -47,22 +40,24 @@ export declare class Challenge {
47
40
  /**
48
41
  * Load the challenge configuration from config.ts
49
42
  */
50
- loadConfig(): Promise<ChallengeSchemaType>;
43
+ loadConfig(): Promise<ChallengeConfigSchemaType>;
51
44
  /**
52
- * Upload the challenge datapack to Google Cloud Storage
45
+ * Build the challenge datapack and upload it to the cloud.
46
+ * @param api - The API client to use.
47
+ * @param asPublic - Whether the challenge should be uploaded as public.
48
+ * @returns The config and datapack hash.
53
49
  */
54
- upload(apiClient: ApiClient): Promise<void>;
55
- buildAndUpload(api: ApiClient): Promise<{
56
- config: ChallengeSchemaType;
50
+ buildAndUpload(api: ApiClient, asPublic: boolean): Promise<{
51
+ config: ChallengeConfigSchemaType;
57
52
  datapackHash: string;
58
53
  }>;
59
54
  /**
60
55
  * Get all local challenges from the challenges directory
61
56
  */
62
- static getLocalChallenges(): Promise<Record<string, boolean>>;
57
+ static getLocalChallenges(): Promise<string[]>;
63
58
  /**
64
59
  * Create a new local challenge folder with challenge.ts template
65
60
  * Note: config.ts is NOT created here - it should be generated later via `challenge config download`
66
61
  */
67
- static createLocal(slug: string, config: Config): Promise<void>;
62
+ static createLocal(slug: string, kradleChallengesPath: string): Promise<void>;
68
63
  }
@@ -4,25 +4,15 @@ import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import pc from "picocolors";
6
6
  import * as tar from "tar";
7
- import { ChallengeSchema } from "./schemas.js";
7
+ import { ChallengeConfigSchema } from "./schemas.js";
8
8
  import { executeNodeCommand, executeTypescriptFile, getStaticResourcePath, readDirSorted } from "./utils.js";
9
9
  export const SOURCE_FOLDER = ".src";
10
10
  export class Challenge {
11
- name;
12
- config;
13
- constructor(name, config) {
14
- this.name = name;
15
- this.config = config;
16
- }
17
- get kradleChallengesPath() {
18
- return path.resolve(this.config.KRADLE_CHALLENGES_PATH);
19
- }
20
- /**
21
- * Get the short slug without username prefix
22
- * e.g., "florianernst:challenge" -> "challenge"
23
- */
24
- get shortSlug() {
25
- return this.name.includes(":") ? this.name.split(":")[1] : this.name;
11
+ shortSlug;
12
+ kradleChallengesPath;
13
+ constructor(shortSlug, kradleChallengesPath) {
14
+ this.shortSlug = shortSlug;
15
+ this.kradleChallengesPath = path.resolve(kradleChallengesPath);
26
16
  }
27
17
  /**
28
18
  * Get the path to the challenge directory
@@ -94,15 +84,27 @@ export class Challenge {
94
84
  throw new Error(`Challenge file not found at ${this.challengePath}`);
95
85
  }
96
86
  try {
97
- await executeTypescriptFile(this.challengePath, this.config, { silent });
87
+ await executeTypescriptFile(this.challengePath, {
88
+ KRADLE_CHALLENGES_PATH: this.kradleChallengesPath,
89
+ NAMESPACE: "kradle",
90
+ }, { silent });
98
91
  }
99
92
  catch (error) {
100
93
  throw new Error(`Failed to build datapack: ${error instanceof Error ? error.message : error}`);
101
94
  }
102
95
  // @TODO - re-enable once we have a proper build pipeline
103
96
  // Copy challenge.ts and config.ts to the src directory
104
- await fs.cp(path.join(this.challengeDir, "challenge.ts"), path.join(this.config.KRADLE_CHALLENGES_PATH, this.shortSlug, SOURCE_FOLDER, "challenge.ts"));
105
- await fs.cp(path.join(this.challengeDir, "config.ts"), path.join(this.config.KRADLE_CHALLENGES_PATH, this.shortSlug, SOURCE_FOLDER, "config.ts"));
97
+ await fs.cp(path.join(this.challengeDir, "challenge.ts"), path.join(this.kradleChallengesPath, this.shortSlug, SOURCE_FOLDER, "challenge.ts"));
98
+ await fs.cp(path.join(this.challengeDir, "config.ts"), path.join(this.kradleChallengesPath, this.shortSlug, SOURCE_FOLDER, "config.ts"));
99
+ // Create tarball
100
+ // We need to use the cwd option to make sure the structure of the tarball uses paths relative to the challenge directory
101
+ await tar.create({
102
+ "no-mtime": true,
103
+ file: this.tarballPath, // Output file name
104
+ gzip: true,
105
+ cwd: this.datapackPath, // Input directory
106
+ strict: true,
107
+ }, ["datapack", SOURCE_FOLDER]);
106
108
  }
107
109
  /**
108
110
  * Load the challenge configuration from config.ts
@@ -118,60 +120,54 @@ export class Challenge {
118
120
  "--no-warnings",
119
121
  "-e",
120
122
  `console.log(JSON.stringify(require("${this.configPath}").config));`,
121
- ], this.config);
122
- return ChallengeSchema.parse(JSON.parse(stdout));
123
+ ], {
124
+ KRADLE_CHALLENGES_PATH: this.kradleChallengesPath,
125
+ NAMESPACE: "kradle",
126
+ });
127
+ return ChallengeConfigSchema.parse(JSON.parse(stdout));
123
128
  }
124
129
  /**
125
- * Upload the challenge datapack to Google Cloud Storage
130
+ * Build the challenge datapack and upload it to the cloud.
131
+ * @param api - The API client to use.
132
+ * @param asPublic - Whether the challenge should be uploaded as public.
133
+ * @returns The config and datapack hash.
126
134
  */
127
- async upload(apiClient) {
128
- // Create tarball
129
- // We need to use the cwd option to make sure the structure of the tarball uses paths relative to the challenge directory
130
- await tar.create({
131
- "no-mtime": true,
132
- file: this.tarballPath, // Output file name
133
- gzip: true,
134
- cwd: this.datapackPath, // Input directory
135
- strict: true,
136
- }, ["datapack", SOURCE_FOLDER]);
137
- const uploadUrl = await apiClient.getChallengeUploadUrl(this);
138
- const fileBuffer = await fs.readFile(this.tarballPath);
139
- const response = await fetch(uploadUrl, {
140
- method: "PUT",
141
- headers: {
142
- "Content-Type": "application/gzip",
143
- "Content-Length": fileBuffer.length.toString(),
144
- },
145
- body: fileBuffer,
146
- });
147
- if (!response.ok) {
148
- throw new Error(`Failed to upload datapack: ${response.statusText}`);
149
- }
150
- }
151
- async buildAndUpload(api) {
135
+ async buildAndUpload(api, asPublic) {
152
136
  // Ensure challenge exists locally
153
137
  if (!this.exists()) {
154
- throw new Error(`Challenge "${this.name}" does not exist locally. Make sure both the challenge.ts file and the config.ts file exist.`);
138
+ throw new Error(`Challenge "${this.shortSlug}" does not exist locally. Make sure both the challenge.ts file and the config.ts file exist.`);
155
139
  }
156
140
  // Ensure challenge exists in the cloud
157
- if (!(await api.challengeExists(this.name))) {
158
- console.log(pc.yellow(`Challenge not found in cloud: ${this.name}`));
159
- console.log(pc.yellow(`Creating challenge: ${this.name}`));
160
- await api.createChallenge(this.name);
141
+ if (!(await api.challengeExists(this.shortSlug))) {
142
+ console.log(pc.yellow(`Challenge not found in cloud: ${this.shortSlug}`));
143
+ console.log(pc.yellow(`Creating challenge: ${this.shortSlug}`));
144
+ await api.createChallenge(this.shortSlug);
161
145
  console.log(pc.green(`✓ Challenge created in cloud`));
162
146
  }
163
- // Load config
164
147
  const config = await this.loadConfig();
148
+ // Ensure challenge's visibility is set to private - else, temporarily set it to private
149
+ const cloudChallengeVisibility = (await api.getChallenge(this.shortSlug)).visibility;
150
+ if (cloudChallengeVisibility === "public") {
151
+ console.log(pc.yellow(`⚠️ Cloud challenge is currently public. Setting its visibility to private...`));
152
+ await api.updateChallengeVisibility(this.shortSlug, "private");
153
+ console.log(pc.yellow(`✓ Challenge visibility set to private\n`));
154
+ }
165
155
  // Upload config
166
156
  console.log(pc.blue(`>> Uploading config: ${this.shortSlug}`));
167
- await api.updateChallenge(this, config);
168
- console.log(pc.green(`✓ Config uploaded`));
157
+ // We have to set it to private because we cannot update public challenges
158
+ await api.updateChallenge(this.shortSlug, config, "private");
159
+ console.log(pc.green(`✓ Config uploaded\n`));
169
160
  // Build and upload datapack
170
161
  console.log(pc.blue(`>> Building datapack: ${this.shortSlug}`));
171
162
  await this.build();
172
163
  console.log(pc.blue(`>> Uploading datapack: ${this.shortSlug}`));
173
- await this.upload(api);
174
- console.log(pc.green(`✓ Build & upload complete for ${this.shortSlug}`));
164
+ await api.uploadChallengeDatapack(this.shortSlug, this.tarballPath);
165
+ console.log(pc.green(`✓ Build & upload complete for ${this.shortSlug}\n`));
166
+ if (asPublic) {
167
+ console.log(pc.green(`👀 Setting challenge visibility to public...`));
168
+ await api.updateChallengeVisibility(this.shortSlug, "public");
169
+ console.log(pc.green(`✓ Challenge visibility set to public\n`));
170
+ }
175
171
  const datapackHash = await this.getDatapackHash();
176
172
  return { config, datapackHash };
177
173
  }
@@ -181,16 +177,16 @@ export class Challenge {
181
177
  static async getLocalChallenges() {
182
178
  const challengesDir = path.resolve(process.cwd(), "challenges");
183
179
  if (!existsSync(challengesDir)) {
184
- return {};
180
+ return [];
185
181
  }
186
182
  const entries = await readDirSorted(challengesDir);
187
- const challenges = {};
183
+ const challenges = [];
188
184
  for (const entry of entries) {
189
185
  if (entry.isDirectory()) {
190
186
  // Check if it has a challenge.ts file
191
187
  const challengePath = path.join(challengesDir, entry.name, "challenge.ts");
192
188
  if (existsSync(challengePath)) {
193
- challenges[entry.name] = true;
189
+ challenges.push(entry.name);
194
190
  }
195
191
  }
196
192
  }
@@ -200,8 +196,8 @@ export class Challenge {
200
196
  * Create a new local challenge folder with challenge.ts template
201
197
  * Note: config.ts is NOT created here - it should be generated later via `challenge config download`
202
198
  */
203
- static async createLocal(slug, config) {
204
- const challenge = new Challenge(slug, config);
199
+ static async createLocal(slug, kradleChallengesPath) {
200
+ const challenge = new Challenge(slug, kradleChallengesPath);
205
201
  const challengeTemplatePath = getStaticResourcePath("challenge.ts");
206
202
  // Create challenge directory
207
203
  await fs.mkdir(challenge.challengeDir, { recursive: true });
@@ -0,0 +1,92 @@
1
+ import type { ApiClient } from "../api-client.js";
2
+ import type { ExperimentMetadata, ExperimentOptions, Manifest, Progress } from "./types.js";
3
+ export declare class Experimenter {
4
+ private name;
5
+ private webUrl;
6
+ private api;
7
+ experimentDir: string;
8
+ metadataPath: string;
9
+ private runner?;
10
+ private tui?;
11
+ private currentVersion?;
12
+ constructor(name: string, webUrl: string, api: ApiClient);
13
+ /**
14
+ * Get paths for a specific version
15
+ */
16
+ private getVersionPaths;
17
+ get configPath(): string;
18
+ /**
19
+ * Get the current version directory path
20
+ */
21
+ getCurrentVersionDir(): string;
22
+ /**
23
+ * Check if experiment exists
24
+ */
25
+ exists(): Promise<boolean>;
26
+ /**
27
+ * Check if config.ts exists (master config)
28
+ */
29
+ configExists(): Promise<boolean>;
30
+ /**
31
+ * Load experiment metadata
32
+ */
33
+ loadMetadata(): Promise<ExperimentMetadata | null>;
34
+ /**
35
+ * Save experiment metadata
36
+ */
37
+ saveMetadata(metadata: ExperimentMetadata): Promise<void>;
38
+ /**
39
+ * Get the current version number, or -1 if none exists
40
+ */
41
+ getCurrentVersionNumber(): Promise<number>;
42
+ /**
43
+ * Create a new version
44
+ */
45
+ createNewVersion(): Promise<number>;
46
+ /**
47
+ * Get or create a version
48
+ * @param createNew - If true, always create a new version. Otherwise, use current version or create first one if none exists.
49
+ */
50
+ getOrCreateVersion(createNew: boolean): Promise<number>;
51
+ /**
52
+ * Load manifest from version
53
+ */
54
+ loadManifest(version: number): Promise<Manifest>;
55
+ /**
56
+ * Load progress from version
57
+ */
58
+ loadProgress(version: number): Promise<Progress | null>;
59
+ /**
60
+ * Save progress to current version
61
+ */
62
+ saveProgress(): Promise<void>;
63
+ /**
64
+ * Execute config.ts to generate manifest
65
+ */
66
+ generateManifest(configPath: string): Promise<Manifest>;
67
+ /**
68
+ * Execute config.ts file and return the manifest
69
+ */
70
+ private executeConfigFile;
71
+ /**
72
+ * Run the experiment
73
+ */
74
+ run(options: ExperimentOptions): Promise<void>;
75
+ /**
76
+ * Handle state change from runner
77
+ */
78
+ private onRunStateChange;
79
+ /**
80
+ * Handle quit request
81
+ */
82
+ private handleQuit;
83
+ /**
84
+ * Open run in browser
85
+ */
86
+ private openRun;
87
+ /**
88
+ * Download recordings for a completed run with smart polling
89
+ * Polls for 90 seconds after run completion (matching pod grace period)
90
+ */
91
+ private downloadRecordingsForRun;
92
+ }