@chalksurf/cli 0.2.0 → 0.2.2

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,11 +1,13 @@
1
+ import { z } from 'zod';
1
2
  import { createApiClient } from '../lib/api-client.js';
2
3
  import { CliCommandError } from '../lib/cli-error.js';
4
+ import { normalizeLimit, normalizeOffset, normalizeOptionalText, normalizeOwnership, toApiOwnership, } from '../lib/command-options.js';
3
5
  import { buildFileImportCommandSources, buildFileImportFormData, normalizeWaitTimeoutMs } from '../lib/import-files.js';
4
- import { buildCliImportSources, formatCliImportSummary, summarizeCliImportJobs, } from '../lib/import-output.js';
5
- import { loadSheetImportManifest, } from '../lib/manifest.js';
6
- import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
6
+ import { buildCliImportSources, formatCliImportSummary, formatCliImportTranslationJobs, summarizeCliImportJobs, } from '../lib/import-output.js';
7
+ import { loadExerciseSheetSolutionImportManifest, loadSheetImportManifest, } from '../lib/manifest.js';
8
+ import { createResolvedApiClient, mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
7
9
  import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
8
- import { translationLanguages } from '../lib/translation-languages.js';
10
+ import { resolveRequestedTranslateToLanguages, translationLanguages, } from '../lib/translation-languages.js';
9
11
  import { formatCliJobSummary, getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
10
12
  const hasUniqueItems = (values) => new Set(values).size === values.length;
11
13
  const isValidTargetFolderPath = (value) => {
@@ -44,20 +46,6 @@ const normalizeOptionalTitle = (value) => {
44
46
  }
45
47
  return normalizedValue;
46
48
  };
47
- const resolveRequestedTranslateToLanguages = (rawValues) => {
48
- if (!rawValues || rawValues.length === 0) {
49
- return undefined;
50
- }
51
- const invalidLanguage = rawValues.find((value) => !translationLanguages.includes(value));
52
- if (invalidLanguage) {
53
- throw new CliCommandError(`--translate-to must be one of: ${translationLanguages.join(', ')}`, 2);
54
- }
55
- const languages = rawValues;
56
- if (new Set(languages).size !== languages.length) {
57
- throw new CliCommandError('--translate-to languages must be unique.', 2);
58
- }
59
- return languages;
60
- };
61
49
  const normalizeOptionalTargetFolderPath = (value) => {
62
50
  const normalizedValue = value?.trim();
63
51
  if (!normalizedValue) {
@@ -65,6 +53,20 @@ const normalizeOptionalTargetFolderPath = (value) => {
65
53
  }
66
54
  return normalizedValue;
67
55
  };
56
+ const resolveRequestedUuid = ({ flagValue, fallbackValue, label, required = false, }) => {
57
+ const resolvedValue = flagValue?.trim() || fallbackValue?.trim();
58
+ if (!resolvedValue) {
59
+ if (required) {
60
+ throw new CliCommandError(`${label} is required.`, 2);
61
+ }
62
+ return undefined;
63
+ }
64
+ const parsedValue = z.uuid().safeParse(resolvedValue);
65
+ if (!parsedValue.success) {
66
+ throw new CliCommandError(`${label} must be a valid UUID.`, 2);
67
+ }
68
+ return parsedValue.data;
69
+ };
68
70
  const getFolderPathFromRelativePath = (relativePath) => {
69
71
  const normalizedSegments = relativePath
70
72
  .replaceAll('\\', '/')
@@ -180,6 +182,7 @@ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) =>
180
182
  status: waitedJob?.status ?? 'queued',
181
183
  error: waitedJob?.error,
182
184
  exerciseSheetId: waitedJob?.exerciseSheetId,
185
+ translationJobs: waitedJob?.translationJobs,
183
186
  };
184
187
  });
185
188
  };
@@ -192,6 +195,38 @@ const buildSheetImportRequest = ({ inputMode, organizationId, timeoutMs, wait, }
192
195
  target: {},
193
196
  };
194
197
  };
198
+ const buildSheetSolutionImportRequest = ({ exerciseSheetId, inputMode, organizationId, timeoutMs, wait, }) => {
199
+ return {
200
+ organizationId,
201
+ inputMode,
202
+ wait,
203
+ timeoutMs,
204
+ target: { exerciseSheetId },
205
+ };
206
+ };
207
+ const buildQueuedSheetSolutionImportJob = ({ jobId, sources, }) => {
208
+ return {
209
+ jobId,
210
+ sourceIndexes: sources.map((source) => source.sourceIndex),
211
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
212
+ status: 'queued',
213
+ };
214
+ };
215
+ const buildWaitedSheetSolutionImportJob = ({ jobId, sources, waitedJob, }) => {
216
+ return {
217
+ jobId,
218
+ sourceIndexes: sources.map((source) => source.sourceIndex),
219
+ sourceIds: sources.map((source) => source.sourceId).filter((sourceId) => sourceId !== null),
220
+ status: waitedJob?.status ?? 'pending',
221
+ error: waitedJob?.error,
222
+ eligibleExerciseCount: waitedJob?.eligibleExerciseCount,
223
+ exerciseSheetId: waitedJob?.exerciseSheetId,
224
+ nonUpdatableExerciseCount: waitedJob?.nonUpdatableExerciseCount,
225
+ skippedExerciseCount: waitedJob?.skippedExerciseCount,
226
+ unmatchedImportedSolutionCount: waitedJob?.unmatchedImportedSolutionCount,
227
+ updatedExerciseCount: waitedJob?.updatedExerciseCount,
228
+ };
229
+ };
195
230
  const formatSheetJobSourceLabel = ({ job, sources, }) => {
196
231
  if (job.sourceIndexes.length !== 1) {
197
232
  return `${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`;
@@ -211,22 +246,112 @@ const formatQueuedImportOutput = ({ jobs, summary, sources, }) => {
211
246
  const formatWaitedImportOutput = ({ jobs, summary, }) => {
212
247
  return [
213
248
  `${jobs.length} job${jobs.length === 1 ? '' : 's'} queued.`,
214
- ...jobs.map((job) => formatCliJobSummary({
215
- id: job.jobId,
216
- status: job.status === 'queued' ? 'pending' : job.status,
217
- type: 'exercise_sheet_import',
218
- createdAt: '',
219
- updatedAt: '',
220
- error: job.error,
221
- exerciseSheetId: job.exerciseSheetId,
222
- })),
249
+ ...jobs.flatMap((job) => [
250
+ formatCliJobSummary({
251
+ id: job.jobId,
252
+ status: job.status === 'queued' ? 'pending' : job.status,
253
+ type: 'exercise_sheet_import',
254
+ createdAt: '',
255
+ updatedAt: '',
256
+ error: job.error,
257
+ exerciseSheetId: job.exerciseSheetId,
258
+ }),
259
+ ...formatCliImportTranslationJobs(job.translationJobs),
260
+ ]),
223
261
  jobs.length > 1 ? formatCliImportSummary(summary) : null,
224
262
  ]
225
263
  .filter(Boolean)
226
264
  .join('\n');
227
265
  };
266
+ const formatQueuedSheetSolutionImportOutput = (job) => {
267
+ return [
268
+ 'Queued exercise sheet solution import.',
269
+ `${job.jobId} ${job.sourceIndexes.length} source${job.sourceIndexes.length === 1 ? '' : 's'}`,
270
+ ].join('\n');
271
+ };
272
+ const formatWaitedSheetSolutionImportOutput = (job) => {
273
+ return formatCliJobSummary({
274
+ id: job.jobId,
275
+ status: job.status === 'queued' ? 'pending' : job.status,
276
+ type: 'exercise_sheet_solution_import',
277
+ createdAt: '',
278
+ updatedAt: '',
279
+ error: job.error,
280
+ exerciseSheetId: job.exerciseSheetId,
281
+ });
282
+ };
283
+ const formatSheetSearchOutput = ({ exerciseSheets, totalCount, }) => {
284
+ if (exerciseSheets.length === 0) {
285
+ return 'No sheets found.';
286
+ }
287
+ return [
288
+ `${exerciseSheets.length} of ${totalCount} sheet${totalCount === 1 ? '' : 's'} returned.`,
289
+ ...exerciseSheets.map((sheet) => `${sheet.id} ${sheet.name} ${sheet.exerciseCount} exercises`),
290
+ ].join('\n');
291
+ };
228
292
  export const registerSheetCommands = (sheetYargs, context) => {
229
293
  return sheetYargs
294
+ .command('search', 'Search exercise sheets visible to the selected organization', (searchYargs) => searchYargs
295
+ .option('text', {
296
+ type: 'string',
297
+ describe: 'Search sheet names',
298
+ })
299
+ .option('ownership', {
300
+ type: 'string',
301
+ default: 'own',
302
+ describe: 'Visibility scope: own, public, or all',
303
+ })
304
+ .option('limit', {
305
+ type: 'number',
306
+ default: 20,
307
+ describe: 'Maximum number of sheets to return',
308
+ })
309
+ .option('offset', {
310
+ type: 'number',
311
+ default: 0,
312
+ describe: 'Number of matching sheets to skip',
313
+ })
314
+ .example('chalksurf sheet search --text "OKTV 2014" --ownership own --json', 'Verify an imported private sheet in the selected organization')
315
+ .example('chalksurf --profile prod-codex sheet search --ownership own --limit 10 --json', 'List recent sheets visible to the agent profile'), async (argv) => {
316
+ const text = normalizeOptionalText(argv.text);
317
+ const ownership = normalizeOwnership(argv.ownership);
318
+ const limit = normalizeLimit(argv.limit);
319
+ const offset = normalizeOffset(argv.offset);
320
+ const { apiClient, organizationId } = await createResolvedApiClient({
321
+ context,
322
+ baseUrlFlagValue: argv.baseUrl,
323
+ organizationFlagValue: argv.organization,
324
+ profileName: argv.profile,
325
+ });
326
+ let searchResult;
327
+ try {
328
+ searchResult = await apiClient.searchExerciseSheets({
329
+ text,
330
+ ownership: toApiOwnership(ownership),
331
+ limit,
332
+ offset,
333
+ });
334
+ }
335
+ catch (error) {
336
+ throw mapApiErrorToCliError(error);
337
+ }
338
+ context.output.print({
339
+ request: {
340
+ text,
341
+ ownership,
342
+ limit,
343
+ offset,
344
+ organizationId: organizationId ?? null,
345
+ },
346
+ exerciseSheets: searchResult.exerciseSheets,
347
+ totalCount: searchResult.totalCount,
348
+ }, (result) => formatSheetSearchOutput({
349
+ exerciseSheets: result.exerciseSheets,
350
+ totalCount: result.totalCount,
351
+ }), {
352
+ command: 'sheet search',
353
+ });
354
+ })
230
355
  .command('import [sources..]', 'Import one or more exercise sheet sources', (importYargs) => importYargs
231
356
  .positional('sources', {
232
357
  array: true,
@@ -271,12 +396,12 @@ export const registerSheetCommands = (sheetYargs, context) => {
271
396
  })
272
397
  .example('chalksurf sheet import ./fixtures/algebra.pdf --title "OKTV 2014 Round 1" --translate-to english --wait', 'Import a sheet, override its title, and request an English translation')
273
398
  .example('chalksurf sheet import ./fixtures/round-1-a.pdf ./fixtures/round-1-b.pdf --single-sheet --target-folder Contests --wait', 'Import multiple source files into one sheet and place the result into a target folder')
274
- .example('cat sheet-import.json | chalksurf sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
399
+ .example('cat sheet-import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json', 'Drive multi-source sheet imports from a manifest in an agent or CI flow')
275
400
  .epilogue([
276
- 'When --wait is set, the command exits only after the full requested import is complete.',
277
- 'For translated sheet imports, that includes generating and storing the requested translations.',
401
+ 'When --wait is set, the command exits after the queued import job reaches a terminal state.',
402
+ 'For translated sheet imports, requested translations continue as separate jobs listed in the import result.',
278
403
  ].join('\n')), async (argv) => {
279
- const config = await context.configStore.load();
404
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
280
405
  const rawSources = (argv.sources ?? []).map(String);
281
406
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
282
407
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
@@ -429,6 +554,175 @@ export const registerSheetCommands = (sheetYargs, context) => {
429
554
  finally {
430
555
  await cleanupResolvedSources(resolvedSources);
431
556
  }
557
+ })
558
+ .command('import-solutions [exerciseSheetId] [sources..]', 'Import one or more solution files into an existing exercise sheet', (importSolutionsYargs) => importSolutionsYargs
559
+ .positional('exerciseSheetId', {
560
+ type: 'string',
561
+ describe: 'Exercise sheet ID to reconcile imported solutions into',
562
+ })
563
+ .positional('sources', {
564
+ array: true,
565
+ type: 'string',
566
+ describe: 'Local file paths, directories, or HTTP(S) URLs',
567
+ })
568
+ .option('manifest', {
569
+ type: 'string',
570
+ describe: 'Load sources from a manifest file, or "-" for stdin',
571
+ })
572
+ .option('relative-path', {
573
+ type: 'string',
574
+ describe: 'Override the relative path for a single positional source',
575
+ })
576
+ .option('timeout-ms', {
577
+ type: 'number',
578
+ default: 300000,
579
+ describe: 'Maximum time to wait before timing out',
580
+ })
581
+ .option('wait', {
582
+ type: 'boolean',
583
+ default: false,
584
+ describe: 'Wait for the queued job to finish',
585
+ })
586
+ .example('chalksurf sheet import-solutions 00000000-0000-4000-8000-000000000001 ./fixtures/solutions.pdf --wait', 'Attach a separate solution file to an existing exercise sheet')
587
+ .example('cat sheet-solution-import.json | chalksurf --profile prod-codex sheet import-solutions --manifest - --wait --json', 'Drive sheet solution imports from a manifest in an agent or CI flow')
588
+ .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
589
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
590
+ const rawSources = (argv.sources ?? []).map(String);
591
+ const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
592
+ const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
593
+ const resolvedBaseUrl = requireResolvedBaseUrl({
594
+ flagValue: argv.baseUrl,
595
+ env: context.env,
596
+ config,
597
+ });
598
+ const resolvedToken = requireResolvedToken({
599
+ env: context.env,
600
+ config,
601
+ });
602
+ const commandSources = await buildFileImportCommandSources({
603
+ cwd: context.cwd,
604
+ loadManifest: loadExerciseSheetSolutionImportManifest,
605
+ manifestPath,
606
+ rawSources: normalizedRawSources,
607
+ relativePath: argv.relativePath,
608
+ stdin: context.stdin,
609
+ wait: argv.wait,
610
+ });
611
+ const organizationId = resolveRequestedOrganizationId({
612
+ flagValue: argv.organization,
613
+ fallbackValue: commandSources.organizationId,
614
+ env: context.env,
615
+ config,
616
+ });
617
+ const exerciseSheetId = resolveRequestedUuid({
618
+ flagValue: argv.exerciseSheetId,
619
+ fallbackValue: commandSources.exerciseSheetId,
620
+ label: 'exerciseSheetId',
621
+ required: true,
622
+ });
623
+ const apiClient = createApiClient({
624
+ baseUrl: resolvedBaseUrl.value,
625
+ token: resolvedToken.value,
626
+ organizationId,
627
+ });
628
+ let resolvedSources = [];
629
+ try {
630
+ resolvedSources = await resolveSources({
631
+ cwd: context.cwd,
632
+ sources: commandSources.sources,
633
+ });
634
+ const sources = buildCliImportSources({
635
+ resolvedSources,
636
+ });
637
+ const formData = await buildFileImportFormData(resolvedSources);
638
+ formData.set('exerciseSheetId', exerciseSheetId);
639
+ const request = buildSheetSolutionImportRequest({
640
+ exerciseSheetId,
641
+ inputMode: manifestPath ? 'manifest' : 'arguments',
642
+ organizationId: organizationId ?? null,
643
+ timeoutMs: commandSources.wait ? normalizeWaitTimeoutMs(argv.timeoutMs) : null,
644
+ wait: commandSources.wait,
645
+ });
646
+ let importResult;
647
+ try {
648
+ importResult = await apiClient.importExerciseSheetSolutions(formData);
649
+ }
650
+ catch (error) {
651
+ throw mapApiErrorToCliError(error);
652
+ }
653
+ if (!commandSources.wait) {
654
+ const jobs = [
655
+ buildQueuedSheetSolutionImportJob({
656
+ jobId: importResult.jobId,
657
+ sources,
658
+ }),
659
+ ];
660
+ const summary = summarizeCliImportJobs({
661
+ jobs,
662
+ sourceCount: sources.length,
663
+ sourceInputCount: commandSources.sources.length,
664
+ timedOut: false,
665
+ });
666
+ context.output.print({
667
+ request,
668
+ sources,
669
+ jobs,
670
+ summary,
671
+ }, (result) => formatQueuedSheetSolutionImportOutput(result.jobs[0]), {
672
+ command: 'sheet import-solutions',
673
+ });
674
+ return;
675
+ }
676
+ const waitTimeoutMs = normalizeWaitTimeoutMs(argv.timeoutMs);
677
+ const waitResult = await waitForCliJobs({
678
+ getUserJob: async (jobId) => await apiClient.getUserJob(jobId),
679
+ jobIds: [importResult.jobId],
680
+ now: context.now,
681
+ sleep: context.sleep,
682
+ timeoutMs: waitTimeoutMs,
683
+ });
684
+ const waitedJob = waitResult.jobs[0];
685
+ const jobs = [
686
+ buildWaitedSheetSolutionImportJob({
687
+ jobId: importResult.jobId,
688
+ sources,
689
+ waitedJob: waitedJob == null
690
+ ? undefined
691
+ : {
692
+ ...waitedJob,
693
+ id: waitedJob.id,
694
+ status: waitedJob.status,
695
+ },
696
+ }),
697
+ ];
698
+ const summary = summarizeCliImportJobs({
699
+ jobs,
700
+ sourceCount: sources.length,
701
+ sourceInputCount: commandSources.sources.length,
702
+ timedOut: waitResult.timedOut,
703
+ });
704
+ const waitError = getWaitError({
705
+ jobs: waitResult.jobs,
706
+ timedOut: waitResult.timedOut,
707
+ });
708
+ context.output.print({
709
+ request: {
710
+ ...request,
711
+ timeoutMs: waitTimeoutMs,
712
+ },
713
+ sources,
714
+ jobs,
715
+ summary,
716
+ }, (result) => formatWaitedSheetSolutionImportOutput(result.jobs[0]), {
717
+ command: 'sheet import-solutions',
718
+ ok: waitError == null,
719
+ error: waitError,
720
+ });
721
+ throwSilentExitCode(getWaitExitCode({ jobs: waitResult.jobs, timedOut: waitResult.timedOut }));
722
+ }
723
+ finally {
724
+ await cleanupResolvedSources(resolvedSources);
725
+ }
432
726
  })
433
727
  .demandCommand(1)
434
728
  .strict();
@@ -58,12 +58,18 @@ export const createApiClient = ({ baseUrl, token, organizationId, }) => {
58
58
  getUserProfile: async () => {
59
59
  return await query('getUserProfile', null);
60
60
  },
61
- listUserJobs: async () => {
62
- return await query('listUserJobs', null);
61
+ listUserJobs: async (input) => {
62
+ return await query('listUserJobs', input ?? null);
63
63
  },
64
64
  getUserJob: async (id) => {
65
65
  return await query('getUserJob', { id });
66
66
  },
67
+ searchExercises: async (input) => {
68
+ return await mutation('searchExercises', input);
69
+ },
70
+ searchExerciseSheets: async (input) => {
71
+ return await mutation('searchExerciseSheets', input);
72
+ },
67
73
  importExerciseSheet: async (formData) => {
68
74
  return await mutation('importExerciseSheet', formData);
69
75
  },
@@ -73,5 +79,8 @@ export const createApiClient = ({ baseUrl, token, organizationId, }) => {
73
79
  importExerciseSolution: async (formData) => {
74
80
  return await mutation('importExerciseSolution', formData);
75
81
  },
82
+ importExerciseSheetSolutions: async (formData) => {
83
+ return await mutation('importExerciseSheetSolutions', formData);
84
+ },
76
85
  };
77
86
  };
@@ -0,0 +1,61 @@
1
+ import { CliCommandError } from './cli-error.js';
2
+ export const cliDefaultLimit = 20;
3
+ export const cliMaxLimit = 100;
4
+ const normalizeOptionalString = (value) => {
5
+ const normalizedValue = value?.trim();
6
+ return normalizedValue ? normalizedValue : undefined;
7
+ };
8
+ export const normalizeOptionalText = (value) => {
9
+ return normalizeOptionalString(value) ?? null;
10
+ };
11
+ export const normalizeLimit = (value) => {
12
+ const normalizedValue = value ?? cliDefaultLimit;
13
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > cliMaxLimit) {
14
+ throw new CliCommandError(`--limit must be an integer between 1 and ${cliMaxLimit}.`, 2);
15
+ }
16
+ return normalizedValue;
17
+ };
18
+ export const normalizeOffset = (value) => {
19
+ const normalizedValue = value ?? 0;
20
+ if (!Number.isInteger(normalizedValue) || normalizedValue < 0) {
21
+ throw new CliCommandError('--offset must be a non-negative integer.', 2);
22
+ }
23
+ return normalizedValue;
24
+ };
25
+ export const normalizeOptionalInteger = ({ label, value }) => {
26
+ if (value === undefined) {
27
+ return null;
28
+ }
29
+ if (!Number.isInteger(value)) {
30
+ throw new CliCommandError(`${label} must be an integer.`, 2);
31
+ }
32
+ return value;
33
+ };
34
+ export const normalizeChoiceValues = ({ allowedValues, label, values, }) => {
35
+ if (!values || values.length === 0) {
36
+ return undefined;
37
+ }
38
+ const normalizedValues = values.map((value) => value.trim()).filter((value) => value.length > 0);
39
+ const invalidValue = normalizedValues.find((value) => !allowedValues.includes(value));
40
+ if (invalidValue) {
41
+ throw new CliCommandError(`${label} must be one of: ${allowedValues.join(', ')}`, 2);
42
+ }
43
+ return Array.from(new Set(normalizedValues));
44
+ };
45
+ export const normalizeStringValues = (values) => {
46
+ if (!values || values.length === 0) {
47
+ return [];
48
+ }
49
+ return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
50
+ };
51
+ export const ownershipOptions = ['own', 'public', 'all'];
52
+ export const normalizeOwnership = (value) => {
53
+ const normalizedValue = normalizeOptionalString(value) ?? 'own';
54
+ if (!ownershipOptions.includes(normalizedValue)) {
55
+ throw new CliCommandError(`--ownership must be one of: ${ownershipOptions.join(', ')}`, 2);
56
+ }
57
+ return normalizedValue;
58
+ };
59
+ export const toApiOwnership = (ownership) => {
60
+ return ownership === 'all' ? null : ownership;
61
+ };