@chalksurf/cli 0.2.2 → 0.2.3

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
@@ -1,21 +1,19 @@
1
1
  # `@chalksurf/cli`
2
2
 
3
- Publishable ChalkSurf CLI package.
4
-
5
- Current internal release line: `0.2.2`. Expect breaking changes while the CLI is still only used internally.
3
+ Publishable ChalkSurf CLI package. Expect breaking changes while the CLI is still only used internally.
6
4
 
7
5
  ## Installation
8
6
 
9
7
  Run the published CLI without a global install:
10
8
 
11
9
  ```bash
12
- npx @chalksurf/cli@0.2.2 --help
10
+ npx @chalksurf/cli@ --help
13
11
  ```
14
12
 
15
13
  Install it globally when you want a persistent local binary:
16
14
 
17
15
  ```bash
18
- npm install -g @chalksurf/cli@0.2.2
16
+ npm install -g @chalksurf/cli
19
17
  chalksurf --version
20
18
  ```
21
19
 
@@ -34,12 +32,12 @@ Headless or agent flow:
34
32
 
35
33
  ```bash
36
34
  CHALKSURF_TOKEN=cs_cli_... \
37
- printf '%s' "$CHALKSURF_TOKEN" | npx @chalksurf/cli@0.2.2 auth login \
35
+ printf '%s' "$CHALKSURF_TOKEN" | npx @chalksurf/cli auth login \
38
36
  --profile prod-codex \
39
37
  --base-url https://chalksurf-api.fly.dev \
40
38
  --with-token
41
39
 
42
- npx @chalksurf/cli@0.2.2 --profile prod-codex sheet import --manifest - --wait --json < import.json
40
+ npx @chalksurf/cli --profile prod-codex sheet import --manifest - --wait --json < import.json
43
41
  ```
44
42
 
45
43
  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).
@@ -60,7 +58,7 @@ Sheet import manifests use top-level `sheets[]`, where each sheet has one `targe
60
58
  Use separate profiles so local, staging, production, human, and agent tokens do not overwrite each other:
61
59
 
62
60
  ```bash
63
- npm run cli-dev -- auth login --profile dev-cztamas --base-url http://localhost:8080
61
+ npm run cli-dev -- auth login --profile dev-cztamas --base-url http://localhost:3101
64
62
  npm run cli-dev -- profile use dev-cztamas
65
63
  npm run cli-dev -- auth status --json
66
64
  npm run cli-dev -- sheet import ./fixtures/algebra.pdf --wait --json
@@ -10,6 +10,7 @@ import { cleanupResolvedSources, resolveSources } from '../lib/source-resolver.j
10
10
  import { resolveRequestedTranslateToLanguages, translationLanguages, } from '../lib/translation-languages.js';
11
11
  import { formatCliJobSummary, getWaitError, getWaitExitCode, throwSilentExitCode, waitForCliJobs, } from '../lib/user-jobs.js';
12
12
  const hasUniqueItems = (values) => new Set(values).size === values.length;
13
+ const sheetImportComponentIdPattern = /^[a-zA-Z0-9_-]{1,80}$/;
13
14
  const isValidTargetFolderPath = (value) => {
14
15
  const normalizedSegments = value
15
16
  .replaceAll('\\', '/')
@@ -31,6 +32,32 @@ const assertValidSheetImportPlan = (plan) => {
31
32
  if (!hasUniqueItems(sheet.sourceIndexes)) {
32
33
  throw new CliCommandError('Invalid sheet import plan: sourceIndexes must be unique within a sheet.', 2);
33
34
  }
35
+ if (sheet.components) {
36
+ if (sheet.targetFolderPath !== undefined || sheet.titleOverride !== undefined || sheet.translateToLanguages) {
37
+ throw new CliCommandError('Invalid sheet import plan: targetFolderPath, titleOverride, and translateToLanguages must be set on components when components are provided.', 2);
38
+ }
39
+ if (!hasUniqueItems(sheet.components.map((component) => component.componentId))) {
40
+ throw new CliCommandError('Invalid sheet import plan: componentId values must be unique within a sheet.', 2);
41
+ }
42
+ sheet.components.forEach((component) => {
43
+ if (!sheetImportComponentIdPattern.test(component.componentId)) {
44
+ throw new CliCommandError('Invalid sheet import plan: componentId must be 1-80 letters, numbers, underscores, or hyphens.', 2);
45
+ }
46
+ if (!component.description.trim()) {
47
+ throw new CliCommandError('Invalid sheet import plan: component descriptions must be non-empty.', 2);
48
+ }
49
+ if (component.targetFolderPath != null && !isValidTargetFolderPath(component.targetFolderPath)) {
50
+ throw new CliCommandError('Invalid sheet import plan: component targetFolderPath must be a normalized folder path without "." or ".." segments.', 2);
51
+ }
52
+ if (component.translateToLanguages && !hasUniqueItems(component.translateToLanguages)) {
53
+ throw new CliCommandError('Invalid sheet import plan: component translateToLanguages must be unique.', 2);
54
+ }
55
+ });
56
+ return;
57
+ }
58
+ if (sheet.targetFolderPath === undefined) {
59
+ throw new CliCommandError('Invalid sheet import plan: targetFolderPath is required.', 2);
60
+ }
34
61
  if (sheet.targetFolderPath !== null && !isValidTargetFolderPath(sheet.targetFolderPath)) {
35
62
  throw new CliCommandError('Invalid sheet import plan: targetFolderPath must be a normalized folder path without "." or ".." segments.', 2);
36
63
  }
@@ -132,9 +159,21 @@ const resolveGroupedSourceIndexes = ({ groups, sourceIndexesByInputIndex, }) =>
132
159
  });
133
160
  return {
134
161
  sourceIndexes,
135
- targetFolderPath: group.targetFolderPath,
136
- titleOverride: group.title,
137
- translateToLanguages: group.translateTo,
162
+ ...(group.components
163
+ ? {
164
+ components: group.components.map((component) => ({
165
+ componentId: component.componentId,
166
+ description: component.description,
167
+ targetFolderPath: component.targetFolderPath,
168
+ titleOverride: component.title,
169
+ translateToLanguages: component.translateTo,
170
+ })),
171
+ }
172
+ : {
173
+ targetFolderPath: group.targetFolderPath,
174
+ titleOverride: group.title,
175
+ translateToLanguages: group.translateTo,
176
+ }),
138
177
  };
139
178
  });
140
179
  };
@@ -173,6 +212,7 @@ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) =>
173
212
  return importResultJobs.map((job, index) => {
174
213
  const sourceIndexes = job.sourceIndexes ?? (sources[index] ? [sources[index].sourceIndex] : []);
175
214
  const waitedJob = waitedJobsById?.get(job.jobId);
215
+ const importedSheetTranslationJobs = waitedJob?.sheets?.flatMap((sheet) => (sheet.status === 'imported' ? (sheet.translationJobs ?? []) : [])) ?? [];
176
216
  return {
177
217
  jobId: job.jobId,
178
218
  sourceIndexes,
@@ -181,8 +221,10 @@ const buildSheetImportJobs = ({ importResultJobs, sources, waitedJobsById, }) =>
181
221
  .filter((sourceId) => sourceId != null))),
182
222
  status: waitedJob?.status ?? 'queued',
183
223
  error: waitedJob?.error,
224
+ componentIds: job.componentIds,
184
225
  exerciseSheetId: waitedJob?.exerciseSheetId,
185
- translationJobs: waitedJob?.translationJobs,
226
+ sheets: waitedJob?.sheets,
227
+ translationJobs: waitedJob?.translationJobs ?? importedSheetTranslationJobs,
186
228
  };
187
229
  });
188
230
  };
@@ -255,6 +297,7 @@ const formatWaitedImportOutput = ({ jobs, summary, }) => {
255
297
  updatedAt: '',
256
298
  error: job.error,
257
299
  exerciseSheetId: job.exerciseSheetId,
300
+ sheets: job.sheets,
258
301
  }),
259
302
  ...formatCliImportTranslationJobs(job.translationJobs),
260
303
  ]),
@@ -19,6 +19,7 @@ const translationLanguageListSchema = z
19
19
  .refine((languages) => new Set(languages).size === languages.length, {
20
20
  message: 'translateTo languages must be unique.',
21
21
  });
22
+ const sheetImportComponentIdPattern = /^[a-zA-Z0-9_-]{1,80}$/;
22
23
  const parseTranslateTo = ({ value, label }) => {
23
24
  if (value == null) {
24
25
  return undefined;
@@ -70,6 +71,52 @@ const parseTargetFolderPath = ({ value, label }) => {
70
71
  }
71
72
  return normalizedValue;
72
73
  };
74
+ const parseOptionalTargetFolderPath = ({ value, label }) => {
75
+ if (value == null) {
76
+ return null;
77
+ }
78
+ return parseTargetFolderPath({ value, label });
79
+ };
80
+ const parseSheetImportComponents = ({ value, sheetIndex, }) => {
81
+ if (value == null) {
82
+ return undefined;
83
+ }
84
+ if (!Array.isArray(value) || value.length === 0) {
85
+ throw new CliCommandError(`Manifest sheet at index ${sheetIndex} components must be a non-empty array.`, 2);
86
+ }
87
+ const seenComponentIds = new Set();
88
+ return value.map((componentValue, componentIndex) => {
89
+ const label = `Manifest sheet at index ${sheetIndex} component at index ${componentIndex}`;
90
+ if (!isObject(componentValue)) {
91
+ throw new CliCommandError(`${label} must be an object.`, 2);
92
+ }
93
+ const componentId = normalizeOptionalString(componentValue.componentId);
94
+ if (!componentId || !sheetImportComponentIdPattern.test(componentId)) {
95
+ throw new CliCommandError(`${label} must include a componentId using 1-80 letters, numbers, underscores, or hyphens.`, 2);
96
+ }
97
+ if (seenComponentIds.has(componentId)) {
98
+ throw new CliCommandError(`Manifest sheet at index ${sheetIndex} componentId "${componentId}" must be unique.`, 2);
99
+ }
100
+ seenComponentIds.add(componentId);
101
+ const description = normalizeOptionalString(componentValue.description);
102
+ if (!description) {
103
+ throw new CliCommandError(`${label} must include a non-empty description.`, 2);
104
+ }
105
+ return {
106
+ componentId,
107
+ description,
108
+ targetFolderPath: parseOptionalTargetFolderPath({
109
+ value: componentValue.targetFolderPath,
110
+ label,
111
+ }),
112
+ title: normalizeOptionalString(componentValue.title),
113
+ translateTo: parseTranslateTo({
114
+ value: componentValue.translateTo,
115
+ label,
116
+ }),
117
+ };
118
+ });
119
+ };
73
120
  const parseManifestMetadata = ({ parsedManifest, topLevelFields, }) => {
74
121
  const wait = typeof parsedManifest.wait === 'boolean' ? parsedManifest.wait : undefined;
75
122
  return {
@@ -215,6 +262,17 @@ const parseSheetImportManifest = (manifestText) => {
215
262
  sources.push(parsedSource);
216
263
  return flattenedSourceIndex;
217
264
  });
265
+ const components = parseSheetImportComponents({ value: sheetValue.components, sheetIndex });
266
+ if (components) {
267
+ if ('targetFolderPath' in sheetValue || 'title' in sheetValue || 'translateTo' in sheetValue) {
268
+ throw new CliCommandError(`Manifest sheet at index ${sheetIndex} must set targetFolderPath, title, and translateTo on components when components are provided.`, 2);
269
+ }
270
+ sheetGroups.push({
271
+ sourceInputIndexes,
272
+ components,
273
+ });
274
+ return;
275
+ }
218
276
  sheetGroups.push({
219
277
  sourceInputIndexes,
220
278
  targetFolderPath: parseTargetFolderPath({
@@ -17,6 +17,7 @@ export const serializeCliJob = (job) => {
17
17
  eligibleExerciseCount: job.result?.eligibleExerciseCount,
18
18
  nonUpdatableExerciseCount: job.result?.nonUpdatableExerciseCount,
19
19
  resultCode: job.result?.resultCode,
20
+ sheets: job.result?.sheets,
20
21
  skippedExerciseCount: job.result?.skippedExerciseCount,
21
22
  translationJobs: job.result?.translationJobs,
22
23
  unmatchedImportedSolutionCount: job.result?.unmatchedImportedSolutionCount,
@@ -28,6 +29,13 @@ export const formatCliJobSummary = (job) => {
28
29
  if (job.exerciseSheetId) {
29
30
  return `${job.id} completed -> ${job.exerciseSheetId}`;
30
31
  }
32
+ const importedSheets = job.sheets?.filter((sheet) => sheet.status === 'imported') ?? [];
33
+ if (importedSheets.length === 1) {
34
+ return `${job.id} completed -> ${importedSheets[0].exerciseSheetId}`;
35
+ }
36
+ if (importedSheets.length > 1) {
37
+ return `${job.id} completed -> ${importedSheets.length} sheets`;
38
+ }
31
39
  if (job.exerciseId) {
32
40
  return `${job.id} completed -> ${job.exerciseId}`;
33
41
  }
@@ -28,6 +28,30 @@
28
28
  "path": "./practice-sheet.pdf"
29
29
  }
30
30
  ]
31
+ },
32
+ {
33
+ "sources": [
34
+ {
35
+ "sourceId": "combined-archive-file",
36
+ "kind": "local",
37
+ "path": "./combined-archive-file.pdf"
38
+ }
39
+ ],
40
+ "components": [
41
+ {
42
+ "componentId": "round_2_part_a",
43
+ "description": "The exercise sheet labeled Round 2, Part A.",
44
+ "targetFolderPath": "OKTV/2014",
45
+ "title": "OKTV 2014 Round 2 Part A",
46
+ "translateTo": ["english"]
47
+ },
48
+ {
49
+ "componentId": "round_2_part_b",
50
+ "description": "The exercise sheet labeled Round 2, Part B.",
51
+ "targetFolderPath": "OKTV/2014",
52
+ "title": "OKTV 2014 Round 2 Part B"
53
+ }
54
+ ]
31
55
  }
32
56
  ]
33
57
  }
package/docs/manifest.md CHANGED
@@ -60,7 +60,7 @@ Top-level fields:
60
60
  | --- | --- | --- | --- |
61
61
  | `sheets` | array | yes | One or more sheets to import. Each sheet may contain one or more source files. |
62
62
 
63
- Sheet fields:
63
+ Normal sheet fields:
64
64
 
65
65
  | Field | Type | Required | Notes |
66
66
  | --- | --- | --- | --- |
@@ -73,6 +73,27 @@ Sheet import metadata is sheet-level, not source-level. Do not put `title` or `t
73
73
 
74
74
  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.
75
75
 
76
+ Declared-component sheet fields:
77
+
78
+ Use `components[]` when the same source group contains a known fixed set of component sheets. In this mode, do not put `targetFolderPath`, `title`, or `translateTo` on the sheet itself; put output metadata on each component.
79
+
80
+ | Field | Type | Required | Notes |
81
+ | --- | --- | --- | --- |
82
+ | `sources` | array | yes | One or more source files that contain the declared components. |
83
+ | `components` | array | yes | The explicitly requested component sheets to extract from the shared source group. |
84
+
85
+ Component fields:
86
+
87
+ | Field | Type | Required | Notes |
88
+ | --- | --- | --- | --- |
89
+ | `componentId` | string | yes | Stable key for this requested component. Must match `/^[a-zA-Z0-9_-]{1,80}$/` and be unique within the sheet group. |
90
+ | `description` | string | yes | Matching instructions that identify this component in the source group. |
91
+ | `targetFolderPath` | string \| null | no | Destination folder for this component. Omit or use `null` for the root folder. |
92
+ | `title` | string | no | Overrides the imported component sheet title. |
93
+ | `translateTo` | string[] | no | Target translation languages for this component. Must be unique and non-empty when present. |
94
+
95
+ The model must return an explicit `import` or `not_present` decision for every declared `componentId`. Jobs complete when at least one component is imported and fail when all declared components are `not_present`.
96
+
76
97
  Canonical example:
77
98
 
78
99
  - [docs/examples/sheet-import-manifest.json](./examples/sheet-import-manifest.json)
@@ -142,6 +163,8 @@ The CLI validates more than the JSON schema can express on its own:
142
163
 
143
164
  - `sourceId` values must be unique within one manifest.
144
165
  - Sheet import `translateTo` values must be unique within one sheet.
166
+ - Sheet import component `componentId` values must be unique within one sheet group.
167
+ - Sheet import components require non-empty descriptions.
145
168
  - Exercise import top-level `translateTo` values must be unique.
146
169
  - `relativePath` cannot be empty or contain `..`.
147
170
  - Sheet import `targetFolderPath` must be `null` or a normalized folder path without `.` or `..` segments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chalksurf/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -40,6 +40,37 @@
40
40
  "type": "string",
41
41
  "enum": ["english", "hungarian", "german", "french", "spanish", "italian"]
42
42
  },
43
+ "componentId": {
44
+ "type": "string",
45
+ "pattern": "^[a-zA-Z0-9_-]{1,80}$"
46
+ },
47
+ "component": {
48
+ "type": "object",
49
+ "additionalProperties": false,
50
+ "required": ["componentId", "description"],
51
+ "properties": {
52
+ "componentId": {
53
+ "$ref": "#/$defs/componentId"
54
+ },
55
+ "description": {
56
+ "$ref": "#/$defs/nonEmptyString"
57
+ },
58
+ "targetFolderPath": {
59
+ "$ref": "#/$defs/targetFolderPath"
60
+ },
61
+ "title": {
62
+ "$ref": "#/$defs/nonEmptyString"
63
+ },
64
+ "translateTo": {
65
+ "type": "array",
66
+ "minItems": 1,
67
+ "uniqueItems": true,
68
+ "items": {
69
+ "$ref": "#/$defs/translateLanguage"
70
+ }
71
+ }
72
+ }
73
+ },
43
74
  "localSource": {
44
75
  "type": "object",
45
76
  "additionalProperties": false,
@@ -111,32 +142,57 @@
111
142
  ]
112
143
  },
113
144
  "sheet": {
114
- "type": "object",
115
- "additionalProperties": false,
116
- "required": ["targetFolderPath", "sources"],
117
- "properties": {
118
- "targetFolderPath": {
119
- "$ref": "#/$defs/targetFolderPath"
120
- },
121
- "title": {
122
- "$ref": "#/$defs/nonEmptyString"
123
- },
124
- "translateTo": {
125
- "type": "array",
126
- "minItems": 1,
127
- "uniqueItems": true,
128
- "items": {
129
- "$ref": "#/$defs/translateLanguage"
145
+ "oneOf": [
146
+ {
147
+ "type": "object",
148
+ "additionalProperties": false,
149
+ "required": ["targetFolderPath", "sources"],
150
+ "properties": {
151
+ "targetFolderPath": {
152
+ "$ref": "#/$defs/targetFolderPath"
153
+ },
154
+ "title": {
155
+ "$ref": "#/$defs/nonEmptyString"
156
+ },
157
+ "translateTo": {
158
+ "type": "array",
159
+ "minItems": 1,
160
+ "uniqueItems": true,
161
+ "items": {
162
+ "$ref": "#/$defs/translateLanguage"
163
+ }
164
+ },
165
+ "sources": {
166
+ "type": "array",
167
+ "minItems": 1,
168
+ "items": {
169
+ "$ref": "#/$defs/source"
170
+ }
171
+ }
130
172
  }
131
173
  },
132
- "sources": {
133
- "type": "array",
134
- "minItems": 1,
135
- "items": {
136
- "$ref": "#/$defs/source"
174
+ {
175
+ "type": "object",
176
+ "additionalProperties": false,
177
+ "required": ["sources", "components"],
178
+ "properties": {
179
+ "sources": {
180
+ "type": "array",
181
+ "minItems": 1,
182
+ "items": {
183
+ "$ref": "#/$defs/source"
184
+ }
185
+ },
186
+ "components": {
187
+ "type": "array",
188
+ "minItems": 1,
189
+ "items": {
190
+ "$ref": "#/$defs/component"
191
+ }
192
+ }
137
193
  }
138
194
  }
139
- }
195
+ ]
140
196
  }
141
197
  }
142
198
  }