-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[vite-plugin] Surface Local Explorer API to headless agents, matching wrangler's hint #14996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nickpatt
wants to merge
4
commits into
cloudflare:main
Choose a base branch
from
nickpatt:vite-plugin-agent-hint-match-wrangler
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+302
−0
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
054b99e
Surface Local Explorer API to headless agents in vite-plugin
dario-piotrowicz 3723da1
[vite-plugin] Match wrangler's Local Explorer agent hint wording
nickpatt f376c58
[vite-plugin] Align agent detection with wrangler's wrapper
nickpatt a603a10
Cross-reference the two agent-hint messages so they stay in sync
nickpatt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| 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( | ||
| "The Cloudflare Vite plugin detected this dev session is running in an AI agent." | ||
| ); | ||
| expect(output).toContain( | ||
| "The Local Explorer API is available at http://localhost:5173/cdn-cgi/local/explorer/api" | ||
| ); | ||
| expect(output).toContain( | ||
| "GET http://localhost:5173/cdn-cgi/local/explorer/api/local/workers - local Workers and bindings" | ||
| ); | ||
| expect(output).toContain( | ||
| "POST http://localhost:5173/cdn-cgi/local/explorer/api/local/observability/query" | ||
| ); | ||
| // The OpenAPI schema is listed last, as a fallback, so agents reach for the | ||
| // specific routes first. | ||
| expect(output.indexOf("- OpenAPI schema")).toBeGreaterThan( | ||
| output.indexOf("- Workflows") | ||
| ); | ||
| }); | ||
|
|
||
| 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( | ||
| "The Cloudflare Vite plugin detected this preview session is 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); | ||
| }); | ||
| }); |
30 changes: 30 additions & 0 deletions
30
packages/vite-plugin-cloudflare/src/__tests__/detect-agent.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { afterEach, describe, test } from "vitest"; | ||
| import { isAgentSession } from "../detect-agent"; | ||
|
|
||
| describe("isAgentSession", () => { | ||
| const saved = { ...process.env }; | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...saved }; | ||
| }); | ||
|
|
||
| test("detects a headless agent from the environment", ({ expect }) => { | ||
| process.env = { CLAUDECODE: "1" }; | ||
| expect(isAgentSession()).toBe(true); | ||
| }); | ||
|
|
||
| test("treats a hybrid terminal as interactive, not an agent", ({ | ||
| expect, | ||
| }) => { | ||
| // Warp embeds agentic features but has a human at the keyboard. The | ||
| // library's isAgent() reports hybrid as an agent, so this guards the | ||
| // stricter check. | ||
| process.env = { TERM_PROGRAM: "WarpTerminal" }; | ||
| expect(isAgentSession()).toBe(false); | ||
| }); | ||
|
|
||
| test("is false in a plain shell", ({ expect }) => { | ||
| process.env = {}; | ||
| expect(isAgentSession()).toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // 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 { detectAgenticEnvironment } from "am-i-vibing"; | ||
|
|
||
| // Process tree traversal shells out to `ps`, which is slow (~75ms) and can time | ||
| // out in CI. Environment variables are enough to identify agentic environments. | ||
| // Passing an empty array keeps that off regardless of the library's default. | ||
| const NO_PROCESS_ANCESTRY: { command?: string }[] = []; | ||
|
|
||
| /** | ||
| * Detects whether the current process is being driven by an AI coding agent. | ||
| * | ||
| * True only when the detected type is exactly `"agent"`. Hybrid terminals (Warp, | ||
| * VS Code) embed agentic features but still have a human at the keyboard, so | ||
| * they're treated as interactive — note `isAgent()` from the library would | ||
| * report them as agents. | ||
| * | ||
| * Any error resolves to `false` rather than propagating. | ||
| */ | ||
| export function isAgentSession(): boolean { | ||
| try { | ||
| return ( | ||
| detectAgenticEnvironment({ | ||
| env: process.env, | ||
| processAncestry: NO_PROCESS_ANCESTRY, | ||
| }).type === "agent" | ||
| ); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| 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. | ||
| * | ||
| * Keep the message in sync with the wrangler copy in | ||
| * packages/wrangler/src/dev/start-dev.ts. | ||
| * | ||
| * @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( | ||
| [ | ||
| "", | ||
| `The Cloudflare Vite plugin detected this ${mode} session is running in an AI agent.`, | ||
| `The Local Explorer API is available at ${explorerApiUrl}`, | ||
|
nickpatt marked this conversation as resolved.
|
||
| `Useful routes:`, | ||
| ` GET ${explorerApiUrl}/local/workers - local Workers and bindings`, | ||
| ` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`, | ||
| ` GET ${explorerApiUrl}/d1/database - D1 databases`, | ||
| ` GET ${explorerApiUrl}/r2/buckets - R2 buckets`, | ||
| ` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`, | ||
| ` GET ${explorerApiUrl}/workflows - Workflows`, | ||
| ` POST ${explorerApiUrl}/local/observability/query - run a read-only SQL query (SELECT/WITH only) over captured request traces and console logs. Tables: spans, logs (read attributes via json(attributes)). Example:`, | ||
| ` curl -X POST ${explorerApiUrl}/local/observability/query -H 'Content-Type: application/json' -d '{"sql":"SELECT service, name, outcome, duration_ms FROM spans WHERE parent_id IS NULL LIMIT 20"}'`, | ||
| `If the routes above don't cover what you need, fetch the full OpenAPI schema (large - use only as a last resort):`, | ||
| ` GET ${explorerApiUrl} - OpenAPI schema`, | ||
| "", | ||
| ].join("\n") | ||
| ); | ||
| } | ||
|
dario-piotrowicz marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.