Skip to content

Commit 81d4e62

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

7 files changed

Lines changed: 246 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": minor
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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
bindCLIShortcuts: 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 originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
48+
maybeAddAgentHint(mockServer, "dev");
49+
50+
expect(mockServer.bindCLIShortcuts).not.toBe(originalBindCLIShortcuts);
51+
52+
mockServer.bindCLIShortcuts({ print: true });
53+
54+
expect(originalBindCLIShortcuts).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.bindCLIShortcuts({ print: true });
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 print hint when print option is false", ({ expect }) => {
81+
process.stdin.isTTY = false;
82+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
83+
84+
maybeAddAgentHint(mockServer, "dev");
85+
mockServer.bindCLIShortcuts();
86+
87+
expect(serverLogs.info).toHaveLength(0);
88+
});
89+
90+
test("does not patch for interactive sessions", ({ expect }) => {
91+
process.stdin.isTTY = true;
92+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
93+
94+
const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
95+
maybeAddAgentHint(mockServer, "dev");
96+
97+
expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);
98+
});
99+
100+
test("does not patch for non-agent sessions", ({ expect }) => {
101+
process.stdin.isTTY = false;
102+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(false);
103+
104+
const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
105+
maybeAddAgentHint(mockServer, "dev");
106+
107+
expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);
108+
});
109+
110+
test("does not patch when Local Explorer is disabled", ({ expect }) => {
111+
process.stdin.isTTY = false;
112+
vi.spyOn(detectAgentModule, "isAgentSession").mockReturnValue(true);
113+
vi.stubEnv("X_LOCAL_EXPLORER", "false");
114+
115+
const originalBindCLIShortcuts = mockServer.bindCLIShortcuts;
116+
maybeAddAgentHint(mockServer, "dev");
117+
118+
expect(mockServer.bindCLIShortcuts).toBe(originalBindCLIShortcuts);
119+
120+
vi.stubEnv("X_LOCAL_EXPLORER", undefined);
121+
});
122+
});
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: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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.bindCLIShortcuts` so it
13+
* appears after both the server URLs and the keyboard shortcut hints.
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.bindCLIShortcuts` to print the explorer API hint
29+
* after the shortcut hints have been printed.
30+
*
31+
* @param server - The Vite dev or preview server
32+
* @param mode - Whether this is a "dev" or "preview" session
33+
*/
34+
export function maybeAddAgentHint(
35+
server: vite.ViteDevServer | vite.PreviewServer,
36+
mode: "dev" | "preview"
37+
): void {
38+
if (
39+
process.stdin.isTTY ||
40+
!getLocalExplorerEnabledFromEnv() ||
41+
!isAgentSession()
42+
) {
43+
return;
44+
}
45+
46+
const originalBindCLIShortcuts = server.bindCLIShortcuts.bind(server);
47+
server.bindCLIShortcuts = (options?: vite.BindCLIShortcutsOptions) => {
48+
originalBindCLIShortcuts(options);
49+
if (options?.print) {
50+
printLocalExplorerAgentHint(server, mode);
51+
}
52+
};
53+
}
54+
55+
/**
56+
* Prints the Local Explorer API URL and useful routes to stdout so that
57+
* headless AI agents can discover and call them programmatically.
58+
*
59+
* @param server - The Vite dev or preview server (must have `resolvedUrls` populated)
60+
* @param mode - Whether this is a "dev" or "preview" session
61+
*/
62+
function printLocalExplorerAgentHint(
63+
server: vite.ViteDevServer | vite.PreviewServer,
64+
mode: "dev" | "preview"
65+
): void {
66+
const url = server.resolvedUrls?.local[0];
67+
if (!url) {
68+
return;
69+
}
70+
71+
const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, url).href;
72+
73+
server.config.logger.info(
74+
[
75+
"",
76+
`This ${mode} session seems to be running in an AI agent.`,
77+
`The Local Explorer API is available at ${explorerApiUrl}`,
78+
`Useful routes:`,
79+
` GET ${explorerApiUrl} - OpenAPI schema`,
80+
` GET ${explorerApiUrl}/d1/database - D1 databases`,
81+
` GET ${explorerApiUrl}/local/workers - local Workers and bindings`,
82+
` GET ${explorerApiUrl}/r2/buckets - R2 buckets`,
83+
` GET ${explorerApiUrl}/storage/kv/namespaces - KV namespaces`,
84+
` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`,
85+
` GET ${explorerApiUrl}/workflows - Workflows`,
86+
"",
87+
].join("\n")
88+
);
89+
}

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)