@almanak/mcp-server 0.2.1 → 0.2.3

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.
package/dist/tools.js CHANGED
@@ -49,6 +49,8 @@ exports.PlatformToolHandler = void 0;
49
49
  const node_fs_1 = require("node:fs");
50
50
  const path = __importStar(require("node:path"));
51
51
  const runner_1 = require("./testing/runner");
52
+ const workspace_git_1 = require("./workspace-git");
53
+ const EXPORT_COMMIT_MESSAGE = 'feat: initial strategy export from Almanak';
52
54
  const ROOT_DIR_ALLOWED_RE = /^[a-zA-Z0-9._/-]+$/;
53
55
  const WORKSPACE_ABSOLUTE_PATH_RE = /^\/(?:tmp\/)?workspaces\/[^/]+\/(.+)$/;
54
56
  const WORKSPACE_ROOT_PATH_RE = /^\/(?:tmp\/)?workspaces\/[^/]+\/?$/;
@@ -196,6 +198,7 @@ class PlatformToolHandler {
196
198
  disabledTools: [],
197
199
  sdkGeneration: undefined,
198
200
  testController: null,
201
+ workspaceRoot: undefined,
199
202
  ...options,
200
203
  };
201
204
  this.enabledModes = new Set(this.options.modes);
@@ -255,7 +258,7 @@ class PlatformToolHandler {
255
258
  * is unavailable. Best effort by design: a failed check must never block the export flow.
256
259
  */
257
260
  async getLinkedSessionRepo() {
258
- var _a;
261
+ var _a, _b;
259
262
  try {
260
263
  const response = await this.client.getSessionRepoLink();
261
264
  const data = ((_a = response === null || response === void 0 ? void 0 : response.data) !== null && _a !== void 0 ? _a : response);
@@ -265,19 +268,317 @@ class PlatformToolHandler {
265
268
  const repoName = typeof data.repoName === 'string' ? data.repoName : '';
266
269
  if (!repoOwner || !repoName)
267
270
  return null;
271
+ const str = (key) => (typeof data[key] === 'string' ? data[key] : null);
268
272
  return {
269
273
  repoOwner,
270
274
  repoName,
271
275
  // The backend URL is provider-aware (Almanak Git or GitHub) — never assume a github.com host here.
272
- repoUrl: typeof data.repoUrl === 'string' ? data.repoUrl : '',
273
- provider: typeof data.provider === 'string' ? data.provider : null,
274
- strategyLinkId: typeof data.strategyLinkId === 'string' ? data.strategyLinkId : null,
276
+ repoUrl: (_b = str('repoUrl')) !== null && _b !== void 0 ? _b : '',
277
+ provider: str('provider'),
278
+ strategyLinkId: str('strategyLinkId'),
279
+ repoId: str('repoId'),
280
+ cloneUrl: str('cloneUrl'),
281
+ gitHost: str('gitHost'),
282
+ defaultBranch: str('defaultBranch'),
275
283
  };
276
284
  }
277
285
  catch {
278
286
  return null;
279
287
  }
280
288
  }
289
+ /** Workspace root for git-backed tools (see ToolHandlerOptions.workspaceRoot). */
290
+ get workspaceRoot() {
291
+ var _a;
292
+ return (_a = this.options.workspaceRoot) !== null && _a !== void 0 ? _a : process.cwd();
293
+ }
294
+ gitOpts(sessionId, timeoutMs) {
295
+ return { cwd: this.workspaceRoot, sessionId, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
296
+ }
297
+ /**
298
+ * Map a failed `POST /agent-sessions/{id}/repository` response to the tool's status vocabulary.
299
+ * Returns null when the response is a success.
300
+ */
301
+ mapLinkFailure(res) {
302
+ if (res && res.valid !== false && !(typeof res.statusCode === 'number' && res.statusCode >= 400) && res.data)
303
+ return null;
304
+ const message = typeof (res === null || res === void 0 ? void 0 : res.error) === 'string' ? res.error : typeof (res === null || res === void 0 ? void 0 : res.message) === 'string' ? res.message : 'Repository link failed';
305
+ const code = typeof (res === null || res === void 0 ? void 0 : res.errorCode) === 'string' ? res.errorCode : null;
306
+ switch (code) {
307
+ case 'name_conflict':
308
+ return { status: 'name_conflict', message: `${message} Choose a different repoName and call the tool again.` };
309
+ case 'github_connection_required':
310
+ return { status: 'github_connection_required', message };
311
+ case 'provider_disabled':
312
+ return { status: 'provider_disabled', message };
313
+ case 'repo_config_in_progress':
314
+ return { status: 'in_progress', message: `${message} Another link operation is still running for this session; wait and retry.` };
315
+ default:
316
+ return { status: 'failed', statusCode: typeof (res === null || res === void 0 ? void 0 : res.statusCode) === 'number' ? res.statusCode : 502, error: message, ...(code ? { errorCode: code } : {}) };
317
+ }
318
+ }
319
+ /**
320
+ * strategies_export_workspace: create + link a new repository, then init/commit/push the
321
+ * workspace from inside this call. Every step is synchronous so the agent can continue in
322
+ * the same turn on `completed`.
323
+ */
324
+ async exportWorkspace(args) {
325
+ var _a, _b, _c, _d, _e, _f, _g;
326
+ const repoName = typeof args.repoName === 'string' ? args.repoName.trim() : '';
327
+ if (!repoName)
328
+ return { statusCode: 400, status: 'failed', error: 'repoName is required' };
329
+ const description = typeof args.description === 'string' && args.description.trim() ? args.description.trim() : undefined;
330
+ let rootDir;
331
+ try {
332
+ rootDir = normalizeExportRootDir(args.rootDir);
333
+ }
334
+ catch (err) {
335
+ return { status: 'preflight_failed', message: (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err) };
336
+ }
337
+ // a. A chat links to exactly one repository — never create a second one.
338
+ const linked = await this.getLinkedSessionRepo();
339
+ if (linked)
340
+ return this.alreadyLinkedResult(linked);
341
+ const sessionId = this.client.sessionId();
342
+ if (!sessionId) {
343
+ return { statusCode: 400, status: 'failed', error: 'The platform token carries no agentSessionId claim; this tool only works inside an Almanak Code session.' };
344
+ }
345
+ const cwd = this.workspaceRoot;
346
+ // b. Flatten rootDir into the workspace root so the repository is flat.
347
+ if (rootDir) {
348
+ const flat = await (0, workspace_git_1.flattenRootDir)(cwd, rootDir);
349
+ if (!flat.ok)
350
+ return { status: 'preflight_failed', message: flat.error };
351
+ }
352
+ // c. Something must survive the export skip rules before we create a repository.
353
+ if (!(await (0, workspace_git_1.hasExportableFiles)(cwd))) {
354
+ return {
355
+ status: 'preflight_failed',
356
+ message: 'No exportable files in the workspace (session-internal, env, dependency and cache files are excluded). Create the strategy files first, then retry.',
357
+ };
358
+ }
359
+ // d. Create + link the repository (the backend also provisions git credentials for this session).
360
+ const res = await this.client.linkSessionRepository(sessionId, { create: { repoName, description } });
361
+ const failure = this.mapLinkFailure(res);
362
+ if (failure)
363
+ return failure;
364
+ const link = res.data;
365
+ if (link.status === 'already_linked') {
366
+ // Lost a race with another link in this session: surface it, never push to a repository we did not just create.
367
+ return this.alreadyLinkedResult({
368
+ repoOwner: link.repoOwner,
369
+ repoName: link.repoName,
370
+ repoUrl: link.repoUrl,
371
+ provider: (_b = link.provider) !== null && _b !== void 0 ? _b : null,
372
+ strategyLinkId: (_c = link.strategyLinkId) !== null && _c !== void 0 ? _c : null,
373
+ repoId: (_d = link.repoId) !== null && _d !== void 0 ? _d : null,
374
+ cloneUrl: (_e = link.cloneUrl) !== null && _e !== void 0 ? _e : null,
375
+ gitHost: (_f = link.gitHost) !== null && _f !== void 0 ? _f : null,
376
+ defaultBranch: (_g = link.defaultBranch) !== null && _g !== void 0 ? _g : null,
377
+ });
378
+ }
379
+ const defaultBranch = link.defaultBranch || 'main';
380
+ const linkedFields = {
381
+ provider: link.provider,
382
+ repoOwner: link.repoOwner,
383
+ repoName: link.repoName,
384
+ repoUrl: link.repoUrl,
385
+ strategyLinkId: link.strategyLinkId,
386
+ cloneUrl: link.cloneUrl,
387
+ };
388
+ // e. git, in the workspace, with ALMANAK_SESSION_ID for the credential helper.
389
+ const opts = this.gitOpts(sessionId);
390
+ let branch = defaultBranch;
391
+ // Always land the export on the repository's default branch: backtests and deploys resolve that
392
+ // branch, so a workspace already on another local branch pushes there via an explicit refspec.
393
+ const pushRefspec = (b) => (b === defaultBranch ? b : `${b}:${defaultBranch}`);
394
+ const pushHint = (b) => `Repository is linked; fix the problem and run: git push -u origin ${pushRefspec(b)}`;
395
+ try {
396
+ if (!(await (0, workspace_git_1.isGitRepo)(cwd))) {
397
+ await (0, workspace_git_1.initRepo)(defaultBranch, opts);
398
+ }
399
+ else {
400
+ const existing = await (0, workspace_git_1.currentBranch)(opts);
401
+ if (existing) {
402
+ branch = existing;
403
+ }
404
+ else if (!(await (0, workspace_git_1.hasCommits)(opts))) {
405
+ // Unborn HEAD with a different name: rename it before the first commit.
406
+ await (0, workspace_git_1.runGit)(['symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`], opts);
407
+ }
408
+ else {
409
+ await (0, workspace_git_1.runGit)(['checkout', '-B', defaultBranch], opts);
410
+ }
411
+ }
412
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
413
+ await (0, workspace_git_1.ensureCommitIdentity)(opts);
414
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
415
+ await (0, workspace_git_1.runGit)(['add', '-A'], opts);
416
+ const nothingStaged = await (0, workspace_git_1.gitSucceeds)(['diff', '--cached', '--quiet'], opts);
417
+ if (nothingStaged && !(await (0, workspace_git_1.hasCommits)(opts))) {
418
+ return {
419
+ status: 'preflight_failed',
420
+ ...linkedFields,
421
+ message: 'No exportable files: everything in the workspace is ignored by git (.gitignore or the managed excludes). ' +
422
+ `The repository ${link.repoOwner}/${link.repoName} is linked but empty — add files and run: git add -A && git commit -m "..." && git push -u origin ${branch}`,
423
+ };
424
+ }
425
+ if (!nothingStaged)
426
+ await (0, workspace_git_1.runGit)(['commit', '-q', '-m', EXPORT_COMMIT_MESSAGE], opts);
427
+ }
428
+ catch (err) {
429
+ return this.linkedPushFailed(err, linkedFields, branch, pushHint(branch));
430
+ }
431
+ try {
432
+ await (0, workspace_git_1.runGit)(['push', '-u', 'origin', pushRefspec(branch)], this.gitOpts(sessionId, workspace_git_1.GIT_NETWORK_TIMEOUT_MS));
433
+ }
434
+ catch (err) {
435
+ return this.linkedPushFailed(err, linkedFields, branch, pushHint(branch));
436
+ }
437
+ const commitSha = (await (0, workspace_git_1.runGit)(['rev-parse', 'HEAD'], opts)).stdout.trim();
438
+ // f. Done — the agent continues in the same turn.
439
+ return {
440
+ status: 'completed',
441
+ provider: link.provider,
442
+ repoOwner: link.repoOwner,
443
+ repoName: link.repoName,
444
+ repoUrl: link.repoUrl,
445
+ strategyLinkId: link.strategyLinkId,
446
+ commitSha,
447
+ branch: defaultBranch,
448
+ ...(branch !== defaultBranch ? { localBranch: branch } : {}),
449
+ message: `Exported and pushed ${commitSha.slice(0, 8)} to ${link.repoOwner}/${link.repoName} (${defaultBranch}). ` +
450
+ (branch !== defaultBranch
451
+ ? `The workspace stays on its local branch ${branch}, which tracks origin/${defaultBranch}. `
452
+ : '') +
453
+ 'The workspace is now a git checkout of this repository.',
454
+ };
455
+ }
456
+ alreadyLinkedResult(linked) {
457
+ return {
458
+ status: 'already_linked',
459
+ repoOwner: linked.repoOwner,
460
+ repoName: linked.repoName,
461
+ repoUrl: linked.repoUrl,
462
+ strategyLinkId: linked.strategyLinkId,
463
+ provider: linked.provider,
464
+ ...(linked.cloneUrl ? { cloneUrl: linked.cloneUrl } : {}),
465
+ ...(linked.defaultBranch ? { defaultBranch: linked.defaultBranch } : {}),
466
+ message: `This chat is already linked to ${linked.repoOwner}/${linked.repoName}. ` +
467
+ 'Exporting again is not possible from this chat — use git add/commit/push in the workspace to publish further changes.',
468
+ };
469
+ }
470
+ linkedPushFailed(err, linkedFields, branch, hint) {
471
+ var _a;
472
+ const raw = err instanceof workspace_git_1.GitError ? err.message : (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err);
473
+ return {
474
+ status: 'linked_push_failed',
475
+ error: (0, workspace_git_1.redactSecrets)(raw),
476
+ ...linkedFields,
477
+ branch,
478
+ message: hint,
479
+ };
480
+ }
481
+ /**
482
+ * strategies_link_workspace: attach the chat to an existing strategy link and wire the
483
+ * workspace to its repository with git (clone into an empty workspace, or fetch + new branch
484
+ * when user files exist so nothing is overwritten).
485
+ */
486
+ async linkWorkspace(args) {
487
+ var _a;
488
+ const strategyLinkId = typeof args.strategy_link_id === 'string' ? args.strategy_link_id.trim() : '';
489
+ if (!strategyLinkId)
490
+ return { statusCode: 400, status: 'failed', error: 'strategy_link_id is required' };
491
+ const sessionId = this.client.sessionId();
492
+ if (!sessionId) {
493
+ return { statusCode: 400, status: 'failed', error: 'The platform token carries no agentSessionId claim; this tool only works inside an Almanak Code session.' };
494
+ }
495
+ const cwd = this.workspaceRoot;
496
+ const res = await this.client.linkSessionRepository(sessionId, { strategyLinkId });
497
+ const failure = this.mapLinkFailure(res);
498
+ if (failure)
499
+ return failure;
500
+ const link = res.data;
501
+ const defaultBranch = link.defaultBranch || 'main';
502
+ const base = {
503
+ provider: link.provider,
504
+ repoId: link.repoId,
505
+ repoOwner: link.repoOwner,
506
+ repoName: link.repoName,
507
+ repoUrl: link.repoUrl,
508
+ cloneUrl: link.cloneUrl,
509
+ strategyLinkId: link.strategyLinkId,
510
+ defaultBranch,
511
+ };
512
+ if (link.status === 'already_linked' && link.strategyLinkId && link.strategyLinkId !== strategyLinkId) {
513
+ return {
514
+ status: 'already_linked',
515
+ ...base,
516
+ branch: null,
517
+ message: `This chat is already linked to ${link.repoOwner}/${link.repoName} (strategy link ${link.strategyLinkId}); it cannot be relinked to ${strategyLinkId}. Workspace git was left untouched.`,
518
+ };
519
+ }
520
+ const opts = this.gitOpts(sessionId);
521
+ const netOpts = this.gitOpts(sessionId, workspace_git_1.GIT_NETWORK_TIMEOUT_MS);
522
+ try {
523
+ if (await (0, workspace_git_1.isGitRepo)(cwd)) {
524
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
525
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
526
+ const branch = await (0, workspace_git_1.currentBranch)(opts);
527
+ return {
528
+ status: link.status,
529
+ ...base,
530
+ branch,
531
+ message: `Linked ${link.repoOwner}/${link.repoName}; the workspace was already a git repository, so only the origin remote was repointed to it. Run git fetch origin and reconcile with origin/${defaultBranch} as needed.`,
532
+ };
533
+ }
534
+ const userFiles = await (0, workspace_git_1.hasUserFiles)(cwd);
535
+ await (0, workspace_git_1.initRepo)(defaultBranch, opts);
536
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
537
+ await (0, workspace_git_1.ensureCommitIdentity)(opts);
538
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
539
+ await (0, workspace_git_1.runGit)(['fetch', '-q', 'origin'], netOpts);
540
+ const remoteHasBranch = await (0, workspace_git_1.remoteBranchExists)(defaultBranch, opts);
541
+ if (!userFiles) {
542
+ if (remoteHasBranch) {
543
+ await (0, workspace_git_1.runGit)(['checkout', '-q', '-B', defaultBranch, `origin/${defaultBranch}`], opts);
544
+ return {
545
+ status: link.status,
546
+ ...base,
547
+ branch: defaultBranch,
548
+ message: `Linked ${link.repoOwner}/${link.repoName} and checked out origin/${defaultBranch} into the workspace.`,
549
+ };
550
+ }
551
+ return {
552
+ status: link.status,
553
+ ...base,
554
+ branch: defaultBranch,
555
+ message: `Linked ${link.repoOwner}/${link.repoName}; the repository has no ${defaultBranch} branch yet, so the workspace is an empty checkout on ${defaultBranch}. Add files, commit, and git push -u origin ${defaultBranch}.`,
556
+ };
557
+ }
558
+ const sessionBranch = `almanak/session-${sessionId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 8)}`;
559
+ await (0, workspace_git_1.runGit)(['symbolic-ref', 'HEAD', `refs/heads/${sessionBranch}`], opts);
560
+ return {
561
+ status: link.status,
562
+ ...base,
563
+ branch: sessionBranch,
564
+ message: `Linked ${link.repoOwner}/${link.repoName}. The workspace already had files, so they were NOT overwritten: git was initialised on new branch ${sessionBranch} ` +
565
+ `with the existing files left as uncommitted changes` +
566
+ (remoteHasBranch
567
+ ? ` and origin/${defaultBranch} fetched. Reconcile with git diff origin/${defaultBranch}, then commit and push (git push -u origin ${sessionBranch}, or merge into ${defaultBranch}).`
568
+ : `; the repository has no ${defaultBranch} branch yet. Commit and git push -u origin ${defaultBranch} when ready.`),
569
+ };
570
+ }
571
+ catch (err) {
572
+ const raw = err instanceof workspace_git_1.GitError ? err.message : (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err);
573
+ return {
574
+ status: 'linked_attach_failed',
575
+ error: (0, workspace_git_1.redactSecrets)(raw),
576
+ ...base,
577
+ branch: null,
578
+ message: `The chat is linked to ${link.repoOwner}/${link.repoName} but attaching the workspace with git failed; fix the problem and run git remote add origin ${link.cloneUrl} && git fetch origin manually.`,
579
+ };
580
+ }
581
+ }
281
582
  register(mode, definition, handler) {
282
583
  this.registeredNames.add(definition.name);
283
584
  if (!this.modeEnabled(mode))
@@ -331,7 +632,7 @@ class PlatformToolHandler {
331
632
  type: 'object',
332
633
  properties: {
333
634
  agent_name: { type: 'string', description: 'Display name for the agent' },
334
- github_strategy_link_id: { type: 'string', description: 'ID of the linked strategy (from strategies_list or the export confirmation)' },
635
+ github_strategy_link_id: { type: 'string', description: 'ID of the linked strategy (from strategies_list or the strategies_export_workspace result)' },
335
636
  github_ref: { type: 'string', description: 'Git branch, tag, or commit SHA to deploy' },
336
637
  github_ref_type: {
337
638
  type: 'string',
@@ -863,92 +1164,56 @@ class PlatformToolHandler {
863
1164
  return this.client.validateRepository(args.owner, args.repo, ref);
864
1165
  });
865
1166
  this.register('strategies', {
866
- name: 'strategies_export_preview',
867
- description: 'Show an interactive repo-export widget in the chat so the user can configure repo name, visibility, and other options before exporting. Showing this widget does NOT perform the export — the user must press the Export button in the widget. Only then does the export run and resume you with a confirmation result containing the new repo. Until you receive that confirmation result, do NOT claim the export completed, that the repo exists, or that the strategy is ready to deploy — the export has not happened yet. Don\'t ask if it finished or open another widget to confirm; just wait for the resume. If this chat is already linked to a repository, no widget is shown and the tool returns that repo instead (already_linked: true) — tell the user which repo it is; the export cannot be repeated in this chat.',
1167
+ name: 'strategies_export_workspace',
1168
+ description: 'Export the workspace to a NEW private repository on Almanak Git, the platform-hosted git service, created under the user namespace — there is no choice of git host. ' +
1169
+ 'Export the current workspace to a NEW git repository and link it to this chat, immediately, inside this tool call: ' +
1170
+ 'the platform creates and links the repository, then this tool runs git init/add/commit/push in the workspace itself. ' +
1171
+ 'No widget is shown and no user click is needed — on status "completed" the repository exists with the pushed commit, so ' +
1172
+ 'continue in the SAME turn (for example run a backtest or deploy). Pass repository metadata only; never read or submit files. ' +
1173
+ 'Session-internal files (.almanak, .almanakdb, almanak_state.db*, .env, .venv, node_modules, .logs) are excluded automatically. ' +
1174
+ 'Pass rootDir only when the strategy lives in a subfolder (e.g. "my_strategy/"): its contents are moved up to the workspace root ' +
1175
+ 'so the repository is flat. Statuses: "completed" (done: repoUrl, strategyLinkId, commitSha, branch); "already_linked" (this chat ' +
1176
+ 'already has a repository — do not export again, use git add/commit/push in the workspace); "name_conflict" (pick a different ' +
1177
+ 'repoName and retry); "preflight_failed" (fix the workspace/rootDir and retry); "github_connection_required", "provider_disabled", ' +
1178
+ '"in_progress" (report to the user); "linked_push_failed" (the repository IS linked but the commit/push failed — fix the git ' +
1179
+ 'problem and run the git command in the message; do not call this tool again). Never claim the export succeeded before "completed".',
868
1180
  inputSchema: {
869
1181
  type: 'object',
870
1182
  properties: {
871
- strategyName: { type: 'string', description: 'Display name for the strategy (shown in widget header).' },
872
- repoName: { type: 'string', description: 'Suggested repository name (pre-fills the widget input, user can edit).' },
873
- description: { type: 'string', description: 'Suggested repository description (pre-fills the widget input, user can edit).' },
874
- rootDir: { type: 'string', description: 'Subdirectory within the workspace to use as the repo root. Pass this when the strategy was scaffolded into a subdirectory (e.g. almanak strat new creates my_strategy/). Omit for strategies at the workspace root (the default). Never pass an absolute path — workspace-relative folder name only. Files outside this dir are excluded and the prefix is stripped so the repo has a flat structure (my_strategy/strategy.py → strategy.py).' },
1183
+ repoName: { type: 'string', description: 'Repository name (required). Lowercase letters, digits, dashes recommended.' },
1184
+ description: { type: 'string', description: 'Repository description.' },
1185
+ rootDir: {
1186
+ type: 'string',
1187
+ description: 'Optional workspace-relative strategy folder whose contents become the repository root (flattened into the workspace). ' +
1188
+ 'Omit for strategies at the workspace root (the default). Never pass an absolute path.',
1189
+ },
875
1190
  },
876
- required: [],
1191
+ required: ['repoName'],
877
1192
  },
878
- }, async (args) => {
879
- var _a;
880
- // A chat session links to exactly one repository. Opening the export widget on a session
881
- // that is already linked can only end in a conflict — and, before the pre-check landed, a
882
- // throwaway repo on the user's account (ALM-3262). Hand back the existing link instead.
883
- const linkedRepo = await this.getLinkedSessionRepo();
884
- if (linkedRepo) {
885
- return {
886
- show_widget: false,
887
- already_linked: true,
888
- ...linkedRepo,
889
- message: `This chat is already linked to ${linkedRepo.repoOwner}/${linkedRepo.repoName}. ` +
890
- 'Exporting again is not possible from this chat — push further changes with git, or start a new chat to export to a different repository.',
891
- };
892
- }
893
- const result = {
894
- show_widget: true,
895
- strategyName: (_a = args.strategyName) !== null && _a !== void 0 ? _a : 'Strategy',
896
- };
897
- if (typeof args.repoName === 'string' && args.repoName.trim())
898
- result.name = args.repoName.trim();
899
- if (typeof args.description === 'string' && args.description.trim())
900
- result.description = args.description.trim();
901
- const rootDir = normalizeExportRootDir(args.rootDir);
902
- if (rootDir)
903
- result.rootDir = rootDir;
904
- return result;
905
- });
1193
+ }, (args) => this.exportWorkspace(args));
906
1194
  this.register('strategies', {
907
- name: 'strategies_export',
908
- description: 'Export code to a new repository on the default git host. Pass a files array of explicit repo-relative file paths and contents. The new repo will be automatically linked to the current chat session (injecting git credentials and restarting the worker). After export, the user can interact with the repo via git commands.',
1195
+ name: 'strategies_link_workspace',
1196
+ description: 'Link an EXISTING strategy repository (a strategy_link_id from strategies_list) to this chat and attach the workspace to it with git, ' +
1197
+ 'immediately, inside this tool call. Use it when the user wants to continue work on a strategy they already exported. ' +
1198
+ 'An empty workspace is checked out from the repository default branch. A workspace that already has files is NOT overwritten: ' +
1199
+ 'the tool initialises git, fetches the repository, and leaves the files as uncommitted changes on a new branch named in the result — ' +
1200
+ 'inspect git status / git diff origin/<default branch>, reconcile, then commit and push. If the workspace is already a git ' +
1201
+ 'repository, only the origin remote is repointed. Statuses: "linked", "already_linked" (with the linked repo), "failed". ' +
1202
+ 'The chat can be linked to exactly one repository; to export a brand new one use strategies_export_workspace instead.',
909
1203
  inputSchema: {
910
1204
  type: 'object',
911
1205
  properties: {
912
- files: {
913
- type: 'array',
914
- description: 'Array of files to export with explicit repo-relative paths.',
915
- items: {
916
- type: 'object',
917
- properties: {
918
- path: { type: 'string', description: 'Relative file path in the repo (e.g. "src/strategy.ts").' },
919
- content: { type: 'string', description: 'File content.' },
920
- encoding: { type: 'string', enum: ['utf-8', 'base64'], description: 'Content encoding. Default: "utf-8".' },
921
- },
922
- required: ['path', 'content'],
923
- },
924
- },
925
- repoName: { type: 'string', description: 'Repository name (required).' },
926
- description: { type: 'string', description: 'Repository description.' },
927
- isPrivate: { type: 'boolean', description: 'Whether the new repo should be private. Default: true.' },
1206
+ strategy_link_id: { type: 'string', description: 'The strategy link ID (from strategies_list).' },
928
1207
  },
929
- required: ['repoName', 'files'],
1208
+ required: ['strategy_link_id'],
930
1209
  },
931
- }, async (args) => {
932
- var _a;
933
- const files = args.files;
934
- if (!files || files.length === 0) {
935
- return { statusCode: 400, error: 'files array is required' };
936
- }
937
- if (!((_a = args.repoName) === null || _a === void 0 ? void 0 : _a.trim())) {
938
- return { statusCode: 400, error: 'repoName is required' };
939
- }
940
- return this.client.exportToGitHub({
941
- files,
942
- repoName: args.repoName,
943
- description: args.description,
944
- isPrivate: args.isPrivate,
945
- });
946
- });
1210
+ }, (args) => this.linkWorkspace(args));
947
1211
  this.register('strategies', {
948
1212
  name: 'backtests_run',
949
- description: 'Run a backtest of a linked strategy against historical market data. Use this after the strategy is ' +
950
- 'exported/pushed to its repository and before deploying — backtests run against a pushed commit, never local files, so ' +
951
- 'push first and re-run after every code change you want tested. The call submits the run, renders a live backtest ' +
1213
+ description: 'Run a backtest of a linked strategy against historical market data. Use this after the strategy is in its ' +
1214
+ 'repository (strategies_export_workspace, or git push on a linked chat) and before deploying — backtests run against a ' +
1215
+ 'pushed commit, never local files, so export/push first and re-run after every code change you want tested. ' +
1216
+ 'The call submits the run, renders a live backtest ' +
952
1217
  'widget in the chat, and PAUSES until the run finishes (typically 1-5 minutes); it then resumes you with the ' +
953
1218
  'result summary (total return, Sharpe, max drawdown, win rate, trades, and the run_validity verdict) merged into ' +
954
1219
  'this tool result as _widgetResponse — or the failure reason if the run failed. A run whose verdict is INVALID or ' +
@@ -966,7 +1231,7 @@ class PlatformToolHandler {
966
1231
  properties: {
967
1232
  strategy_link_id: {
968
1233
  type: 'string',
969
- description: 'The strategy link ID (from strategies_list or the export confirmation).',
1234
+ description: 'The strategy link ID (from strategies_list or the strategies_export_workspace result).',
970
1235
  },
971
1236
  start_time: {
972
1237
  type: 'string',
@@ -1345,7 +1610,7 @@ class PlatformToolHandler {
1345
1610
  if (!strategyLinkId) {
1346
1611
  return {
1347
1612
  statusCode: 400,
1348
- error: 'strategy_link_id is required — pass the linked strategy id (from strategies_list or the export result).',
1613
+ error: 'strategy_link_id is required — pass the linked strategy id (from strategies_list or the strategies_export_workspace result).',
1349
1614
  backtest_config: { start_time: startTime, end_time: String((_c = args.end_time) !== null && _c !== void 0 ? _c : '').trim(), include_gas_costs: args.include_gas_costs !== false },
1350
1615
  };
1351
1616
  }