Skip to content

Commit 3f3aadc

Browse files
Surface Local Explorer API to headless agents in vite-plugin
1 parent edc203e commit 3f3aadc

7 files changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
---
4+
5+
Surface Local Explorer API to headless agents
6+
7+
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.

packages/vite-plugin-cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"@types/node": "catalog:default",
7575
"@types/semver": "^7.5.1",
7676
"@types/ws": "^8.5.13",
77+
"am-i-vibing": "^0.5.0",
7778
"defu": "^6.1.4",
7879
"get-port": "^7.1.0",
7980
"magic-string": "^0.30.12",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, beforeEach, describe, test, vi } from "vitest";
2+
import * as detectAgentModule from "../detect-agent";
3+
import { maybeAddAgentHint } from "../plugins/agent-hint";
4+
import type * as vite from "vite";
5+
6+
function createMockServer(serverLogs: { info: string[] }) {
7+
const mockLogger: vite.Logger = {
8+
info: (msg: string) => serverLogs.info.push(msg),
9+
warn: vi.fn(),
10+
warnOnce: vi.fn(),
11+
error: vi.fn(),
12+
clearScreen: vi.fn(),
13+
hasErrorLogged: () => false,
14+
hasWarned: false,
15+
};
16+
17+
return {
18+
config: { logger: mockLogger },
19+
resolvedUrls: {
20+
local: ["http://localhost:5173/"],
21+
network: [],
22+
},
23+
printUrls: vi.fn(),
24+
} as unknown as vite.ViteDevServer;
25+
}
26+
27+
describe("Local Explorer agent hint", () => {
28+
let savedIsTTY: typeof process.stdin.isTTY;
29+
let serverLogs: { info: string[] };
30+
let mockServer: vite.ViteDevServer;
31+
32+
beforeEach(() => {
33+
savedIsTTY = process.stdin.isTTY;
34+
serverLogs = { info: [] };
35+
mockServer = createMockServer(serverLogs);
36+
});
37+
38+
afterEach(() => {
39+
process.stdin.isTTY = savedIsTTY;
40+
vi.restoreAllMocks();
41+
});
42+
43+
test("prints hint with 'dev' for dev sessions", ({ expect }) => {
44+
process.stdin.isTTY = false;
45+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
46+
47+
const originalPrintUrls = mockServer.printUrls;
48+
maybeAddAgentHint(mockServer, "dev");
49+
50+
expect(mockServer.printUrls).not.toBe(originalPrintUrls);
51+
52+
mockServer.printUrls();
53+
54+
expect(originalPrintUrls).toHaveBeenCalledOnce();
55+
const output = serverLogs.info.join("\n");
56+
expect(output).toContain(
57+
"This dev session seems to be running in an AI agent."
58+
);
59+
expect(output).toContain(
60+
"The Local Explorer API is available at http://localhost:5173/cdn-cgi/explorer/api"
61+
);
62+
expect(output).toContain(
63+
"GET http://localhost:5173/cdn-cgi/explorer/api/local/workers - local Workers and bindings"
64+
);
65+
});
66+
67+
test("prints hint with 'preview' for preview sessions", ({ expect }) => {
68+
process.stdin.isTTY = false;
69+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
70+
71+
maybeAddAgentHint(mockServer, "preview");
72+
mockServer.printUrls();
73+
74+
const output = serverLogs.info.join("\n");
75+
expect(output).toContain(
76+
"This preview session seems to be running in an AI agent."
77+
);
78+
});
79+
80+
test("does not patch printUrls for interactive sessions", ({ expect }) => {
81+
process.stdin.isTTY = true;
82+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
83+
84+
const originalPrintUrls = mockServer.printUrls;
85+
maybeAddAgentHint(mockServer, "dev");
86+
87+
expect(mockServer.printUrls).toBe(originalPrintUrls);
88+
});
89+
90+
test("does not patch printUrls for non-agent sessions", ({ expect }) => {
91+
process.stdin.isTTY = false;
92+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(false);
93+
94+
const originalPrintUrls = mockServer.printUrls;
95+
maybeAddAgentHint(mockServer, "dev");
96+
97+
expect(mockServer.printUrls).toBe(originalPrintUrls);
98+
});
99+
100+
test("does not patch printUrls when Local Explorer is disabled", ({
101+
expect,
102+
}) => {
103+
process.stdin.isTTY = false;
104+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
105+
vi.stubEnv("X_LOCAL_EXPLORER", "false");
106+
107+
const originalPrintUrls = mockServer.printUrls;
108+
maybeAddAgentHint(mockServer, "dev");
109+
110+
expect(mockServer.printUrls).toBe(originalPrintUrls);
111+
112+
vi.stubEnv("X_LOCAL_EXPLORER", undefined);
113+
});
114+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// 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
2+
import { isAgent } from "am-i-vibing";
3+
4+
/**
5+
* Detects whether the current process is being driven by an AI coding agent.
6+
*
7+
* Returns `true` only when the detected environment type is exactly `"agent"`,
8+
* NOT `"hybrid"` or `"interactive"`. Hybrid terminals (such as Warp or VS Code)
9+
* embed agentic features but are still driven by a human at the keyboard, so
10+
* they should behave like a regular interactive session.
11+
*
12+
* Any error resolves to `false` rather than propagating.
13+
*
14+
* @returns Whether the session is driven by a headless AI agent
15+
*/
16+
export function isAgentSession(): boolean {
17+
try {
18+
return isAgent({ env: process.env });
19+
} catch {
20+
return false;
21+
}
22+
}

packages/vite-plugin-cloudflare/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isForcedBuildOutput } from "./build-output-env";
44
import { PluginContext } from "./context";
55
import { resolvePluginConfig } from "./plugin-config";
66
import { additionalModulesPlugin } from "./plugins/additional-modules";
7+
import { agentHintPlugin } from "./plugins/agent-hint";
78
import { buildOutputPlugin } from "./plugins/build-output";
89
import { configPlugin } from "./plugins/config";
910
import { debugPlugin } from "./plugins/debug";
@@ -111,6 +112,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
111112
tunnelPlugin(ctx),
112113
previewPlugin(ctx),
113114
shortcutsPlugin(ctx),
115+
agentHintPlugin(ctx),
114116
debugPlugin(ctx),
115117
triggerHandlersPlugin(ctx),
116118
virtualModulesPlugin(ctx),
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { getLocalExplorerEnabledFromEnv } from "@cloudflare/workers-utils";
2+
import { CorePaths } from "miniflare";
3+
import { isAgentSession } from "../detect-agent";
4+
import { createPlugin } from "../utils";
5+
import type * as vite from "vite";
6+
7+
/**
8+
* Plugin that prints the Local Explorer API URL and useful routes when
9+
* the dev server is started by a headless AI agent. This allows agents
10+
* to discover and call the Local Explorer API programmatically.
11+
*
12+
* The hint is printed by patching `server.printUrls` (the same pattern
13+
* used by the debug plugin), so it appears right after the server URLs.
14+
*/
15+
export const agentHintPlugin = createPlugin("agent-hint", () => {
16+
return {
17+
configureServer(viteDevServer) {
18+
maybeAddAgentHint(viteDevServer, "dev");
19+
},
20+
configurePreviewServer(vitePreviewServer) {
21+
maybeAddAgentHint(vitePreviewServer, "preview");
22+
},
23+
};
24+
});
25+
26+
/**
27+
* If the session is a headless AI agent with Local Explorer enabled,
28+
* patches `server.printUrls` to append the explorer API hint.
29+
*
30+
* @param server - The Vite dev or preview server
31+
* @param mode - Whether this is a "dev" or "preview" session
32+
*/
33+
export function maybeAddAgentHint(
34+
server: vite.ViteDevServer | vite.PreviewServer,
35+
mode: "dev" | "preview"
36+
): void {
37+
if (
38+
process.stdin.isTTY ||
39+
!getLocalExplorerEnabledFromEnv() ||
40+
!isAgentSession()
41+
) {
42+
return;
43+
}
44+
45+
const originalPrintUrls = server.printUrls;
46+
server.printUrls = () => {
47+
originalPrintUrls();
48+
printLocalExplorerAgentHint(server, mode);
49+
};
50+
}
51+
52+
/**
53+
* Prints the Local Explorer API URL and useful routes to stdout so that
54+
* headless AI agents can discover and call them programmatically.
55+
*
56+
* @param server - The Vite dev or preview server (must have `resolvedUrls` populated)
57+
* @param mode - Whether this is a "dev" or "preview" session
58+
*/
59+
function printLocalExplorerAgentHint(
60+
server: vite.ViteDevServer | vite.PreviewServer,
61+
mode: "dev" | "preview"
62+
): void {
63+
const url = server.resolvedUrls?.local[0];
64+
if (!url) {
65+
return;
66+
}
67+
68+
const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, url).href;
69+
70+
server.config.logger.info(
71+
[
72+
`This ${mode} session seems to be running in an AI agent.`,
73+
`The Local Explorer API is available at ${explorerApiUrl}`,
74+
`Useful routes:`,
75+
` GET ${explorerApiUrl} - OpenAPI schema`,
76+
` GET ${explorerApiUrl}/d1/database - D1 databases`,
77+
` GET ${explorerApiUrl}/local/workers - local Workers and bindings`,
78+
` GET ${explorerApiUrl}/r2/buckets - R2 buckets`,
79+
` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`,
80+
` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`,
81+
` GET ${explorerApiUrl}/workflows - Workflows`,
82+
].join("\n")
83+
);
84+
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)