pi-cursor-sdk 0.1.17 → 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 (51) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -1
  3. package/docs/cursor-live-smoke-checklist.md +22 -2
  4. package/docs/cursor-model-ux-spec.md +5 -4
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +428 -0
  7. package/package.json +11 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/isolated-cursor-smoke.sh +226 -0
  11. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  12. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  13. package/scripts/validate-smoke-jsonl.mjs +86 -7
  14. package/src/context.ts +45 -32
  15. package/src/cursor-agent-message-web-tools.ts +172 -0
  16. package/src/cursor-agents-context.ts +176 -0
  17. package/src/cursor-context-tools.ts +6 -0
  18. package/src/cursor-display-text.ts +10 -0
  19. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  20. package/src/cursor-live-run-coordinator.ts +18 -7
  21. package/src/cursor-model.ts +12 -0
  22. package/src/cursor-native-replay-routing.ts +48 -0
  23. package/src/cursor-native-replay-trace.ts +29 -0
  24. package/src/cursor-native-tool-display-registration.ts +14 -7
  25. package/src/cursor-native-tool-display-replay.ts +63 -5
  26. package/src/cursor-native-tool-display-tools.ts +20 -0
  27. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  28. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  29. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  30. package/src/cursor-provider-errors.ts +96 -0
  31. package/src/cursor-provider-live-run-drain.ts +208 -63
  32. package/src/cursor-provider-turn-coordinator.ts +217 -47
  33. package/src/cursor-provider.ts +275 -83
  34. package/src/cursor-question-tool.ts +10 -5
  35. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  36. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  37. package/src/cursor-sdk-event-debug-session.ts +163 -0
  38. package/src/cursor-sdk-event-debug.ts +597 -0
  39. package/src/cursor-sensitive-text.ts +27 -7
  40. package/src/cursor-session-agent.ts +25 -3
  41. package/src/cursor-session-send-policy.ts +43 -0
  42. package/src/cursor-setting-sources.ts +29 -0
  43. package/src/cursor-state.ts +1 -5
  44. package/src/cursor-tool-lifecycle.ts +111 -0
  45. package/src/cursor-tool-names.ts +12 -0
  46. package/src/cursor-tool-transcript.ts +4 -2
  47. package/src/cursor-transcript-tool-formatters.ts +228 -5
  48. package/src/cursor-transcript-tool-specs.ts +113 -14
  49. package/src/cursor-transcript-utils.ts +12 -0
  50. package/src/cursor-web-tool-activity.ts +84 -0
  51. package/src/index.ts +4 -1
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Maintainer-only Cursor SDK event capture probe.
4
+ * Captures timestamped run.stream(), onDelta, and onStep surfaces for one run.
5
+ */
6
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { dirname, join, resolve } from "node:path";
9
+ import {
10
+ CURSOR_SETTING_SOURCES_ENV,
11
+ resolveCursorSettingSources,
12
+ scrubSensitiveText,
13
+ } from "./lib/cursor-probe-utils.mjs";
14
+ import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const packageJson = require("../package.json");
18
+
19
+ const ARTIFACTS = {
20
+ metadata: "metadata.json",
21
+ streamEvents: "stream-events.jsonl",
22
+ onDelta: "on-delta.jsonl",
23
+ onStep: "on-step.jsonl",
24
+ waitResult: "wait-result.json",
25
+ conversation: "conversation.json",
26
+ summary: "summary.json",
27
+ };
28
+
29
+ const DEFAULT_MODEL = "composer-2.5";
30
+ const RAW_ARTIFACT_WARNING =
31
+ "Raw artifact files may contain local paths, project text, tool args/results, or secrets from the workspace. Do not commit or share them.";
32
+
33
+ function readSdkVersion() {
34
+ try {
35
+ const sdkEntry = require.resolve("@cursor/sdk");
36
+ const sdkPackagePath = join(dirname(sdkEntry), "../../package.json");
37
+ return JSON.parse(readFileSync(sdkPackagePath, "utf8")).version;
38
+ } catch {
39
+ return "unknown";
40
+ }
41
+ }
42
+
43
+ function artifactPath(artifactDir, name) {
44
+ return join(artifactDir, ARTIFACTS[name]);
45
+ }
46
+
47
+ function printHelp() {
48
+ console.log(`Capture timestamped Cursor SDK event timelines for one local run.
49
+
50
+ Usage:
51
+ CURSOR_API_KEY=... npm run debug:sdk-events -- [options]
52
+ node scripts/debug-sdk-events.mjs [options]
53
+
54
+ Options:
55
+ --cwd <path> Agent working directory. Default: process.cwd().
56
+ --model <id> Cursor model id. Default: ${DEFAULT_MODEL}.
57
+ --prompt <text> Required user prompt for the run.
58
+ --out <dir> Artifact directory. Default: /tmp/pi-cursor-sdk-sdk-events-<timestamp>.
59
+ --setting-sources <value> Comma-separated Cursor setting sources, or all/none.
60
+ Default: PI_CURSOR_SETTING_SOURCES env, otherwise all.
61
+ --include-conversation Also capture run.conversation() when supported.
62
+ --api-key <key> Cursor API key. Prefer CURSOR_API_KEY to avoid shell history.
63
+ -h, --help Show this help.
64
+
65
+ Stdout:
66
+ Prints artifact paths and summary counts only. Raw payloads stay on disk under:
67
+ ${ARTIFACTS.streamEvents} (run.stream()), ${ARTIFACTS.onDelta} (onDelta), ${ARTIFACTS.onStep} (onStep).
68
+
69
+ Exit codes:
70
+ 0 capture completed
71
+ 1 invalid arguments, missing auth, or Cursor SDK failure
72
+
73
+ Safety:
74
+ - Never prints CURSOR_API_KEY or --api-key values.
75
+ - Default artifact root is outside the repo (/tmp/...).
76
+ - ${RAW_ARTIFACT_WARNING}
77
+ - Verify Cursor SDK behavior against the installed @cursor/sdk package and/or
78
+ https://cursor.com/docs/sdk/typescript before drawing integration conclusions.`);
79
+ }
80
+
81
+ function fail(message, secrets = []) {
82
+ const scrubbed = scrubSensitiveText(message, secrets[0]);
83
+ console.error(`debug-sdk-events: ${scrubbed}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ export function parseDebugSdkEventsArgs(argv, env = process.env) {
88
+ const args = {
89
+ cwd: process.cwd(),
90
+ model: DEFAULT_MODEL,
91
+ prompt: undefined,
92
+ out: undefined,
93
+ settingSources: resolveCursorSettingSources(env[CURSOR_SETTING_SOURCES_ENV]),
94
+ includeConversation: false,
95
+ apiKey: env.CURSOR_API_KEY?.trim() || undefined,
96
+ help: false,
97
+ };
98
+ for (let index = 0; index < argv.length; index++) {
99
+ const arg = argv[index];
100
+ if (arg === "-h" || arg === "--help") {
101
+ args.help = true;
102
+ continue;
103
+ }
104
+ if (arg === "--include-conversation") {
105
+ args.includeConversation = true;
106
+ continue;
107
+ }
108
+ if (arg === "--cwd") {
109
+ const value = argv[++index];
110
+ if (!value || value.startsWith("--")) fail("--cwd requires a path");
111
+ args.cwd = resolve(value);
112
+ continue;
113
+ }
114
+ if (arg.startsWith("--cwd=")) {
115
+ args.cwd = resolve(arg.slice("--cwd=".length));
116
+ continue;
117
+ }
118
+ if (arg === "--model") {
119
+ const value = argv[++index];
120
+ if (!value || value.startsWith("--")) fail("--model requires a value");
121
+ args.model = value.trim();
122
+ continue;
123
+ }
124
+ if (arg.startsWith("--model=")) {
125
+ args.model = arg.slice("--model=".length).trim();
126
+ continue;
127
+ }
128
+ if (arg === "--prompt") {
129
+ const value = argv[++index];
130
+ if (!value || value.startsWith("--")) fail("--prompt requires a value");
131
+ args.prompt = value;
132
+ continue;
133
+ }
134
+ if (arg.startsWith("--prompt=")) {
135
+ args.prompt = arg.slice("--prompt=".length);
136
+ continue;
137
+ }
138
+ if (arg === "--out") {
139
+ const value = argv[++index];
140
+ if (!value || value.startsWith("--")) fail("--out requires a directory path");
141
+ args.out = resolve(value);
142
+ continue;
143
+ }
144
+ if (arg.startsWith("--out=")) {
145
+ args.out = resolve(arg.slice("--out=".length));
146
+ continue;
147
+ }
148
+ if (arg === "--setting-sources") {
149
+ const value = argv[++index];
150
+ if (!value || value.startsWith("--")) fail("--setting-sources requires a value");
151
+ args.settingSources = resolveCursorSettingSources(value);
152
+ continue;
153
+ }
154
+ if (arg.startsWith("--setting-sources=")) {
155
+ args.settingSources = resolveCursorSettingSources(arg.slice("--setting-sources=".length));
156
+ continue;
157
+ }
158
+ if (arg === "--api-key") {
159
+ const value = argv[++index];
160
+ if (!value || value.startsWith("--")) fail("--api-key requires a value");
161
+ args.apiKey = value.trim();
162
+ continue;
163
+ }
164
+ if (arg.startsWith("--api-key=")) {
165
+ args.apiKey = arg.slice("--api-key=".length).trim();
166
+ continue;
167
+ }
168
+ fail(`unknown argument: ${arg}`);
169
+ }
170
+ return args;
171
+ }
172
+
173
+ function defaultOutDir() {
174
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
175
+ return join("/tmp", `pi-cursor-sdk-sdk-events-${stamp}`);
176
+ }
177
+
178
+ function eventType(value) {
179
+ if (value && typeof value === "object" && typeof value.type === "string") return value.type;
180
+ return "unknown";
181
+ }
182
+
183
+ export function createTimingTracker() {
184
+ return {
185
+ eventCount: 0,
186
+ firstMs: undefined,
187
+ lastMs: undefined,
188
+ maxGapMs: undefined,
189
+ record(elapsedMs) {
190
+ if (this.eventCount === 0) {
191
+ this.firstMs = elapsedMs;
192
+ } else {
193
+ this.maxGapMs = Math.max(this.maxGapMs ?? 0, elapsedMs - (this.lastMs ?? elapsedMs));
194
+ }
195
+ this.eventCount += 1;
196
+ this.lastMs = elapsedMs;
197
+ },
198
+ snapshot() {
199
+ return {
200
+ eventCount: this.eventCount,
201
+ firstMs: this.firstMs,
202
+ lastMs: this.lastMs,
203
+ maxGapMs: this.maxGapMs,
204
+ };
205
+ },
206
+ };
207
+ }
208
+
209
+ export function createEventJsonlSink(artifactDir, startedAt) {
210
+ const paths = {
211
+ streamEvents: artifactPath(artifactDir, "streamEvents"),
212
+ onDelta: artifactPath(artifactDir, "onDelta"),
213
+ onStep: artifactPath(artifactDir, "onStep"),
214
+ };
215
+ for (const path of Object.values(paths)) {
216
+ writeFileSync(path, "");
217
+ }
218
+ const counts = {
219
+ stream: {},
220
+ onDelta: {},
221
+ onStep: {},
222
+ };
223
+ const timing = {
224
+ stream: createTimingTracker(),
225
+ onDelta: createTimingTracker(),
226
+ onStep: createTimingTracker(),
227
+ };
228
+
229
+ function append(pathKey, countKey, recordKey, value) {
230
+ const elapsedMs = Date.now() - startedAt;
231
+ const record = {
232
+ ts: new Date().toISOString(),
233
+ elapsedMs,
234
+ [recordKey]: value,
235
+ };
236
+ appendFileSync(paths[pathKey], `${JSON.stringify(record)}\n`);
237
+ const type = eventType(value);
238
+ counts[countKey][type] = (counts[countKey][type] ?? 0) + 1;
239
+ timing[countKey].record(elapsedMs);
240
+ return record;
241
+ }
242
+
243
+ return {
244
+ appendStream: (event) => append("streamEvents", "stream", "event", event),
245
+ appendDelta: (update) => append("onDelta", "onDelta", "update", update),
246
+ appendStep: (step) => append("onStep", "onStep", "step", step),
247
+ getSummaryState() {
248
+ return {
249
+ counts: {
250
+ stream: { ...counts.stream },
251
+ onDelta: { ...counts.onDelta },
252
+ onStep: { ...counts.onStep },
253
+ },
254
+ timing: {
255
+ stream: timing.stream.snapshot(),
256
+ onDelta: timing.onDelta.snapshot(),
257
+ onStep: timing.onStep.snapshot(),
258
+ },
259
+ };
260
+ },
261
+ close() {
262
+ return Promise.resolve();
263
+ },
264
+ };
265
+ }
266
+
267
+ function summarizeConversation(conversation) {
268
+ if (!conversation) return undefined;
269
+ if (Array.isArray(conversation)) return { turnCount: conversation.length };
270
+ return conversation;
271
+ }
272
+
273
+ export function buildSummary({
274
+ artifactDir,
275
+ counts,
276
+ timing,
277
+ waitResult,
278
+ conversation,
279
+ includeConversation,
280
+ }) {
281
+ return {
282
+ artifactDir,
283
+ files: {
284
+ metadata: artifactPath(artifactDir, "metadata"),
285
+ streamEvents: artifactPath(artifactDir, "streamEvents"),
286
+ onDelta: artifactPath(artifactDir, "onDelta"),
287
+ onStep: artifactPath(artifactDir, "onStep"),
288
+ waitResult: artifactPath(artifactDir, "waitResult"),
289
+ conversation: includeConversation ? artifactPath(artifactDir, "conversation") : undefined,
290
+ },
291
+ counts,
292
+ timing,
293
+ wait: waitResult
294
+ ? {
295
+ status: waitResult.status,
296
+ durationMs: waitResult.durationMs,
297
+ hasResultText: Boolean(waitResult.result?.trim()),
298
+ }
299
+ : undefined,
300
+ conversation: summarizeConversation(conversation),
301
+ warnings: [RAW_ARTIFACT_WARNING],
302
+ };
303
+ }
304
+
305
+ function printStdoutSummary(summary) {
306
+ console.log(JSON.stringify(summary, null, 2));
307
+ }
308
+
309
+ async function captureEvents(args) {
310
+ const artifactDir = args.out ?? defaultOutDir();
311
+ mkdirSync(artifactDir, { recursive: true });
312
+ const startedAt = Date.now();
313
+ const metadata = {
314
+ capturedAt: new Date(startedAt).toISOString(),
315
+ cwd: args.cwd,
316
+ model: args.model,
317
+ settingSources: args.settingSources ?? null,
318
+ prompt: args.prompt,
319
+ packageVersion: packageJson.version,
320
+ sdkVersion: readSdkVersion(),
321
+ includeConversation: args.includeConversation,
322
+ warnings: [RAW_ARTIFACT_WARNING],
323
+ };
324
+ writeFileSync(artifactPath(artifactDir, "metadata"), `${JSON.stringify(metadata, null, 2)}\n`);
325
+
326
+ const restoreOutputFilter = installCursorSdkOutputFilter();
327
+ const eventSink = createEventJsonlSink(artifactDir, startedAt);
328
+ let agent;
329
+ try {
330
+ const { Agent } = await suppressCursorSdkOutput(() => import("@cursor/sdk"));
331
+ agent = await suppressCursorSdkOutput(() =>
332
+ Agent.create({
333
+ apiKey: args.apiKey,
334
+ model: { id: args.model },
335
+ local: args.settingSources ? { cwd: args.cwd, settingSources: args.settingSources } : { cwd: args.cwd },
336
+ }),
337
+ );
338
+
339
+ const run = await suppressCursorSdkOutput(() =>
340
+ agent.send(
341
+ { text: args.prompt },
342
+ {
343
+ onDelta: ({ update }) => eventSink.appendDelta(update),
344
+ onStep: ({ step }) => eventSink.appendStep(step),
345
+ },
346
+ ),
347
+ );
348
+
349
+ await suppressCursorSdkOutput(async () => {
350
+ for await (const event of run.stream()) {
351
+ eventSink.appendStream(event);
352
+ }
353
+ });
354
+
355
+ const waitResult = await suppressCursorSdkOutput(() => run.wait());
356
+ writeFileSync(artifactPath(artifactDir, "waitResult"), `${JSON.stringify(waitResult, null, 2)}\n`);
357
+
358
+ let conversation;
359
+ if (args.includeConversation) {
360
+ if (run.supports("conversation")) {
361
+ conversation = await suppressCursorSdkOutput(() => run.conversation());
362
+ } else {
363
+ conversation = {
364
+ skipped: true,
365
+ reason: run.unsupportedReason("conversation") ?? "conversation unsupported",
366
+ };
367
+ }
368
+ writeFileSync(artifactPath(artifactDir, "conversation"), `${JSON.stringify(conversation, null, 2)}\n`);
369
+ }
370
+
371
+ const summary = buildSummary({
372
+ artifactDir,
373
+ ...eventSink.getSummaryState(),
374
+ waitResult,
375
+ conversation,
376
+ includeConversation: args.includeConversation,
377
+ });
378
+ writeFileSync(artifactPath(artifactDir, "summary"), `${JSON.stringify(summary, null, 2)}\n`);
379
+ printStdoutSummary(summary);
380
+ } catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ fail(message, [args.apiKey]);
383
+ } finally {
384
+ await eventSink.close().catch(() => {});
385
+ try {
386
+ agent?.close();
387
+ } finally {
388
+ restoreOutputFilter();
389
+ }
390
+ }
391
+ }
392
+
393
+ async function main(argv = process.argv.slice(2), env = process.env) {
394
+ const args = parseDebugSdkEventsArgs(argv, env);
395
+ if (args.help) {
396
+ printHelp();
397
+ process.exit(0);
398
+ }
399
+ if (!args.prompt?.trim()) {
400
+ fail("--prompt is required");
401
+ }
402
+ if (!args.apiKey) {
403
+ fail("Cursor API key is required. Set CURSOR_API_KEY or pass --api-key.");
404
+ }
405
+ await captureEvents(args);
406
+ }
407
+
408
+ if (import.meta.url === new URL(process.argv[1], "file:").href) {
409
+ main().catch((error) => {
410
+ const message = error instanceof Error ? error.message : String(error);
411
+ fail(message);
412
+ });
413
+ }
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
2
+ # Isolated /tmp install + fail-fast live smoke for pi-cursor-sdk native replay.
3
+ #
4
+ # Validates packed extension load, plan-strip resync, and absence of "Tool * not found".
5
+ set -euo pipefail
6
+
7
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
8
+ REAL_HOME="${REAL_HOME:-$HOME}"
9
+ PI_AGENT_DIR="${PI_AGENT_DIR:-$REAL_HOME/.pi/agent}"
10
+ AUTH_JSON="${AUTH_JSON:-$PI_AGENT_DIR/auth.json}"
11
+ REPO="${REPO:-$ROOT}"
12
+ ISOLATED="${ISOLATED:-/tmp/pi-cursor-sdk-isolated-$(date +%Y%m%dT%H%M%S)}"
13
+ PI_LIVE_TIMEOUT="${PI_LIVE_TIMEOUT:-45}"
14
+ SKIP_LIVE="${SKIP_LIVE:-0}"
15
+ SKIP_UNIT="${SKIP_UNIT:-0}"
16
+ PI_BIN="${PI_BIN:-pi}"
17
+ PI_PATH="${PI_PATH:-/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin}"
18
+
19
+ PACK_DIR="$ISOLATED/pack"
20
+ EXTRACT_DIR="$ISOLATED/extract"
21
+ PROJECT_DIR="$ISOLATED/project"
22
+ SESSION_ROOT="$ISOLATED/sessions"
23
+ SHIM_DIR="$ROOT/scripts/fixtures/plan-strip-shim"
24
+ HOME_DIR="$ISOLATED/home"
25
+
26
+ print_help() {
27
+ cat <<EOF
28
+ Isolated /tmp install smoke for pi-cursor-sdk (native replay + plan-strip resync).
29
+
30
+ Usage:
31
+ ./scripts/isolated-cursor-smoke.sh
32
+ SKIP_LIVE=1 ./scripts/isolated-cursor-smoke.sh
33
+ PI_LIVE_TIMEOUT=90 ./scripts/isolated-cursor-smoke.sh
34
+
35
+ Environment:
36
+ REPO Repo under test (default: script parent directory).
37
+ ISOLATED Artifact root (default: /tmp/pi-cursor-sdk-isolated-<timestamp>).
38
+ REAL_HOME Source for auth.json (default: \$HOME).
39
+ AUTH_JSON Path to pi auth.json to seed isolated HOME (default: ~/.pi/agent/auth.json).
40
+ PI_LIVE_TIMEOUT Per live pi check timeout in seconds (default: 45).
41
+ PI_BIN pi executable (default: pi on PATH).
42
+ PI_PATH PATH for isolated pi runs.
43
+ SKIP_LIVE=1 Run unit tests + pack only; skip live Cursor calls.
44
+ SKIP_UNIT=1 Skip repo unit tests (live checks only).
45
+ CURSOR_API_KEY Optional fallback when auth.json lacks cursor provider.
46
+
47
+ Prerequisites:
48
+ node, npm, pi, rg, python3 on PATH
49
+ ~/.pi/agent/auth.json with cursor provider OR CURSOR_API_KEY
50
+
51
+ Exit codes:
52
+ 0 all requested checks passed
53
+ 1 prerequisite, unit, pack, live smoke, or JSONL replay validation failure
54
+ EOF
55
+ }
56
+
57
+ log() {
58
+ printf '[isolated-smoke] %s\n' "$*"
59
+ }
60
+
61
+ fail() {
62
+ printf '[isolated-smoke] FAIL: %s\n' "$*" >&2
63
+ exit 1
64
+ }
65
+
66
+ seed_pi_agent_home() {
67
+ local home="$1"
68
+ mkdir -p "$home/.pi/agent"
69
+ if [[ -f "$AUTH_JSON" ]]; then
70
+ cp "$AUTH_JSON" "$home/.pi/agent/auth.json"
71
+ chmod 600 "$home/.pi/agent/auth.json"
72
+ log "seeded $home/.pi/agent/auth.json"
73
+ else
74
+ log "WARN: no auth.json at $AUTH_JSON"
75
+ fi
76
+ if [[ -f "$PI_AGENT_DIR/models.json" ]]; then
77
+ cp "$PI_AGENT_DIR/models.json" "$home/.pi/agent/models.json"
78
+ fi
79
+ }
80
+
81
+ has_auth_provider() {
82
+ local provider="$1"
83
+ python3 - "$provider" "$HOME_DIR/.pi/agent/auth.json" <<'PY'
84
+ import json, sys
85
+ provider, path = sys.argv[1], sys.argv[2]
86
+ try:
87
+ data = json.load(open(path))
88
+ except FileNotFoundError:
89
+ sys.exit(1)
90
+ sys.exit(0 if provider in data and data[provider] else 1)
91
+ PY
92
+ }
93
+
94
+ run_with_timeout() {
95
+ local label="$1"
96
+ local seconds="$2"
97
+ shift 2
98
+ log "$label (timeout ${seconds}s)"
99
+ if command -v timeout >/dev/null 2>&1; then
100
+ timeout --foreground "${seconds}s" "$@" || {
101
+ local rc=$?
102
+ [[ $rc -eq 124 ]] && fail "$label timed out after ${seconds}s"
103
+ fail "$label exited $rc"
104
+ }
105
+ return
106
+ fi
107
+ if command -v gtimeout >/dev/null 2>&1; then
108
+ gtimeout "${seconds}s" "$@" || {
109
+ local rc=$?
110
+ [[ $rc -eq 124 ]] && fail "$label timed out after ${seconds}s"
111
+ fail "$label exited $rc"
112
+ }
113
+ return
114
+ fi
115
+ "$@" &
116
+ local pid=$!
117
+ local waited=0
118
+ while kill -0 "$pid" 2>/dev/null; do
119
+ if (( waited >= seconds )); then
120
+ kill -TERM "$pid" 2>/dev/null || true
121
+ sleep 1
122
+ kill -KILL "$pid" 2>/dev/null || true
123
+ fail "$label timed out after ${seconds}s"
124
+ fi
125
+ sleep 1
126
+ waited=$((waited + 1))
127
+ done
128
+ wait "$pid" || fail "$label exited $?"
129
+ }
130
+
131
+ validate_replay_jsonl() {
132
+ local dir="$1"
133
+ node "$ROOT/scripts/validate-smoke-jsonl.mjs" --replay-errors-only "$dir"
134
+ }
135
+
136
+ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
137
+ print_help
138
+ exit 0
139
+ fi
140
+
141
+ if [[ -f "${SECRETS_FILE:-$REAL_HOME/.secrets}" ]]; then
142
+ set +u
143
+ # shellcheck disable=SC1090
144
+ source "${SECRETS_FILE:-$REAL_HOME/.secrets}"
145
+ set -u
146
+ fi
147
+
148
+ command -v node >/dev/null || fail "missing node"
149
+ command -v npm >/dev/null || fail "missing npm"
150
+ command -v rg >/dev/null || fail "missing rg"
151
+ command -v python3 >/dev/null || fail "missing python3"
152
+
153
+ mkdir -p "$PACK_DIR" "$EXTRACT_DIR" "$PROJECT_DIR" "$SESSION_ROOT" "$HOME_DIR"
154
+ seed_pi_agent_home "$HOME_DIR"
155
+
156
+ log "isolated root: $ISOLATED"
157
+ log "HOME=$HOME_DIR"
158
+
159
+ if [[ "$SKIP_UNIT" != "1" ]]; then
160
+ log "preflight: repo unit tests"
161
+ run_with_timeout "npm test" 120 bash -lc "cd '$REPO' && npm test"
162
+ fi
163
+
164
+ if [[ "$SKIP_LIVE" == "1" ]]; then
165
+ log "SKIP_LIVE=1 — skipping live pi checks"
166
+ exit 0
167
+ fi
168
+
169
+ if ! has_auth_provider cursor && [[ -z "${CURSOR_API_KEY:-}" ]]; then
170
+ fail "no cursor auth in $HOME_DIR/.pi/agent/auth.json and CURSOR_API_KEY unset"
171
+ fi
172
+
173
+ command -v "$PI_BIN" >/dev/null || fail "PI_BIN not found: $PI_BIN"
174
+
175
+ log "npm pack from $REPO"
176
+ (cd "$REPO" && npm pack --pack-destination "$PACK_DIR" >/dev/null 2>&1)
177
+ PACK_TGZ="$(ls -t "$PACK_DIR"/*.tgz | head -1)"
178
+ [[ -f "$PACK_TGZ" ]] || fail "missing pack tarball"
179
+ tar -xzf "$PACK_TGZ" -C "$EXTRACT_DIR"
180
+ [[ -d "$EXTRACT_DIR/package" ]] || fail "extract missing package/ dir"
181
+
182
+ log "npm install packed extension deps"
183
+ run_with_timeout "npm install --omit=dev" 120 bash -lc "cd '$EXTRACT_DIR/package' && npm install --omit=dev >/dev/null 2>&1"
184
+
185
+ log "pi install -l (clean HOME)"
186
+ cp "$REPO/README.md" "$PROJECT_DIR/README.md"
187
+ run_with_timeout "pi install" 30 env -i HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 \
188
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' install -l '$EXTRACT_DIR/package' >/dev/null"
189
+
190
+ run_with_timeout "pi list" 15 env -i HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 \
191
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' list" | rg -q "extract/package" || fail "packed extension not installed"
192
+
193
+ PI_ENV=(HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 PI_CURSOR_SETTING_SOURCES=none)
194
+ if [[ -n "${CURSOR_API_KEY:-}" ]]; then
195
+ PI_ENV+=(CURSOR_API_KEY="$CURSOR_API_KEY")
196
+ fi
197
+
198
+ log "check: list-models"
199
+ LIST_OUT="$ISOLATED/list-models.txt"
200
+ run_with_timeout "list-models" 30 env -i "${PI_ENV[@]}" \
201
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --list-models cursor > '$LIST_OUT' 2>&1"
202
+ rg -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2.5 not listed (see $LIST_OUT)"
203
+
204
+ log "check: basic provider prompt"
205
+ BASIC_DIR="$SESSION_ROOT/basic"
206
+ mkdir -p "$BASIC_DIR"
207
+ run_with_timeout "basic prompt" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" \
208
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$BASIC_DIR' --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK' > '$ISOLATED/basic.stdout.txt' 2> '$ISOLATED/basic.stderr.txt'"
209
+ rg -q "PI_CURSOR_ISOLATED_OK" "$ISOLATED/basic.stdout.txt" || fail "basic prompt missing PI_CURSOR_ISOLATED_OK"
210
+ validate_replay_jsonl "$BASIC_DIR"
211
+
212
+ log "check: native replay"
213
+ REPLAY_DIR="$SESSION_ROOT/native-replay"
214
+ mkdir -p "$REPLAY_DIR"
215
+ run_with_timeout "native replay" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
216
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$REPLAY_DIR' -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.' > '$ISOLATED/replay.stdout.txt' 2> '$ISOLATED/replay.stderr.txt'"
217
+ validate_replay_jsonl "$REPLAY_DIR"
218
+
219
+ log "check: plan-strip shim (plan-mode execute reset)"
220
+ PLAN_DIR="$SESSION_ROOT/plan-strip"
221
+ mkdir -p "$PLAN_DIR"
222
+ run_with_timeout "plan-strip replay" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
223
+ bash -c "cd '$PROJECT_DIR' && '$PI_BIN' -e '$SHIM_DIR' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$PLAN_DIR' -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.' > '$ISOLATED/plan.stdout.txt' 2> '$ISOLATED/plan.stderr.txt'"
224
+ validate_replay_jsonl "$PLAN_DIR"
225
+
226
+ log "PASS isolated install smoke: $ISOLATED"
@@ -0,0 +1,52 @@
1
+ export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
2
+
3
+ function escapeRegExp(value) {
4
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+
7
+ export function resolveCursorSettingSources(raw) {
8
+ const trimmed = raw?.trim();
9
+ if (!trimmed) return ["all"];
10
+ const normalized = trimmed.toLowerCase();
11
+ if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
12
+ if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
13
+ return trimmed
14
+ .split(",")
15
+ .map((entry) => entry.trim())
16
+ .filter(Boolean);
17
+ }
18
+
19
+ const BRIDGE_ENDPOINT_ROOT = "/cursor-pi-tool-bridge";
20
+ const BRIDGE_ENDPOINT_TOKEN_PATTERN = "[^/\\s\"'<>]+";
21
+ const BRIDGE_LOOPBACK_HOST_PATTERN = "127\\.0\\.0\\.1(?::\\d+)?";
22
+ const BRIDGE_ENDPOINT_PATH_PATTERN = `${escapeRegExp(BRIDGE_ENDPOINT_ROOT)}/${BRIDGE_ENDPOINT_TOKEN_PATTERN}/mcp`;
23
+
24
+ function scrubBridgeEndpointMaterial(text) {
25
+ return text
26
+ .replace(
27
+ new RegExp(`https?://${BRIDGE_LOOPBACK_HOST_PATTERN}${BRIDGE_ENDPOINT_PATH_PATTERN}`, "gi"),
28
+ "[redacted-bridge-endpoint]",
29
+ )
30
+ .replace(
31
+ new RegExp(`${BRIDGE_LOOPBACK_HOST_PATTERN}${BRIDGE_ENDPOINT_PATH_PATTERN}`, "gi"),
32
+ "[redacted-bridge-endpoint]",
33
+ )
34
+ .replace(new RegExp(BRIDGE_ENDPOINT_PATH_PATTERN, "gi"), "[redacted-bridge-endpoint]");
35
+ }
36
+
37
+ export function scrubSensitiveText(text, apiKey) {
38
+ let scrubbed = text;
39
+ const trimmedKey = apiKey?.trim();
40
+ if (trimmedKey) {
41
+ scrubbed = scrubbed.replace(new RegExp(escapeRegExp(trimmedKey), "g"), "[redacted]");
42
+ }
43
+ return scrubBridgeEndpointMaterial(
44
+ scrubbed
45
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
46
+ .replace(/((?:^|[\s,{])cookie["']?\s*[:=]\s*["']?)[^\n]+/gi, "$1[redacted]")
47
+ .replace(
48
+ /((?:authorization|api[_-]?key|apiKey|token|session(?:[_-]?id)?)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/gi,
49
+ "$1[redacted]",
50
+ ),
51
+ );
52
+ }