diff --git a/.changeset/skills-update-prompt.md b/.changeset/skills-update-prompt.md new file mode 100644 index 0000000000..a4cea9fdc0 --- /dev/null +++ b/.changeset/skills-update-prompt.md @@ -0,0 +1,11 @@ +--- +"wrangler": minor +--- + +Add automatic update prompts for out-of-date Cloudflare agent skills + +When Cloudflare skills were previously installed by Wrangler and the upstream `cloudflare/skills` repository has newer content, Wrangler now offers to update them after eligible commands complete. + +To reduce prompt fatigue, the update check only runs when at least 7 days have elapsed since the last install or update, and only when the upstream changes are significant enough (5+ files changed or 10KB+ size delta). Declining suppresses the prompt until the next upstream change. + +To opt out of update prompts entirely, set `"no_skills_update_prompts": true` in your `wrangler.jsonc` or set the `WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true` environment variable. The `--install-skills` flag remains available regardless of this setting. diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index a8ae511040..14f3158bc4 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -64,6 +64,21 @@ export interface ConfigFields { */ send_metrics: boolean | undefined; + /** + * Whether to suppress the prompt that offers to update Cloudflare agent + * skills when they are out of date. + * + * When `true`, Wrangler will not prompt the user to update skills even if + * newer versions are available upstream. The `--install-skills` flag still + * works regardless of this setting. + * + * Can also be controlled via the `WRANGLER_NO_SKILLS_UPDATE_PROMPTS` + * environment variable. + * + * @default false + */ + no_skills_update_prompts: boolean | undefined; + /** * Configuration for npm package dependency instrumentation. * @@ -338,6 +353,7 @@ export const defaultWranglerConfig: Config = { /* TOP-LEVEL ONLY FIELDS */ pages_build_output_dir: undefined, send_metrics: undefined, + no_skills_update_prompts: undefined, dependencies_instrumentation: undefined, dev: { ip: process.platform === "win32" ? "127.0.0.1" : "localhost", diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index dd271cd574..b1338f8e83 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -318,6 +318,14 @@ export function normalizeAndValidateConfig( "boolean" ); + validateOptionalProperty( + diagnostics, + "", + "no_skills_update_prompts", + rawConfig.no_skills_update_prompts, + "boolean" + ); + if ( validateOptionalProperty( diagnostics, @@ -526,6 +534,7 @@ export function normalizeAndValidateConfig( rawConfig.pages_build_output_dir ), send_metrics: rawConfig.send_metrics, + no_skills_update_prompts: rawConfig.no_skills_update_prompts, dependencies_instrumentation: rawConfig.dependencies_instrumentation, keep_vars: rawConfig.keep_vars, addresses: rawConfig.addresses, diff --git a/packages/workers-utils/src/environment-variables/factory.ts b/packages/workers-utils/src/environment-variables/factory.ts index db3a4ae5bf..61dd6d10f8 100644 --- a/packages/workers-utils/src/environment-variables/factory.ts +++ b/packages/workers-utils/src/environment-variables/factory.ts @@ -129,6 +129,8 @@ type VariableNames = | "WRANGLER_SEND_METRICS" /** Enable/disable error reporting to Sentry. */ | "WRANGLER_SEND_ERROR_REPORTS" + /** Suppress the prompt that offers to update Cloudflare agent skills. */ + | "WRANGLER_NO_SKILLS_UPDATE_PROMPTS" /** CI branch name (internal use). */ | "WORKERS_CI_BRANCH" /** CI tag matching configuration (internal use). */ diff --git a/packages/workers-utils/src/environment-variables/misc-variables.ts b/packages/workers-utils/src/environment-variables/misc-variables.ts index 2f0f4bcc8d..e815b9d400 100644 --- a/packages/workers-utils/src/environment-variables/misc-variables.ts +++ b/packages/workers-utils/src/environment-variables/misc-variables.ts @@ -45,6 +45,17 @@ export const getWranglerSendMetricsFromEnv = variableName: "WRANGLER_SEND_METRICS", }); +/** + * `WRANGLER_NO_SKILLS_UPDATE_PROMPTS` suppresses the prompt that offers to + * update Cloudflare agent skills when they are out of date. + * + * Set to `"true"` to never be prompted for skills updates. + */ +export const getNoSkillsUpdatePromptsFromEnv = + getBooleanEnvironmentVariableFactory({ + variableName: "WRANGLER_NO_SKILLS_UPDATE_PROMPTS", + }); + /** * `WRANGLER_SEND_ERROR_REPORTS` controls whether we attempt to send error reports to Sentry. * diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 76d2a5db34..6168e838f7 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -66,6 +66,7 @@ describe("normalizeAndValidateConfig()", () => { bindings: [], }, send_metrics: undefined, + no_skills_update_prompts: undefined, dependencies_instrumentation: undefined, main: undefined, migrations: [], diff --git a/packages/wrangler/src/__tests__/agents-skills-install.test.ts b/packages/wrangler/src/__tests__/agents-skills-install.test.ts index 9affca3a13..735f82e69b 100644 --- a/packages/wrangler/src/__tests__/agents-skills-install.test.ts +++ b/packages/wrangler/src/__tests__/agents-skills-install.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { skillInstallPromptMessageAfterWranglerCommandHandler, type runSkillsInstallFlow as RunFlowFnType, + type runSkillsUpdateFlow as UpdateFlowFnType, type telemetryCurrentAgentSkillsInstalled as TelemetryFnType, } from "../agents-skills-install"; import { sendMetricsEvent } from "../metrics/send-event"; @@ -110,6 +111,18 @@ async function freshImport(): Promise { return mod.runSkillsInstallFlow; } +/** + * Like {@link freshImport} but returns the update flow function instead. + * Each call resets the module graph so that tests start with a clean state. + * + * @returns The `runSkillsUpdateFlow` function from a fresh module import. + */ +async function freshUpdateImport(): Promise { + vi.resetModules(); + const mod = await import("../agents-skills-install"); + return mod.runSkillsUpdateFlow; +} + /** * Like {@link freshImport} but returns the telemetry function instead. * Each call resets the module graph (and hence the memoised promise) so @@ -810,6 +823,9 @@ describe("runSkillsInstallFlow with force-install prompt", () => { const metadata = readMetadataFile(); expect(metadata.accepted).toBe(true); expect(metadata.installFailed).toEqual(["cursor"]); + // installedTreeSha must be undefined so the update flow re-prompts + // for the failed agents instead of short-circuiting. + expect(metadata.installedTreeSha).toBeUndefined(); }); test("sets installFailed to false when all agents succeed", async ({ @@ -1025,8 +1041,9 @@ describe("runSkillsInstallFlow with custom prompt message", () => { * MSW handler that returns a fake GitHub Contents API response. * * @param skillNames The skill directory names to include in the mocked response. + * @param treeSha Optional tree SHA for the `skills/` directory root entry. */ -function mockGitHubSkillsApi(skillNames: string[]) { +function mockGitHubSkillsApi(skillNames: string[], treeSha?: string) { const entries = skillNames.map((name) => ({ name, type: "dir" })); msw.use( http.get( @@ -1034,7 +1051,17 @@ function mockGitHubSkillsApi(skillNames: string[]) { () => { return HttpResponse.json(entries); } - ) + ), + http.get("https://api.github.com/repos/cloudflare/skills/contents/", () => { + return HttpResponse.json([ + { + name: "skills", + type: "dir", + sha: treeSha ?? "default-tree-sha", + }, + { name: "README.md", type: "file", sha: "readme-sha" }, + ]); + }) ); } @@ -1050,7 +1077,10 @@ function mockGitHubSkillsApiError(status = 403) { () => { return new HttpResponse(null, { status }); } - ) + ), + http.get("https://api.github.com/repos/cloudflare/skills/contents/", () => { + return new HttpResponse(null, { status }); + }) ); } @@ -1062,10 +1092,106 @@ function mockGitHubSkillsApiNetworkError() { () => { return HttpResponse.error(); } + ), + http.get("https://api.github.com/repos/cloudflare/skills/contents/", () => { + return HttpResponse.error(); + }) + ); +} + +/** + * Shape of a fake tree entry used by {@link mockGitHubTreesApi}. + */ +interface FakeTreeEntry { + path: string; + sha: string; + size: number; +} + +/** + * Generates a list of fake tree entries for testing the significance check. + * + * @param count Number of blob entries to generate. + * @param prefix Path prefix for the entries (e.g. "skills/foo/"). + * @param sizePerFile Size in bytes for each fake file. + * @returns An array of fake tree entries. + */ +function generateFakeTreeEntries( + count: number, + prefix: string, + sizePerFile = 3000 +): FakeTreeEntry[] { + return Array.from({ length: count }, (_, i) => ({ + path: `${prefix}file-${i}.md`, + sha: `sha-${prefix}-${i}`, + size: sizePerFile, + })); +} + +/** + * MSW handler that mocks the GitHub Git Trees API for both an installed + * and a remote tree SHA. The handler matches requests by the SHA in the URL. + * + * @param installedSha Tree SHA representing the currently installed version. + * @param installedEntries Blob entries to return for the installed tree. + * @param remoteSha Tree SHA representing the upstream version. + * @param remoteEntries Blob entries to return for the remote tree. + */ +function mockGitHubTreesApi( + installedSha: string, + installedEntries: FakeTreeEntry[], + remoteSha: string, + remoteEntries: FakeTreeEntry[] +) { + msw.use( + http.get( + "https://api.github.com/repos/cloudflare/skills/git/trees/:sha", + ({ params }) => { + const sha = params.sha as string; + if (sha === installedSha) { + return HttpResponse.json({ + sha: installedSha, + tree: installedEntries.map((e) => ({ + ...e, + mode: "100644", + type: "blob", + })), + }); + } + if (sha === remoteSha) { + return HttpResponse.json({ + sha: remoteSha, + tree: remoteEntries.map((e) => ({ + ...e, + mode: "100644", + type: "blob", + })), + }); + } + return new HttpResponse(null, { status: 404 }); + } ) ); } +/** + * MSW handler that mocks the GitHub Git Trees API to return significant + * changes between two tree SHAs. Generates enough differing entries to + * exceed the significance thresholds. + * + * @param installedSha Tree SHA for the installed version. + * @param remoteSha Tree SHA for the upstream version. + */ +function mockGitHubTreesApiWithSignificantChanges( + installedSha: string, + remoteSha: string +) { + const shared = generateFakeTreeEntries(5, "shared/"); + const installedEntries = [...shared, ...generateFakeTreeEntries(3, "old/")]; + const remoteEntries = [...shared, ...generateFakeTreeEntries(6, "new/")]; + mockGitHubTreesApi(installedSha, installedEntries, remoteSha, remoteEntries); +} + describe("telemetryCurrentAgentSkillsInstalled", () => { runInTempDir(); mockConsoleMethods(); @@ -1659,3 +1785,708 @@ describe("telemetryCurrentAgentSkillsInstalled", () => { expect(await first).toBe(null); }); }); + +describe("runSkillsUpdateFlow", () => { + runInTempDir(); + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + beforeEach(() => { + setIsTTY(true); + mockRosieAgents.mockResolvedValue(DEFAULT_AGENTS); + mockRosieInstall.mockResolvedValue(DEFAULT_INSTALL_RESULT); + }); + + afterEach(() => { + clearDialogs(); + }); + + test("skips when no metadata file exists", async ({ expect }) => { + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips when metadata says user declined install", async ({ expect }) => { + writeMetadataFile({ + version: 1, + accepted: false, + date: "2025-01-01T00:00:00Z", + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips when metadata says install failed entirely", async ({ + expect, + }) => { + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + installFailed: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips when installedTreeSha matches remote SHA", async ({ expect }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "same-sha", + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "same-sha"); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips when user already declined this remote SHA", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + declinedTreeSha: "new-sha", + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("prompts and updates when upstream SHA is different", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({ command: "deploy" }); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + expect(std.out).toContain("Successfully updated Cloudflare skills"); + + const metadata = readMetadataFile(); + expect(metadata.installedTreeSha).toBe("new-sha"); + expect(metadata.declinedTreeSha).toBeUndefined(); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_update_completed", + expect.objectContaining({ + command: "deploy", + }), + {} + ); + }); + + test("records a freshly-fetched tree SHA instead of the cached one after a successful update", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + // The cached remote SHA is "cached-sha", but by the time rosieInstall + // completes upstream has advanced to "fresh-sha". + mockGitHubSkillsApi(["cloudflare", "wrangler"], "cached-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "cached-sha"); + + mockRosieInstall.mockImplementationOnce(async () => { + // Simulate upstream advancing while install runs: override the + // /contents/ handler so fetchSkillsTreeSha() returns the newer SHA. + msw.use( + http.get( + "https://api.github.com/repos/cloudflare/skills/contents/", + () => { + return HttpResponse.json([ + { name: "skills", type: "dir", sha: "fresh-sha" }, + { name: "README.md", type: "file", sha: "readme-sha" }, + ]); + } + ) + ); + return { failedAgents: [] }; + }); + + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + const metadata = readMetadataFile(); + expect(metadata.installedTreeSha).toBe("fresh-sha"); + }); + + test("writes declinedTreeSha before showing the confirm prompt so an interrupted prompt does not reappear", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + + // Intercept the prompts call to inspect the metadata file state at + // the moment the confirmation prompt is displayed to the user. + vi.mocked(prompts).mockImplementationOnce(() => { + const metadata = readMetadataFile(); + expect(metadata.declinedTreeSha).toBe("new-sha"); + return Promise.resolve({ value: true }); + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({ command: "deploy" }); + + // After the flow completes with acceptance, the final metadata should + // clear declinedTreeSha and record the new installedTreeSha. + const finalMetadata = readMetadataFile(); + expect(finalMetadata.installedTreeSha).toBe("new-sha"); + expect(finalMetadata.declinedTreeSha).toBeUndefined(); + }); + + test("records declinedTreeSha and shows opt-out hint when user declines", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: false, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({ command: "deploy" }); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + expect(std.out).toContain( + "Understood, skills will not be updated at this time." + ); + expect(std.out).toContain("no_skills_update_prompts"); + + const metadata = readMetadataFile(); + expect(metadata.declinedTreeSha).toBe("new-sha"); + expect(metadata.date).not.toBe("2025-01-01T00:00:00Z"); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_update_skipped", + expect.objectContaining({ + reason: "User declined", + command: "deploy", + }), + {} + ); + }); + + test("prompts again after upstream changes past a previously declined SHA", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + declinedTreeSha: "declined-sha", + installFailed: false, + }); + // Remote now has a newer SHA than the one that was declined + mockGitHubSkillsApi(["cloudflare", "wrangler"], "even-newer-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "even-newer-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + expect(std.out).toContain("Successfully updated Cloudflare skills"); + }); + + test("handles update failure gracefully", async ({ expect }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + mockRosieInstall.mockRejectedValue(new Error("Network failure")); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(std.warn).toContain("Failed to update Cloudflare skills"); + + const metadata = readMetadataFile(); + expect(metadata.installedTreeSha).toBe("old-sha"); + expect(new Date(metadata.date as string).getTime()).toBeGreaterThan( + new Date("2025-01-01T00:00:00Z").getTime() + ); + + expect(sendMetricsEvent).toHaveBeenCalledWith( + "skills_update_skipped", + expect.objectContaining({ + reason: "Failed to update skills", + }), + {} + ); + }); + + test("retries previously-failed agents on next update run", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + const cursorSkills = path.join(os.homedir(), ".cursor", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + // cursor's skills dir may not exist if the previous install failed + // before creating it — the update flow should still include it. + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + { + name: "Cursor", + rosie: { id: "cursor", globalPath: cursorSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: ["cursor"], + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + const installCall = mockRosieInstall.mock.calls[0]; + expect(installCall[1].agent).toContain("cursor"); + expect(installCall[1].agent).toContain("claude"); + + const metadata = readMetadataFile(); + expect(metadata.installedTreeSha).toBe("new-sha"); + expect(metadata.installFailed).toBe(false); + }); + + test("skips when no managed agent skills exist on disk", async ({ + expect, + }) => { + // Metadata says skills were installed for Claude, but no skills + // directory exists on disk. + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { + id: "claude", + globalPath: path.join(os.homedir(), ".claude", "skills"), + }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips when skills were installed less than 7 days ago", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + // Date is 3 days ago — within the 7-day cooldown window + const threeDaysAgo = new Date( + Date.now() - 3 * 24 * 60 * 60 * 1000 + ).toISOString(); + writeMetadataFile({ + version: 1, + accepted: true, + date: threeDaysAgo, + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + // Don't even need to mock the GitHub API — the cooldown guard + // should short-circuit before any network requests. + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("checks for updates when skills are older than 7 days", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + // Date is 10 days ago — past the 7-day cooldown + const tenDaysAgo = new Date( + Date.now() - 10 * 24 * 60 * 60 * 1000 + ).toISOString(); + writeMetadataFile({ + version: 1, + accepted: true, + date: tenDaysAgo, + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockGitHubTreesApiWithSignificantChanges("old-sha", "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + }); + + test("skips when upstream changes are below significance threshold", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + + // Both trees share the same entries except for 2 tiny file changes + // (below the 5-file and 10KB thresholds). + const shared = generateFakeTreeEntries(10, "shared/"); + const installedEntries = [ + ...shared, + { path: "skills/a.md", sha: "sha-a-old", size: 100 }, + { path: "skills/b.md", sha: "sha-b-old", size: 200 }, + ]; + const remoteEntries = [ + ...shared, + { path: "skills/a.md", sha: "sha-a-new", size: 110 }, + { path: "skills/b.md", sha: "sha-b-new", size: 210 }, + ]; + mockGitHubTreesApi("old-sha", installedEntries, "new-sha", remoteEntries); + + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + + const metadata = readMetadataFile(); + // the data is still saved so that we don't constantly keep repeating this check + expect(metadata.date).not.toBe("2025-01-01T00:00:00Z"); + }); + + test("prompts when upstream changes exceed significance threshold by file count", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + + // 5 new files added (meets the MIN_CHANGED_FILES_FOR_UPDATE threshold) + const shared = generateFakeTreeEntries(3, "shared/"); + const installedEntries = [...shared]; + const remoteEntries = [ + ...shared, + ...generateFakeTreeEntries(5, "added/", 100), + ]; + mockGitHubTreesApi("old-sha", installedEntries, "new-sha", remoteEntries); + + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + }); + + test("prompts when upstream changes exceed significance threshold by size delta", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + + // Only 2 files changed (below file-count threshold) but with a large + // size delta (>10KB, meets the MIN_SIZE_DELTA_FOR_UPDATE threshold). + const shared = generateFakeTreeEntries(5, "shared/"); + const installedEntries = [ + ...shared, + { path: "skills/big.md", sha: "sha-big-old", size: 1000 }, + { path: "skills/huge.md", sha: "sha-huge-old", size: 2000 }, + ]; + const remoteEntries = [ + ...shared, + { path: "skills/big.md", sha: "sha-big-new", size: 7000 }, + { path: "skills/huge.md", sha: "sha-huge-new", size: 8000 }, + ]; + mockGitHubTreesApi("old-sha", installedEntries, "new-sha", remoteEntries); + + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).toHaveBeenCalledOnce(); + }); + + test("skips prompt when installed tree fetch fails", async ({ expect }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + installedTreeSha: "old-sha", + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + + // Trees API returns 404 for both SHAs — areChangesSignificant + // can't determine significance so it bails and returns false, + // which skips the update prompt entirely. + msw.use( + http.get( + "https://api.github.com/repos/cloudflare/skills/git/trees/:sha", + () => { + return new HttpResponse(null, { status: 404 }); + } + ) + ); + + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + expect(mockRosieInstall).not.toHaveBeenCalled(); + }); + + test("skips significance check when installedTreeSha is missing", async ({ + expect, + }) => { + const claudeSkills = path.join(os.homedir(), ".claude", "skills"); + mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true }); + writeMetadataFile({ + version: 1, + accepted: true, + date: "2025-01-01T00:00:00Z", + detectedAgents: [ + { + name: "Claude Code", + rosie: { id: "claude", globalPath: claudeSkills }, + }, + ], + // No installedTreeSha — old metadata before SHA tracking existed + installFailed: false, + }); + mockGitHubSkillsApi(["cloudflare", "wrangler"], "new-sha"); + mockConfirm({ + text: "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + result: true, + }); + const runSkillsUpdateFlow = await freshUpdateImport(); + + await runSkillsUpdateFlow({}); + + // Should prompt without the significance check since there's no + // baseline SHA to compare against. + expect(mockRosieInstall).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts index 6757faf8cc..20715d0952 100644 --- a/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts +++ b/packages/wrangler/src/__tests__/register-yargs-command-skills.test.ts @@ -1,6 +1,9 @@ import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers"; import { beforeEach, describe, test, vi } from "vitest"; -import { runSkillsInstallFlow } from "../agents-skills-install"; +import { + runSkillsInstallFlow, + runSkillsUpdateFlow, +} from "../agents-skills-install"; import { mockConsoleMethods } from "./helpers/mock-console"; import { runWrangler } from "./helpers/run-wrangler"; @@ -122,4 +125,66 @@ describe("register-yargs-command skills integration", () => { expect(runSkillsInstallFlow).toHaveBeenCalled(); }); + + test("does not call runSkillsUpdateFlow when runSkillsInstallFlow just installed skills", async ({ + expect, + }) => { + // When the install flow returns true it means it just performed a + // fresh install, so the update flow should be skipped — the newly + // installed skills are already the latest version. + vi.mocked(runSkillsInstallFlow).mockResolvedValueOnce(true); + + await runWrangler("setup"); + + expect(runSkillsInstallFlow).toHaveBeenCalled(); + expect(runSkillsUpdateFlow).not.toHaveBeenCalled(); + }); + + test("calls runSkillsUpdateFlow when runSkillsInstallFlow did not install", async ({ + expect, + }) => { + // When the install flow returns false (skills already installed or + // user was not prompted), the update flow should run to check + // whether the existing skills are out of date. + vi.mocked(runSkillsInstallFlow).mockResolvedValueOnce(false); + + await runWrangler("setup"); + + expect(runSkillsInstallFlow).toHaveBeenCalled(); + expect(runSkillsUpdateFlow).toHaveBeenCalledWith( + expect.objectContaining({ + command: "setup", + }) + ); + }); + + test("does not call runSkillsUpdateFlow when no_skills_update_prompts is set in config", async ({ + expect, + }) => { + vi.mocked(runSkillsInstallFlow).mockResolvedValueOnce(false); + + await seed({ + "wrangler.jsonc": JSON.stringify({ + name: "test-worker", + no_skills_update_prompts: true, + }), + }); + + await runWrangler("setup"); + + expect(runSkillsInstallFlow).toHaveBeenCalled(); + expect(runSkillsUpdateFlow).not.toHaveBeenCalled(); + }); + + test("does not call runSkillsUpdateFlow when WRANGLER_NO_SKILLS_UPDATE_PROMPTS env var is set", async ({ + expect, + }) => { + vi.mocked(runSkillsInstallFlow).mockResolvedValueOnce(false); + vi.stubEnv("WRANGLER_NO_SKILLS_UPDATE_PROMPTS", "true"); + + await runWrangler("setup"); + + expect(runSkillsInstallFlow).toHaveBeenCalled(); + expect(runSkillsUpdateFlow).not.toHaveBeenCalled(); + }); }); diff --git a/packages/wrangler/src/__tests__/vitest.setup.ts b/packages/wrangler/src/__tests__/vitest.setup.ts index f62a9a4b5d..52176efef6 100644 --- a/packages/wrangler/src/__tests__/vitest.setup.ts +++ b/packages/wrangler/src/__tests__/vitest.setup.ts @@ -263,7 +263,8 @@ vi.mock("../metrics/metrics-config", async (importOriginal) => { vi.mock("../agents-skills-install", async (importOriginal) => { const realModule = await importOriginal(); - vi.spyOn(realModule, "runSkillsInstallFlow").mockResolvedValue(undefined); + vi.spyOn(realModule, "runSkillsInstallFlow").mockResolvedValue(false); + vi.spyOn(realModule, "runSkillsUpdateFlow").mockResolvedValue(undefined); vi.spyOn(realModule, "telemetryCurrentAgentSkillsInstalled").mockReturnValue( Promise.resolve(null) ); diff --git a/packages/wrangler/src/agents-skills-install.ts b/packages/wrangler/src/agents-skills-install.ts index 07302c44dd..02dbf115c5 100644 --- a/packages/wrangler/src/agents-skills-install.ts +++ b/packages/wrangler/src/agents-skills-install.ts @@ -56,10 +56,12 @@ type SkillsInstallFlowOptions = * caller-provided message, and performs the installation via rosie. * * @param options - Controls whether to force-install and what prompt to show. + * @returns `true` if skills were installed (or attempted to) during this call, + * `false` if the flow was skipped or the user declined. */ export async function runSkillsInstallFlow( options: SkillsInstallFlowOptions -): Promise { +): Promise { const { force, command } = options; const sendResultMetricsEvent = ( @@ -95,13 +97,13 @@ export async function runSkillsInstallFlow( const existingConfig = readSkillsInstallMetadataFile(); if (existingConfig !== undefined && !force) { // Note: no metrics event is sent in this case - return; + return false; } if (ci.isCI && !force) { // In CI environments, skip silently sendResultMetricsEvent({ skippedBecause: "Running in CI" }); - return; + return false; } let detectedAgents: AgentInfo[]; @@ -112,14 +114,14 @@ export async function runSkillsInstallFlow( skippedBecause: "Failed to install skills", errorMessage: err instanceof Error ? err.message : String(err), }); - return; + return false; } if (detectedAgents.length === 0) { sendResultMetricsEvent({ skippedBecause: "No supported agents detected", }); - return; + return false; } // In non-interactive terminals do nothing @@ -127,7 +129,7 @@ export async function runSkillsInstallFlow( sendResultMetricsEvent({ skippedBecause: "Non-interactive terminal", }); - return; + return false; } let accepted: boolean; @@ -161,7 +163,7 @@ export async function runSkillsInstallFlow( detectedAgents, }); sendResultMetricsEvent({ skippedBecause: "User declined" }); - return; + return false; } try { @@ -195,18 +197,25 @@ export async function runSkillsInstallFlow( logger.warn(SKILLS_INSTALL_RETRY_HINT); } + const freshTreeSha = await fetchSkillsTreeSha(); + writeSkillsInstallMetadataFile({ version: 1, accepted: true, date: new Date().toISOString(), detectedAgents, installFailed: failedAgents.length > 0 ? failedAgents : false, + // Only mark this revision as installed when every agent succeeded. + // If some agents failed, leave `installedTreeSha` undefined so the + // update flow re-prompts them on the next run instead of treating + // them as up to date. + installedTreeSha: failedAgents.length > 0 ? undefined : freshTreeSha, }); sendResultMetricsEvent({ targetedAgents: succeededAgents, }); - return; + return true; } catch (err) { logger.warn( `Failed to install Cloudflare skills: ${err instanceof Error ? err.message : String(err)}` @@ -224,6 +233,7 @@ export async function runSkillsInstallFlow( sendResultMetricsEvent({ skippedBecause: "Failed to install skills", }); + return true; } } @@ -296,6 +306,18 @@ interface SkillsInstallMetadata { * attempted (user declined or prompt was skipped). */ installFailed?: boolean | string[]; + /** + * Git tree SHA of the `skills/` directory in `cloudflare/skills` at the + * time of the last successful Wrangler-managed install or update. Used to + * detect when upstream content has changed. + */ + installedTreeSha?: string; + /** + * Git tree SHA of the `skills/` directory that the user declined to update + * to. When set, Wrangler will not prompt again until the upstream SHA + * changes to a different value. + */ + declinedTreeSha?: string; } /** Jsonc metadata file created when Cloudflare agent skills are installed */ @@ -305,6 +327,10 @@ const SKILLS_INSTALL_METADATA_FILENAME = "agents-skills-install.jsonc"; const SKILLS_REPO_CONTENTS_URL = "https://api.github.com/repos/cloudflare/skills/contents/skills"; +/** GitHub Contents API URL for the repo root, used to retrieve the tree SHA of the `skills/` directory. */ +const SKILLS_REPO_ROOT_CONTENTS_URL = + "https://api.github.com/repos/cloudflare/skills/contents/"; + /** Cache filename for skill directory names fetched from the GitHub API. */ const SKILLS_REPO_CACHE_FILENAME = "cloudflare-skills-repo-cache.json"; @@ -419,6 +445,22 @@ interface SkillsRepoCache { lastUpdate: number; /** Skill directory names from the `cloudflare/skills` repository. */ skillNames: string[]; + /** + * Git tree SHA of the `skills/` directory in the `cloudflare/skills` + * repository, fetched from the GitHub Trees API. Used to detect upstream + * content changes for the skills-update flow. + */ + skillsTreeSha?: string; +} + +/** + * Result of reading the skills repo cache, including optional tree SHA. + */ +interface SkillsRepoCacheResult { + /** Skill directory names from the repository. */ + skillNames: string[]; + /** Git tree SHA of the `skills/` directory, if available. */ + skillsTreeSha?: string; } /** @@ -426,9 +468,11 @@ interface SkillsRepoCache { * Falls back to stale data when {@link allowStale} is true (e.g. after a failed network request). * * @param allowStale When `true`, returns cached data even if the TTL has expired. - * @returns Array of cached skill names, or `undefined` on cache miss. + * @returns Cached skill names and tree SHA, or `undefined` on cache miss. */ -function readSkillsRepoCache(allowStale = false): string[] | undefined { +function readSkillsRepoCache( + allowStale = false +): SkillsRepoCacheResult | undefined { try { const raw = readFileSync(getSkillsRepoCachePath(), "utf8"); const cache = JSON.parse(raw) as SkillsRepoCache; @@ -436,7 +480,10 @@ function readSkillsRepoCache(allowStale = false): string[] | undefined { allowStale || cache.lastUpdate + SKILLS_REPO_CACHE_TTL_MS > Date.now() ) { - return cache.skillNames; + return { + skillNames: cache.skillNames, + skillsTreeSha: cache.skillsTreeSha, + }; } } catch { // Cache file missing, corrupt, or unreadable — treat as cache miss. @@ -445,17 +492,22 @@ function readSkillsRepoCache(allowStale = false): string[] | undefined { } /** - * Writes skill names to the disk cache with the current timestamp. + * Writes skill names and optional tree SHA to the disk cache with the current timestamp. * * @param skillNames The skill directory names to persist. + * @param skillsTreeSha Optional git tree SHA for the `skills/` directory. */ -function writeSkillsRepoCache(skillNames: string[]): void { +function writeSkillsRepoCache( + skillNames: string[], + skillsTreeSha?: string +): void { try { const cachePath = getSkillsRepoCachePath(); mkdirSync(path.dirname(cachePath), { recursive: true }); const data: SkillsRepoCache = { lastUpdate: Date.now(), skillNames, + ...(skillsTreeSha ? { skillsTreeSha } : {}), }; writeFileSync(cachePath, JSON.stringify(data)); } catch { @@ -464,13 +516,23 @@ function writeSkillsRepoCache(skillNames: string[]): void { } /** - * Fetches the list of skill directory names from the `cloudflare/skills` GitHub - * repository using the GitHub Contents API. Results are cached to disk for 24 hours - * to avoid hitting API rate limits. + * Standard HTTP headers sent with every GitHub API request. + */ +const GITHUB_API_HEADERS = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "cloudflare-wrangler", +} as const; + +/** + * Fetches the list of skill directory names (and the `skills/` tree SHA) from + * the `cloudflare/skills` GitHub repository using the GitHub Contents API. + * Results are cached to disk for 24 hours to avoid hitting API rate limits. * - * @returns Array of skill directory names, or `undefined` if both fetch and cache fail. + * @returns Skill names and optional tree SHA, or `undefined` if both fetch and cache fail. */ -async function fetchSkillNamesFromGitHub(): Promise { +async function fetchSkillNamesFromGitHub(): Promise< + SkillsRepoCacheResult | undefined +> { // Return fresh cached data if available. const cached = readSkillsRepoCache(); if (cached) { @@ -479,10 +541,7 @@ async function fetchSkillNamesFromGitHub(): Promise { try { const res = await fetch(SKILLS_REPO_CONTENTS_URL, { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "cloudflare-wrangler", - }, + headers: GITHUB_API_HEADERS, }); if (!res.ok) { // API error (rate limited, 404, etc.) — fall back to stale cache. @@ -496,14 +555,47 @@ async function fetchSkillNamesFromGitHub(): Promise { .filter((e) => e.type === "dir") .map((e) => e.name); - writeSkillsRepoCache(skillNames); - return skillNames; + // Fetch the tree SHA of the `skills/` directory from the repo root. + const treeSha = await fetchSkillsTreeSha(); + + writeSkillsRepoCache(skillNames, treeSha); + return { skillNames, skillsTreeSha: treeSha }; } catch { // Network failure — fall back to stale cache. return readSkillsRepoCache(true); } } +/** + * Fetches the git tree SHA of the `skills/` directory from the root of the + * `cloudflare/skills` repository. This single SHA changes whenever any file + * inside `skills/` is modified, added, or removed, making it a lightweight + * change-detection signal. + * + * @returns The tree SHA string, or `undefined` on failure. + */ +async function fetchSkillsTreeSha(): Promise { + try { + const res = await fetch(SKILLS_REPO_ROOT_CONTENTS_URL, { + headers: GITHUB_API_HEADERS, + }); + if (!res.ok) { + return undefined; + } + const entries = (await res.json()) as Array<{ + name: string; + type: string; + sha: string; + }>; + const skillsEntry = entries.find( + (e) => e.name === "skills" && e.type === "dir" + ); + return skillsEntry?.sha; + } catch { + return undefined; + } +} + /** * Result of checking whether the current AI agent has Cloudflare skills installed. * @@ -697,10 +789,11 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise { }, })); } + +/** Actionable hint shown after declining a skills update. */ +const SKILLS_UPDATE_OPT_OUT_HINT = + 'If you don\'t want to be prompted again, set "no_skills_update_prompts": true in your wrangler.jsonc or set the WRANGLER_NO_SKILLS_UPDATE_PROMPTS=true environment variable.'; + +/** + * Options for {@link runSkillsUpdateFlow}. + */ +type SkillsUpdateFlowOptions = { + /** + * The wrangler command that triggered the update flow, for telemetry. + */ + command?: string; +}; + +/** + * Minimum time (7 days in ms) that must have elapsed since the last + * skills install or update before we check for upstream changes again. + * This prevents pestering users who just installed/updated recently. + */ +const SKILLS_UPDATE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Checks whether Wrangler-installed skills are out of date and, if so, prompts + * the user to update them. Only runs when: + * + * 1. Skills were previously installed by Wrangler (metadata `accepted === true` + * with no full failure). + * 2. At least {@link SKILLS_UPDATE_COOLDOWN_MS} has elapsed since the last + * install or update (avoids pestering users who just updated). + * 3. The upstream `skills/` tree SHA differs from the recorded + * `installedTreeSha` (i.e. content has actually changed). + * 4. The upstream changes are significant enough (measured by file count and + * size delta) to warrant an interruption. + * 5. The user has not already declined this specific upstream revision + * (`declinedTreeSha`). + * + * Callers are responsible for checking config / env-var opt-outs and simply + * not calling this function when the user has opted out. + * + * @param options - Controls telemetry context. + */ +export async function runSkillsUpdateFlow( + options: SkillsUpdateFlowOptions +): Promise { + const { command } = options; + + const metadata = readSkillsInstallMetadataFile(); + if (!metadata) { + return; + } + if (metadata.accepted !== true) { + return; + } + if (metadata.installFailed === true) { + return; + } + + if (ci.isCI) { + return; + } + if (!isInteractive()) { + return; + } + + if (metadata.date) { + const lastModified = new Date(metadata.date).getTime(); + if ( + !Number.isNaN(lastModified) && + Date.now() - lastModified < SKILLS_UPDATE_COOLDOWN_MS + ) { + logger.debug( + "Skills were recently installed/updated; skipping update check." + ); + return; + } + } + + const cacheResult = await fetchSkillNamesFromGitHub(); + const remoteTreeSha = cacheResult?.skillsTreeSha; + + if (!remoteTreeSha) { + logger.debug("Skills update check: could not determine remote tree SHA."); + return; + } + + if ( + metadata.installedTreeSha && + metadata.installedTreeSha === remoteTreeSha + ) { + logger.debug("Skills are up to date."); + return; + } + + if (metadata.declinedTreeSha === remoteTreeSha) { + logger.debug("User already declined update for this upstream revision."); + return; + } + + if (metadata.installedTreeSha) { + const significant = await areChangesSignificant( + metadata.installedTreeSha, + remoteTreeSha + ); + if (!significant) { + logger.debug( + "Upstream skills changes are below the significance threshold; skipping update prompt." + ); + writeSkillsInstallMetadataFile({ + ...metadata, + date: new Date().toISOString(), + }); + return; + } + } + + const skillNames = cacheResult.skillNames; + if (!skillNames || skillNames.length === 0) { + return; + } + + const wranglerManagedAgents = metadata.detectedAgents ?? []; + const managedAgentsWithSkills = wranglerManagedAgents.filter((agent) => { + if ( + Array.isArray(metadata.installFailed) && + metadata.installFailed.includes(agent.rosie.id) + ) { + return true; + } + return directoryContainsAnySkill( + path.resolve(agent.rosie.globalPath), + skillNames + ); + }); + + if (managedAgentsWithSkills.length === 0) { + return; + } + + // --- Prompt --- + // Persist the current remote SHA as "declined" *before* showing the prompt + // so that if the user interrupts the process (Ctrl+C, terminal closed, etc.) + // the prompt won't reappear for this upstream revision. An explicit accept or + // decline will overwrite this marker with the correct final state. + writeSkillsInstallMetadataFile({ + ...metadata, + date: new Date().toISOString(), + declinedTreeSha: remoteTreeSha, + }); + + logger.log(); + const accepted = await confirm( + "It looks like your Cloudflare skills are out of date. Would you like Wrangler to update them for you?", + { + defaultValue: true, + fallbackValue: false, + } + ); + + if (!accepted) { + logger.log("\nUnderstood, skills will not be updated at this time."); + logger.log(SKILLS_UPDATE_OPT_OUT_HINT); + + writeSkillsInstallMetadataFile({ + ...metadata, + date: new Date().toISOString(), + declinedTreeSha: remoteTreeSha, + }); + + sendMetricsEvent( + "skills_update_skipped", + { + reason: "User declined", + ...(command ? { command } : {}), + }, + {} + ); + return; + } + + try { + const agentNames = managedAgentsWithSkills.map((a) => a.rosie.id); + const { failedAgents } = await rosieInstall(SKILLS_REPO, { + global: true, + agent: agentNames, + lockfile: false, + onLog: ({ message }) => logger.debug(message), + }); + + const failedSet = new Set(failedAgents); + const succeededAgents = managedAgentsWithSkills.filter( + (a) => !failedSet.has(a.rosie.id) + ); + + if (succeededAgents.length > 0) { + logger.log( + `\n🚀 Successfully updated Cloudflare skills for: ${succeededAgents.map(({ name }) => name).join(", ")}.\n` + ); + } + + if (failedAgents.length > 0) { + logger.log(); + logger.warn( + `Skills update failed for agents: ${failedAgents.join(", ")}.` + ); + logger.warn(SKILLS_INSTALL_RETRY_HINT); + } + + const freshTreeSha = await fetchSkillsTreeSha(); + + writeSkillsInstallMetadataFile({ + ...metadata, + date: new Date().toISOString(), + // Only mark this revision as installed when every agent succeeded. + // If some agents failed, keep the previous `installedTreeSha` so the + // update flow re-prompts them on the next run instead of treating + // them as up to date. + installedTreeSha: + failedAgents.length > 0 + ? metadata.installedTreeSha + : (freshTreeSha ?? remoteTreeSha), + declinedTreeSha: undefined, + installFailed: failedAgents.length > 0 ? failedAgents : false, + }); + + sendMetricsEvent( + "skills_update_completed", + { + agents: succeededAgents, + ...(command ? { command } : {}), + }, + {} + ); + } catch (err) { + logger.warn( + `Failed to update Cloudflare skills: ${err instanceof Error ? err.message : String(err)}` + ); + logger.warn(SKILLS_INSTALL_RETRY_HINT); + + writeSkillsInstallMetadataFile({ + ...metadata, + date: new Date().toISOString(), + }); + + sendMetricsEvent( + "skills_update_skipped", + { + reason: "Failed to update skills", + errorMessage: err instanceof Error ? err.message : String(err), + ...(command ? { command } : {}), + }, + {} + ); + } +} + +/** Shape of a single blob entry from the GitHub Git Trees API. */ +interface TreeBlobEntry { + sha: string; + size: number; +} + +/** + * Fetches the full recursive tree for a given tree SHA from the + * `cloudflare/skills` repository using the GitHub Git Trees API. + * + * Returns a map of `path -> { sha, size }` for blob entries only (files, + * not sub-trees), which is used to diff two revisions of the `skills/` + * directory. + * + * @param treeSha - The git tree SHA to fetch (e.g. the `skills/` directory SHA). + * @returns A map of blob entries keyed by path, or `undefined` on failure. + */ +async function fetchSkillsTreeEntries( + treeSha: string +): Promise | undefined> { + try { + const url = `https://api.github.com/repos/cloudflare/skills/git/trees/${treeSha}?recursive=1`; + const res = await fetch(url, { headers: GITHUB_API_HEADERS }); + if (!res.ok) { + return undefined; + } + const body = (await res.json()) as { + tree: Array<{ + path: string; + type: string; + sha: string; + size?: number; + }>; + }; + const entries = new Map(); + for (const entry of body.tree) { + if (entry.type === "blob") { + entries.set(entry.path, { sha: entry.sha, size: entry.size ?? 0 }); + } + } + return entries; + } catch { + return undefined; + } +} + +/** + * Minimum number of changed files (added + removed + modified) in the + * upstream `skills/` tree before the user is prompted to update. Changes + * below this threshold are considered too minor to warrant an interruption. + */ +const MIN_CHANGED_FILES_FOR_UPDATE = 5; + +/** + * Minimum total size delta (in bytes) of changed blobs in the upstream + * `skills/` tree before the user is prompted to update. Allows large + * single-file rewrites to trigger an update even if fewer than + * {@link MIN_CHANGED_FILES_FOR_UPDATE} files changed. + */ +const MIN_SIZE_DELTA_FOR_UPDATE = 10 * 1024; // 10 KB + +/** + * Determines whether the changes between two revisions of the `skills/` + * tree are significant enough to warrant prompting the user to update. + * + * Fetches the full recursive tree for both the installed and remote SHAs, + * then counts the number of changed files (added, removed, or modified) + * and the total size delta. The changes are considered significant when + * either the file count or size delta exceeds the configured thresholds + * ({@link MIN_CHANGED_FILES_FOR_UPDATE} / {@link MIN_SIZE_DELTA_FOR_UPDATE}). + * + * If either tree cannot be fetched the result is indeterminate, so returns + * `false` to avoid pestering the user when we can't reliably compare. + * + * @param installedTreeSha - Tree SHA of the currently installed `skills/` revision. + * @param remoteTreeSha - Tree SHA of the upstream `skills/` revision. + * @returns `true` if the changes are significant, `false` otherwise (including when indeterminate). + */ +async function areChangesSignificant( + installedTreeSha: string, + remoteTreeSha: string +): Promise { + const [installedTree, remoteTree] = await Promise.all([ + fetchSkillsTreeEntries(installedTreeSha), + fetchSkillsTreeEntries(remoteTreeSha), + ]); + + if (!installedTree || !remoteTree) { + // If one of the fetches failed we can't determine significance — bail + // and skip the update prompt rather than pestering the user. + return false; + } + + let changedFiles = 0; + let sizeDelta = 0; + + for (const [filePath, remote] of remoteTree) { + const installed = installedTree.get(filePath); + if (!installed) { + changedFiles++; + sizeDelta += remote.size; + } else if (installed.sha !== remote.sha) { + changedFiles++; + sizeDelta += Math.abs(remote.size - installed.size); + } + } + + for (const [filePath, installed] of installedTree) { + if (!remoteTree.has(filePath)) { + changedFiles++; + sizeDelta += installed.size; + } + } + + return ( + changedFiles >= MIN_CHANGED_FILES_FOR_UPDATE || + sizeDelta >= MIN_SIZE_DELTA_FOR_UPDATE + ); +} diff --git a/packages/wrangler/src/core/register-yargs-command.ts b/packages/wrangler/src/core/register-yargs-command.ts index 51706fdb36..9f3e97e450 100644 --- a/packages/wrangler/src/core/register-yargs-command.ts +++ b/packages/wrangler/src/core/register-yargs-command.ts @@ -5,6 +5,7 @@ import { defaultWranglerConfig, FatalError, getCloudflareEnv, + getNoSkillsUpdatePromptsFromEnv, getWranglerHideBanner, experimental_readRawConfig, UserError, @@ -13,6 +14,7 @@ import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { runSkillsInstallFlow, + runSkillsUpdateFlow, skillInstallPromptMessageAfterWranglerCommandHandler, } from "../agents-skills-install"; import { @@ -359,12 +361,26 @@ function createHandler(def: InternalCommandDefinition, argv: string[]) { if (suggestSkillsEnabled) { try { - await runSkillsInstallFlow({ + const justInstalled = await runSkillsInstallFlow({ force: false, command: sanitizedCommand, promptMessage: skillInstallPromptMessageAfterWranglerCommandHandler, }); + + // Only check for updates when the install flow did not + // just perform a fresh install — a brand-new install + // already has the latest content — and the user has + // not opted out via config or environment variable. + if ( + !justInstalled && + config.no_skills_update_prompts !== true && + getNoSkillsUpdatePromptsFromEnv() !== true + ) { + await runSkillsUpdateFlow({ + command: sanitizedCommand, + }); + } } catch (skillsErr) { logger.debug( `Skills suggestion failed: ${skillsErr instanceof Error ? skillsErr.message : skillsErr}` diff --git a/packages/wrangler/src/metrics/send-event.ts b/packages/wrangler/src/metrics/send-event.ts index 50a9f5acd7..44a47542a7 100644 --- a/packages/wrangler/src/metrics/send-event.ts +++ b/packages/wrangler/src/metrics/send-event.ts @@ -85,7 +85,11 @@ type AutoConfigEvent = | "autoconfig_configuration_completed"; /** Event related to the agent skills install flow */ -type SkillsInstallEvent = "skills_install_skipped" | "skills_install_completed"; +type SkillsInstallEvent = + | "skills_install_skipped" + | "skills_install_completed" + | "skills_update_skipped" + | "skills_update_completed"; /** * Send a metrics event, with no extra properties, to Cloudflare, if usage tracking is enabled.