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
8282 executor upgrade
8383 executor down
8484 executor up
85+executor claude
8586 executor web
8687 executor backend --help
8788 ```
@@ -105,6 +106,7 @@ Run these from `executor/`: · 17 unmodified lines
105106 ```bash
106107 bun run doctor
107108 bun run up
109+bun run claude
108110 bun run backend -- --help
109111 bun run web
110112 bun run codegen
executor/executor.ts+228−0
@@ -37,6 +37,7 @@ Usage: · 36 unmodified lines
3737 executor doctor [--verbose]
3838 executor upgrade [--version <version>]
3939 executor up [backend-args]
40+ executor claude [--allow-bash] [-- claude-args]
4041 executor down
4142 executor backend <args>
4243 executor web [--port <number>]
@@ -46,6 +47,7 @@ Commands: · 3 unmodified lines
4647 doctor Show install health and quick status
4748 upgrade Re-run installer to update executor
4849 up Run managed backend
50+ claude Start Claude Code wired to executor MCP execute tool
4951 down Stop background backend/web services started by installer
5052 backend Pass through arguments to managed convex-local-backend binary
5153 web Run packaged web UI (expects backend already running)
@@ -602,6 +604,227 @@ Options: · 550 unmodified lines
602604 }
603605 }
604606
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+
605828 async function run(): Promise<void> {
606829 const [command, ...rest] = process.argv.slice(2);
607830
@@ -630,6 +853,11 @@ async function run(): Promise<void> { · 22 unmodified lines
630853 process.exit(exitCode);
631854 }
632855
856+ if (command === "claude") {
857+ const exitCode = await runClaude(rest);
858+ process.exit(exitCode);
859+ }
860+
633861 if (command === "backend" || command === "convex") {
634862 if (rest.length === 0) {
635863 throw new Error("Missing backend arguments. Example: executor backend --help");
executor/package.json+1−0
@@ -16,6 +16,7 @@ · 15 unmodified lines
1616 "backend": "bun run executor.ts backend",
1717 "convex": "bun run executor.ts backend",
1818 "web": "bun run executor.ts web",
19+ "claude": "bun run executor.ts claude",
1920 "deploy": "bunx convex deploy",
2021 "codegen": "bunx convex codegen",
2122 "test": "bun run scripts/test/run-suite.ts fast",