@almanak/mcp-server 0.2.1 → 0.2.2

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,9 @@ 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 GIT_PROVIDERS = new Set(['almanak', 'github']);
54
+ const EXPORT_COMMIT_MESSAGE = 'feat: initial strategy export from Almanak';
52
55
  const ROOT_DIR_ALLOWED_RE = /^[a-zA-Z0-9._/-]+$/;
53
56
  const WORKSPACE_ABSOLUTE_PATH_RE = /^\/(?:tmp\/)?workspaces\/[^/]+\/(.+)$/;
54
57
  const WORKSPACE_ROOT_PATH_RE = /^\/(?:tmp\/)?workspaces\/[^/]+\/?$/;
@@ -196,6 +199,7 @@ class PlatformToolHandler {
196
199
  disabledTools: [],
197
200
  sdkGeneration: undefined,
198
201
  testController: null,
202
+ workspaceRoot: undefined,
199
203
  ...options,
200
204
  };
201
205
  this.enabledModes = new Set(this.options.modes);
@@ -255,7 +259,7 @@ class PlatformToolHandler {
255
259
  * is unavailable. Best effort by design: a failed check must never block the export flow.
256
260
  */
257
261
  async getLinkedSessionRepo() {
258
- var _a;
262
+ var _a, _b;
259
263
  try {
260
264
  const response = await this.client.getSessionRepoLink();
261
265
  const data = ((_a = response === null || response === void 0 ? void 0 : response.data) !== null && _a !== void 0 ? _a : response);
@@ -265,19 +269,321 @@ class PlatformToolHandler {
265
269
  const repoName = typeof data.repoName === 'string' ? data.repoName : '';
266
270
  if (!repoOwner || !repoName)
267
271
  return null;
272
+ const str = (key) => (typeof data[key] === 'string' ? data[key] : null);
268
273
  return {
269
274
  repoOwner,
270
275
  repoName,
271
276
  // 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,
277
+ repoUrl: (_b = str('repoUrl')) !== null && _b !== void 0 ? _b : '',
278
+ provider: str('provider'),
279
+ strategyLinkId: str('strategyLinkId'),
280
+ repoId: str('repoId'),
281
+ cloneUrl: str('cloneUrl'),
282
+ gitHost: str('gitHost'),
283
+ defaultBranch: str('defaultBranch'),
275
284
  };
276
285
  }
277
286
  catch {
278
287
  return null;
279
288
  }
280
289
  }
290
+ /** Workspace root for git-backed tools (see ToolHandlerOptions.workspaceRoot). */
291
+ get workspaceRoot() {
292
+ var _a;
293
+ return (_a = this.options.workspaceRoot) !== null && _a !== void 0 ? _a : process.cwd();
294
+ }
295
+ gitOpts(sessionId, timeoutMs) {
296
+ return { cwd: this.workspaceRoot, sessionId, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
297
+ }
298
+ /**
299
+ * Map a failed `POST /agent-sessions/{id}/repository` response to the tool's status vocabulary.
300
+ * Returns null when the response is a success.
301
+ */
302
+ mapLinkFailure(res) {
303
+ if (res && res.valid !== false && !(typeof res.statusCode === 'number' && res.statusCode >= 400) && res.data)
304
+ return null;
305
+ 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';
306
+ const code = typeof (res === null || res === void 0 ? void 0 : res.errorCode) === 'string' ? res.errorCode : null;
307
+ switch (code) {
308
+ case 'name_conflict':
309
+ return { status: 'name_conflict', message: `${message} Choose a different repoName and call the tool again.` };
310
+ case 'github_connection_required':
311
+ return { status: 'github_connection_required', message };
312
+ case 'provider_disabled':
313
+ return { status: 'provider_disabled', message };
314
+ case 'repo_config_in_progress':
315
+ return { status: 'in_progress', message: `${message} Another link operation is still running for this session; wait and retry.` };
316
+ default:
317
+ return { status: 'failed', statusCode: typeof (res === null || res === void 0 ? void 0 : res.statusCode) === 'number' ? res.statusCode : 502, error: message, ...(code ? { errorCode: code } : {}) };
318
+ }
319
+ }
320
+ /**
321
+ * strategies_export_workspace: create + link a new repository, then init/commit/push the
322
+ * workspace from inside this call. Every step is synchronous so the agent can continue in
323
+ * the same turn on `completed`.
324
+ */
325
+ async exportWorkspace(args) {
326
+ var _a, _b, _c, _d, _e, _f, _g;
327
+ const repoName = typeof args.repoName === 'string' ? args.repoName.trim() : '';
328
+ if (!repoName)
329
+ return { statusCode: 400, status: 'failed', error: 'repoName is required' };
330
+ const description = typeof args.description === 'string' && args.description.trim() ? args.description.trim() : undefined;
331
+ const provider = typeof args.provider === 'string' && args.provider ? args.provider : undefined;
332
+ if (provider !== undefined && !GIT_PROVIDERS.has(provider)) {
333
+ return { statusCode: 400, status: 'failed', error: 'provider must be "almanak" or "github"' };
334
+ }
335
+ let rootDir;
336
+ try {
337
+ rootDir = normalizeExportRootDir(args.rootDir);
338
+ }
339
+ catch (err) {
340
+ return { status: 'preflight_failed', message: (_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : String(err) };
341
+ }
342
+ // a. A chat links to exactly one repository — never create a second one.
343
+ const linked = await this.getLinkedSessionRepo();
344
+ if (linked)
345
+ return this.alreadyLinkedResult(linked);
346
+ const sessionId = this.client.sessionId();
347
+ if (!sessionId) {
348
+ return { statusCode: 400, status: 'failed', error: 'The platform token carries no agentSessionId claim; this tool only works inside an Almanak Code session.' };
349
+ }
350
+ const cwd = this.workspaceRoot;
351
+ // b. Flatten rootDir into the workspace root so the repository is flat.
352
+ if (rootDir) {
353
+ const flat = await (0, workspace_git_1.flattenRootDir)(cwd, rootDir);
354
+ if (!flat.ok)
355
+ return { status: 'preflight_failed', message: flat.error };
356
+ }
357
+ // c. Something must survive the export skip rules before we create a repository.
358
+ if (!(await (0, workspace_git_1.hasExportableFiles)(cwd))) {
359
+ return {
360
+ status: 'preflight_failed',
361
+ message: 'No exportable files in the workspace (session-internal, env, dependency and cache files are excluded). Create the strategy files first, then retry.',
362
+ };
363
+ }
364
+ // d. Create + link the repository (the backend also provisions git credentials for this session).
365
+ const res = await this.client.linkSessionRepository(sessionId, { create: { repoName, description, provider: provider } });
366
+ const failure = this.mapLinkFailure(res);
367
+ if (failure)
368
+ return failure;
369
+ const link = res.data;
370
+ if (link.status === 'already_linked') {
371
+ // Lost a race with another link in this session: surface it, never push to a repository we did not just create.
372
+ return this.alreadyLinkedResult({
373
+ repoOwner: link.repoOwner,
374
+ repoName: link.repoName,
375
+ repoUrl: link.repoUrl,
376
+ provider: (_b = link.provider) !== null && _b !== void 0 ? _b : null,
377
+ strategyLinkId: (_c = link.strategyLinkId) !== null && _c !== void 0 ? _c : null,
378
+ repoId: (_d = link.repoId) !== null && _d !== void 0 ? _d : null,
379
+ cloneUrl: (_e = link.cloneUrl) !== null && _e !== void 0 ? _e : null,
380
+ gitHost: (_f = link.gitHost) !== null && _f !== void 0 ? _f : null,
381
+ defaultBranch: (_g = link.defaultBranch) !== null && _g !== void 0 ? _g : null,
382
+ });
383
+ }
384
+ const defaultBranch = link.defaultBranch || 'main';
385
+ const linkedFields = {
386
+ provider: link.provider,
387
+ repoOwner: link.repoOwner,
388
+ repoName: link.repoName,
389
+ repoUrl: link.repoUrl,
390
+ strategyLinkId: link.strategyLinkId,
391
+ cloneUrl: link.cloneUrl,
392
+ };
393
+ // e. git, in the workspace, with ALMANAK_SESSION_ID for the credential helper.
394
+ const opts = this.gitOpts(sessionId);
395
+ let branch = defaultBranch;
396
+ // Always land the export on the repository's default branch: backtests and deploys resolve that
397
+ // branch, so a workspace already on another local branch pushes there via an explicit refspec.
398
+ const pushRefspec = (b) => (b === defaultBranch ? b : `${b}:${defaultBranch}`);
399
+ const pushHint = (b) => `Repository is linked; fix the problem and run: git push -u origin ${pushRefspec(b)}`;
400
+ try {
401
+ if (!(await (0, workspace_git_1.isGitRepo)(cwd))) {
402
+ await (0, workspace_git_1.initRepo)(defaultBranch, opts);
403
+ }
404
+ else {
405
+ const existing = await (0, workspace_git_1.currentBranch)(opts);
406
+ if (existing) {
407
+ branch = existing;
408
+ }
409
+ else if (!(await (0, workspace_git_1.hasCommits)(opts))) {
410
+ // Unborn HEAD with a different name: rename it before the first commit.
411
+ await (0, workspace_git_1.runGit)(['symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`], opts);
412
+ }
413
+ else {
414
+ await (0, workspace_git_1.runGit)(['checkout', '-B', defaultBranch], opts);
415
+ }
416
+ }
417
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
418
+ await (0, workspace_git_1.ensureCommitIdentity)(opts);
419
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
420
+ await (0, workspace_git_1.runGit)(['add', '-A'], opts);
421
+ const nothingStaged = await (0, workspace_git_1.gitSucceeds)(['diff', '--cached', '--quiet'], opts);
422
+ if (nothingStaged && !(await (0, workspace_git_1.hasCommits)(opts))) {
423
+ return {
424
+ status: 'preflight_failed',
425
+ ...linkedFields,
426
+ message: 'No exportable files: everything in the workspace is ignored by git (.gitignore or the managed excludes). ' +
427
+ `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}`,
428
+ };
429
+ }
430
+ if (!nothingStaged)
431
+ await (0, workspace_git_1.runGit)(['commit', '-q', '-m', EXPORT_COMMIT_MESSAGE], opts);
432
+ }
433
+ catch (err) {
434
+ return this.linkedPushFailed(err, linkedFields, branch, pushHint(branch));
435
+ }
436
+ try {
437
+ await (0, workspace_git_1.runGit)(['push', '-u', 'origin', pushRefspec(branch)], this.gitOpts(sessionId, workspace_git_1.GIT_NETWORK_TIMEOUT_MS));
438
+ }
439
+ catch (err) {
440
+ return this.linkedPushFailed(err, linkedFields, branch, pushHint(branch));
441
+ }
442
+ const commitSha = (await (0, workspace_git_1.runGit)(['rev-parse', 'HEAD'], opts)).stdout.trim();
443
+ // f. Done — the agent continues in the same turn.
444
+ return {
445
+ status: 'completed',
446
+ provider: link.provider,
447
+ repoOwner: link.repoOwner,
448
+ repoName: link.repoName,
449
+ repoUrl: link.repoUrl,
450
+ strategyLinkId: link.strategyLinkId,
451
+ commitSha,
452
+ branch: defaultBranch,
453
+ ...(branch !== defaultBranch ? { localBranch: branch } : {}),
454
+ message: `Exported and pushed ${commitSha.slice(0, 8)} to ${link.repoOwner}/${link.repoName} (${defaultBranch}). ` +
455
+ (branch !== defaultBranch
456
+ ? `The workspace stays on its local branch ${branch}, which tracks origin/${defaultBranch}. `
457
+ : '') +
458
+ 'The workspace is now a git checkout of this repository.',
459
+ };
460
+ }
461
+ alreadyLinkedResult(linked) {
462
+ return {
463
+ status: 'already_linked',
464
+ repoOwner: linked.repoOwner,
465
+ repoName: linked.repoName,
466
+ repoUrl: linked.repoUrl,
467
+ strategyLinkId: linked.strategyLinkId,
468
+ provider: linked.provider,
469
+ ...(linked.cloneUrl ? { cloneUrl: linked.cloneUrl } : {}),
470
+ ...(linked.defaultBranch ? { defaultBranch: linked.defaultBranch } : {}),
471
+ message: `This chat is already linked to ${linked.repoOwner}/${linked.repoName}. ` +
472
+ 'Exporting again is not possible from this chat — use git add/commit/push in the workspace to publish further changes.',
473
+ };
474
+ }
475
+ linkedPushFailed(err, linkedFields, branch, hint) {
476
+ var _a;
477
+ 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);
478
+ return {
479
+ status: 'linked_push_failed',
480
+ error: (0, workspace_git_1.redactSecrets)(raw),
481
+ ...linkedFields,
482
+ branch,
483
+ message: hint,
484
+ };
485
+ }
486
+ /**
487
+ * strategies_link_workspace: attach the chat to an existing strategy link and wire the
488
+ * workspace to its repository with git (clone into an empty workspace, or fetch + new branch
489
+ * when user files exist so nothing is overwritten).
490
+ */
491
+ async linkWorkspace(args) {
492
+ var _a;
493
+ const strategyLinkId = typeof args.strategy_link_id === 'string' ? args.strategy_link_id.trim() : '';
494
+ if (!strategyLinkId)
495
+ return { statusCode: 400, status: 'failed', error: 'strategy_link_id is required' };
496
+ const sessionId = this.client.sessionId();
497
+ if (!sessionId) {
498
+ return { statusCode: 400, status: 'failed', error: 'The platform token carries no agentSessionId claim; this tool only works inside an Almanak Code session.' };
499
+ }
500
+ const cwd = this.workspaceRoot;
501
+ const res = await this.client.linkSessionRepository(sessionId, { strategyLinkId });
502
+ const failure = this.mapLinkFailure(res);
503
+ if (failure)
504
+ return failure;
505
+ const link = res.data;
506
+ const defaultBranch = link.defaultBranch || 'main';
507
+ const base = {
508
+ provider: link.provider,
509
+ repoId: link.repoId,
510
+ repoOwner: link.repoOwner,
511
+ repoName: link.repoName,
512
+ repoUrl: link.repoUrl,
513
+ cloneUrl: link.cloneUrl,
514
+ strategyLinkId: link.strategyLinkId,
515
+ defaultBranch,
516
+ };
517
+ if (link.status === 'already_linked' && link.strategyLinkId && link.strategyLinkId !== strategyLinkId) {
518
+ return {
519
+ status: 'already_linked',
520
+ ...base,
521
+ branch: null,
522
+ 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.`,
523
+ };
524
+ }
525
+ const opts = this.gitOpts(sessionId);
526
+ const netOpts = this.gitOpts(sessionId, workspace_git_1.GIT_NETWORK_TIMEOUT_MS);
527
+ try {
528
+ if (await (0, workspace_git_1.isGitRepo)(cwd)) {
529
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
530
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
531
+ const branch = await (0, workspace_git_1.currentBranch)(opts);
532
+ return {
533
+ status: link.status,
534
+ ...base,
535
+ branch,
536
+ 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.`,
537
+ };
538
+ }
539
+ const userFiles = await (0, workspace_git_1.hasUserFiles)(cwd);
540
+ await (0, workspace_git_1.initRepo)(defaultBranch, opts);
541
+ await (0, workspace_git_1.ensureManagedExcludes)(cwd);
542
+ await (0, workspace_git_1.ensureCommitIdentity)(opts);
543
+ await (0, workspace_git_1.ensureOriginRemote)(link.cloneUrl, opts);
544
+ await (0, workspace_git_1.runGit)(['fetch', '-q', 'origin'], netOpts);
545
+ const remoteHasBranch = await (0, workspace_git_1.remoteBranchExists)(defaultBranch, opts);
546
+ if (!userFiles) {
547
+ if (remoteHasBranch) {
548
+ await (0, workspace_git_1.runGit)(['checkout', '-q', '-B', defaultBranch, `origin/${defaultBranch}`], opts);
549
+ return {
550
+ status: link.status,
551
+ ...base,
552
+ branch: defaultBranch,
553
+ message: `Linked ${link.repoOwner}/${link.repoName} and checked out origin/${defaultBranch} into the workspace.`,
554
+ };
555
+ }
556
+ return {
557
+ status: link.status,
558
+ ...base,
559
+ branch: defaultBranch,
560
+ 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}.`,
561
+ };
562
+ }
563
+ const sessionBranch = `almanak/session-${sessionId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 8)}`;
564
+ await (0, workspace_git_1.runGit)(['symbolic-ref', 'HEAD', `refs/heads/${sessionBranch}`], opts);
565
+ return {
566
+ status: link.status,
567
+ ...base,
568
+ branch: sessionBranch,
569
+ message: `Linked ${link.repoOwner}/${link.repoName}. The workspace already had files, so they were NOT overwritten: git was initialised on new branch ${sessionBranch} ` +
570
+ `with the existing files left as uncommitted changes` +
571
+ (remoteHasBranch
572
+ ? ` and origin/${defaultBranch} fetched. Reconcile with git diff origin/${defaultBranch}, then commit and push (git push -u origin ${sessionBranch}, or merge into ${defaultBranch}).`
573
+ : `; the repository has no ${defaultBranch} branch yet. Commit and git push -u origin ${defaultBranch} when ready.`),
574
+ };
575
+ }
576
+ catch (err) {
577
+ 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);
578
+ return {
579
+ status: 'linked_attach_failed',
580
+ error: (0, workspace_git_1.redactSecrets)(raw),
581
+ ...base,
582
+ branch: null,
583
+ 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.`,
584
+ };
585
+ }
586
+ }
281
587
  register(mode, definition, handler) {
282
588
  this.registeredNames.add(definition.name);
283
589
  if (!this.modeEnabled(mode))
@@ -331,7 +637,7 @@ class PlatformToolHandler {
331
637
  type: 'object',
332
638
  properties: {
333
639
  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)' },
640
+ github_strategy_link_id: { type: 'string', description: 'ID of the linked strategy (from strategies_list or the strategies_export_workspace result)' },
335
641
  github_ref: { type: 'string', description: 'Git branch, tag, or commit SHA to deploy' },
336
642
  github_ref_type: {
337
643
  type: 'string',
@@ -863,92 +1169,60 @@ class PlatformToolHandler {
863
1169
  return this.client.validateRepository(args.owner, args.repo, ref);
864
1170
  });
865
1171
  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.',
1172
+ name: 'strategies_export_workspace',
1173
+ description: 'Export the current workspace to a NEW git repository and link it to this chat, immediately, inside this tool call: ' +
1174
+ 'the platform creates and links the repository, then this tool runs git init/add/commit/push in the workspace itself. ' +
1175
+ 'No widget is shown and no user click is needed — on status "completed" the repository exists with the pushed commit, so ' +
1176
+ 'continue in the SAME turn (for example run a backtest or deploy). Pass repository metadata only; never read or submit files. ' +
1177
+ 'Session-internal files (.almanak, .almanakdb, almanak_state.db*, .env, .venv, node_modules, .logs) are excluded automatically. ' +
1178
+ 'Pass rootDir only when the strategy lives in a subfolder (e.g. "my_strategy/"): its contents are moved up to the workspace root ' +
1179
+ 'so the repository is flat. Statuses: "completed" (done: repoUrl, strategyLinkId, commitSha, branch); "already_linked" (this chat ' +
1180
+ 'already has a repository — do not export again, use git add/commit/push in the workspace); "name_conflict" (pick a different ' +
1181
+ 'repoName and retry); "preflight_failed" (fix the workspace/rootDir and retry); "github_connection_required", "provider_disabled", ' +
1182
+ '"in_progress" (report to the user); "linked_push_failed" (the repository IS linked but the commit/push failed — fix the git ' +
1183
+ 'problem and run the git command in the message; do not call this tool again). Never claim the export succeeded before "completed".',
868
1184
  inputSchema: {
869
1185
  type: 'object',
870
1186
  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).' },
1187
+ repoName: { type: 'string', description: 'Repository name (required). Lowercase letters, digits, dashes recommended.' },
1188
+ description: { type: 'string', description: 'Repository description.' },
1189
+ rootDir: {
1190
+ type: 'string',
1191
+ description: 'Optional workspace-relative strategy folder whose contents become the repository root (flattened into the workspace). ' +
1192
+ 'Omit for strategies at the workspace root (the default). Never pass an absolute path.',
1193
+ },
1194
+ provider: {
1195
+ type: 'string',
1196
+ enum: ['almanak', 'github'],
1197
+ description: 'Git host. Default: the platform default (Almanak Git). "github" requires the user to have GitHub connected.',
1198
+ },
875
1199
  },
876
- required: [],
1200
+ required: ['repoName'],
877
1201
  },
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
- });
1202
+ }, (args) => this.exportWorkspace(args));
906
1203
  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.',
1204
+ name: 'strategies_link_workspace',
1205
+ description: 'Link an EXISTING strategy repository (a strategy_link_id from strategies_list) to this chat and attach the workspace to it with git, ' +
1206
+ 'immediately, inside this tool call. Use it when the user wants to continue work on a strategy they already exported. ' +
1207
+ 'An empty workspace is checked out from the repository default branch. A workspace that already has files is NOT overwritten: ' +
1208
+ 'the tool initialises git, fetches the repository, and leaves the files as uncommitted changes on a new branch named in the result — ' +
1209
+ 'inspect git status / git diff origin/<default branch>, reconcile, then commit and push. If the workspace is already a git ' +
1210
+ 'repository, only the origin remote is repointed. Statuses: "linked", "already_linked" (with the linked repo), "failed". ' +
1211
+ 'The chat can be linked to exactly one repository; to export a brand new one use strategies_export_workspace instead.',
909
1212
  inputSchema: {
910
1213
  type: 'object',
911
1214
  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.' },
1215
+ strategy_link_id: { type: 'string', description: 'The strategy link ID (from strategies_list).' },
928
1216
  },
929
- required: ['repoName', 'files'],
1217
+ required: ['strategy_link_id'],
930
1218
  },
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
- });
1219
+ }, (args) => this.linkWorkspace(args));
947
1220
  this.register('strategies', {
948
1221
  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 ' +
1222
+ description: 'Run a backtest of a linked strategy against historical market data. Use this after the strategy is in its ' +
1223
+ 'repository (strategies_export_workspace, or git push on a linked chat) and before deploying — backtests run against a ' +
1224
+ 'pushed commit, never local files, so export/push first and re-run after every code change you want tested. ' +
1225
+ 'The call submits the run, renders a live backtest ' +
952
1226
  'widget in the chat, and PAUSES until the run finishes (typically 1-5 minutes); it then resumes you with the ' +
953
1227
  'result summary (total return, Sharpe, max drawdown, win rate, trades, and the run_validity verdict) merged into ' +
954
1228
  'this tool result as _widgetResponse — or the failure reason if the run failed. A run whose verdict is INVALID or ' +
@@ -966,7 +1240,7 @@ class PlatformToolHandler {
966
1240
  properties: {
967
1241
  strategy_link_id: {
968
1242
  type: 'string',
969
- description: 'The strategy link ID (from strategies_list or the export confirmation).',
1243
+ description: 'The strategy link ID (from strategies_list or the strategies_export_workspace result).',
970
1244
  },
971
1245
  start_time: {
972
1246
  type: 'string',
@@ -1345,7 +1619,7 @@ class PlatformToolHandler {
1345
1619
  if (!strategyLinkId) {
1346
1620
  return {
1347
1621
  statusCode: 400,
1348
- error: 'strategy_link_id is required — pass the linked strategy id (from strategies_list or the export result).',
1622
+ error: 'strategy_link_id is required — pass the linked strategy id (from strategies_list or the strategies_export_workspace result).',
1349
1623
  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
1624
  };
1351
1625
  }