@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.
package/README.md CHANGED
@@ -2,20 +2,20 @@
2
2
 
3
3
  Publishable ChalkSurf CLI package.
4
4
 
5
- Current internal release line: `0.1.0`. Expect breaking changes while the CLI is still only used internally.
5
+ Current internal release line: `0.2.2`. Expect breaking changes while the CLI is still only used internally.
6
6
 
7
7
  ## Installation
8
8
 
9
9
  Run the published CLI without a global install:
10
10
 
11
11
  ```bash
12
- npx @chalksurf/cli@0.1.0 --help
12
+ npx @chalksurf/cli@0.2.2 --help
13
13
  ```
14
14
 
15
15
  Install it globally when you want a persistent local binary:
16
16
 
17
17
  ```bash
18
- npm install -g @chalksurf/cli@0.1.0
18
+ npm install -g @chalksurf/cli@0.2.2
19
19
  chalksurf --version
20
20
  ```
21
21
 
@@ -24,7 +24,8 @@ chalksurf --version
24
24
  Interactive operator flow:
25
25
 
26
26
  ```bash
27
- chalksurf auth login --base-url https://api.chalksurf.com
27
+ chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev
28
+ chalksurf profile use prod-cztamas
28
29
  chalksurf org list
29
30
  chalksurf sheet import ./fixtures/algebra.pdf --wait
30
31
  ```
@@ -32,38 +33,41 @@ chalksurf sheet import ./fixtures/algebra.pdf --wait
32
33
  Headless or agent flow:
33
34
 
34
35
  ```bash
35
- CHALKSURF_BASE_URL=https://api.chalksurf.com \
36
36
  CHALKSURF_TOKEN=cs_cli_... \
37
- CHALKSURF_ORGANIZATION_ID=org_123 \
38
- npx @chalksurf/cli@0.1.0 sheet import --manifest - --wait --json < import.json
37
+ printf '%s' "$CHALKSURF_TOKEN" | npx @chalksurf/cli@0.2.2 auth login \
38
+ --profile prod-codex \
39
+ --base-url https://chalksurf-api.fly.dev \
40
+ --with-token
41
+
42
+ npx @chalksurf/cli@0.2.2 --profile prod-codex sheet import --manifest - --wait --json < import.json
39
43
  ```
40
44
 
41
- Sheet import manifests use top-level `sheets[]`, where each sheet has one `targetFolderPath` and one or more ordered `sources[]`. See [the canonical example](./docs/examples/sheet-import-manifest.json).
45
+ Sheet import manifests use top-level `sheets[]`, where each sheet has one `targetFolderPath` and one or more ordered `sources[]`. See [the canonical example](./packages/cli/docs/examples/sheet-import-manifest.json).
42
46
 
43
47
  ## Docs
44
48
 
45
- - [Manual operator guide](./docs/manual.md)
46
- - [Agent and Codex guide](./docs/agents.md)
47
- - [Manifest reference](./docs/manifest.md)
48
- - [Exit codes and JSON errors](./docs/exit-codes.md)
49
- - [Sheet import schema](./schemas/sheet-import-manifest.schema.json)
50
- - [Exercise import schema](./schemas/exercise-import-manifest.schema.json)
51
- - [Exercise solution import schema](./schemas/exercise-solution-import-manifest.schema.json)
49
+ - [Manual operator guide](./packages/cli/docs/manual.md)
50
+ - [Agent and Codex guide](./packages/cli/docs/agents.md)
51
+ - [Manifest reference](./packages/cli/docs/manifest.md)
52
+ - [Exit codes and JSON errors](./packages/cli/docs/exit-codes.md)
53
+ - [Sheet import schema](./packages/cli/schemas/sheet-import-manifest.schema.json)
54
+ - [Exercise import schema](./packages/cli/schemas/exercise-import-manifest.schema.json)
55
+ - [Exercise solution import schema](./packages/cli/schemas/exercise-solution-import-manifest.schema.json)
56
+ - [Exercise sheet solution import schema](./packages/cli/schemas/exercise-sheet-solution-import-manifest.schema.json)
52
57
 
53
58
  ## Local And Staging Testing
54
59
 
55
- Use separate config files per environment so local and staging tokens do not overwrite each other:
60
+ Use separate profiles so local, staging, production, human, and agent tokens do not overwrite each other:
56
61
 
57
62
  ```bash
58
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-local.json
59
- npm run cli-dev -- auth login --base-url http://localhost:8080
63
+ npm run cli-dev -- auth login --profile dev-cztamas --base-url http://localhost:8080
64
+ npm run cli-dev -- profile use dev-cztamas
60
65
  npm run cli-dev -- auth status --json
61
66
  npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
62
67
  ```
63
68
 
64
69
  ```bash
65
- export CHALKSURF_CONFIG_PATH=/tmp/chalksurf-staging.json
66
- npm run cli-dev -- auth login --base-url https://staging-api.chalksurf.com
67
- npm run cli-dev -- auth status --json
68
- npm run cli-dev -- sheet import https://example.com/worksheet.docx --single-sheet --target-folder Imported --json
70
+ npm run cli-dev -- auth login --profile staging-codex --base-url https://chalksurf-api-staging.fly.dev
71
+ npm run cli-dev -- --profile staging-codex auth status --json
72
+ npm run cli-dev -- --profile staging-codex sheet import https://example.com/worksheet.docx --single-sheet --target-folder Imported --json
69
73
  ```
@@ -7,13 +7,15 @@ import { registerAuthCommands } from '../commands/auth.js';
7
7
  import { registerExerciseCommands } from '../commands/exercise.js';
8
8
  import { registerJobCommands } from '../commands/job.js';
9
9
  import { registerOrgCommands } from '../commands/org.js';
10
+ import { registerProfileCommands } from '../commands/profile.js';
10
11
  import { registerSheetCommands } from '../commands/sheet.js';
11
12
  import { CliCommandError, createSerializableCliError, serializeCliError } from '../lib/cli-error.js';
12
13
  import { createConfigStore } from '../lib/config-store.js';
13
14
  import { createOutput } from '../lib/output.js';
14
15
  import { createPromptSecret } from '../lib/prompt-secret.js';
15
16
  const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
16
- const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecret, }) => {
17
+ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, }) => {
18
+ const promptSecret = createPromptSecret();
17
19
  const commandContext = {
18
20
  cwd,
19
21
  configStore: createConfigStore({ configPath, env }),
@@ -42,6 +44,11 @@ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecr
42
44
  type: 'string',
43
45
  global: true,
44
46
  describe: 'Organization ID to use for this command',
47
+ })
48
+ .option('profile', {
49
+ type: 'string',
50
+ global: true,
51
+ describe: 'CLI profile to use for this command',
45
52
  })
46
53
  .option('version', {
47
54
  type: 'boolean',
@@ -50,6 +57,7 @@ const createCli = ({ cwd, configPath, env, now, output, sleep, stdin, promptSecr
50
57
  })
51
58
  .command('auth <subcommand>', 'Authentication commands', (authYargs) => registerAuthCommands(authYargs, commandContext), () => { })
52
59
  .command('org <subcommand>', 'Organization commands', (orgYargs) => registerOrgCommands(orgYargs, commandContext), () => { })
60
+ .command('profile <subcommand>', 'Profile commands', (profileYargs) => registerProfileCommands(profileYargs, commandContext), () => { })
53
61
  .command('exercise <subcommand>', 'Exercise commands', (exerciseYargs) => registerExerciseCommands(exerciseYargs, commandContext), () => { })
54
62
  .command('sheet <subcommand>', 'Exercise sheet commands', (sheetYargs) => registerSheetCommands(sheetYargs, commandContext), () => { })
55
63
  .command('job <subcommand>', 'Job commands', (jobYargs) => registerJobCommands(jobYargs, commandContext), () => { })
@@ -74,7 +82,7 @@ const parseCliWithCapturedOutput = async ({ cli, argv, stdout, }) => {
74
82
  const hasFlag = (argv, flags) => {
75
83
  return argv.some((argument) => flags.includes(argument));
76
84
  };
77
- const topLevelCommands = new Set(['auth', 'org', 'exercise', 'sheet', 'job']);
85
+ const topLevelCommands = new Set(['auth', 'org', 'profile', 'exercise', 'sheet', 'job']);
78
86
  const hasTopLevelCommand = (argv) => {
79
87
  return argv.some((argument) => topLevelCommands.has(argument));
80
88
  };
@@ -94,7 +102,7 @@ const resolveCommandName = (argv) => {
94
102
  }
95
103
  return 'chalksurf';
96
104
  };
97
- export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = process.env, nowImpl = Date.now, promptSecretImpl = createPromptSecret(), sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, }) => {
105
+ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = process.env, nowImpl = Date.now, sleepImpl = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, }) => {
98
106
  const output = createOutput({
99
107
  json: hasFlag(argv, ['--json']),
100
108
  stdout,
@@ -107,7 +115,6 @@ export const runCli = async ({ argv, cwd = process.cwd(), configPath, env = proc
107
115
  env,
108
116
  now: nowImpl,
109
117
  output,
110
- promptSecret: promptSecretImpl,
111
118
  sleep: sleepImpl,
112
119
  stdin,
113
120
  });
@@ -38,10 +38,10 @@ export const registerAuthCommands = (authYargs, context) => {
38
38
  default: false,
39
39
  describe: 'Read the token from stdin when piping input',
40
40
  })
41
- .example('chalksurf auth login --base-url https://api.chalksurf.com', 'Prompt for a CLI token and store it locally')
42
- .example(`printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --base-url https://api.chalksurf.com`, 'Read a CLI token from stdin instead of prompting');
41
+ .example('chalksurf auth login --profile prod-cztamas --base-url https://chalksurf-api.fly.dev', 'Prompt for a CLI token and store it in the prod-cztamas profile')
42
+ .example(`printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login --profile prod-codex --base-url https://chalksurf-api.fly.dev`, 'Read a CLI token from stdin into an explicit profile');
43
43
  }, async (argv) => {
44
- const config = await context.configStore.load();
44
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
45
45
  const resolvedBaseUrl = requireResolvedBaseUrl({
46
46
  flagValue: argv.baseUrl,
47
47
  env: context.env,
@@ -70,7 +70,7 @@ export const registerAuthCommands = (authYargs, context) => {
70
70
  config,
71
71
  profile,
72
72
  });
73
- await context.configStore.update((currentConfig) => ({
73
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
74
74
  ...currentConfig,
75
75
  token,
76
76
  baseUrl: resolvedBaseUrl.value,
@@ -84,6 +84,8 @@ export const registerAuthCommands = (authYargs, context) => {
84
84
  baseUrl: resolvedBaseUrl.value,
85
85
  baseUrlSource: resolvedBaseUrl.source,
86
86
  organization: serializeOrganization(resolvedOrganization.organization),
87
+ profile: config.profileName,
88
+ profileSource: config.profileSource,
87
89
  user: {
88
90
  email: profile.email,
89
91
  id: profile.id,
@@ -95,14 +97,15 @@ export const registerAuthCommands = (authYargs, context) => {
95
97
  })
96
98
  .command('status', 'Show the current authentication and organization state', (statusYargs) => statusYargs
97
99
  .example('chalksurf auth status', 'Show the current authenticated user and default organization')
98
- .example('CHALKSURF_BASE_URL=https://api.chalksurf.com CHALKSURF_TOKEN=cs_cli_... chalksurf auth status --json', 'Inspect the active session in machine-readable mode')
100
+ .example('chalksurf --profile prod-codex auth status --json', 'Inspect a specific profile in machine-readable mode')
99
101
  .epilogue([
100
102
  'Resolution order:',
101
- ' base URL: --base-url, then CHALKSURF_BASE_URL, then stored config',
102
- ' token: CHALKSURF_TOKEN, then stored config',
103
- ' organization: --organization, then CHALKSURF_ORGANIZATION_ID, then stored config',
103
+ ' profile: --profile, then CHALKSURF_PROFILE, then stored default profile',
104
+ ' base URL: --base-url, then CHALKSURF_BASE_URL, then active profile',
105
+ ' token: CHALKSURF_TOKEN, then active profile',
106
+ ' organization: --organization, then CHALKSURF_ORGANIZATION_ID, then active profile',
104
107
  ].join('\n')), async (argv) => {
105
- const config = await context.configStore.load();
108
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
106
109
  const resolvedBaseUrl = requireResolvedBaseUrl({
107
110
  flagValue: argv.baseUrl,
108
111
  env: context.env,
@@ -138,6 +141,8 @@ export const registerAuthCommands = (authYargs, context) => {
138
141
  baseUrlSource: resolvedBaseUrl.source,
139
142
  organization: serializeOrganization(resolvedOrganization.organization),
140
143
  organizationSource: resolvedOrganization.source,
144
+ profile: config.profileName,
145
+ profileSource: config.profileSource,
141
146
  tokenSource: resolvedToken.source,
142
147
  user: {
143
148
  email: profile.email,
@@ -148,16 +153,18 @@ export const registerAuthCommands = (authYargs, context) => {
148
153
  command: 'auth status',
149
154
  });
150
155
  })
151
- .command('logout', 'Remove the locally stored CLI token', (logoutYargs) => logoutYargs.example('chalksurf auth logout', 'Remove the stored CLI token from local config'), async () => {
152
- const config = await context.configStore.load();
156
+ .command('logout', 'Remove the locally stored CLI token', (logoutYargs) => logoutYargs.example('chalksurf auth logout', 'Remove the stored CLI token from local config'), async (argv) => {
157
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
153
158
  const hadStoredToken = Boolean(config.token);
154
- await context.configStore.update((currentConfig) => ({
159
+ await context.configStore.update({ profileName: argv.profile }, (currentConfig) => ({
155
160
  ...currentConfig,
156
161
  token: undefined,
157
162
  }));
158
163
  context.output.print({
159
164
  clearedStoredToken: hadStoredToken,
160
165
  loggedOut: true,
166
+ profile: config.profileName,
167
+ profileSource: config.profileSource,
161
168
  }, hadStoredToken ? 'Stored CLI token removed.' : 'No stored CLI token was present.', {
162
169
  command: 'auth logout',
163
170
  });
@@ -1,12 +1,39 @@
1
1
  import { z } from 'zod';
2
2
  import { createApiClient } from '../lib/api-client.js';
3
3
  import { CliCommandError } from '../lib/cli-error.js';
4
+ import { normalizeChoiceValues, normalizeLimit, normalizeOffset, normalizeOptionalInteger, normalizeOptionalText, normalizeOwnership, normalizeStringValues, toApiOwnership, } from '../lib/command-options.js';
4
5
  import { buildFileImportCommandSources, buildFileImportFormData, normalizeWaitTimeoutMs } from '../lib/import-files.js';
5
- import { buildCliImportSources, summarizeCliImportJobs, } from '../lib/import-output.js';
6
+ import { buildCliImportSources, formatCliImportTranslationJobs, summarizeCliImportJobs, } from '../lib/import-output.js';
6
7
  import { loadExerciseImportManifest, loadExerciseSolutionImportManifest } from '../lib/manifest.js';
7
- import { mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
8
+ import { createResolvedApiClient, mapApiErrorToCliError, requireResolvedBaseUrl, requireResolvedToken, resolveRequestedOrganizationId, } from '../lib/session.js';
8
9
  import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.js';
10
+ import { resolveRequestedTranslateToLanguages, translationLanguages, } from '../lib/translation-languages.js';
9
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
+ };
10
37
  const resolveRequestedUuid = ({ flagValue, fallbackValue, label, required = false, }) => {
11
38
  const resolvedValue = flagValue?.trim() || fallbackValue?.trim();
12
39
  if (!resolvedValue) {
@@ -50,6 +77,7 @@ const buildWaitedExerciseImportJob = ({ jobId, sources, waitedJob, }) => {
50
77
  error: waitedJob?.error,
51
78
  exerciseId: waitedJob?.exerciseId,
52
79
  exerciseIds: waitedJob?.exerciseIds,
80
+ translationJobs: waitedJob?.translationJobs,
53
81
  };
54
82
  };
55
83
  const formatQueuedImportOutput = (job) => {
@@ -66,13 +94,17 @@ const formatQueuedSolutionImportOutput = (job) => {
66
94
  };
67
95
  const formatWaitedImportOutput = (job) => {
68
96
  if (job.status === 'completed') {
97
+ const translationJobLines = formatCliImportTranslationJobs(job.translationJobs);
69
98
  if (job.exerciseIds && job.exerciseIds.length > 0) {
70
- return `${job.jobId} completed -> ${job.exerciseIds.length} exercise${job.exerciseIds.length === 1 ? '' : 's'}`;
99
+ return [
100
+ `${job.jobId} completed -> ${job.exerciseIds.length} exercise${job.exerciseIds.length === 1 ? '' : 's'}`,
101
+ ...translationJobLines,
102
+ ].join('\n');
71
103
  }
72
104
  if (job.exerciseId) {
73
- return `${job.jobId} completed -> ${job.exerciseId}`;
105
+ return [`${job.jobId} completed -> ${job.exerciseId}`, ...translationJobLines].join('\n');
74
106
  }
75
- return `${job.jobId} completed`;
107
+ return [`${job.jobId} completed`, ...translationJobLines].join('\n');
76
108
  }
77
109
  if (job.status === 'failed') {
78
110
  return `${job.jobId} failed -> ${job.error ?? 'Unknown error'}`;
@@ -81,6 +113,133 @@ const formatWaitedImportOutput = (job) => {
81
113
  };
82
114
  export const registerExerciseCommands = (exerciseYargs, context) => {
83
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
+ })
84
243
  .command('import [sources..]', 'Import one or more exercises', (importYargs) => importYargs
85
244
  .positional('sources', {
86
245
  array: true,
@@ -103,6 +262,11 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
103
262
  type: 'number',
104
263
  default: 300000,
105
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(', ')})`,
106
270
  })
107
271
  .option('wait', {
108
272
  type: 'boolean',
@@ -110,12 +274,16 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
110
274
  describe: 'Wait for the queued job to finish',
111
275
  })
112
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')
113
- .example('cat exercise-import.json | chalksurf exercise import --manifest - --wait --json', 'Drive imports from a manifest in an agent or CI flow')
114
- .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
115
- const config = await context.configStore.load();
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 });
116
283
  const rawSources = (argv.sources ?? []).map(String);
117
284
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
118
285
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;
286
+ const requestedTranslateTo = resolveRequestedTranslateToLanguages(argv.translateTo);
119
287
  const resolvedBaseUrl = requireResolvedBaseUrl({
120
288
  flagValue: argv.baseUrl,
121
289
  env: context.env,
@@ -145,6 +313,7 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
145
313
  fallbackValue: commandSources.exerciseSheetId,
146
314
  label: '--sheet-id',
147
315
  });
316
+ const translateToLanguages = requestedTranslateTo ?? commandSources.translateTo;
148
317
  const apiClient = createApiClient({
149
318
  baseUrl: resolvedBaseUrl.value,
150
319
  token: resolvedToken.value,
@@ -170,6 +339,9 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
170
339
  if (exerciseSheetId) {
171
340
  formData.set('exerciseSheetId', exerciseSheetId);
172
341
  }
342
+ if (translateToLanguages && translateToLanguages.length > 0) {
343
+ formData.set('translateToLanguages', JSON.stringify(translateToLanguages));
344
+ }
173
345
  let importResult;
174
346
  try {
175
347
  importResult = await apiClient.importExercise(formData);
@@ -283,9 +455,9 @@ export const registerExerciseCommands = (exerciseYargs, context) => {
283
455
  describe: 'Wait for the queued job to finish',
284
456
  })
285
457
  .example('chalksurf exercise import-solution 00000000-0000-4000-8000-000000000001 ./fixtures/solution.pdf --wait', 'Attach a solution file to an existing exercise')
286
- .example('cat exercise-solution-import.json | chalksurf exercise import-solution --manifest - --wait --json', 'Drive solution imports from a manifest in an agent or CI flow')
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')
287
459
  .epilogue('When --wait is set, the command exits after the queued import job reaches a terminal state.'), async (argv) => {
288
- const config = await context.configStore.load();
460
+ const config = await context.configStore.loadProfile({ profileName: argv.profile });
289
461
  const rawSources = (argv.sources ?? []).map(String);
290
462
  const manifestPath = argv.manifest === '' ? '-' : argv.manifest;
291
463
  const normalizedRawSources = manifestPath === '-' && rawSources[0] === '-' ? rawSources.slice(1) : rawSources;