diff --git a/.changeset/vite-plugin-agent-explorer-hint.md b/.changeset/vite-plugin-agent-explorer-hint.md new file mode 100644 index 00000000000..33e7e414e34 --- /dev/null +++ b/.changeset/vite-plugin-agent-explorer-hint.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": minor +--- + +Surface Local Explorer API to headless agents + +When a Vite dev or preview server with the Cloudflare plugin is started in a headless AI agent environment, the plugin now prints the Local Explorer API URL and useful resource routes to stdout so agents can discover and call them programmatically. diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index ae2bbe325b0..afcdb2cfc26 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -74,6 +74,7 @@ "@types/node": "catalog:default", "@types/semver": "^7.5.1", "@types/ws": "^8.5.13", + "am-i-vibing": "^0.5.0", "defu": "^6.1.4", "get-port": "^7.1.0", "magic-string": "^0.30.12", diff --git a/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts new file mode 100644 index 00000000000..1c170930286 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, test, vi } from "vitest"; +import * as detectAgentModule from "../detect-agent"; +import { maybeAddAgentHint } from "../plugins/agent-hint"; +import type * as vite from "vite"; + +function createMockServer(serverLogs: { info: string[] }) { + const mockLogger: vite.Logger = { + info: (msg: string) => serverLogs.info.push(msg), + warn: vi.fn(), + warnOnce: vi.fn(), + error: vi.fn(), + clearScreen: vi.fn(), + hasErrorLogged: () => false, + hasWarned: false, + }; + + return { + config: { logger: mockLogger }, + resolvedUrls: { + local: ["http://localhost:5173/"], + network: [], + }, + bindCLIShortcuts: vi.fn(), + } as unknown as vite.ViteDevServer; +} + +describe("Local Explorer agent hint", () => { + let savedIsTTY: typeof process.stdin.isTTY; + let serverLogs: { info: string[] }; + let mockServer: vite.ViteDevServer; + + beforeEach(() => { + savedIsTTY = process.stdin.isTTY; + serverLogs = { info: [] }; + mockServer = createMockServer(serverLogs); + }); + + afterEach(() => { + process.stdin.isTTY = savedIsTTY; + vi.restoreAllMocks(); + }); + + test("prints hint with 'dev' for dev sessions", ({ expect }) => { + process.stdin.isTTY = false; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true); + + const originalBindCLIShortcuts = mockServer.bindCLIShortcuts; + maybeAddAgentHint(mockServer, "dev"); + + expect(mockServer.bindCLIShortcuts).not.toBe(originalBindCLIShortcuts); + + mockServer.bindCLIShortcuts({ print: true }); + + expect(originalBindCLIShortcuts).toHaveBeenCalledOnce(); + const output = serverLogs.info.join("\n"); + expect(output).toContain( + "This dev session seems to be running in an AI agent." + ); + expect(output).toContain( + "The Local Explorer API is available at http://localhost:5173/cdn-cgi/explorer/api" + ); + expect(output).toContain( + "GET http://localhost:5173/cdn-cgi/explorer/api/local/workers - local Workers and bindings" + ); + }); + + test("prints hint with 'preview' for preview sessions", ({ expect }) => { + process.stdin.isTTY = false; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true); + + maybeAddAgentHint(mockServer, "preview"); + mockServer.bindCLIShortcuts({ print: true }); + + const output = serverLogs.info.join("\n"); + expect(output).toContain( + "This preview session seems to be running in an AI agent." + ); + }); + + test("does not print hint when print option is false", ({ expect }) => { + process.stdin.isTTY = false; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true); + + maybeAddAgentHint(mockServer, "dev"); + mockServer.bindCLIShortcuts(); + + expect(serverLogs.info).toHaveLength(0); + }); + + test("does not patch for interactive sessions", ({ expect }) => { + process.stdin.isTTY = true; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true); + + const originalBindCLIShortcuts = mockServer.bindCLIShortcuts; + maybeAddAgentHint(mockServer, "dev"); + + expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts); + }); + + test("does not patch for non-agent sessions", ({ expect }) => { + process.stdin.isTTY = false; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(false); + + const originalBindCLIShortcuts = mockServer.bindCLIShortcuts; + maybeAddAgentHint(mockServer, "dev"); + + expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts); + }); + + test("does not patch when Local Explorer is disabled", ({ expect }) => { + process.stdin.isTTY = false; + vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true); + vi.stubEnv("X_LOCAL_EXPLORER", "false"); + + const originalBindCLIShortcuts = mockServer.bindCLIShortcuts; + maybeAddAgentHint(mockServer, "dev"); + + expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts); + + vi.stubEnv("X_LOCAL_EXPLORER", undefined); + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/detect-agent.ts b/packages/vite-plugin-cloudflare/src/detect-agent.ts new file mode 100644 index 00000000000..a5a409e0fa6 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/detect-agent.ts @@ -0,0 +1,22 @@ +// eslint-disable-next-line no-restricted-imports -- This is the canonical wrapper around am-i-vibing; all other code should use isAgentSession() from this module +import { isAgent } from "am-i-vibing"; + +/** + * Detects whether the current process is being driven by an AI coding agent. + * + * Returns `true` only when the detected environment type is exactly `"agent"`, + * NOT `"hybrid"` or `"interactive"`. Hybrid terminals (such as Warp or VS Code) + * embed agentic features but are still driven by a human at the keyboard, so + * they should behave like a regular interactive session. + * + * Any error resolves to `false` rather than propagating. + * + * @returns Whether the session is driven by a headless AI agent + */ +export function isAgentSession(): boolean { + try { + return isAgent({ env: process.env }); + } catch { + return false; + } +} diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index 1ef33d32a68..0f08a3ccbaa 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -4,6 +4,7 @@ import { isForcedBuildOutput } from "./build-output-env"; import { PluginContext } from "./context"; import { resolvePluginConfig } from "./plugin-config"; import { additionalModulesPlugin } from "./plugins/additional-modules"; +import { agentHintPlugin } from "./plugins/agent-hint"; import { buildOutputPlugin } from "./plugins/build-output"; import { configPlugin } from "./plugins/config"; import { debugPlugin } from "./plugins/debug"; @@ -111,6 +112,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] { tunnelPlugin(ctx), previewPlugin(ctx), shortcutsPlugin(ctx), + agentHintPlugin(ctx), debugPlugin(ctx), triggerHandlersPlugin(ctx), virtualModulesPlugin(ctx), diff --git a/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts b/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts new file mode 100644 index 00000000000..2ee445e1593 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts @@ -0,0 +1,89 @@ +import { getLocalExplorerEnabledFromEnv } from "@cloudflare/workers-utils"; +import { CorePaths } from "miniflare"; +import { isAgentSession } from "../detect-agent"; +import { createPlugin } from "../utils"; +import type * as vite from "vite"; + +/** + * Plugin that prints the Local Explorer API URL and useful routes when + * the dev server is started by a headless AI agent. This allows agents + * to discover and call the Local Explorer API programmatically. + * + * The hint is printed by patching `server.bindCLIShortcuts` so it + * appears after both the server URLs and the keyboard shortcut hints. + */ +export const agentHintPlugin = createPlugin("agent-hint", () => { + return { + configureServer(viteDevServer) { + maybeAddAgentHint(viteDevServer, "dev"); + }, + configurePreviewServer(vitePreviewServer) { + maybeAddAgentHint(vitePreviewServer, "preview"); + }, + }; +}); + +/** + * If the session is a headless AI agent with Local Explorer enabled, + * patches `server.bindCLIShortcuts` to print the explorer API hint + * after the shortcut hints have been printed. + * + * @param server - The Vite dev or preview server + * @param mode - Whether this is a "dev" or "preview" session + */ +export function maybeAddAgentHint( + server: vite.ViteDevServer | vite.PreviewServer, + mode: "dev" | "preview" +): void { + if ( + process.stdin.isTTY || + !getLocalExplorerEnabledFromEnv() || + !isAgentSession() + ) { + return; + } + + const originalBindCLIShortcuts = server.bindCLIShortcuts.bind(server); + server.bindCLIShortcuts = (options?: vite.BindCLIShortcutsOptions) => { + originalBindCLIShortcuts(options); + if (options?.print) { + printLocalExplorerAgentHint(server, mode); + } + }; +} + +/** + * Prints the Local Explorer API URL and useful routes to stdout so that + * headless AI agents can discover and call them programmatically. + * + * @param server - The Vite dev or preview server (must have `resolvedUrls` populated) + * @param mode - Whether this is a "dev" or "preview" session + */ +function printLocalExplorerAgentHint( + server: vite.ViteDevServer | vite.PreviewServer, + mode: "dev" | "preview" +): void { + const url = server.resolvedUrls?.local[0]; + if (!url) { + return; + } + + const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, url).href; + + server.config.logger.info( + [ + "", + `This ${mode} session seems to be running in an AI agent.`, + `The Local Explorer API is available at ${explorerApiUrl}`, + `Useful routes:`, + ` GET ${explorerApiUrl} - OpenAPI schema`, + ` GET ${explorerApiUrl}/d1/database - D1 databases`, + ` GET ${explorerApiUrl}/local/workers - local Workers and bindings`, + ` GET ${explorerApiUrl}/r2/buckets - R2 buckets`, + ` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`, + ` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`, + ` GET ${explorerApiUrl}/workflows - Workflows`, + "", + ].join("\n") + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 048d144bb6d..aa936cf619c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2743,6 +2743,9 @@ importers: '@types/ws': specifier: ^8.5.13 version: 8.5.13 + am-i-vibing: + specifier: ^0.5.0 + version: 0.5.0 defu: specifier: ^6.1.4 version: 6.1.4