@toloka-platform/sdk 0.1.0-preview.3 → 0.1.0-preview.5

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 (38) hide show
  1. package/README.md +2 -2
  2. package/bin/toloka.js +7 -0
  3. package/dist/cli/toloka.js +119 -114
  4. package/dist/cli/toloka.js.jsc +0 -0
  5. package/dist/cli/toloka.js.jsc.sha256 +1 -1
  6. package/dist/cli/toloka.js.sha256 +1 -1
  7. package/docs/cli-v2.md +4 -0
  8. package/docs/cli.md +56 -5
  9. package/docs/getting-started.md +3 -3
  10. package/docs/limitations.md +1 -1
  11. package/openapi/gen/api.ts +22 -0
  12. package/openapi/gen/models/createDatasetBody.ts +2 -0
  13. package/openapi/gen/models/createDatasetBodyProfile.ts +2 -0
  14. package/openapi/gen/models/createDatasetUploadBodyFormat.ts +1 -1
  15. package/openapi/gen/models/dataset.ts +2 -0
  16. package/openapi/gen/models/datasetProfile.ts +3 -0
  17. package/openapi/gen/models/datasetProfileStamp.ts +6 -0
  18. package/openapi/gen/models/datasetProfileStampName.ts +2 -0
  19. package/openapi/gen/models/index.ts +5 -0
  20. package/openapi/gen/models/runCost.ts +7 -0
  21. package/package.json +4 -2
  22. package/skills/catalog.json +22 -0
  23. package/skills/{SKILL.md → toloka-build/SKILL.md} +1 -1
  24. package/skills/{references → toloka-build/references}/workflow-and-cli.md +14 -4
  25. package/skills/toloka-report-issue/SKILL.md +25 -0
  26. package/skills/toloka-report-issue/references/report-template.md +34 -0
  27. package/src/authoring/index.ts +216 -171
  28. package/src/cli/analytics.ts +53 -1
  29. package/src/cli/auth.ts +5 -5
  30. package/src/cli/credentials.ts +8 -2
  31. package/src/cli/file-system.ts +34 -0
  32. package/src/cli/format-error.ts +19 -0
  33. package/src/cli/init.ts +149 -194
  34. package/src/cli/program.ts +103 -21
  35. package/src/cli/report.ts +144 -0
  36. package/src/cli/skill.ts +363 -0
  37. package/src/pipeline-graph-v1.contract.json +5 -1
  38. /package/skills/{references → toloka-build/references}/node-semantics.md +0 -0
@@ -4,14 +4,13 @@ import { resolve } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { createClient } from "../client";
6
6
  import type { PotatoClient } from "../client";
7
- import { PotatoApiError } from "../errors";
8
7
  import { readTolokaState } from "../state";
9
8
  import type { TolokaState } from "../state";
10
9
  import { authStatus, login, logout } from "./auth";
11
10
  import type { AuthPrompt } from "./auth";
12
11
  import { commandNameFromArgv, createCliAnalytics } from "./analytics";
13
12
  import type { CliAnalytics, CliAnalyticsCommandOutcome } from "./analytics";
14
- import { PRODUCTION_BASE_URL, resolveTolokaConfig } from "./config";
13
+ import { PRODUCTION_BASE_URL, resolveInternalBaseUrl, resolveTolokaConfig } from "./config";
15
14
  import type { TolokaEnvironment } from "./config";
16
15
  import {
17
16
  findCommandCredentials,
@@ -26,7 +25,7 @@ import {
26
25
  setProjectSecret,
27
26
  uploadDataset,
28
27
  } from "./data";
29
- import { initProject, readAgentSkillStalenessWarning } from "./init";
28
+ import { SDK_VERSION, initProject, readAgentSkillStalenessWarning } from "./init";
30
29
  import {
31
30
  applyProject,
32
31
  importProject,
@@ -45,6 +44,9 @@ import {
45
44
  switchProject,
46
45
  } from "./project-context";
47
46
  import { estimatePipelineCost } from "./cost";
47
+ import { submitIssueReport } from "./report";
48
+ import type { IssueReportAuthor } from "./report";
49
+ import { formatCliError } from "./format-error";
48
50
  import {
49
51
  cancelPipelineRun,
50
52
  listLivePipelines,
@@ -56,6 +58,7 @@ import {
56
58
  } from "./runs";
57
59
 
58
60
  import type { RunOutcome } from "./runs";
61
+ import { skillInstallCommand, skillListCommand, skillSearchCommand } from "./skill";
59
62
  import { findSourceRoot } from "./source";
60
63
 
61
64
  export type CliIo = {
@@ -88,6 +91,12 @@ const CREDENTIAL_PRECEDENCE = [
88
91
  `The platform defaults to ${PRODUCTION_BASE_URL}.`,
89
92
  ].join("\n");
90
93
 
94
+ const authoredByOption = (raw: string): IssueReportAuthor => {
95
+ if (raw !== "human" && raw !== "agent")
96
+ throw new InvalidArgumentError('Expected "human" or "agent".');
97
+ return raw;
98
+ };
99
+
91
100
  const positiveInteger = (raw: string): number => {
92
101
  const value = Number(raw);
93
102
  if (!Number.isInteger(value) || value <= 0)
@@ -104,6 +113,7 @@ const exitCodeForOutcome = (outcome: RunOutcome): number => {
104
113
  type Resolved = {
105
114
  readonly sourceRoot: string;
106
115
  readonly client: PotatoClient;
116
+ readonly baseUrl: string;
107
117
  readonly state: TolokaState | undefined;
108
118
  readonly stored: StoredCredentials | undefined;
109
119
  readonly projectId?: string;
@@ -154,6 +164,7 @@ const resolveContext = async (
154
164
  return {
155
165
  sourceRoot,
156
166
  client,
167
+ baseUrl: config.baseUrl,
157
168
  state,
158
169
  stored,
159
170
  ...(config.projectId === undefined ? {} : { projectId: config.projectId }),
@@ -187,6 +198,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
187
198
  .description(
188
199
  "Preview. Build and operate Toloka pipeline projects from TypeScript source. The public API may change.",
189
200
  )
201
+ .version(SDK_VERSION, "-v, --version", "output the current version")
190
202
  .option(
191
203
  "--source-root <directory>",
192
204
  "project root; discovered from the working directory when omitted",
@@ -203,19 +215,30 @@ export function createProgram(context: ProgramContext): CreatedProgram {
203
215
 
204
216
  const globals = (): GlobalOptions => program.opts<GlobalOptions>();
205
217
 
206
- program.hook("postAction", async (thisCommand) => {
218
+ program.hook("postAction", async (_thisCommand, actionCommand) => {
207
219
  if (warnedAboutStaleSkills) return;
208
220
  const commandNames = [];
209
- for (let current: Command | null = thisCommand; current !== null; current = current.parent) {
221
+ for (let current: Command | null = actionCommand; current !== null; current = current.parent) {
210
222
  commandNames.push(current.name());
211
223
  }
212
- if (commandNames.includes("init") || commandNames.includes("help")) return;
224
+ if (
225
+ commandNames.includes("init") ||
226
+ commandNames.includes("help") ||
227
+ commandNames.includes("report") ||
228
+ commandNames.includes("url") ||
229
+ commandNames.includes("version")
230
+ )
231
+ return;
213
232
  const sourceRoot = await resolveSourceRoot(globals(), context.io);
214
233
  const warning = await readAgentSkillStalenessWarning(sourceRoot).catch(() => undefined);
215
234
  if (warning === undefined) return;
216
235
  warnedAboutStaleSkills = true;
217
236
  context.io.stderr(warning);
218
237
  });
238
+ program
239
+ .command("version")
240
+ .description("print the current CLI version")
241
+ .action(() => context.io.stdout(SDK_VERSION));
219
242
 
220
243
  program
221
244
  .command("login")
@@ -351,6 +374,19 @@ export function createProgram(context: ProgramContext): CreatedProgram {
351
374
  });
352
375
  if (result.project === undefined) exitCode.value = 1;
353
376
  });
377
+ project
378
+ .command("url")
379
+ .description("print the current project URL")
380
+ .action(async () => {
381
+ const { baseUrl, client, projectId } = await resolveContext(globals(), context);
382
+ assert(projectId, MISSING_PROJECT_ID_MESSAGE);
383
+ const live = await client.projects.get({ projectId });
384
+ const url = new URL(
385
+ `/teams/${encodeURIComponent(live.teamId)}/projects/${encodeURIComponent(live.id)}`,
386
+ baseUrl,
387
+ );
388
+ context.io.stdout(url.href);
389
+ });
354
390
 
355
391
  project
356
392
  .command("remove")
@@ -390,7 +426,7 @@ export function createProgram(context: ProgramContext): CreatedProgram {
390
426
 
391
427
  program
392
428
  .command("init")
393
- .description("initialize this project directory and install agent skills")
429
+ .description("initialize this project directory and install the agent bootstrap")
394
430
  .action(async () => {
395
431
  const flags = globals();
396
432
  const sourceRoot = resolveInitRoot(flags);
@@ -677,6 +713,66 @@ export function createProgram(context: ProgramContext): CreatedProgram {
677
713
  const result = await projectStatus(client, { sourceRoot, projectId });
678
714
  context.io.stdout(result.output);
679
715
  });
716
+ program
717
+ .command("report")
718
+ .argument("<markdownPath>", "path to a UTF-8 Markdown report")
719
+ .option("--authored-by <who>", "human or agent", authoredByOption, "human")
720
+ .option("--confirm", "confirm submission in a non-interactive environment", false)
721
+ .description("preview and send an issue report to Toloka through PostHog")
722
+ .action(
723
+ async (
724
+ markdownPath: string,
725
+ options: { authoredBy: IssueReportAuthor; confirm: boolean },
726
+ ) => {
727
+ assert(context.analytics, "Issue reporting requires CLI analytics.");
728
+ const sourceRoot = resolveInitRoot(globals());
729
+ const baseUrl =
730
+ globals().baseUrl ??
731
+ context.environment.POTATO_BASE_URL ??
732
+ resolveInternalBaseUrl(context.environment) ??
733
+ PRODUCTION_BASE_URL;
734
+ exitCode.value = await submitIssueReport({
735
+ path: markdownPath,
736
+ authoredBy: options.authoredBy,
737
+ platform: new URL(baseUrl).origin,
738
+ confirm: options.confirm,
739
+ sourceRoot,
740
+ cwd: process.cwd(),
741
+ analytics: context.analytics,
742
+ io: context.io,
743
+ });
744
+ },
745
+ );
746
+
747
+ const skill = program
748
+ .command("skill")
749
+ .description("discover bundled agent skills and install them into .agents/skills");
750
+ skill
751
+ .command("list")
752
+ .option("--json", "print Skill Descriptors with their status as JSON", false)
753
+ .description("list bundled skills as not-installed, current, or stale")
754
+ .action(async (options: { json: boolean }) => {
755
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
756
+ await skillListCommand({ sourceRoot, json: options.json, io: context.io });
757
+ });
758
+ skill
759
+ .command("search")
760
+ .argument("<query>", "text matched case-insensitively against id, name, description, and tags")
761
+ .option("--json", "print matching Skill Descriptors with their status as JSON", false)
762
+ .description("find bundled skills for a task")
763
+ .action(async (query: string, options: { json: boolean }) => {
764
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
765
+ await skillSearchCommand({ sourceRoot, query, json: options.json, io: context.io });
766
+ });
767
+ skill
768
+ .command("install")
769
+ .argument("<skillId>", "skill id from toloka skill list")
770
+ .option("-f, --force", "replace an existing .agents/skills/<skillId> directory", false)
771
+ .description("copy one bundled skill into .agents/skills/<skillId>")
772
+ .action(async (skillId: string, options: { force: boolean }) => {
773
+ const sourceRoot = await resolveSourceRoot(globals(), context.io);
774
+ await skillInstallCommand({ sourceRoot, skillId, force: options.force, io: context.io });
775
+ });
680
776
 
681
777
  return { program, exitCode };
682
778
  }
@@ -785,20 +881,6 @@ type CommandResult = {
785
881
  readonly outcome: CliAnalyticsCommandOutcome;
786
882
  readonly error?: unknown;
787
883
  };
788
- const formatCliError = (error: unknown): string => {
789
- if (error instanceof PotatoApiError) {
790
- const details =
791
- typeof error.body === "string"
792
- ? error.body
793
- : error.body === undefined
794
- ? undefined
795
- : JSON.stringify(error.body);
796
- return details === undefined || details.length === 0
797
- ? error.message
798
- : `${error.message}\n${details}`;
799
- }
800
- return error instanceof Error ? error.message : String(error);
801
- };
802
884
 
803
885
  const runProgram = async (
804
886
  program: Command,
@@ -0,0 +1,144 @@
1
+ import { assert } from "es-toolkit";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { lstat, open, type FileHandle } from "node:fs/promises";
5
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import type { CliAnalytics } from "./analytics";
7
+
8
+ export type IssueReportAuthor = "human" | "agent";
9
+
10
+ type IssueReportIo = {
11
+ readonly stdout: (text: string) => void;
12
+ readonly stderr: (text: string) => void;
13
+ readonly prompt?: (question: string) => Promise<string>;
14
+ };
15
+
16
+ const ISSUE_REPORT_MAX_BYTES = 256 * 1024;
17
+ const REPORT_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
18
+
19
+ const assertNoSymlinkParents = async (absolutePath: string, sourceRoot: string): Promise<void> => {
20
+ const relativePath = relative(sourceRoot, absolutePath);
21
+ if (
22
+ relativePath === "" ||
23
+ relativePath === ".." ||
24
+ relativePath.startsWith(`..${sep}`) ||
25
+ isAbsolute(relativePath)
26
+ ) {
27
+ return;
28
+ }
29
+
30
+ const segments = relativePath.split(sep);
31
+ for (let index = 1; index < segments.length; index += 1) {
32
+ const directory = join(sourceRoot, ...segments.slice(0, index));
33
+ const stats = await lstat(directory);
34
+ assert(
35
+ !stats.isSymbolicLink(),
36
+ `Report file ${absolutePath} is inside the symbolic link ${directory}; select a path that does not traverse symbolic links.`,
37
+ );
38
+ }
39
+ };
40
+
41
+ const openReportFile = async (
42
+ path: string,
43
+ sourceRoot: string,
44
+ cwd: string,
45
+ ): Promise<{ handle: FileHandle; absolutePath: string; size: number }> => {
46
+ const absolutePath = resolve(cwd, path);
47
+ await assertNoSymlinkParents(absolutePath, sourceRoot);
48
+ let handle: FileHandle;
49
+ try {
50
+ handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
51
+ } catch (error) {
52
+ assert(
53
+ !(error instanceof Error && "code" in error && error.code === "ELOOP"),
54
+ `Report file ${absolutePath} is a symbolic link; select the file it points to directly.`,
55
+ );
56
+ throw error;
57
+ }
58
+
59
+ try {
60
+ 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(
64
+ stats.size <= ISSUE_REPORT_MAX_BYTES,
65
+ `Report file ${absolutePath} is ${stats.size} bytes; the limit is 256 KiB.`,
66
+ );
67
+ return { handle, absolutePath, size: stats.size };
68
+ } catch (error) {
69
+ await handle.close();
70
+ throw error;
71
+ }
72
+ };
73
+
74
+ const readReportText = async (path: string, sourceRoot: string, cwd: string): Promise<string> => {
75
+ const opened = await openReportFile(path, sourceRoot, cwd);
76
+ try {
77
+ const bytes = await opened.handle.readFile();
78
+ assert(
79
+ bytes.byteLength === opened.size,
80
+ `${opened.absolutePath} changed while it was being read.`,
81
+ );
82
+ try {
83
+ return REPORT_DECODER.decode(bytes);
84
+ } catch {
85
+ throw new Error(`Report file ${opened.absolutePath} is not valid UTF-8.`);
86
+ }
87
+ } finally {
88
+ await opened.handle.close();
89
+ }
90
+ };
91
+
92
+ const confirmed = async (io: IssueReportIo, confirm: boolean): Promise<boolean> => {
93
+ if (confirm) return true;
94
+ if (io.prompt === undefined) return false;
95
+ const answer = (await io.prompt("Send this report to Toloka? [y/N] ")).trim().toLowerCase();
96
+ return answer === "y" || answer === "yes";
97
+ };
98
+
99
+ export async function submitIssueReport(options: {
100
+ readonly path: string;
101
+ readonly authoredBy: IssueReportAuthor;
102
+ readonly platform: string;
103
+ readonly confirm: boolean;
104
+ readonly sourceRoot: string;
105
+ readonly cwd: string;
106
+ readonly analytics: CliAnalytics;
107
+ readonly io: IssueReportIo;
108
+ }): Promise<number> {
109
+ const { analytics, io } = options;
110
+ assert(
111
+ analytics.issueReportingEnabled,
112
+ "Issue reporting is disabled. Re-run with TOLOKA_TELEMETRY=1.",
113
+ );
114
+ const reportText = await readReportText(options.path, options.sourceRoot, options.cwd);
115
+ io.stdout("Issue report preview:");
116
+ io.stdout("");
117
+ io.stdout(
118
+ reportText.replace(
119
+ /(?![\t\n])(?!\r\n)(?:\p{Cc}|\p{Bidi_Control})/gu,
120
+ (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
121
+ ),
122
+ );
123
+ io.stdout("");
124
+ io.stdout(`Authored by: ${options.authoredBy}`);
125
+ io.stdout(`Platform: ${options.platform}`);
126
+ io.stdout("");
127
+
128
+ if (!(await confirmed(io, options.confirm))) {
129
+ io.stderr(
130
+ "Refusing to submit without confirmation in a non-interactive environment. Pass --confirm after reviewing the preview above.",
131
+ );
132
+ return 1;
133
+ }
134
+
135
+ const reportId = randomUUID();
136
+ await analytics.submitIssueReport({
137
+ reportId,
138
+ reportText,
139
+ authoredBy: options.authoredBy,
140
+ platform: options.platform,
141
+ });
142
+ io.stdout(`Submitted issue report: ${reportId}`);
143
+ return 0;
144
+ }