@chalksurf/cli 0.2.2 → 0.2.4

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.
@@ -1,600 +0,0 @@
1
- import { z } from 'zod';
2
- import { createApiClient } from '../lib/api-client.js';
3
- import { CliCommandError } from '../lib/cli-error.js';
4
- import { normalizeChoiceValues, normalizeLimit, normalizeOffset, normalizeOptionalInteger, normalizeOptionalText, normalizeOwnership, normalizeStringValues, toApiOwnership, } from '../lib/command-options.js';
5
- import { buildFileImportCommandSources, buildFileImportFormData, normalizeWaitTimeoutMs } from '../lib/import-files.js';
6
- import { buildCliImportSources, formatCliImportTranslationJobs, summarizeCliImportJobs, } from '../lib/import-output.js';
7
- import { loadExerciseImportManifest, loadExerciseSolutionImportManifest } from '../lib/manifest.js';
8
- import { createResolvedApiClient, mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
9
- import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
10
- import { resolveRequestedTranslateToLanguages, translationLanguages, } from '../lib/translation-languages.js';
11
- import { getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs } from '../lib/user-jobs.js';
12
- const exerciseStatusOptions = ['verified', 'unverified', 'invalid'];
13
- const getExerciseTextPreview = ({ exercise, language }) => {
14
- if (language && exercise.text[language]) {
15
- return exercise.text[language];
16
- }
17
- return Object.values(exercise.text)[0] ?? '';
18
- };
19
- const formatExerciseSearchOutput = ({ exercises, language, totalCount, }) => {
20
- if (exercises.length === 0) {
21
- return 'No exercises found.';
22
- }
23
- return [
24
- `${exercises.length} of ${totalCount} exercise${totalCount === 1 ? '' : 's'} returned.`,
25
- ...exercises.map((exercise) => {
26
- const preview = getExerciseTextPreview({ exercise, language }).replace(/\s+/g, ' ').trim();
27
- const labels = exercise.labels.length > 0 ? ` [${exercise.labels.join(', ')}]` : '';
28
- return `${exercise.id} ${exercise.status}${labels}${preview ? ` ${preview}` : ''}`;
29
- }),
30
- ].join('\n');
31
- };
32
- const normalizeSearchNumberRange = ({ maxDifficulty, minDifficulty, }) => {
33
- if (minDifficulty !== null && maxDifficulty !== null && minDifficulty > maxDifficulty) {
34
- throw new CliCommandError('--min-difficulty cannot be greater than --max-difficulty.', 2);
35
- }
36
- };
37
- const resolveRequestedUuid = ({ flagValue, fallbackValue, label, required = false, }) => {
38
- const resolvedValue = flagValue?.trim() || fallbackValue?.trim();
39
- if (!resolvedValue) {
40
- if (required) {
41
- throw new CliCommandError(`${label} is required.`, 2);
42
- }
43
- return undefined;
44
- }
45
- const parsedValue = z.uuid().safeParse(resolvedValue);
46
- if (!parsedValue.success) {
47
- throw new CliCommandError(`${label} must be a valid UUID.`, 2);
48
- }
49
- return parsedValue.data;
50
- };
51
- const buildExerciseImportRequest = ({ exerciseId, exerciseSheetId, inputMode, organizationId, timeoutMs, wait, }) => {
52
- return {
53
- organizationId,
54
- inputMode,
55
- wait,
56
- timeoutMs,
57
- target: {
58
- ...(exerciseId === undefined ? {} : { exerciseId }),
59
- ...(exerciseSheetId === undefined ? {} : { exerciseSheetId }),
60
- },
61
- };
62
- };
63
- const buildQueuedExerciseImportJob = ({ jobId, sources, }) => {
64
- return {
65
- jobId,
66
- sourceIndexes: sources.map((source) => source.sourceIndex),
67
- sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
68
- status: 'queued',
69
- };
70
- };
71
- const buildWaitedExerciseImportJob = ({ jobId, sources, waitedJob, }) => {
72
- return {
73
- jobId,
74
- sourceIndexes: sources.map((source) => source.sourceIndex),
75
- sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
76
- status: waitedJob?.status ?? 'pending',
77
- error: waitedJob?.error,
78
- exerciseId: waitedJob?.exerciseId,
79
- exerciseIds: waitedJob?.exerciseIds,
80
- translationJobs: waitedJob?.translationJobs,
81
- };
82
- };
83
- const formatQueuedImportOutput = (job) => {
84
- return [
85
- 'Queued exercise import.',
86
- `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
87
- ].join('\n');
88
- };
89
- const formatQueuedSolutionImportOutput = (job) => {
90
- return [
91
- 'Queued exercise solution import.',
92
- `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
93
- ].join('\n');
94
- };
95
- const formatWaitedImportOutput = (job) => {
96
- if (job.status === 'completed') {
97
- const translationJobLines = formatCliImportTranslationJobs(job.translationJobs);
98
- if (job.exerciseIds && job.exerciseIds.length > 0) {
99
- return [
100
- `${job.jobId} completed -> ${job.exerciseIds.length} exercise${job.exerciseIds.length === 1 ? '' : 's'}`,
101
- ...translationJobLines,
102
- ].join('\n');
103
- }
104
- if (job.exerciseId) {
105
- return [`${job.jobId} completed -> ${job.exerciseId}`, ...translationJobLines].join('\n');
106
- }
107
- return [`${job.jobId} completed`, ...translationJobLines].join('\n');
108
- }
109
- if (job.status === 'failed') {
110
- return `${job.jobId} failed -> ${job.error ?? 'Unknown error'}`;
111
- }
112
- return `${job.jobId} ${job.status ?? 'pending'}`;
113
- };
114
- export const registerExerciseCommands = (exerciseYargs, context) => {
115
- return exerciseYargs
116
- .command('search', 'Search exercises visible to the selected organization', (searchYargs) => searchYargs
117
- .option('text', {
118
- type: 'string',
119
- describe: 'Search exercise text. Requires --language.',
120
- })
121
- .option('language', {
122
- type: 'string',
123
- describe: 'Language to search or preview, such as english or hungarian',
124
- })
125
- .option('semantic', {
126
- type: 'string',
127
- describe: 'Run semantic search with this query text',
128
- })
129
- .option('label', {
130
- array: true,
131
- type: 'string',
132
- describe: 'Require an exercise label or label prefix',
133
- })
134
- .option('age', {
135
- type: 'number',
136
- describe: 'Filter exercises appropriate for this age',
137
- })
138
- .option('min-difficulty', {
139
- type: 'number',
140
- describe: 'Minimum difficulty',
141
- })
142
- .option('max-difficulty', {
143
- type: 'number',
144
- describe: 'Maximum difficulty',
145
- })
146
- .option('status', {
147
- array: true,
148
- type: 'string',
149
- describe: `Only include exercises with these statuses (${exerciseStatusOptions.join(', ')})`,
150
- })
151
- .option('ownership', {
152
- type: 'string',
153
- default: 'own',
154
- describe: 'Visibility scope: own, public, or all',
155
- })
156
- .option('limit', {
157
- type: 'number',
158
- default: 20,
159
- describe: 'Maximum number of exercises to return',
160
- })
161
- .option('offset', {
162
- type: 'number',
163
- default: 0,
164
- describe: 'Number of matching exercises to skip',
165
- })
166
- .example('chalksurf exercise search --text "binomial theorem" --language english --ownership own --json', 'Verify imported exercises in the selected organization')
167
- .example('chalksurf --profile prod-codex exercise search --label geometry.triangles --status unverified --json', 'Find agent-created exercises that still need review'), async (argv) => {
168
- const text = normalizeOptionalText(argv.text);
169
- const language = normalizeOptionalText(argv.language);
170
- const semanticSearch = normalizeOptionalText(argv.semantic);
171
- const ownership = normalizeOwnership(argv.ownership);
172
- const labels = normalizeStringValues(argv.label);
173
- const status = normalizeChoiceValues({
174
- allowedValues: exerciseStatusOptions,
175
- label: '--status',
176
- values: argv.status,
177
- });
178
- const age = normalizeOptionalInteger({ label: '--age', value: argv.age });
179
- const minDifficulty = normalizeOptionalInteger({
180
- label: '--min-difficulty',
181
- value: argv.minDifficulty,
182
- });
183
- const maxDifficulty = normalizeOptionalInteger({
184
- label: '--max-difficulty',
185
- value: argv.maxDifficulty,
186
- });
187
- normalizeSearchNumberRange({ minDifficulty, maxDifficulty });
188
- if (text !== null && language === null) {
189
- throw new CliCommandError('--language is required when --text is provided.', 2);
190
- }
191
- const limit = normalizeLimit(argv.limit);
192
- const offset = normalizeOffset(argv.offset);
193
- const { apiClient, organizationId } = await createResolvedApiClient({
194
- context,
195
- baseUrlFlagValue: argv.baseUrl,
196
- organizationFlagValue: argv.organization,
197
- profileName: argv.profile,
198
- });
199
- let searchResult;
200
- try {
201
- searchResult = await apiClient.searchExercises({
202
- age,
203
- language,
204
- labels,
205
- minDifficulty,
206
- maxDifficulty,
207
- text,
208
- semanticSearch,
209
- status,
210
- ownership: toApiOwnership(ownership),
211
- limit,
212
- offset,
213
- });
214
- }
215
- catch (error) {
216
- throw mapApiErrorToCliError(error);
217
- }
218
- context.output.print({
219
- request: {
220
- age,
221
- language,
222
- labels,
223
- minDifficulty,
224
- maxDifficulty,
225
- text,
226
- semanticSearch,
227
- status: status ?? [],
228
- ownership,
229
- limit,
230
- offset,
231
- organizationId: organizationId ?? null,
232
- },
233
- exercises: searchResult.exercises,
234
- totalCount: searchResult.totalCount,
235
- }, (result) => formatExerciseSearchOutput({
236
- exercises: result.exercises,
237
- language: result.request.language ?? undefined,
238
- totalCount: result.totalCount,
239
- }), {
240
- command: 'exercise search',
241
- });
242
- })
243
- .command('import [sources..]', 'Import one or more exercises', (importYargs) => importYargs
244
- .positional('sources', {
245
- array: true,
246
- type: 'string',
247
- describe: 'Local file paths, directories, or HTTP(S) URLs',
248
- })
249
- .option('manifest', {
250
- type: 'string',
251
- describe: 'Load sources from a manifest file, or "-" for stdin',
252
- })
253
- .option('relative-path', {
254
- type: 'string',
255
- describe: 'Override the relative path for a single positional source',
256
- })
257
- .option('sheet-id', {
258
- type: 'string',
259
- describe: 'Append the imported exercises to an existing sheet',
260
- })
261
- .option('timeout-ms', {
262
- type: 'number',
263
- default: 300000,
264
- describe: 'Maximum time to wait before timing out',
265
- })
266
- .option('translate-to', {
267
- array: true,
268
- type: 'string',
269
- describe: `Generate translated exercise content for these languages (${translationLanguages.join(', ')})`,
270
- })
271
- .option('wait', {
272
- type: 'boolean',
273
- default: false,
274
- describe: 'Wait for the queued job to finish',
275
- })
276
- .example('chalksurf exercise import ./fixtures/problem-set.pdf --sheet-id 00000000-0000-4000-8000-000000000001 --wait', 'Import a problem set into an existing sheet')
277
- .example('cat exercise-import.json | chalksurf --profile prod-codex exercise import --manifest - --wait --json', 'Drive imports from a manifest in an agent or CI flow')
278
- .epilogue([
279
- 'When --wait is set, the command exits after the queued import job reaches a terminal state.',
280
- 'For translated exercise imports, requested translations continue as separate jobs listed in the import result.',
281
- ].join('\n')), async (argv) => {
282
- const config = await context.configStore.loadProfile({ profileName: argv.profile });
283
- const rawSources = (argv.sources ?? []).map(String);
284
- const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
285
- const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
286
- const requestedTranslateTo = resolveRequestedTranslateToLanguages(argv.translateTo);
287
- const resolvedBaseUrl = requireResolvedBaseUrl({
288
- flagValue: argv.baseUrl,
289
- env: context.env,
290
- config,
291
- });
292
- const resolvedToken = requireResolvedToken({
293
- env: context.env,
294
- config,
295
- });
296
- const commandSources = await buildFileImportCommandSources({
297
- cwd: context.cwd,
298
- loadManifest: loadExerciseImportManifest,
299
- manifestPath,
300
- rawSources: normalizedRawSources,
301
- relativePath: argv.relativePath,
302
- stdin: context.stdin,
303
- wait: argv.wait,
304
- });
305
- const organizationId = resolveRequestedOrganizationId({
306
- flagValue: argv.organization,
307
- fallbackValue: commandSources.organizationId,
308
- env: context.env,
309
- config,
310
- });
311
- const exerciseSheetId = resolveRequestedUuid({
312
- flagValue: argv.sheetId,
313
- fallbackValue: commandSources.exerciseSheetId,
314
- label: '--sheet-id',
315
- });
316
- const translateToLanguages = requestedTranslateTo ?? commandSources.translateTo;
317
- const apiClient = createApiClient({
318
- baseUrl: resolvedBaseUrl.value,
319
- token: resolvedToken.value,
320
- organizationId,
321
- });
322
- let resolvedSources = [];
323
- try {
324
- resolvedSources = await resolveSources({
325
- cwd: context.cwd,
326
- sources: commandSources.sources,
327
- });
328
- const sources = buildCliImportSources({
329
- resolvedSources,
330
- });
331
- const formData = await buildFileImportFormData(resolvedSources);
332
- const request = buildExerciseImportRequest({
333
- exerciseSheetId: exerciseSheetId ?? null,
334
- inputMode: manifestPath ? 'manifest' : 'arguments',
335
- organizationId: organizationId ?? null,
336
- timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
337
- wait: commandSources.wait,
338
- });
339
- if (exerciseSheetId) {
340
- formData.set('exerciseSheetId', exerciseSheetId);
341
- }
342
- if (translateToLanguages && translateToLanguages.length > 0) {
343
- formData.set('translateToLanguages', JSON.stringify(translateToLanguages));
344
- }
345
- let importResult;
346
- try {
347
- importResult = await apiClient.importExercise(formData);
348
- }
349
- catch (error) {
350
- throw mapApiErrorToCliError(error);
351
- }
352
- if (!commandSources.wait) {
353
- const jobs = [
354
- buildQueuedExerciseImportJob({
355
- jobId: importResult.jobId,
356
- sources,
357
- }),
358
- ];
359
- const summary = summarizeCliImportJobs({
360
- jobs,
361
- sourceCount: sources.length,
362
- sourceInputCount: commandSources.sources.length,
363
- timedOut: false,
364
- });
365
- context.output.print({
366
- request,
367
- sources,
368
- jobs,
369
- summary,
370
- }, (result) => formatQueuedImportOutput(result.jobs[0]), {
371
- command: 'exercise import',
372
- });
373
- return;
374
- }
375
- const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
376
- const waitResult = await waitForCliJobs({
377
- getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
378
- jobIds: [importResult.jobId],
379
- now: context.now,
380
- sleep: context.sleep,
381
- timeoutMs: waitTimeoutMs,
382
- });
383
- const waitedJob = waitResult.jobs[0];
384
- const jobs = [
385
- buildWaitedExerciseImportJob({
386
- jobId: importResult.jobId,
387
- sources,
388
- waitedJob: waitedJob == null
389
- ? undefined
390
- : {
391
- ...waitedJob,
392
- id: waitedJob.id,
393
- status: waitedJob.status,
394
- },
395
- }),
396
- ];
397
- const summary = summarizeCliImportJobs({
398
- jobs,
399
- sourceCount: sources.length,
400
- sourceInputCount: commandSources.sources.length,
401
- timedOut: waitResult.timedOut,
402
- });
403
- const waitError = getWaitError({
404
- jobs: waitResult.jobs,
405
- timedOut: waitResult.timedOut,
406
- });
407
- context.output.print({
408
- request: {
409
- ...request,
410
- timeoutMs: waitTimeoutMs,
411
- },
412
- sources,
413
- jobs,
414
- summary,
415
- }, (result) => formatWaitedImportOutput(result.jobs[0]), {
416
- command: 'exercise import',
417
- ok: waitError == null,
418
- error: waitError,
419
- });
420
- throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
421
- }
422
- catch (error) {
423
- throw error;
424
- }
425
- finally {
426
- await cleanupResolvedSources(resolvedSources);
427
- }
428
- })
429
- .command('import-solution [exerciseId] [sources..]', 'Import one or more exercise solution files', (importSolutionYargs) => importSolutionYargs
430
- .positional('exerciseId', {
431
- type: 'string',
432
- describe: 'Exercise ID to attach the imported solution to',
433
- })
434
- .positional('sources', {
435
- array: true,
436
- type: 'string',
437
- describe: 'Local file paths, directories, or HTTP(S) URLs',
438
- })
439
- .option('manifest', {
440
- type: 'string',
441
- describe: 'Load sources from a manifest file, or "-" for stdin',
442
- })
443
- .option('relative-path', {
444
- type: 'string',
445
- describe: 'Override the relative path for a single positional source',
446
- })
447
- .option('timeout-ms', {
448
- type: 'number',
449
- default: 300000,
450
- describe: 'Maximum time to wait before timing out',
451
- })
452
- .option('wait', {
453
- type: 'boolean',
454
- default: false,
455
- describe: 'Wait for the queued job to finish',
456
- })
457
- .example('chalksurf exercise import-solution 00000000-0000-4000-8000-000000000001 ./fixtures/solution.pdf --wait', 'Attach a solution file to an existing exercise')
458
- .example('cat exercise-solution-import.json | chalksurf --profile prod-codex exercise import-solution --manifest - --wait --json', 'Drive solution imports from a manifest in an agent or CI flow')
459
- .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
460
- const config = await context.configStore.loadProfile({ profileName: argv.profile });
461
- const rawSources = (argv.sources ?? []).map(String);
462
- const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
463
- const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
464
- const resolvedBaseUrl = requireResolvedBaseUrl({
465
- flagValue: argv.baseUrl,
466
- env: context.env,
467
- config,
468
- });
469
- const resolvedToken = requireResolvedToken({
470
- env: context.env,
471
- config,
472
- });
473
- const commandSources = await buildFileImportCommandSources({
474
- cwd: context.cwd,
475
- loadManifest: loadExerciseSolutionImportManifest,
476
- manifestPath,
477
- rawSources: normalizedRawSources,
478
- relativePath: argv.relativePath,
479
- stdin: context.stdin,
480
- wait: argv.wait,
481
- });
482
- const organizationId = resolveRequestedOrganizationId({
483
- flagValue: argv.organization,
484
- fallbackValue: commandSources.organizationId,
485
- env: context.env,
486
- config,
487
- });
488
- const exerciseId = resolveRequestedUuid({
489
- flagValue: argv.exerciseId,
490
- fallbackValue: commandSources.exerciseId,
491
- label: 'exerciseId',
492
- required: true,
493
- });
494
- const apiClient = createApiClient({
495
- baseUrl: resolvedBaseUrl.value,
496
- token: resolvedToken.value,
497
- organizationId,
498
- });
499
- let resolvedSources = [];
500
- try {
501
- resolvedSources = await resolveSources({
502
- cwd: context.cwd,
503
- sources: commandSources.sources,
504
- });
505
- const sources = buildCliImportSources({
506
- resolvedSources,
507
- });
508
- const formData = await buildFileImportFormData(resolvedSources);
509
- formData.set('exerciseId', exerciseId);
510
- const request = buildExerciseImportRequest({
511
- exerciseId,
512
- inputMode: manifestPath ? 'manifest' : 'arguments',
513
- organizationId: organizationId ?? null,
514
- timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
515
- wait: commandSources.wait,
516
- });
517
- let importResult;
518
- try {
519
- importResult = await apiClient.importExerciseSolution(formData);
520
- }
521
- catch (error) {
522
- throw mapApiErrorToCliError(error);
523
- }
524
- if (!commandSources.wait) {
525
- const jobs = [
526
- buildQueuedExerciseImportJob({
527
- jobId: importResult.jobId,
528
- sources,
529
- }),
530
- ];
531
- const summary = summarizeCliImportJobs({
532
- jobs,
533
- sourceCount: sources.length,
534
- sourceInputCount: commandSources.sources.length,
535
- timedOut: false,
536
- });
537
- context.output.print({
538
- request,
539
- sources,
540
- jobs,
541
- summary,
542
- }, (result) => formatQueuedSolutionImportOutput(result.jobs[0]), {
543
- command: 'exercise import-solution',
544
- });
545
- return;
546
- }
547
- const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
548
- const waitResult = await waitForCliJobs({
549
- getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
550
- jobIds: [importResult.jobId],
551
- now: context.now,
552
- sleep: context.sleep,
553
- timeoutMs: waitTimeoutMs,
554
- });
555
- const waitedJob = waitResult.jobs[0];
556
- const jobs = [
557
- buildWaitedExerciseImportJob({
558
- jobId: importResult.jobId,
559
- sources,
560
- waitedJob: waitedJob == null
561
- ? undefined
562
- : {
563
- ...waitedJob,
564
- id: waitedJob.id,
565
- status: waitedJob.status,
566
- },
567
- }),
568
- ];
569
- const summary = summarizeCliImportJobs({
570
- jobs,
571
- sourceCount: sources.length,
572
- sourceInputCount: commandSources.sources.length,
573
- timedOut: waitResult.timedOut,
574
- });
575
- const waitError = getWaitError({
576
- jobs: waitResult.jobs,
577
- timedOut: waitResult.timedOut,
578
- });
579
- context.output.print({
580
- request: {
581
- ...request,
582
- timeoutMs: waitTimeoutMs,
583
- },
584
- sources,
585
- jobs,
586
- summary,
587
- }, (result) => formatWaitedImportOutput(result.jobs[0]), {
588
- command: 'exercise import-solution',
589
- ok: waitError == null,
590
- error: waitError,
591
- });
592
- throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
593
- }
594
- finally {
595
- await cleanupResolvedSources(resolvedSources);
596
- }
597
- })
598
- .demandCommand(1)
599
- .strict();
600
- };