pi-cursor-sdk 0.1.20 → 0.1.22

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 (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +49 -9
  3. package/docs/cursor-dogfood-checklist.md +57 -0
  4. package/docs/cursor-live-smoke-checklist.md +115 -9
  5. package/docs/cursor-model-ux-spec.md +58 -18
  6. package/docs/cursor-native-tool-replay.md +15 -7
  7. package/docs/cursor-native-tool-visual-audit.md +104 -59
  8. package/docs/cursor-testing-lessons.md +8 -3
  9. package/docs/cursor-tool-surfaces.md +69 -0
  10. package/package.json +34 -10
  11. package/scripts/debug-provider-events.d.mts +59 -0
  12. package/scripts/debug-provider-events.mjs +70 -175
  13. package/scripts/debug-sdk-events.d.mts +90 -0
  14. package/scripts/debug-sdk-events.mjs +36 -98
  15. package/scripts/fixtures/plan-strip-shim/index.ts +12 -0
  16. package/scripts/isolated-cursor-smoke.sh +264 -102
  17. package/scripts/lib/cursor-child-process.d.mts +10 -0
  18. package/scripts/lib/cursor-child-process.mjs +50 -0
  19. package/scripts/lib/cursor-cli-args.d.mts +63 -0
  20. package/scripts/lib/cursor-cli-args.mjs +129 -0
  21. package/scripts/lib/cursor-script-fail.d.mts +1 -0
  22. package/scripts/lib/cursor-script-fail.mjs +13 -0
  23. package/scripts/lib/cursor-sdk-output-filter.d.mts +5 -0
  24. package/scripts/lib/cursor-smoke-env.d.mts +38 -0
  25. package/scripts/lib/cursor-smoke-env.mjs +81 -0
  26. package/scripts/lib/cursor-smoke-shell.sh +174 -0
  27. package/scripts/lib/cursor-visual-render.d.mts +15 -0
  28. package/scripts/lib/cursor-visual-render.mjs +131 -0
  29. package/scripts/probe-mcp-coldstart.mjs +20 -38
  30. package/scripts/refresh-cursor-model-snapshots.mjs +29 -65
  31. package/scripts/steering-rpc-smoke.mjs +170 -65
  32. package/scripts/tmux-live-smoke.sh +152 -98
  33. package/scripts/visual-tui-smoke.mjs +659 -0
  34. package/shared/cursor-sdk-event-debug-env.d.mts +12 -0
  35. package/shared/cursor-sdk-event-debug-env.mjs +13 -0
  36. package/shared/cursor-sensitive-text.d.mts +1 -0
  37. package/{scripts/lib/cursor-probe-utils.mjs → shared/cursor-sensitive-text.mjs} +1 -13
  38. package/shared/cursor-setting-sources.d.mts +5 -0
  39. package/shared/cursor-setting-sources.mjs +22 -0
  40. package/src/context.ts +21 -12
  41. package/src/cursor-bridge-contract.ts +1 -3
  42. package/src/cursor-incomplete-tool-visibility.ts +22 -5
  43. package/src/cursor-native-tool-display-registration.ts +63 -27
  44. package/src/cursor-native-tool-display-replay.ts +246 -144
  45. package/src/cursor-native-tool-display-state.ts +2 -0
  46. package/src/cursor-native-tool-display-tools.ts +149 -41
  47. package/src/cursor-provider-live-run-drain.ts +1 -52
  48. package/src/cursor-provider-run-finalizer.ts +237 -0
  49. package/src/cursor-provider-run-outcome.ts +149 -0
  50. package/src/cursor-provider-turn-api-key.ts +8 -0
  51. package/src/cursor-provider-turn-coordinator.ts +98 -446
  52. package/src/cursor-provider-turn-display-router.ts +216 -0
  53. package/src/cursor-provider-turn-emit.ts +59 -0
  54. package/src/cursor-provider-turn-finalize.ts +119 -0
  55. package/src/cursor-provider-turn-lifecycle-emitter.ts +97 -0
  56. package/src/cursor-provider-turn-message-offset.ts +15 -0
  57. package/src/cursor-provider-turn-prepare.ts +216 -0
  58. package/src/cursor-provider-turn-runner.ts +140 -0
  59. package/src/cursor-provider-turn-sdk-normalizer.ts +88 -0
  60. package/src/cursor-provider-turn-send.ts +103 -0
  61. package/src/cursor-provider-turn-shell-output.ts +107 -0
  62. package/src/cursor-provider-turn-tool-ledger.ts +126 -0
  63. package/src/cursor-provider-turn-types.ts +87 -0
  64. package/src/cursor-provider.ts +16 -504
  65. package/src/cursor-replay-activity-builders.ts +276 -0
  66. package/src/cursor-replay-source-names.ts +33 -0
  67. package/src/cursor-replay-summary-args.ts +191 -0
  68. package/src/cursor-replay-tool-details.ts +464 -0
  69. package/src/cursor-run-final-text.ts +56 -0
  70. package/src/cursor-sdk-abort-error-guard.ts +4 -0
  71. package/src/cursor-sdk-event-debug-constants.ts +14 -5
  72. package/src/cursor-sdk-event-debug.ts +2 -1
  73. package/src/cursor-sensitive-text.ts +3 -36
  74. package/src/cursor-session-agent.ts +3 -1
  75. package/src/cursor-session-compaction-prep.ts +19 -0
  76. package/src/cursor-setting-sources.ts +7 -10
  77. package/src/cursor-state.ts +232 -28
  78. package/src/cursor-tool-lifecycle.ts +9 -8
  79. package/src/cursor-tool-manifest.ts +41 -0
  80. package/src/cursor-tool-names.ts +18 -106
  81. package/src/cursor-tool-presentation-registry.ts +556 -0
  82. package/src/cursor-tool-transcript.ts +1 -1
  83. package/src/cursor-tool-visibility.ts +3 -27
  84. package/src/cursor-transcript-tool-formatters.ts +0 -59
  85. package/src/cursor-transcript-tool-specs.ts +158 -233
  86. package/src/cursor-transcript-utils.ts +0 -44
  87. package/src/cursor-web-tool-activity.ts +10 -60
  88. package/src/cursor-web-tool-args.ts +39 -0
  89. package/src/index.ts +8 -10
@@ -5,12 +5,17 @@
5
5
  */
6
6
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { createRequire } from "node:module";
8
- import { dirname, join, resolve } from "node:path";
8
+ import { dirname, join } from "node:path";
9
9
  import {
10
- CURSOR_SETTING_SOURCES_ENV,
11
- resolveCursorSettingSources,
12
- scrubSensitiveText,
13
- } from "./lib/cursor-probe-utils.mjs";
10
+ apiKeySecretsFromProcess,
11
+ commonProbeFlags,
12
+ defaultApiKeyFromEnv,
13
+ defaultSettingSourcesFromEnv,
14
+ defaultTimestampedDir,
15
+ parseArgv,
16
+ requireApiKey,
17
+ } from "./lib/cursor-cli-args.mjs";
18
+ import { createScriptFail } from "./lib/cursor-script-fail.mjs";
14
19
  import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
15
20
 
16
21
  const require = createRequire(import.meta.url);
@@ -78,101 +83,36 @@ Safety:
78
83
  https://cursor.com/docs/sdk/typescript before drawing integration conclusions.`);
79
84
  }
80
85
 
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
+ const fail = createScriptFail("debug-sdk-events");
86
87
 
87
88
  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
- }
89
+ const includeConversation = argv.includes("--include-conversation");
90
+ const filteredArgv = argv.filter((arg) => arg !== "--include-conversation");
91
+ const args = parseArgv(filteredArgv, {
92
+ defaults: {
93
+ cwd: process.cwd(),
94
+ model: DEFAULT_MODEL,
95
+ prompt: undefined,
96
+ out: undefined,
97
+ settingSources: defaultSettingSourcesFromEnv(env),
98
+ includeConversation,
99
+ apiKey: defaultApiKeyFromEnv(env),
100
+ },
101
+ flags: {
102
+ cwd: commonProbeFlags.cwd,
103
+ model: commonProbeFlags.model,
104
+ prompt: commonProbeFlags.prompt,
105
+ out: commonProbeFlags.out,
106
+ apiKey: commonProbeFlags.apiKey,
107
+ settingSources: commonProbeFlags.settingSources,
108
+ },
109
+ fail,
110
+ });
170
111
  return args;
171
112
  }
172
113
 
173
114
  function defaultOutDir() {
174
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
175
- return join("/tmp", `pi-cursor-sdk-sdk-events-${stamp}`);
115
+ return defaultTimestampedDir("pi-cursor-sdk-sdk-events");
176
116
  }
177
117
 
178
118
  function eventType(value) {
@@ -399,15 +339,13 @@ async function main(argv = process.argv.slice(2), env = process.env) {
399
339
  if (!args.prompt?.trim()) {
400
340
  fail("--prompt is required");
401
341
  }
402
- if (!args.apiKey) {
403
- fail("Cursor API key is required. Set CURSOR_API_KEY or pass --api-key.");
404
- }
342
+ args.apiKey = requireApiKey(args, env, fail);
405
343
  await captureEvents(args);
406
344
  }
407
345
 
408
346
  if (import.meta.url === new URL(process.argv[1], "file:").href) {
409
347
  main().catch((error) => {
410
348
  const message = error instanceof Error ? error.message : String(error);
411
- fail(message);
349
+ fail(message, apiKeySecretsFromProcess());
412
350
  });
413
351
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Simulates plan-mode execute: strips grep/find/cursor before turn_start resync.
3
+ */
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
7
+
8
+ export default function planStripShim(pi: ExtensionAPI): void {
9
+ pi.on("turn_start", () => {
10
+ pi.setActiveTools(NORMAL_MODE_TOOLS);
11
+ });
12
+ }
@@ -5,6 +5,10 @@
5
5
  set -euo pipefail
6
6
 
7
7
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
8
+ # shellcheck source=scripts/lib/cursor-smoke-shell.sh
9
+ . "$ROOT/scripts/lib/cursor-smoke-shell.sh"
10
+ SMOKE_LOG_PREFIX=isolated-smoke
11
+
8
12
  REAL_HOME="${REAL_HOME:-$HOME}"
9
13
  PI_AGENT_DIR="${PI_AGENT_DIR:-$REAL_HOME/.pi/agent}"
10
14
  AUTH_JSON="${AUTH_JSON:-$PI_AGENT_DIR/auth.json}"
@@ -13,8 +17,6 @@ ISOLATED="${ISOLATED:-/tmp/pi-cursor-sdk-isolated-$(date +%Y%m%dT%H%M%S)}"
13
17
  PI_LIVE_TIMEOUT="${PI_LIVE_TIMEOUT:-45}"
14
18
  SKIP_LIVE="${SKIP_LIVE:-0}"
15
19
  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
20
 
19
21
  PACK_DIR="$ISOLATED/pack"
20
22
  EXTRACT_DIR="$ISOLATED/extract"
@@ -23,6 +25,19 @@ SESSION_ROOT="$ISOLATED/sessions"
23
25
  SHIM_DIR="$ROOT/scripts/fixtures/plan-strip-shim"
24
26
  HOME_DIR="$ISOLATED/home"
25
27
 
28
+ PI_BIN="${PI_BIN:-}"
29
+ NODE_BIN=""
30
+ NPM_BIN=""
31
+ RG_BIN=""
32
+ ENV_BIN=""
33
+ SHELL_BIN="${BASH:-/bin/bash}"
34
+ SEALED_PATH=""
35
+ DEBUG_ENV_UNSETS=()
36
+ TOOL_ENV=()
37
+ PI_DEFAULT_ENV=()
38
+ PI_NONE_ENV=()
39
+ SELF_TEST_TEMP_DIR=""
40
+
26
41
  print_help() {
27
42
  cat <<EOF
28
43
  Isolated /tmp install smoke for pi-cursor-sdk (native replay + plan-strip resync).
@@ -38,15 +53,21 @@ Environment:
38
53
  REAL_HOME Source for auth.json (default: \$HOME).
39
54
  AUTH_JSON Path to pi auth.json to seed isolated HOME (default: ~/.pi/agent/auth.json).
40
55
  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.
56
+ PI_BIN Optional pi command/path to resolve from the parent PATH (default: pi).
43
57
  SKIP_LIVE=1 Run unit tests + pack only; skip live Cursor calls.
44
58
  SKIP_UNIT=1 Skip repo unit tests (live checks only).
45
59
  CURSOR_API_KEY Optional fallback when auth.json lacks cursor provider.
46
60
 
47
61
  Prerequisites:
48
- node, npm, pi, rg, python3 on PATH
49
- ~/.pi/agent/auth.json with cursor provider OR CURSOR_API_KEY
62
+ SKIP_LIVE=1: node, npm, env, tar on PATH; pi is not required.
63
+ Live checks: pi, rg, python3, and ~/.pi/agent/auth.json with cursor provider OR CURSOR_API_KEY.
64
+ Resolved node/npm/env paths from the parent shell are reused for pack-only work; live checks then resolve pi/rg.
65
+ Pi and npm shims run with the resolved node directory first on PATH.
66
+ Child pi runs clear Cursor SDK event-debug env. Live provider checks force PI_CURSOR_SETTING_SOURCES=none; install/list checks explicitly unset it.
67
+
68
+ Options:
69
+ -h, --help Show this help.
70
+ --self-test Run sealed PATH/env probes without live Cursor auth.
50
71
 
51
72
  Exit codes:
52
73
  0 all requested checks passed
@@ -54,89 +75,213 @@ Exit codes:
54
75
  EOF
55
76
  }
56
77
 
57
- log() {
58
- printf '[isolated-smoke] %s\n' "$*"
78
+ log() { smoke_log "$@"; }
79
+ fail() { smoke_fail "$@"; }
80
+ seed_pi_agent_home() { smoke_seed_pi_agent_home "$@"; }
81
+ has_auth_provider() { smoke_has_auth_provider "$1" "$HOME_DIR/.pi/agent/auth.json"; }
82
+ run_with_timeout() { smoke_run_with_timeout_or_fail "$@"; }
83
+
84
+ build_smoke_env_arrays() {
85
+ smoke_build_cursor_sdk_event_debug_unsets
86
+ DEBUG_ENV_UNSETS=( "${SMOKE_CURSOR_SDK_EVENT_DEBUG_ENV_UNSETS[@]}" )
87
+ TOOL_ENV=( "$ENV_BIN" "${DEBUG_ENV_UNSETS[@]}" "PATH=$SEALED_PATH" )
88
+ PI_DEFAULT_ENV=( "$ENV_BIN" -i "${DEBUG_ENV_UNSETS[@]}" -u PI_CURSOR_SETTING_SOURCES HOME="$HOME_DIR" PATH="$SEALED_PATH" MISE_DISABLE=1 )
89
+ PI_NONE_ENV=( "$ENV_BIN" -i "${DEBUG_ENV_UNSETS[@]}" HOME="$HOME_DIR" PATH="$SEALED_PATH" MISE_DISABLE=1 PI_CURSOR_SETTING_SOURCES=none )
59
90
  }
60
91
 
61
- fail() {
62
- printf '[isolated-smoke] FAIL: %s\n' "$*" >&2
63
- exit 1
92
+ run_in_dir() {
93
+ local label="$1"
94
+ local timeout_secs="$2"
95
+ local dir="$3"
96
+ shift 3
97
+ run_with_timeout "$label" "$timeout_secs" "$SHELL_BIN" -c 'cd "$1" || exit 97; shift; exec "$@"' sh "$dir" "$@"
64
98
  }
65
99
 
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
100
+ run_in_dir_capture_combined() {
101
+ local label="$1"
102
+ local timeout_secs="$2"
103
+ local dir="$3"
104
+ local output="$4"
105
+ shift 4
106
+ run_with_timeout "$label" "$timeout_secs" "$SHELL_BIN" -c 'cd "$1" || exit 97; output="$2"; shift 2; exec "$@" >"$output" 2>&1' sh "$dir" "$output" "$@"
79
107
  }
80
108
 
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
109
+ run_in_dir_capture_split() {
110
+ local label="$1"
111
+ local timeout_secs="$2"
112
+ local dir="$3"
113
+ local stdout="$4"
114
+ local stderr="$5"
115
+ shift 5
116
+ run_with_timeout "$label" "$timeout_secs" "$SHELL_BIN" -c 'cd "$1" || exit 97; stdout="$2"; stderr="$3"; shift 3; exec "$@" </dev/null >"$stdout" 2>"$stderr"' sh "$dir" "$stdout" "$stderr" "$@"
92
117
  }
93
118
 
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
119
+ validate_replay_jsonl() {
120
+ local dir="$1"
121
+ "$NODE_BIN" "$ROOT/scripts/validate-smoke-jsonl.mjs" --replay-errors-only "$dir"
122
+ }
123
+
124
+ run_self_test() {
125
+ local temp_dir bin_dir fake_pi fake_node fake_node_marker env_capture hostile_path captured_path node_dir name
126
+ local old_path old_pi_bin old_pi_bin_was_set
127
+ local no_pi_bin fake_npm fake_npm_marker no_pi_repo no_pi_isolated no_pi_path no_pi_output_file no_pi_status
128
+ temp_dir="$(mktemp -d /tmp/pi-cursor-sdk-isolated-smoke-self-test.XXXXXX)"
129
+ SELF_TEST_TEMP_DIR="$temp_dir"
130
+ trap '[[ -z "${SELF_TEST_TEMP_DIR:-}" ]] || rm -rf "$SELF_TEST_TEMP_DIR"' EXIT
131
+ bin_dir="$temp_dir/bin"
132
+ mkdir -p "$bin_dir"
133
+ fake_pi="$bin_dir/pi"
134
+ fake_node="$bin_dir/node"
135
+ fake_node_marker="$temp_dir/fake-node-used"
136
+ env_capture="$temp_dir/fake-pi.env"
137
+ cat >"$fake_pi" <<EOF_SELFTEST_PI
138
+ #!/usr/bin/env node
139
+ const { writeFileSync } = require("node:fs");
140
+ writeFileSync("$env_capture", Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");
141
+ EOF_SELFTEST_PI
142
+ cat >"$fake_node" <<EOF_SELFTEST_NODE
143
+ #!/usr/bin/env bash
144
+ echo fake-node-used > "$fake_node_marker"
145
+ exit 99
146
+ EOF_SELFTEST_NODE
147
+ chmod +x "$fake_pi" "$fake_node"
148
+
149
+ ENV_BIN="$(smoke_resolve_cmd env)"
150
+ NODE_BIN="$(smoke_resolve_cmd node)"
151
+ if [[ "$SHELL_BIN" != /* ]]; then
152
+ SHELL_BIN="$(smoke_resolve_cmd "$SHELL_BIN")"
106
153
  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
154
+ smoke_load_cursor_sdk_event_debug_env_names "$NODE_BIN" "$ROOT/shared/cursor-sdk-event-debug-env.mjs"
155
+ hostile_path="$bin_dir:$PATH"
156
+ old_path="$PATH"
157
+ old_pi_bin="${PI_BIN-}"
158
+ old_pi_bin_was_set=0
159
+ [[ ${PI_BIN+x} ]] && old_pi_bin_was_set=1
160
+ unset PI_BIN
161
+ PATH="$hostile_path"
162
+ [[ "$(smoke_resolve_cmd "${PI_BIN:-pi}")" == "$fake_pi" ]] || fail "self-test failed: default PI_BIN did not resolve through parent PATH"
163
+ PI_BIN="$fake_pi"
164
+ [[ "$(smoke_resolve_cmd "${PI_BIN:-pi}")" == "$fake_pi" ]] || fail "self-test failed: absolute PI_BIN was not honored"
165
+ PATH="$old_path"
166
+ if (( old_pi_bin_was_set )); then
167
+ PI_BIN="$old_pi_bin"
168
+ else
169
+ unset PI_BIN
114
170
  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"
171
+
172
+ [[ "$(smoke_build_sealed_node_path "$NODE_BIN" "")" != *: ]] || fail "self-test failed: empty inherited PATH left a trailing PATH separator"
173
+ SEALED_PATH="$(smoke_build_sealed_node_path "$NODE_BIN" "$hostile_path")"
174
+ HOME_DIR="$temp_dir/home"
175
+ mkdir -p "$HOME_DIR"
176
+ build_smoke_env_arrays
177
+ node_dir="$(dirname "$NODE_BIN")"
178
+
179
+ PI_CURSOR_SETTING_SOURCES=all \
180
+ PI_CURSOR_SDK_EVENT_DEBUG=1 \
181
+ PI_CURSOR_SDK_EVENT_DEBUG_DIR="$temp_dir/debug-dir" \
182
+ PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR="$temp_dir/debug-run-dir" \
183
+ PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR="$temp_dir/debug-session-dir" \
184
+ PI_CURSOR_SDK_EVENT_DEBUG_STDERR=1 \
185
+ "${PI_NONE_ENV[@]}" "$fake_pi" --version
186
+ [[ ! -e "$fake_node_marker" ]] || fail "self-test failed: sealed PATH still used hostile fake node"
187
+ captured_path="$(awk -F= '$1 == "PATH" { print substr($0, 6); exit }' "$env_capture")"
188
+ [[ "${captured_path%%:*}" == "$node_dir" ]] || fail "self-test failed: PATH did not start with resolved node dir"
189
+ grep -qx "HOME=$HOME_DIR" "$env_capture" || fail "self-test failed: isolated HOME was not set"
190
+ grep -qx 'MISE_DISABLE=1' "$env_capture" || fail "self-test failed: MISE_DISABLE was not set"
191
+ grep -qx 'PI_CURSOR_SETTING_SOURCES=none' "$env_capture" || fail "self-test failed: live pi env did not force PI_CURSOR_SETTING_SOURCES=none"
192
+ for name in "${SMOKE_CURSOR_SDK_EVENT_DEBUG_ENV_NAMES[@]}"; do
193
+ if grep -q "^${name}=" "$env_capture"; then
194
+ fail "self-test failed: $name was not cleared"
124
195
  fi
125
- sleep 1
126
- waited=$((waited + 1))
127
196
  done
128
- wait "$pid" || fail "$label exited $?"
129
- }
130
197
 
131
- validate_replay_jsonl() {
132
- local dir="$1"
133
- node "$ROOT/scripts/validate-smoke-jsonl.mjs" --replay-errors-only "$dir"
198
+ PI_CURSOR_SETTING_SOURCES=all \
199
+ PI_CURSOR_SDK_EVENT_DEBUG=1 \
200
+ PI_CURSOR_SDK_EVENT_DEBUG_DIR="$temp_dir/debug-dir" \
201
+ PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR="$temp_dir/debug-run-dir" \
202
+ PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR="$temp_dir/debug-session-dir" \
203
+ PI_CURSOR_SDK_EVENT_DEBUG_STDERR=1 \
204
+ "${PI_DEFAULT_ENV[@]}" "$fake_pi" --version
205
+ if grep -q '^PI_CURSOR_SETTING_SOURCES=' "$env_capture"; then
206
+ fail "self-test failed: default pi env did not unset PI_CURSOR_SETTING_SOURCES"
207
+ fi
208
+ for name in "${SMOKE_CURSOR_SDK_EVENT_DEBUG_ENV_NAMES[@]}"; do
209
+ if grep -q "^${name}=" "$env_capture"; then
210
+ fail "self-test failed: default pi env leaked $name"
211
+ fi
212
+ done
213
+
214
+ PI_CURSOR_SDK_EVENT_DEBUG=1 \
215
+ PI_CURSOR_SDK_EVENT_DEBUG_DIR="$temp_dir/debug-dir" \
216
+ "${TOOL_ENV[@]}" "$fake_pi" --version
217
+ [[ ! -e "$fake_node_marker" ]] || fail "self-test failed: tool env still used hostile fake node"
218
+ captured_path="$(awk -F= '$1 == "PATH" { print substr($0, 6); exit }' "$env_capture")"
219
+ [[ "${captured_path%%:*}" == "$node_dir" ]] || fail "self-test failed: tool PATH did not start with resolved node dir"
220
+ for name in "${SMOKE_CURSOR_SDK_EVENT_DEBUG_ENV_NAMES[@]}"; do
221
+ if grep -q "^${name}=" "$env_capture"; then
222
+ fail "self-test failed: tool env leaked $name"
223
+ fi
224
+ done
225
+
226
+ no_pi_bin="$temp_dir/no-pi-bin"
227
+ fake_npm="$no_pi_bin/npm"
228
+ fake_npm_marker="$temp_dir/fake-npm-pack-used"
229
+ no_pi_repo="$temp_dir/no-pi-repo"
230
+ no_pi_isolated="$temp_dir/no-pi-isolated"
231
+ mkdir -p "$no_pi_bin" "$no_pi_repo"
232
+ ln -s "$NODE_BIN" "$no_pi_bin/node"
233
+ cat >"$fake_npm" <<EOF_SELFTEST_NPM
234
+ #!/usr/bin/env bash
235
+ set -euo pipefail
236
+ if [[ "\${1:-}" != "pack" ]]; then
237
+ printf 'fake npm only supports pack, got: %s\\n' "\$*" >&2
238
+ exit 64
239
+ fi
240
+ destination=""
241
+ shift
242
+ while (( \$# )); do
243
+ case "\$1" in
244
+ --pack-destination)
245
+ destination="\${2:-}"
246
+ shift 2
247
+ ;;
248
+ *)
249
+ shift
250
+ ;;
251
+ esac
252
+ done
253
+ [[ -n "\$destination" ]] || { printf 'missing --pack-destination\\n' >&2; exit 64; }
254
+ mkdir -p "\$destination/.fake/package"
255
+ printf '{"name":"fake-package","version":"1.0.0"}\\n' > "\$destination/.fake/package/package.json"
256
+ tar -czf "\$destination/fake-package-1.0.0.tgz" -C "\$destination/.fake" package
257
+ printf 'pack\\n' > "$fake_npm_marker"
258
+ EOF_SELFTEST_NPM
259
+ chmod +x "$fake_npm"
260
+ no_pi_path="$no_pi_bin:/usr/bin:/bin"
261
+ no_pi_output_file="$temp_dir/no-pi-output.txt"
262
+ set +e
263
+ PATH="$no_pi_path" REAL_HOME="$temp_dir/no-auth" PI_BIN=pi-must-not-exist REPO="$no_pi_repo" ISOLATED="$no_pi_isolated" SKIP_LIVE=1 SKIP_UNIT=1 "$SHELL_BIN" "$ROOT/scripts/isolated-cursor-smoke.sh" >"$no_pi_output_file" 2>&1
264
+ no_pi_status=$?
265
+ set -e
266
+ if [[ "$no_pi_status" != "0" ]]; then
267
+ cat "$no_pi_output_file" >&2 || true
268
+ fail "self-test failed: SKIP_LIVE=1 required pi or another live-only prerequisite"
269
+ fi
270
+ [[ -f "$fake_npm_marker" ]] || fail "self-test failed: no-pi SKIP_LIVE path did not run pack"
271
+ ! grep -q 'missing required command: pi' "$no_pi_output_file" || fail "self-test failed: no-pi SKIP_LIVE path still resolved pi"
272
+ grep -q 'SKIP_LIVE=1' "$no_pi_output_file" || fail "self-test failed: no-pi SKIP_LIVE path did not reach skip-live exit"
273
+
274
+ printf '[isolated-smoke] self-test PASS\n'
134
275
  }
135
276
 
136
277
  if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
137
278
  print_help
138
279
  exit 0
139
280
  fi
281
+ if [[ "${1:-}" == "--self-test" ]]; then
282
+ run_self_test
283
+ exit 0
284
+ fi
140
285
 
141
286
  if [[ -f "${SECRETS_FILE:-$REAL_HOME/.secrets}" ]]; then
142
287
  set +u
@@ -145,82 +290,99 @@ if [[ -f "${SECRETS_FILE:-$REAL_HOME/.secrets}" ]]; then
145
290
  set -u
146
291
  fi
147
292
 
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"
293
+ NODE_BIN="$(smoke_resolve_cmd node)"
294
+ NPM_BIN="$(smoke_resolve_cmd npm)"
295
+ ENV_BIN="$(smoke_resolve_cmd env)"
296
+ if [[ "$SHELL_BIN" != /* ]]; then
297
+ SHELL_BIN="$(smoke_resolve_cmd "$SHELL_BIN")"
298
+ fi
299
+ smoke_load_cursor_sdk_event_debug_env_names "$NODE_BIN" "$ROOT/shared/cursor-sdk-event-debug-env.mjs"
300
+ SEALED_PATH="$(smoke_build_sealed_node_path "$NODE_BIN" "$PATH")"
301
+ build_smoke_env_arrays
152
302
 
153
303
  mkdir -p "$PACK_DIR" "$EXTRACT_DIR" "$PROJECT_DIR" "$SESSION_ROOT" "$HOME_DIR"
154
304
  seed_pi_agent_home "$HOME_DIR"
155
305
 
156
306
  log "isolated root: $ISOLATED"
157
307
  log "HOME=$HOME_DIR"
308
+ log "node=$NODE_BIN"
309
+ log "npm=$NPM_BIN"
158
310
 
159
311
  if [[ "$SKIP_UNIT" != "1" ]]; then
160
312
  log "preflight: repo unit tests"
161
- run_with_timeout "npm test" 120 bash -lc "cd '$REPO' && npm test"
313
+ run_in_dir "npm test" 120 "$REPO" "${TOOL_ENV[@]}" "$NPM_BIN" test
162
314
  fi
163
315
 
316
+ log "npm pack from $REPO"
317
+ run_in_dir_capture_combined "npm pack" 120 "$REPO" "$ISOLATED/npm-pack.log" "${TOOL_ENV[@]}" "$NPM_BIN" pack --pack-destination "$PACK_DIR"
318
+ PACK_TGZ=""
319
+ for candidate in "$PACK_DIR"/*.tgz; do
320
+ [[ -e "$candidate" ]] || continue
321
+ if [[ -z "$PACK_TGZ" || "$candidate" -nt "$PACK_TGZ" ]]; then
322
+ PACK_TGZ="$candidate"
323
+ fi
324
+ done
325
+ [[ -n "$PACK_TGZ" && -f "$PACK_TGZ" ]] || fail "missing pack tarball"
326
+ tar -xzf "$PACK_TGZ" -C "$EXTRACT_DIR"
327
+ [[ -d "$EXTRACT_DIR/package" ]] || fail "extract missing package/ dir"
328
+
164
329
  if [[ "$SKIP_LIVE" == "1" ]]; then
165
- log "SKIP_LIVE=1 — skipping live pi checks"
330
+ log "SKIP_LIVE=1 — skipping live pi checks after unit + pack"
166
331
  exit 0
167
332
  fi
168
333
 
334
+ PI_BIN="$(smoke_resolve_cmd "${PI_BIN:-pi}")"
335
+ RG_BIN="$(smoke_resolve_cmd rg)"
336
+ smoke_require_cmd python3
337
+ log "pi=$PI_BIN"
338
+ log "rg=$RG_BIN"
339
+
169
340
  if ! has_auth_provider cursor && [[ -z "${CURSOR_API_KEY:-}" ]]; then
170
341
  fail "no cursor auth in $HOME_DIR/.pi/agent/auth.json and CURSOR_API_KEY unset"
171
342
  fi
172
343
 
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
344
  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"
345
+ run_in_dir_capture_combined "npm install --omit=dev" 120 "$EXTRACT_DIR/package" "$ISOLATED/npm-install.log" "${TOOL_ENV[@]}" "$NPM_BIN" install --omit=dev
184
346
 
185
347
  log "pi install -l (clean HOME)"
186
348
  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"
349
+ run_in_dir_capture_combined "pi install" 30 "$PROJECT_DIR" "$ISOLATED/pi-install.log" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" install -l "$EXTRACT_DIR/package"
189
350
 
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"
351
+ PI_LIST_OUT="$ISOLATED/pi-list.txt"
352
+ run_in_dir_capture_combined "pi list" 15 "$PROJECT_DIR" "$PI_LIST_OUT" "${PI_DEFAULT_ENV[@]}" "$PI_BIN" list
353
+ "$RG_BIN" -q "extract/package" "$PI_LIST_OUT" || fail "packed extension not installed"
192
354
 
193
- PI_ENV=(HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 PI_CURSOR_SETTING_SOURCES=none)
355
+ PI_CURSOR_ENV=( "${PI_NONE_ENV[@]}" )
194
356
  if [[ -n "${CURSOR_API_KEY:-}" ]]; then
195
- PI_ENV+=(CURSOR_API_KEY="$CURSOR_API_KEY")
357
+ PI_CURSOR_ENV+=( CURSOR_API_KEY="$CURSOR_API_KEY" )
196
358
  fi
197
359
 
198
360
  log "check: list-models"
199
361
  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)"
362
+ run_in_dir_capture_combined "list-models" 30 "$PROJECT_DIR" "$LIST_OUT" "${PI_CURSOR_ENV[@]}" \
363
+ "$PI_BIN" --cursor-no-fast --list-models cursor
364
+ "$RG_BIN" -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2.5 not listed (see $LIST_OUT)"
203
365
 
204
366
  log "check: basic provider prompt"
205
367
  BASIC_DIR="$SESSION_ROOT/basic"
206
368
  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"
369
+ run_in_dir_capture_split "basic prompt" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/basic.stdout.txt" "$ISOLATED/basic.stderr.txt" "${PI_CURSOR_ENV[@]}" \
370
+ "$PI_BIN" --cursor-no-fast --model cursor/composer-2.5 --session-dir "$BASIC_DIR" --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK'
371
+ "$RG_BIN" -q "PI_CURSOR_ISOLATED_OK" "$ISOLATED/basic.stdout.txt" || fail "basic prompt missing PI_CURSOR_ISOLATED_OK"
210
372
  validate_replay_jsonl "$BASIC_DIR"
211
373
 
212
374
  log "check: native replay"
213
375
  REPLAY_DIR="$SESSION_ROOT/native-replay"
214
376
  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'"
377
+ run_in_dir_capture_split "native replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/replay.stdout.txt" "$ISOLATED/replay.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
378
+ "$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.'
217
379
  validate_replay_jsonl "$REPLAY_DIR"
218
380
 
219
381
  log "check: plan-strip shim (plan-mode execute reset)"
220
382
  PLAN_DIR="$SESSION_ROOT/plan-strip"
221
383
  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'"
384
+ run_in_dir_capture_split "plan-strip replay" "$PI_LIVE_TIMEOUT" "$PROJECT_DIR" "$ISOLATED/plan.stdout.txt" "$ISOLATED/plan.stderr.txt" "${PI_CURSOR_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
385
+ "$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.'
224
386
  validate_replay_jsonl "$PLAN_DIR"
225
387
 
226
388
  log "PASS isolated install smoke: $ISOLATED"