diff --git a/.changeset/local-explorer-agent-hint.md b/.changeset/local-explorer-agent-hint.md new file mode 100644 index 0000000000..68487ddc8c --- /dev/null +++ b/.changeset/local-explorer-agent-hint.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Print Local Explorer API details for headless agent-driven `wrangler dev` sessions + +When `wrangler dev` is started in a headless AI agent environment, Wrangler now prints the Local Explorer API URL and basic resource routes so agents can inspect local Workers and bindings without relying on the interactive UI. diff --git a/packages/wrangler/src/__tests__/dev.test.ts b/packages/wrangler/src/__tests__/dev.test.ts index 3498d14335..b256a4a4d8 100644 --- a/packages/wrangler/src/__tests__/dev.test.ts +++ b/packages/wrangler/src/__tests__/dev.test.ts @@ -20,6 +20,7 @@ import { logger } from "../logger"; import { sniffUserAgent } from "../package-manager"; import { DEFAULT_WORKERS_TYPES_FILE_PATH } from "../type-generation/helpers"; import * as generateRuntime from "../type-generation/runtime"; +import { detectAgent } from "../utils/detect-agent"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { useMockIsTTY } from "./helpers/mock-istty"; @@ -36,15 +37,30 @@ import type { StartDevWorkerOptions, Trigger, } from "../api"; +import type { StartDevOptions } from "../dev"; import type { RawConfig } from "@cloudflare/workers-utils"; import type { ExpectStatic } from "vitest"; import type { Mock, MockInstance } from "vitest"; +const startDevMock = vi.hoisted(() => ({ + calls: [] as StartDevOptions[], +})); + vi.mock("../api/startDevWorker/ConfigController", (importOriginal) => importOriginal() ); vi.mock("node:child_process"); vi.mock("../dev/hotkeys"); +vi.mock("../dev/start-dev", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startDev: vi.fn((args: StartDevOptions) => { + startDevMock.calls.push(args); + return actual.startDev(args); + }), + }; +}); // Don't memoize in tests. If we did, it would memoize across test runs, which causes problems vi.mock("../utils/memoizeGetPort", () => { @@ -125,6 +141,7 @@ describe.sequential("wrangler dev", () => { beforeEach(() => { setIsTTY(true); + startDevMock.calls = []; setSpy = vi.spyOn(ConfigController.prototype, "set"); spy = vi .spyOn(ConfigController.prototype, "emitConfigUpdateEvent") @@ -166,6 +183,62 @@ describe.sequential("wrangler dev", () => { return { ...spy.mock.calls[0][0], input: setSpy.mock.calls[0][0] }; } + function getLatestStartDevArgs(): StartDevOptions { + const args = startDevMock.calls.at(-1); + assert(args); + return args; + } + + describe("Local Explorer agent hint", () => { + beforeEach(() => { + writeWranglerConfig({ + name: "test-worker", + main: "index.js", + compatibility_date: "2024-01-01", + }); + fs.writeFileSync("index.js", `export default {};`); + vi.mocked(detectAgent).mockReturnValue({ isAgent: false, id: null }); + }); + + it("asks startDev to print the hint for headless agent sessions", async ({ + expect, + }) => { + setIsTTY(false); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(true); + }); + + it("does not ask startDev to print the hint for interactive agent sessions", async ({ + expect, + }) => { + setIsTTY(true); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(false); + }); + + it("does not ask startDev to print the hint for non-agent sessions", async ({ + expect, + }) => { + setIsTTY(false); + + await runWranglerUntilConfig("dev"); + + expect(getLatestStartDevArgs().showLocalExplorerAgentHint).toBe(false); + }); + }); + describe("config file support", () => { it("should support wrangler.toml", async ({ expect }) => { writeWranglerConfig({ diff --git a/packages/wrangler/src/__tests__/dev/start-dev.test.ts b/packages/wrangler/src/__tests__/dev/start-dev.test.ts index cf0bfaecde..1c266423e6 100644 --- a/packages/wrangler/src/__tests__/dev/start-dev.test.ts +++ b/packages/wrangler/src/__tests__/dev/start-dev.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert"; import { beforeEach, describe, it, vi } from "vitest"; import registerDevHotKeys from "../../dev/hotkeys"; import { startDev } from "../../dev/start-dev"; +import { logger } from "../../logger"; import { requireAuth } from "../../user"; +import { mockConsoleMethods } from "../helpers/mock-console"; import type { StartDevWorkerInput } from "../../api"; import type { StartDevOptions } from "../../dev"; @@ -42,10 +44,14 @@ vi.mock("../../user", () => ({ requireAuth: vi.fn(async () => "test-account-id"), })); +const std = mockConsoleMethods(); + describe("startDev", () => { beforeEach(() => { vi.clearAllMocks(); + logger.clearHistory(); mocks.configSet.mockResolvedValue(undefined); + mocks.fakeDevEnv.proxy.ready.promise = new Promise(() => {}); }); it("unregisters the latest hotkey registration after auth re-registers hotkeys", async ({ @@ -78,4 +84,47 @@ describe("startDev", () => { expect(unregisterHotKeys[0]).toHaveBeenCalledOnce(); expect(unregisterHotKeys[1]).toHaveBeenCalledOnce(); }); + + it("prints the Local Explorer API hint when the caller asks for it", async ({ + expect, + }) => { + const readyPromise = Promise.resolve({ + url: new URL("http://127.0.0.1:8787"), + }); + mocks.fakeDevEnv.proxy.ready.promise = readyPromise; + + await startDev({ + disableDevRegistry: true, + showLocalExplorerAgentHint: true, + } as StartDevOptions); + await readyPromise; + await Promise.resolve(); + + expect(std.out).toContain( + "Wrangler detected this dev session is running in an AI agent." + ); + expect(std.out).toContain( + "The Local Explorer API is available at http://127.0.0.1:8787/cdn-cgi/explorer/api" + ); + expect(std.out).toContain( + "GET http://127.0.0.1:8787/cdn-cgi/explorer/api/local/workers - local Workers and bindings" + ); + }); + + it("does not print the Local Explorer API hint when the caller has not opted in", async ({ + expect, + }) => { + const readyPromise = Promise.resolve({ + url: new URL("http://127.0.0.1:8787"), + }); + mocks.fakeDevEnv.proxy.ready.promise = readyPromise; + + await startDev({ + disableDevRegistry: true, + } as StartDevOptions); + await readyPromise; + await Promise.resolve(); + + expect(std.out).not.toContain("The Local Explorer API is available"); + }); }); diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 042dfb872b..3a47a5bac8 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -4,6 +4,8 @@ import { convertConfigToBindings } from "@cloudflare/deploy-helpers"; import { configFileName, formatConfigSnippet, + getLocalExplorerEnabledFromEnv, + isInteractive, UserError, } from "@cloudflare/workers-utils"; import { getHostFromRoute } from "@cloudflare/workers-utils"; @@ -15,6 +17,7 @@ import { getVarsForDev } from "./dev/dev-vars"; import { startDev } from "./dev/start-dev"; import { experimentalNewConfigArg } from "./experimental-config/cli-flag"; import { logger } from "./logger"; +import { detectAgent } from "./utils/detect-agent"; import type { StartDevWorkerInput, Trigger } from "./api"; import type { EnablePagesAssetsServiceBindingOptions } from "./miniflare-cli/types"; import type { @@ -299,7 +302,17 @@ export const dev = createCommand({ } }, async handler(args) { - const devInstance = await startDev(args); + const interactiveDevSession = + isInteractive() && args.showInteractiveDevSession !== false; + const showLocalExplorerAgentHint = + !interactiveDevSession && + !args.remote && + getLocalExplorerEnabledFromEnv() && + detectAgent().isAgent; + const devInstance = await startDev({ + ...args, + showLocalExplorerAgentHint, + }); assert(devInstance.devEnv !== undefined); await events.once(devInstance.devEnv, "teardown"); await Promise.all(devInstance.secondary.map((d) => d.teardown())); @@ -358,6 +371,7 @@ export type AdditionalDevProps = { moduleRoot?: string; rules?: Rule[]; showInteractiveDevSession?: boolean; + showLocalExplorerAgentHint?: boolean; }; type DevArguments = Omit<(typeof dev)["args"], "installSkills" | "profile">; diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 320188495f..f7a27d6187 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -2,8 +2,8 @@ import assert from "node:assert"; import path from "node:path"; import { bold, green } from "@cloudflare/cli-shared-helpers/colors"; import { generateContainerBuildId } from "@cloudflare/containers-shared"; -import { getRegistryPath } from "@cloudflare/workers-utils"; -import { isInteractive } from "@cloudflare/workers-utils"; +import { getRegistryPath, isInteractive } from "@cloudflare/workers-utils"; +import { CorePaths } from "miniflare"; import dedent from "ts-dedent"; import { DevEnv } from "../api"; import { convertStartDevOptionsToBindings } from "../api/startDevWorker/binding-utils"; @@ -123,7 +123,10 @@ export async function startDev(args: StartDevOptions) { tunnelManager?.getTunnel()?.dispose(); }); - if (isInteractive() && args.showInteractiveDevSession !== false) { + const interactiveDevSession = + isInteractive() && args.showInteractiveDevSession !== false; + + if (interactiveDevSession) { unregisterHotKeys = registerDevHotKeys(devEnvs, args, { tunnelManager }); } @@ -155,6 +158,9 @@ export async function startDev(args: StartDevOptions) { false ); maybePrintScheduledWorkerWarning(hasCrons, !!args.testScheduled, url); + if (args.showLocalExplorerAgentHint) { + printLocalExplorerAgentHint(url); + } }); // Start tunnel early, before the proxy is ready. @@ -361,6 +367,23 @@ function maybePrintScheduledWorkerWarning( ); } +function printLocalExplorerAgentHint(url: URL): void { + const displayUrl = new URL(url.href); + displayUrl.hostname = formatHostname(url.hostname); + const explorerApiUrl = new URL(`${CorePaths.EXPLORER}/api`, displayUrl).href; + logger.once.log(dedent` + Wrangler detected this dev session is 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`); +} + export function formatHostname(hostname: string): string { if (hostname === "0.0.0.0" || hostname === "::" || hostname === "*") { return "localhost";