@toloka-platform/sdk 0.1.0-preview.4 → 0.1.0-preview.6

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 (60) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/toloka.js +132 -126
  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/cli-v2.md +4 -0
  7. package/docs/cli.md +82 -6
  8. package/docs/datasets.md +8 -9
  9. package/docs/getting-started.md +2 -2
  10. package/docs/limitations.md +5 -8
  11. package/docs/troubleshooting.md +0 -11
  12. package/openapi/gen/api.ts +782 -876
  13. package/openapi/gen/models/baseModel.ts +9 -0
  14. package/openapi/gen/models/baseModelId.ts +2 -0
  15. package/openapi/gen/models/baseModelIdProperty.ts +2 -0
  16. package/openapi/gen/models/createDatasetBody.ts +2 -0
  17. package/openapi/gen/models/createDatasetBodyProfile.ts +2 -0
  18. package/openapi/gen/models/createDatasetUploadBodyFormat.ts +1 -1
  19. package/openapi/gen/models/createProjectModelBody.ts +8 -0
  20. package/openapi/gen/models/createProjectModelBodyDescription.ts +2 -0
  21. package/openapi/gen/models/dataset.ts +2 -0
  22. package/openapi/gen/models/datasetProfile.ts +3 -0
  23. package/openapi/gen/models/datasetProfileStamp.ts +6 -0
  24. package/openapi/gen/models/datasetProfileStampName.ts +2 -0
  25. package/openapi/gen/models/generationNodeSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  26. package/openapi/gen/models/index.ts +21 -0
  27. package/openapi/gen/models/listBaseModels200.ts +5 -0
  28. package/openapi/gen/models/listModelVersions200.ts +7 -0
  29. package/openapi/gen/models/listModelVersionsParams.ts +5 -0
  30. package/openapi/gen/models/listProjectModels200.ts +7 -0
  31. package/openapi/gen/models/listProjectModelsParams.ts +5 -0
  32. package/openapi/gen/models/model.ts +16 -0
  33. package/openapi/gen/models/modelArchivedAt.ts +2 -0
  34. package/openapi/gen/models/modelDescription.ts +2 -0
  35. package/openapi/gen/models/modelId.ts +2 -0
  36. package/openapi/gen/models/modelVersion.ts +9 -0
  37. package/openapi/gen/models/modelVersionId.ts +2 -0
  38. package/openapi/gen/models/pipelineGraphSnapshotGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  39. package/openapi/gen/models/runCost.ts +7 -0
  40. package/openapi/gen/models/validatePipelineGraphBodyGraphNodesItemOneOfThreeoneSettingsAudienceAndPricingOneOfOneone.ts +1 -0
  41. package/package.json +2 -1
  42. package/skills/catalog.json +22 -0
  43. package/skills/{SKILL.md → toloka-build/SKILL.md} +2 -2
  44. package/skills/{references → toloka-build/references}/workflow-and-cli.md +26 -5
  45. package/skills/toloka-report-issue/SKILL.md +25 -0
  46. package/skills/toloka-report-issue/references/report-template.md +34 -0
  47. package/src/cli/analytics.ts +53 -1
  48. package/src/cli/data.ts +148 -25
  49. package/src/cli/file-system.ts +34 -0
  50. package/src/cli/format-error.ts +19 -0
  51. package/src/cli/init.ts +149 -194
  52. package/src/cli/models.ts +188 -0
  53. package/src/cli/program.ts +202 -22
  54. package/src/cli/report.ts +144 -0
  55. package/src/cli/skill.ts +363 -0
  56. package/src/client.ts +109 -35
  57. package/src/errors.ts +25 -10
  58. package/src/pipeline-graph-v1.contract.json +5 -1
  59. package/src/transport.ts +16 -13
  60. /package/skills/{references → toloka-build/references}/node-semantics.md +0 -0
package/src/cli/init.ts CHANGED
@@ -1,45 +1,39 @@
1
1
  import { assert } from "es-toolkit";
2
- import { createHash } from "node:crypto";
3
- import { lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
4
- import { dirname, join, sep } from "node:path";
2
+ import { lstat, mkdir, readdir, readFile, realpath, rm, rmdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
5
4
  import packageMetadata from "../../package.json" with { type: "json" };
6
5
  import { readTolokaState, writeTolokaState } from "../state";
6
+ import { assertSafeDestination, entryStats, isMissingFileError } from "./file-system";
7
+ import { SKILL_ENTRY_POINT, SKILL_INSTALL_ROOT } from "./skill";
7
8
 
8
9
  export const SDK_VERSION = packageMetadata.version;
9
10
 
10
11
  const SDK_PACKAGE_NAME = "@toloka-platform/sdk";
11
- const SKILL_NAME = "toloka-build";
12
- const CLAUDE_SKILL_PATH = `.claude/skills/${SKILL_NAME}/SKILL.md`;
13
- const CURSOR_RULE_PATH = `.cursor/rules/${SKILL_NAME}.mdc`;
12
+ const BOOTSTRAP_NAME = "toloka-bootstrap";
13
+ const CLAUDE_POINTER_PATH = `.claude/skills/${BOOTSTRAP_NAME}/SKILL.md`;
14
+ const CURSOR_POINTER_PATH = `.cursor/rules/${BOOTSTRAP_NAME}.mdc`;
14
15
  const AGENTS_PATH = "AGENTS.md";
15
- const BLOCK_BEGIN = "<!-- toloka:begin -->";
16
- const BLOCK_END = "<!-- toloka:end -->";
16
+ const MANAGED_PATHS = [CLAUDE_POINTER_PATH, CURSOR_POINTER_PATH, AGENTS_PATH];
17
+ const CURRENT_BLOCK = { begin: "<toloka>", end: "</toloka>" };
18
+ const LEGACY_BLOCK = { begin: "<!-- toloka:begin -->", end: "<!-- toloka:end -->" };
17
19
  const VERSION_PATTERN = /<!-- toloka:sdk-version ([^\s>]+) -->/u;
18
- const DIGEST_PATTERN = /<!-- toloka:skill-digest sha256:([a-f0-9]{64}) -->/u;
19
- const AGENT_ENTRY_PATHS = [CLAUDE_SKILL_PATH, CURSOR_RULE_PATH, AGENTS_PATH];
20
- const LEGACY_SKILL_NAME = "toloka-pipeline-as-code";
21
- const LEGACY_SKILL_DIRECTORY = join(".claude", "skills", LEGACY_SKILL_NAME);
22
- const LEGACY_CURSOR_RULE = join(".cursor", "rules", `${LEGACY_SKILL_NAME}.mdc`);
23
- const LEGACY_PATHS = [LEGACY_SKILL_DIRECTORY, LEGACY_CURSOR_RULE];
24
- const LEGACY_ENTRY_PATHS = [join(LEGACY_SKILL_DIRECTORY, "SKILL.md"), LEGACY_CURSOR_RULE];
25
- const BUNDLED_SKILL_FILES = [
26
- ["SKILL.md", new URL("../../skills/SKILL.md", import.meta.url)],
27
- [
28
- "references/node-semantics.md",
29
- new URL("../../skills/references/node-semantics.md", import.meta.url),
30
- ],
31
- [
32
- "references/workflow-and-cli.md",
33
- new URL("../../skills/references/workflow-and-cli.md", import.meta.url),
20
+ type LegacyEntry = { readonly file: string; readonly directory?: string };
21
+
22
+ const LEGACY_ENTRIES: readonly LegacyEntry[] = ["toloka-build", "toloka-pipeline-as-code"].flatMap(
23
+ (skillName): LegacyEntry[] => [
24
+ { file: `.claude/skills/${skillName}/SKILL.md`, directory: `.claude/skills/${skillName}` },
25
+ { file: `.cursor/rules/${skillName}.mdc` },
34
26
  ],
35
- ] satisfies ReadonlyArray<readonly [string, URL]>;
27
+ );
28
+
29
+ type BlockMarkers = { readonly begin: string; readonly end: string };
36
30
 
37
31
  export type InitProjectResult = {
38
32
  readonly targetDirectory: string;
39
33
  readonly files: readonly string[];
40
34
  readonly writtenFiles: readonly string[];
35
+ readonly removedFiles: readonly string[];
41
36
  readonly sdkVersion: string;
42
- readonly skillDigest: string;
43
37
  readonly dependencyReady: boolean;
44
38
  readonly output: string;
45
39
  readonly warning?: string;
@@ -50,9 +44,6 @@ type AgentEntry = {
50
44
  readonly source: string;
51
45
  };
52
46
 
53
- const isMissingFileError = (error: unknown) =>
54
- error instanceof Error && "code" in error && error.code === "ENOENT";
55
-
56
47
  const readOptionalFile = async (path: string) => {
57
48
  try {
58
49
  return await readFile(path, "utf8");
@@ -71,116 +62,85 @@ const isDirectory = async (path: string) => {
71
62
  }
72
63
  };
73
64
 
74
- const isFile = async (path: string) => {
75
- try {
76
- return (await lstat(path)).isFile();
77
- } catch (error) {
78
- if (isMissingFileError(error)) return false;
79
- throw error;
80
- }
81
- };
65
+ const isGenerated = (source: string) => VERSION_PATTERN.test(source);
82
66
 
83
- const assertSafeDestination = async (targetDirectory: string, relativePath: string) => {
84
- let currentPath = targetDirectory;
85
- for (const segment of relativePath.split("/")) {
86
- currentPath = join(currentPath, segment);
87
- try {
88
- const entry = await lstat(currentPath);
89
- assert(!entry.isSymbolicLink(), `Refusing to write through symbolic link ${currentPath}.`);
90
- } catch (error) {
91
- if (isMissingFileError(error)) return;
92
- throw error;
93
- }
94
- }
95
- };
67
+ const PROVENANCE = `Generated by \`${SDK_PACKAGE_NAME}\` version \`${SDK_VERSION}\`.
68
+ <!-- toloka:sdk-version ${SDK_VERSION} -->`;
96
69
 
97
- export async function computeBundledSkillDigest(): Promise<string> {
98
- const digest = createHash("sha256");
99
- for (const [relativePath, url] of BUNDLED_SKILL_FILES) {
100
- digest.update(relativePath);
101
- digest.update("\0");
102
- digest.update(await readFile(url));
103
- digest.update("\0");
104
- }
105
- return digest.digest("hex");
106
- }
70
+ const BOOTSTRAP_BLOCK = `${CURRENT_BLOCK.begin}
107
71
 
108
- const BUNDLED_SKILL_BODY = new URL("../../skills/SKILL.md", import.meta.url);
109
- const INSTALLED_REFERENCE_PREFIX = `node_modules/${SDK_PACKAGE_NAME}/skills/references/`;
110
-
111
- const bundledSkillBody = async (sectionHeading: "##" | "###"): Promise<string> => {
112
- const source = await readFile(BUNDLED_SKILL_BODY, "utf8");
113
- const withoutFrontmatter = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "");
114
- const withoutTitle = withoutFrontmatter.replace(/^#\s.*\r?\n+/u, "");
115
- const withInstalledReferences = withoutTitle
116
- .replaceAll("](references/", `](${INSTALLED_REFERENCE_PREFIX}`)
117
- .replaceAll("](../examples/", `](node_modules/${SDK_PACKAGE_NAME}/examples/`);
118
- return sectionHeading === "##"
119
- ? withInstalledReferences
120
- : withInstalledReferences.replaceAll(/^## /gmu, "### ");
121
- };
72
+ ## Toloka Agent Bootstrap
122
73
 
123
- const guidance = async (
124
- sectionHeading: "##" | "###",
125
- skillDigest: string,
126
- ) => `Generated by \`${SDK_PACKAGE_NAME}\` version \`${SDK_VERSION}\`.
127
- <!-- toloka:sdk-version ${SDK_VERSION} -->
128
- <!-- toloka:skill-digest sha256:${skillDigest} -->
74
+ ${PROVENANCE}
75
+ Re-run \`toloka init\` to refresh this block. Keep your own instructions outside it.
129
76
 
130
- ${await bundledSkillBody(sectionHeading)}
131
- Use the globally installed \`toloka\` binary. Install it with \`curl -fsSL https://platform.toloka.ai/cli/install.sh | sh\` if it is not on \`PATH\`.
77
+ This repository works with the Toloka platform through the \`toloka\` CLI. Before starting a Toloka task:
132
78
 
133
- If the reference paths above are missing, keep following the workflow and constraints in this file, do not invent SDK helpers or CLI commands, and install the project's declared dependencies before validating, planning, or applying. When no project is selected, use \`toloka project add\` to adopt one or \`toloka project create\` to create one. Adopt live source with \`toloka import\`.
79
+ 1. Run \`toloka --help\`, then \`toloka <resource> --help\` for the resource you need.
80
+ 2. Run \`toloka skill search <query>\` to find a bundled skill for the task; \`toloka skill list\` shows every skill and whether it is installed.
81
+ 3. Run \`toloka skill install <skill-id>\` and read \`${SKILL_INSTALL_ROOT}/<skill-id>/${SKILL_ENTRY_POINT}\` before acting; add \`--force\` to refresh a stale skill.
82
+ 4. Follow the installed skill. When no skill matches, continue with the CLI help and never invent commands or SDK helpers.
83
+ 5. To report a Toloka platform problem or unexpected failure to support, run \`toloka skill search report-issue\` and install \`toloka-report-issue\` before collecting evidence.
134
84
 
135
- `;
85
+ ${CURRENT_BLOCK.end}`;
136
86
 
137
- const claudeSkillSource = async (skillDigest: string) => `---
138
- name: ${SKILL_NAME}
139
- description: Build, inspect, and change Toloka platform pipeline projects as TypeScript source with @toloka-platform/sdk and toloka. Use when the user wants to create, adopt, inspect, plan, or apply a pipeline project as code.
140
- ---
87
+ const POINTER_BODY = `# Toloka Agent Bootstrap pointer
141
88
 
142
- # Build Toloka pipelines as code
89
+ ${PROVENANCE}
143
90
 
144
- ${await guidance("##", skillDigest)}`;
91
+ Read the \`${CURRENT_BLOCK.begin}\` block in \`${AGENTS_PATH}\` at the source root and follow it before doing Toloka work. It tells you to run \`toloka --help\`, search the bundled Skill Catalog with \`toloka skill search <query>\`, install the match with \`toloka skill install <skill-id>\`, and read \`${SKILL_INSTALL_ROOT}/<skill-id>/${SKILL_ENTRY_POINT}\` before acting.
92
+ `;
145
93
 
146
- const cursorRuleSource = async (skillDigest: string) => `---
147
- description: Build, inspect, and change Toloka platform pipeline projects as TypeScript source with @toloka-platform/sdk and toloka
148
- alwaysApply: false
94
+ const CLAUDE_POINTER_SOURCE = `---
95
+ name: ${BOOTSTRAP_NAME}
96
+ description: Toloka platform work with the toloka CLI and ${SDK_PACKAGE_NAME}, such as pipeline projects, datasets, runs, secrets, and issue reporting. Use when the user mentions Toloka or the toloka CLI. Start by reading the ${CURRENT_BLOCK.begin} Agent Bootstrap block in ${AGENTS_PATH}, then find and install the matching skill.
149
97
  ---
150
98
 
151
- # Build Toloka pipelines as code
99
+ ${POINTER_BODY}`;
152
100
 
153
- ${await guidance("##", skillDigest)}`;
101
+ const CURSOR_POINTER_SOURCE = `---
102
+ description: Toloka Agent Bootstrap pointer for the toloka CLI and ${SDK_PACKAGE_NAME}
103
+ alwaysApply: true
104
+ ---
154
105
 
155
- const agentsBlockSource = async (skillDigest: string) => `${BLOCK_BEGIN}
156
- ## Build Toloka pipelines as code
106
+ ${POINTER_BODY}`;
157
107
 
158
- ${(await guidance("###", skillDigest)).trimEnd()}
159
- ${BLOCK_END}`;
108
+ const locateBlock = (source: string, markers: BlockMarkers, path: string) => {
109
+ const beginIndex = source.indexOf(markers.begin);
110
+ const endIndex = source.indexOf(markers.end);
111
+ if (beginIndex === -1 && endIndex === -1) return undefined;
112
+ assert(
113
+ beginIndex !== -1 && endIndex !== -1 && beginIndex < endIndex,
114
+ `Malformed Toloka block in ${path}: expected one ${markers.begin} followed by one ${markers.end}. Repair the file by hand, then re-run \`toloka init\`.`,
115
+ );
116
+ assert(
117
+ !source.includes(markers.begin, beginIndex + markers.begin.length) &&
118
+ !source.includes(markers.end, endIndex + markers.end.length),
119
+ `Multiple Toloka blocks in ${path}: keep exactly one ${markers.begin} ... ${markers.end} block, then re-run \`toloka init\`.`,
120
+ );
121
+ return { beginIndex, endIndex: endIndex + markers.end.length };
122
+ };
160
123
 
161
- const mergeAgentsBlock = (source: string, block: string) => {
162
- const beginIndex = source.indexOf(BLOCK_BEGIN);
163
- const endIndex = source.indexOf(BLOCK_END);
164
- const hasBegin = beginIndex !== -1;
165
- const hasEnd = endIndex !== -1;
166
- const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
167
- const normalizedBlock = block.replaceAll("\n", lineEnding);
168
- if (!hasBegin && !hasEnd) {
169
- if (source.length === 0) return `${normalizedBlock}${lineEnding}`;
170
- const gap = source.endsWith(`${lineEnding}${lineEnding}`)
171
- ? ""
172
- : source.endsWith(lineEnding)
173
- ? lineEnding
174
- : `${lineEnding}${lineEnding}`;
175
- return `${source}${gap}${normalizedBlock}${lineEnding}`;
176
- }
177
- assert(hasBegin && hasEnd && beginIndex < endIndex, `Malformed toloka block in ${AGENTS_PATH}.`);
124
+ const mergeBootstrap = (source: string, path: string) => {
125
+ const current = locateBlock(source, CURRENT_BLOCK, path);
126
+ const legacy = locateBlock(source, LEGACY_BLOCK, path);
178
127
  assert(
179
- !source.includes(BLOCK_BEGIN, beginIndex + BLOCK_BEGIN.length) &&
180
- !source.includes(BLOCK_END, endIndex + BLOCK_END.length),
181
- `Multiple toloka blocks found in ${AGENTS_PATH}.`,
128
+ current === undefined || legacy === undefined,
129
+ `Ambiguous Toloka blocks in ${path}: both ${CURRENT_BLOCK.begin} and ${LEGACY_BLOCK.begin} are present. Keep one, then re-run \`toloka init\`.`,
182
130
  );
183
- return `${source.slice(0, beginIndex)}${normalizedBlock}${source.slice(endIndex + BLOCK_END.length)}`;
131
+ const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
132
+ const block = BOOTSTRAP_BLOCK.replaceAll("\n", lineEnding);
133
+ const existing = current ?? legacy;
134
+ if (existing !== undefined) {
135
+ return `${source.slice(0, existing.beginIndex)}${block}${source.slice(existing.endIndex)}`;
136
+ }
137
+ if (source.length === 0) return `${block}${lineEnding}`;
138
+ const gap = source.endsWith(`${lineEnding}${lineEnding}`)
139
+ ? ""
140
+ : source.endsWith(lineEnding)
141
+ ? lineEnding
142
+ : `${lineEnding}${lineEnding}`;
143
+ return `${source}${gap}${block}${lineEnding}`;
184
144
  };
185
145
 
186
146
  const hasSdkDependency = async (targetDirectory: string) => {
@@ -210,43 +170,46 @@ const writeIfChanged = async (path: string, source: string) => {
210
170
  return true;
211
171
  };
212
172
 
213
- const entryStats = async (path: string) => {
214
- try {
215
- return await lstat(path);
216
- } catch (error) {
217
- if (isMissingFileError(error)) return undefined;
218
- throw error;
219
- }
173
+ const assertRefreshablePointer = async (targetRoot: string, relativePath: string) => {
174
+ const path = join(targetRoot, relativePath);
175
+ const existing = await readOptionalFile(path);
176
+ assert(
177
+ existing === undefined || isGenerated(existing),
178
+ `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\`.`,
179
+ );
220
180
  };
221
181
 
222
- const removeLegacyEntryPoints = async (targetRoot: string): Promise<string[]> => {
223
- const removed = await Promise.all(
224
- LEGACY_PATHS.map(async (relativePath) => {
225
- const path = join(targetRoot, relativePath);
226
- const entry = await entryStats(path);
227
- if (entry === undefined) return undefined;
228
- assert(
229
- !entry.isSymbolicLink(),
230
- `Refusing to remove legacy agent entry point through symbolic link ${path}.`,
231
- );
232
- await assertSafeDestination(targetRoot, relativePath.split(sep).join("/"));
233
- await rm(path, { recursive: true, force: true });
234
- return relativePath;
182
+ const recognizeLegacyEntries = async (targetRoot: string): Promise<LegacyEntry[]> => {
183
+ const recognized = await Promise.all(
184
+ LEGACY_ENTRIES.map(async (entry) => {
185
+ const path = join(targetRoot, entry.file);
186
+ if ((await entryStats(path)) === undefined) return undefined;
187
+ await assertSafeDestination(targetRoot, entry.file, "remove legacy agent entry point");
188
+ return isGenerated(await readFile(path, "utf8")) ? entry : undefined;
235
189
  }),
236
190
  );
237
- return removed.filter((relativePath): relativePath is string => relativePath !== undefined);
191
+ return recognized.filter((entry): entry is LegacyEntry => entry !== undefined);
192
+ };
193
+
194
+ const removeLegacyEntry = async (targetRoot: string, entry: LegacyEntry) => {
195
+ await rm(join(targetRoot, entry.file), { force: true });
196
+ if (entry.directory === undefined) return;
197
+ const directory = join(targetRoot, entry.directory);
198
+ if ((await readdir(directory)).length === 0) await rmdir(directory);
238
199
  };
239
200
 
240
201
  const formatInitOutput = (
241
202
  targetRoot: string,
242
203
  files: readonly string[],
243
204
  writtenFiles: readonly string[],
244
- removedLegacyFiles: readonly string[],
205
+ removedFiles: readonly string[],
245
206
  ): string =>
246
207
  [
247
- `${writtenFiles.length === 0 ? "Agent entry points are up to date" : "Initialized agent entry points"} for ${SDK_PACKAGE_NAME} ${SDK_VERSION} in ${targetRoot}:`,
208
+ `${writtenFiles.length === 0 && removedFiles.length === 0 ? "Agent entry points are up to date" : "Initialized agent entry points"} for ${SDK_PACKAGE_NAME} ${SDK_VERSION} in ${targetRoot}:`,
248
209
  ...files.map((file) => ` ${file}`),
249
- ...removedLegacyFiles.map((file) => ` removed ${file} (renamed to ${SKILL_NAME})`),
210
+ ...removedFiles.map(
211
+ (file) => ` removed ${file} (replaced by the ${AGENTS_PATH} Agent Bootstrap)`,
212
+ ),
250
213
  ].join("\n");
251
214
 
252
215
  const bindCheckout = async (
@@ -270,44 +233,27 @@ export async function initProject(
270
233
  ): Promise<InitProjectResult> {
271
234
  await mkdir(targetDirectory, { recursive: true });
272
235
  const targetRoot = await realpath(targetDirectory);
273
- const boundCheckout = await bindCheckout(targetRoot, options.projectId);
274
- const [hasClaude, hasCursor, hasAgents, skillDigest] = await Promise.all([
236
+ const agentsPath = join(targetRoot, AGENTS_PATH);
237
+ const existingAgents = await readOptionalFile(agentsPath);
238
+ const agentsSource = mergeBootstrap(existingAgents ?? "", agentsPath);
239
+ const [hasClaude, hasCursor] = await Promise.all([
275
240
  isDirectory(join(targetRoot, ".claude")),
276
241
  isDirectory(join(targetRoot, ".cursor", "rules")),
277
- isFile(join(targetRoot, AGENTS_PATH)),
278
- computeBundledSkillDigest(),
279
242
  ]);
280
- const useDefaults = !hasClaude && !hasCursor && !hasAgents;
281
- const candidates = [
282
- {
283
- relativePath: CLAUDE_SKILL_PATH,
284
- wanted: hasClaude || useDefaults,
285
- source: () => claudeSkillSource(skillDigest),
286
- },
287
- {
288
- relativePath: CURSOR_RULE_PATH,
289
- wanted: hasCursor,
290
- source: () => cursorRuleSource(skillDigest),
291
- },
292
- {
293
- relativePath: AGENTS_PATH,
294
- wanted: hasAgents || useDefaults,
295
- source: async () => {
296
- const existingSource = await readOptionalFile(join(targetRoot, AGENTS_PATH));
297
- return mergeAgentsBlock(existingSource ?? "", await agentsBlockSource(skillDigest));
298
- },
299
- },
243
+ const useDefaults = !hasClaude && !hasCursor && existingAgents === undefined;
244
+ const pointers: AgentEntry[] = [
245
+ ...(hasClaude || useDefaults
246
+ ? [{ relativePath: CLAUDE_POINTER_PATH, source: CLAUDE_POINTER_SOURCE }]
247
+ : []),
248
+ ...(hasCursor ? [{ relativePath: CURSOR_POINTER_PATH, source: CURSOR_POINTER_SOURCE }] : []),
300
249
  ];
301
- const selected = candidates.filter((candidate) => candidate.wanted);
250
+ const entries: AgentEntry[] = [...pointers, { relativePath: AGENTS_PATH, source: agentsSource }];
251
+ await Promise.all(entries.map((entry) => assertSafeDestination(targetRoot, entry.relativePath)));
302
252
  await Promise.all(
303
- selected.map((candidate) => assertSafeDestination(targetRoot, candidate.relativePath)),
304
- );
305
- const entries: AgentEntry[] = await Promise.all(
306
- selected.map(async (candidate) => ({
307
- relativePath: candidate.relativePath,
308
- source: await candidate.source(),
309
- })),
253
+ pointers.map((pointer) => assertRefreshablePointer(targetRoot, pointer.relativePath)),
310
254
  );
255
+ const legacyEntries = await recognizeLegacyEntries(targetRoot);
256
+ const boundCheckout = await bindCheckout(targetRoot, options.projectId);
311
257
 
312
258
  const writtenFiles = boundCheckout === undefined ? [] : [boundCheckout];
313
259
  for (const entry of entries) {
@@ -315,7 +261,10 @@ export async function initProject(
315
261
  writtenFiles.push(entry.relativePath);
316
262
  }
317
263
  }
318
- const removedLegacyFiles = await removeLegacyEntryPoints(targetRoot);
264
+ for (const entry of legacyEntries) {
265
+ await removeLegacyEntry(targetRoot, entry);
266
+ }
267
+ const removedFiles = legacyEntries.map((entry) => entry.file);
319
268
  const dependencyReady = await hasSdkDependency(targetRoot);
320
269
  const files = [
321
270
  ...entries.map((entry) => entry.relativePath),
@@ -328,10 +277,10 @@ export async function initProject(
328
277
  targetDirectory: targetRoot,
329
278
  files,
330
279
  writtenFiles,
280
+ removedFiles,
331
281
  sdkVersion: SDK_VERSION,
332
- skillDigest,
333
282
  dependencyReady,
334
- output: formatInitOutput(targetRoot, files, writtenFiles, removedLegacyFiles),
283
+ output: formatInitOutput(targetRoot, files, writtenFiles, removedFiles),
335
284
  ...(warning === undefined ? {} : { warning }),
336
285
  };
337
286
  }
@@ -339,23 +288,29 @@ export async function initProject(
339
288
  export async function readAgentSkillStalenessWarning(
340
289
  targetDirectory: string,
341
290
  ): Promise<string | undefined> {
342
- const currentDigest = await computeBundledSkillDigest();
343
- const staleDigests = new Set<string>();
344
- const generatedVersions = new Set<string>();
345
- for (const relativePath of [...AGENT_ENTRY_PATHS, ...LEGACY_ENTRY_PATHS]) {
291
+ const outdated: string[] = [];
292
+ const versions = new Set<string>();
293
+ const record = (relativePath: string, source: string) => {
294
+ outdated.push(relativePath);
295
+ const version = VERSION_PATTERN.exec(source)?.[1];
296
+ if (version !== undefined) versions.add(version);
297
+ };
298
+ for (const { file } of LEGACY_ENTRIES) {
299
+ const source = await readOptionalFile(join(targetDirectory, file));
300
+ if (source !== undefined && isGenerated(source)) record(file, source);
301
+ }
302
+ for (const relativePath of MANAGED_PATHS) {
346
303
  const source = await readOptionalFile(join(targetDirectory, relativePath));
347
304
  if (source === undefined) continue;
348
- const recordedDigest = DIGEST_PATTERN.exec(source)?.[1];
349
- if (recordedDigest !== undefined && recordedDigest !== currentDigest) {
350
- staleDigests.add(recordedDigest.slice(0, 12));
351
- const recordedVersion = VERSION_PATTERN.exec(source)?.[1];
352
- if (recordedVersion !== undefined) generatedVersions.add(recordedVersion);
305
+ const version = VERSION_PATTERN.exec(source)?.[1];
306
+ if (source.includes(LEGACY_BLOCK.begin) || (version !== undefined && version !== SDK_VERSION)) {
307
+ record(relativePath, source);
353
308
  }
354
309
  }
355
- if (staleDigests.size === 0) return undefined;
356
- const versions =
357
- generatedVersions.size === 0
358
- ? "an earlier build"
359
- : [...generatedVersions].toSorted().join(", ");
360
- return `Warning: Agent instructions generated by ${SDK_PACKAGE_NAME} ${versions} do not match the installed ${SDK_VERSION} skill content (${[...staleDigests].toSorted().join(", ")} -> ${currentDigest.slice(0, 12)}). Re-run \`toloka init\`.`;
310
+ if (outdated.length === 0) return undefined;
311
+ const generatedBy =
312
+ versions.size === 0
313
+ ? `an earlier ${SDK_PACKAGE_NAME} build`
314
+ : `${SDK_PACKAGE_NAME} ${[...versions].toSorted().join(", ")}`;
315
+ return `Warning: Agent instructions generated by ${generatedBy} are out of date (${outdated.join(", ")}). The installed ${SDK_VERSION} manages one Agent Bootstrap in ${AGENTS_PATH} plus pointer files. Re-run \`toloka init\`.`;
361
316
  }
@@ -0,0 +1,188 @@
1
+ import { assert } from "es-toolkit";
2
+
3
+ import type { BaseModel, BaseModelId, Model, ModelVersion, PotatoClient } from "../client";
4
+
5
+ type ModelApi = Pick<PotatoClient, "baseModels" | "models">;
6
+ type ModelIo = { readonly stdout: (text: string) => void };
7
+ type TableRow = readonly string[];
8
+
9
+ const BASE_MODEL_IDS = [
10
+ "qwen3-4b",
11
+ "qwen3-8b",
12
+ "qwen3-32b",
13
+ "qwen3-235b",
14
+ ] satisfies readonly BaseModelId[];
15
+
16
+ const formatTable = (rows: readonly TableRow[]): string => {
17
+ const widths = rows.reduce<number[]>(
18
+ (current, row) => row.map((cell, index) => Math.max(current[index] ?? 0, cell.length)),
19
+ [],
20
+ );
21
+ return rows
22
+ .map((row) =>
23
+ row
24
+ .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
25
+ .join(" ")
26
+ .trimEnd(),
27
+ )
28
+ .join("\n");
29
+ };
30
+
31
+ const writeOutput = (io: ModelIo, json: boolean, value: unknown, formatted: string): void => {
32
+ io.stdout(json ? JSON.stringify(value, null, 2) : formatted);
33
+ };
34
+
35
+ const formatBaseModel = (model: BaseModel): string =>
36
+ formatTable([
37
+ ["ID", model.id],
38
+ ["Name", model.displayName],
39
+ ["Provider", model.provider],
40
+ ["Upstream model", model.upstreamModel],
41
+ ["Upstream revision", model.upstreamRevision],
42
+ ]);
43
+
44
+ const formatModel = (model: Model): string =>
45
+ formatTable([
46
+ ["ID", model.id],
47
+ ["Project", model.projectId],
48
+ ["Name", model.name],
49
+ ["Base model", model.baseModelId],
50
+ ["Description", model.description ?? "-"],
51
+ ["Archived at", model.archivedAt ?? "-"],
52
+ ["Created at", model.createdAt],
53
+ ["Updated at", model.updatedAt],
54
+ ]);
55
+
56
+ export const parseBaseModelId = (value: string): BaseModelId => {
57
+ const modelId = BASE_MODEL_IDS.find((candidate) => candidate === value);
58
+ assert(modelId, `Unknown base model ${value}.`);
59
+ return modelId;
60
+ };
61
+
62
+ const resolveModel = async (
63
+ client: ModelApi,
64
+ projectId: string,
65
+ nameOrId: string,
66
+ ): Promise<Model> => {
67
+ if (nameOrId.startsWith("mdl.")) return client.models.get({ projectId, modelId: nameOrId });
68
+ const matches: Model[] = [];
69
+ for await (const model of client.models.listAll({ projectId, limit: 100 })) {
70
+ if (model.name === nameOrId) matches.push(model);
71
+ }
72
+ assert(matches.length === 1, `Model ${nameOrId} was not found or is ambiguous.`);
73
+ return matches[0]!;
74
+ };
75
+
76
+ export const listBaseModels = async (
77
+ client: ModelApi,
78
+ input: { json: boolean },
79
+ io: ModelIo,
80
+ ): Promise<void> => {
81
+ const models = await client.baseModels.list();
82
+ writeOutput(
83
+ io,
84
+ input.json,
85
+ models,
86
+ models.length === 0
87
+ ? "No base models found."
88
+ : formatTable([
89
+ ["ID", "NAME", "PROVIDER", "UPSTREAM MODEL"],
90
+ ...models.map((model) => [
91
+ model.id,
92
+ model.displayName,
93
+ model.provider,
94
+ model.upstreamModel,
95
+ ]),
96
+ ]),
97
+ );
98
+ };
99
+
100
+ export const getBaseModel = async (
101
+ client: ModelApi,
102
+ input: { id: BaseModelId; json: boolean },
103
+ io: ModelIo,
104
+ ): Promise<void> => {
105
+ const model = await client.baseModels.get({ id: input.id });
106
+ writeOutput(io, input.json, model, formatBaseModel(model));
107
+ };
108
+
109
+ export const listModels = async (
110
+ client: ModelApi,
111
+ input: { projectId: string; json: boolean },
112
+ io: ModelIo,
113
+ ): Promise<void> => {
114
+ const page = await client.models.listPage({ projectId: input.projectId, limit: 100, offset: 0 });
115
+ writeOutput(
116
+ io,
117
+ input.json,
118
+ page.models,
119
+ page.models.length === 0
120
+ ? "No models found."
121
+ : formatTable([
122
+ ["ID", "NAME", "BASE MODEL"],
123
+ ...page.models.map((model) => [model.id, model.name, model.baseModelId]),
124
+ ]),
125
+ );
126
+ };
127
+
128
+ export const getModel = async (
129
+ client: ModelApi,
130
+ input: { projectId: string; nameOrId: string; json: boolean },
131
+ io: ModelIo,
132
+ ): Promise<void> => {
133
+ const model = await resolveModel(client, input.projectId, input.nameOrId);
134
+ writeOutput(io, input.json, model, formatModel(model));
135
+ };
136
+
137
+ export const listModelVersions = async (
138
+ client: ModelApi,
139
+ input: { projectId: string; nameOrId: string; json: boolean },
140
+ io: ModelIo,
141
+ ): Promise<void> => {
142
+ const model = await resolveModel(client, input.projectId, input.nameOrId);
143
+ const versions: ModelVersion[] = [];
144
+ for await (const version of client.models.listAllVersions({
145
+ projectId: input.projectId,
146
+ modelId: model.id,
147
+ limit: 100,
148
+ })) {
149
+ versions.push(version);
150
+ }
151
+ writeOutput(
152
+ io,
153
+ input.json,
154
+ versions,
155
+ versions.length === 0
156
+ ? "No Model Versions found."
157
+ : formatTable([
158
+ ["VERSION", "ID", "CREATED AT"],
159
+ ...versions.map((version) => [version.version, version.id, version.createdAt]),
160
+ ]),
161
+ );
162
+ };
163
+
164
+ export const createModel = async (
165
+ client: ModelApi,
166
+ input: {
167
+ projectId: string;
168
+ name: string;
169
+ description?: string;
170
+ baseModelId: BaseModelId;
171
+ json: boolean;
172
+ },
173
+ io: ModelIo,
174
+ ): Promise<void> => {
175
+ const { json, ...body } = input;
176
+ const model = await client.models.create(body);
177
+ writeOutput(io, json, model, formatModel(model));
178
+ };
179
+
180
+ export const archiveModel = async (
181
+ client: ModelApi,
182
+ input: { projectId: string; nameOrId: string; json: boolean },
183
+ io: ModelIo,
184
+ ): Promise<void> => {
185
+ const model = await resolveModel(client, input.projectId, input.nameOrId);
186
+ const archived = await client.models.archive({ projectId: input.projectId, modelId: model.id });
187
+ writeOutput(io, input.json, archived, `Archived Model ${archived.id}.`);
188
+ };