diff --git a/.changeset/agents-skills-install.md b/.changeset/agents-skills-install.md new file mode 100644 index 0000000000..2f6f45c79c --- /dev/null +++ b/.changeset/agents-skills-install.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Add automatic Cloudflare skills installation for AI coding agents + +Wrangler now detects AI coding agent configuration directories (e.g. Claude Code, Cursor, Cline, Gemini CLI, OpenCode) and offers to install Cloudflare skill files from the `cloudflare/skills` GitHub repository. Users are prompted once interactively; subsequent runs skip the prompt. Use `--experimental-force-skills-install` (alias `--x-force-skills-install`) to install without prompting. diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index c24ac39c8f..dcc8f9207c 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -128,6 +128,7 @@ "esprima": "4.0.1", "execa": "^6.1.0", "get-port": "^7.0.0", + "giget": "^3.2.0", "glob-to-regexp": "^0.4.1", "https-proxy-agent": "7.0.2", "itty-time": "^1.0.6", diff --git a/packages/wrangler/src/__tests__/agents-skills-install.test.ts b/packages/wrangler/src/__tests__/agents-skills-install.test.ts new file mode 100644 index 0000000000..6740e021a8 --- /dev/null +++ b/packages/wrangler/src/__tests__/agents-skills-install.test.ts @@ -0,0 +1,534 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getGlobalWranglerConfigPath } from "@cloudflare/workers-utils"; +import ci from "ci-info"; +import { afterEach, assert, beforeEach, describe, test, vi } from "vitest"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import type { installCloudflareSkillsGlobally as InstallFnType } from "../agents-skills-install"; + +// Undo the global no-op mock from vitest.setup.ts so we test the real implementation +vi.unmock("../agents-skills-install"); + +// Mock giget to avoid real network calls. The default downloadTemplate +// implementation creates two fake skill directories; individual tests can +// override via mockDownloadTemplate.mockImplementationOnce(). +const mockDownloadTemplate = vi.fn(); +vi.mock("giget", () => ({ + downloadTemplate: mockDownloadTemplate, +})); + +/** Creates a fake agent config directory under the mocked HOME. */ +function createAgentDir(dirName: string): string { + const agentPath = path.join(os.homedir(), dirName); + mkdirSync(agentPath, { recursive: true }); + return agentPath; +} + +/** Writes the skills-install metadata file to the global wrangler config path. */ +function writeMetadataFile(content: Record): void { + const configDir = getGlobalWranglerConfigPath(); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + path.join(configDir, "agents-skills-install.jsonc"), + JSON.stringify(content) + ); +} + +/** Reads and parses the skills-install metadata file. */ +function readMetadataFile(): Record { + const filePath = path.join( + getGlobalWranglerConfigPath(), + "agents-skills-install.jsonc" + ); + return JSON.parse(readFileSync(filePath, "utf8")); +} + +/** Default mockDownloadTemplate implementation that creates fake skill directories. */ +function defaultDownloadImpl(_source: string, opts: { dir: string }): void { + mkdirSync(path.join(opts.dir, "cloudflare"), { recursive: true }); + writeFileSync( + path.join(opts.dir, "cloudflare", "SKILL.md"), + "# Cloudflare skill" + ); + mkdirSync(path.join(opts.dir, "wrangler"), { recursive: true }); + writeFileSync( + path.join(opts.dir, "wrangler", "SKILL.md"), + "# Wrangler skill" + ); +} + +/** + * Re-imports the agents-skills-install module with a fresh module graph. + * This is necessary because the `supportedAgents` array computes paths + * using `os.homedir()` at module load time. Since HOME is stubbed per-test + * by runInTempDir (in beforeEach), we must reload the module after each + * stub so that agent paths point to the temp dir, not the real home. + */ +async function freshImport(): Promise { + vi.resetModules(); + const mod = await import("../agents-skills-install"); + return mod.installCloudflareSkillsGlobally; +} + +describe("installCloudflareSkillsGlobally", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + beforeEach(() => { + setIsTTY(true); + mockDownloadTemplate.mockImplementation( + async (source: string, opts: { dir: string }) => { + defaultDownloadImpl(source, opts); + } + ); + }); + + afterEach(() => { + clearDialogs(); + }); + + describe("skip conditions", () => { + test("returns 'Already prompted' when metadata file exists", async ({ + expect, + }) => { + writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "Already prompted", + }); + }); + + test("force=true ignores existing metadata file", async ({ expect }) => { + writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" }); + // No agent dirs exist, so it proceeds past metadata check but skips + // at agent detection + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(true); + + expect(result).toEqual({ + skipped: true, + reason: "No supported agents detected", + }); + }); + + test("returns 'No supported agents detected' when no agent dirs exist", async ({ + expect, + }) => { + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "No supported agents detected", + }); + }); + + test("returns 'Failed to download skills' when download throws", async ({ + expect, + }) => { + createAgentDir(".claude"); + mockDownloadTemplate.mockRejectedValueOnce(new Error("network failure")); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "Failed to download skills", + }); + expect(std.warn).toContain( + "Failed to download Cloudflare skills from GitHub: network failure" + ); + }); + + test("returns 'Downloaded skills repo is empty' when cloned dir has no subdirectories", async ({ + expect, + }) => { + createAgentDir(".claude"); + // Download creates no directories (empty temp dir) + mockDownloadTemplate.mockImplementationOnce(async () => {}); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "Downloaded skills repo is empty", + }); + expect(std.warn).toContain( + "Downloaded Cloudflare skills repo appears empty" + ); + }); + + test("returns 'All agents already have skills installed' when skills already exist", async ({ + expect, + }) => { + const agentDir = createAgentDir(".claude"); + // Pre-populate the agent's skills directory with the same skill names + // that giget will download + mkdirSync(path.join(agentDir, "skills", "cloudflare"), { + recursive: true, + }); + mkdirSync(path.join(agentDir, "skills", "wrangler"), { + recursive: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "All agents already have skills installed", + }); + }); + + test("returns 'Running in CI' when ci.isCI is true", async ({ expect }) => { + createAgentDir(".claude"); + vi.mocked(ci).isCI = true; + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "Running in CI", + }); + // Verify no network call was made + expect(mockDownloadTemplate).not.toHaveBeenCalled(); + }); + + test("returns 'Non-interactive terminal' when TTY is false", async ({ + expect, + }) => { + createAgentDir(".claude"); + setIsTTY(false); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "Non-interactive terminal", + }); + expect(std.out).toContain( + "Cloudflare agent skills are available for: Claude Code" + ); + // Verify no network call was made + expect(mockDownloadTemplate).not.toHaveBeenCalled(); + }); + }); + + describe("user prompt interaction", () => { + test("returns 'User declined' and writes metadata when user declines", async ({ + expect, + }) => { + createAgentDir(".claude"); + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: false, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toEqual({ + skipped: true, + reason: "User declined", + }); + + // must not log a success message when the user declined + expect(std.out).not.toContain( + "Successfully installed Cloudflare skills for:" + ); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(false); + expect(metadata.date).toBeDefined(); + }); + + test("copies skills to agent when user accepts", async ({ expect }) => { + createAgentDir(".claude"); + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + expect(result).toMatchObject({ + targetedAgents: expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]), + }); + assert(!("skipped" in result)); + + const skillsDir = path.join(os.homedir(), ".claude", "skills"); + expect(existsSync(path.join(skillsDir, "cloudflare", "SKILL.md"))).toBe( + true + ); + expect(existsSync(path.join(skillsDir, "wrangler", "SKILL.md"))).toBe( + true + ); + + expect(std.out).toContain( + "Successfully installed Cloudflare skills for: Claude Code." + ); + }); + + test("force=true copies skills without prompting", async ({ expect }) => { + createAgentDir(".claude"); + // No mockConfirm — if a prompt fires, the test will fail with "Unexpected call to prompts" + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(true); + + expect(result).toMatchObject({ + targetedAgents: expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]), + }); + + const skillsDir = path.join(os.homedir(), ".claude", "skills"); + expect(existsSync(path.join(skillsDir, "cloudflare", "SKILL.md"))).toBe( + true + ); + + expect(std.out).toContain( + "Successfully installed Cloudflare skills for: Claude Code." + ); + }); + }); + + describe("multiple agents", () => { + test("detects and installs skills for multiple agents", async ({ + expect, + }) => { + createAgentDir(".claude"); + createAgentDir(".cursor"); + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + assert(!("skipped" in result)); + const agentNames = result.targetedAgents.map((a) => a.name); + expect(agentNames).toContain("Claude Code"); + expect(agentNames).toContain("Cursor"); + + expect( + existsSync(path.join(os.homedir(), ".claude", "skills", "cloudflare")) + ).toBe(true); + expect( + existsSync(path.join(os.homedir(), ".cursor", "skills", "cloudflare")) + ).toBe(true); + + expect(std.out).toContain( + "Successfully installed Cloudflare skills for: Claude Code, Cursor." + ); + }); + + test("only targets agents missing skills, skips those that already have them", async ({ + expect, + }) => { + createAgentDir(".claude"); + createAgentDir(".cursor"); + // Pre-populate Cursor with all skills so it doesn't need install + const cursorSkills = path.join(os.homedir(), ".cursor", "skills"); + mkdirSync(path.join(cursorSkills, "cloudflare"), { recursive: true }); + mkdirSync(path.join(cursorSkills, "wrangler"), { recursive: true }); + + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + assert(!("skipped" in result)); + const agentNames = result.targetedAgents.map((a) => a.name); + expect(agentNames).toContain("Claude Code"); + expect(agentNames).not.toContain("Cursor"); + }); + }); + + describe("copy failures", () => { + test("reports partial copy failure and logs warning", async ({ + expect, + }) => { + createAgentDir(".claude"); + createAgentDir(".cursor"); + + // Make Cursor's skills path a file (not directory) so cpSync fails + // when trying to write skill files into it + const cursorSkillsParent = path.join(os.homedir(), ".cursor", "skills"); + mkdirSync(cursorSkillsParent, { recursive: true }); + const cursorBlockerFile = path.join(cursorSkillsParent, "cloudflare"); + // Create a file where a directory is expected — cpSync will fail + writeFileSync(cursorBlockerFile, "blocker"); + + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + assert(!("skipped" in result)); + expect(result.copyFailedFor).toBeDefined(); + expect(result.copyFailedFor).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "Cursor" })]) + ); + expect( + existsSync(path.join(os.homedir(), ".claude", "skills", "cloudflare")) + ).toBe(true); + + expect(std.out).toContain( + "Successfully installed Cloudflare skills for: Claude Code." + ); + expect(std.warn).toContain( + "Failed to install Cloudflare skills for some of the detected agents." + ); + }); + + test("logs generic warning when all copies fail", async ({ expect }) => { + createAgentDir(".claude"); + + // Block both skill directories for Claude + const claudeSkillsParent = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(claudeSkillsParent, { recursive: true }); + writeFileSync(path.join(claudeSkillsParent, "cloudflare"), "blocker"); + + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + assert(!("skipped" in result)); + expect(result.copyFailedFor).toBeDefined(); + expect(result.copyFailedFor?.length).toBe(result.targetedAgents.length); + + expect(std.out).not.toContain( + "Successfully installed Cloudflare skills for:" + ); + expect(std.warn).toContain( + "Failed to install Cloudflare skills for all the detected agents." + ); + }); + }); + + describe("metadata file", () => { + test("writes metadata file with correct content when user accepts", async ({ + expect, + }) => { + createAgentDir(".claude"); + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + await installCloudflareSkillsGlobally(false); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(true); + expect(metadata.date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(metadata.detectedAgents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]) + ); + expect(metadata.agentsNeedingInstall).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]) + ); + }); + + test("writes metadata file when user declines", async ({ expect }) => { + createAgentDir(".claude"); + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: false, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + await installCloudflareSkillsGlobally(false); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(false); + }); + + test("includes copyFailedFor in metadata when copy fails", async ({ + expect, + }) => { + createAgentDir(".claude"); + const claudeSkillsParent = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(claudeSkillsParent, { recursive: true }); + writeFileSync(path.join(claudeSkillsParent, "cloudflare"), "blocker"); + + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + await installCloudflareSkillsGlobally(false); + + const metadata = readMetadataFile(); + expect(metadata.accepted).toBe(true); + expect(metadata.copyFailedFor).toBeDefined(); + expect(metadata.copyFailedFor).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]) + ); + }); + }); + + describe("agent detection with partial skills", () => { + test("detects agent as needing install when only some skills are present", async ({ + expect, + }) => { + createAgentDir(".claude"); + // Only install one of two skills — agent should still be targeted + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + // Missing "wrangler" skill + + mockConfirm({ + text: expect.stringContaining("Claude Code") as unknown as string, + result: true, + }); + const installCloudflareSkillsGlobally = await freshImport(); + + const result = await installCloudflareSkillsGlobally(false); + + assert(!("skipped" in result)); + expect(result.targetedAgents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Claude Code" }), + ]) + ); + }); + }); +}); diff --git a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts index 6278406a4b..131a7b229e 100644 --- a/packages/wrangler/src/__tests__/experimental-commands-api.test.ts +++ b/packages/wrangler/src/__tests__/experimental-commands-api.test.ts @@ -37,6 +37,13 @@ describe("experimental_getWranglerCommands", () => { "hidden": true, "type": "boolean", }, + "experimental-force-skills-install": { + "alias": "x-force-skills-install", + "default": false, + "describe": "Install Cloudflare agents skills, if not already present, without asking the user for confirmation", + "hidden": true, + "type": "boolean", + }, "experimental-provision": { "alias": [ "x-provision", diff --git a/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts new file mode 100644 index 0000000000..74add75fc1 --- /dev/null +++ b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts @@ -0,0 +1,119 @@ +import { seed } from "@cloudflare/workers-utils/test-helpers"; +import { beforeEach, describe, test, vi } from "vitest"; +import { installCloudflareSkillsGlobally } from "../agents-skills-install"; +import { sendMetricsEvent } from "../metrics/send-event"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; +import type * as SendEventModule from "../metrics/send-event"; + +// The global vitest.setup.ts mock for installCloudflareSkillsGlobally is active +// (returns { skipped: true, reason: "Already prompted" }), so we can spy on it +// without making real network calls. + +vi.mock("../metrics/send-event", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + sendMetricsEvent: vi.fn(), + }; +}); + +vi.mock("../package-manager", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getPackageManager() { + return { + type: "npm", + npx: "npx", + }; + }, + }; +}); + +describe("register-yargs-command skills integration", () => { + runInTempDir(); + mockConsoleMethods(); + + beforeEach(async () => { + // Seed a wrangler config so `wrangler setup` skips autoconfig and + // returns quickly — we only care about the skills install call. + await seed({ + "wrangler.jsonc": JSON.stringify({ name: "test-worker" }), + }); + }); + + test("calls installCloudflareSkillsGlobally with false by default", async ({ + expect, + }) => { + await runWrangler("setup"); + + expect(installCloudflareSkillsGlobally).toHaveBeenCalledWith(false); + }); + + test("calls installCloudflareSkillsGlobally with true when --x-force-skills-install is passed", async ({ + expect, + }) => { + await runWrangler("setup --x-force-skills-install"); + + expect(installCloudflareSkillsGlobally).toHaveBeenCalledWith(true); + }); + + test("does not send skills_install metrics when result is 'Already prompted'", async ({ + expect, + }) => { + // The global mock returns { skipped: true, reason: "Already prompted" } + await runWrangler("setup"); + + const metricsCalls = vi + .mocked(sendMetricsEvent) + .mock.calls.filter( + ([event]) => + event === "skills_install_skipped" || + event === "skills_install_completed" + ); + + expect(metricsCalls).toHaveLength(0); + }); + + test("sends skills_install_skipped metric for non-'Already prompted' skip reasons", async ({ + expect, + }) => { + vi.mocked(installCloudflareSkillsGlobally).mockResolvedValueOnce({ + skipped: true, + reason: "No supported agents detected", + }); + + await runWrangler("setup"); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_skipped", + { reason: "No supported agents detected" }, + {} + ); + }); + + test("sends skills_install_completed metric when skills are installed", async ({ + expect, + }) => { + const fakeAgents = [ + { + name: "Claude Code", + globalPath: "/fake/.claude", + globalSkillsPath: "/fake/.claude/skills", + }, + ]; + vi.mocked(installCloudflareSkillsGlobally).mockResolvedValueOnce({ + targetedAgents: fakeAgents, + }); + + await runWrangler("setup"); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_install_completed", + { agents: fakeAgents }, + {} + ); + }); +}); diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index 10d56dad53..73065bd141 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -221,6 +221,16 @@ vi.mock("../metrics/metrics-config", async (importOriginal) => { return realModule; }); +vi.mock("../agents-skills-install", async (importOriginal) => { + const realModule = + await importOriginal(); + vi.spyOn(realModule, "installCloudflareSkillsGlobally").mockResolvedValue({ + skipped: true, + reason: "Already prompted", + }); + return realModule; +}); + vi.mock("prompts", () => { return { __esModule: true, diff --git a/packages/wrangler/src/agents-skills-install.ts b/packages/wrangler/src/agents-skills-install.ts new file mode 100644 index 0000000000..99c40622fa --- /dev/null +++ b/packages/wrangler/src/agents-skills-install.ts @@ -0,0 +1,595 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + getGlobalWranglerConfigPath, + parseJSONC, + removeDir, +} from "@cloudflare/workers-utils"; +import ci from "ci-info"; +import { downloadTemplate } from "giget"; +import { confirm } from "./dialogs"; +import isInteractive from "./is-interactive"; +import { logger } from "./logger"; + +export type SkillsInstallSkipReason = + | "Already prompted" + | "No supported agents detected" + | "Failed to download skills" + | "Downloaded skills repo is empty" + | "All agents already have skills installed" + | "Running in CI" + | "Non-interactive terminal" + | "User declined"; + +export type SkillsInstallResult = + | { skipped: true; reason: SkillsInstallSkipReason } + | { + targetedAgents: AgentInfo[]; + copyFailedFor?: (AgentInfo & { + error: string; + })[]; + }; + +/** + * Detects AI coding agents installed on the user's machine and offers to + * install Cloudflare skill files into their global skills directories. + * + * Skills are downloaded dynamically from the `cloudflare/skills` GitHub + * repository. The existence check verifies that all skill directories from + * the repo are present — if any are missing, the agent is considered to need + * an update. + * + * @param force - When `true` the interactive prompt is skipped and skills are + * installed unconditionally (used by `--experimental-force-skills-install`). + */ +export async function installCloudflareSkillsGlobally( + force: boolean +): Promise { + // If the user has already been prompted, don't ask again + const existingConfig = readSkillsInstallMetadataFile(); + if (existingConfig !== undefined && !force) { + return { skipped: true, reason: "Already prompted" }; + } + + const configuredAgentsFound = supportedAgents.filter((agent) => { + return existsSync(agent.globalPath); + }); + + if (configuredAgentsFound.length === 0) { + return { skipped: true, reason: "No supported agents detected" }; + } + + if (ci.isCI) { + // In CI environments, skip silently + return { skipped: true, reason: "Running in CI" }; + } + + // In non-interactive terminals (but not CI), log a message + if (!force && !isInteractive()) { + logger.log( + `Cloudflare agent skills are available for: ${configuredAgentsFound.map(({ name }) => name).join(", ")}. Run wrangler in an interactive terminal to install them, or use \`--experimental-force-skills-install\` to install without prompting.` + ); + return { skipped: true, reason: "Non-interactive terminal" }; + } + + // Download skills from GitHub to a temp directory so we can discover + // skill names dynamically and use them for both the existence check and install. + const skillsTempDir = await downloadSkillsToTempDir(); + if (skillsTempDir === undefined) { + return { skipped: true, reason: "Failed to download skills" }; + } + + try { + const skillNames = getSkillNamesFromDir(skillsTempDir); + if (skillNames.length === 0) { + logger.warn( + "Downloaded Cloudflare skills repo appears empty. Skipping installation." + ); + return { skipped: true, reason: "Downloaded skills repo is empty" }; + } + + const agentsNeedingInstall = configuredAgentsFound.filter((agent) => { + return !agentHasAllSkills(agent.globalSkillsPath, skillNames); + }); + + if (agentsNeedingInstall.length === 0) { + return { + skipped: true, + reason: "All agents already have skills installed", + }; + } + + const accepted = + force || + (await confirm( + `Wrangler detected configuration directories for the following AI coding agents without Cloudflare skills: ${agentsNeedingInstall.map(({ name }) => name).join(", ")}. Would you like to install them?`, + { defaultValue: true, fallbackValue: false } + )); + + let copyFailedFor: + | (AgentInfo & { + error: string; + })[] + | undefined = undefined; + if (accepted) { + for (const agent of agentsNeedingInstall) { + try { + copySkillsToAgent(skillsTempDir, agent.globalSkillsPath, skillNames); + } catch (err) { + const error = `${err instanceof Error ? err.message : err}`; + copyFailedFor ??= []; + copyFailedFor.push({ + ...agent, + error, + }); + } + } + } + + const failedNames = new Set(copyFailedFor?.map(({ name }) => name)); + const succeededAgents = agentsNeedingInstall.filter( + (agent) => !failedNames.has(agent.name) + ); + + if (accepted && succeededAgents.length > 0) { + logger.log( + `Successfully installed Cloudflare skills for: ${succeededAgents.map(({ name }) => name).join(", ")}.` + ); + } + + if (copyFailedFor && copyFailedFor.length > 0) { + logger.warn( + `Failed to install Cloudflare skills for ${succeededAgents.length === 0 ? "all" : "some of"} the detected agents. You can retry by passing \`--experimental-force-skills-install\` to your next wrangler command.` + ); + } + + writeSkillsInstallMetadataFile({ + accepted, + date: new Date().toISOString(), + detectedAgents: configuredAgentsFound, + agentsNeedingInstall: agentsNeedingInstall, + ...(copyFailedFor ? { copyFailedFor } : {}), + }); + + if (!accepted) { + return { skipped: true, reason: "User declined" }; + } + + return { + targetedAgents: agentsNeedingInstall, + ...(copyFailedFor ? { copyFailedFor } : {}), + }; + } finally { + // Clean up the temp directory (fire-and-forget, errors suppressed) + removeDir(skillsTempDir, { fireAndForget: true }); + } +} + +/** + * Describes a supported AI coding agent and the filesystem paths Wrangler uses to detect it and install Cloudflare skills. + */ +type AgentInfo = { + /** (Human readable) name of the agent (e.g. "Cursor", "Claude Code"). */ + name: string; + /** Absolute path to the agent's global configuration directory. Its existence signals the agent is installed. */ + globalPath: string; + /** Absolute path to the directory where Cloudflare skill files should be copied for this agent. */ + globalSkillsPath: string; +}; + +/** + * Persisted configuration that tracks whether the user has been prompted + * about installing Cloudflare agent skills globally. + */ +interface SkillsInstallMetadata { + /** Whether the user accepted the prompt to create the skills directory. */ + accepted: boolean; + /** ISO date string of when the user was prompted. */ + date: string; + /** All agents detected on the user's machine (globalPath exists). */ + detectedAgents?: AgentInfo[]; + /** Agents that were missing some or all skills and were targeted for installation. */ + agentsNeedingInstall?: AgentInfo[]; + /** Agents for which the skill copy failed, if any. */ + copyFailedFor?: (AgentInfo & { + error: string; + })[]; +} + +/** Jsonc metadata file created when Cloudflare agent skills are installed */ +const SKILLS_INSTALL_METADATA_FILENAME = "agents-skills-install.jsonc"; + +/** giget source for the skills subdirectory of the cloudflare/skills repo */ +const SKILLS_REPO_SOURCE = "gh:cloudflare/skills/skills"; + +/** + * Returns the absolute path to the skills install config file within the global wrangler config directory. + * + * @returns Absolute path to the `agents-skills-install.jsonc` file. + */ +function getSkillsInstallMetadataFilePath(): string { + return path.resolve( + getGlobalWranglerConfigPath(), + SKILLS_INSTALL_METADATA_FILENAME + ); +} + +/** + * List of AI coding agents that support global skill installation. + * + * Each entry maps an agent's display name to its global configuration path + * (`globalPath`) and the skills subdirectory within it (`globalSkillsPath`). + * Wrangler checks for the existence of `globalPath` to detect which agents + * are installed on the user's machine, then creates `globalSkillsPath` + * (recursively) if needed before copying Cloudflare skill files into it. + */ +const supportedAgents: AgentInfo[] = [ + { + name: "AiderDesk", + globalPath: path.join(os.homedir(), ".aider-desk"), + globalSkillsPath: path.join(os.homedir(), ".aider-desk", "skills"), + }, + { + name: "Amp, Kimi Code CLI, Replit, Universal", + globalPath: path.join(os.homedir(), ".config", "agents"), + globalSkillsPath: path.join(os.homedir(), ".config", "agents", "skills"), + }, + { + name: "Antigravity", + globalPath: path.join(os.homedir(), ".gemini", "antigravity"), + globalSkillsPath: path.join( + os.homedir(), + ".gemini", + "antigravity", + "skills" + ), + }, + { + name: "Augment", + globalPath: path.join(os.homedir(), ".augment"), + globalSkillsPath: path.join(os.homedir(), ".augment", "skills"), + }, + { + name: "IBM Bob", + globalPath: path.join(os.homedir(), ".bob"), + globalSkillsPath: path.join(os.homedir(), ".bob", "skills"), + }, + { + name: "Claude Code", + globalPath: path.join(os.homedir(), ".claude"), + globalSkillsPath: path.join(os.homedir(), ".claude", "skills"), + }, + { + name: "OpenClaw", + globalPath: path.join(os.homedir(), ".openclaw"), + globalSkillsPath: path.join(os.homedir(), ".openclaw", "skills"), + }, + { + name: "Cline, Dexto, Warp", + globalPath: path.join(os.homedir(), ".agents"), + globalSkillsPath: path.join(os.homedir(), ".agents", "skills"), + }, + { + name: "CodeArts Agent", + globalPath: path.join(os.homedir(), ".codeartsdoer"), + globalSkillsPath: path.join(os.homedir(), ".codeartsdoer", "skills"), + }, + { + name: "CodeBuddy", + globalPath: path.join(os.homedir(), ".codebuddy"), + globalSkillsPath: path.join(os.homedir(), ".codebuddy", "skills"), + }, + { + name: "Codemaker", + globalPath: path.join(os.homedir(), ".codemaker"), + globalSkillsPath: path.join(os.homedir(), ".codemaker", "skills"), + }, + { + name: "Code Studio", + globalPath: path.join(os.homedir(), ".codestudio"), + globalSkillsPath: path.join(os.homedir(), ".codestudio", "skills"), + }, + { + name: "Codex", + globalPath: path.join(os.homedir(), ".codex"), + globalSkillsPath: path.join(os.homedir(), ".codex", "skills"), + }, + { + name: "Command Code", + globalPath: path.join(os.homedir(), ".commandcode"), + globalSkillsPath: path.join(os.homedir(), ".commandcode", "skills"), + }, + { + name: "Continue", + globalPath: path.join(os.homedir(), ".continue"), + globalSkillsPath: path.join(os.homedir(), ".continue", "skills"), + }, + { + name: "Cortex Code", + globalPath: path.join(os.homedir(), ".snowflake", "cortex"), + globalSkillsPath: path.join(os.homedir(), ".snowflake", "cortex", "skills"), + }, + { + name: "Crush", + globalPath: path.join(os.homedir(), ".config", "crush"), + globalSkillsPath: path.join(os.homedir(), ".config", "crush", "skills"), + }, + { + name: "Cursor", + globalPath: path.join(os.homedir(), ".cursor"), + globalSkillsPath: path.join(os.homedir(), ".cursor", "skills"), + }, + { + name: "Deep Agents", + globalPath: path.join(os.homedir(), ".deepagents"), + globalSkillsPath: path.join(os.homedir(), ".deepagents", "agent", "skills"), + }, + { + name: "Devin for Terminal", + globalPath: path.join(os.homedir(), ".config", "devin"), + globalSkillsPath: path.join(os.homedir(), ".config", "devin", "skills"), + }, + { + name: "Droid", + globalPath: path.join(os.homedir(), ".factory"), + globalSkillsPath: path.join(os.homedir(), ".factory", "skills"), + }, + { + name: "Firebender", + globalPath: path.join(os.homedir(), ".firebender"), + globalSkillsPath: path.join(os.homedir(), ".firebender", "skills"), + }, + { + name: "ForgeCode", + globalPath: path.join(os.homedir(), ".forge"), + globalSkillsPath: path.join(os.homedir(), ".forge", "skills"), + }, + { + name: "Gemini CLI", + globalPath: path.join(os.homedir(), ".gemini"), + globalSkillsPath: path.join(os.homedir(), ".gemini", "skills"), + }, + { + name: "GitHub Copilot", + globalPath: path.join(os.homedir(), ".copilot"), + globalSkillsPath: path.join(os.homedir(), ".copilot", "skills"), + }, + { + name: "Goose", + globalPath: path.join(os.homedir(), ".config", "goose"), + globalSkillsPath: path.join(os.homedir(), ".config", "goose", "skills"), + }, + { + name: "Hermes Agent", + globalPath: path.join(os.homedir(), ".hermes"), + globalSkillsPath: path.join(os.homedir(), ".hermes", "skills"), + }, + { + name: "Junie", + globalPath: path.join(os.homedir(), ".junie"), + globalSkillsPath: path.join(os.homedir(), ".junie", "skills"), + }, + { + name: "iFlow CLI", + globalPath: path.join(os.homedir(), ".iflow"), + globalSkillsPath: path.join(os.homedir(), ".iflow", "skills"), + }, + { + name: "Kilo Code", + globalPath: path.join(os.homedir(), ".kilocode"), + globalSkillsPath: path.join(os.homedir(), ".kilocode", "skills"), + }, + { + name: "Kiro CLI", + globalPath: path.join(os.homedir(), ".kiro"), + globalSkillsPath: path.join(os.homedir(), ".kiro", "skills"), + }, + { + name: "Kode", + globalPath: path.join(os.homedir(), ".kode"), + globalSkillsPath: path.join(os.homedir(), ".kode", "skills"), + }, + { + name: "MCPJam", + globalPath: path.join(os.homedir(), ".mcpjam"), + globalSkillsPath: path.join(os.homedir(), ".mcpjam", "skills"), + }, + { + name: "Mistral Vibe", + globalPath: path.join(os.homedir(), ".vibe"), + globalSkillsPath: path.join(os.homedir(), ".vibe", "skills"), + }, + { + name: "Mux", + globalPath: path.join(os.homedir(), ".mux"), + globalSkillsPath: path.join(os.homedir(), ".mux", "skills"), + }, + { + name: "OpenCode", + globalPath: path.join(os.homedir(), ".config", "opencode"), + globalSkillsPath: path.join(os.homedir(), ".config", "opencode", "skills"), + }, + { + name: "OpenHands", + globalPath: path.join(os.homedir(), ".openhands"), + globalSkillsPath: path.join(os.homedir(), ".openhands", "skills"), + }, + { + name: "Pi", + globalPath: path.join(os.homedir(), ".pi"), + globalSkillsPath: path.join(os.homedir(), ".pi", "agent", "skills"), + }, + { + name: "Qoder", + globalPath: path.join(os.homedir(), ".qoder"), + globalSkillsPath: path.join(os.homedir(), ".qoder", "skills"), + }, + { + name: "Qwen Code", + globalPath: path.join(os.homedir(), ".qwen"), + globalSkillsPath: path.join(os.homedir(), ".qwen", "skills"), + }, + { + name: "Rovo Dev", + globalPath: path.join(os.homedir(), ".rovodev"), + globalSkillsPath: path.join(os.homedir(), ".rovodev", "skills"), + }, + { + name: "Roo Code", + globalPath: path.join(os.homedir(), ".roo"), + globalSkillsPath: path.join(os.homedir(), ".roo", "skills"), + }, + { + name: "Tabnine CLI", + globalPath: path.join(os.homedir(), ".tabnine"), + globalSkillsPath: path.join(os.homedir(), ".tabnine", "agent", "skills"), + }, + { + name: "Trae", + globalPath: path.join(os.homedir(), ".trae"), + globalSkillsPath: path.join(os.homedir(), ".trae", "skills"), + }, + { + name: "Trae CN", + globalPath: path.join(os.homedir(), ".trae-cn"), + globalSkillsPath: path.join(os.homedir(), ".trae-cn", "skills"), + }, + { + name: "Windsurf", + globalPath: path.join(os.homedir(), ".codeium", "windsurf"), + globalSkillsPath: path.join(os.homedir(), ".codeium", "windsurf", "skills"), + }, + { + name: "Zencoder", + globalPath: path.join(os.homedir(), ".zencoder"), + globalSkillsPath: path.join(os.homedir(), ".zencoder", "skills"), + }, + { + name: "Neovate", + globalPath: path.join(os.homedir(), ".neovate"), + globalSkillsPath: path.join(os.homedir(), ".neovate", "skills"), + }, + { + name: "Pochi", + globalPath: path.join(os.homedir(), ".pochi"), + globalSkillsPath: path.join(os.homedir(), ".pochi", "skills"), + }, + { + name: "AdaL", + globalPath: path.join(os.homedir(), ".adal"), + globalSkillsPath: path.join(os.homedir(), ".adal", "skills"), + }, +]; + +/** + * Reads and parses the skills install metadata file. + * + * @returns The parsed metadata file, or `undefined` if the file doesn't exist or can't be parsed. + */ +function readSkillsInstallMetadataFile(): SkillsInstallMetadata | undefined { + try { + const content = readFileSync(getSkillsInstallMetadataFilePath(), "utf8"); + return parseJSONC(content) as SkillsInstallMetadata; + } catch { + return undefined; + } +} + +/** + * Persists the skills install metadata to disk, creating parent directories as needed. + * + * @param metadata - The metadata to write. + */ +function writeSkillsInstallMetadataFile(metadata: SkillsInstallMetadata): void { + const configPath = getSkillsInstallMetadataFilePath(); + mkdirSync(path.dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(metadata, null, "\t")); +} + +/** + * Downloads the Cloudflare skills from the `cloudflare/skills` GitHub repo + * into a temporary directory using giget (tarball mode, no git required). + * + * @returns The path to the temp directory containing the downloaded skill + * directories, or `undefined` if the download failed. + */ +async function downloadSkillsToTempDir(): Promise { + try { + const tmpDir = await mkdtemp( + path.join(os.tmpdir(), "wrangler-skills-install-") + ); + await downloadTemplate(SKILLS_REPO_SOURCE, { + dir: tmpDir, + force: true, + registry: false, + }); + return tmpDir; + } catch (err) { + logger.warn( + `Failed to download Cloudflare skills from GitHub: ${err instanceof Error ? err.message : String(err)}` + ); + return undefined; + } +} + +/** + * Reads the top-level directory names from the downloaded skills temp directory. + * Each directory name corresponds to a skill (e.g. "cloudflare", "wrangler", "agents-sdk"). + * + * @param skillsTempDir - Path to the temp directory containing downloaded skills. + * @returns Array of skill directory names. + */ +function getSkillNamesFromDir(skillsTempDir: string): string[] { + return readdirSync(skillsTempDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); +} + +/** + * Checks whether an agent's skills directory contains all of the given skill names. + * + * @param agentSkillsPath - The agent's global skills directory. + * @param skillNames - The skill directory names to check for. + * @returns `true` if every skill directory exists, `false` otherwise. + */ +function agentHasAllSkills( + agentSkillsPath: string, + skillNames: string[] +): boolean { + try { + const entries = new Set(readdirSync(agentSkillsPath)); + return skillNames.every((name) => entries.has(name)); + } catch { + return false; + } +} + +/** + * Copies all skill directories from the temp directory into an agent's skills path. + * + * @param skillsTempDir - Path to the temp directory containing downloaded skills. + * @param agentSkillsPath - The agent's global skills directory to copy into. + * @param skillNames - The skill directory names to copy. + */ +function copySkillsToAgent( + skillsTempDir: string, + agentSkillsPath: string, + skillNames: string[] +): void { + mkdirSync(agentSkillsPath, { recursive: true }); + for (const skillName of skillNames) { + const src = path.join(skillsTempDir, skillName); + const dest = path.join(agentSkillsPath, skillName); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); + } +} diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index f80fec2ce2..a42e306490 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -6,12 +6,13 @@ import { } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { experimental_readRawConfig } from "../../../workers-utils/src"; +import { installCloudflareSkillsGlobally } from "../agents-skills-install"; import { fetchResult } from "../cfetch"; import { createCloudflareClient } from "../cfetch/internal"; import { readConfig } from "../config"; import { run } from "../experimental-flags"; import { logger } from "../logger"; -import { getMetricsDispatcher } from "../metrics"; +import { getMetricsDispatcher, sendMetricsEvent } from "../metrics"; import { COMMAND_ARG_ALLOW_LIST, getAllowedArgs, @@ -136,6 +137,33 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { await printWranglerBanner(); } + const skillsInstallResult = await installCloudflareSkillsGlobally( + args.experimentalForceSkillsInstall + ); + const alreadyPrompted = + "skipped" in skillsInstallResult && + skillsInstallResult.reason === "Already prompted"; + if (!alreadyPrompted) { + if ("skipped" in skillsInstallResult) { + sendMetricsEvent( + "skills_install_skipped", + { reason: skillsInstallResult.reason }, + {} + ); + } else { + sendMetricsEvent( + "skills_install_completed", + { + agents: skillsInstallResult.targetedAgents, + ...(skillsInstallResult.copyFailedFor + ? { copyFailedFor: skillsInstallResult.copyFailedFor } + : {}), + }, + {} + ); + } + } + if (!getWranglerHideBanner()) { if (def.metadata.deprecated) { logger.warn(def.metadata.deprecatedMessage); diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index 0b6a173fe4..0c4e2f0adf 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -355,7 +355,10 @@ export type AdditionalDevProps = { showInteractiveDevSession?: boolean; }; -type DevArguments = (typeof dev)["args"]; +type DevArguments = Omit< + (typeof dev)["args"], + "experimentalForceSkillsInstall" +>; export type StartDevOptions = DevArguments & // These options can be passed in directly when called with the `wrangler.dev()` API. diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 3ee2fdd1eb..fc4f790afa 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -524,6 +524,16 @@ export function createCLIParser(argv: string[]) { hidden: true, alias: "x-auto-create", }, + "experimental-force-skills-install": { + describe: + "Install Cloudflare agents skills, if not already present, without asking the user for confirmation", + type: "boolean", + default: false, + // This flag is quite long and it wouldn't be great to show in all the wrangler help messages + // so we just hide it, we can unhide it in the future if this turns out not to be ok + hidden: true, + alias: "x-force-skills-install", + }, } as const; // Type check result against CommonYargsOptions to make sure we've included // all common options diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index 8a5bd4c2e8..de7cd61c59 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -71,7 +71,8 @@ export type EventNames = | "update pipeline" | "show pipeline" | "provision resources" - | AutoConfigEvent; + | AutoConfigEvent + | SkillsInstallEvent; /** Event related to the autoconfig flow */ type AutoConfigEvent = @@ -82,6 +83,9 @@ type AutoConfigEvent = | "autoconfig_configuration_started" | "autoconfig_configuration_completed"; +/** Event related to the agent skills install flow */ +type SkillsInstallEvent = "skills_install_skipped" | "skills_install_completed"; + /** * Send a metrics event, with no extra properties, to Cloudflare, if usage tracking is enabled. * diff --git a/packages/wrangler/src/yargs-types.ts b/packages/wrangler/src/yargs-types.ts index e3fe55c59f..bf1ef1fbb3 100644 --- a/packages/wrangler/src/yargs-types.ts +++ b/packages/wrangler/src/yargs-types.ts @@ -12,6 +12,7 @@ export interface CommonYargsOptions { "env-file": string[] | undefined; "experimental-provision": boolean | undefined; "experimental-auto-create": boolean; + "experimental-force-skills-install": boolean; } export type CommonYargsArgvSanitized

= OnlyCamelCase< diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d4de954d..279615c539 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4102,6 +4102,9 @@ importers: get-port: specifier: ^7.0.0 version: 7.0.0 + giget: + specifier: ^3.2.0 + version: 3.2.0 glob-to-regexp: specifier: ^0.4.1 version: 0.4.1 @@ -10988,6 +10991,10 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -22145,6 +22152,8 @@ snapshots: nypm: 0.6.5 pathe: 2.0.3 + giget@3.2.0: {} + github-from-package@0.0.0: optional: true