@toloka-platform/sdk 0.2.0 → 0.3.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.
Files changed (62) hide show
  1. package/README.md +12 -6
  2. package/dist/cli/toloka.js +269 -142
  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 +2 -0
  7. package/docs/cli.md +86 -23
  8. package/docs/getting-started.md +11 -9
  9. package/docs/limitations.md +6 -0
  10. package/docs/testing-examples.md +2 -2
  11. package/docs/testing.md +10 -2
  12. package/docs/troubleshooting.md +16 -4
  13. package/openapi/gen/api.ts +30 -0
  14. package/openapi/gen/models/gistingTrainingConfigurationGistTokens.ts +1 -1
  15. package/openapi/gen/models/gistingTrainingConfigurationInputGistTokens.ts +1 -1
  16. package/openapi/gen/models/index.ts +3 -0
  17. package/openapi/gen/models/listAudienceSpecializations200.ts +7 -0
  18. package/openapi/gen/models/listAudienceSpecializationsParams.ts +5 -0
  19. package/openapi/gen/models/specialization.ts +6 -0
  20. package/openapi/gen/models/trainingCostEstimateOneOfTwoseven.ts +0 -2
  21. package/openapi/gen/models/trainingRunQuote.ts +0 -2
  22. package/package.json +1 -1
  23. package/skills/catalog.json +10 -10
  24. package/skills/toloka-build/SKILL.md +8 -5
  25. package/skills/toloka-build/references/node-semantics.md +2 -2
  26. package/skills/toloka-build/references/workflow-and-cli.md +50 -25
  27. package/skills/toloka-fine-tuning/SKILL.md +3 -2
  28. package/skills/toloka-get-started/SKILL.md +4 -3
  29. package/skills/toloka-gisting/SKILL.md +4 -3
  30. package/skills/toloka-report-issue/references/report-template.md +1 -1
  31. package/src/checks/task-ui-typecheck.ts +4 -1
  32. package/src/cli/analytics.ts +1 -0
  33. package/src/cli/audience.ts +31 -0
  34. package/src/cli/auth.ts +382 -81
  35. package/src/cli/auto-update.ts +363 -24
  36. package/src/cli/browser-login.ts +265 -0
  37. package/src/cli/browser-session.ts +244 -0
  38. package/src/cli/check.ts +51 -52
  39. package/src/cli/cli-entry.ts +31 -0
  40. package/src/cli/commands/audience.ts +22 -0
  41. package/src/cli/commands/auth.ts +22 -15
  42. package/src/cli/commands/project.ts +3 -3
  43. package/src/cli/commands/training.ts +10 -2
  44. package/src/cli/config.ts +41 -14
  45. package/src/cli/context.ts +45 -9
  46. package/src/cli/credentials.ts +13 -13
  47. package/src/cli/init.ts +7 -2
  48. package/src/cli/keycloak.ts +189 -0
  49. package/src/cli/models.ts +1 -21
  50. package/src/cli/native-runtime.ts +7 -0
  51. package/src/cli/program.ts +5 -0
  52. package/src/cli/sign-in-page.ts +153 -0
  53. package/src/cli/skill.ts +34 -26
  54. package/src/cli/style.ts +37 -0
  55. package/src/cli/table.ts +21 -0
  56. package/src/cli/terminal-io.ts +9 -2
  57. package/src/cli/trainings.ts +1 -22
  58. package/src/cli/version.ts +12 -1
  59. package/src/client.ts +20 -3
  60. package/src/index.ts +1 -0
  61. package/src/pipeline-graph-v1.contract.json +47 -24
  62. package/src/transport.ts +19 -11
@@ -0,0 +1,189 @@
1
+ import { TolokaCliError } from "./cli-error";
2
+
3
+ const KEYCLOAK_REQUEST_TIMEOUT_MS = 15_000;
4
+
5
+ export type IdentityProviderChoice = {
6
+ readonly alias: string;
7
+ readonly name: string;
8
+ readonly variant: "outline" | "ghost";
9
+ readonly hint?: string;
10
+ };
11
+
12
+ const GOOGLE: IdentityProviderChoice = { alias: "google", name: "Google", variant: "outline", hint: "google" };
13
+ const MICROSOFT: IdentityProviderChoice = { alias: "ms", name: "Microsoft", variant: "outline", hint: "ms" };
14
+ const EMAIL: IdentityProviderChoice = { alias: "email", name: "Email", variant: "ghost" };
15
+ const TESTING_USERS: IdentityProviderChoice = {
16
+ alias: "testing-users",
17
+ name: "Testing",
18
+ variant: "ghost",
19
+ hint: "testing-users",
20
+ };
21
+
22
+ export type BrowserLoginProvider = {
23
+ readonly authHost: string;
24
+ readonly realm: string;
25
+ readonly clientId: string;
26
+ readonly identityProviders: readonly IdentityProviderChoice[];
27
+ readonly issuer: string;
28
+ readonly authorizationEndpoint: string;
29
+ readonly tokenEndpoint: string;
30
+ readonly logoutEndpoint: string;
31
+ };
32
+
33
+ const keycloakProvider = (
34
+ authHost: string,
35
+ realm: string,
36
+ clientId: string,
37
+ identityProviders: readonly IdentityProviderChoice[],
38
+ ): BrowserLoginProvider => {
39
+ const issuer = `https://${authHost}/realms/${realm}`;
40
+ return {
41
+ authHost,
42
+ realm,
43
+ clientId,
44
+ identityProviders,
45
+ issuer,
46
+ authorizationEndpoint: `${issuer}/protocol/openid-connect/auth`,
47
+ tokenEndpoint: `${issuer}/protocol/openid-connect/token`,
48
+ logoutEndpoint: `${issuer}/protocol/openid-connect/logout`,
49
+ };
50
+ };
51
+
52
+ const PRODUCTION_PROVIDER = keycloakProvider("auth.toloka.ai", "toloka", "platform-cli-production", [
53
+ GOOGLE,
54
+ MICROSOFT,
55
+ ]);
56
+ const TESTING_PROVIDER = keycloakProvider("auth.toloka-test.ai", "toloka", "platform-cli-testing", [
57
+ GOOGLE,
58
+ MICROSOFT,
59
+ EMAIL,
60
+ TESTING_USERS,
61
+ ]);
62
+
63
+ export const KNOWN_BROWSER_LOGIN_PROVIDERS: readonly BrowserLoginProvider[] = [
64
+ PRODUCTION_PROVIDER,
65
+ TESTING_PROVIDER,
66
+ ];
67
+
68
+ export const browserLoginProviderFor = (baseUrl: string): BrowserLoginProvider | undefined => {
69
+ let url: URL;
70
+ try {
71
+ url = new URL(baseUrl);
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ if (url.protocol !== "https:") return undefined;
76
+ if (url.host === "platform.toloka.ai") return PRODUCTION_PROVIDER;
77
+ if (url.host.endsWith(".platform.toloka-test.ai")) return TESTING_PROVIDER;
78
+ return undefined;
79
+ };
80
+
81
+ export type TokenResponse = {
82
+ readonly access_token: string;
83
+ readonly expires_in: number;
84
+ readonly refresh_token: string;
85
+ readonly refresh_expires_in: number;
86
+ readonly scope: string;
87
+ };
88
+
89
+ const readField = (payload: unknown, key: string): unknown =>
90
+ typeof payload === "object" && payload !== null ? Reflect.get(payload, key) : undefined;
91
+
92
+ const parseJson = (text: string): unknown => {
93
+ try {
94
+ return JSON.parse(text);
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ };
99
+
100
+ const parseTokenResponse = (text: string): TokenResponse | undefined => {
101
+ const payload = parseJson(text);
102
+ const accessToken = readField(payload, "access_token");
103
+ const expiresIn = readField(payload, "expires_in");
104
+ const refreshToken = readField(payload, "refresh_token");
105
+ const refreshExpiresIn = readField(payload, "refresh_expires_in") ?? 0;
106
+ const scope = readField(payload, "scope") ?? "";
107
+ if (typeof accessToken !== "string" || accessToken.length === 0) return undefined;
108
+ if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn)) return undefined;
109
+ if (typeof refreshToken !== "string" || refreshToken.length === 0) return undefined;
110
+ if (typeof refreshExpiresIn !== "number" || !Number.isFinite(refreshExpiresIn)) return undefined;
111
+ if (typeof scope !== "string") return undefined;
112
+ return {
113
+ access_token: accessToken,
114
+ expires_in: expiresIn,
115
+ refresh_token: refreshToken,
116
+ refresh_expires_in: refreshExpiresIn,
117
+ scope,
118
+ };
119
+ };
120
+
121
+ export class TokenRequestError extends Error {
122
+ readonly status: number;
123
+ readonly oauthError: string | undefined;
124
+
125
+ constructor(status: number, body: string) {
126
+ const payload = parseJson(body);
127
+ const error = readField(payload, "error");
128
+ const description = readField(payload, "error_description");
129
+ const detail =
130
+ typeof description === "string" && description.length > 0
131
+ ? typeof error === "string"
132
+ ? `${error}: ${description}`
133
+ : description
134
+ : typeof error === "string"
135
+ ? error
136
+ : body.slice(0, 200) || "empty response";
137
+ super(`The login service answered ${status}: ${detail}`);
138
+ this.name = "TokenRequestError";
139
+ this.status = status;
140
+ this.oauthError = typeof error === "string" ? error : undefined;
141
+ }
142
+ }
143
+
144
+ export const requestTokens = async (
145
+ provider: BrowserLoginProvider,
146
+ form: Readonly<Record<string, string>>,
147
+ fetchImplementation: typeof globalThis.fetch = globalThis.fetch,
148
+ ): Promise<TokenResponse> => {
149
+ const response = await fetchImplementation(provider.tokenEndpoint, {
150
+ method: "POST",
151
+ headers: {
152
+ Accept: "application/json",
153
+ "Content-Type": "application/x-www-form-urlencoded",
154
+ },
155
+ body: new URLSearchParams({ client_id: provider.clientId, ...form }).toString(),
156
+ signal: AbortSignal.timeout(KEYCLOAK_REQUEST_TIMEOUT_MS),
157
+ });
158
+ const text = await response.text();
159
+ if (!response.ok) throw new TokenRequestError(response.status, text);
160
+ const tokens = parseTokenResponse(text);
161
+ if (tokens === undefined) {
162
+ throw new TolokaCliError(
163
+ `${provider.issuer} returned a token response the CLI does not understand.`,
164
+ { hint: { description: "Retry; if it keeps failing, report it with toloka report." } },
165
+ );
166
+ }
167
+ return tokens;
168
+ };
169
+
170
+ export const endKeycloakSession = async (
171
+ provider: BrowserLoginProvider,
172
+ refreshToken: string,
173
+ fetchImplementation: typeof globalThis.fetch = globalThis.fetch,
174
+ ): Promise<boolean> => {
175
+ try {
176
+ const response = await fetchImplementation(provider.logoutEndpoint, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
179
+ body: new URLSearchParams({
180
+ client_id: provider.clientId,
181
+ refresh_token: refreshToken,
182
+ }).toString(),
183
+ signal: AbortSignal.timeout(KEYCLOAK_REQUEST_TIMEOUT_MS),
184
+ });
185
+ return response.ok;
186
+ } catch {
187
+ return false;
188
+ }
189
+ };
package/src/cli/models.ts CHANGED
@@ -8,10 +8,9 @@ import { pipeline } from "node:stream/promises";
8
8
 
9
9
  import type { BaseModel, BaseModelId, Model, ModelVersion, PotatoClient } from "../client";
10
10
  import { assertCli, REPORT_HINT, RETRY_OR_REPORT_HINT } from "./cli-error";
11
+ import { formatTable, type TableIo as ModelIo, writeOutput } from "./table";
11
12
 
12
13
  type ModelApi = Pick<PotatoClient, "baseModels" | "models">;
13
- type ModelIo = { readonly stdout: (text: string) => void };
14
- type TableRow = readonly string[];
15
14
 
16
15
  const BASE_MODEL_IDS = [
17
16
  "qwen3-4b",
@@ -20,25 +19,6 @@ const BASE_MODEL_IDS = [
20
19
  "qwen3-235b",
21
20
  ] satisfies readonly BaseModelId[];
22
21
 
23
- const formatTable = (rows: readonly TableRow[]): string => {
24
- const widths = rows.reduce<number[]>(
25
- (current, row) => row.map((cell, index) => Math.max(current[index] ?? 0, cell.length)),
26
- [],
27
- );
28
- return rows
29
- .map((row) =>
30
- row
31
- .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
32
- .join(" ")
33
- .trimEnd(),
34
- )
35
- .join("\n");
36
- };
37
-
38
- const writeOutput = (io: ModelIo, json: boolean, value: unknown, formatted: string): void => {
39
- io.stdout(json ? JSON.stringify(value, null, 2) : formatted);
40
- };
41
-
42
22
  const formatBaseModel = (model: BaseModel): string =>
43
23
  formatTable([
44
24
  ["ID", model.id],
@@ -0,0 +1,7 @@
1
+ export const isNativeCli = (): boolean =>
2
+ Reflect.get(globalThis, "Bun")?.isStandaloneExecutable === true;
3
+
4
+ export const bunSubprocessEnvironment = (
5
+ environment: NodeJS.ProcessEnv,
6
+ native = isNativeCli(),
7
+ ): NodeJS.ProcessEnv => (native ? { ...environment, BUN_BE_BUN: "1" } : environment);
@@ -4,7 +4,9 @@ import { commandNameFromArgv, createCliAnalytics } from "./analytics";
4
4
  import type { CliAnalytics, CliAnalyticsCommandOutcome } from "./analytics";
5
5
  import type { AutoUpdateHooks } from "./auto-update";
6
6
  import { maybeAutoUpdate } from "./auto-update";
7
+ import type { BrowserLoginFlow } from "./browser-login";
7
8
  import { CHECK_RESULT_FILE_ENV, type checkProject } from "./check";
9
+ import { registerAudienceCommands } from "./commands/audience";
8
10
  import { registerAuthCommands } from "./commands/auth";
9
11
  import { registerConfigCommands } from "./commands/config";
10
12
  import { registerDatasetCommands } from "./commands/dataset";
@@ -91,6 +93,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
91
93
  registerAuthCommands(program, host);
92
94
  registerProjectCommands(program, host);
93
95
  registerDeliveryOrderCommands(program, host);
96
+ registerAudienceCommands(program, host);
94
97
  registerSourceCommands(program, host);
95
98
  registerSecretCommands(program, host);
96
99
  registerModelCommands(program, host);
@@ -194,6 +197,7 @@ export async function runTolokaCli(
194
197
  analytics?: CliAnalytics,
195
198
  runChecks?: typeof checkProject,
196
199
  autoUpdate?: AutoUpdateHooks,
200
+ browserLogin?: BrowserLoginFlow,
197
201
  ): Promise<number> {
198
202
  const { environment, warnings } = normalizeEnvironment(rawEnvironment);
199
203
  for (const warning of warnings) io.stderr(warning);
@@ -214,6 +218,7 @@ export async function runTolokaCli(
214
218
  io,
215
219
  ...(activeAnalytics === undefined ? {} : { analytics: activeAnalytics }),
216
220
  ...(injectedClient === undefined ? {} : { injectedClient }),
221
+ ...(browserLogin === undefined ? {} : { browserLogin }),
217
222
  ...(runChecks === undefined ? {} : { runChecks }),
218
223
  };
219
224
  const { program, exitCode } = createProgram(baseContext);
@@ -0,0 +1,153 @@
1
+ import type { IdentityProviderChoice } from "./keycloak";
2
+
3
+ const escapeHtml = (text: string): string =>
4
+ text.replace(
5
+ /[&<>"']/gu,
6
+ (character) =>
7
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ??
8
+ character,
9
+ );
10
+
11
+ const STYLE = `
12
+ :root {
13
+ color-scheme: light dark;
14
+ --background: oklch(0.967 0.001 286.375 / 60%);
15
+ --card: oklch(1 0 0);
16
+ --foreground: oklch(0.141 0.005 285.823);
17
+ --primary: oklch(0.21 0.006 285.885);
18
+ --muted: oklch(0.967 0.001 286.375);
19
+ --muted-foreground: oklch(0.552 0.016 285.938);
20
+ --border: oklch(0.92 0.004 286.32);
21
+ --ring: oklch(0.141 0.005 285.823 / 10%);
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ :root {
25
+ --background: oklch(0.141 0.005 285.823);
26
+ --card: oklch(0.21 0.006 285.885);
27
+ --foreground: oklch(0.985 0 0);
28
+ --primary: oklch(0.92 0.004 286.32);
29
+ --muted: oklch(0.274 0.006 286.033);
30
+ --muted-foreground: oklch(0.705 0.015 286.067);
31
+ --border: oklch(1 0 0 / 10%);
32
+ --ring: oklch(1 0 0 / 10%);
33
+ }
34
+ }
35
+ * { box-sizing: border-box; }
36
+ body {
37
+ margin: 0;
38
+ min-height: 100vh;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ background: var(--background);
43
+ color: var(--foreground);
44
+ font-family: Antarctica, SuisseIntl, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
45
+ font-size: 14px;
46
+ line-height: 1.45;
47
+ -webkit-font-smoothing: antialiased;
48
+ }
49
+ .stage {
50
+ position: relative;
51
+ top: -2rem;
52
+ display: flex;
53
+ flex-direction: column;
54
+ align-items: center;
55
+ gap: 1rem;
56
+ padding: 0 0.5rem;
57
+ animation: enter 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
58
+ }
59
+ @keyframes enter {
60
+ from { opacity: 0; transform: translateY(100px); }
61
+ to { opacity: 1; transform: none; }
62
+ }
63
+ .logo { height: 3rem; width: 9rem; color: var(--primary); }
64
+ .card {
65
+ width: 100%;
66
+ max-width: 380px;
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 2rem;
70
+ padding-top: 1rem;
71
+ overflow: hidden;
72
+ border-radius: 0.75rem;
73
+ background: var(--card);
74
+ box-shadow: 0 0 0 1px var(--ring);
75
+ }
76
+ .header { padding: 0 1rem; text-align: center; display: grid; gap: 0.25rem; }
77
+ .title { margin: 0; font-size: 2.25rem; line-height: 1.2; font-weight: 500; }
78
+ .title span, .lead span { display: block; }
79
+ .lead { margin: 0; font-size: 1rem; color: var(--primary); }
80
+ .content { margin-top: 1.5rem; padding: 0 1rem; display: flex; flex-direction: column; gap: 1rem; }
81
+ .button {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ justify-content: center;
85
+ gap: 0.75rem;
86
+ height: 2.75rem;
87
+ width: 100%;
88
+ padding: 0 0.875rem;
89
+ border: 1px solid var(--border);
90
+ border-radius: 9999px;
91
+ background: var(--card);
92
+ color: var(--foreground);
93
+ font: inherit;
94
+ font-weight: 600;
95
+ text-decoration: none;
96
+ white-space: nowrap;
97
+ transition: background-color 0.15s ease;
98
+ }
99
+ .button:hover, .button:focus-visible { background: var(--muted); outline: none; }
100
+ .button svg { width: 1.5rem; height: 1.5rem; flex: none; }
101
+ .button.ghost { border-color: transparent; background: transparent; color: var(--muted-foreground); height: 2.5rem; }
102
+ .button.ghost:hover, .button.ghost:focus-visible { background: var(--muted); color: var(--foreground); }
103
+ .footer {
104
+ border-top: 1px solid var(--border);
105
+ background: color-mix(in oklch, var(--muted) 50%, transparent);
106
+ padding: 1rem;
107
+ text-align: center;
108
+ font-size: 0.8rem;
109
+ color: var(--muted-foreground);
110
+ }
111
+ .footer a { color: var(--primary); font-weight: 500; text-decoration: underline; text-underline-offset: 4px; }
112
+ .footer div + div { margin-top: 0.5rem; }
113
+ `;
114
+
115
+ const TOLOKA_LOGO = `<svg class="logo" viewBox="0 0 99 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Toloka"><path d="M54.7053 8H42.8716V9.94458H47.6776V23.7003H49.9094V9.94458H54.7053V8Z" fill="currentColor"/><path d="M61.1436 13.8363C60.7103 13.335 60.1687 12.9471 59.5264 12.6725C58.8816 12.398 58.136 12.2619 57.2897 12.2619C56.4433 12.2619 55.7078 12.398 55.068 12.6725C54.4282 12.9471 53.8891 13.335 53.4559 13.8363C53.0226 14.3375 52.6927 14.937 52.4735 15.6347C52.2519 16.3325 52.1436 17.1159 52.1436 17.9849V18.272C52.1436 19.1335 52.2544 19.9169 52.4735 20.6222C52.6952 21.3274 53.0226 21.9295 53.4559 22.4282C53.8891 22.9269 54.4282 23.3123 55.068 23.5869C55.7078 23.8614 56.4483 23.9975 57.2897 23.9975C58.131 23.9975 58.8816 23.8614 59.5264 23.5869C60.1713 23.3123 60.7103 22.9269 61.1436 22.4282C61.5768 21.9295 61.9043 21.3274 62.1259 20.6222C62.3476 19.9169 62.4559 19.1335 62.4559 18.272V17.9849C62.4559 17.1159 62.3451 16.3325 62.1259 15.6347C61.9043 14.937 61.5768 14.3375 61.1436 13.8363ZM60.1813 18.3174C60.1813 18.9219 60.1259 19.466 60.0151 19.9471C59.9043 20.4282 59.733 20.8388 59.4962 21.1813C59.2619 21.5239 58.9597 21.7859 58.5944 21.9672C58.2267 22.1486 57.7909 22.2393 57.2871 22.2393C56.7834 22.2393 56.3551 22.1486 55.9899 21.9672C55.6221 21.7859 55.3224 21.5239 55.0881 21.1813C54.8539 20.8388 54.6801 20.4282 54.5692 19.9471C54.4584 19.466 54.403 18.9244 54.403 18.3174V17.932C54.403 17.335 54.4584 16.7934 54.5692 16.3148C54.6801 15.8337 54.8514 15.4257 55.0881 15.0856C55.3224 14.7481 55.6247 14.4861 55.9899 14.2997C56.3577 14.1159 56.7884 14.0227 57.2871 14.0227C57.7859 14.0227 58.2292 14.1159 58.5944 14.2997C58.9622 14.4861 59.2619 14.7456 59.4962 15.0856C59.7305 15.4232 59.9043 15.8337 60.0151 16.3148C60.1259 16.796 60.1813 17.335 60.1813 17.932V18.3174Z" fill="currentColor"/><path d="M66.3302 8H64.1841V23.7003H66.3302V8Z" fill="currentColor"/><path d="M77.0728 13.8363C76.6395 13.335 76.0979 12.9471 75.4556 12.6725C74.8108 12.398 74.0652 12.2619 73.2188 12.2619C72.3725 12.2619 71.637 12.398 70.9972 12.6725C70.3574 12.9471 69.8183 13.335 69.3851 13.8363C68.9518 14.3375 68.6219 14.937 68.4027 15.6347C68.1811 16.3325 68.0728 17.1159 68.0728 17.9849V18.272C68.0728 19.1335 68.1836 19.9169 68.4027 20.6222C68.6244 21.3274 68.9518 21.9295 69.3851 22.4282C69.8183 22.9269 70.3574 23.3123 70.9972 23.5869C71.637 23.8614 72.3775 23.9975 73.2188 23.9975C74.0602 23.9975 74.8108 23.8614 75.4556 23.5869C76.1005 23.3123 76.6395 22.9269 77.0728 22.4282C77.506 21.9295 77.8335 21.3274 78.0551 20.6222C78.2768 19.9169 78.3851 19.1335 78.3851 18.272V17.9849C78.3851 17.1159 78.2743 16.3325 78.0551 15.6347C77.8335 14.937 77.506 14.3375 77.0728 13.8363ZM76.1131 18.3174C76.1131 18.9219 76.0576 19.466 75.9468 19.9471C75.836 20.4282 75.6647 20.8388 75.4279 21.1813C75.1937 21.5239 74.8914 21.7859 74.5262 21.9672C74.1584 22.1486 73.7226 22.2393 73.2188 22.2393C72.7151 22.2393 72.2869 22.1486 71.9216 21.9672C71.5539 21.7859 71.2541 21.5239 71.0199 21.1813C70.7856 20.8388 70.6118 20.4282 70.501 19.9471C70.3901 19.466 70.3347 18.9244 70.3347 18.3174V17.932C70.3347 17.335 70.3901 16.7934 70.501 16.3148C70.6118 15.8337 70.7831 15.4257 71.0199 15.0856C71.2541 14.7481 71.5564 14.4861 71.9216 14.2997C72.2894 14.1159 72.7201 14.0227 73.2188 14.0227C73.7176 14.0227 74.1609 14.1159 74.5262 14.2997C74.8939 14.4861 75.1937 14.7456 75.4279 15.0856C75.6622 15.4232 75.836 15.8337 75.9468 16.3148C76.0576 16.796 76.1131 17.335 76.1131 17.932V18.3174Z" fill="currentColor"/><path d="M80.043 8V23.7003H82.1689V18.7305L86.4838 23.7003H89.2722L84.0379 17.7733L89.0052 12.5718H86.3881L82.1689 17.2141V8H80.043Z" fill="currentColor"/><path d="M29.267 8H-0.188965V9.94458H13.4307V23.7003H15.6649V9.94458H29.267V8Z" fill="currentColor"/><path d="M98.8563 22.9471C98.816 22.6952 98.7858 22.4282 98.7657 22.1461C98.7455 21.864 98.7329 21.5617 98.7279 21.2393C98.7254 20.9143 98.7228 20.5567 98.7228 20.1662L98.743 16.7481C98.743 16.2065 98.7002 15.7204 98.6145 15.2897C98.5289 14.8589 98.4029 14.476 98.2342 14.141C98.0679 13.806 97.8589 13.5189 97.6095 13.2821C97.3601 13.0428 97.073 12.8488 96.7505 12.6952C96.4256 12.5415 96.0654 12.4282 95.6674 12.3526C95.2694 12.2771 94.8387 12.2393 94.3752 12.2393C93.7128 12.2393 93.1158 12.3274 92.5818 12.5012C92.0478 12.675 91.5893 12.9219 91.2039 13.2443C90.8186 13.5642 90.5138 13.9572 90.2845 14.4181C90.0578 14.8816 89.9243 15.4005 89.8891 15.9773H92.0679C92.083 15.6347 92.1385 15.335 92.2392 15.0755C92.34 14.8161 92.4835 14.5995 92.6725 14.4282C92.8614 14.2569 93.0931 14.1284 93.3727 14.0378C93.6498 13.9496 93.9747 13.9043 94.345 13.9043C94.6926 13.9043 95.0075 13.9546 95.2845 14.0529C95.5616 14.1536 95.7984 14.3048 95.9898 14.5113C96.1813 14.7179 96.3299 14.9823 96.4332 15.3073C96.5364 15.6322 96.5868 16.0151 96.5868 16.4559V16.8211C95.549 16.8791 94.5994 16.9773 93.7405 17.1209C92.8689 17.267 92.1259 17.4836 91.5062 17.7758C90.8891 18.0655 90.408 18.4458 90.0553 18.9169C89.7027 19.3879 89.5264 19.9748 89.5264 20.6801V20.7431C89.5264 21.2619 89.6246 21.7229 89.8211 22.1259C90.0175 22.5289 90.2871 22.869 90.6271 23.1461C90.9697 23.4232 91.3727 23.6347 91.8362 23.7758C92.2997 23.9194 92.7808 23.9949 93.2971 23.9949C93.7102 23.9949 94.0931 23.9597 94.4483 23.8917C94.8034 23.8237 95.1259 23.7229 95.4181 23.5944C95.7102 23.466 95.9747 23.3073 96.209 23.1184C96.4432 22.9295 96.6448 22.7153 96.8186 22.4307C96.816 22.932 96.8538 23.3123 96.9521 23.6952H99.0024C98.9445 23.4458 98.8966 23.1939 98.8589 22.942L98.8563 22.9471ZM96.6397 19.2519C96.6397 19.5995 96.6044 19.9143 96.5364 20.1939C96.4684 20.4735 96.3702 20.7229 96.2442 20.942C96.1183 21.1612 95.9621 21.3526 95.7833 21.5164C95.6019 21.6801 95.4029 21.8161 95.1863 21.9219C94.9697 22.0277 94.7329 22.1058 94.481 22.1587C94.2266 22.209 93.9621 22.2368 93.6876 22.2368C93.3626 22.2368 93.0679 22.2015 92.806 22.1284C92.544 22.0579 92.3223 21.9496 92.1385 21.8111C91.9546 21.67 91.811 21.4962 91.7102 21.2871C91.6095 21.0781 91.5591 20.8388 91.5591 20.5642V20.5038C91.5591 20.1284 91.6674 19.8111 91.884 19.5541C92.1007 19.2972 92.4256 19.0907 92.8563 18.9345C93.2871 18.7783 93.8236 18.6574 94.4659 18.5768C95.0931 18.4962 95.8211 18.4357 96.6448 18.3954V19.2544L96.6397 19.2519Z" fill="currentColor"/></svg>`;
116
+
117
+ const ICONS: Readonly<Record<string, string>> = {
118
+ google: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M30.0014 16.3109C30.0014 15.1598 29.9061 14.3198 29.6998 13.4487H16.2871V18.6442H24.1601C24.0014 19.9354 23.1442 21.8798 21.2394 23.1864L21.2127 23.3604L25.4536 26.58L25.7474 26.6087C28.4458 24.1665 30.0014 20.5731 30.0014 16.3109Z" fill="#4285F4"/><path d="M16.2863 29.9998C20.1434 29.9998 23.3814 28.7553 25.7466 26.6086L21.2386 23.1863C20.0323 24.0108 18.4132 24.5863 16.2863 24.5863C12.5086 24.5863 9.30225 22.1441 8.15929 18.7686L7.99176 18.7825L3.58208 22.127L3.52441 22.2841C5.87359 26.8574 10.699 29.9998 16.2863 29.9998Z" fill="#34A853"/><path d="M8.15964 18.769C7.85806 17.8979 7.68352 16.9645 7.68352 16.0001C7.68352 15.0356 7.85806 14.1023 8.14377 13.2312L8.13578 13.0456L3.67083 9.64746L3.52475 9.71556C2.55654 11.6134 2.00098 13.7445 2.00098 16.0001C2.00098 18.2556 2.55654 20.3867 3.52475 22.2845L8.15964 18.769Z" fill="#FBBC05"/><path d="M16.2864 7.4133C18.9689 7.4133 20.7784 8.54885 21.8102 9.4978L25.8419 5.64C23.3658 3.38445 20.1435 2 16.2864 2C10.699 2 5.8736 5.1422 3.52441 9.71549L8.14345 13.2311C9.30229 9.85555 12.5086 7.4133 16.2864 7.4133Z" fill="#EB4335"/></svg>`,
119
+ ms: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect x="17" y="17" width="10" height="10" fill="#FEBA08"/><rect x="5" y="17" width="10" height="10" fill="#05A6F0"/><rect x="17" y="5" width="10" height="10" fill="#80BC06"/><rect x="5" y="5" width="10" height="10" fill="#F25325"/></svg>`,
120
+ "testing-users": `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#FFCC4D" d="M22.845 13.625c-.21-1.907-.428-2.875-1.012-4.042 0 0 1.75-5.25.584-7.408-.394-.727-4.32.985-5.834 2.158 0 0-2.333-.582-4.083-.582s-4.083.582-4.083.582C6.902 3.16 2.977 1.448 2.583 2.175c-1.166 2.158.584 7.408.584 7.408-.584 1.167-.803 2.135-1.012 4.042-.23 2.09.228 3.757.428 4.125.228.418 1.381 1.764 2.917 2.916 2.333 1.75 5.833 2.334 7 2.334 1.166 0 4.666-.584 7-2.334 1.536-1.152 2.689-2.498 2.916-2.916.2-.368.66-2.034.429-4.125z"/><path fill="#664500" d="M18.6 12.382c-.035-.08-.874-1.924-2.6-1.924-1.725 0-2.565 1.845-2.6 1.924a.294.294 0 00.44.353c.008-.006.737-.527 2.16-.527 1.415 0 2.143.514 2.16.526a.29.29 0 00.44-.352zm-7 0c-.035-.08-.874-1.924-2.6-1.924-1.725 0-2.565 1.845-2.6 1.924a.291.291 0 00.44.353c.008-.006.736-.527 2.16-.527 1.415 0 2.143.514 2.16.526a.29.29 0 00.44-.352zm3.617 3.406a.299.299 0 01.348-.035.29.29 0 01.135.318c-.007.025-.668 2.554-3.2 2.554s-3.193-2.529-3.199-2.554a.292.292 0 01.489-.278s.586.499 2.71.499c2.126 0 2.71-.499 2.717-.504z"/></svg>`,
121
+ email: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`,
122
+ };
123
+
124
+ const document = (title: string, body: string): string =>
125
+ `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><style>${STYLE}</style></head><body><main class="stage">${TOLOKA_LOGO}<section class="card" role="region" aria-labelledby="title">${body}</section></main></body></html>`;
126
+
127
+ const button = (choice: IdentityProviderChoice, href: string): string =>
128
+ `<a class="button ${choice.variant}" href="${escapeHtml(href)}">${ICONS[choice.alias] ?? ""}Continue with ${escapeHtml(choice.name)}</a>`;
129
+
130
+ const externalLink = (href: string, text: string): string =>
131
+ `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`;
132
+
133
+ export const signInPage = (
134
+ choices: readonly IdentityProviderChoice[],
135
+ authorizationUrlFor: (hint: string | undefined) => string,
136
+ registrationUrl?: string,
137
+ ): string => {
138
+ const buttons = choices.map((choice) => button(choice, authorizationUrlFor(choice.hint))).join("");
139
+ const registration =
140
+ registrationUrl === undefined
141
+ ? ""
142
+ : `<div>New to Toloka? ${externalLink(registrationUrl, "Create an account")}. By signing up, you agree to our ${externalLink("https://toloka.ai/legal/self-service-agreement", "User Agreement")}.</div>`;
143
+ return document(
144
+ "Sign in to Toloka",
145
+ `<header class="header"><h1 class="title" id="title"><span>Welcome</span><span>to Toloka Platform</span></h1><p class="lead"><span>Choose how to sign in.</span><span>The Toloka CLI is waiting in your terminal.</span></p></header><div class="content">${buttons}</div><footer class="footer">${registration}<div>You can close this tab once you are signed in.</div><div>Want to use an API key instead? Run <code>toloka login</code> and choose API key. ${externalLink("https://platform.toloka.ai/docs/integration/cli/", "Help")}</div></footer>`,
146
+ );
147
+ };
148
+
149
+ export const statusPage = (title: string, body: string): string =>
150
+ document(
151
+ title,
152
+ `<header class="header"><h1 class="title" id="title">${escapeHtml(title)}</h1><p class="lead">${escapeHtml(body)}</p></header><footer class="footer"><div>Toloka CLI</div></footer>`,
153
+ );
package/src/cli/skill.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import { createHash } from "node:crypto";
2
- import { copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises";
2
+ import {
3
+ copyFile,
4
+ mkdir,
5
+ mkdtemp,
6
+ readFile,
7
+ realpath,
8
+ rename,
9
+ rm,
10
+ stat,
11
+ writeFile,
12
+ } from "node:fs/promises";
3
13
  import { dirname, join } from "node:path";
4
14
  import { assertSafeDestination, entryStats, hasErrorCode } from "./file-system";
5
15
  import { formatCliError } from "./format-error";
6
- import { releaseChannelFor, SDK_PACKAGE_NAME, SDK_VERSION } from "./sdk-version";
16
+ import { SDK_PACKAGE_NAME, SDK_VERSION } from "./sdk-version";
7
17
  import { assertCli, REINSTALL_HINT, TolokaCliError } from "./cli-error";
8
18
 
9
19
  export const SKILL_CATALOG_MANIFEST = "catalog.json";
@@ -16,9 +26,7 @@ const MAX_MANIFEST_BYTES = 1024 * 1024;
16
26
  const MAX_SKILL_ID_LENGTH = 64;
17
27
  const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
18
28
  const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
19
- const UPGRADE_HINT = `bun add --global ${SDK_PACKAGE_NAME}@${
20
- releaseChannelFor(SDK_VERSION) ?? "latest"
21
- }`;
29
+ const UPGRADE_HINT = "rerun the CLI installer to update";
22
30
 
23
31
  export type SkillDescriptor = {
24
32
  readonly id: string;
@@ -86,21 +94,15 @@ const parseDescriptor = (value: unknown, invalid: (detail: string) => string): S
86
94
  { hint: REINSTALL_HINT },
87
95
  );
88
96
  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
- );
97
+ assertCli(isNonEmptyString(description), invalid(`skill ${id} needs a description.`), {
98
+ hint: REINSTALL_HINT,
99
+ });
100
+ assertCli(isStringArray(tags), invalid(`skill ${id} tags must be strings.`), {
101
+ hint: REINSTALL_HINT,
102
+ });
103
+ assertCli(isNonEmptyString(version), invalid(`skill ${id} needs a version.`), {
104
+ hint: REINSTALL_HINT,
105
+ });
104
106
  assertCli(
105
107
  typeof digest === "string" && DIGEST_PATTERN.test(digest),
106
108
  invalid(`skill ${id} digest must be the sha256 hex digest of its files.`),
@@ -154,6 +156,9 @@ const findSkillCatalogRootAbove = async (directory: string): Promise<string | un
154
156
  export async function resolveBundledSkillCatalogRoot(
155
157
  runtimeEntry: string | undefined = process.argv[1],
156
158
  ): Promise<string> {
159
+ if (Reflect.get(globalThis, "Bun")?.isStandaloneExecutable === true) {
160
+ return join(dirname(process.argv[1]!), "skills");
161
+ }
157
162
  const entry = runtimeEntry === undefined ? undefined : await realpath(runtimeEntry);
158
163
  const runtimeRoot =
159
164
  entry === undefined ? undefined : await findSkillCatalogRootAbove(dirname(entry));
@@ -197,11 +202,9 @@ export async function loadSkillCatalog(catalogRoot: string): Promise<SkillCatalo
197
202
  const skills = manifest.skills.map((skill) => parseDescriptor(skill, invalid));
198
203
  const ids = new Set<string>();
199
204
  for (const skill of skills) {
200
- assertCli(
201
- !ids.has(skill.id),
202
- invalid(`skill id ${skill.id} appears more than once.`),
203
- { hint: REINSTALL_HINT },
204
- );
205
+ assertCli(!ids.has(skill.id), invalid(`skill id ${skill.id} appears more than once.`), {
206
+ hint: REINSTALL_HINT,
207
+ });
205
208
  ids.add(skill.id);
206
209
  }
207
210
  return { root: catalogRoot, manifestPath, skills };
@@ -329,7 +332,12 @@ export async function installSkill({
329
332
  for (const path of skill.files) {
330
333
  const target = join(temporary, path);
331
334
  await mkdir(dirname(target), { recursive: true });
332
- await copyFile(join(catalog.root, skill.id, path), target);
335
+ const source = join(catalog.root, skill.id, path);
336
+ if (Reflect.get(globalThis, "Bun")?.isStandaloneExecutable === true) {
337
+ await writeFile(target, await readFile(source));
338
+ } else {
339
+ await copyFile(source, target);
340
+ }
333
341
  }
334
342
  assertCli(
335
343
  (await computeSkillDigest(temporary, skill.files)) === skill.digest,
@@ -0,0 +1,37 @@
1
+ export type Style = {
2
+ readonly bold: (text: string) => string;
3
+ readonly dim: (text: string) => string;
4
+ readonly green: (text: string) => string;
5
+ readonly cyan: (text: string) => string;
6
+ };
7
+
8
+ const ESC = String.fromCharCode(27);
9
+
10
+ const paint =
11
+ (open: number, close: number) =>
12
+ (text: string): string =>
13
+ `${ESC}[${open}m${text}${ESC}[${close}m`;
14
+
15
+ const identity = (text: string): string => text;
16
+
17
+ export const ANSI_STYLE: Style = {
18
+ bold: paint(1, 22),
19
+ dim: paint(2, 22),
20
+ green: paint(32, 39),
21
+ cyan: paint(36, 39),
22
+ };
23
+
24
+ export const PLAIN_STYLE: Style = { bold: identity, dim: identity, green: identity, cyan: identity };
25
+
26
+ export const styleFor = (
27
+ environment: Readonly<Record<string, string | undefined>>,
28
+ isTerminal: boolean,
29
+ ): Style => {
30
+ if (environment.FORCE_COLOR !== undefined) {
31
+ return environment.FORCE_COLOR === "0" ? PLAIN_STYLE : ANSI_STYLE;
32
+ }
33
+ if (!isTerminal || environment.NO_COLOR !== undefined || environment.TERM === "dumb") {
34
+ return PLAIN_STYLE;
35
+ }
36
+ return ANSI_STYLE;
37
+ };
@@ -0,0 +1,21 @@
1
+ export type TableRow = readonly string[];
2
+ export type TableIo = { readonly stdout: (text: string) => void };
3
+
4
+ export const formatTable = (rows: readonly TableRow[]): string => {
5
+ const widths = rows.reduce<number[]>(
6
+ (current, row) => row.map((cell, index) => Math.max(current[index] ?? 0, cell.length)),
7
+ [],
8
+ );
9
+ return rows
10
+ .map((row) =>
11
+ row
12
+ .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
13
+ .join(" ")
14
+ .trimEnd(),
15
+ )
16
+ .join("\n");
17
+ };
18
+
19
+ export const writeOutput = (io: TableIo, json: boolean, value: unknown, formatted: string): void => {
20
+ io.stdout(json ? JSON.stringify(value, null, 2) : formatted);
21
+ };
@@ -1,8 +1,10 @@
1
+ import select from "@inquirer/select";
1
2
  import { createInterface } from "node:readline/promises";
2
- import type { AuthPrompt } from "./auth";
3
+ import type { AuthChoose, AuthPrompt } from "./auth";
3
4
  import type { CliIo } from "./context";
4
5
  import { pickPipeline } from "./runs";
5
6
  import { TolokaCliError } from "./cli-error";
7
+ import { styleFor } from "./style";
6
8
 
7
9
  const stdinClosedError = () =>
8
10
  new TolokaCliError("Standard input closed before an API key was entered.", {
@@ -102,9 +104,14 @@ const promptFromTerminal: AuthPrompt = async (question, options = {}) => {
102
104
  }
103
105
  };
104
106
 
107
+ const chooseFromTerminal: AuthChoose = (message, choices) => select({ message, choices });
108
+
105
109
  export const terminalIo = (): CliIo => ({
106
110
  stdout: (text) => console.log(text),
107
111
  stderr: (text) => console.error(text),
108
112
  readStdin: readStandardInput,
109
- ...(process.stdin.isTTY ? { prompt: promptFromTerminal, select: pickPipeline } : {}),
113
+ style: styleFor(process.env, process.stdout.isTTY),
114
+ ...(process.stdin.isTTY
115
+ ? { prompt: promptFromTerminal, choose: chooseFromTerminal, select: pickPipeline }
116
+ : {}),
110
117
  });
@@ -14,6 +14,7 @@ import type {
14
14
  import type { BaseModelId, TrainingConfigurationInput } from "../../openapi/gen/models";
15
15
  import { assertCli, RETRY_OR_REPORT_HINT, TolokaCliError } from "./cli-error";
16
16
  import type { CliErrorHint } from "./cli-error";
17
+ import { formatTable, type TableIo as TrainingIo, writeOutput } from "./table";
17
18
 
18
19
  const ESTIMATE_FILE_HINT: CliErrorHint = {
19
20
  description: "Regenerate it with toloka training cost <training> --json > <file>.",
@@ -49,28 +50,6 @@ type TrainingDraftApi = Pick<PotatoClient, "trainings" | "files" | "datasets">;
49
50
  type TrainingCostApi = Pick<PotatoClient, "trainings" | "waitFor">;
50
51
  type TrainingRunApi = Pick<PotatoClient, "trainings" | "waitFor">;
51
52
  const GISTING_PROMPT_MAX_BYTES = 256 * 1024;
52
- type TrainingIo = { readonly stdout: (text: string) => void };
53
- type TableRow = readonly string[];
54
-
55
- const formatTable = (rows: readonly TableRow[]): string => {
56
- const widths = rows.reduce<number[]>(
57
- (current, row) => row.map((cell, index) => Math.max(current[index] ?? 0, cell.length)),
58
- [],
59
- );
60
- return rows
61
- .map((row) =>
62
- row
63
- .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
64
- .join(" ")
65
- .trimEnd(),
66
- )
67
- .join("\n");
68
- };
69
-
70
- const writeOutput = (io: TrainingIo, json: boolean, value: unknown, formatted: string): void => {
71
- io.stdout(json ? JSON.stringify(value, null, 2) : formatted);
72
- };
73
-
74
53
  const formatTraining = (training: Training): string =>
75
54
  formatTable([
76
55
  ["ID", training.id],