@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.
@@ -5,7 +5,11 @@ import { CliCommandError } from './cli-error.js';
5
5
  export const chalksurfBaseUrlEnvVar = 'CHALKSURF_BASE_URL';
6
6
  export const chalksurfTokenEnvVar = 'CHALKSURF_TOKEN';
7
7
  export const chalksurfOrganizationIdEnvVar = 'CHALKSURF_ORGANIZATION_ID';
8
+ export const chalksurfProfileEnvVar = 'CHALKSURF_PROFILE';
8
9
  export const chalksurfConfigPathEnvVar = 'CHALKSURF_CONFIG_PATH';
10
+ export const cliConfigSchemaVersion = 2;
11
+ export const implicitDefaultProfileName = 'default';
12
+ const profileNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
13
  const normalizeOptionalString = (value) => {
10
14
  const trimmedValue = value?.trim();
11
15
  return trimmedValue ? trimmedValue : undefined;
@@ -14,13 +18,91 @@ const normalizeStoredBaseUrl = (value) => {
14
18
  const normalizedValue = normalizeOptionalString(value);
15
19
  return normalizedValue?.replace(/\/+$/, '') || undefined;
16
20
  };
17
- const sanitizeConfig = (config) => {
21
+ const isRecord = (value) => {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ };
24
+ export const assertValidProfileName = (profileName) => {
25
+ if (!profileNamePattern.test(profileName)) {
26
+ throw new CliCommandError(`Invalid profile "${profileName}". Profile names must start with a letter or number and contain only letters, numbers, ".", "_", or "-".`, 2);
27
+ }
28
+ };
29
+ const normalizeProfileName = (value) => {
30
+ const profileName = normalizeOptionalString(value);
31
+ if (!profileName) {
32
+ return undefined;
33
+ }
34
+ assertValidProfileName(profileName);
35
+ return profileName;
36
+ };
37
+ const sanitizeProfileConfig = (config) => {
18
38
  return Object.fromEntries(Object.entries({
19
39
  token: normalizeOptionalString(config.token),
20
40
  baseUrl: normalizeStoredBaseUrl(config.baseUrl),
21
41
  organizationId: normalizeOptionalString(config.organizationId),
22
42
  }).filter(([, value]) => value !== undefined));
23
43
  };
44
+ const hasProfileConfigValues = (config) => {
45
+ return Boolean(config.baseUrl || config.token || config.organizationId);
46
+ };
47
+ const readProfileConfig = (value) => {
48
+ if (!isRecord(value)) {
49
+ return {};
50
+ }
51
+ return sanitizeProfileConfig({
52
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : undefined,
53
+ organizationId: typeof value.organizationId === 'string' ? value.organizationId : undefined,
54
+ token: typeof value.token === 'string' ? value.token : undefined,
55
+ });
56
+ };
57
+ const sanitizeRootConfig = (config) => {
58
+ const profiles = Object.fromEntries(Object.entries(config.profiles)
59
+ .map(([profileName, profileConfig]) => {
60
+ const normalizedProfileName = normalizeProfileName(profileName);
61
+ const sanitizedProfileConfig = sanitizeProfileConfig(profileConfig);
62
+ return normalizedProfileName && hasProfileConfigValues(sanitizedProfileConfig)
63
+ ? [normalizedProfileName, sanitizedProfileConfig]
64
+ : null;
65
+ })
66
+ .filter((entry) => entry !== null));
67
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
68
+ return {
69
+ schemaVersion: cliConfigSchemaVersion,
70
+ ...(defaultProfile && profiles[defaultProfile] ? { defaultProfile } : {}),
71
+ profiles,
72
+ };
73
+ };
74
+ const readRootConfig = (value) => {
75
+ if (!isRecord(value)) {
76
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
77
+ }
78
+ if (isRecord(value.profiles)) {
79
+ const profiles = Object.fromEntries(Object.entries(value.profiles)
80
+ .map(([profileName, profileConfig]) => {
81
+ const sanitizedProfileConfig = readProfileConfig(profileConfig);
82
+ return hasProfileConfigValues(sanitizedProfileConfig)
83
+ ? [profileName, sanitizedProfileConfig]
84
+ : null;
85
+ })
86
+ .filter((entry) => entry !== null));
87
+ return sanitizeRootConfig({
88
+ schemaVersion: cliConfigSchemaVersion,
89
+ defaultProfile: typeof value.defaultProfile === 'string' ? value.defaultProfile : undefined,
90
+ profiles,
91
+ });
92
+ }
93
+ const legacyProfile = readProfileConfig(value);
94
+ if (!hasProfileConfigValues(legacyProfile)) {
95
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
96
+ }
97
+ return {
98
+ schemaVersion: cliConfigSchemaVersion,
99
+ defaultProfile: implicitDefaultProfileName,
100
+ profiles: {
101
+ [implicitDefaultProfileName]: legacyProfile,
102
+ },
103
+ };
104
+ };
105
+ const rootConfigHasProfiles = (config) => Object.keys(config.profiles).length > 0;
24
106
  const getDefaultConfigPath = ({ env, platform, homeDirectory, }) => {
25
107
  const explicitConfigPath = normalizeOptionalString(env[chalksurfConfigPathEnvVar]);
26
108
  if (explicitConfigPath) {
@@ -73,6 +155,24 @@ export const resolveToken = ({ env, config }) => {
73
155
  }
74
156
  return { source: 'none' };
75
157
  };
158
+ export const resolveProfile = ({ flagValue, env, config, }) => {
159
+ const flagProfileName = normalizeProfileName(flagValue);
160
+ if (flagProfileName) {
161
+ return { profileName: flagProfileName, profileSource: 'flag' };
162
+ }
163
+ const envProfileName = normalizeProfileName(env[chalksurfProfileEnvVar]);
164
+ if (envProfileName) {
165
+ return { profileName: envProfileName, profileSource: 'env' };
166
+ }
167
+ const defaultProfile = normalizeProfileName(config.defaultProfile);
168
+ if (defaultProfile) {
169
+ return { profileName: defaultProfile, profileSource: 'config' };
170
+ }
171
+ if (!rootConfigHasProfiles(config)) {
172
+ return { profileName: implicitDefaultProfileName, profileSource: 'implicit-default' };
173
+ }
174
+ throw new CliCommandError(`No active ChalkSurf profile selected. Pass --profile, set ${chalksurfProfileEnvVar}, or run "chalksurf profile use <profile>".`, 2);
175
+ };
76
176
  export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
77
177
  const flagOrganizationId = normalizeOptionalString(flagValue);
78
178
  if (flagOrganizationId) {
@@ -134,14 +234,23 @@ export const resolveOrganization = ({ flagValue, env, config, profile, }) => {
134
234
  export const createConfigStore = ({ configPath, env = process.env, platform = process.platform, homeDirectory = homedir(), fs = { chmod, mkdir, readFile, rm, writeFile }, } = {}) => {
135
235
  const resolvedConfigPath = configPath || getDefaultConfigPath({ env, platform, homeDirectory });
136
236
  const load = async () => {
237
+ const rootConfig = await loadRoot();
238
+ const resolvedProfile = resolveProfile({ env, config: rootConfig });
239
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
240
+ return {
241
+ ...profileConfig,
242
+ ...resolvedProfile,
243
+ };
244
+ };
245
+ const loadRoot = async () => {
137
246
  try {
138
247
  const configContents = await fs.readFile(resolvedConfigPath, 'utf8');
139
248
  const parsedConfig = JSON.parse(configContents);
140
- return sanitizeConfig(parsedConfig);
249
+ return readRootConfig(parsedConfig);
141
250
  }
142
251
  catch (error) {
143
252
  if (error.code === 'ENOENT') {
144
- return {};
253
+ return { schemaVersion: cliConfigSchemaVersion, profiles: {} };
145
254
  }
146
255
  if (error instanceof SyntaxError) {
147
256
  throw new CliCommandError(`Failed to parse CLI config at ${resolvedConfigPath}.`, 2);
@@ -149,9 +258,18 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
149
258
  throw error;
150
259
  }
151
260
  };
152
- const save = async (config) => {
153
- const sanitizedConfig = sanitizeConfig(config);
154
- if (Object.keys(sanitizedConfig).length === 0) {
261
+ const loadProfile = async ({ profileName } = {}) => {
262
+ const rootConfig = await loadRoot();
263
+ const resolvedProfile = resolveProfile({ flagValue: profileName, env, config: rootConfig });
264
+ const profileConfig = rootConfig.profiles[resolvedProfile.profileName] ?? {};
265
+ return {
266
+ ...profileConfig,
267
+ ...resolvedProfile,
268
+ };
269
+ };
270
+ const saveRoot = async (config) => {
271
+ const sanitizedConfig = sanitizeRootConfig(config);
272
+ if (!rootConfigHasProfiles(sanitizedConfig)) {
155
273
  await fs.rm(resolvedConfigPath, { force: true });
156
274
  return sanitizedConfig;
157
275
  }
@@ -162,15 +280,75 @@ export const createConfigStore = ({ configPath, env = process.env, platform = pr
162
280
  await fs.chmod(resolvedConfigPath, 0o600);
163
281
  return sanitizedConfig;
164
282
  };
165
- const update = async (updater) => {
166
- const currentConfig = await load();
167
- const nextConfig = await updater(currentConfig);
168
- return await save(nextConfig);
283
+ const save = async (config) => {
284
+ const rootConfig = await loadRoot();
285
+ const nextProfileConfig = sanitizeProfileConfig(config);
286
+ const nextRootConfig = {
287
+ ...rootConfig,
288
+ profiles: {
289
+ ...rootConfig.profiles,
290
+ },
291
+ };
292
+ if (hasProfileConfigValues(nextProfileConfig)) {
293
+ nextRootConfig.profiles[config.profileName] = nextProfileConfig;
294
+ }
295
+ else {
296
+ delete nextRootConfig.profiles[config.profileName];
297
+ }
298
+ if (config.profileSource === 'implicit-default' && hasProfileConfigValues(nextProfileConfig)) {
299
+ nextRootConfig.defaultProfile = config.profileName;
300
+ }
301
+ return await saveRoot(nextRootConfig);
302
+ };
303
+ const update = async ({ profileName }, updater) => {
304
+ const currentConfig = await loadProfile({ profileName });
305
+ const nextProfileConfig = await updater(currentConfig);
306
+ return await save({
307
+ ...nextProfileConfig,
308
+ profileName: currentConfig.profileName,
309
+ profileSource: currentConfig.profileSource,
310
+ });
311
+ };
312
+ const setDefaultProfile = async (profileName) => {
313
+ const normalizedProfileName = normalizeProfileName(profileName);
314
+ if (!normalizedProfileName) {
315
+ throw new CliCommandError('Profile name is required.', 2);
316
+ }
317
+ const rootConfig = await loadRoot();
318
+ if (!rootConfig.profiles[normalizedProfileName]) {
319
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist. Run "chalksurf auth login --profile ${normalizedProfileName} --base-url <url>" first.`, 2);
320
+ }
321
+ return await saveRoot({
322
+ ...rootConfig,
323
+ defaultProfile: normalizedProfileName,
324
+ });
325
+ };
326
+ const deleteProfile = async (profileName) => {
327
+ const normalizedProfileName = normalizeProfileName(profileName);
328
+ if (!normalizedProfileName) {
329
+ throw new CliCommandError('Profile name is required.', 2);
330
+ }
331
+ const rootConfig = await loadRoot();
332
+ if (!rootConfig.profiles[normalizedProfileName]) {
333
+ throw new CliCommandError(`Profile "${normalizedProfileName}" does not exist.`, 2);
334
+ }
335
+ const profiles = { ...rootConfig.profiles };
336
+ delete profiles[normalizedProfileName];
337
+ return await saveRoot({
338
+ schemaVersion: cliConfigSchemaVersion,
339
+ ...(rootConfig.defaultProfile === normalizedProfileName ? {} : { defaultProfile: rootConfig.defaultProfile }),
340
+ profiles,
341
+ });
169
342
  };
170
343
  return {
171
344
  path: resolvedConfigPath,
172
345
  load,
346
+ loadProfile,
347
+ loadRoot,
173
348
  save,
349
+ saveRoot,
174
350
  update,
351
+ setDefaultProfile,
352
+ deleteProfile,
175
353
  };
176
354
  };
@@ -65,3 +65,17 @@ export const formatCliImportSummary = (summary) => {
65
65
  }
66
66
  return `Summary: ${parts.join(', ') || '0 jobs'}.`;
67
67
  };
68
+ export const formatCliImportTranslationJobs = (translationJobs) => {
69
+ if (!translationJobs || translationJobs.length === 0) {
70
+ return [];
71
+ }
72
+ return [
73
+ `Queued ${translationJobs.length} translation job${translationJobs.length === 1 ? '' : 's'}:`,
74
+ ...translationJobs.map((job) => {
75
+ if (job.type === 'exercise_translation_generation') {
76
+ return `${job.jobId} exercise ${job.exerciseId} ${job.languages.join(', ')}`;
77
+ }
78
+ return `${job.jobId} sheet ${job.exerciseSheetId} ${job.language}`;
79
+ }),
80
+ ];
81
+ };
@@ -161,6 +161,10 @@ const parseExerciseImportManifest = (manifestText) => {
161
161
  parsedManifest,
162
162
  topLevelFields: ['exerciseSheetId'],
163
163
  }),
164
+ translateTo: parseTranslateTo({
165
+ value: parsedManifest.translateTo,
166
+ label: 'Manifest',
167
+ }),
164
168
  sources,
165
169
  };
166
170
  };
@@ -176,6 +180,18 @@ const parseExerciseSolutionImportManifest = (manifestText) => {
176
180
  sources,
177
181
  };
178
182
  };
183
+ const parseExerciseSheetSolutionImportManifest = (manifestText) => {
184
+ const parsedManifest = parseSourceManifestObject(manifestText);
185
+ const sources = parsedManifest.sources.map((source, index) => parseBaseManifestSource(source, index));
186
+ assertUniqueSourceIds(sources);
187
+ return {
188
+ ...parseManifestMetadata({
189
+ parsedManifest,
190
+ topLevelFields: ['exerciseSheetId'],
191
+ }),
192
+ sources,
193
+ };
194
+ };
179
195
  const parseSheetImportManifest = (manifestText) => {
180
196
  const parsedManifest = parseManifestObject(manifestText);
181
197
  if ('sources' in parsedManifest) {
@@ -261,6 +277,14 @@ export const loadExerciseSolutionImportManifest = async ({ cwd, manifestPath, st
261
277
  stdin,
262
278
  });
263
279
  };
280
+ export const loadExerciseSheetSolutionImportManifest = async ({ cwd, manifestPath, stdin, }) => {
281
+ return await loadManifest({
282
+ cwd,
283
+ manifestPath,
284
+ parseManifest: parseExerciseSheetSolutionImportManifest,
285
+ stdin,
286
+ });
287
+ };
264
288
  export const loadSheetImportManifest = async ({ cwd, manifestPath, stdin, }) => {
265
289
  return await loadManifest({
266
290
  cwd,
@@ -1,3 +1,4 @@
1
+ import { createApiClient } from './api-client.js';
1
2
  import { CliCommandError } from './cli-error.js';
2
3
  import { chalksurfBaseUrlEnvVar, chalksurfOrganizationIdEnvVar, chalksurfTokenEnvVar, resolveBaseUrl, resolveToken, } from './config-store.js';
3
4
  const normalizeOptionalString = (value) => {
@@ -31,6 +32,32 @@ export const resolveRequestedOrganizationId = ({ config, env, fallbackValue, fla
31
32
  normalizeOptionalString(env[chalksurfOrganizationIdEnvVar]) ??
32
33
  normalizeOptionalString(config.organizationId));
33
34
  };
35
+ export const createResolvedApiClient = async ({ context, baseUrlFlagValue, organizationFallbackValue, organizationFlagValue, profileName, }) => {
36
+ const config = await context.configStore.loadProfile({ profileName });
37
+ const resolvedBaseUrl = requireResolvedBaseUrl({
38
+ flagValue: baseUrlFlagValue,
39
+ env: context.env,
40
+ config,
41
+ });
42
+ const resolvedToken = requireResolvedToken({
43
+ env: context.env,
44
+ config,
45
+ });
46
+ const organizationId = resolveRequestedOrganizationId({
47
+ flagValue: organizationFlagValue,
48
+ fallbackValue: organizationFallbackValue,
49
+ env: context.env,
50
+ config,
51
+ });
52
+ return {
53
+ apiClient: createApiClient({
54
+ baseUrl: resolvedBaseUrl.value,
55
+ token: resolvedToken.value,
56
+ organizationId,
57
+ }),
58
+ organizationId,
59
+ };
60
+ };
34
61
  export const formatUserIdentity = (profile) => {
35
62
  if (profile.name && profile.email) {
36
63
  return `${profile.name} <${profile.email}>`;
@@ -1 +1,16 @@
1
+ import { CliCommandError } from './cli-error.js';
1
2
  export const translationLanguages = ['english', 'hungarian', 'german', 'french', 'spanish', 'italian'];
3
+ export const resolveRequestedTranslateToLanguages = (rawValues) => {
4
+ if (!rawValues || rawValues.length === 0) {
5
+ return undefined;
6
+ }
7
+ const invalidLanguage = rawValues.find((value) => !translationLanguages.includes(value));
8
+ if (invalidLanguage) {
9
+ throw new CliCommandError(`--translate-to must be one of: ${translationLanguages.join(', ')}`, 2);
10
+ }
11
+ const languages = rawValues;
12
+ if (new Set(languages).size !== languages.length) {
13
+ throw new CliCommandError('--translate-to languages must be unique.', 2);
14
+ }
15
+ return languages;
16
+ };
@@ -14,7 +14,13 @@ export const serializeCliJob = (job) => {
14
14
  exerciseId: job.result?.exerciseId,
15
15
  exerciseIds: job.result?.exerciseIds,
16
16
  exerciseSheetId: job.result?.exerciseSheetId,
17
+ eligibleExerciseCount: job.result?.eligibleExerciseCount,
18
+ nonUpdatableExerciseCount: job.result?.nonUpdatableExerciseCount,
17
19
  resultCode: job.result?.resultCode,
20
+ skippedExerciseCount: job.result?.skippedExerciseCount,
21
+ translationJobs: job.result?.translationJobs,
22
+ unmatchedImportedSolutionCount: job.result?.unmatchedImportedSolutionCount,
23
+ updatedExerciseCount: job.result?.updatedExerciseCount,
18
24
  };
19
25
  };
20
26
  export const formatCliJobSummary = (job) => {
package/docs/agents.md CHANGED
@@ -4,34 +4,42 @@ Use this flow when ChalkSurf is driven by Codex, CI, or another orchestration la
4
4
 
5
5
  ## Authentication Strategy
6
6
 
7
- Prefer environment variables over interactive config:
7
+ Agents must always use an explicit profile. The profile name must follow the
8
+ `ENV-AGENT_TYPE` convention, such as `prod-codex`, `staging-codex`, `dev-claude`,
9
+ or `prod-claude`.
8
10
 
9
11
  ```bash
10
- export CHALKSURF_BASE_URL=https://api.chalksurf.com
11
- export CHALKSURF_TOKEN=cs_cli_...
12
- export CHALKSURF_ORGANIZATION_ID=org_123
12
+ chalksurf --profile prod-codex auth status --json
13
13
  ```
14
14
 
15
- Check the session in machine-readable mode:
15
+ Do not rely on the default profile in agent-driven runs. The default profile is
16
+ for the human operator's terminal workflow and may point at a different
17
+ environment, user token, or selected organization.
18
+
19
+ Create or refresh an agent profile explicitly:
16
20
 
17
21
  ```bash
18
- chalksurf auth status --json
22
+ printf '%s' "$CHALKSURF_TOKEN" | chalksurf auth login \
23
+ --profile prod-codex \
24
+ --base-url https://chalksurf-api.fly.dev \
25
+ --with-token
19
26
  ```
20
27
 
21
- Avoid `auth login` for short-lived runs unless you explicitly want local persistence.
28
+ Use `CHALKSURF_TOKEN` only as the source for login or as a short-lived override.
29
+ Prefer storing the token in the explicit agent profile for repeated Codex/CI runs.
22
30
 
23
31
  ## Command Contract
24
32
 
25
33
  For agent-driven imports, always use:
26
34
 
27
35
  - `--manifest -` or a generated manifest file
28
- - `--wait` when the next step depends on completed imports
36
+ - `--wait` when the next step depends on parsed content being saved
29
37
  - `--json` so the response stays machine-readable
30
38
 
31
39
  Recommended invocation shape:
32
40
 
33
41
  ```bash
34
- chalksurf sheet import --manifest - --wait --json
42
+ chalksurf --profile prod-codex sheet import --manifest - --wait --json
35
43
  ```
36
44
 
37
45
  In `--json` mode:
@@ -41,6 +49,7 @@ In `--json` mode:
41
49
  - stderr is reserved for unexpected runtime failures
42
50
 
43
51
  Wait-style failures still include a populated `result` payload, so agents can inspect partial outcomes on exit code `6` or `7`.
52
+ When an import requests translations, `--wait` waits for the import job only. Follow-up translation jobs are listed under each completed import job's `translationJobs`.
44
53
 
45
54
  ## Manifest Design
46
55
 
@@ -52,6 +61,8 @@ Why:
52
61
  - each logical source can carry a stable `sourceId`
53
62
  - sheet imports can group multiple source files into one resulting sheet
54
63
  - sheet imports carry `targetFolderPath`, `title`, and `translateTo` at the sheet level
64
+ - exercise imports can carry top-level `translateTo` for every imported exercise
65
+ - sheet solution imports can reconcile one or more solution files against all updatable exercises in one existing sheet
55
66
  - agents can correlate import results back to the discovered source set
56
67
 
57
68
  Use `sourceId` whenever a browsing step or upstream scraper already has a stable identifier:
@@ -90,6 +101,7 @@ Further reference:
90
101
  - [Sheet import schema](../schemas/sheet-import-manifest.schema.json)
91
102
  - [Exercise import schema](../schemas/exercise-import-manifest.schema.json)
92
103
  - [Exercise solution import schema](../schemas/exercise-solution-import-manifest.schema.json)
104
+ - [Exercise sheet solution import schema](../schemas/exercise-sheet-solution-import-manifest.schema.json)
93
105
 
94
106
  ## JSON Result Shape
95
107
 
@@ -112,6 +124,7 @@ Each job includes:
112
124
  - `sourceIndexes`
113
125
  - `sourceIds`
114
126
  - `status`
127
+ - `translationJobs` when requested translations were queued after import
115
128
 
116
129
  `summary` includes:
117
130
 
@@ -125,13 +138,14 @@ Each job includes:
125
138
  2. Filter them to the target scope.
126
139
  3. Build a manifest with stable `sourceId` values.
127
140
  4. Pipe the manifest into the CLI with `--wait --json`.
128
- 5. Inspect `ok`, `result.summary`, and `result.jobs`.
129
- 6. Retry only the failed or timed-out source set.
141
+ 5. Inspect `ok`, `result.summary`, `result.jobs`, and any nested `translationJobs`.
142
+ 6. Use `job list`, `job wait`, `sheet search`, and `exercise search` to verify the created resources or translation jobs when needed.
143
+ 7. Retry only the failed or timed-out source set.
130
144
 
131
145
  For example:
132
146
 
133
147
  ```bash
134
- cat import.json | chalksurf sheet import --manifest - --wait --json
148
+ cat import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json
135
149
  ```
136
150
 
137
151
  On success, the envelope looks like:
@@ -155,6 +169,54 @@ On success, the envelope looks like:
155
169
 
156
170
  On timeout or job failure, `ok` becomes `false`, `error.code` is stable, and `result` still contains the normalized import data needed for retries.
157
171
 
172
+ ## Verification Commands
173
+
174
+ Agents can inspect their own work without opening the web UI. Search commands default to `--ownership own`, which means the selected organization from `--organization`, `CHALKSURF_ORGANIZATION_ID`, or the active profile.
175
+
176
+ List recent jobs, including jobs the UI tray has already viewed or dismissed:
177
+
178
+ ```bash
179
+ chalksurf --profile prod-codex job list --include-viewed --include-dismissed --json
180
+ ```
181
+
182
+ Find failed import jobs for retry:
183
+
184
+ ```bash
185
+ chalksurf --profile prod-codex job list \
186
+ --status failed \
187
+ --type exercise_sheet_import \
188
+ --include-viewed \
189
+ --include-dismissed \
190
+ --json
191
+ ```
192
+
193
+ Verify an imported private sheet by title:
194
+
195
+ ```bash
196
+ chalksurf --profile prod-codex sheet search --text "OKTV 2014" --json
197
+ ```
198
+
199
+ Verify imported exercises by text. Text search requires an explicit language:
200
+
201
+ ```bash
202
+ chalksurf --profile prod-codex exercise search \
203
+ --text "binomial theorem" \
204
+ --language english \
205
+ --json
206
+ ```
207
+
208
+ Import solutions for an already imported sheet when the solution key is separate:
209
+
210
+ ```bash
211
+ chalksurf --profile prod-codex sheet import-solutions \
212
+ 00000000-0000-4000-8000-000000000001 \
213
+ ./solutions.pdf \
214
+ --wait \
215
+ --json
216
+ ```
217
+
218
+ Use `--ownership public` for public-library checks and `--ownership all` when the agent intentionally wants public results plus selected-organization results.
219
+
158
220
  ## Codex Workflow Example
159
221
 
160
222
  Target task:
@@ -168,13 +230,13 @@ Recommended workflow:
168
230
  3. Codex builds a sheet-import manifest with one `sheets[]` entry per resulting sheet.
169
231
  4. Codex groups multiple source files inside one `sources[]` array when they belong to the same resulting sheet.
170
232
  5. Codex sets `translateTo: ["english"]` on the sheet entries that should produce an English translation.
171
- 6. Codex runs `chalksurf sheet import --manifest - --wait --json`.
233
+ 6. Codex runs `chalksurf --profile prod-codex sheet import --manifest - --wait --json`.
172
234
  7. Codex inspects `result.summary.failed`, `result.summary.timedOut`, and `jobs` to decide whether to retry or report failures.
173
235
 
174
236
  Minimal shell shape:
175
237
 
176
238
  ```bash
177
- cat import.json | chalksurf sheet import --manifest - --wait --json
239
+ cat import.json | chalksurf --profile prod-codex sheet import --manifest - --wait --json
178
240
  ```
179
241
 
180
242
  The discovery step belongs outside the ChalkSurf CLI. The CLI contract starts at a concrete manifest and ends at structured import results.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "organizationId": "org_123",
3
3
  "exerciseSheetId": "00000000-0000-4000-8000-000000000111",
4
+ "translateTo": ["english"],
4
5
  "wait": true,
5
6
  "sources": [
6
7
  {
@@ -0,0 +1,13 @@
1
+ {
2
+ "organizationId": "org_123",
3
+ "exerciseSheetId": "00000000-0000-4000-8000-000000000333",
4
+ "wait": true,
5
+ "sources": [
6
+ {
7
+ "sourceId": "problem-set-solutions",
8
+ "kind": "local",
9
+ "path": "./imports/problem-set-solutions.pdf",
10
+ "relativePath": "Solutions/problem-set-solutions.pdf"
11
+ }
12
+ ]
13
+ }
package/docs/manifest.md CHANGED
@@ -7,6 +7,7 @@ One manifest file is passed to exactly one command:
7
7
  - `chalksurf sheet import --manifest <path|->`
8
8
  - `chalksurf exercise import --manifest <path|->`
9
9
  - `chalksurf exercise import-solution --manifest <path|->`
10
+ - `chalksurf sheet import-solutions --manifest <path|->`
10
11
 
11
12
  Use `--manifest -` to pipe JSON on stdin.
12
13
 
@@ -17,9 +18,9 @@ Every manifest is a JSON object with these shared metadata fields:
17
18
  | Field | Type | Required | Notes |
18
19
  | --- | --- | --- | --- |
19
20
  | `organizationId` | string | no | Overrides the default organization for this invocation. |
20
- | `wait` | boolean | no | Behaves like `--wait`. The CLI also accepts `--wait`, which wins if set. |
21
+ | `wait` | boolean | no | Behaves like `--wait`. The CLI also accepts `--wait`, which wins if set. Requested translations run as follow-up jobs listed in the import result. |
21
22
 
22
- Every source object supports these common fields. For `sheet import`, sources appear inside `sheets[].sources[]`. For the exercise import commands, they appear in top-level `sources[]`.
23
+ Every source object supports these common fields. For `sheet import`, sources appear inside `sheets[].sources[]`. For the exercise import commands and `sheet import-solutions`, they appear in top-level `sources[]`.
23
24
 
24
25
  | Field | Type | Required | Notes |
25
26
  | --- | --- | --- | --- |
@@ -85,11 +86,12 @@ Top-level fields:
85
86
  | --- | --- | --- | --- |
86
87
  | `sources` | array | yes | One or more import sources. |
87
88
  | `exerciseSheetId` | string | no | Default target sheet for the imported exercises. Can still be overridden by `--sheet-id`. |
89
+ | `translateTo` | string[] | no | Target translation languages for every imported exercise. Must be unique and non-empty when present. |
88
90
 
89
91
  Per-source fields:
90
92
 
91
93
  - Only the common source fields are allowed.
92
- - `title` and `translateTo` are rejected for this command.
94
+ - `title` and source-level `translateTo` are rejected for this command.
93
95
 
94
96
  Canonical example:
95
97
 
@@ -115,12 +117,32 @@ Canonical example:
115
117
  - [docs/examples/exercise-solution-import-manifest.json](./examples/exercise-solution-import-manifest.json)
116
118
  - [schemas/exercise-solution-import-manifest.schema.json](../schemas/exercise-solution-import-manifest.schema.json)
117
119
 
120
+ ### Exercise Sheet Solution Import
121
+
122
+ Top-level fields:
123
+
124
+ | Field | Type | Required | Notes |
125
+ | --- | --- | --- | --- |
126
+ | `sources` | array | yes | One or more source files containing solutions for some or all exercises in the target sheet. |
127
+ | `exerciseSheetId` | string | no | Default target sheet for the imported solution files. The command still requires a sheet id overall, either here or positionally. |
128
+
129
+ Per-source fields:
130
+
131
+ - Only the common source fields are allowed.
132
+ - `title` and `translateTo` are rejected for this command.
133
+
134
+ Canonical example:
135
+
136
+ - [docs/examples/exercise-sheet-solution-import-manifest.json](./examples/exercise-sheet-solution-import-manifest.json)
137
+ - [schemas/exercise-sheet-solution-import-manifest.schema.json](../schemas/exercise-sheet-solution-import-manifest.schema.json)
138
+
118
139
  ## Validation Notes
119
140
 
120
141
  The CLI validates more than the JSON schema can express on its own:
121
142
 
122
143
  - `sourceId` values must be unique within one manifest.
123
144
  - Sheet import `translateTo` values must be unique within one sheet.
145
+ - Exercise import top-level `translateTo` values must be unique.
124
146
  - `relativePath` cannot be empty or contain `..`.
125
147
  - Sheet import `targetFolderPath` must be `null` or a normalized folder path without `.` or `..` segments.
126
148
  - `directory` imports must resolve to at least one file.