@velum-labs/cursorkit 0.1.0 → 0.1.2

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Builders that shape the Cursor desktop `applicationUser` state and local
3
+ * model catalog entries. Extracted from `ckLauncher` so the (large) launcher
4
+ * module is not also responsible for the desktop state-seed schema. These are
5
+ * pure data transforms over plain JSON-shaped records.
6
+ */
7
+ export function mergeLocalAgentBackendUrlsIntoApplicationUser(applicationUser, agentOrigin) {
8
+ const cursorCreds = ensureRecord(applicationUser, "cursorCreds");
9
+ const urls = { default: agentOrigin };
10
+ cursorCreds.agentBackendUrlPrivacy = urls;
11
+ cursorCreds.agentBackendUrlNonPrivacy = urls;
12
+ }
13
+ export function mergeLocalDesktopModelsIntoApplicationUser(applicationUser, models) {
14
+ const localModelIds = new Set(models.map((model) => model.id));
15
+ const current = Array.isArray(applicationUser.availableDefaultModels2)
16
+ ? applicationUser.availableDefaultModels2.filter((item) => {
17
+ if (!isPlainRecord(item) || typeof item.name !== "string") {
18
+ return true;
19
+ }
20
+ return !localModelIds.has(item.name);
21
+ })
22
+ : [];
23
+ applicationUser.availableDefaultModels2 = current;
24
+ const aiSettings = ensureRecord(applicationUser, "aiSettings");
25
+ for (const model of models) {
26
+ appendUnique(ensureStringArray(aiSettings, "userAddedModels"), model.id);
27
+ appendUnique(ensureStringArray(aiSettings, "modelOverrideEnabled"), model.id);
28
+ removeValue(ensureStringArray(aiSettings, "modelOverrideDisabled"), model.id);
29
+ }
30
+ const preferences = ensureRecord(aiSettings, "modelParameterPreferences");
31
+ const updatedAt = new Date().toISOString();
32
+ for (const model of models) {
33
+ preferences[model.id] = {
34
+ modelId: model.id,
35
+ parameters: localDesktopParameterValues(true),
36
+ updatedAt,
37
+ };
38
+ }
39
+ const firstModel = models[0];
40
+ if (firstModel !== undefined) {
41
+ applicationUser.useOpenAIKey = true;
42
+ applicationUser.openAIBaseUrl = firstModel.baseUrl;
43
+ applicationUser.openAIKey = firstModel.apiKey;
44
+ const modelConfig = ensureRecord(aiSettings, "modelConfig");
45
+ const selectedModel = {
46
+ modelId: firstModel.id,
47
+ parameters: localDesktopParameterValues(true),
48
+ };
49
+ for (const key of ["composer", "background-composer"]) {
50
+ modelConfig[key] = {
51
+ modelName: firstModel.id,
52
+ maxMode: true,
53
+ selectedModels: [selectedModel],
54
+ };
55
+ }
56
+ }
57
+ const featureModelConfigs = ensureRecord(applicationUser, "featureModelConfigs");
58
+ for (const value of Object.values(featureModelConfigs)) {
59
+ if (!isPlainRecord(value)) {
60
+ continue;
61
+ }
62
+ const fallbackModels = ensureStringArray(value, "fallbackModels");
63
+ for (const model of models) {
64
+ appendUnique(fallbackModels, model.id);
65
+ }
66
+ }
67
+ }
68
+ export function buildLocalDesktopModelEntry(model) {
69
+ const tooltipData = {
70
+ primaryText: "",
71
+ secondaryText: "",
72
+ secondaryWarningText: false,
73
+ icon: "",
74
+ tertiaryText: "",
75
+ tertiaryTextUrl: "",
76
+ markdownContent: `**${model.displayName}**<br />Local OpenAI-compatible model served by cursorkit.<br /><br />${model.contextTokenLimit.toLocaleString()} token context window`,
77
+ };
78
+ return {
79
+ name: model.id,
80
+ serverModelName: model.id,
81
+ clientDisplayName: model.displayName,
82
+ inputboxShortModelName: model.displayName,
83
+ vendorName: "local",
84
+ vendor: { displayName: "Local" },
85
+ supportsAgent: true,
86
+ supportsCmdK: false,
87
+ supportsImages: false,
88
+ supportsMaxMode: true,
89
+ supportsNonMaxMode: true,
90
+ supportsPlanMode: true,
91
+ supportsSandboxing: false,
92
+ supportsThinking: false,
93
+ cloudAgentEffortModes: [],
94
+ defaultOn: true,
95
+ degradationStatus: 0,
96
+ isRecommendedForBackgroundComposer: false,
97
+ legacySlugs: [model.id],
98
+ idAliases: [model.id, model.displayName],
99
+ parameterDefinitions: localDesktopParameterDefinitions(),
100
+ namedModelSectionIndex: 10_000,
101
+ visibleInRoutedModelView: true,
102
+ tooltipData,
103
+ tooltipDataForMaxMode: tooltipData,
104
+ variants: [
105
+ localDesktopVariantConfig(model, tooltipData, false),
106
+ localDesktopVariantConfig(model, tooltipData, true),
107
+ ],
108
+ };
109
+ }
110
+ function localDesktopVariantConfig(model, tooltipData, isMaxMode) {
111
+ return {
112
+ parameterValues: localDesktopParameterValues(isMaxMode),
113
+ displayName: model.displayName,
114
+ isMaxMode,
115
+ isDefaultMaxConfig: isMaxMode,
116
+ isDefaultNonMaxConfig: !isMaxMode,
117
+ tooltipData,
118
+ displayNameOutsidePicker: model.displayName,
119
+ variantStringRepresentation: localDesktopVariantString(model.id, isMaxMode),
120
+ legacySlug: model.id,
121
+ };
122
+ }
123
+ function localDesktopVariantString(modelId, isMaxMode) {
124
+ const context = isMaxMode ? "1m" : "272k";
125
+ return `${modelId}[context=${context},reasoning=medium,fast=false]`;
126
+ }
127
+ function localDesktopParameterValues(isMaxMode) {
128
+ return [
129
+ { id: "context", value: isMaxMode ? "1m" : "272k" },
130
+ { id: "reasoning", value: "medium" },
131
+ { id: "fast", value: "false" },
132
+ ];
133
+ }
134
+ function localDesktopParameterDefinitions() {
135
+ return [
136
+ {
137
+ id: "context",
138
+ name: "Context",
139
+ markdownTooltip: "Context size the model has available.",
140
+ parameterType: {
141
+ enumParameter: {
142
+ values: [
143
+ { value: "272k", displayName: "272K" },
144
+ { value: "1m", displayName: "1M" },
145
+ ],
146
+ },
147
+ },
148
+ },
149
+ {
150
+ id: "reasoning",
151
+ name: "Reasoning",
152
+ markdownTooltip: "Reasoning effort the model uses to generate its response.",
153
+ parameterType: {
154
+ enumParameter: {
155
+ values: [
156
+ { value: "none", displayName: "None" },
157
+ { value: "low", displayName: "Low" },
158
+ { value: "medium", displayName: "Medium" },
159
+ { value: "high", displayName: "High" },
160
+ { value: "extra-high", displayName: "Extra High" },
161
+ ],
162
+ },
163
+ },
164
+ isCycleableByHotkey: true,
165
+ },
166
+ {
167
+ id: "fast",
168
+ name: "Fast",
169
+ markdownTooltip: "Use the provider's fast lane when supported.",
170
+ parameterType: {
171
+ booleanParameter: {
172
+ values: [{ value: "false" }, { value: "true", displayName: "Fast" }],
173
+ },
174
+ },
175
+ },
176
+ ];
177
+ }
178
+ function ensureRecord(target, key) {
179
+ if (!isPlainRecord(target[key])) {
180
+ target[key] = {};
181
+ }
182
+ return target[key];
183
+ }
184
+ function ensureStringArray(target, key) {
185
+ const values = Array.isArray(target[key])
186
+ ? target[key].filter((value) => typeof value === "string")
187
+ : [];
188
+ target[key] = values;
189
+ return values;
190
+ }
191
+ function appendUnique(values, value) {
192
+ if (!values.includes(value)) {
193
+ values.push(value);
194
+ }
195
+ }
196
+ function removeValue(values, value) {
197
+ const index = values.indexOf(value);
198
+ if (index !== -1) {
199
+ values.splice(index, 1);
200
+ }
201
+ }
202
+ function isPlainRecord(value) {
203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
204
+ }
@@ -5,9 +5,19 @@ export interface DesktopConnectProxyOptions {
5
5
  bridgeHost: string;
6
6
  bridgePort: number;
7
7
  logPath?: string;
8
+ /**
9
+ * When true, CONNECT requests to non-Cursor hosts are tunneled through to
10
+ * their destination, turning this into an open forwarder. Defaults to false
11
+ * so only Cursor backends (cursorHostnames) are reachable.
12
+ */
8
13
  passthrough?: boolean;
9
14
  cursorHostnames?: readonly string[];
10
15
  headerTimeoutMs?: number;
16
+ /**
17
+ * Allow binding to a non-loopback host. Off by default to avoid exposing the
18
+ * proxy beyond the local machine.
19
+ */
20
+ allowExternalHost?: boolean;
11
21
  }
12
22
  export interface DesktopConnectProxy {
13
23
  server: net.Server;
@@ -5,8 +5,11 @@ const CONNECT_LINE_PATTERN = /^CONNECT\s+([^\s]+)\s+HTTP\/\d(?:\.\d)?$/i;
5
5
  const DEFAULT_HEADER_TIMEOUT_MS = 5_000;
6
6
  const MAX_CONNECT_HEADER_BYTES = 32 * 1024;
7
7
  export async function startDesktopConnectProxy(options) {
8
+ if (options.allowExternalHost !== true && !isLoopbackHost(options.host)) {
9
+ throw new Error(`Refusing to bind CONNECT proxy to non-loopback host ${options.host}. Set allowExternalHost: true to override.`);
10
+ }
8
11
  const cursorHostnames = new Set(options.cursorHostnames ?? DESKTOP_HOSTNAMES);
9
- const passthrough = options.passthrough ?? true;
12
+ const passthrough = options.passthrough ?? false;
10
13
  const activeSockets = new Set();
11
14
  const server = net.createServer((client) => {
12
15
  activeSockets.add(client);
@@ -155,6 +158,9 @@ function handleClient(client, options, cursorHostnames, passthrough, activeSocke
155
158
  });
156
159
  });
157
160
  }
161
+ function isLoopbackHost(host) {
162
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
163
+ }
158
164
  function parseConnectDestination(value) {
159
165
  if (value.startsWith("[")) {
160
166
  const closeBracket = value.indexOf("]");
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import http from "node:http";
2
3
  import http2 from "node:http2";
3
4
  import https from "node:https";
@@ -101,7 +102,9 @@ export async function createBridgeRuntime(config, logger) {
101
102
  }
102
103
  export async function startServer(runtime) {
103
104
  const listener = (request, response) => {
104
- void handleRequest(runtime, request, response);
105
+ handleRequest(runtime, request, response).catch((error) => {
106
+ handleRequestFailure(runtime, response, error);
107
+ });
105
108
  };
106
109
  const server = runtime.config.useTls
107
110
  ? runtime.config.desktopMode
@@ -252,6 +255,35 @@ async function handleRequest(runtime, request, response) {
252
255
  }
253
256
  proxyRequest(request, response, runtime.config, runtime.logger);
254
257
  }
258
+ function handleRequestFailure(runtime, response, error) {
259
+ runtime.logger.error("request handler crashed", {
260
+ error: error instanceof Error ? error.message : String(error),
261
+ });
262
+ try {
263
+ if (response.headersSent) {
264
+ response.destroy();
265
+ return;
266
+ }
267
+ response.writeHead(500, { "content-type": "application/json" });
268
+ response.end(JSON.stringify({ error: "internal bridge error" }));
269
+ }
270
+ catch {
271
+ response.destroy();
272
+ }
273
+ }
274
+ /**
275
+ * Length-independent constant-time comparison so bridge-token checks do not leak
276
+ * the secret via early-mismatch timing. A missing header never matches.
277
+ */
278
+ function constantTimeEquals(value, expected) {
279
+ if (value === undefined)
280
+ return false;
281
+ const valueBuf = Buffer.from(value);
282
+ const expectedBuf = Buffer.from(expected);
283
+ if (valueBuf.length !== expectedBuf.length)
284
+ return false;
285
+ return timingSafeEqual(valueBuf, expectedBuf);
286
+ }
255
287
  function authorizeRequest(runtime, request, response) {
256
288
  const token = runtime.config.authToken;
257
289
  if (token === undefined) {
@@ -259,7 +291,8 @@ function authorizeRequest(runtime, request, response) {
259
291
  }
260
292
  const authorization = headerValue(request.headers.authorization);
261
293
  const bridgeToken = headerValue(request.headers["x-cursor-rpc-auth"]);
262
- const authorized = authorization === `Bearer ${token}` || bridgeToken === token;
294
+ const authorized = constantTimeEquals(authorization, `Bearer ${token}`) ||
295
+ constantTimeEquals(bridgeToken, token);
263
296
  if (authorized) {
264
297
  return true;
265
298
  }
@@ -20,7 +20,7 @@ export interface ReleaseCategoryStatus {
20
20
  optionalSuiteIds: string[];
21
21
  summary: string;
22
22
  }
23
- export declare const REQUIRED_PACKAGE_ENTRIES: readonly ["package/package.json", "package/README.md", "package/DISCLAIMER.md", "package/dist/src/cli.js", "package/dist/src/cli.d.ts", "package/dist/src/ck.js", "package/dist/src/ck.d.ts", "package/proto/agent/v1/agent.proto", "package/proto/aiserver/v1/aiserver.proto", "package/docs/protocol.md", "package/docs/release-gates.md", "package/docs/testing-harness.md", "package/docs/test-manifest.json", "package/docs/route-contract-manifest.json", "package/docs/release-summary.json"];
23
+ export declare const REQUIRED_PACKAGE_ENTRIES: readonly ["package/package.json", "package/README.md", "package/DISCLAIMER.md", "package/dist/src/cli.js", "package/dist/src/cli.d.ts", "package/proto/agent/v1/agent.proto", "package/proto/aiserver/v1/aiserver.proto", "package/docs/protocol.md", "package/docs/release-gates.md", "package/docs/testing-harness.md", "package/docs/test-manifest.json", "package/docs/route-contract-manifest.json", "package/docs/release-summary.json"];
24
24
  export declare const EXCLUDED_PACKAGE_ENTRY_PREFIXES: readonly ["package/examples/"];
25
25
  export declare function runReleaseCheck(repoRoot?: string): number;
26
26
  export declare function buildReleaseCategoryStatuses(results: ReleaseGateStatusInput[], optionalSuites?: OptionalLiveSuite[]): ReleaseCategoryStatus[];
@@ -10,8 +10,6 @@ export const REQUIRED_PACKAGE_ENTRIES = [
10
10
  "package/DISCLAIMER.md",
11
11
  "package/dist/src/cli.js",
12
12
  "package/dist/src/cli.d.ts",
13
- "package/dist/src/ck.js",
14
- "package/dist/src/ck.d.ts",
15
13
  "package/proto/agent/v1/agent.proto",
16
14
  "package/proto/aiserver/v1/aiserver.proto",
17
15
  "package/docs/protocol.md",
@@ -273,10 +271,7 @@ function runPackageSmoke(repoRoot) {
273
271
  }
274
272
  const packedPackageJsonPath = path.join(extractDir, "package", "package.json");
275
273
  const packedPackageJson = JSON.parse(fs.readFileSync(packedPackageJsonPath, "utf8"));
276
- const expectedBins = new Map([
277
- ["cursorkit", "./dist/src/cli.js"],
278
- ["ck", "./dist/src/ck.js"],
279
- ]);
274
+ const expectedBins = new Map([["cursorkit", "./dist/src/cli.js"]]);
280
275
  const invalidBins = Array.from(expectedBins).filter(([name, target]) => packedPackageJson.bin?.[name] !== target);
281
276
  if (invalidBins.length > 0) {
282
277
  details.error =
@@ -0,0 +1,8 @@
1
+ /** The cursorkit CLI's zero-dependency terminal UI layer. */
2
+ export * from "./theme.js";
3
+ export * from "./runtime.js";
4
+ export { Spinner, withSpinner } from "./spinner.js";
5
+ export { StepList } from "./steps.js";
6
+ export type { StepInput, StepStatus } from "./steps.js";
7
+ export { select, confirm, text, done, note } from "./prompt.js";
8
+ export type { SelectOption } from "./prompt.js";
@@ -0,0 +1,6 @@
1
+ /** The cursorkit CLI's zero-dependency terminal UI layer. */
2
+ export * from "./theme.js";
3
+ export * from "./runtime.js";
4
+ export { Spinner, withSpinner } from "./spinner.js";
5
+ export { StepList } from "./steps.js";
6
+ export { select, confirm, text, done, note } from "./prompt.js";
@@ -0,0 +1,30 @@
1
+ export type SelectOption<T> = {
2
+ value: T;
3
+ label: string;
4
+ hint?: string;
5
+ };
6
+ /**
7
+ * Single-choice selection. On a raw-capable TTY this is arrow-key driven with a
8
+ * live highlighted cursor; otherwise it falls back to a numbered prompt read
9
+ * from stdin (so piped input and non-raw terminals still work). Returns the
10
+ * default when input is empty or unparseable.
11
+ */
12
+ export declare function select<T>(input: {
13
+ message: string;
14
+ options: ReadonlyArray<SelectOption<T>>;
15
+ defaultIndex?: number;
16
+ }): Promise<T>;
17
+ /** Yes/no confirmation. Returns `defaultValue` on empty input. */
18
+ export declare function confirm(input: {
19
+ message: string;
20
+ defaultValue?: boolean;
21
+ }): Promise<boolean>;
22
+ /** Free-text prompt. Returns `defaultValue` (or "") on empty input. */
23
+ export declare function text(input: {
24
+ message: string;
25
+ defaultValue?: string;
26
+ }): Promise<string>;
27
+ /** A success line for the end of a wizard. */
28
+ export declare function done(message: string): void;
29
+ /** A neutral note line. */
30
+ export declare function note(message: string): void;
@@ -0,0 +1,182 @@
1
+ import { createInterface, emitKeypressEvents } from "node:readline";
2
+ import { canPromptInteractively, uiStream } from "./runtime.js";
3
+ import { bold, cyan, dim, glyph, gray, green } from "./theme.js";
4
+ const out = uiStream();
5
+ // For non-interactive input (piped/redirected/empty stdin) we read all of stdin
6
+ // exactly once and serve answers line by line. This supports scripted input
7
+ // (`printf "2\n3\n" | ck ...`) and falls back to "" (the prompt default) once
8
+ // exhausted — without the fragile behavior of attaching multiple readline
9
+ // interfaces to an already-ended stdin.
10
+ let bufferedLines;
11
+ let bufferedRead = false;
12
+ async function ensureBufferedStdin() {
13
+ if (bufferedRead)
14
+ return;
15
+ bufferedRead = true;
16
+ if (process.stdin.isTTY || process.stdin.readableEnded) {
17
+ bufferedLines = [];
18
+ return;
19
+ }
20
+ const chunks = [];
21
+ await new Promise((resolve) => {
22
+ process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
23
+ process.stdin.once("end", () => resolve());
24
+ process.stdin.once("error", () => resolve());
25
+ });
26
+ bufferedLines = Buffer.concat(chunks).toString("utf8").split("\n");
27
+ }
28
+ /**
29
+ * Read a single line from stdin, prompting on stderr. On a TTY this reads live;
30
+ * otherwise it draws from buffered stdin and resolves to "" when there is no
31
+ * more input, so callers fall back to their default instead of hanging.
32
+ */
33
+ async function readLine(promptText) {
34
+ if (!process.stdin.isTTY) {
35
+ out.write(promptText);
36
+ await ensureBufferedStdin();
37
+ const next = bufferedLines?.shift();
38
+ out.write("\n");
39
+ return (next ?? "").trim();
40
+ }
41
+ return new Promise((resolve) => {
42
+ const rl = createInterface({ input: process.stdin, output: out });
43
+ let answered = false;
44
+ rl.question(promptText, (answer) => {
45
+ answered = true;
46
+ rl.close();
47
+ resolve(answer.trim());
48
+ });
49
+ rl.on("close", () => {
50
+ if (!answered)
51
+ resolve("");
52
+ });
53
+ });
54
+ }
55
+ /**
56
+ * Single-choice selection. On a raw-capable TTY this is arrow-key driven with a
57
+ * live highlighted cursor; otherwise it falls back to a numbered prompt read
58
+ * from stdin (so piped input and non-raw terminals still work). Returns the
59
+ * default when input is empty or unparseable.
60
+ */
61
+ export async function select(input) {
62
+ const { options } = input;
63
+ if (options.length === 0)
64
+ throw new Error("select requires at least one option");
65
+ const fallbackIndex = Math.min(Math.max(input.defaultIndex ?? 0, 0), options.length - 1);
66
+ if (!canPromptInteractively()) {
67
+ return selectNumbered(input.message, options, fallbackIndex);
68
+ }
69
+ return selectInteractive(input.message, options, fallbackIndex);
70
+ }
71
+ function optionAt(options, index) {
72
+ const option = options[index];
73
+ if (option === undefined)
74
+ throw new Error(`option index out of range: ${index}`);
75
+ return option;
76
+ }
77
+ async function selectNumbered(message, options, fallbackIndex) {
78
+ out.write(`${bold(message)}\n`);
79
+ options.forEach((option, index) => {
80
+ const marker = index === fallbackIndex ? cyan(`${index + 1}`) : `${index + 1}`;
81
+ const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
82
+ out.write(` ${marker}) ${option.label}${hint}\n`);
83
+ });
84
+ const answer = await readLine(`Choose [1-${options.length}] (${fallbackIndex + 1}): `);
85
+ if (answer.length === 0)
86
+ return optionAt(options, fallbackIndex).value;
87
+ const byNumber = Number.parseInt(answer, 10);
88
+ if (Number.isInteger(byNumber) &&
89
+ byNumber >= 1 &&
90
+ byNumber <= options.length) {
91
+ return optionAt(options, byNumber - 1).value;
92
+ }
93
+ const byLabel = options.findIndex((option) => option.label.toLowerCase() === answer.toLowerCase());
94
+ if (byLabel >= 0)
95
+ return optionAt(options, byLabel).value;
96
+ return optionAt(options, fallbackIndex).value;
97
+ }
98
+ function selectInteractive(message, options, fallbackIndex) {
99
+ return new Promise((resolve) => {
100
+ let cursor = fallbackIndex;
101
+ let rendered = 0;
102
+ const stdin = process.stdin;
103
+ emitKeypressEvents(stdin);
104
+ const wasRaw = stdin.isRaw === true;
105
+ if (stdin.setRawMode)
106
+ stdin.setRawMode(true);
107
+ stdin.resume();
108
+ out.write("\u001b[?25l");
109
+ const render = () => {
110
+ if (rendered > 0) {
111
+ out.write(`\u001b[${rendered}A`);
112
+ out.write("\u001b[0J");
113
+ }
114
+ const lines = [bold(message)];
115
+ options.forEach((option, index) => {
116
+ const active = index === cursor;
117
+ const pointer = active ? cyan(glyph.pointer()) : " ";
118
+ const label = active ? cyan(option.label) : option.label;
119
+ const hint = option.hint !== undefined ? dim(` — ${option.hint}`) : "";
120
+ lines.push(`${pointer} ${label}${hint}`);
121
+ });
122
+ lines.push(dim(" (arrows to move, enter to select)"));
123
+ out.write(lines.join("\n") + "\n");
124
+ rendered = lines.length;
125
+ };
126
+ const cleanup = () => {
127
+ stdin.removeListener("keypress", onKey);
128
+ if (stdin.setRawMode)
129
+ stdin.setRawMode(wasRaw);
130
+ stdin.pause();
131
+ out.write("\u001b[?25h");
132
+ };
133
+ const onKey = (_str, key) => {
134
+ if (key.sequence === "\u0003") {
135
+ cleanup();
136
+ out.write("\n");
137
+ process.exit(130);
138
+ }
139
+ if (key.name === "up" || key.name === "k") {
140
+ cursor = (cursor - 1 + options.length) % options.length;
141
+ render();
142
+ }
143
+ else if (key.name === "down" || key.name === "j") {
144
+ cursor = (cursor + 1) % options.length;
145
+ render();
146
+ }
147
+ else if (key.name === "return" || key.name === "enter") {
148
+ cleanup();
149
+ resolve(optionAt(options, cursor).value);
150
+ }
151
+ };
152
+ stdin.on("keypress", onKey);
153
+ render();
154
+ });
155
+ }
156
+ /** Yes/no confirmation. Returns `defaultValue` on empty input. */
157
+ export async function confirm(input) {
158
+ const def = input.defaultValue ?? false;
159
+ const hint = def ? "[Y/n]" : "[y/N]";
160
+ const answer = (await readLine(`${bold(input.message)} ${dim(hint)} `)).toLowerCase();
161
+ if (answer.length === 0)
162
+ return def;
163
+ return answer === "y" || answer === "yes";
164
+ }
165
+ /** Free-text prompt. Returns `defaultValue` (or "") on empty input. */
166
+ export async function text(input) {
167
+ const suffix = input.defaultValue !== undefined && input.defaultValue.length > 0
168
+ ? dim(` (${input.defaultValue})`)
169
+ : "";
170
+ const answer = await readLine(`${bold(input.message)}${suffix} `);
171
+ if (answer.length === 0)
172
+ return input.defaultValue ?? "";
173
+ return answer;
174
+ }
175
+ /** A success line for the end of a wizard. */
176
+ export function done(message) {
177
+ out.write(`${green(glyph.tick())} ${message}\n`);
178
+ }
179
+ /** A neutral note line. */
180
+ export function note(message) {
181
+ out.write(`${gray(glyph.arrow())} ${message}\n`);
182
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Interaction-mode detection. The CLI's rich surfaces (spinners, live step
3
+ * lists, prompts) only render when we are attached to an interactive terminal
4
+ * and not running under CI; otherwise everything degrades to plain line logs so
5
+ * pipes, captures, and tests stay deterministic.
6
+ */
7
+ /** True under a recognized CI environment. */
8
+ export declare function isCI(): boolean;
9
+ /** The stream all UI is written to (stderr; stdout is reserved for tool output). */
10
+ export declare function uiStream(): NodeJS.WriteStream;
11
+ /** True when we should render rich, animated UI to `stream`. */
12
+ export declare function isInteractive(stream?: NodeJS.WriteStream): boolean;
13
+ /** True when we can read interactive keypresses (raw mode) from stdin. */
14
+ export declare function canPromptInteractively(): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Interaction-mode detection. The CLI's rich surfaces (spinners, live step
3
+ * lists, prompts) only render when we are attached to an interactive terminal
4
+ * and not running under CI; otherwise everything degrades to plain line logs so
5
+ * pipes, captures, and tests stay deterministic.
6
+ */
7
+ /** True under a recognized CI environment. */
8
+ export function isCI() {
9
+ const env = process.env;
10
+ return Boolean(env.CI === "true" ||
11
+ env.CI === "1" ||
12
+ env.CONTINUOUS_INTEGRATION ||
13
+ env.GITHUB_ACTIONS ||
14
+ env.GITLAB_CI ||
15
+ env.BUILDKITE ||
16
+ env.CIRCLECI);
17
+ }
18
+ /** The stream all UI is written to (stderr; stdout is reserved for tool output). */
19
+ export function uiStream() {
20
+ return process.stderr;
21
+ }
22
+ /** True when we should render rich, animated UI to `stream`. */
23
+ export function isInteractive(stream = uiStream()) {
24
+ if (process.env.CURSORKIT_NO_TUI === "1")
25
+ return false;
26
+ if (isCI())
27
+ return false;
28
+ return Boolean(stream.isTTY);
29
+ }
30
+ /** True when we can read interactive keypresses (raw mode) from stdin. */
31
+ export function canPromptInteractively() {
32
+ return (Boolean(process.stdin.isTTY) &&
33
+ !isCI() &&
34
+ process.env.CURSORKIT_NO_TUI !== "1");
35
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A single-line spinner. On an interactive TTY it animates in place; otherwise
3
+ * it prints one line per state transition so logs stay readable and ordered.
4
+ */
5
+ export declare class Spinner {
6
+ private timer;
7
+ private frame;
8
+ private text;
9
+ private readonly stream;
10
+ private readonly interactive;
11
+ private active;
12
+ constructor(text: string);
13
+ start(): this;
14
+ update(text: string): this;
15
+ succeed(text?: string): void;
16
+ fail(text?: string): void;
17
+ warn(text?: string): void;
18
+ info(text?: string): void;
19
+ stop(): void;
20
+ private settle;
21
+ private render;
22
+ private teardown;
23
+ private clearLine;
24
+ private hideCursor;
25
+ private showCursor;
26
+ }
27
+ /** Run `work` under a spinner, settling to success/failure automatically. */
28
+ export declare function withSpinner<T>(text: string, work: () => Promise<T>, options?: {
29
+ success?: (value: T) => string;
30
+ failure?: (error: unknown) => string;
31
+ }): Promise<T>;