@toloka-platform/sdk 0.2.0-preview.2 → 0.2.0-preview.4

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.
Files changed (110) hide show
  1. package/README.md +10 -4
  2. package/dist/cli/toloka.js +162 -154
  3. package/dist/cli/toloka.js.jsc +0 -0
  4. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  5. package/dist/cli/toloka.js.sha256 +1 -1
  6. package/docs/authoring.md +18 -1
  7. package/docs/cli-v2.md +6 -4
  8. package/docs/cli.md +58 -11
  9. package/docs/getting-started.md +3 -3
  10. package/docs/limitations.md +1 -1
  11. package/docs/testing.md +1 -1
  12. package/docs/troubleshooting.md +1 -1
  13. package/examples/face-image-collection/README.md +2 -2
  14. package/openapi/gen/api.ts +78 -0
  15. package/openapi/gen/models/baseModel.ts +1 -0
  16. package/openapi/gen/models/checkReport.ts +2 -1
  17. package/openapi/gen/models/checkReportAnyOfTwoseven.ts +5 -0
  18. package/openapi/gen/models/checkReportAnyOfTwosevenResultsItem.ts +6 -0
  19. package/openapi/gen/models/checkReportAnyOfTwosevenResultsItemVerdict.ts +2 -0
  20. package/openapi/gen/models/currentUser.ts +3 -0
  21. package/openapi/gen/models/currentUserTeamsItem.ts +6 -0
  22. package/openapi/gen/models/deliveryOrder.ts +9 -0
  23. package/openapi/gen/models/deliveryOrderClientName.ts +2 -0
  24. package/openapi/gen/models/getTrainingRunMetricHistoryParams.ts +4 -0
  25. package/openapi/gen/models/gistingTrainingConfiguration.ts +6 -2
  26. package/openapi/gen/models/gistingTrainingConfigurationBatchSize.ts +2 -0
  27. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +2 -0
  28. package/openapi/gen/models/gistingTrainingConfigurationInput.ts +6 -2
  29. package/openapi/gen/models/gistingTrainingConfigurationInputBatchSize.ts +2 -0
  30. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +2 -0
  31. package/openapi/gen/models/index.ts +28 -4
  32. package/openapi/gen/models/listDeliveryOrders200.ts +5 -0
  33. package/openapi/gen/models/sftTrainingConfiguration.ts +6 -3
  34. package/openapi/gen/models/sftTrainingConfigurationBatchSize.ts +2 -0
  35. package/openapi/gen/models/sftTrainingConfigurationInput.ts +6 -3
  36. package/openapi/gen/models/sftTrainingConfigurationInputBatchSize.ts +2 -0
  37. package/openapi/gen/models/sftTrainingConfigurationInputLoraRank.ts +2 -0
  38. package/openapi/gen/models/sftTrainingConfigurationLoraRank.ts +2 -0
  39. package/openapi/gen/models/trainingRunMetricHistory.ts +9 -0
  40. package/openapi/gen/models/trainingRunMetricHistoryMetric.ts +2 -0
  41. package/openapi/gen/models/trainingRunMetricHistoryPointsItem.ts +9 -0
  42. package/openapi/gen/models/trainingRunMetricHistoryPointsItemNonFinite.ts +2 -0
  43. package/openapi/gen/models/trainingRunMetricHistoryPointsItemValue.ts +2 -0
  44. package/openapi/gen/models/trainingRunMetrics.ts +9 -0
  45. package/openapi/gen/models/trainingRunMetricsMetricsItem.ts +8 -0
  46. package/openapi/gen/models/trainingRunMetricsMetricsItemLatest.ts +9 -0
  47. package/openapi/gen/models/trainingRunMetricsMetricsItemLatestNonFinite.ts +2 -0
  48. package/openapi/gen/models/trainingRunMetricsMetricsItemLatestValue.ts +2 -0
  49. package/openapi/gen/models/trainingRunMetricsMetricsItemMetric.ts +2 -0
  50. package/openapi/gen/models/trainingRunMetricsUnavailableReason.ts +2 -0
  51. package/package.json +2 -2
  52. package/skills/catalog.json +55 -7
  53. package/skills/toloka-build/SKILL.md +35 -21
  54. package/skills/toloka-build/references/node-semantics.md +13 -11
  55. package/skills/toloka-build/references/workflow-and-cli.md +31 -20
  56. package/skills/toloka-fine-tuning/SKILL.md +155 -0
  57. package/skills/toloka-get-started/SKILL.md +70 -0
  58. package/skills/toloka-gisting/SKILL.md +166 -0
  59. package/skills/toloka-report-issue/references/report-template.md +1 -1
  60. package/src/authoring/index.ts +14 -0
  61. package/src/checks/task-ui-typecheck.ts +12 -10
  62. package/src/cli/analytics.ts +88 -15
  63. package/src/cli/auth.ts +93 -21
  64. package/src/cli/auto-update.ts +36 -39
  65. package/src/cli/check.ts +88 -39
  66. package/src/cli/cli-entry.ts +3 -2
  67. package/src/cli/cli-error.ts +43 -0
  68. package/src/cli/commands/auth.ts +89 -0
  69. package/src/cli/commands/config.ts +28 -0
  70. package/src/cli/commands/dataset.ts +85 -0
  71. package/src/cli/commands/delivery-order.ts +18 -0
  72. package/src/cli/commands/models.ts +121 -0
  73. package/src/cli/commands/pipeline.ts +131 -0
  74. package/src/cli/commands/project.ts +168 -0
  75. package/src/cli/commands/report.ts +43 -0
  76. package/src/cli/commands/secret.ts +40 -0
  77. package/src/cli/commands/skill.ts +36 -0
  78. package/src/cli/commands/source.ts +204 -0
  79. package/src/cli/commands/status.ts +17 -0
  80. package/src/cli/commands/training.ts +261 -0
  81. package/src/cli/commands/version.ts +14 -0
  82. package/src/cli/config.ts +7 -8
  83. package/src/cli/context.ts +251 -0
  84. package/src/cli/credentials.ts +12 -4
  85. package/src/cli/data.ts +96 -34
  86. package/src/cli/delivery-orders.ts +47 -0
  87. package/src/cli/environment.ts +26 -0
  88. package/src/cli/file-system.ts +6 -2
  89. package/src/cli/format-error.ts +45 -2
  90. package/src/cli/init.ts +14 -11
  91. package/src/cli/models.ts +43 -12
  92. package/src/cli/program.ts +120 -1347
  93. package/src/cli/project-context.ts +126 -43
  94. package/src/cli/project.ts +80 -28
  95. package/src/cli/report.ts +24 -9
  96. package/src/cli/runs.ts +33 -8
  97. package/src/cli/sdk-version.ts +38 -0
  98. package/src/cli/settings.ts +13 -5
  99. package/src/cli/skill.ts +67 -24
  100. package/src/cli/source.ts +3 -2
  101. package/src/cli/terminal-io.ts +110 -0
  102. package/src/cli/trainings.ts +242 -57
  103. package/src/cli/version.ts +3 -3
  104. package/src/client.ts +21 -9
  105. package/src/importer.ts +32 -2
  106. package/src/transport.ts +13 -1
  107. package/openapi/gen/models/gistingTrainingConfigurationInputRegime.ts +0 -2
  108. package/openapi/gen/models/gistingTrainingConfigurationRegime.ts +0 -2
  109. package/openapi/gen/models/sftTrainingConfigurationInputRegime.ts +0 -2
  110. package/openapi/gen/models/sftTrainingConfigurationRegime.ts +0 -2
package/src/cli/report.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { assert } from "es-toolkit";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import { constants } from "node:fs";
4
3
  import { lstat, open, type FileHandle } from "node:fs/promises";
5
4
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
5
  import type { CliAnalytics } from "./analytics";
6
+ import { assertCli, TolokaCliError } from "./cli-error";
7
7
 
8
8
  export type IssueReportAuthor = "human" | "agent";
9
9
 
@@ -31,9 +31,10 @@ const assertNoSymlinkParents = async (absolutePath: string, sourceRoot: string):
31
31
  for (let index = 1; index < segments.length; index += 1) {
32
32
  const directory = join(sourceRoot, ...segments.slice(0, index));
33
33
  const stats = await lstat(directory);
34
- assert(
34
+ assertCli(
35
35
  !stats.isSymbolicLink(),
36
36
  `Report file ${absolutePath} is inside the symbolic link ${directory}; select a path that does not traverse symbolic links.`,
37
+ { hint: { disabled: true } },
37
38
  );
38
39
  }
39
40
  };
@@ -49,20 +50,30 @@ const openReportFile = async (
49
50
  try {
50
51
  handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
51
52
  } catch (error) {
52
- assert(
53
+ assertCli(
53
54
  !(error instanceof Error && "code" in error && error.code === "ELOOP"),
54
55
  `Report file ${absolutePath} is a symbolic link; select the file it points to directly.`,
56
+ { hint: { disabled: true } },
55
57
  );
56
58
  throw error;
57
59
  }
58
60
 
59
61
  try {
60
62
  const stats = await handle.stat();
61
- assert(stats.isFile(), `Report file ${absolutePath} is not a regular file.`);
62
- assert(stats.size > 0, `Report file ${absolutePath} is empty.`);
63
- assert(
63
+ assertCli(
64
+ stats.isFile(),
65
+ `Report file ${absolutePath} is not a regular file.`,
66
+ { hint: { description: "Pass the path of a Markdown or text file." } },
67
+ );
68
+ assertCli(
69
+ stats.size > 0,
70
+ `Report file ${absolutePath} is empty.`,
71
+ { hint: { description: "Write the report first; the toloka-report-issue skill describes the format." } },
72
+ );
73
+ assertCli(
64
74
  stats.size <= ISSUE_REPORT_MAX_BYTES,
65
75
  `Report file ${absolutePath} is ${stats.size} bytes; the limit is 256 KiB.`,
76
+ { hint: { description: "Trim the report to under 256 KiB, for example by shortening logs." } },
66
77
  );
67
78
  return { handle, absolutePath, size: stats.size };
68
79
  } catch (error) {
@@ -75,14 +86,17 @@ const readReportText = async (path: string, sourceRoot: string, cwd: string): Pr
75
86
  const opened = await openReportFile(path, sourceRoot, cwd);
76
87
  try {
77
88
  const bytes = await opened.handle.readFile();
78
- assert(
89
+ assertCli(
79
90
  bytes.byteLength === opened.size,
80
91
  `${opened.absolutePath} changed while it was being read.`,
92
+ { hint: { description: "Re-run toloka report once the file is no longer being written." } },
81
93
  );
82
94
  try {
83
95
  return REPORT_DECODER.decode(bytes);
84
96
  } catch {
85
- throw new Error(`Report file ${opened.absolutePath} is not valid UTF-8.`);
97
+ throw new TolokaCliError(`Report file ${opened.absolutePath} is not valid UTF-8.`, {
98
+ hint: { description: "Save the report as UTF-8 text." },
99
+ });
86
100
  }
87
101
  } finally {
88
102
  await opened.handle.close();
@@ -107,9 +121,10 @@ export async function submitIssueReport(options: {
107
121
  readonly io: IssueReportIo;
108
122
  }): Promise<number> {
109
123
  const { analytics, io } = options;
110
- assert(
124
+ assertCli(
111
125
  analytics.issueReportingEnabled,
112
126
  "Issue reporting is disabled. Re-run with TOLOKA_TELEMETRY=1.",
127
+ { hint: { disabled: true } },
113
128
  );
114
129
  const reportText = await readReportText(options.path, options.sourceRoot, options.cwd);
115
130
  io.stdout("Issue report preview:");
package/src/cli/runs.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import select from "@inquirer/select";
2
- import { assert } from "es-toolkit";
3
2
  import type { Pipeline, PotatoClient } from "../client";
4
3
  import { PotatoApiError } from "../errors";
5
4
  import type { TolokaState } from "../state";
6
5
  import type { RunItem, RunJob } from "../index";
6
+ import { assertCli } from "./cli-error";
7
7
 
8
8
  export type PipelineRef = {
9
9
  readonly pipelineId: string;
@@ -131,10 +131,18 @@ const uniqueMatch = (
131
131
  ambiguous: string,
132
132
  unknown: string,
133
133
  ): PipelineRef | undefined => {
134
- assert(matches.length <= 1, ambiguous);
134
+ assertCli(
135
+ matches.length <= 1,
136
+ ambiguous,
137
+ { hint: { description: "Pass the pipeline id shown by toloka pipeline list instead of the name." } },
138
+ );
135
139
  if (matches.length !== 1) return undefined;
136
140
  const [ref] = matches;
137
- assert(ref, unknown);
141
+ assertCli(
142
+ ref,
143
+ unknown,
144
+ { hint: { description: "Run toloka pipeline list to see the pipelines of this project." } },
145
+ );
138
146
  return ref;
139
147
  };
140
148
 
@@ -174,7 +182,11 @@ const resolveToken = (token: string, live: readonly PipelineRef[]): PipelineRef
174
182
  );
175
183
  if (basename !== undefined) return basename;
176
184
 
177
- assert(false, unknown);
185
+ assertCli(
186
+ false,
187
+ unknown,
188
+ { hint: { description: "Run toloka pipeline list to see the pipelines of this project." } },
189
+ );
178
190
  };
179
191
 
180
192
  export async function resolvePipelineRef(options: {
@@ -188,7 +200,11 @@ export async function resolvePipelineRef(options: {
188
200
 
189
201
  if (options.pipeline !== undefined) return resolveToken(options.pipeline, live);
190
202
 
191
- assert(live.length > 0, "This project has no pipelines.");
203
+ assertCli(
204
+ live.length > 0,
205
+ "This project has no pipelines.",
206
+ { hint: { description: "Run toloka apply to create the pipelines declared in source." } },
207
+ );
192
208
 
193
209
  if (live.length > 1 && options.select !== undefined) {
194
210
  const chosen = await options.select(
@@ -199,13 +215,22 @@ export async function resolvePipelineRef(options: {
199
215
 
200
216
  if (live.length === 1) {
201
217
  const [only] = live;
202
- assert(only, "This project has no pipelines.");
203
- assert(false, `Pass a pipeline name. This project has: ${formatPipelineChoice(only)}.`);
218
+ assertCli(
219
+ only,
220
+ "This project has no pipelines.",
221
+ { hint: { description: "Run toloka apply to create the pipelines declared in source." } },
222
+ );
223
+ assertCli(
224
+ false,
225
+ `Pass a pipeline name. This project has: ${formatPipelineChoice(only)}.`,
226
+ { hint: { disabled: true } },
227
+ );
204
228
  }
205
229
 
206
- assert(
230
+ assertCli(
207
231
  false,
208
232
  `This project tracks several pipelines, so name one: ${live.map(formatPipelineChoice).join(", ")}.`,
233
+ { hint: { disabled: true } },
209
234
  );
210
235
  }
211
236
 
@@ -0,0 +1,38 @@
1
+ import packageMetadata from "../../package.json" with { type: "json" };
2
+
3
+ export const SDK_PACKAGE_NAME = packageMetadata.name;
4
+ export const SDK_VERSION = packageMetadata.version;
5
+
6
+ export type ReleaseChannel = "latest" | "preview";
7
+
8
+ export const parseSdkVersion = (version: string): readonly number[] | undefined => {
9
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-preview\.(\d+))?$/u.exec(version);
10
+ if (match === null) return undefined;
11
+ const [, major, minor, patch, preview] = match;
12
+ return [
13
+ Number(major),
14
+ Number(minor),
15
+ Number(patch),
16
+ preview === undefined ? Number.POSITIVE_INFINITY : Number(preview),
17
+ ];
18
+ };
19
+
20
+ export const releaseChannelFor = (version: string): ReleaseChannel | undefined => {
21
+ const parsed = parseSdkVersion(version);
22
+ if (parsed === undefined) return undefined;
23
+ return parsed[3] === Number.POSITIVE_INFINITY ? "latest" : "preview";
24
+ };
25
+
26
+ export const compareSdkVersions = (left: string, right: string): number | undefined => {
27
+ const parsedLeft = parseSdkVersion(left);
28
+ const parsedRight = parseSdkVersion(right);
29
+ if (parsedLeft === undefined || parsedRight === undefined) return undefined;
30
+ const length = Math.max(parsedLeft.length, parsedRight.length);
31
+ for (let index = 0; index < length; index += 1) {
32
+ const leftPart = parsedLeft[index] ?? 0;
33
+ const rightPart = parsedRight[index] ?? 0;
34
+ if (leftPart < rightPart) return -1;
35
+ if (leftPart > rightPart) return 1;
36
+ }
37
+ return 0;
38
+ };
@@ -3,6 +3,8 @@ import { join } from "node:path";
3
3
  import { isFalseyFlag, isTruthyFlag } from "./boolean-flag";
4
4
  import type { CliEnvironment } from "./credentials";
5
5
  import { configDirectory, preparePrivateFilePath } from "./credentials";
6
+ import { TolokaCliError } from "./cli-error";
7
+ import type { TolokaCliErrorOptions } from "./cli-error";
6
8
 
7
9
  export const AUTO_UPDATE_SETTING = "auto-update";
8
10
 
@@ -14,8 +16,8 @@ export const DEFAULT_CLI_SETTINGS: CliSettings = {
14
16
  autoUpdate: true,
15
17
  };
16
18
 
17
- export class SettingsError extends Error {
18
- constructor(message: string, options?: { readonly cause?: unknown }) {
19
+ export class SettingsError extends TolokaCliError {
20
+ constructor(message: string, options: TolokaCliErrorOptions) {
19
21
  super(message, options);
20
22
  this.name = "SettingsError";
21
23
  }
@@ -59,7 +61,9 @@ export async function writeCliSettings(
59
61
  export const parseBooleanSetting = (value: string): boolean => {
60
62
  if (isTruthyFlag(value)) return true;
61
63
  if (isFalseyFlag(value)) return false;
62
- throw new SettingsError(`Expected a boolean (true or false), got ${JSON.stringify(value)}.`);
64
+ throw new SettingsError(`Expected a boolean (true or false), got ${JSON.stringify(value)}.`, {
65
+ hint: { description: "Run toloka config set auto-update true or false." },
66
+ });
63
67
  };
64
68
 
65
69
  export const formatCliSettings = (settings: CliSettings): string =>
@@ -71,7 +75,9 @@ export const getCliSetting = (
71
75
  ): { readonly output: string } => {
72
76
  if (name === undefined || name.length === 0) return { output: formatCliSettings(settings) };
73
77
  if (name !== AUTO_UPDATE_SETTING) {
74
- throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`);
78
+ throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`, {
79
+ hint: { disabled: true },
80
+ });
75
81
  }
76
82
  return { output: String(settings.autoUpdate) };
77
83
  };
@@ -82,7 +88,9 @@ export async function setCliSetting(
82
88
  value: string,
83
89
  ): Promise<{ readonly output: string; readonly settings: CliSettings }> {
84
90
  if (name !== AUTO_UPDATE_SETTING) {
85
- throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`);
91
+ throw new SettingsError(`Unknown setting "${name}". Known settings: ${AUTO_UPDATE_SETTING}.`, {
92
+ hint: { disabled: true },
93
+ });
86
94
  }
87
95
  const settings = { autoUpdate: parseBooleanSetting(value) };
88
96
  await writeCliSettings(settings, environment);
package/src/cli/skill.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { assert } from "es-toolkit";
2
1
  import { createHash } from "node:crypto";
3
2
  import { copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import { assertSafeDestination, entryStats, hasErrorCode } from "./file-system";
6
5
  import { formatCliError } from "./format-error";
6
+ import { releaseChannelFor, SDK_PACKAGE_NAME, SDK_VERSION } from "./sdk-version";
7
+ import { assertCli, REINSTALL_HINT, TolokaCliError } from "./cli-error";
7
8
 
8
9
  export const SKILL_CATALOG_MANIFEST = "catalog.json";
9
10
  export const SKILL_INSTALL_ROOT = ".agents/skills";
@@ -15,6 +16,9 @@ const MAX_MANIFEST_BYTES = 1024 * 1024;
15
16
  const MAX_SKILL_ID_LENGTH = 64;
16
17
  const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
17
18
  const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
19
+ const UPGRADE_HINT = `bun add --global ${SDK_PACKAGE_NAME}@${
20
+ releaseChannelFor(SDK_VERSION) ?? "latest"
21
+ }`;
18
22
 
19
23
  export type SkillDescriptor = {
20
24
  readonly id: string;
@@ -72,31 +76,51 @@ const isSafeRelativePath = (path: string) =>
72
76
  path.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
73
77
 
74
78
  const parseDescriptor = (value: unknown, invalid: (detail: string) => string): SkillDescriptor => {
75
- assert(isRecord(value), invalid("every skill must be an object."));
79
+ assertCli(isRecord(value), invalid("every skill must be an object."), { hint: REINSTALL_HINT });
76
80
  const { id, name, description, tags, version, digest, files } = value;
77
- assert(
81
+ assertCli(
78
82
  isSafeSkillId(id),
79
83
  invalid(
80
84
  `skill id ${JSON.stringify(id)} must be at most ${MAX_SKILL_ID_LENGTH} lowercase letters, digits, and single hyphens.`,
81
85
  ),
86
+ { hint: REINSTALL_HINT },
82
87
  );
83
- assert(isNonEmptyString(name), invalid(`skill ${id} needs a name.`));
84
- assert(isNonEmptyString(description), invalid(`skill ${id} needs a description.`));
85
- assert(isStringArray(tags), invalid(`skill ${id} tags must be strings.`));
86
- assert(isNonEmptyString(version), invalid(`skill ${id} needs a version.`));
87
- assert(
88
+ assertCli(isNonEmptyString(name), invalid(`skill ${id} needs a name.`), { hint: REINSTALL_HINT });
89
+ assertCli(
90
+ isNonEmptyString(description),
91
+ invalid(`skill ${id} needs a description.`),
92
+ { hint: REINSTALL_HINT },
93
+ );
94
+ assertCli(
95
+ isStringArray(tags),
96
+ invalid(`skill ${id} tags must be strings.`),
97
+ { hint: REINSTALL_HINT },
98
+ );
99
+ assertCli(
100
+ isNonEmptyString(version),
101
+ invalid(`skill ${id} needs a version.`),
102
+ { hint: REINSTALL_HINT },
103
+ );
104
+ assertCli(
88
105
  typeof digest === "string" && DIGEST_PATTERN.test(digest),
89
106
  invalid(`skill ${id} digest must be the sha256 hex digest of its files.`),
107
+ { hint: REINSTALL_HINT },
90
108
  );
91
- assert(
109
+ assertCli(
92
110
  isStringArray(files) && files.includes(SKILL_ENTRY_POINT),
93
111
  invalid(`skill ${id} files must include ${SKILL_ENTRY_POINT}.`),
112
+ { hint: REINSTALL_HINT },
94
113
  );
95
- assert(
114
+ assertCli(
96
115
  files.every(isSafeRelativePath),
97
116
  invalid(`skill ${id} files must be relative paths inside its directory.`),
117
+ { hint: REINSTALL_HINT },
118
+ );
119
+ assertCli(
120
+ new Set(files).size === files.length,
121
+ invalid(`skill ${id} lists a file more than once.`),
122
+ { hint: REINSTALL_HINT },
98
123
  );
99
- assert(new Set(files).size === files.length, invalid(`skill ${id} lists a file more than once.`));
100
124
  return { id, name, description, tags, version, digest, files };
101
125
  };
102
126
 
@@ -104,7 +128,10 @@ const parseManifest = (source: string, manifestPath: string): unknown => {
104
128
  try {
105
129
  return JSON.parse(source);
106
130
  } catch (error) {
107
- throw new Error(`Invalid skill catalog ${manifestPath}: ${formatCliError(error)}`);
131
+ throw new TolokaCliError(`Invalid skill catalog ${manifestPath}: ${formatCliError(error)}`, {
132
+ cause: error,
133
+ hint: REINSTALL_HINT,
134
+ });
108
135
  }
109
136
  };
110
137
 
@@ -136,9 +163,10 @@ export async function resolveBundledSkillCatalogRoot(
136
163
  entry === undefined
137
164
  ? "process.argv[1] names no runtime entry"
138
165
  : `none exists between ${entry} and the filesystem root`;
139
- assert(
166
+ assertCli(
140
167
  workingDirectoryRoot !== undefined,
141
168
  `Cannot locate the bundled skill catalog. An installed @toloka-platform/sdk must ship ${SKILL_CATALOG_DIRECTORY}/${SKILL_CATALOG_MANIFEST} in a directory above its runtime entry, but ${searched}, and no ${SKILL_CATALOG_DIRECTORY}/${SKILL_CATALOG_MANIFEST} exists above ${process.cwd()} either. Reinstall @toloka-platform/sdk.`,
169
+ { hint: { disabled: true } },
142
170
  );
143
171
  return workingDirectoryRoot;
144
172
  }
@@ -150,23 +178,30 @@ export async function loadSkillCatalog(catalogRoot: string): Promise<SkillCatalo
150
178
  const manifestPath = join(catalogRoot, SKILL_CATALOG_MANIFEST);
151
179
  const invalid = (detail: string) => `Invalid skill catalog ${manifestPath}: ${detail}`;
152
180
  const { size } = await stat(manifestPath);
153
- assert(
181
+ assertCli(
154
182
  size <= MAX_MANIFEST_BYTES,
155
183
  invalid(`the manifest is ${size} bytes; the limit is ${MAX_MANIFEST_BYTES} bytes.`),
184
+ { hint: REINSTALL_HINT },
156
185
  );
157
186
  const manifest = parseManifest(await readFile(manifestPath, "utf8"), manifestPath);
158
- assert(
187
+ assertCli(
159
188
  isRecord(manifest) && Array.isArray(manifest.skills),
160
189
  invalid("expected an object with a skills array."),
190
+ { hint: REINSTALL_HINT },
161
191
  );
162
- assert(
192
+ assertCli(
163
193
  manifest.skills.length <= MAX_SKILL_COUNT,
164
194
  invalid(`${manifest.skills.length} skills exceed the limit of ${MAX_SKILL_COUNT}.`),
195
+ { hint: REINSTALL_HINT },
165
196
  );
166
197
  const skills = manifest.skills.map((skill) => parseDescriptor(skill, invalid));
167
198
  const ids = new Set<string>();
168
199
  for (const skill of skills) {
169
- assert(!ids.has(skill.id), invalid(`skill id ${skill.id} appears more than once.`));
200
+ assertCli(
201
+ !ids.has(skill.id),
202
+ invalid(`skill id ${skill.id} appears more than once.`),
203
+ { hint: REINSTALL_HINT },
204
+ );
170
205
  ids.add(skill.id);
171
206
  }
172
207
  return { root: catalogRoot, manifestPath, skills };
@@ -249,9 +284,14 @@ const swapDirectories = async (
249
284
  try {
250
285
  await renameEntry(backup, destination);
251
286
  } catch (restoreError) {
252
- throw new Error(
287
+ throw new TolokaCliError(
253
288
  `Failed to replace ${destination}; the previous version is preserved at ${backup}. ${formatCliError(restoreError)}`,
254
- { cause: error },
289
+ {
290
+ cause: error,
291
+ hint: {
292
+ description: `Move ${backup} back to ${destination} by hand, then re-run toloka skill install <skill-id> --force.`,
293
+ },
294
+ },
255
295
  );
256
296
  }
257
297
  throw error;
@@ -267,17 +307,19 @@ export async function installSkill({
267
307
  rename: renameEntry = rename,
268
308
  }: InstallSkillOptions): Promise<InstalledSkill> {
269
309
  const skill = catalog.skills.find((candidate) => candidate.id === skillId);
270
- assert(
310
+ assertCli(
271
311
  skill,
272
- `Skill ${JSON.stringify(skillId)} is not in the catalog. Run \`toloka skill list\` to see the available skills.`,
312
+ `Skill ${JSON.stringify(skillId)} is not in the catalog bundled with ${SDK_PACKAGE_NAME} ${SDK_VERSION}. Run \`toloka skill list\` to see the available skills; a newer release may ship it (${UPGRADE_HINT}).`,
313
+ { hint: { disabled: true } },
273
314
  );
274
315
  const relativeDestination = `${SKILL_INSTALL_ROOT}/${skill.id}`;
275
316
  await assertSafeDestination(sourceRoot, relativeDestination);
276
317
  const destination = join(sourceRoot, relativeDestination);
277
318
  const existing = await entryStats(destination);
278
- assert(
319
+ assertCli(
279
320
  existing === undefined || force,
280
321
  `${relativeDestination} already exists. Re-run with --force to replace it with the catalog version of ${skill.id}.`,
322
+ { hint: { disabled: true } },
281
323
  );
282
324
 
283
325
  const skillsRoot = dirname(destination);
@@ -289,9 +331,10 @@ export async function installSkill({
289
331
  await mkdir(dirname(target), { recursive: true });
290
332
  await copyFile(join(catalog.root, skill.id, path), target);
291
333
  }
292
- assert(
334
+ assertCli(
293
335
  (await computeSkillDigest(temporary, skill.files)) === skill.digest,
294
336
  `Skill ${skill.id} failed its digest check: the packaged files do not match the catalog digest ${skill.digest}. Nothing was installed.`,
337
+ { hint: REINSTALL_HINT },
295
338
  );
296
339
  if (existing === undefined) {
297
340
  await renameEntry(temporary, destination);
@@ -348,7 +391,7 @@ export async function skillSearchCommand({
348
391
  return;
349
392
  }
350
393
  io.stdout(
351
- `No skills match ${JSON.stringify(query)}. Run \`toloka skill list\` to see the catalog.`,
394
+ `No skills match ${JSON.stringify(query)} in the catalog bundled with ${SDK_PACKAGE_NAME} ${SDK_VERSION}. Run \`toloka skill list\` to see it; a newer release may add more (${UPGRADE_HINT}).`,
352
395
  );
353
396
  }
354
397
 
package/src/cli/source.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { assert } from "es-toolkit";
2
1
  import { readdir, stat } from "node:fs/promises";
3
2
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
3
  import { pathToFileURL } from "node:url";
5
4
  import { validatePipelineDefinition } from "../authoring";
6
5
  import type { PipelineDefinition } from "../authoring";
6
+ import { assertCli } from "./cli-error";
7
7
 
8
8
  export type ProjectPipelineSource = {
9
9
  readonly sourceDirectory: string;
@@ -64,9 +64,10 @@ export async function loadProjectSource(sourceRoot: string): Promise<ProjectSour
64
64
  const moduleUrl = pathToFileURL(modulePath);
65
65
  moduleUrl.searchParams.set("version", `${moduleStat.mtimeMs}-${moduleStat.size}`);
66
66
  const module: unknown = await import(moduleUrl.href);
67
- assert(
67
+ assertCli(
68
68
  typeof module === "object" && module !== null,
69
69
  `Invalid ${modulePath}: expected a module`,
70
+ { hint: { description: "Make the file a TypeScript module that exports the pipeline definition." } },
70
71
  );
71
72
  const definition: unknown = Reflect.get(module, "default");
72
73
  validatePipelineDefinition(definition);
@@ -0,0 +1,110 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import type { AuthPrompt } from "./auth";
3
+ import type { CliIo } from "./context";
4
+ import { pickPipeline } from "./runs";
5
+ import { TolokaCliError } from "./cli-error";
6
+
7
+ const stdinClosedError = () =>
8
+ new TolokaCliError("Standard input closed before an API key was entered.", {
9
+ hint: {
10
+ description:
11
+ "Run toloka login in an interactive terminal, or pipe the key with toloka login --api-key-stdin.",
12
+ },
13
+ });
14
+
15
+ const readStandardInput = async (): Promise<string> => {
16
+ process.stdin.setEncoding("utf8");
17
+ const chunks: string[] = [];
18
+ for await (const chunk of process.stdin) chunks.push(String(chunk));
19
+ return chunks.join("");
20
+ };
21
+
22
+ const readMaskedLine = async (question: string): Promise<string> => {
23
+ process.stdout.write(question);
24
+ const wasRaw = process.stdin.isRaw;
25
+ process.stdin.setRawMode(true);
26
+ process.stdin.resume();
27
+ process.stdin.setEncoding("utf8");
28
+
29
+ const { promise, resolve, reject } = Promise.withResolvers<string>();
30
+ let value = "";
31
+
32
+ const restore = () => {
33
+ process.stdin.off("data", onData);
34
+ process.stdin.off("end", onEnd);
35
+ process.stdin.off("error", onError);
36
+ process.stdin.setRawMode(wasRaw);
37
+ process.stdin.pause();
38
+ };
39
+
40
+ const finish = () => {
41
+ restore();
42
+ process.stdout.write("\n");
43
+ };
44
+
45
+ const onEnd = () => {
46
+ finish();
47
+ reject(stdinClosedError());
48
+ };
49
+
50
+ const onError = (error: Error) => {
51
+ finish();
52
+ reject(error);
53
+ };
54
+
55
+ const onData = (chunk: string) => {
56
+ for (const character of chunk) {
57
+ if (character === "\r" || character === "\n") {
58
+ finish();
59
+ resolve(value);
60
+ return;
61
+ }
62
+ if (character === "\u0003" || character === "\u0004") {
63
+ finish();
64
+ reject(
65
+ character === "\u0003"
66
+ ? new TolokaCliError("Canceled.", { hint: { disabled: true } })
67
+ : stdinClosedError(),
68
+ );
69
+ return;
70
+ }
71
+ if (character === "\u007f" || character === "\b") {
72
+ if (value.length > 0) {
73
+ value = value.slice(0, -1);
74
+ process.stdout.write("\b \b");
75
+ }
76
+ continue;
77
+ }
78
+ value += character;
79
+ process.stdout.write("*");
80
+ }
81
+ };
82
+
83
+ process.stdin.on("data", onData);
84
+ process.stdin.once("end", onEnd);
85
+ process.stdin.once("error", onError);
86
+
87
+ try {
88
+ return await promise;
89
+ } finally {
90
+ restore();
91
+ }
92
+ };
93
+
94
+ const promptFromTerminal: AuthPrompt = async (question, options = {}) => {
95
+ if (options.mask === true) return readMaskedLine(question);
96
+
97
+ const input = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
98
+ try {
99
+ return await input.question(question);
100
+ } finally {
101
+ input.close();
102
+ }
103
+ };
104
+
105
+ export const terminalIo = (): CliIo => ({
106
+ stdout: (text) => console.log(text),
107
+ stderr: (text) => console.error(text),
108
+ readStdin: readStandardInput,
109
+ ...(process.stdin.isTTY ? { prompt: promptFromTerminal, select: pickPipeline } : {}),
110
+ });