lacy 1.8.11 → 1.8.13

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 (109) hide show
  1. package/.claude/settings.local.json +26 -0
  2. package/.github/FUNDING.yml +3 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +17 -0
  7. package/.github/SECURITY.md +32 -0
  8. package/.github/assets/logo-horizontal-dark.png +0 -0
  9. package/.github/assets/logo-horizontal-dark.svg +17 -0
  10. package/.github/assets/logo-horizontal.png +0 -0
  11. package/.github/assets/logo-horizontal.svg +17 -0
  12. package/.github/assets/logo.png +0 -0
  13. package/.github/assets/logo.svg +12 -0
  14. package/.github/assets/social-preview.png +0 -0
  15. package/.github/assets/social-preview.svg +50 -0
  16. package/.github/dependabot.yml +21 -0
  17. package/.github/workflows/ci.yml +80 -0
  18. package/.github/workflows/dependabot-auto-merge.yml +32 -0
  19. package/CHANGELOG.md +366 -0
  20. package/CLAUDE.md +340 -0
  21. package/CONTRIBUTING.md +141 -0
  22. package/LICENSE +110 -0
  23. package/README.md +201 -31
  24. package/RELEASING.md +148 -0
  25. package/STYLE.md +202 -0
  26. package/assets/hero.jpeg +0 -0
  27. package/assets/mode-indicators.jpeg +0 -0
  28. package/assets/real-time-indicator.jpeg +0 -0
  29. package/assets/supported-tools.jpeg +0 -0
  30. package/bin/lacy +1028 -0
  31. package/docs/ADDING-BACKENDS.md +124 -0
  32. package/docs/DEVTO-ARTICLE.md +94 -0
  33. package/docs/DOCS.md +68 -0
  34. package/docs/GROWTH-STRATEGY.md +119 -0
  35. package/docs/HN-RESPONSES.md +122 -0
  36. package/docs/LAUNCH-COPY-FINAL.md +105 -0
  37. package/docs/MARKETING.md +411 -0
  38. package/docs/NATURAL_LANGUAGE_DETECTION.md +204 -0
  39. package/docs/UGC_VIDEO_SCRIPT.md +114 -0
  40. package/docs/articles/devto-how-i-made-my-terminal-understand-english.md +117 -0
  41. package/docs/demo-color-transition.gif +0 -0
  42. package/docs/demo-full.gif +0 -0
  43. package/docs/demo-indicator.gif +0 -0
  44. package/docs/launch-thread-may6.sh +158 -0
  45. package/docs/videos/README.md +189 -0
  46. package/docs/videos/generate_frames.py +510 -0
  47. package/docs/videos/generate_frames_v2.py +729 -0
  48. package/docs/videos/generate_short.py +328 -0
  49. package/docs/videos/generate_short_v2.py +526 -0
  50. package/docs/videos/lacy-shell-demo-v2.mp4 +0 -0
  51. package/docs/videos/lacy-shell-demo.mp4 +0 -0
  52. package/docs/videos/lacy-shell-short-v2.mp4 +0 -0
  53. package/docs/videos/lacy-shell-short.mp4 +0 -0
  54. package/install.sh +1009 -0
  55. package/lacy.plugin.bash +75 -0
  56. package/lacy.plugin.fish +43 -0
  57. package/lacy.plugin.zsh +65 -0
  58. package/lib/animations.zsh +3 -0
  59. package/lib/bash/completions.bash +40 -0
  60. package/lib/bash/execute.bash +233 -0
  61. package/lib/bash/init.bash +40 -0
  62. package/lib/bash/keybindings.bash +134 -0
  63. package/lib/bash/prompt.bash +85 -0
  64. package/lib/commands/info.sh +25 -0
  65. package/lib/config.zsh +3 -0
  66. package/lib/constants.zsh +3 -0
  67. package/lib/core/animations.sh +271 -0
  68. package/lib/core/commands.sh +297 -0
  69. package/lib/core/config.sh +340 -0
  70. package/lib/core/constants.sh +366 -0
  71. package/lib/core/context.sh +260 -0
  72. package/lib/core/detection.sh +417 -0
  73. package/lib/core/mcp.sh +741 -0
  74. package/lib/core/modes.sh +123 -0
  75. package/lib/core/preheat.sh +496 -0
  76. package/lib/core/spinner.sh +174 -0
  77. package/lib/core/telemetry.sh +99 -0
  78. package/lib/detection.zsh +3 -0
  79. package/lib/execute.zsh +3 -0
  80. package/lib/fish/config.fish +66 -0
  81. package/lib/fish/detection.fish +90 -0
  82. package/lib/fish/execute.fish +105 -0
  83. package/lib/fish/keybindings.fish +42 -0
  84. package/lib/fish/prompt.fish +30 -0
  85. package/lib/keybindings.zsh +3 -0
  86. package/lib/mcp.zsh +3 -0
  87. package/lib/modes.zsh +3 -0
  88. package/lib/preheat.zsh +3 -0
  89. package/lib/prompt.zsh +3 -0
  90. package/lib/spinner.zsh +3 -0
  91. package/lib/zsh/completions.zsh +60 -0
  92. package/lib/zsh/execute.zsh +294 -0
  93. package/lib/zsh/init.zsh +26 -0
  94. package/lib/zsh/keybindings.zsh +551 -0
  95. package/lib/zsh/prompt.zsh +90 -0
  96. package/package.json +42 -27
  97. package/packages/lacy/README.md +61 -0
  98. package/packages/lacy/commands/info.sh +25 -0
  99. package/{index.mjs → packages/lacy/index.mjs} +247 -20
  100. package/packages/lacy/package-lock.json +71 -0
  101. package/packages/lacy/package.json +42 -0
  102. package/script/release.ts +487 -0
  103. package/squirrel.toml +36 -0
  104. package/tests/test_bash.bash +163 -0
  105. package/tests/test_core.sh +607 -0
  106. package/tests/test_gemini.sh +119 -0
  107. package/tests/test_gemini_mcp.sh +126 -0
  108. package/tests/test_preheat_server.zsh +446 -0
  109. package/uninstall.sh +52 -0
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Test Gemini session resume functionality
4
+ # Runs in Bash 4+ and ZSH
5
+ #
6
+ # Usage:
7
+ # bash tests/test_gemini.sh
8
+ # zsh tests/test_gemini.sh
9
+
10
+ # Determine which shell we're running in
11
+ if [[ -n "$ZSH_VERSION" ]]; then
12
+ LACY_SHELL_TYPE="zsh"
13
+ elif [[ -n "$BASH_VERSION" ]]; then
14
+ LACY_SHELL_TYPE="bash"
15
+ else
16
+ echo "FAIL: Unsupported shell"
17
+ exit 1
18
+ fi
19
+
20
+ echo "Testing Gemini Session Resume in: ${LACY_SHELL_TYPE}"
21
+ echo "================================================================"
22
+
23
+ # Find repo root
24
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
25
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
26
+
27
+ # Set up test environment
28
+ TEST_TMPDIR=$(mktemp -d)
29
+ export LACY_SHELL_HOME="$TEST_TMPDIR"
30
+
31
+ # Source core modules
32
+ source "$REPO_DIR/lib/core/constants.sh"
33
+ source "$REPO_DIR/lib/core/mcp.sh"
34
+ source "$REPO_DIR/lib/core/preheat.sh"
35
+
36
+ # Mock dependencies
37
+ lacy_start_spinner() { :; }
38
+ lacy_stop_spinner() { :; }
39
+ lacy_print_color() { :; }
40
+
41
+ # Test counter
42
+ PASS=0
43
+ FAIL=0
44
+
45
+ assert_eq() {
46
+ local test_name="$1"
47
+ local expected="$2"
48
+ local actual="$3"
49
+
50
+ if [[ "$expected" == "$actual" ]]; then
51
+ PASS=$(( PASS + 1 ))
52
+ printf ' \e[32m✓\e[0m %s\n' "$test_name"
53
+ else
54
+ printf ' \e[31m✗\e[0m %s\n' "$test_name"
55
+ echo " Expected: $expected"
56
+ echo " Actual: $actual"
57
+ FAIL=$(( FAIL + 1 ))
58
+ fi
59
+ }
60
+
61
+ summary() {
62
+ echo ""
63
+ echo "================================================================"
64
+ printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL"
65
+ if (( FAIL > 0 )); then
66
+ printf '\e[31mFAILED\e[0m\n'
67
+ exit 1
68
+ else
69
+ printf '\e[32mALL PASSED\e[0m\n'
70
+ exit 0
71
+ fi
72
+ }
73
+
74
+ # ============================================================================
75
+ # Gemini Session Tests
76
+ # ============================================================================
77
+
78
+ section() {
79
+ echo ""
80
+ echo "--- $1 ---"
81
+ }
82
+
83
+ section "Gemini Session State"
84
+
85
+ # Initial state
86
+ assert_eq "LACY_GEMINI_SESSION_ID should be empty" "" "$LACY_GEMINI_SESSION_ID"
87
+ assert_eq "Initial build cmd" "gemini -p" "$(lacy_preheat_gemini_build_cmd)"
88
+
89
+ # Capture session
90
+ MOCK_JSON='{"session_id": "test-uuid-123", "response": "hello"}'
91
+ lacy_preheat_gemini_capture_session "$MOCK_JSON"
92
+
93
+ assert_eq "LACY_GEMINI_SESSION_ID should be captured" "test-uuid-123" "$LACY_GEMINI_SESSION_ID"
94
+ assert_eq "Session ID should be persisted to file" "test-uuid-123" "$(cat "$LACY_GEMINI_SESSION_ID_FILE")"
95
+ assert_eq "Resumed build cmd" "gemini --resume test-uuid-123 -p" "$(lacy_preheat_gemini_build_cmd)"
96
+
97
+ # Extract result
98
+ assert_eq "Extract result from JSON" "hello" "$(lacy_preheat_gemini_extract_result "$MOCK_JSON")"
99
+
100
+ # Restore session (simulating new shell)
101
+ LACY_GEMINI_SESSION_ID=""
102
+ lacy_preheat_gemini_restore_session
103
+ assert_eq "Restore session from file" "test-uuid-123" "$LACY_GEMINI_SESSION_ID"
104
+
105
+ # Reset session
106
+ lacy_preheat_gemini_reset_session
107
+ assert_eq "LACY_GEMINI_SESSION_ID should be cleared" "" "$LACY_GEMINI_SESSION_ID"
108
+ if [[ ! -f "$LACY_GEMINI_SESSION_ID_FILE" ]]; then
109
+ PASS=$(( PASS + 1 ))
110
+ printf ' \e[32m✓\e[0m %s\n' "Session file should be removed"
111
+ else
112
+ printf ' \e[31m✗\e[0m %s\n' "Session file should be removed"
113
+ FAIL=$(( FAIL + 1 ))
114
+ fi
115
+
116
+ # Cleanup via trap (ensures cleanup even on script errors)
117
+ trap 'rm -rf "$TEST_TMPDIR"' EXIT
118
+
119
+ summary
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Test Gemini MCP refactoring (helper and retry logic)
4
+ # Runs in Bash 4+ and ZSH
5
+
6
+ if [[ -n "$ZSH_VERSION" ]]; then
7
+ LACY_SHELL_TYPE="zsh"
8
+ elif [[ -n "$BASH_VERSION" ]]; then
9
+ LACY_SHELL_TYPE="bash"
10
+ else
11
+ echo "FAIL: Unsupported shell"
12
+ exit 1
13
+ fi
14
+
15
+ echo "Testing Gemini MCP Refactoring in: ${LACY_SHELL_TYPE}"
16
+ echo "================================================================"
17
+
18
+ # Find repo root
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
20
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
21
+
22
+ # Set up test environment
23
+ TEST_TMPDIR=$(mktemp -d)
24
+ export LACY_SHELL_HOME="$TEST_TMPDIR"
25
+
26
+ # Source core modules
27
+ source "$REPO_DIR/lib/core/constants.sh"
28
+ source "$REPO_DIR/lib/core/mcp.sh"
29
+ source "$REPO_DIR/lib/core/preheat.sh"
30
+
31
+ # Mock dependencies
32
+ lacy_start_spinner() { :; }
33
+ lacy_stop_spinner() { :; }
34
+ lacy_print_color() { :; }
35
+
36
+ # Mock tool command execution
37
+ MOCK_EXIT_CODE=0
38
+ MOCK_RESPONSE='{"session_id": "new-id", "response": "success"}'
39
+ _lacy_run_tool_cmd() {
40
+ echo "$MOCK_RESPONSE"
41
+ return $MOCK_EXIT_CODE
42
+ }
43
+
44
+ # Test counter
45
+ PASS=0
46
+ FAIL=0
47
+
48
+ assert_eq() {
49
+ local test_name="$1"
50
+ local expected="$2"
51
+ local actual="$3"
52
+
53
+ if [[ "$expected" == "$actual" ]]; then
54
+ PASS=$(( PASS + 1 ))
55
+ printf ' \e[32m✓\e[0m %s\n' "$test_name"
56
+ else
57
+ printf ' \e[31m✗\e[0m %s\n' "$test_name"
58
+ echo " Expected: $expected"
59
+ echo " Actual: $actual"
60
+ FAIL=$(( FAIL + 1 ))
61
+ fi
62
+ }
63
+
64
+ section() {
65
+ echo ""
66
+ echo "--- $1 ---"
67
+ }
68
+
69
+ # ============================================================================
70
+ # Tests
71
+ # ============================================================================
72
+
73
+ section "Helper Function: _lacy_gemini_query_exec"
74
+
75
+ # Initial query (no session)
76
+ LACY_GEMINI_SESSION_ID=""
77
+ out=$(_lacy_gemini_query_exec "hello")
78
+ assert_eq "Initial query should succeed" 0 $?
79
+ assert_eq "Initial query should return mock response" "$MOCK_RESPONSE" "$out"
80
+
81
+ # Query with session
82
+ LACY_GEMINI_SESSION_ID="old-id"
83
+ out=$(_lacy_gemini_query_exec "hello")
84
+ assert_eq "Resumed query should succeed" 0 $?
85
+ assert_eq "Resumed query should return mock response" "$MOCK_RESPONSE" "$out"
86
+
87
+ section "Retry Logic in lacy_shell_query_agent"
88
+
89
+ # Simulate a failure on resume followed by a success on retry
90
+ LACY_GEMINI_SESSION_ID="bad-id"
91
+ LACY_ACTIVE_TOOL="gemini"
92
+
93
+ # Mock behavior: first call fails (if --resume), second call succeeds (if no --resume)
94
+ _lacy_run_tool_cmd() {
95
+ if [[ "$1" == *" --resume "* ]]; then
96
+ return 1
97
+ else
98
+ echo "$MOCK_RESPONSE"
99
+ return 0
100
+ fi
101
+ }
102
+
103
+ # Run query agent
104
+ out=$(lacy_shell_query_agent "retry-test" 2>/dev/null)
105
+ if [[ "$out" == *"success"* ]]; then
106
+ PASS=$(( PASS + 1 ))
107
+ printf ' \e[32m✓\e[0m %s\n' "Retry logic should eventually return success"
108
+ else
109
+ printf ' \e[31m✗\e[0m %s\n' "Retry logic should eventually return success"
110
+ echo " Actual output: $out"
111
+ FAIL=$(( FAIL + 1 ))
112
+ fi
113
+
114
+ # Cleanup
115
+ rm -rf "$TEST_TMPDIR"
116
+
117
+ echo ""
118
+ echo "================================================================"
119
+ printf 'Results: %d passed, %d failed\n' "$PASS" "$FAIL"
120
+ if (( FAIL > 0 )); then
121
+ printf '\e[31mFAILED\e[0m\n'
122
+ exit 1
123
+ else
124
+ printf '\e[32mALL PASSED\e[0m\n'
125
+ exit 0
126
+ fi
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env zsh
2
+
3
+ # Integration tests for preheat server lifecycle (lash + opencode)
4
+ # Usage: zsh tests/test_preheat_server.zsh
5
+
6
+ setopt NO_MONITOR # suppress job control messages
7
+
8
+ # ============================================================================
9
+ # Test configuration
10
+ # ============================================================================
11
+
12
+ TEST_PORT=14096
13
+ TEST_TMPDIR=$(mktemp -d)
14
+ SCRIPT_DIR="${0:A:h}"
15
+ REPO_ROOT="${SCRIPT_DIR:h}"
16
+
17
+ # Override before sourcing constants.zsh (`:=` pattern respects pre-existing)
18
+ export LACY_SHELL_HOME="$TEST_TMPDIR"
19
+ export LACY_PREHEAT_SERVER_PORT="$TEST_PORT"
20
+
21
+ # ============================================================================
22
+ # Source modules (minimal chain — no ZLE/prompt deps)
23
+ # ============================================================================
24
+
25
+ source "$REPO_ROOT/lib/constants.zsh"
26
+ source "$REPO_ROOT/lib/spinner.zsh"
27
+ source "$REPO_ROOT/lib/mcp.zsh"
28
+ source "$REPO_ROOT/lib/preheat.zsh"
29
+
30
+ # ============================================================================
31
+ # Assertion helpers
32
+ # ============================================================================
33
+
34
+ _PASS=0 _FAIL=0 _SKIP=0
35
+
36
+ pass() {
37
+ (( _PASS++ ))
38
+ printf ' \e[32m✓\e[0m %s\n' "$1"
39
+ }
40
+
41
+ fail() {
42
+ (( _FAIL++ ))
43
+ printf ' \e[31m✗\e[0m %s\n' "$1"
44
+ [[ -n "$2" ]] && printf ' %s\n' "$2"
45
+ }
46
+
47
+ skip() {
48
+ (( _SKIP++ ))
49
+ printf ' \e[33m⊘\e[0m %s (skipped: %s)\n' "$1" "$2"
50
+ }
51
+
52
+ assert_eq() {
53
+ local label="$1" expected="$2" actual="$3"
54
+ if [[ "$expected" == "$actual" ]]; then
55
+ pass "$label"
56
+ else
57
+ fail "$label" "expected='$expected' actual='$actual'"
58
+ fi
59
+ }
60
+
61
+ assert_nonblank() {
62
+ local label="$1" value="$2"
63
+ if [[ -n "$value" ]]; then
64
+ pass "$label"
65
+ else
66
+ fail "$label" "expected non-blank value"
67
+ fi
68
+ }
69
+
70
+ assert_empty() {
71
+ local label="$1" value="$2"
72
+ if [[ -z "$value" ]]; then
73
+ pass "$label"
74
+ else
75
+ fail "$label" "expected empty, got='$value'"
76
+ fi
77
+ }
78
+
79
+ summary() {
80
+ echo ""
81
+ printf '=%.0s' {1..60}; echo ""
82
+ printf 'Results: %d passed, %d failed, %d skipped\n' "$_PASS" "$_FAIL" "$_SKIP"
83
+ if (( _FAIL > 0 )); then
84
+ printf '\e[31mFAILED\e[0m\n'
85
+ return 1
86
+ else
87
+ printf '\e[32mALL PASSED\e[0m\n'
88
+ return 0
89
+ fi
90
+ }
91
+
92
+ section() {
93
+ echo ""
94
+ printf '--- %s ---\n' "$1"
95
+ }
96
+
97
+ # ============================================================================
98
+ # Cleanup (runs on exit, Ctrl-C, assertion failure)
99
+ # ============================================================================
100
+
101
+ cleanup() {
102
+ # Stop server via library function
103
+ lacy_preheat_server_stop 2>/dev/null
104
+
105
+ # Fallback: kill anything on the test port
106
+ local pids
107
+ pids=$(lsof -ti "tcp:$TEST_PORT" 2>/dev/null)
108
+ if [[ -n "$pids" ]]; then
109
+ echo "$pids" | xargs kill 2>/dev/null
110
+ sleep 0.3
111
+ pids=$(lsof -ti "tcp:$TEST_PORT" 2>/dev/null)
112
+ [[ -n "$pids" ]] && echo "$pids" | xargs kill -9 2>/dev/null
113
+ fi
114
+
115
+ # Remove temp directory
116
+ rm -rf "$TEST_TMPDIR"
117
+ }
118
+
119
+ trap cleanup EXIT INT TERM
120
+
121
+ # ============================================================================
122
+ # Helpers
123
+ # ============================================================================
124
+
125
+ # Create a mock server script that mimics the lash/opencode REST API.
126
+ # Uses raw sockets for fast startup (< 100ms) since the preheat start
127
+ # function only waits ~3s for health.
128
+ create_mock_server() {
129
+ local tool="$1"
130
+ local mock_bin="$TEST_TMPDIR/bin/$tool"
131
+ mkdir -p "$TEST_TMPDIR/bin"
132
+
133
+ cat > "$mock_bin" << 'MOCK_SERVER'
134
+ #!/usr/bin/env python3
135
+ """Fast mock server mimicking lash/opencode REST API using raw sockets."""
136
+ import socket, json, sys, uuid, threading
137
+
138
+ if "serve" not in sys.argv:
139
+ print(f"Unknown command: {sys.argv[1:]}", file=sys.stderr)
140
+ sys.exit(1)
141
+
142
+ port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else 4096
143
+ sessions = {}
144
+
145
+ def handle_client(conn):
146
+ try:
147
+ data = conn.recv(65536).decode()
148
+ if not data:
149
+ conn.close()
150
+ return
151
+ lines = data.split("\r\n")
152
+ method, path, _ = lines[0].split(" ", 2)
153
+
154
+ # Extract body (after blank line)
155
+ body = ""
156
+ for i, line in enumerate(lines):
157
+ if line == "":
158
+ body = "\r\n".join(lines[i + 1:])
159
+ break
160
+
161
+ status_code = 404
162
+ resp_body = json.dumps({"error": "not found"})
163
+
164
+ if method == "GET" and path == "/global/health":
165
+ status_code = 200
166
+ resp_body = json.dumps({"status": "ok"})
167
+
168
+ elif method == "POST" and path == "/session":
169
+ sid = str(uuid.uuid4())
170
+ sessions[sid] = []
171
+ status_code = 200
172
+ resp_body = json.dumps({"id": sid})
173
+
174
+ elif method == "POST" and path.startswith("/session/") and path.endswith("/message"):
175
+ sid = path.split("/")[2]
176
+ if sid in sessions:
177
+ d = json.loads(body) if body.strip() else {}
178
+ qt = ""
179
+ for p in d.get("parts", []):
180
+ if p.get("type") == "text":
181
+ qt = p["text"]
182
+ sessions[sid].append(qt)
183
+ status_code = 200
184
+ resp_body = json.dumps([{
185
+ "role": "assistant",
186
+ "parts": [{"type": "text", "text": f"Mock response to: {qt}"}]
187
+ }])
188
+ # else: 404 (default)
189
+
190
+ status_text = "OK" if status_code == 200 else "Not Found"
191
+ resp = (
192
+ f"HTTP/1.1 {status_code} {status_text}\r\n"
193
+ f"Content-Type: application/json\r\n"
194
+ f"Content-Length: {len(resp_body)}\r\n"
195
+ f"Connection: close\r\n"
196
+ f"\r\n"
197
+ f"{resp_body}"
198
+ )
199
+ conn.sendall(resp.encode())
200
+ except Exception:
201
+ pass
202
+ finally:
203
+ conn.close()
204
+
205
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
206
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
207
+ srv.bind(("127.0.0.1", port))
208
+ srv.listen(5)
209
+
210
+ while True:
211
+ conn, _ = srv.accept()
212
+ threading.Thread(target=handle_client, args=(conn,), daemon=True).start()
213
+ MOCK_SERVER
214
+
215
+ chmod +x "$mock_bin"
216
+ }
217
+
218
+ # Check that a tool (mock) is available on PATH
219
+ ensure_mock_on_path() {
220
+ local tool="$1"
221
+ if [[ ! -x "$TEST_TMPDIR/bin/$tool" ]]; then
222
+ create_mock_server "$tool"
223
+ fi
224
+ export PATH="$TEST_TMPDIR/bin:$PATH"
225
+ }
226
+
227
+ # Wait for the test port to be free (up to 3 seconds)
228
+ wait_for_port_free() {
229
+ local attempts=0
230
+ while (( attempts < 30 )); do
231
+ if ! lsof -ti "tcp:$TEST_PORT" >/dev/null 2>&1; then
232
+ return 0
233
+ fi
234
+ sleep 0.1
235
+ (( attempts++ ))
236
+ done
237
+ # Force kill anything still on the port
238
+ local pids
239
+ pids=$(lsof -ti "tcp:$TEST_PORT" 2>/dev/null)
240
+ [[ -n "$pids" ]] && echo "$pids" | xargs kill -9 2>/dev/null
241
+ sleep 0.3
242
+ }
243
+
244
+ # Reset preheat state between tool test runs
245
+ reset_preheat_state() {
246
+ lacy_preheat_server_stop 2>/dev/null
247
+ LACY_PREHEAT_SERVER_PID=""
248
+ LACY_PREHEAT_SERVER_PASSWORD=""
249
+ LACY_PREHEAT_SERVER_SESSION_ID=""
250
+ rm -f "$LACY_PREHEAT_SERVER_PID_FILE"
251
+ wait_for_port_free
252
+ }
253
+
254
+ # ============================================================================
255
+ # Tests
256
+ # ============================================================================
257
+
258
+ run_tests_for_tool() {
259
+ local tool="$1"
260
+
261
+ section "Tests for: $tool"
262
+
263
+ ensure_mock_on_path "$tool"
264
+ reset_preheat_state
265
+
266
+ # ------------------------------------------------------------------
267
+ # Test 1: Server lifecycle
268
+ # ------------------------------------------------------------------
269
+ lacy_preheat_server_start "$tool"
270
+ local start_rc=$?
271
+
272
+ assert_eq "$tool: server start returns 0" "0" "$start_rc"
273
+ assert_nonblank "$tool: PID is set after start" "$LACY_PREHEAT_SERVER_PID"
274
+
275
+ # PID file written
276
+ if [[ -f "$LACY_PREHEAT_SERVER_PID_FILE" ]]; then
277
+ local file_pid
278
+ file_pid=$(cat "$LACY_PREHEAT_SERVER_PID_FILE")
279
+ assert_eq "$tool: PID file matches in-memory PID" "$LACY_PREHEAT_SERVER_PID" "$file_pid"
280
+ else
281
+ fail "$tool: PID file written" "file not found at $LACY_PREHEAT_SERVER_PID_FILE"
282
+ fi
283
+
284
+ # Health check
285
+ lacy_preheat_server_is_healthy
286
+ assert_eq "$tool: server is healthy" "0" "$?"
287
+
288
+ # Stop
289
+ local saved_pid="$LACY_PREHEAT_SERVER_PID"
290
+ lacy_preheat_server_stop
291
+ assert_empty "$tool: PID cleared after stop" "$LACY_PREHEAT_SERVER_PID"
292
+
293
+ # Process is actually gone
294
+ sleep 0.3
295
+ if kill -0 "$saved_pid" 2>/dev/null; then
296
+ fail "$tool: process gone after stop" "PID $saved_pid still alive"
297
+ else
298
+ pass "$tool: process gone after stop"
299
+ fi
300
+
301
+ # ------------------------------------------------------------------
302
+ # Test 2: Health endpoint validation
303
+ # ------------------------------------------------------------------
304
+ reset_preheat_state
305
+ lacy_preheat_server_start "$tool"
306
+
307
+ local health_body
308
+ health_body=$(curl -sf --max-time 2 "http://localhost:${TEST_PORT}/global/health" 2>/dev/null)
309
+ local health_rc=$?
310
+
311
+ assert_eq "$tool: health endpoint reachable" "0" "$health_rc"
312
+
313
+ # Verify it's JSON, not HTML (SPA fallback would return <!DOCTYPE or <html)
314
+ if printf '%s' "$health_body" | grep -q '^<'; then
315
+ fail "$tool: health returns JSON (not HTML)" "got HTML: ${health_body:0:80}"
316
+ else
317
+ # Verify it parses as JSON
318
+ if printf '%s' "$health_body" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
319
+ pass "$tool: health returns JSON (not HTML)"
320
+ else
321
+ fail "$tool: health returns JSON (not HTML)" "not valid JSON: ${health_body:0:80}"
322
+ fi
323
+ fi
324
+
325
+ # ------------------------------------------------------------------
326
+ # Test 3: Session creation
327
+ # ------------------------------------------------------------------
328
+ local session_json
329
+ session_json=$(curl -sf --max-time 5 \
330
+ -X POST \
331
+ -H "Content-Type: application/json" \
332
+ -d '{}' \
333
+ "http://localhost:${TEST_PORT}/session" 2>/dev/null)
334
+
335
+ local session_id
336
+ session_id=$(printf '%s' "$session_json" | python3 -c "import json,sys; print(json.loads(sys.stdin.read()).get('id',''))" 2>/dev/null)
337
+
338
+ assert_nonblank "$tool: session creation returns an ID" "$session_id"
339
+
340
+ # ------------------------------------------------------------------
341
+ # Test 4: Job control suppression
342
+ # ------------------------------------------------------------------
343
+ reset_preheat_state
344
+
345
+ local start_output
346
+ start_output=$(lacy_preheat_server_start "$tool" 2>&1)
347
+
348
+ # Check that output does NOT contain job control noise like "[1] 12345"
349
+ if printf '%s' "$start_output" | grep -qE '^\[[0-9]+\] [0-9]+'; then
350
+ fail "$tool: no job control output from start" "got: $start_output"
351
+ else
352
+ pass "$tool: no job control output from start"
353
+ fi
354
+
355
+ # ------------------------------------------------------------------
356
+ # Test 5: Stale session reset
357
+ # ------------------------------------------------------------------
358
+ # Set a fake session ID and try to query — should fail and clear it.
359
+ # Redirect to file instead of $() to preserve global state changes.
360
+ LACY_PREHEAT_SERVER_SESSION_ID="fake-session-id-that-does-not-exist"
361
+
362
+ local _stale_out="$TEST_TMPDIR/_stale_out"
363
+ lacy_preheat_server_query "hello" > "$_stale_out" 2>/dev/null
364
+ local stale_rc=$?
365
+
366
+ # The query should fail (404 from mock for unknown session)
367
+ assert_eq "$tool: stale session query fails" "1" "$stale_rc"
368
+ assert_empty "$tool: stale session ID cleared" "$LACY_PREHEAT_SERVER_SESSION_ID"
369
+
370
+ # ------------------------------------------------------------------
371
+ # Test 6: Message sending (uses mock server, no API key needed)
372
+ # ------------------------------------------------------------------
373
+ LACY_PREHEAT_SERVER_SESSION_ID=""
374
+
375
+ local _query_out="$TEST_TMPDIR/_query_out"
376
+ lacy_preheat_server_query "say hello" > "$_query_out" 2>/dev/null
377
+ local query_rc=$?
378
+ local query_result
379
+ query_result=$(cat "$_query_out")
380
+
381
+ assert_eq "$tool: mock query returns 0" "0" "$query_rc"
382
+ assert_nonblank "$tool: mock query returns text" "$query_result"
383
+
384
+ # ------------------------------------------------------------------
385
+ # Test 7: Session reuse
386
+ # ------------------------------------------------------------------
387
+ local first_session="$LACY_PREHEAT_SERVER_SESSION_ID"
388
+ assert_nonblank "$tool: session ID set after first query" "$first_session"
389
+
390
+ # Second query should reuse the same session
391
+ local _query_out2="$TEST_TMPDIR/_query_out2"
392
+ lacy_preheat_server_query "say goodbye" > "$_query_out2" 2>/dev/null
393
+ local query_rc2=$?
394
+
395
+ assert_eq "$tool: second query returns 0" "0" "$query_rc2"
396
+ assert_eq "$tool: session ID reused" "$first_session" "$LACY_PREHEAT_SERVER_SESSION_ID"
397
+
398
+ # ------------------------------------------------------------------
399
+ # Test 8: Full mcp.zsh integration
400
+ # ------------------------------------------------------------------
401
+ LACY_ACTIVE_TOOL="$tool"
402
+ LACY_PREHEAT_SERVER_SESSION_ID=""
403
+
404
+ # Stub spinner to avoid terminal noise
405
+ lacy_start_spinner() { : }
406
+ lacy_stop_spinner() { : }
407
+
408
+ local _mcp_out="$TEST_TMPDIR/_mcp_out"
409
+ lacy_shell_query_agent "what is 2+2" > "$_mcp_out" 2>/dev/null
410
+ local mcp_rc=$?
411
+ local mcp_result
412
+ mcp_result=$(cat "$_mcp_out")
413
+
414
+ assert_eq "$tool: mcp integration returns 0" "0" "$mcp_rc"
415
+ assert_nonblank "$tool: mcp integration returns text" "$mcp_result"
416
+ assert_nonblank "$tool: server PID set during integration" "$LACY_PREHEAT_SERVER_PID"
417
+
418
+ # Clean up for next tool
419
+ reset_preheat_state
420
+ }
421
+
422
+ # ============================================================================
423
+ # Main
424
+ # ============================================================================
425
+
426
+ echo "Preheat Server Integration Tests"
427
+ echo "Port: $TEST_PORT | Temp: $TEST_TMPDIR"
428
+
429
+ # Check prerequisites
430
+ if ! command -v python3 >/dev/null 2>&1; then
431
+ echo "ERROR: python3 required for mock server"
432
+ exit 1
433
+ fi
434
+
435
+ if ! command -v curl >/dev/null 2>&1; then
436
+ echo "ERROR: curl required for HTTP tests"
437
+ exit 1
438
+ fi
439
+
440
+ # Run tests for each tool
441
+ run_tests_for_tool "lash"
442
+ run_tests_for_tool "opencode"
443
+
444
+ # Print summary and exit with appropriate code
445
+ summary
446
+ exit $?