History

feat: add 'executor claude' command to launch Claude Code with MCP

Bootstraps an anonymous session, obtains an MCP API key, writes a temporary MCP config, and launches Claude Code wired to executor's execute tool. Supports --allow-bash flag and pass-through args. Session IDs are persisted for reuse across invocations.

Rhys Sullivan committed 8 months ago

Commit a39c55a1 parent f46d9d03 files · +231 −0Browse files

Unified · Split

3 files
  1. executor/README.md +2 −0
  2. executor/executor.ts +228 −0
  3. executor/package.json +1 −0
executor/README.md+2−0
@@ -82,6 +82,7 @@ executor doctor · 81 unmodified lines
82executor upgrade82executor upgrade
83executor down83executor down
84executor up84executor up
85executor claude
85executor web86executor web
86executor backend --help87executor backend --help
87```88```
@@ -105,6 +106,7 @@ Run these from `executor/`: · 17 unmodified lines
105```bash106```bash
106bun run doctor107bun run doctor
107bun run up108bun run up
109bun run claude
108bun run backend -- --help110bun run backend -- --help
109bun run web111bun run web
110bun run codegen112bun run codegen
executor/executor.ts+228−0
@@ -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 down41 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 status47 doctor Show install health and quick status
47 upgrade Re-run installer to update executor48 upgrade Re-run installer to update executor
48 up Run managed backend49 up Run managed backend
50 claude Start Claude Code wired to executor MCP execute tool
49 down Stop background backend/web services started by installer51 down Stop background backend/web services started by installer
50 backend Pass through arguments to managed convex-local-backend binary52 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
607type AnonymousTokenResponse = {
608 accessToken: string;
609 accountId: string;
610 expiresAtMs: number;
611};
612
613type AnonymousSessionContext = {
614 sessionId: string;
615 workspaceId: string;
616};
617
618type McpApiKeyResponse = {
619 enabled: boolean;
620 apiKey: string | null;
621 error?: string;
622};
623
624type ClaudeLaunchContext = {
625 mcpUrl: string;
626 apiKey: string;
627};
628
629const CLAUDE_SESSION_FILE = "claude-session-id";
630const CLAUDE_SYSTEM_PROMPT = "Use mcp__executor__execute for command-like work and code execution. That tool runs TypeScript inside executor's sandbox.";
631
632async 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
646async 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
656async 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
690async 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
731function printClaudeHelp(): void {
732 console.log(`Usage:
733 executor claude [--allow-bash] [-- claude-args]
734
735Starts Claude Code with a temporary MCP config wired to executor's execute tool.
736
737Options:
738 --allow-bash Keep Claude's Bash tool enabled
739 -h, --help Show this help
740
741Examples:
742 executor claude
743 executor claude -- "fix failing tests"
744 executor claude --allow-bash -- --model sonnet`);
745}
746
747async 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
605async function run(): Promise<void> {828async 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");
executor/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
16 "backend": "bun run executor.ts backend",16 "backend": "bun run executor.ts backend",
17 "convex": "bun run executor.ts backend",17 "convex": "bun run executor.ts backend",
18 "web": "bun run executor.ts web",18 "web": "bun run executor.ts web",
19 "claude": "bun run executor.ts claude",
19 "deploy": "bunx convex deploy",20 "deploy": "bunx convex deploy",
20 "codegen": "bunx convex codegen",21 "codegen": "bunx convex codegen",
21 "test": "bun run scripts/test/run-suite.ts fast",22 "test": "bun run scripts/test/run-suite.ts fast",