| @@ -37,6 +37,7 @@ Usage: · 36 unmodified lines |
| 37 | executor doctor [--verbose] | 37 | executor doctor [--verbose] |
| 38 | executor upgrade [--version <version>] | 38 | executor upgrade [--version <version>] |
| 39 | executor up [backend-args] | 39 | executor up [backend-args] |
| | 40 | executor claude [--allow-bash] [-- claude-args] |
| 40 | executor down | 41 | executor down |
| 41 | executor backend <args> | 42 | executor backend <args> |
| 42 | executor web [--port <number>] | 43 | executor web [--port <number>] |
| @@ -46,6 +47,7 @@ Commands: · 3 unmodified lines |
| 46 | doctor Show install health and quick status | 47 | doctor Show install health and quick status |
| 47 | upgrade Re-run installer to update executor | 48 | upgrade Re-run installer to update executor |
| 48 | up Run managed backend | 49 | up Run managed backend |
| | 50 | claude Start Claude Code wired to executor MCP execute tool |
| 49 | down Stop background backend/web services started by installer | 51 | down Stop background backend/web services started by installer |
| 50 | backend Pass through arguments to managed convex-local-backend binary | 52 | backend Pass through arguments to managed convex-local-backend binary |
| 51 | web Run packaged web UI (expects backend already running) | 53 | web Run packaged web UI (expects backend already running) |
| @@ -602,6 +604,227 @@ Options: · 550 unmodified lines |
| 602 | } | 604 | } |
| 603 | } | 605 | } |
| 604 | | 606 | |
| | 607 | type AnonymousTokenResponse = { |
| | 608 | accessToken: string; |
| | 609 | accountId: string; |
| | 610 | expiresAtMs: number; |
| | 611 | }; |
| | 612 | |
| | 613 | type AnonymousSessionContext = { |
| | 614 | sessionId: string; |
| | 615 | workspaceId: string; |
| | 616 | }; |
| | 617 | |
| | 618 | type McpApiKeyResponse = { |
| | 619 | enabled: boolean; |
| | 620 | apiKey: string | null; |
| | 621 | error?: string; |
| | 622 | }; |
| | 623 | |
| | 624 | type ClaudeLaunchContext = { |
| | 625 | mcpUrl: string; |
| | 626 | apiKey: string; |
| | 627 | }; |
| | 628 | |
| | 629 | const CLAUDE_SESSION_FILE = "claude-session-id"; |
| | 630 | const CLAUDE_SYSTEM_PROMPT = "Use mcp__executor__execute for command-like work and code execution. That tool runs TypeScript inside executor's sandbox."; |
| | 631 | |
| | 632 | async function readStoredClaudeSessionId(runtimeRoot: string): Promise<string | undefined> { |
| | 633 | const filePath = path.join(runtimeRoot, CLAUDE_SESSION_FILE); |
| | 634 | try { |
| | 635 | const value = (await fs.readFile(filePath, "utf8")).trim(); |
| | 636 | if (value.startsWith("mcp_")) { |
| | 637 | return value; |
| | 638 | } |
| | 639 | } catch { |
| | 640 | // missing file is expected on first run |
| | 641 | } |
| | 642 | |
| | 643 | return undefined; |
| | 644 | } |
| | 645 | |
| | 646 | async function writeStoredClaudeSessionId(runtimeRoot: string, sessionId: string): Promise<void> { |
| | 647 | if (!sessionId.startsWith("mcp_")) { |
| | 648 | return; |
| | 649 | } |
| | 650 | |
| | 651 | const filePath = path.join(runtimeRoot, CLAUDE_SESSION_FILE); |
| | 652 | await fs.mkdir(runtimeRoot, { recursive: true }); |
| | 653 | await fs.writeFile(filePath, `${sessionId}\n`, { encoding: "utf8", mode: 0o600 }); |
| | 654 | } |
| | 655 | |
| | 656 | async function requestAnonymousToken(convexSiteUrl: string): Promise<AnonymousTokenResponse> { |
| | 657 | const response = await fetch(`${convexSiteUrl}/auth/anonymous/token`, { |
| | 658 | method: "POST", |
| | 659 | }); |
| | 660 | |
| | 661 | const responseText = await response.text(); |
| | 662 | if (!response.ok) { |
| | 663 | const detail = responseText.trim().length > 0 ? responseText.trim() : `${response.status} ${response.statusText}`; |
| | 664 | throw new Error(`Failed to obtain anonymous auth token: ${detail}`); |
| | 665 | } |
| | 666 | |
| | 667 | let payload: unknown; |
| | 668 | try { |
| | 669 | payload = JSON.parse(responseText); |
| | 670 | } catch { |
| | 671 | throw new Error("Anonymous token response was not valid JSON"); |
| | 672 | } |
| | 673 | |
| | 674 | const parsed = payload as Partial<AnonymousTokenResponse>; |
| | 675 | if ( |
| | 676 | typeof parsed.accessToken !== "string" |
| | 677 | || typeof parsed.accountId !== "string" |
| | 678 | || typeof parsed.expiresAtMs !== "number" |
| | 679 | ) { |
| | 680 | throw new Error("Anonymous token response was malformed"); |
| | 681 | } |
| | 682 | |
| | 683 | return { |
| | 684 | accessToken: parsed.accessToken, |
| | 685 | accountId: parsed.accountId, |
| | 686 | expiresAtMs: parsed.expiresAtMs, |
| | 687 | }; |
| | 688 | } |
| | 689 | |
| | 690 | async function bootstrapClaudeContext(convexUrl: string, convexSiteUrl: string, runtimeRoot: string): Promise<ClaudeLaunchContext> { |
| | 691 | const token = await requestAnonymousToken(convexSiteUrl); |
| | 692 | |
| | 693 | const convex = new ConvexHttpClient(convexUrl, { |
| | 694 | skipConvexDeploymentUrlCheck: true, |
| | 695 | }); |
| | 696 | convex.setAuth(token.accessToken); |
| | 697 | const convexAny = convex as unknown as { |
| | 698 | mutation: (name: string, args: Record<string, unknown>) => Promise<unknown>; |
| | 699 | query: (name: string, args: Record<string, unknown>) => Promise<unknown>; |
| | 700 | }; |
| | 701 | |
| | 702 | const storedSessionId = await readStoredClaudeSessionId(runtimeRoot); |
| | 703 | const anonymousContext = await convexAny.mutation("workspace:bootstrapAnonymousSession", { |
| | 704 | ...(storedSessionId ? { sessionId: storedSessionId } : {}), |
| | 705 | }) as AnonymousSessionContext; |
| | 706 | |
| | 707 | if (typeof anonymousContext?.sessionId !== "string" || typeof anonymousContext?.workspaceId !== "string") { |
| | 708 | throw new Error("Anonymous session bootstrap returned malformed data"); |
| | 709 | } |
| | 710 | |
| | 711 | await writeStoredClaudeSessionId(runtimeRoot, anonymousContext.sessionId); |
| | 712 | |
| | 713 | const mcpApiKey = await convexAny.query("workspace:getMcpApiKey", { |
| | 714 | workspaceId: anonymousContext.workspaceId, |
| | 715 | sessionId: anonymousContext.sessionId, |
| | 716 | }) as McpApiKeyResponse; |
| | 717 | |
| | 718 | if (!mcpApiKey.enabled || !mcpApiKey.apiKey) { |
| | 719 | throw new Error(mcpApiKey.error ?? "MCP API key is not available"); |
| | 720 | } |
| | 721 | |
| | 722 | const mcpUrl = new URL("/mcp/anonymous", convexSiteUrl); |
| | 723 | mcpUrl.searchParams.set("workspaceId", anonymousContext.workspaceId); |
| | 724 | |
| | 725 | return { |
| | 726 | mcpUrl: mcpUrl.toString(), |
| | 727 | apiKey: mcpApiKey.apiKey, |
| | 728 | }; |
| | 729 | } |
| | 730 | |
| | 731 | function printClaudeHelp(): void { |
| | 732 | console.log(`Usage: |
| | 733 | executor claude [--allow-bash] [-- claude-args] |
| | 734 | |
| | 735 | Starts Claude Code with a temporary MCP config wired to executor's execute tool. |
| | 736 | |
| | 737 | Options: |
| | 738 | --allow-bash Keep Claude's Bash tool enabled |
| | 739 | -h, --help Show this help |
| | 740 | |
| | 741 | Examples: |
| | 742 | executor claude |
| | 743 | executor claude -- "fix failing tests" |
| | 744 | executor claude --allow-bash -- --model sonnet`); |
| | 745 | } |
| | 746 | |
| | 747 | async function runClaude(args: string[]): Promise<number> { |
| | 748 | const claudePath = Bun.which("claude"); |
| | 749 | if (!claudePath) { |
| | 750 | console.log("Claude Code CLI is not installed. Install it first with:"); |
| | 751 | console.log(" curl -fsSL https://claude.ai/install.sh | bash"); |
| | 752 | return 1; |
| | 753 | } |
| | 754 | |
| | 755 | if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) { |
| | 756 | printClaudeHelp(); |
| | 757 | return 0; |
| | 758 | } |
| | 759 | |
| | 760 | let allowBash = false; |
| | 761 | let passThrough = args; |
| | 762 | |
| | 763 | if (passThrough[0] !== "--") { |
| | 764 | passThrough = passThrough.filter((arg) => { |
| | 765 | if (arg === "--allow-bash") { |
| | 766 | allowBash = true; |
| | 767 | return false; |
| | 768 | } |
| | 769 | return true; |
| | 770 | }); |
| | 771 | } |
| | 772 | |
| | 773 | if (passThrough[0] === "--") { |
| | 774 | passThrough = passThrough.slice(1); |
| | 775 | } |
| | 776 | |
| | 777 | const info = await managedRuntimeDiagnostics(); |
| | 778 | const backendRunning = await checkHttp(`${info.convexUrl}/version`); |
| | 779 | if (!backendRunning) { |
| | 780 | console.log("Managed backend is not running. Start it first with `executor up`."); |
| | 781 | return 1; |
| | 782 | } |
| | 783 | |
| | 784 | const launchContext = await bootstrapClaudeContext(info.convexUrl, info.convexSiteUrl, info.rootDir); |
| | 785 | |
| | 786 | const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-claude-")); |
| | 787 | const configPath = path.join(tempDir, "mcp.json"); |
| | 788 | const mcpConfig = { |
| | 789 | mcpServers: { |
| | 790 | executor: { |
| | 791 | type: "http", |
| | 792 | url: launchContext.mcpUrl, |
| | 793 | headers: { |
| | 794 | "x-api-key": launchContext.apiKey, |
| | 795 | }, |
| | 796 | }, |
| | 797 | }, |
| | 798 | }; |
| | 799 | |
| | 800 | try { |
| | 801 | await fs.writeFile(configPath, `${JSON.stringify(mcpConfig, null, 2)}\n`, { |
| | 802 | encoding: "utf8", |
| | 803 | mode: 0o600, |
| | 804 | }); |
| | 805 | |
| | 806 | const claudeArgs: string[] = [ |
| | 807 | "--mcp-config", |
| | 808 | configPath, |
| | 809 | "--strict-mcp-config", |
| | 810 | "--append-system-prompt", |
| | 811 | CLAUDE_SYSTEM_PROMPT, |
| | 812 | ...(!allowBash ? ["--disallowedTools", "Bash"] : []), |
| | 813 | ...passThrough, |
| | 814 | ]; |
| | 815 | |
| | 816 | const proc = Bun.spawn([claudePath, ...claudeArgs], { |
| | 817 | env: process.env, |
| | 818 | stdin: "inherit", |
| | 819 | stdout: "inherit", |
| | 820 | stderr: "inherit", |
| | 821 | }); |
| | 822 | return await proc.exited; |
| | 823 | } finally { |
| | 824 | await fs.rm(tempDir, { recursive: true, force: true }); |
| | 825 | } |
| | 826 | } |
| | 827 | |
| 605 | async function run(): Promise<void> { | 828 | async function run(): Promise<void> { |
| 606 | const [command, ...rest] = process.argv.slice(2); | 829 | const [command, ...rest] = process.argv.slice(2); |
| 607 | | 830 | |
| @@ -630,6 +853,11 @@ async function run(): Promise<void> { · 22 unmodified lines |
| 630 | process.exit(exitCode); | 853 | process.exit(exitCode); |
| 631 | } | 854 | } |
| 632 | | 855 | |
| | 856 | if (command === "claude") { |
| | 857 | const exitCode = await runClaude(rest); |
| | 858 | process.exit(exitCode); |
| | 859 | } |
| | 860 | |
| 633 | if (command === "backend" || command === "convex") { | 861 | if (command === "backend" || command === "convex") { |
| 634 | if (rest.length === 0) { | 862 | if (rest.length === 0) { |
| 635 | throw new Error("Missing backend arguments. Example: executor backend --help"); | 863 | throw new Error("Missing backend arguments. Example: executor backend --help"); |