@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.
- package/README.md +18 -17
- package/dist/src/ckLauncher.d.ts +16 -6
- package/dist/src/ckLauncher.js +334 -478
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +25 -253
- package/dist/src/commands/doctor.d.ts +2 -0
- package/dist/src/commands/doctor.js +129 -0
- package/dist/src/commands/maintenance.d.ts +2 -0
- package/dist/src/commands/maintenance.js +94 -0
- package/dist/src/commands/render.d.ts +14 -0
- package/dist/src/commands/render.js +17 -0
- package/dist/src/commands/serve.d.ts +2 -0
- package/dist/src/commands/serve.js +52 -0
- package/dist/src/cursorDesktopState.d.ts +10 -0
- package/dist/src/cursorDesktopState.js +204 -0
- package/dist/src/desktopConnectProxy.d.ts +10 -0
- package/dist/src/desktopConnectProxy.js +7 -1
- package/dist/src/server.js +35 -2
- package/dist/src/tools/releaseCheck.d.ts +1 -1
- package/dist/src/tools/releaseCheck.js +1 -6
- package/dist/src/ui/index.d.ts +8 -0
- package/dist/src/ui/index.js +6 -0
- package/dist/src/ui/prompt.d.ts +30 -0
- package/dist/src/ui/prompt.js +182 -0
- package/dist/src/ui/runtime.d.ts +14 -0
- package/dist/src/ui/runtime.js +35 -0
- package/dist/src/ui/spinner.d.ts +31 -0
- package/dist/src/ui/spinner.js +102 -0
- package/dist/src/ui/steps.d.ts +38 -0
- package/dist/src/ui/steps.js +154 -0
- package/dist/src/ui/theme.d.ts +35 -0
- package/dist/src/ui/theme.js +63 -0
- package/package.json +5 -3
- package/dist/src/ck.d.ts +0 -2
- package/dist/src/ck.js +0 -6
|
@@ -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 ??
|
|
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("]");
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
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>;
|