@chalksurf/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { createWriteStream } from 'node:fs';
2
- import { mkdtemp, readdir, rm, stat } from 'node:fs/promises';
2
+ import { mkdtemp, open, readdir, rm, stat } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, isAbsolute, relative, resolve } from 'node:path';
5
5
  import { Readable } from 'node:stream';
@@ -8,6 +8,8 @@ import pLimit from 'p-limit';
8
8
  import { CliCommandError } from './cli-error.js';
9
9
  const sourceResolutionExitCode = 4;
10
10
  const defaultMaxConcurrentUrlDownloads = 4;
11
+ const htmlSniffBytes = 1024;
12
+ const genericMimeTypes = new Set(['application/octet-stream', 'binary/octet-stream']);
11
13
  const createSourceResolutionError = (message) => {
12
14
  return new CliCommandError(message, sourceResolutionExitCode);
13
15
  };
@@ -85,7 +87,44 @@ const deriveRelativePathFromUrl = (url) => {
85
87
  }
86
88
  return fileName;
87
89
  };
88
- const resolveLocalFileSource = async ({ source, cwd, }) => {
90
+ const normalizeMimeType = (contentTypeHeaderValue) => {
91
+ const mimeType = contentTypeHeaderValue?.split(';', 1)[0]?.trim().toLowerCase();
92
+ return mimeType || undefined;
93
+ };
94
+ const stripLeadingHtmlComments = (value) => {
95
+ let normalizedValue = value.trimStart();
96
+ while (normalizedValue.startsWith('<!--')) {
97
+ const commentEndIndex = normalizedValue.indexOf('-->');
98
+ if (commentEndIndex === -1) {
99
+ return normalizedValue;
100
+ }
101
+ normalizedValue = normalizedValue.slice(commentEndIndex + 3).trimStart();
102
+ }
103
+ return normalizedValue;
104
+ };
105
+ const looksLikeHtmlDocument = (value) => {
106
+ const normalizedValue = stripLeadingHtmlComments(value.replace(/^\uFEFF/, ''));
107
+ return /^<(?:!doctype html|html|head|body)\b/i.test(normalizedValue);
108
+ };
109
+ const sniffMimeType = async ({ filePath, responseMimeType }) => {
110
+ if (responseMimeType && !genericMimeTypes.has(responseMimeType)) {
111
+ return responseMimeType;
112
+ }
113
+ const fileHandle = await open(filePath, 'r');
114
+ try {
115
+ const buffer = Buffer.alloc(htmlSniffBytes);
116
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0);
117
+ const filePrefix = buffer.subarray(0, bytesRead).toString('utf8');
118
+ if (looksLikeHtmlDocument(filePrefix)) {
119
+ return 'text/html';
120
+ }
121
+ return responseMimeType;
122
+ }
123
+ finally {
124
+ await fileHandle.close();
125
+ }
126
+ };
127
+ const resolveLocalFileSource = async ({ source, cwd, sourceInputIndex, }) => {
89
128
  const resolvedPath = resolve(cwd, source.path);
90
129
  await ensureExistingFile(resolvedPath, 'Local source');
91
130
  const relativePath = normalizeRelativePath(source.relativePath ?? basename(resolvedPath));
@@ -96,10 +135,12 @@ const resolveLocalFileSource = async ({ source, cwd, }) => {
96
135
  filePath: resolvedPath,
97
136
  fileName: basename(relativePath),
98
137
  relativePath,
138
+ sourceInputIndex,
139
+ sourceInput: source,
99
140
  },
100
141
  ];
101
142
  };
102
- const resolveDirectorySource = async ({ source, cwd, }) => {
143
+ const resolveDirectorySource = async ({ source, cwd, sourceInputIndex, }) => {
103
144
  const resolvedDirectoryPath = resolve(cwd, source.path);
104
145
  const resolvedRelativeRoot = resolve(cwd, source.relativeRoot ?? source.path);
105
146
  await ensureExistingDirectory(resolvedDirectoryPath, 'Directory source');
@@ -119,10 +160,12 @@ const resolveDirectorySource = async ({ source, cwd, }) => {
119
160
  filePath,
120
161
  fileName: basename(relativePath),
121
162
  relativePath,
163
+ sourceInputIndex,
164
+ sourceInput: source,
122
165
  };
123
166
  });
124
167
  };
125
- const resolveUrlSource = async ({ source, fetchImpl, }) => {
168
+ const resolveUrlSource = async ({ source, sourceInputIndex, }) => {
126
169
  let parsedUrl;
127
170
  try {
128
171
  parsedUrl = new URL(source.url);
@@ -137,22 +180,30 @@ const resolveUrlSource = async ({ source, fetchImpl, }) => {
137
180
  const downloadDirectoryPath = await mkdtemp(resolve(tmpdir(), 'chalksurf-cli-source-'));
138
181
  const downloadedFilePath = resolve(downloadDirectoryPath, basename(relativePath));
139
182
  try {
140
- const response = await fetchImpl(source.url);
183
+ const response = await fetch(source.url);
141
184
  if (!response.ok) {
142
185
  throw createSourceResolutionError(`Failed to download "${source.url}": HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`);
143
186
  }
144
187
  if (!response.body) {
145
188
  throw createSourceResolutionError(`Failed to download "${source.url}": response body was empty.`);
146
189
  }
190
+ const responseMimeType = normalizeMimeType(response.headers.get('content-type'));
147
191
  await pipeline(Readable.fromWeb(response.body), createWriteStream(downloadedFilePath));
192
+ const mimeType = await sniffMimeType({
193
+ filePath: downloadedFilePath,
194
+ responseMimeType,
195
+ });
148
196
  return [
149
197
  {
150
198
  kind: 'url',
151
199
  input: source.url,
152
200
  filePath: downloadedFilePath,
153
201
  fileName: basename(relativePath),
202
+ mimeType,
154
203
  relativePath,
204
+ sourceInputIndex,
155
205
  cleanupPath: downloadDirectoryPath,
206
+ sourceInput: source,
156
207
  },
157
208
  ];
158
209
  }
@@ -182,16 +233,27 @@ export const cleanupResolvedSources = async (resolvedSources) => {
182
233
  await rm(cleanupPath, { force: true, recursive: true });
183
234
  }));
184
235
  };
185
- export const resolveSources = async ({ sources, cwd = process.cwd(), fetchImpl = fetch, maxConcurrentUrlDownloads = defaultMaxConcurrentUrlDownloads, }) => {
236
+ export const resolveSources = async ({ sources, cwd = process.cwd(), maxConcurrentUrlDownloads = defaultMaxConcurrentUrlDownloads, }) => {
186
237
  const limitUrlDownloads = pLimit(Math.max(1, maxConcurrentUrlDownloads));
187
- const settledSourceGroups = await Promise.allSettled(sources.map(async (source) => {
238
+ const settledSourceGroups = await Promise.allSettled(sources.map(async (source, sourceInputIndex) => {
188
239
  if (source.kind === 'local') {
189
- return await resolveLocalFileSource({ source, cwd });
240
+ return await resolveLocalFileSource({
241
+ source: source,
242
+ cwd,
243
+ sourceInputIndex,
244
+ });
190
245
  }
191
246
  if (source.kind === 'directory') {
192
- return await resolveDirectorySource({ source, cwd });
247
+ return await resolveDirectorySource({
248
+ source: source,
249
+ cwd,
250
+ sourceInputIndex,
251
+ });
193
252
  }
194
- return await limitUrlDownloads(() => resolveUrlSource({ source, fetchImpl }));
253
+ return await limitUrlDownloads(() => resolveUrlSource({
254
+ source: source,
255
+ sourceInputIndex,
256
+ }));
195
257
  }));
196
258
  const resolvedSources = settledSourceGroups.flatMap((result) => (result.status === 'fulfilled' ? result.value : []));
197
259
  const firstRejectedResult = settledSourceGroups.find((result) => result.status === 'rejected');
@@ -0,0 +1 @@
1
+ export const translationLanguages = ['english', 'hungarian', 'german', 'french', 'spanish', 'italian'];
@@ -1,4 +1,4 @@
1
- import { CliCommandError } from './cli-error.js';
1
+ import { CliCommandError, createSerializableCliError } from './cli-error.js';
2
2
  import { mapApiErrorToCliError } from './session.js';
3
3
  const isTerminalJob = (job) => {
4
4
  return ['completed', 'failed'].includes(job.status);
@@ -41,6 +41,21 @@ export const getWaitExitCode = ({ jobs, timedOut }) => {
41
41
  }
42
42
  return 0;
43
43
  };
44
+ export const getWaitError = ({ jobs, timedOut }) => {
45
+ if (timedOut) {
46
+ return createSerializableCliError({
47
+ exitCode: 6,
48
+ message: 'Timed out before all jobs completed.',
49
+ });
50
+ }
51
+ if (jobs.some((job) => job.status === 'failed')) {
52
+ return createSerializableCliError({
53
+ exitCode: 7,
54
+ message: 'One or more jobs failed.',
55
+ });
56
+ }
57
+ return undefined;
58
+ };
44
59
  export const waitForCliJobs = async ({ getUserJob, jobIds, maxPollIntervalMs = 5000, now, sleep, timeoutMs = 300000, }) => {
45
60
  const startedAt = now();
46
61
  let nextPollDelayMs = 1000;
package/docs/agents.md ADDED
@@ -0,0 +1,195 @@
1
+ # Agent And Codex Guide
2
+
3
+ Use this flow when ChalkSurf is driven by Codex, CI, or another orchestration layer.
4
+
5
+ ## Authentication Strategy
6
+
7
+ Prefer environment variables over interactive config:
8
+
9
+ ```bash
10
+ export CHALKSURF_BASE_URL=https://api.chalksurf.com
11
+ export CHALKSURF_TOKEN=cs_cli_...
12
+ export CHALKSURF_ORGANIZATION_ID=org_123
13
+ ```
14
+
15
+ Check the session in machine-readable mode:
16
+
17
+ ```bash
18
+ chalksurf auth status --json
19
+ ```
20
+
21
+ Avoid `auth login` for short-lived runs unless you explicitly want local persistence.
22
+
23
+ ## Command Contract
24
+
25
+ For agent-driven imports, always use:
26
+
27
+ - `--manifest -` or a generated manifest file
28
+ - `--wait` when the next step depends on completed imports
29
+ - `--json` so the response stays machine-readable
30
+
31
+ Recommended invocation shape:
32
+
33
+ ```bash
34
+ chalksurf sheet import --manifest - --wait --json
35
+ ```
36
+
37
+ In `--json` mode:
38
+
39
+ - stdout contains a stable JSON envelope
40
+ - known failures still produce JSON on stdout
41
+ - stderr is reserved for unexpected runtime failures
42
+
43
+ Wait-style failures still include a populated `result` payload, so agents can inspect partial outcomes on exit code `6` or `7`.
44
+
45
+ ## Manifest Design
46
+
47
+ Prefer manifests over large positional argument lists.
48
+
49
+ Why:
50
+
51
+ - multi-source imports stay explicit
52
+ - each logical source can carry a stable `sourceId`
53
+ - sheet imports can group multiple source files into one resulting sheet
54
+ - sheet imports carry `targetFolderPath`, `title`, and `translateTo` at the sheet level
55
+ - agents can correlate import results back to the discovered source set
56
+
57
+ Use `sourceId` whenever a browsing step or upstream scraper already has a stable identifier:
58
+
59
+ ```json
60
+ {
61
+ "organizationId": "org_123",
62
+ "wait": true,
63
+ "sheets": [
64
+ {
65
+ "targetFolderPath": "OKTV/2014",
66
+ "title": "OKTV 2014 Round 1",
67
+ "translateTo": ["english"],
68
+ "sources": [
69
+ {
70
+ "sourceId": "oktv-2014-round-1-part-a",
71
+ "kind": "url",
72
+ "url": "https://example.com/oktv-2014-round-1-part-a.pdf"
73
+ },
74
+ {
75
+ "sourceId": "oktv-2014-round-1-part-b",
76
+ "kind": "url",
77
+ "url": "https://example.com/oktv-2014-round-1-part-b.pdf"
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
83
+ ```
84
+
85
+ For sheet imports, `targetFolderPath` is the destination of the resulting sheet. Use `null` for the root folder. Source-level `relativePath` is only a filename or source-label override and should not be used as a destination path.
86
+
87
+ Further reference:
88
+
89
+ - [Manifest reference](./manifest.md)
90
+ - [Sheet import schema](../schemas/sheet-import-manifest.schema.json)
91
+ - [Exercise import schema](../schemas/exercise-import-manifest.schema.json)
92
+ - [Exercise solution import schema](../schemas/exercise-solution-import-manifest.schema.json)
93
+
94
+ ## JSON Result Shape
95
+
96
+ Import commands normalize results around:
97
+
98
+ - `request`
99
+ - `sources`
100
+ - `jobs`
101
+ - `summary`
102
+
103
+ Each resolved source includes stable correlation fields:
104
+
105
+ - `sourceIndex`
106
+ - `sourceInputIndex`
107
+ - `sourceId`
108
+
109
+ Each job includes:
110
+
111
+ - `jobId`
112
+ - `sourceIndexes`
113
+ - `sourceIds`
114
+ - `status`
115
+
116
+ `summary` includes:
117
+
118
+ - source and job counts
119
+ - per-status counts
120
+ - `timedOut`
121
+
122
+ ## Recommended Control Flow
123
+
124
+ 1. Discover candidate URLs or local files.
125
+ 2. Filter them to the target scope.
126
+ 3. Build a manifest with stable `sourceId` values.
127
+ 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.
130
+
131
+ For example:
132
+
133
+ ```bash
134
+ cat import.json | chalksurf sheet import --manifest - --wait --json
135
+ ```
136
+
137
+ On success, the envelope looks like:
138
+
139
+ ```json
140
+ {
141
+ "schemaVersion": "v1",
142
+ "command": "sheet import",
143
+ "ok": true,
144
+ "result": {
145
+ "request": {},
146
+ "sources": [],
147
+ "jobs": [],
148
+ "summary": {
149
+ "timedOut": false
150
+ }
151
+ },
152
+ "warnings": []
153
+ }
154
+ ```
155
+
156
+ On timeout or job failure, `ok` becomes `false`, `error.code` is stable, and `result` still contains the normalized import data needed for retries.
157
+
158
+ ## Codex Workflow Example
159
+
160
+ Target task:
161
+
162
+ Import all OKTV exercise sheets between 2010 and 2020 from an archive page, and translate the non-English sheets to English.
163
+
164
+ Recommended workflow:
165
+
166
+ 1. Codex browses the archive page and collects the relevant links.
167
+ 2. Codex filters the links to years `2010` through `2020`.
168
+ 3. Codex builds a sheet-import manifest with one `sheets[]` entry per resulting sheet.
169
+ 4. Codex groups multiple source files inside one `sources[]` array when they belong to the same resulting sheet.
170
+ 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`.
172
+ 7. Codex inspects `result.summary.failed`, `result.summary.timedOut`, and `jobs` to decide whether to retry or report failures.
173
+
174
+ Minimal shell shape:
175
+
176
+ ```bash
177
+ cat import.json | chalksurf sheet import --manifest - --wait --json
178
+ ```
179
+
180
+ The discovery step belongs outside the ChalkSurf CLI. The CLI contract starts at a concrete manifest and ends at structured import results.
181
+
182
+ ## Failure Handling
183
+
184
+ Use the exit code and JSON `error.code` together:
185
+
186
+ - `2` / `usage_error`: the manifest or command input is invalid
187
+ - `3` / `not_authenticated`: missing or invalid token
188
+ - `4` / `source_resolution_failed`: local file, directory, or URL preparation failed
189
+ - `5` / `api_error`: the ChalkSurf API rejected the request
190
+ - `6` / `wait_timed_out`: jobs did not finish in time
191
+ - `7` / `job_failed`: one or more jobs finished with failure
192
+
193
+ Reference:
194
+
195
+ - [Exit codes and JSON errors](./exit-codes.md)
@@ -0,0 +1,13 @@
1
+ {
2
+ "organizationId": "org_123",
3
+ "exerciseSheetId": "00000000-0000-4000-8000-000000000111",
4
+ "wait": true,
5
+ "sources": [
6
+ {
7
+ "sourceId": "problem-1",
8
+ "kind": "local",
9
+ "path": "./imports/problem-1.pdf",
10
+ "relativePath": "Problem Set/problem-1.pdf"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "organizationId": "org_123",
3
+ "exerciseId": "00000000-0000-4000-8000-000000000222",
4
+ "wait": true,
5
+ "sources": [
6
+ {
7
+ "sourceId": "problem-1-solution",
8
+ "kind": "local",
9
+ "path": "./imports/problem-1-solution.pdf",
10
+ "relativePath": "Solutions/problem-1.pdf"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "organizationId": "org_123",
3
+ "wait": true,
4
+ "sheets": [
5
+ {
6
+ "targetFolderPath": "OKTV/2014",
7
+ "title": "OKTV 2014 Round 1",
8
+ "translateTo": ["english"],
9
+ "sources": [
10
+ {
11
+ "sourceId": "oktv-2014-round-1-part-a",
12
+ "kind": "url",
13
+ "url": "https://example.com/oktv-2014-round-1-part-a.pdf"
14
+ },
15
+ {
16
+ "sourceId": "oktv-2014-round-1-part-b",
17
+ "kind": "url",
18
+ "url": "https://example.com/oktv-2014-round-1-part-b.pdf"
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "targetFolderPath": null,
24
+ "sources": [
25
+ {
26
+ "sourceId": "practice-sheet",
27
+ "kind": "local",
28
+ "path": "./practice-sheet.pdf"
29
+ }
30
+ ]
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,57 @@
1
+ # Exit Codes And JSON Errors
2
+
3
+ The CLI uses stable exit codes so automation can branch without parsing human text.
4
+
5
+ ## Exit Codes
6
+
7
+ | Exit code | Error code | Meaning |
8
+ | --- | --- | --- |
9
+ | `0` | n/a | Success |
10
+ | `1` | `unexpected_error` | Unhandled runtime failure |
11
+ | `2` | `usage_error` | Invalid arguments, invalid manifest, or missing required config such as `--base-url` |
12
+ | `3` | `not_authenticated` | Missing token or rejected CLI token |
13
+ | `4` | `source_resolution_failed` | Local file, directory, URL, or relative path resolution failed |
14
+ | `5` | `api_error` | The ChalkSurf API returned a non-auth failure |
15
+ | `6` | `wait_timed_out` | Waiting ended before all jobs reached a terminal state |
16
+ | `7` | `job_failed` | One or more jobs completed with failure |
17
+
18
+ These same codes are mirrored in `error.exitCode` and `error.code` inside the JSON envelope.
19
+
20
+ ## JSON Envelope
21
+
22
+ In `--json` mode, stdout contains a stable envelope:
23
+
24
+ ```json
25
+ {
26
+ "schemaVersion": "v1",
27
+ "command": "sheet import",
28
+ "ok": true,
29
+ "result": {},
30
+ "warnings": []
31
+ }
32
+ ```
33
+
34
+ Known failures also return JSON on stdout:
35
+
36
+ ```json
37
+ {
38
+ "schemaVersion": "v1",
39
+ "command": "sheet import",
40
+ "ok": false,
41
+ "result": {},
42
+ "error": {
43
+ "code": "source_resolution_failed",
44
+ "message": "Local source \"/tmp/imports/missing.pdf\" does not exist.",
45
+ "exitCode": 4,
46
+ "retryable": false
47
+ },
48
+ "warnings": []
49
+ }
50
+ ```
51
+
52
+ ## Contract Notes
53
+
54
+ - `warnings` contains informational messages that would otherwise be printed for humans.
55
+ - In `--json` mode, stderr is reserved for unexpected runtime failures only.
56
+ - Wait-style commands and imports keep their `result` payload even when `ok` is `false`.
57
+ - Import commands normalize their `result` payloads around `request`, `sources`, `jobs`, and `summary`.
@@ -0,0 +1,129 @@
1
+ # Manifest Reference
2
+
3
+ ChalkSurf uses JSON manifests for batch and agent-driven imports.
4
+
5
+ One manifest file is passed to exactly one command:
6
+
7
+ - `chalksurf sheet import --manifest <path|->`
8
+ - `chalksurf exercise import --manifest <path|->`
9
+ - `chalksurf exercise import-solution --manifest <path|->`
10
+
11
+ Use `--manifest -` to pipe JSON on stdin.
12
+
13
+ ## Common Fields
14
+
15
+ Every manifest is a JSON object with these shared metadata fields:
16
+
17
+ | Field | Type | Required | Notes |
18
+ | --- | --- | --- | --- |
19
+ | `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
+
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
+
24
+ | Field | Type | Required | Notes |
25
+ | --- | --- | --- | --- |
26
+ | `sourceId` | string | no | Optional stable identifier for correlating source inputs to results. Must be unique within the manifest. |
27
+ | `kind` | `local` \| `directory` \| `url` | yes | Selects the source shape. |
28
+
29
+ ### Source Kinds
30
+
31
+ `local`
32
+
33
+ | Field | Type | Required | Notes |
34
+ | --- | --- | --- | --- |
35
+ | `path` | string | yes | Local file path relative to `cwd` unless already absolute. |
36
+ | `relativePath` | string | no | Stored path shown to ChalkSurf. Defaults to the basename of `path`. |
37
+
38
+ `directory`
39
+
40
+ | Field | Type | Required | Notes |
41
+ | --- | --- | --- | --- |
42
+ | `path` | string | yes | Directory to expand recursively. |
43
+ | `relativeRoot` | string | no | Base directory used to compute stored relative paths. Defaults to `path`. |
44
+
45
+ `url`
46
+
47
+ | Field | Type | Required | Notes |
48
+ | --- | --- | --- | --- |
49
+ | `url` | string | yes | HTTP(S) URL to download before import. |
50
+ | `relativePath` | string | no | Stored path shown to ChalkSurf. Defaults to the filename derived from the URL. |
51
+
52
+ ## Command-Specific Fields
53
+
54
+ ### Sheet Import
55
+
56
+ Top-level fields:
57
+
58
+ | Field | Type | Required | Notes |
59
+ | --- | --- | --- | --- |
60
+ | `sheets` | array | yes | One or more sheets to import. Each sheet may contain one or more source files. |
61
+
62
+ Sheet fields:
63
+
64
+ | Field | Type | Required | Notes |
65
+ | --- | --- | --- | --- |
66
+ | `targetFolderPath` | string \| null | yes | Destination folder for the resulting sheet. Use `null` for the root folder. |
67
+ | `sources` | array | yes | One or more source files that belong to this sheet, in import order. |
68
+ | `title` | string | no | Overrides the imported sheet title. |
69
+ | `translateTo` | string[] | no | Target translation languages. Must be unique and non-empty when present. |
70
+
71
+ Sheet import metadata is sheet-level, not source-level. Do not put `title` or `translateTo` on individual sources inside `sheets[].sources[]`.
72
+
73
+ Source-level `relativePath` is only a source label or filename override. It does not choose the destination folder for grouped sheet imports; `targetFolderPath` is the only destination field.
74
+
75
+ Canonical example:
76
+
77
+ - [docs/examples/sheet-import-manifest.json](./examples/sheet-import-manifest.json)
78
+ - [schemas/sheet-import-manifest.schema.json](../schemas/sheet-import-manifest.schema.json)
79
+
80
+ ### Exercise Import
81
+
82
+ Top-level fields:
83
+
84
+ | Field | Type | Required | Notes |
85
+ | --- | --- | --- | --- |
86
+ | `sources` | array | yes | One or more import sources. |
87
+ | `exerciseSheetId` | string | no | Default target sheet for the imported exercises. Can still be overridden by `--sheet-id`. |
88
+
89
+ Per-source fields:
90
+
91
+ - Only the common source fields are allowed.
92
+ - `title` and `translateTo` are rejected for this command.
93
+
94
+ Canonical example:
95
+
96
+ - [docs/examples/exercise-import-manifest.json](./examples/exercise-import-manifest.json)
97
+ - [schemas/exercise-import-manifest.schema.json](../schemas/exercise-import-manifest.schema.json)
98
+
99
+ ### Exercise Solution Import
100
+
101
+ Top-level fields:
102
+
103
+ | Field | Type | Required | Notes |
104
+ | --- | --- | --- | --- |
105
+ | `sources` | array | yes | One or more import sources. |
106
+ | `exerciseId` | string | no | Default target exercise for the imported solution files. The command still requires an exercise id overall, either here or positionally. |
107
+
108
+ Per-source fields:
109
+
110
+ - Only the common source fields are allowed.
111
+ - `title` and `translateTo` are rejected for this command.
112
+
113
+ Canonical example:
114
+
115
+ - [docs/examples/exercise-solution-import-manifest.json](./examples/exercise-solution-import-manifest.json)
116
+ - [schemas/exercise-solution-import-manifest.schema.json](../schemas/exercise-solution-import-manifest.schema.json)
117
+
118
+ ## Validation Notes
119
+
120
+ The CLI validates more than the JSON schema can express on its own:
121
+
122
+ - `sourceId` values must be unique within one manifest.
123
+ - Sheet import `translateTo` values must be unique within one sheet.
124
+ - `relativePath` cannot be empty or contain `..`.
125
+ - Sheet import `targetFolderPath` must be `null` or a normalized folder path without `.` or `..` segments.
126
+ - `directory` imports must resolve to at least one file.
127
+ - URL imports must use `http` or `https`.
128
+
129
+ Treat the schema files as a reference format and the CLI as the final validator.