Skip to content

Commit f387256

Browse files
Make Ctrl+C triggered during the skills-install prompt dismiss it permanently (#14172)
1 parent b205fb7 commit f387256

3 files changed

Lines changed: 201 additions & 11 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Make Ctrl+C triggered during the skills-install prompt dismiss it permanently
6+
7+
Previously, pressing Ctrl+C (SIGINT) during the "Would you like to install Cloudflare skills?" prompt terminated the process without writing the metadata file, causing the prompt to reappear on every subsequent `wrangler` invocation. A SIGINT handler is now registered around the prompt so that the metadata file is written with `accepted: "SIGINT"` before the process exits, preventing the prompt from being shown again.

packages/wrangler/src/__tests__/agents-skills-install.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { runInTempDir } from "@cloudflare/workers-utils/test-helpers";
66
import { detectAgenticEnvironment } from "am-i-vibing";
77
import ci from "ci-info";
88
import { http, HttpResponse } from "msw";
9+
import prompts from "prompts";
910
import { afterEach, beforeEach, describe, test, vi } from "vitest";
1011
import { sendMetricsEvent } from "../metrics/send-event";
1112
import { mockConsoleMethods } from "./helpers/mock-console";
@@ -17,6 +18,7 @@ import type {
1718
telemetryCurrentAgentSkillsInstalled as TelemetryFnType,
1819
} from "../agents-skills-install";
1920
import type * as SendEventModule from "../metrics/send-event";
21+
import type { Mock } from "vitest";
2022

2123
// Undo the global no-op mock from vitest.setup.ts so we test the real implementation
2224
vi.unmock("../agents-skills-install");
@@ -193,6 +195,23 @@ describe("maybeInstallCloudflareSkillsGlobally", () => {
193195
]);
194196
});
195197

198+
test("skips silently when metadata file has accepted: 'SIGINT' (Ctrl+C dismissal)", async ({
199+
expect,
200+
}) => {
201+
writeMetadataFile({
202+
version: 1,
203+
accepted: "SIGINT",
204+
date: "2025-01-01T00:00:00Z",
205+
});
206+
const maybeInstallCloudflareSkillsGlobally = await freshImport();
207+
208+
await maybeInstallCloudflareSkillsGlobally(false);
209+
210+
expect(mockRosieAgents).not.toHaveBeenCalled();
211+
expect(mockRosieInstall).not.toHaveBeenCalled();
212+
expect(sendMetricsEvent).not.toHaveBeenCalled();
213+
});
214+
196215
test("force=true ignores existing metadata file", async ({ expect }) => {
197216
writeMetadataFile({ accepted: true, date: "2025-01-01T00:00:00Z" });
198217
const maybeInstallCloudflareSkillsGlobally = await freshImport();
@@ -412,6 +431,62 @@ describe("maybeInstallCloudflareSkillsGlobally", () => {
412431
);
413432
});
414433

434+
test("writes SIGINT metadata when user presses Ctrl+C during the prompt", async ({
435+
expect,
436+
}) => {
437+
// Stub process.exit so the abort flow doesn't terminate the test runner.
438+
const exitSpy = vi
439+
.spyOn(process, "exit")
440+
.mockImplementation((() => {}) as never);
441+
442+
// Simulate Ctrl+C: invoke the onState callback with
443+
// { aborted: true }, then resolve with { value: undefined }
444+
// just as the real prompts library does on abort.
445+
(prompts as unknown as Mock).mockImplementationOnce(
446+
({ type, name, message, onState }) => {
447+
expect({ type, name }).toStrictEqual({
448+
type: "confirm",
449+
name: "value",
450+
});
451+
expect(message).toContain("Claude Code");
452+
453+
// Trigger the abort handler (simulates Ctrl+C)
454+
onState({ aborted: true });
455+
456+
return Promise.resolve({ value: undefined });
457+
}
458+
);
459+
const maybeInstallCloudflareSkillsGlobally = await freshImport();
460+
461+
await maybeInstallCloudflareSkillsGlobally(false);
462+
463+
// Should have warned the user that Ctrl+C was treated as a decline
464+
expect(std.warn).toContain(
465+
"Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again."
466+
);
467+
468+
// The onState abort handler should have written metadata
469+
// with accepted: "SIGINT"
470+
const metadata = readMetadataFile();
471+
expect(metadata.accepted).toBe("SIGINT");
472+
expect(metadata.version).toBe(1);
473+
474+
// Should not have attempted installation
475+
expect(mockRosieInstall).not.toHaveBeenCalled();
476+
477+
// Should have sent a skipped metrics event
478+
expect(sendMetricsEvent).toHaveBeenCalledWith(
479+
"skills_install_skipped",
480+
{ reason: "User dismissed (SIGINT)" },
481+
{}
482+
);
483+
484+
// Should have called process.exit(1) after flushing metrics
485+
expect(exitSpy).toHaveBeenCalledWith(1);
486+
487+
exitSpy.mockRestore();
488+
});
489+
415490
test("force=true installs skills without prompting", async ({ expect }) => {
416491
// No mockConfirm — if a prompt fires, the test will fail with "Unexpected call to prompts"
417492
const maybeInstallCloudflareSkillsGlobally = await freshImport();
@@ -922,6 +997,69 @@ describe("telemetryCurrentAgentSkillsInstalled", () => {
922997
expect(result).toBe("manual");
923998
});
924999

1000+
test("resolves to 'manual' when metadata has accepted: 'SIGINT' at primary path", async ({
1001+
expect,
1002+
}) => {
1003+
vi.mocked(detectAgenticEnvironment).mockReturnValue({
1004+
isAgentic: true,
1005+
id: "claude-code",
1006+
name: "Claude Code",
1007+
type: "agent",
1008+
});
1009+
createAgentDir(".claude");
1010+
const claudeSkills = path.join(os.homedir(), ".claude", "skills");
1011+
mkdirSync(path.join(claudeSkills, "cloudflare"), { recursive: true });
1012+
const claudeGlobalSkillsPath = path.join(os.homedir(), ".claude", "skills");
1013+
writeMetadataFile({
1014+
version: 1,
1015+
accepted: "SIGINT",
1016+
date: new Date().toISOString(),
1017+
detectedAgents: [
1018+
{
1019+
name: "Claude Code",
1020+
rosie: { id: "claude", globalPath: claudeGlobalSkillsPath },
1021+
},
1022+
],
1023+
});
1024+
mockGitHubSkillsApi(["cloudflare", "wrangler"]);
1025+
const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport();
1026+
1027+
const result = await telemetryCurrentAgentSkillsInstalled();
1028+
1029+
expect(result).toBe("manual");
1030+
});
1031+
1032+
test("resolves to 'manual' when metadata has accepted: 'SIGINT' at alternativeGlobalPath", async ({
1033+
expect,
1034+
}) => {
1035+
vi.mocked(detectAgenticEnvironment).mockReturnValue({
1036+
isAgentic: true,
1037+
id: "opencode",
1038+
name: "OpenCode",
1039+
type: "agent",
1040+
});
1041+
createAgentDir(".config/opencode");
1042+
const agentsSkills = path.join(os.homedir(), ".agents", "skills");
1043+
mkdirSync(path.join(agentsSkills, "cloudflare"), { recursive: true });
1044+
writeMetadataFile({
1045+
version: 1,
1046+
accepted: "SIGINT",
1047+
date: new Date().toISOString(),
1048+
detectedAgents: [
1049+
{
1050+
name: "Cline, Dexto, Warp",
1051+
rosie: { id: "warp", globalPath: agentsSkills },
1052+
},
1053+
],
1054+
});
1055+
mockGitHubSkillsApi(["cloudflare", "wrangler"]);
1056+
const telemetryCurrentAgentSkillsInstalled = await freshTelemetryImport();
1057+
1058+
const result = await telemetryCurrentAgentSkillsInstalled();
1059+
1060+
expect(result).toBe("manual");
1061+
});
1062+
9251063
test("uses cached GitHub API response within TTL", async ({ expect }) => {
9261064
vi.mocked(detectAgenticEnvironment).mockReturnValue({
9271065
isAgentic: true,

packages/wrangler/src/agents-skills-install.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ import {
77
} from "@cloudflare/workers-utils";
88
import { detectAgenticEnvironment } from "am-i-vibing";
99
import ci from "ci-info";
10+
import prompts from "prompts";
1011
import { install as rosieInstall, agents as rosieAgents } from "rosie-skills";
1112
import { fetch } from "undici";
12-
import { confirm } from "./dialogs";
1313
import isInteractive from "./is-interactive";
1414
import { logger } from "./logger";
1515
import { sendMetricsEvent } from "./metrics";
16+
import { allMetricsDispatchesCompleted } from "./metrics/metrics-dispatcher";
1617

1718
/**
1819
* Detects AI coding agents installed on the user's machine and, if
@@ -95,12 +96,48 @@ export async function maybeInstallCloudflareSkillsGlobally(
9596
return;
9697
}
9798

98-
const accepted =
99-
force ||
100-
(await confirm(
101-
`Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`,
102-
{ defaultValue: true, fallbackValue: false }
103-
));
99+
let accepted: boolean;
100+
let sigintReceived = false;
101+
if (force) {
102+
accepted = true;
103+
} else {
104+
// Use prompts directly (instead of the shared `confirm()` helper) so
105+
// we can intercept the abort (Ctrl+C) and write SIGINT metadata
106+
// before the process exits. The prompts library's readline interface
107+
// swallows SIGINT — it never reaches `process.on("SIGINT")` — so this
108+
// `onState` callback is the only reliable place to handle it.
109+
const { value } = await prompts({
110+
type: "confirm",
111+
name: "value",
112+
message: `Wrangler detected the following AI coding agents: ${detectedAgents.map(({ name }) => name).join(", ")}. Would you like to install Cloudflare skills for them?`,
113+
initial: true,
114+
onState: (state) => {
115+
if (state.aborted) {
116+
sigintReceived = true;
117+
logger.warn(
118+
"Ctrl+C received — skipping Cloudflare skills installation. This prompt will not be shown again."
119+
);
120+
// Write metadata synchronously so it survives the exit.
121+
writeSkillsInstallMetadataFile({
122+
version: 1,
123+
accepted: "SIGINT",
124+
date: new Date().toISOString(),
125+
detectedAgents,
126+
});
127+
}
128+
},
129+
});
130+
accepted = value;
131+
}
132+
133+
if (sigintReceived) {
134+
// Metadata was already written in the onState callback.
135+
// Send metrics and wait for the dispatch to complete before exiting.
136+
sendResultMetricsEvent({ skippedBecause: "User dismissed (SIGINT)" });
137+
await allMetricsDispatchesCompleted();
138+
// Note: the return is unnecessary but it guards against tests that stub process.exit
139+
return process.exit(1);
140+
}
104141

105142
if (!accepted) {
106143
writeSkillsInstallMetadataFile({
@@ -200,8 +237,16 @@ type AgentInfo = {
200237
interface SkillsInstallMetadata {
201238
/** Schema version for forward-compatibility. Currently always `1`. */
202239
version: 1;
203-
/** Whether the user accepted the prompt to install skills. */
204-
accepted: boolean;
240+
/**
241+
* Whether the user accepted the prompt to install skills.
242+
*
243+
* - `true` — the user explicitly accepted.
244+
* - `false` — the user explicitly declined.
245+
* - `"SIGINT"` — the user dismissed the prompt via Ctrl+C / SIGINT before
246+
* answering. Treated as a decline but stored separately so we can
247+
* distinguish these users in telemetry.
248+
*/
249+
accepted: boolean | "SIGINT";
205250
/** ISO date string of when the user was prompted. */
206251
date: string;
207252
/** All agents detected on the user's machine. */
@@ -653,7 +698,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise<AgentSkill
653698
// happens to match this alternative path (e.g. OpenCode reads
654699
// ~/.agents/skills, which is Warp's rosie install target).
655700
const metadata = readSkillsInstallMetadataFile();
656-
if (metadata?.accepted) {
701+
if (metadata?.accepted === true) {
657702
const altAbsPath = path.resolve(matchedAlternativePath);
658703
const wasInstalledForAnotherAgent = metadata.detectedAgents?.some(
659704
(agent) => {
@@ -700,7 +745,7 @@ async function computeTelemetryCurrentAgentSkillsInstalled(): Promise<AgentSkill
700745
));
701746

702747
if (
703-
metadata.accepted &&
748+
metadata.accepted === true &&
704749
isInDetectedAgents === true &&
705750
!installFailedForAgent
706751
) {

0 commit comments

Comments
 (0)