@cursor/july 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/bin/agent-serve.js +20 -3
  2. package/dist/docs/404.html +1 -1
  3. package/dist/docs/ab.html +2 -2
  4. package/dist/docs/assets/{app.BR5EdVSq.js → app.CrsWMchO.js} +1 -1
  5. package/dist/docs/assets/chunks/@localSearchIndexroot.BnHRjfoe.js +1 -0
  6. package/dist/docs/assets/chunks/{VPLocalSearchBox.CNCzfUKC.js → VPLocalSearchBox.D1JqzSh8.js} +1 -1
  7. package/dist/docs/assets/chunks/{theme.CwKvMSWv.js → theme.DaBvZYwl.js} +2 -2
  8. package/dist/docs/building-with-agents.html +2 -2
  9. package/dist/docs/concepts.html +2 -2
  10. package/dist/docs/deployment.html +2 -2
  11. package/dist/docs/evals.html +2 -2
  12. package/dist/docs/example-agents/approval-buddy.html +2 -2
  13. package/dist/docs/example-agents/benny.html +2 -2
  14. package/dist/docs/example-agents/bugbot.html +2 -2
  15. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  16. package/dist/docs/example-agents/codeowners-review.html +2 -2
  17. package/dist/docs/example-agents/concierge.html +2 -2
  18. package/dist/docs/example-agents/fsd.html +2 -2
  19. package/dist/docs/example-agents/index.html +2 -2
  20. package/dist/docs/example-agents/knowledge-base.html +2 -2
  21. package/dist/docs/example-agents/oncall.html +2 -2
  22. package/dist/docs/example-agents/security-reviewer.html +2 -2
  23. package/dist/docs/example-agents/slack-agent.html +2 -2
  24. package/dist/docs/example-agents/weather-agent.html +2 -2
  25. package/dist/docs/guides/agent-to-agent.html +2 -2
  26. package/dist/docs/guides/cloud-runtime.html +2 -2
  27. package/dist/docs/guides/github.html +2 -2
  28. package/dist/docs/guides/human-in-the-loop.html +2 -2
  29. package/dist/docs/guides/mcp-oauth.html +2 -2
  30. package/dist/docs/guides/slack.html +2 -2
  31. package/dist/docs/guides/webhooks.html +2 -2
  32. package/dist/docs/hillclimbing.html +2 -2
  33. package/dist/docs/index.html +2 -2
  34. package/dist/docs/quickstart.html +2 -2
  35. package/dist/docs/reference/agent-config.html +2 -2
  36. package/dist/docs/reference/channels.html +2 -2
  37. package/dist/docs/reference/cli.html +2 -2
  38. package/dist/docs/reference/connections.html +2 -2
  39. package/dist/docs/reference/hooks.html +2 -2
  40. package/dist/docs/reference/http-api.html +2 -2
  41. package/dist/docs/reference/instructions.html +2 -2
  42. package/dist/docs/reference/playground.html +2 -2
  43. package/dist/docs/reference/project-layout.html +2 -2
  44. package/dist/docs/reference/prompt.html +2 -2
  45. package/dist/docs/reference/schedules.html +2 -2
  46. package/dist/docs/reference/sessions.html +2 -2
  47. package/dist/docs/reference/skills.html +2 -2
  48. package/dist/docs/reference/subagents.html +2 -2
  49. package/dist/docs/reference/tools.html +2 -2
  50. package/dist/docs/scaffolding-agents.html +2 -2
  51. package/dist/docs/storage.html +2 -2
  52. package/dist/docs/troubleshooting.html +2 -2
  53. package/dist/internal/cli-skills.d.ts +19 -0
  54. package/dist/internal/cli-skills.d.ts.map +1 -0
  55. package/dist/internal/cli-skills.js +72 -0
  56. package/dist/internal/install-cursor-skills.d.ts +46 -4
  57. package/dist/internal/install-cursor-skills.d.ts.map +1 -1
  58. package/dist/internal/install-cursor-skills.js +117 -49
  59. package/dist/playground/assets/{index-DRjS5klQ.js → index-C2SU2xV5.js} +23 -23
  60. package/dist/playground/assets/index-CidizGZv.css +1 -0
  61. package/dist/playground/index.html +2 -2
  62. package/package.json +1 -1
  63. package/src/bin/agent-serve.ts +21 -3
  64. package/src/internal/cli-skills.ts +95 -0
  65. package/src/internal/install-cursor-skills.ts +168 -60
  66. package/dist/docs/assets/chunks/@localSearchIndexroot.BOzkeUi8.js +0 -1
  67. package/dist/playground/assets/index-TCDs-UhZ.css +0 -1
@@ -8,8 +8,8 @@
8
8
  />
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1" />
10
10
  <title>agent-serve playground</title>
11
- <script type="module" crossorigin src="./assets/index-DRjS5klQ.js"></script>
12
- <link rel="stylesheet" crossorigin href="./assets/index-TCDs-UhZ.css">
11
+ <script type="module" crossorigin src="./assets/index-C2SU2xV5.js"></script>
12
+ <link rel="stylesheet" crossorigin href="./assets/index-CidizGZv.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cursor/july",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -94,6 +94,7 @@ Usage:
94
94
  ${CLI} call <tool> [--input <json>] [--dir <path> | --url <baseUrl> | --prod]
95
95
  [--session <id>] [--slug <slug>] [--team <id>]
96
96
  ${CLI} init [dir] [--json]
97
+ ${CLI} install-skills [--print] [--json]
97
98
  ${CLI} mcp --prod [--slug <slug>] [--team <id>] [--dir <path>]
98
99
  | --url <baseUrl> [--bearer-token <t>]
99
100
  ${CLI} mcp install [--name <serverName>] [--print] [--json]
@@ -173,6 +174,12 @@ Commands:
173
174
  run login when unsigned, then print next steps. --json is
174
175
  machine-readable and never blocks on login. No directory:
175
176
  print the setup guide.
177
+ install-skills
178
+ Install (or refresh) the agentkit skills for coding agents in
179
+ ~/.cursor/skills/agentkit and print where they landed. \`init\`
180
+ offers this once, interactively, and skips it when the prompt
181
+ goes unanswered; this command never asks. --print previews
182
+ without writing.
176
183
  mcp Serve this agent's MCP endpoint over stdio for MCP clients
177
184
  that spawn local servers (e.g. Cursor). Proxies
178
185
  newline-delimited JSON-RPC to <target>/v1/mcp. --prod resolves
@@ -291,6 +298,8 @@ Options:
291
298
  --once logs: print current progress / log buffer once and exit
292
299
  (default is to follow).
293
300
  --print playground: print the URL only; do not open a browser.
301
+ install-skills: list the skills and the install path
302
+ without writing them.
294
303
  --no-wait deploy/stop/eval: return right after the request is
295
304
  accepted instead of polling until a terminal status.
296
305
  For eval, prints the runId (deployment / --url only).
@@ -332,9 +341,10 @@ Options:
332
341
  --no-artifacts eval: do not write run artifacts.
333
342
  --skip-report eval: ignore reporters from evals.config.ts / eval files.
334
343
  --no-stream run/eval: silence live progress on stderr (JSON still prints).
335
- --json JSON output (init/info/chat/eval/logs/sessions/session/
336
- github; run defaults to JSON). logs: NDJSON progress/log
337
- events. session: trajectory JSON (not raw events).
344
+ --json JSON output (init/install-skills/info/chat/eval/logs/
345
+ sessions/session/github; run defaults to JSON). logs:
346
+ NDJSON progress/log events. session: trajectory JSON
347
+ (not raw events).
338
348
  --no-color chat: force plain output.
339
349
  --cursor-events serve: pull SCM webhook events from /v0/scm-events. The
340
350
  stream is read as your Cursor user, so serve requires
@@ -558,6 +568,14 @@ async function main(argv: string[]): Promise<number> {
558
568
  });
559
569
  }
560
570
 
571
+ case "install-skills": {
572
+ const { cmdInstallSkills } = await import("../internal/cli-skills.js");
573
+ return cmdInstallSkills({
574
+ json: values.json,
575
+ print: values.print,
576
+ });
577
+ }
578
+
561
579
  case "mcp": {
562
580
  const { cmdMcp } = await import("../internal/cli-mcp.js");
563
581
  const mcpSub = positionals[0];
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `install-skills` — install the package's coding-agent skills into
3
+ * `~/.cursor/skills/agentkit/` without running `init`.
4
+ *
5
+ * Never prompts: running the command is the confirmation. The interactive
6
+ * offer lives in `init`.
7
+ */
8
+
9
+ import { CLI_COMMAND_NAME } from "./distribution.js";
10
+ import {
11
+ formatSkillInstall,
12
+ installCursorSkills,
13
+ planSkillInstall,
14
+ type SkillInstallPlan,
15
+ } from "./install-cursor-skills.js";
16
+ import { stdoutPalette } from "./terminal-style.js";
17
+
18
+ export interface InstallSkillsCliOptions {
19
+ json?: boolean;
20
+ /** Preview the skills, the removals, and the install address; write nothing. */
21
+ print?: boolean;
22
+ /** Injected seams for tests. */
23
+ packageRoot?: string;
24
+ skillsHome?: string;
25
+ out?: (text: string) => void;
26
+ err?: (text: string) => void;
27
+ }
28
+
29
+ export async function cmdInstallSkills(
30
+ options: InstallSkillsCliOptions = {}
31
+ ): Promise<number> {
32
+ const out = options.out ?? ((text) => process.stdout.write(text));
33
+ const err = options.err ?? ((text) => process.stderr.write(text));
34
+ const plan = await planSkillInstall({
35
+ packageRoot: options.packageRoot,
36
+ skillsHome: options.skillsHome,
37
+ });
38
+
39
+ if (plan.shipped.length === 0) {
40
+ err(`${CLI_COMMAND_NAME} ships no skills to install.\n`);
41
+ return 1;
42
+ }
43
+
44
+ const emit = (run: { dryRun: boolean; removed: string[] }): void => {
45
+ const payload: SkillInstallPayload = {
46
+ ok: true,
47
+ dryRun: run.dryRun,
48
+ directory: plan.directory,
49
+ firstInstall: plan.firstInstall,
50
+ skills: plan.leaves,
51
+ removed: run.removed,
52
+ };
53
+ out(`${JSON.stringify(payload, null, 2)}\n`);
54
+ };
55
+
56
+ const noun = plan.shipped.length === 1 ? "skill" : "skills";
57
+ if (options.print === true) {
58
+ if (options.json === true) {
59
+ emit({ dryRun: true, removed: plan.orphans });
60
+ return 0;
61
+ }
62
+ const action = plan.firstInstall ? "install" : "update";
63
+ out(`Would ${action} ${plan.leaves.length} agentkit ${noun}\n`);
64
+ out(previewBlock(plan));
65
+ return 0;
66
+ }
67
+
68
+ const result = await installCursorSkills(plan.shipped, plan.skillsHome);
69
+ if (options.json === true) {
70
+ emit({ dryRun: false, removed: result.removed });
71
+ return 0;
72
+ }
73
+ out(formatSkillInstall(plan, result));
74
+ return 0;
75
+ }
76
+
77
+ interface SkillInstallPayload {
78
+ ok: true;
79
+ /** True when `--print` previewed the install instead of running it. */
80
+ dryRun: boolean;
81
+ directory: string;
82
+ firstInstall: boolean;
83
+ skills: string[];
84
+ removed: string[];
85
+ }
86
+
87
+ function previewBlock(plan: SkillInstallPlan): string {
88
+ const c = stdoutPalette();
89
+ const lines = [c.dim(` skills: ${plan.leaves.join(", ")}`)];
90
+ if (plan.orphans.length > 0) {
91
+ lines.push(` removes: ${plan.orphans.join(", ")}`);
92
+ }
93
+ lines.push(` location: ${plan.directory}`);
94
+ return `${lines.join("\n")}\n`;
95
+ }
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs/promises";
19
19
  import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
- import { packageRootDir } from "./distribution.js";
21
+ import { CLI_COMMAND_NAME, packageRootDir } from "./distribution.js";
22
22
  import { stdoutPalette } from "./terminal-style.js";
23
23
 
24
24
  /** Monorepo-only skill; excluded from the published package. */
@@ -27,6 +27,9 @@ const SKIP_SKILL_DIRS = new Set(["operate"]);
27
27
  /** Category folder under ~/.cursor/skills (Cursor discovers nested skills). */
28
28
  export const CURSOR_SKILLS_NAMESPACE = "agentkit";
29
29
 
30
+ /** How long the `init` offer waits for an answer before skipping the install. */
31
+ export const SKILL_PROMPT_TIMEOUT_MS = 45_000;
32
+
30
33
  export interface PackageSkill {
31
34
  /** Frontmatter `name` (e.g. agentkit-create-agent). */
32
35
  name: string;
@@ -115,6 +118,13 @@ export async function findMissingCursorSkills(options?: {
115
118
  return missing;
116
119
  }
117
120
 
121
+ export interface SkillInstallResult {
122
+ /** Leaf dirs written. */
123
+ installed: string[];
124
+ /** Leaf dirs deleted because the package no longer ships them. */
125
+ removed: string[];
126
+ }
127
+
118
128
  /**
119
129
  * Copy each skill directory into `skillsHome/agentkit/<leafDir>/`.
120
130
  * Always overwrites so a package upgrade refreshes local Cursor skills.
@@ -123,24 +133,13 @@ export async function findMissingCursorSkills(options?: {
123
133
  export async function installCursorSkills(
124
134
  skills: readonly PackageSkill[],
125
135
  skillsHome: string = cursorSkillsHome()
126
- ): Promise<string[]> {
136
+ ): Promise<SkillInstallResult> {
127
137
  const namespaceDir = agentkitCursorSkillsDir(skillsHome);
128
138
  await mkdir(namespaceDir, { recursive: true });
129
139
 
130
- const shippedLeaves = new Set(skills.map((skill) => skill.leafDir));
131
- let existingLeaves: string[] = [];
132
- try {
133
- const entries = await readdir(namespaceDir, { withFileTypes: true });
134
- existingLeaves = entries
135
- .filter((entry) => entry.isDirectory())
136
- .map((entry) => entry.name);
137
- } catch {
138
- existingLeaves = [];
139
- }
140
- for (const leaf of existingLeaves) {
141
- if (!shippedLeaves.has(leaf)) {
142
- await rm(join(namespaceDir, leaf), { recursive: true, force: true });
143
- }
140
+ const removed = await orphanLeaves(namespaceDir, skills);
141
+ for (const leaf of removed) {
142
+ await rm(join(namespaceDir, leaf), { recursive: true, force: true });
144
143
  }
145
144
 
146
145
  const installed: string[] = [];
@@ -155,7 +154,24 @@ export async function installCursorSkills(
155
154
  }
156
155
  installed.push(skill.leafDir);
157
156
  }
158
- return installed;
157
+ return { installed, removed };
158
+ }
159
+
160
+ /** Installed leaf dirs the package no longer ships. Installing deletes these. */
161
+ async function orphanLeaves(
162
+ namespaceDir: string,
163
+ skills: readonly PackageSkill[]
164
+ ): Promise<string[]> {
165
+ const shipped = new Set(skills.map((skill) => skill.leafDir));
166
+ let entries: Array<{ name: string; isDirectory: () => boolean }>;
167
+ try {
168
+ entries = await readdir(namespaceDir, { withFileTypes: true });
169
+ } catch {
170
+ return [];
171
+ }
172
+ return entries
173
+ .filter((entry) => entry.isDirectory() && !shipped.has(entry.name))
174
+ .map((entry) => entry.name);
159
175
  }
160
176
 
161
177
  /** Drop monorepo `paths` globs and fix package doc links for ~/.cursor installs. */
@@ -191,20 +207,72 @@ function stripPathsFromFrontmatter(frontmatter: string): string {
191
207
  return out.join("\n");
192
208
  }
193
209
 
210
+ export interface SkillInstallPlan {
211
+ shipped: PackageSkill[];
212
+ /** Resolved `~/.cursor/skills`, to install into. */
213
+ skillsHome: string;
214
+ /** Install target: `<skillsHome>/agentkit`. */
215
+ directory: string;
216
+ /** Leaf dirs the package ships. */
217
+ leaves: string[];
218
+ /** Nothing installed yet, so this is a fresh install rather than a refresh. */
219
+ firstInstall: boolean;
220
+ /** Installed leaf dirs the package dropped; installing deletes them. */
221
+ orphans: string[];
222
+ }
223
+
224
+ /** What an install would do, for the offer and for `install-skills`. */
225
+ export async function planSkillInstall(options?: {
226
+ packageRoot?: string;
227
+ skillsHome?: string;
228
+ }): Promise<SkillInstallPlan> {
229
+ const skillsHome = options?.skillsHome ?? cursorSkillsHome();
230
+ const directory = agentkitCursorSkillsDir(skillsHome);
231
+ const shipped = await listShippedPackageSkills(options?.packageRoot);
232
+ const missing = await findMissingCursorSkills({
233
+ packageRoot: options?.packageRoot,
234
+ skillsHome,
235
+ });
236
+ return {
237
+ shipped,
238
+ skillsHome,
239
+ directory,
240
+ leaves: shipped.map((skill) => skill.leafDir),
241
+ firstInstall: missing.length === shipped.length,
242
+ orphans: await orphanLeaves(directory, shipped),
243
+ };
244
+ }
245
+
246
+ /** `Installed 9 agentkit skills`, what it dropped, and where it went. */
247
+ export function formatSkillInstall(
248
+ plan: SkillInstallPlan,
249
+ result: SkillInstallResult
250
+ ): string {
251
+ const noun = result.installed.length === 1 ? "skill" : "skills";
252
+ const verb = plan.firstInstall ? "Installed" : "Updated";
253
+ const lines = [`${verb} ${result.installed.length} agentkit ${noun}`];
254
+ if (result.removed.length > 0) {
255
+ lines.push(` removed: ${result.removed.join(", ")}`);
256
+ }
257
+ lines.push(` location: ${plan.directory}`);
258
+ return `${lines.join("\n")}\n`;
259
+ }
260
+
194
261
  export interface OfferInstallCursorSkillsOptions {
195
262
  /** Skip when false (non-TTY / `--json`). */
196
263
  interactive?: boolean;
197
264
  packageRoot?: string;
198
265
  skillsHome?: string;
199
- /** Injected yes/no for tests. */
200
- confirm?: (question: string) => Promise<boolean>;
266
+ /** Injected yes/no for tests. `undefined` means the prompt went unanswered. */
267
+ confirm?: (question: string) => Promise<boolean | undefined>;
201
268
  out?: (text: string) => void;
202
269
  }
203
270
 
204
271
  /**
205
272
  * On a TTY, offer to install/update every shipped package skill into
206
273
  * ~/.cursor/skills/agentkit (always overwriting). No-op when non-interactive
207
- * or the package ships no skills.
274
+ * or the package ships no skills. An unanswered prompt skips the install
275
+ * rather than blocking the caller — see `SKILL_PROMPT_TIMEOUT_MS`.
208
276
  */
209
277
  export async function offerInstallCursorSkills(
210
278
  options: OfferInstallCursorSkillsOptions = {}
@@ -213,48 +281,60 @@ export async function offerInstallCursorSkills(
213
281
  const interactive =
214
282
  options.interactive ??
215
283
  (process.stdin.isTTY === true && process.stdout.isTTY === true);
216
- const shipped = await listShippedPackageSkills(options.packageRoot);
217
- if (shipped.length === 0) {
284
+ if (!interactive) {
218
285
  return { installed: [], skipped: true };
219
286
  }
220
287
 
221
- const home = options.skillsHome ?? cursorSkillsHome();
222
- const targetDir = agentkitCursorSkillsDir(home);
223
- const missing = await findMissingCursorSkills({
288
+ const plan = await planSkillInstall({
224
289
  packageRoot: options.packageRoot,
225
- skillsHome: home,
290
+ skillsHome: options.skillsHome,
226
291
  });
227
- const names = shipped.map((skill) => skill.leafDir).join(", ");
228
- const noun = shipped.length === 1 ? "skill" : "skills";
229
- const verb = missing.length === shipped.length ? "Install" : "Update";
230
- const question = `${verb} ${shipped.length} agentkit ${noun} in ${targetDir}?`;
231
-
232
- if (!interactive) {
292
+ if (plan.shipped.length === 0) {
233
293
  return { installed: [], skipped: true };
234
294
  }
235
295
 
296
+ const noun = plan.shipped.length === 1 ? "skill" : "skills";
297
+ const verb = plan.firstInstall ? "Install" : "Update";
236
298
  const c = stdoutPalette();
237
- out(`${question}\n`);
238
- out(c.dim(` ${names}\n`));
239
- if (missing.length < shipped.length) {
299
+ out(
300
+ `${verb} ${plan.shipped.length} agentkit ${noun} in ${plan.directory}?\n`
301
+ );
302
+ out(c.dim(` ${plan.leaves.join(", ")}\n`));
303
+ if (!plan.firstInstall) {
240
304
  out(c.dim(" (overwrites existing copies with the package version)\n"));
241
305
  }
306
+ if (plan.orphans.length > 0) {
307
+ out(c.dim(` (deletes ${plan.orphans.join(", ")})\n`));
308
+ }
242
309
 
243
310
  const confirm =
244
- options.confirm ?? ((q) => promptYesNo(q, { defaultYes: true, out }));
245
- const accepted = await confirm(
246
- missing.length === shipped.length ? "Install now?" : "Update now?"
247
- );
248
- if (!accepted) {
249
- out(c.dim("Skipped skill install.\n\n"));
311
+ options.confirm ??
312
+ ((q) =>
313
+ promptYesNo(q, {
314
+ defaultYes: true,
315
+ out,
316
+ timeoutMs: SKILL_PROMPT_TIMEOUT_MS,
317
+ }));
318
+ const accepted = await confirm(`${verb} now?`);
319
+
320
+ if (accepted !== true) {
321
+ // `undefined` is the prompt timing out or stdin closing; both mean nobody
322
+ // said yes, so do not claim a duration neither case is guaranteed to have.
323
+ const why =
324
+ accepted === undefined
325
+ ? "No answer — skipped the skill install."
326
+ : "Skipped skill install.";
327
+ out(
328
+ c.dim(
329
+ `${why}\nInstall later: ${CLI_COMMAND_NAME} install-skills (${plan.directory})\n\n`
330
+ )
331
+ );
250
332
  return { installed: [], skipped: true };
251
333
  }
252
334
 
253
- const installed = await installCursorSkills(shipped, home);
254
- out(
255
- `${verb === "Install" ? "Installed" : "Updated"} ${installed.length} ${noun} in ${targetDir}\n\n`
256
- );
257
- return { installed, skipped: false };
335
+ const result = await installCursorSkills(plan.shipped, plan.skillsHome);
336
+ out(`${formatSkillInstall(plan, result)}\n`);
337
+ return { installed: result.installed, skipped: false };
258
338
  }
259
339
 
260
340
  function parseSkillName(skillMd: string): string | undefined {
@@ -274,19 +354,34 @@ function parseSkillName(skillMd: string): string | undefined {
274
354
  return raw.replace(/^["']|["']$/g, "");
275
355
  }
276
356
 
277
- async function promptYesNo(
357
+ export interface PromptYesNoOptions {
358
+ defaultYes: boolean;
359
+ out: (text: string) => void;
360
+ /** Give up after this long; `0` (or omitted) waits forever. */
361
+ timeoutMs?: number;
362
+ /** Answer source; defaults to `process.stdin`. */
363
+ input?: NodeJS.ReadableStream;
364
+ }
365
+
366
+ /**
367
+ * Ask a yes/no question. Resolves `undefined` when the answer never comes —
368
+ * the prompt timed out, or stdin closed on an empty line.
369
+ */
370
+ export async function promptYesNo(
278
371
  question: string,
279
- options: {
280
- defaultYes: boolean;
281
- out: (text: string) => void;
282
- }
283
- ): Promise<boolean> {
372
+ options: PromptYesNoOptions
373
+ ): Promise<boolean | undefined> {
284
374
  const hint = options.defaultYes ? "Y/n" : "y/N";
375
+ const input = options.input ?? process.stdin;
285
376
  options.out(`${question} [${hint}] `);
286
- process.stdin.resume();
287
- const line = await readOneLine(process.stdin);
288
- process.stdin.pause();
289
- if (line === undefined || line.trim() === "") {
377
+ input.resume();
378
+ const line = await readOneLine(input, options.timeoutMs);
379
+ input.pause();
380
+ if (line === undefined) {
381
+ options.out("\n");
382
+ return undefined;
383
+ }
384
+ if (line.trim() === "") {
290
385
  return options.defaultYes;
291
386
  }
292
387
  const answer = line.trim().toLowerCase();
@@ -299,11 +394,14 @@ async function promptYesNo(
299
394
  return options.defaultYes;
300
395
  }
301
396
 
397
+ /** The line, or `undefined` when the stream ends or the clock runs out first. */
302
398
  function readOneLine(
303
- stream: NodeJS.ReadableStream
399
+ stream: NodeJS.ReadableStream,
400
+ timeoutMs?: number
304
401
  ): Promise<string | undefined> {
305
402
  return new Promise((resolve) => {
306
403
  let buffer = "";
404
+ let timer: NodeJS.Timeout | undefined;
307
405
  const onData = (chunk: string | Buffer): void => {
308
406
  buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
309
407
  const newline = buffer.indexOf("\n");
@@ -313,15 +411,25 @@ function readOneLine(
313
411
  cleanup();
314
412
  resolve(buffer.slice(0, newline).replace(/\r$/, ""));
315
413
  };
316
- const onEnd = (): void => {
414
+ // EOF and the timeout both land here. A tty holds a half-typed line until
415
+ // Enter, so a non-empty buffer only ever happens on piped input.
416
+ const giveUp = (): void => {
317
417
  cleanup();
318
418
  resolve(buffer === "" ? undefined : buffer.replace(/\r$/, ""));
319
419
  };
320
420
  const cleanup = (): void => {
421
+ if (timer !== undefined) {
422
+ clearTimeout(timer);
423
+ }
321
424
  stream.off("data", onData);
322
- stream.off("end", onEnd);
425
+ stream.off("end", giveUp);
323
426
  };
324
427
  stream.on("data", onData);
325
- stream.on("end", onEnd);
428
+ stream.on("end", giveUp);
429
+ if (timeoutMs !== undefined && timeoutMs > 0) {
430
+ timer = setTimeout(giveUp, timeoutMs);
431
+ // Never keep the process alive purely to wait for an answer.
432
+ timer.unref();
433
+ }
326
434
  });
327
435
  }