@toloka-platform/sdk 0.2.0-preview.3 → 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 (71) hide show
  1. package/dist/cli/toloka.js +136 -136
  2. package/dist/cli/toloka.js.jsc +0 -0
  3. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  4. package/dist/cli/toloka.js.sha256 +1 -1
  5. package/docs/cli-v2.md +3 -2
  6. package/docs/cli.md +32 -4
  7. package/openapi/gen/models/baseModel.ts +1 -0
  8. package/openapi/gen/models/currentUser.ts +2 -0
  9. package/openapi/gen/models/currentUserTeamsItem.ts +6 -0
  10. package/openapi/gen/models/gistingTrainingConfiguration.ts +6 -2
  11. package/openapi/gen/models/gistingTrainingConfigurationBatchSize.ts +2 -0
  12. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +2 -0
  13. package/openapi/gen/models/gistingTrainingConfigurationInput.ts +6 -2
  14. package/openapi/gen/models/gistingTrainingConfigurationInputBatchSize.ts +2 -0
  15. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +2 -0
  16. package/openapi/gen/models/index.ts +9 -4
  17. package/openapi/gen/models/sftTrainingConfiguration.ts +6 -3
  18. package/openapi/gen/models/sftTrainingConfigurationBatchSize.ts +2 -0
  19. package/openapi/gen/models/sftTrainingConfigurationInput.ts +6 -3
  20. package/openapi/gen/models/sftTrainingConfigurationInputBatchSize.ts +2 -0
  21. package/openapi/gen/models/sftTrainingConfigurationInputLoraRank.ts +2 -0
  22. package/openapi/gen/models/sftTrainingConfigurationLoraRank.ts +2 -0
  23. package/package.json +1 -1
  24. package/skills/catalog.json +10 -10
  25. package/skills/toloka-build/SKILL.md +28 -17
  26. package/skills/toloka-build/references/node-semantics.md +7 -7
  27. package/skills/toloka-build/references/workflow-and-cli.md +13 -10
  28. package/skills/toloka-fine-tuning/SKILL.md +145 -62
  29. package/skills/toloka-get-started/SKILL.md +35 -20
  30. package/skills/toloka-gisting/SKILL.md +156 -66
  31. package/src/cli/analytics.ts +50 -23
  32. package/src/cli/auth.ts +31 -12
  33. package/src/cli/auto-update.ts +12 -6
  34. package/src/cli/check.ts +76 -29
  35. package/src/cli/cli-entry.ts +3 -2
  36. package/src/cli/cli-error.ts +43 -0
  37. package/src/cli/commands/dataset.ts +13 -3
  38. package/src/cli/commands/models.ts +6 -7
  39. package/src/cli/commands/pipeline.ts +6 -7
  40. package/src/cli/commands/project.ts +9 -16
  41. package/src/cli/commands/report.ts +6 -2
  42. package/src/cli/commands/secret.ts +5 -4
  43. package/src/cli/commands/source.ts +4 -4
  44. package/src/cli/commands/status.ts +2 -3
  45. package/src/cli/commands/training.ts +50 -17
  46. package/src/cli/config.ts +2 -2
  47. package/src/cli/context.ts +55 -17
  48. package/src/cli/credentials.ts +12 -4
  49. package/src/cli/data.ts +96 -34
  50. package/src/cli/delivery-orders.ts +18 -1
  51. package/src/cli/file-system.ts +6 -2
  52. package/src/cli/format-error.ts +45 -2
  53. package/src/cli/init.ts +13 -7
  54. package/src/cli/models.ts +43 -12
  55. package/src/cli/program.ts +4 -55
  56. package/src/cli/project-context.ts +117 -32
  57. package/src/cli/project.ts +80 -28
  58. package/src/cli/report.ts +24 -9
  59. package/src/cli/runs.ts +33 -8
  60. package/src/cli/settings.ts +13 -5
  61. package/src/cli/skill.ts +61 -22
  62. package/src/cli/source.ts +3 -2
  63. package/src/cli/terminal-io.ts +13 -6
  64. package/src/cli/trainings.ts +129 -35
  65. package/src/cli/version.ts +2 -2
  66. package/src/client.ts +3 -9
  67. package/src/transport.ts +13 -1
  68. package/openapi/gen/models/gistingTrainingConfigurationInputRegime.ts +0 -2
  69. package/openapi/gen/models/gistingTrainingConfigurationRegime.ts +0 -2
  70. package/openapi/gen/models/sftTrainingConfigurationInputRegime.ts +0 -2
  71. package/openapi/gen/models/sftTrainingConfigurationRegime.ts +0 -2
@@ -1,4 +1,5 @@
1
1
  import { PotatoApiError } from "../errors";
2
+ import { formatHint, TolokaCliError } from "./cli-error";
2
3
 
3
4
  export const apiErrorDetails = (error: PotatoApiError): string | undefined => {
4
5
  const details =
@@ -10,10 +11,52 @@ export const apiErrorDetails = (error: PotatoApiError): string | undefined => {
10
11
  return details === undefined || details.length === 0 ? undefined : details;
11
12
  };
12
13
 
14
+ const apiErrorHint = (error: PotatoApiError): string | undefined => {
15
+ if (error.code === "wait_timeout") {
16
+ return "Hint: The job is still running. Check it later with its status command; commands that accept --timeout can wait longer.";
17
+ }
18
+ if (error.status === 401 || error.status === 403) {
19
+ return "Hint: Run toloka whoami to see which key and platform are in use; toloka login stores a new key.";
20
+ }
21
+ if (error.status === 404) {
22
+ return "Hint: Check the id against toloka project current and the matching list command; ids belong to one project on one platform.";
23
+ }
24
+ if (error.status === 429) {
25
+ return "Hint: The platform is rate limiting this key; wait a moment and retry.";
26
+ }
27
+ if (error.status !== undefined && error.status >= 500) {
28
+ return "Hint: The platform failed to handle the request. Retry; if it keeps failing, write a report as the toloka-report-issue skill describes and send it with toloka report <report.md>.";
29
+ }
30
+ return undefined;
31
+ };
32
+
33
+ const joinLines = (lines: ReadonlyArray<string | undefined>): string =>
34
+ lines.filter((line) => line !== undefined && line.length > 0).join("\n");
35
+
36
+ export const findPotatoApiError = (error: unknown): PotatoApiError | undefined => {
37
+ if (error instanceof PotatoApiError) return error;
38
+ if (error instanceof AggregateError) {
39
+ for (const nested of error.errors) {
40
+ const apiError = findPotatoApiError(nested);
41
+ if (apiError !== undefined) return apiError;
42
+ }
43
+ }
44
+ return error instanceof Error ? findPotatoApiError(error.cause) : undefined;
45
+ };
46
+
13
47
  export const formatCliError = (error: unknown): string => {
48
+ if (error instanceof TolokaCliError) {
49
+ const cause = findPotatoApiError(error.cause);
50
+ const causeLines =
51
+ cause === undefined
52
+ ? []
53
+ : cause.message === error.message
54
+ ? [apiErrorDetails(cause)]
55
+ : [cause.message, apiErrorDetails(cause)];
56
+ return joinLines([error.message, ...causeLines, formatHint(error.hint)]);
57
+ }
14
58
  if (error instanceof PotatoApiError) {
15
- const details = apiErrorDetails(error);
16
- return details === undefined ? error.message : `${error.message}\n${details}`;
59
+ return joinLines([error.message, apiErrorDetails(error), apiErrorHint(error)]);
17
60
  }
18
61
  return error instanceof Error ? error.message : String(error);
19
62
  };
package/src/cli/init.ts CHANGED
@@ -1,10 +1,10 @@
1
- import { assert } from "es-toolkit";
2
1
  import { lstat, mkdir, readdir, readFile, realpath, rm, rmdir, writeFile } from "node:fs/promises";
3
2
  import { dirname, join } from "node:path";
4
3
  import { readTolokaState, writeTolokaState } from "../state";
5
4
  import { assertSafeDestination, entryStats, isMissingFileError } from "./file-system";
6
5
  import { SDK_PACKAGE_NAME, SDK_VERSION } from "./sdk-version";
7
6
  import { SKILL_ENTRY_POINT, SKILL_INSTALL_ROOT } from "./skill";
7
+ import { assertCli } from "./cli-error";
8
8
 
9
9
  const BOOTSTRAP_NAME = "toloka-bootstrap";
10
10
  const CLAUDE_POINTER_PATH = `.claude/skills/${BOOTSTRAP_NAME}/SKILL.md`;
@@ -106,14 +106,16 @@ const locateBlock = (source: string, markers: BlockMarkers, path: string) => {
106
106
  const beginIndex = source.indexOf(markers.begin);
107
107
  const endIndex = source.indexOf(markers.end);
108
108
  if (beginIndex === -1 && endIndex === -1) return undefined;
109
- assert(
109
+ assertCli(
110
110
  beginIndex !== -1 && endIndex !== -1 && beginIndex < endIndex,
111
111
  `Malformed Toloka block in ${path}: expected one ${markers.begin} followed by one ${markers.end}. Repair the file by hand, then re-run \`toloka init\`.`,
112
+ { hint: { disabled: true } },
112
113
  );
113
- assert(
114
+ assertCli(
114
115
  !source.includes(markers.begin, beginIndex + markers.begin.length) &&
115
116
  !source.includes(markers.end, endIndex + markers.end.length),
116
117
  `Multiple Toloka blocks in ${path}: keep exactly one ${markers.begin} ... ${markers.end} block, then re-run \`toloka init\`.`,
118
+ { hint: { disabled: true } },
117
119
  );
118
120
  return { beginIndex, endIndex: endIndex + markers.end.length };
119
121
  };
@@ -121,9 +123,10 @@ const locateBlock = (source: string, markers: BlockMarkers, path: string) => {
121
123
  const mergeBootstrap = (source: string, path: string) => {
122
124
  const current = locateBlock(source, CURRENT_BLOCK, path);
123
125
  const legacy = locateBlock(source, LEGACY_BLOCK, path);
124
- assert(
126
+ assertCli(
125
127
  current === undefined || legacy === undefined,
126
128
  `Ambiguous Toloka blocks in ${path}: both ${CURRENT_BLOCK.begin} and ${LEGACY_BLOCK.begin} are present. Keep one, then re-run \`toloka init\`.`,
129
+ { hint: { disabled: true } },
127
130
  );
128
131
  const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
129
132
  const block = BOOTSTRAP_BLOCK.replaceAll("\n", lineEnding);
@@ -146,9 +149,10 @@ const hasSdkDependency = async (targetDirectory: string) => {
146
149
  const source = await readOptionalFile(join(targetDirectory, "package.json"));
147
150
  if (source === undefined) return false;
148
151
  const manifest: unknown = JSON.parse(source);
149
- assert(
152
+ assertCli(
150
153
  typeof manifest === "object" && manifest !== null,
151
154
  "package.json must contain a JSON object.",
155
+ { hint: { description: "Fix package.json in the source root, then re-run toloka init." } },
152
156
  );
153
157
  return ["dependencies", "devDependencies"].some((key) => {
154
158
  const dependencies = Reflect.get(manifest, key);
@@ -170,9 +174,10 @@ const writeIfChanged = async (path: string, source: string) => {
170
174
  const assertRefreshablePointer = async (targetRoot: string, relativePath: string) => {
171
175
  const path = join(targetRoot, relativePath);
172
176
  const existing = await readOptionalFile(path);
173
- assert(
177
+ assertCli(
174
178
  existing === undefined || isGenerated(existing),
175
179
  `Refusing to overwrite ${path}: it carries no \`<!-- toloka:sdk-version ... -->\` marker, so it is not a pointer generated by ${SDK_PACKAGE_NAME}. Move it aside, then re-run \`toloka init\`.`,
180
+ { hint: { disabled: true } },
176
181
  );
177
182
  };
178
183
 
@@ -215,9 +220,10 @@ const bindCheckout = async (
215
220
  ): Promise<string | undefined> => {
216
221
  if (projectId === undefined) return undefined;
217
222
  const existing = await readTolokaState(targetRoot);
218
- assert(
223
+ assertCli(
219
224
  existing === undefined || existing.projectId === projectId,
220
225
  `This directory is already bound to ${existing?.projectId}.`,
226
+ { hint: { description: "Run toloka init without --project-id to keep the current binding, or remove toloka.json to bind another project." } },
221
227
  );
222
228
  if (existing !== undefined) return undefined;
223
229
  await writeTolokaState(targetRoot, { projectId, pipelines: {}, datasets: {} });
package/src/cli/models.ts CHANGED
@@ -5,9 +5,9 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { Readable, Transform } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
7
7
 
8
- import { assert } from "es-toolkit";
9
8
 
10
9
  import type { BaseModel, BaseModelId, Model, ModelVersion, PotatoClient } from "../client";
10
+ import { assertCli, REPORT_HINT, RETRY_OR_REPORT_HINT } from "./cli-error";
11
11
 
12
12
  type ModelApi = Pick<PotatoClient, "baseModels" | "models">;
13
13
  type ModelIo = { readonly stdout: (text: string) => void };
@@ -46,6 +46,7 @@ const formatBaseModel = (model: BaseModel): string =>
46
46
  ["Provider", model.provider],
47
47
  ["Upstream model", model.upstreamModel],
48
48
  ["Upstream revision", model.upstreamRevision],
49
+ ["Upstream snapshot", model.upstreamUrl],
49
50
  ]);
50
51
 
51
52
  const formatModel = (model: Model): string =>
@@ -62,7 +63,11 @@ const formatModel = (model: Model): string =>
62
63
 
63
64
  export const parseBaseModelId = (value: string): BaseModelId => {
64
65
  const modelId = BASE_MODEL_IDS.find((candidate) => candidate === value);
65
- assert(modelId, `Unknown base model ${value}.`);
66
+ assertCli(
67
+ modelId,
68
+ `Unknown base model ${value}.`,
69
+ { hint: { description: "Run toloka base-model list to see the catalog." } },
70
+ );
66
71
  return modelId;
67
72
  };
68
73
 
@@ -76,7 +81,11 @@ const resolveModel = async (
76
81
  for await (const model of client.models.listAll({ projectId, limit: 100 })) {
77
82
  if (model.name === nameOrId) matches.push(model);
78
83
  }
79
- assert(matches.length === 1, `Model ${nameOrId} was not found or is ambiguous.`);
84
+ assertCli(
85
+ matches.length === 1,
86
+ `Model ${nameOrId} was not found or is ambiguous.`,
87
+ { hint: { description: "Run toloka model list and pass the model id (mdl.…)." } },
88
+ );
80
89
  return matches[0]!;
81
90
  };
82
91
 
@@ -87,8 +96,16 @@ const resolveModelVersion = async (
87
96
  ): Promise<ModelVersion> => {
88
97
  if (nameOrId.startsWith("mdv.")) return client.models.getVersion({ modelVersionId: nameOrId });
89
98
  const separator = nameOrId.lastIndexOf("@");
90
- assert(separator > 0, `Model Version ${nameOrId} was not found. Use mdv.<id> or Model@name.`);
91
- assert(projectId, "Select a project to resolve Model Version names.");
99
+ assertCli(
100
+ separator > 0,
101
+ `Model Version ${nameOrId} was not found. Use mdv.<id> or Model@name.`,
102
+ { hint: { disabled: true } },
103
+ );
104
+ assertCli(
105
+ projectId,
106
+ "Select a project to resolve Model Version names.",
107
+ { hint: { description: "Select a project with toloka project add or toloka project switch, or pass the version id (mdv.…) directly." } },
108
+ );
92
109
  const model = await resolveModel(client, projectId, nameOrId.slice(0, separator));
93
110
  const versionName = nameOrId.slice(separator + 1);
94
111
  const matches: ModelVersion[] = [];
@@ -99,7 +116,11 @@ const resolveModelVersion = async (
99
116
  })) {
100
117
  if (version.name === versionName) matches.push(version);
101
118
  }
102
- assert(matches.length === 1, `Model Version ${nameOrId} was not found or is ambiguous.`);
119
+ assertCli(
120
+ matches.length === 1,
121
+ `Model Version ${nameOrId} was not found or is ambiguous.`,
122
+ { hint: { description: "Run toloka model versions <model> and pass the version id (mdv.…)." } },
123
+ );
103
124
  return matches[0]!;
104
125
  };
105
126
 
@@ -116,12 +137,14 @@ export const listBaseModels = async (
116
137
  models.length === 0
117
138
  ? "No base models found."
118
139
  : formatTable([
119
- ["ID", "NAME", "PROVIDER", "UPSTREAM MODEL"],
140
+ ["ID", "NAME", "PROVIDER", "UPSTREAM MODEL", "REVISION", "UPSTREAM SNAPSHOT"],
120
141
  ...models.map((model) => [
121
142
  model.id,
122
143
  model.displayName,
123
144
  model.provider,
124
145
  model.upstreamModel,
146
+ model.upstreamRevision.slice(0, 12),
147
+ model.upstreamUrl,
125
148
  ]),
126
149
  ]),
127
150
  );
@@ -238,25 +261,31 @@ export const downloadModelVersion = async (
238
261
  for (const file of descriptor.files) {
239
262
  const target = resolve(root, file.path);
240
263
  const relativeTarget = relative(root, target);
241
- assert(
264
+ assertCli(
242
265
  relativeTarget !== "" &&
243
266
  relativeTarget !== ".." &&
244
267
  !relativeTarget.startsWith(`..${sep}`) &&
245
268
  !isAbsolute(relativeTarget),
246
269
  `Invalid Model Version file path: ${file.path}`,
270
+ { hint: REPORT_HINT },
247
271
  );
248
272
  let parent = root;
249
273
  for (const segment of file.path.split("/").slice(0, -1)) {
250
274
  parent = resolve(parent, segment);
251
275
  await mkdir(parent, { recursive: true });
252
276
  const realParent = await realpath(parent);
253
- assert(
277
+ assertCli(
254
278
  realParent === realRoot || realParent.startsWith(`${realRoot}${sep}`),
255
279
  `Invalid Model Version file path: ${file.path}`,
280
+ { hint: REPORT_HINT },
256
281
  );
257
282
  }
258
283
  const response = await fetch(file.url, { redirect: "error" });
259
- assert(response.ok && response.body, `Failed to download Model Version file ${file.path}.`);
284
+ assertCli(
285
+ response.ok && response.body,
286
+ `Failed to download Model Version file ${file.path}.`,
287
+ { hint: RETRY_OR_REPORT_HINT },
288
+ );
260
289
  const hash = createHash("sha256");
261
290
  let byteSize = 0;
262
291
  const verify = new Transform({
@@ -274,13 +303,15 @@ export const downloadModelVersion = async (
274
303
  0o600,
275
304
  );
276
305
  await pipeline(Readable.from(response.body), verify, handle.createWriteStream());
277
- assert(
306
+ assertCli(
278
307
  byteSize === file.byteSize,
279
308
  `Downloaded Model Version file ${file.path} has an unexpected size.`,
309
+ { hint: { description: "Delete the partial download and retry; if it keeps failing, run toloka report." } },
280
310
  );
281
- assert(
311
+ assertCli(
282
312
  hash.digest("hex") === file.sha256,
283
313
  `Downloaded Model Version file ${file.path} has an invalid hash.`,
314
+ { hint: { description: "Delete the partial download and retry; if it keeps failing, run toloka report." } },
284
315
  );
285
316
  await rename(temporary, target);
286
317
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { Command, CommanderError } from "commander";
2
- import { createClient, type PotatoClient } from "../client";
2
+ import type { PotatoClient } from "../client";
3
3
  import { commandNameFromArgv, createCliAnalytics } from "./analytics";
4
4
  import type { CliAnalytics, CliAnalyticsCommandOutcome } from "./analytics";
5
5
  import type { AutoUpdateHooks } from "./auto-update";
@@ -19,11 +19,9 @@ import { registerSourceCommands } from "./commands/source";
19
19
  import { registerStatusCommand } from "./commands/status";
20
20
  import { registerTrainingCommands } from "./commands/training";
21
21
  import { registerVersionCommand } from "./commands/version";
22
- import { resolveTolokaConfig, type TolokaEnvironment } from "./config";
23
- import { findCommandCredentials } from "./credentials";
22
+ import type { TolokaEnvironment } from "./config";
24
23
  import {
25
24
  CREDENTIAL_PRECEDENCE,
26
- resolveInternalUser,
27
25
  resolveSourceRoot,
28
26
  type CliIo,
29
27
  type CreatedProgram,
@@ -39,51 +37,6 @@ import { terminalIo } from "./terminal-io";
39
37
  export type { CliIo, CreatedProgram, ProgramContext } from "./context";
40
38
  export { terminalIo } from "./terminal-io";
41
39
 
42
- const argvFlag = (argv: readonly string[], flag: string): string | undefined => {
43
- const index = argv.indexOf(flag);
44
- if (index < 0) return undefined;
45
- const value = argv[index + 1];
46
- return value === undefined || value.startsWith("-") ? undefined : value;
47
- };
48
-
49
- const resolveInternalCommandAccess = async (
50
- argv: readonly string[],
51
- environment: TolokaEnvironment,
52
- injectedClient: PotatoClient | undefined,
53
- ): Promise<boolean | undefined> => {
54
- const commandName = commandNameFromArgv(argv);
55
- if (
56
- commandName !== "help" &&
57
- commandName !== "project create" &&
58
- !commandName.startsWith("delivery-order")
59
- ) {
60
- return undefined;
61
- }
62
- if (injectedClient !== undefined) return resolveInternalUser(injectedClient);
63
- try {
64
- const apiKey = argvFlag(argv, "--api-key");
65
- const baseUrl = argvFlag(argv, "--base-url");
66
- const { found } = await findCommandCredentials(
67
- environment,
68
- apiKey ?? environment.TOLOKA_API_KEY,
69
- );
70
- const config = resolveTolokaConfig(
71
- {
72
- ...(apiKey === undefined ? {} : { apiKey }),
73
- ...(baseUrl === undefined ? {} : { baseUrl }),
74
- },
75
- undefined,
76
- environment,
77
- found?.credentials,
78
- );
79
- return await resolveInternalUser(
80
- createClient({ apiKey: config.apiKey, baseUrl: config.baseUrl }),
81
- );
82
- } catch {
83
- return undefined;
84
- }
85
- };
86
-
87
40
  export function createProgram(context: ProgramContext): CreatedProgram {
88
41
  const program = new Command("toloka");
89
42
  const exitCode = { value: 0 };
@@ -137,7 +90,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
137
90
  registerConfigCommands(program, host);
138
91
  registerAuthCommands(program, host);
139
92
  registerProjectCommands(program, host);
140
- if (context.internalUser === true) registerDeliveryOrderCommands(program, host);
93
+ registerDeliveryOrderCommands(program, host);
141
94
  registerSourceCommands(program, host);
142
95
  registerSecretCommands(program, host);
143
96
  registerModelCommands(program, host);
@@ -263,11 +216,7 @@ export async function runTolokaCli(
263
216
  ...(injectedClient === undefined ? {} : { injectedClient }),
264
217
  ...(runChecks === undefined ? {} : { runChecks }),
265
218
  };
266
- const internalUser = await resolveInternalCommandAccess(argv, environment, injectedClient);
267
- const { program, exitCode } = createProgram({
268
- ...baseContext,
269
- ...(internalUser === undefined ? {} : { internalUser }),
270
- });
219
+ const { program, exitCode } = createProgram(baseContext);
271
220
  const startedAt = Date.now();
272
221
 
273
222
  try {
@@ -1,4 +1,4 @@
1
- import { assert } from "es-toolkit";
1
+ import { PotatoApiError } from "../errors";
2
2
  import { readTolokaState, TOLOKA_STATE_FILENAME, writeTolokaState } from "../state";
3
3
  import type { TolokaState } from "../state";
4
4
  import type { AuthPrompt } from "./auth";
@@ -6,6 +6,8 @@ import type { TolokaCliFlags, TolokaEnvironment } from "./config";
6
6
  import { resolveInternalBaseUrl } from "./config";
7
7
  import { normalizeBaseUrl, requireStoredCredentials, writeStoredCredentials } from "./credentials";
8
8
  import type { CliEnvironment, StoredCredentials, StoredProjectAlias } from "./credentials";
9
+ import { apiErrorDetails } from "./format-error";
10
+ import { assertCli, TolokaCliError } from "./cli-error";
9
11
 
10
12
  type ProjectRecord = { readonly id: string; readonly name: string; readonly teamId: string };
11
13
 
@@ -24,20 +26,26 @@ export type ProjectGetApi = {
24
26
  };
25
27
  };
26
28
 
29
+ export type AccountTeam = { readonly id: string; readonly writable: boolean; readonly internal?: boolean };
30
+
27
31
  export type ProjectCreateApi = {
32
+ readonly me: () => Promise<{ readonly teams?: readonly AccountTeam[] }>;
28
33
  readonly projects: {
29
34
  readonly create: (input: {
30
35
  readonly teamId: string;
31
36
  readonly name?: string;
32
37
  readonly deliveryOrderId?: string;
33
38
  }) => Promise<ProjectRecord>;
34
- readonly listAll: (params?: object) => AsyncIterable<ProjectRecord>;
35
39
  };
36
40
  };
37
41
 
38
42
  export const MISSING_PROJECT_ID_MESSAGE =
39
43
  "Missing project ID. Pass --project-id, set TOLOKA_PROJECT_ID, use a bound toloka.json, run toloka project create for a new project, or run toloka project add for an existing project.";
40
44
 
45
+ export function assertProjectSelected(projectId: string | undefined): asserts projectId is string {
46
+ assertCli(projectId, MISSING_PROJECT_ID_MESSAGE, { hint: { disabled: true } });
47
+ }
48
+
41
49
  export const NO_PROJECT_SELECTED_MESSAGE =
42
50
  "No project selected. Run toloka project create for a new project, toloka project add for an existing project, or cd into a checkout.";
43
51
 
@@ -70,9 +78,10 @@ export type ProjectIo = {
70
78
 
71
79
  export const validateProjectAlias = (alias: string): string => {
72
80
  const trimmed = alias.trim();
73
- assert(
81
+ assertCli(
74
82
  trimmed.length > 0 && ALIAS_PATTERN.test(trimmed),
75
83
  "Project alias must be non-empty and contain no / or whitespace.",
84
+ { hint: { description: "Use letters, digits, dashes, or underscores, for example my-project." } },
76
85
  );
77
86
  return trimmed;
78
87
  };
@@ -183,11 +192,16 @@ const assertStoredNetworkMatches = (
183
192
  firstNonEmpty(flags.baseUrl) ??
184
193
  firstNonEmpty(environment.TOLOKA_BASE_URL) ??
185
194
  resolveInternalBaseUrl(environment);
186
- assert(flagOrEnvKey === undefined || flagOrEnvKey === stored.apiKey, NETWORK_MISMATCH_MESSAGE);
187
- assert(
195
+ assertCli(
196
+ flagOrEnvKey === undefined || flagOrEnvKey === stored.apiKey,
197
+ NETWORK_MISMATCH_MESSAGE,
198
+ { hint: { disabled: true } },
199
+ );
200
+ assertCli(
188
201
  flagOrEnvBase === undefined ||
189
202
  normalizeBaseUrl(flagOrEnvBase) === normalizeBaseUrl(stored.baseUrl),
190
203
  NETWORK_MISMATCH_MESSAGE,
204
+ { hint: { disabled: true } },
191
205
  );
192
206
  };
193
207
 
@@ -214,9 +228,9 @@ const promptOrFail = async (
214
228
  question: string,
215
229
  missing: string,
216
230
  ): Promise<string> => {
217
- assert(io?.prompt, missing);
231
+ assertCli(io?.prompt, missing, { hint: { disabled: true } });
218
232
  const value = (await io.prompt(question)).trim();
219
- assert(value.length > 0, missing);
233
+ assertCli(value.length > 0, missing, { hint: { disabled: true } });
220
234
  return value;
221
235
  };
222
236
 
@@ -254,27 +268,37 @@ export async function untrackProjectResource(options: {
254
268
  readonly io: ProjectIo;
255
269
  }): Promise<{ readonly output: string }> {
256
270
  const state = await readTolokaState(options.sourceRoot);
257
- assert(state, `No ${TOLOKA_STATE_FILENAME} found in ${options.sourceRoot}.`);
271
+ assertCli(
272
+ state,
273
+ `No ${TOLOKA_STATE_FILENAME} found in ${options.sourceRoot}.`,
274
+ { hint: { description: "Run toloka init in the source root, or cd into the checkout that holds toloka.json." } },
275
+ );
258
276
  const bindings = options.type === "pipeline" ? state.pipelines : state.datasets;
259
277
  const binding = bindings[options.name];
260
- assert(
278
+ assertCli(
261
279
  binding,
262
280
  `${options.type === "pipeline" ? "Pipeline" : "Dataset"} ${options.name} is not tracked.`,
281
+ { hint: { description: "Open toloka.json to see the tracked pipelines and datasets." } },
263
282
  );
264
283
  const id = "pipelineId" in binding ? binding.pipelineId : binding.datasetId;
265
284
  const label = `${options.type} ${options.name} (${id})`;
266
285
 
267
286
  if (!options.confirm) {
268
- assert(
287
+ assertCli(
269
288
  options.io.prompt,
270
289
  `Refusing to untrack ${label} without confirmation. Pass --confirm after reviewing ${TOLOKA_STATE_FILENAME}.`,
290
+ { hint: { disabled: true } },
271
291
  );
272
292
  const answer = (
273
293
  await options.io.prompt(`Untrack ${label}? The live resource will not be deleted. [y/N] `)
274
294
  )
275
295
  .trim()
276
296
  .toLowerCase();
277
- assert(answer === "y" || answer === "yes", "Untrack canceled.");
297
+ assertCli(
298
+ answer === "y" || answer === "yes",
299
+ "Untrack canceled.",
300
+ { hint: { disabled: true } },
301
+ );
278
302
  }
279
303
 
280
304
  const remaining = Object.fromEntries(
@@ -319,9 +343,10 @@ export async function addProject(options: {
319
343
 
320
344
  const existing = ownProject(found.credentials.projects, alias);
321
345
 
322
- assert(
346
+ assertCli(
323
347
  existing === undefined || existing.id === projectId,
324
348
  `Alias ${alias} already points at ${existing?.id}. Run toloka project remove ${alias} first.`,
349
+ { hint: { disabled: true } },
325
350
  );
326
351
 
327
352
  await options.afterPreflight?.();
@@ -346,7 +371,11 @@ export async function switchProject(options: {
346
371
  assertStoredNetworkMatches(options.flags ?? {}, options.environment, found.credentials);
347
372
  const alias = validateProjectAlias(options.alias);
348
373
  const project = ownProject(found.credentials.projects, alias);
349
- assert(project, `Unknown project alias ${alias}.`);
374
+ assertCli(
375
+ project,
376
+ `Unknown project alias ${alias}.`,
377
+ { hint: { description: "Run toloka project add <alias> <project-id> to register it, or check the alias spelling." } },
378
+ );
350
379
 
351
380
  await writeRegistry({ ...found.credentials, current: alias }, options.environment);
352
381
  const output = `Using project ${project.id} as ${alias}.`;
@@ -367,7 +396,11 @@ export async function removeProject(options: {
367
396
  string,
368
397
  StoredProjectAlias
369
398
  >;
370
- assert(ownProject(projects, alias), `Unknown project alias ${alias}.`);
399
+ assertCli(
400
+ ownProject(projects, alias),
401
+ `Unknown project alias ${alias}.`,
402
+ { hint: { description: "Run toloka project add <alias> <project-id> to register it, or check the alias spelling." } },
403
+ );
371
404
  delete projects[alias];
372
405
 
373
406
  const current = found.credentials.current === alias ? undefined : found.credentials.current;
@@ -401,25 +434,71 @@ export function currentProject(options: {
401
434
  return project === undefined ? { output } : { output, project };
402
435
  }
403
436
 
404
- const uniqueTeamId = (projects: ReadonlyArray<{ readonly teamId: string }>): string | undefined => {
405
- const teams = [...new Set(projects.map((project) => project.teamId))];
406
- return teams.length === 1 ? teams[0] : undefined;
407
- };
408
-
409
437
  const resolveTeamId = async (
410
438
  client: ProjectCreateApi,
411
439
  teamId: string | undefined,
412
440
  ): Promise<string> => {
413
441
  const explicit = firstNonEmpty(teamId);
414
442
  if (explicit !== undefined) return explicit;
415
- const visible: Array<{ readonly teamId: string }> = [];
416
- for await (const project of client.projects.listAll()) visible.push(project);
417
- const inferred = uniqueTeamId(visible);
418
- assert(
419
- inferred,
420
- "Missing team id. Pass --team-id. Visible projects do not share a single team, or none are visible.",
443
+ const { teams } = await client.me();
444
+ assertCli(
445
+ teams !== undefined,
446
+ "Missing team id. Pass --team-id. This platform does not report team memberships, so the team cannot be inferred.",
447
+ {
448
+ hint: {
449
+ description: "Copy the team id from the platform URL after /teams/, or update the platform.",
450
+ },
451
+ },
452
+ );
453
+ const writable = teams.filter((team) => team.writable).map((team) => team.id);
454
+ assertCli(
455
+ writable.length > 0,
456
+ "This account has no writable team, so it cannot create a project. Ask a team admin for write access or use toloka project add for an existing project.",
457
+ { hint: { disabled: true } },
421
458
  );
422
- return inferred;
459
+ const [single] = writable;
460
+ assertCli(
461
+ single !== undefined && writable.length === 1,
462
+ `Missing team id. Pass --team-id. This account can create projects in several teams: ${writable.join(", ")}.`,
463
+ { hint: { disabled: true } },
464
+ );
465
+ return single;
466
+ };
467
+
468
+ const DELIVERY_ORDER_HINT =
469
+ "Internal teams attach a Sunday delivery order to every project: run toloka delivery-order list, then re-run toloka project create with --delivery-order-id <id>.";
470
+
471
+ const DELIVERY_ORDER_NOT_ALLOWED_HINT =
472
+ "The owning team is not internal, so re-run toloka project create without --delivery-order-id. toloka whoami marks internal teams (internal) on its Teams: line.";
473
+
474
+ const deliveryOrderHint = (
475
+ input: Parameters<ProjectCreateApi["projects"]["create"]>[0],
476
+ details: string,
477
+ ): string | undefined => {
478
+ if (!details.includes("deliveryOrderId")) return undefined;
479
+ if (input.deliveryOrderId === undefined && details.includes("required")) return DELIVERY_ORDER_HINT;
480
+ if (input.deliveryOrderId !== undefined && details.includes("only be set")) {
481
+ return DELIVERY_ORDER_NOT_ALLOWED_HINT;
482
+ }
483
+ return undefined;
484
+ };
485
+
486
+ const createLiveProject = async (
487
+ client: ProjectCreateApi,
488
+ input: Parameters<ProjectCreateApi["projects"]["create"]>[0],
489
+ ): Promise<ProjectRecord> => {
490
+ try {
491
+ return await client.projects.create(input);
492
+ } catch (error) {
493
+ if (error instanceof PotatoApiError && error.status === 400) {
494
+ const details = apiErrorDetails(error);
495
+ const hint = details === undefined ? undefined : deliveryOrderHint(input, details);
496
+ if (hint !== undefined) {
497
+ throw new TolokaCliError(error.message, { cause: error, hint: { description: hint } });
498
+ }
499
+ }
500
+ throw error;
501
+ }
423
502
  };
424
503
 
425
504
  const bindCheckout = async (
@@ -437,12 +516,17 @@ const bindCheckout = async (
437
516
  });
438
517
  } catch (error) {
439
518
  const reason = error instanceof Error ? error.message : String(error);
440
- throw new Error(
519
+ throw new TolokaCliError(
441
520
  [
442
521
  `Created project ${created.id} as ${alias}.`,
443
522
  `Failed to write toloka.json: ${reason}`,
444
523
  ].join("\n"),
445
- { cause: error },
524
+ {
525
+ cause: error,
526
+ hint: {
527
+ description: `The project exists. Make ${sourceRoot} writable, then run toloka --project-id ${created.id} init to bind it.`,
528
+ },
529
+ },
446
530
  );
447
531
  }
448
532
  return undefined;
@@ -474,9 +558,10 @@ export async function createProject(options: {
474
558
  "Missing project alias. Pass an alias or run this in a terminal.",
475
559
  )),
476
560
  );
477
- assert(
561
+ assertCli(
478
562
  found.credentials.projects?.[alias] === undefined,
479
563
  `Alias ${alias} already exists. Run toloka project remove ${alias} first.`,
564
+ { hint: { disabled: true } },
480
565
  );
481
566
 
482
567
  const existingState =
@@ -486,7 +571,7 @@ export async function createProject(options: {
486
571
 
487
572
  const teamId = await resolveTeamId(options.client, options.teamId);
488
573
  const deliveryOrderId = firstNonEmpty(options.deliveryOrderId);
489
- const created = await options.client.projects.create({
574
+ const created = await createLiveProject(options.client, {
490
575
  teamId,
491
576
  name: firstNonEmpty(options.name) ?? alias,
492
577
  ...(deliveryOrderId === undefined ? {} : { deliveryOrderId }),
@@ -499,12 +584,12 @@ export async function createProject(options: {
499
584
  );
500
585
  } catch (error) {
501
586
  const reason = error instanceof Error ? error.message : String(error);
502
- throw new Error(
587
+ throw new TolokaCliError(
503
588
  [
504
589
  `Created ${created.id} but failed to update the local registry: ${reason}`,
505
590
  `Run: toloka project add ${alias} ${created.id}`,
506
591
  ].join("\n"),
507
- { cause: error },
592
+ { cause: error, hint: { disabled: true } },
508
593
  );
509
594
  }
510
595