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,741 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Agent query functions for Lacy Shell
4
+ # Routes queries to configured AI CLI tools
5
+ # Shared across Bash 4+ and ZSH
6
+
7
+ # ============================================================================
8
+ # JSON Extraction Helpers
9
+ # ============================================================================
10
+
11
+ # Extract a value from JSON using the best available tool (jq > python3 > grep).
12
+ # For top-level fields: _lacy_json_get "$json" "field_name"
13
+ # Returns the field value on stdout, or empty string if not found.
14
+ _lacy_json_get() {
15
+ local json="$1"
16
+ local field="$2"
17
+
18
+ if command -v jq >/dev/null 2>&1; then
19
+ printf '%s\n' "$json" | jq -r --arg f "$field" '.[$f] // empty' 2>/dev/null
20
+ elif command -v python3 >/dev/null 2>&1; then
21
+ printf '%s\n' "$json" | python3 -c "
22
+ import json, sys
23
+ try:
24
+ d = json.loads(sys.stdin.read())
25
+ v = d.get('$field')
26
+ if v is not None:
27
+ print(v if isinstance(v, str) else json.dumps(v))
28
+ except: pass" 2>/dev/null
29
+ else
30
+ # Grep fallback — handles simple "key": "value" and "key": true/false/number
31
+ local val
32
+ val=$(printf '%s' "$json" | grep -o "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed "s/\"${field}\"[[:space:]]*:[[:space:]]*\"//" | sed 's/"$//')
33
+ if [[ -n "$val" ]]; then
34
+ printf '%s' "$val"
35
+ else
36
+ # Try unquoted values (booleans, numbers)
37
+ printf '%s' "$json" | grep -o "\"${field}\"[[:space:]]*:[[:space:]]*[^,}\"]*" | head -1 | sed "s/\"${field}\"[[:space:]]*:[[:space:]]*//" | tr -d ' '
38
+ fi
39
+ fi
40
+ }
41
+
42
+ # Run an arbitrary query expression against JSON (jq syntax, python3 fallback).
43
+ # Usage: _lacy_json_query "$json" '.choices[0].message.content'
44
+ # The second argument is a jq expression. A python3 equivalent is auto-generated
45
+ # for common patterns: .a.b.c and .a[N].b.c
46
+ # Returns empty string if the query fails or tools are unavailable.
47
+ _lacy_json_query() {
48
+ local json="$1"
49
+ local expr="$2"
50
+
51
+ if command -v jq >/dev/null 2>&1; then
52
+ printf '%s\n' "$json" | jq -r "$expr // empty" 2>/dev/null
53
+ elif command -v python3 >/dev/null 2>&1; then
54
+ printf '%s\n' "$json" | python3 -c "
55
+ import json, sys, re
56
+ try:
57
+ d = json.loads(sys.stdin.read())
58
+ # Parse jq-like expression: .key[0].key2
59
+ parts = re.findall(r'\.(\w+)|\[(\d+)\]', '''$expr''')
60
+ obj = d
61
+ for key, idx in parts:
62
+ if key:
63
+ obj = obj[key]
64
+ else:
65
+ obj = obj[int(idx)]
66
+ if obj is not None:
67
+ print(obj if isinstance(obj, str) else json.dumps(obj))
68
+ except: pass" 2>/dev/null
69
+ else
70
+ # No structured parser available — return empty
71
+ return 1
72
+ fi
73
+ }
74
+
75
+ # ============================================================================
76
+ # Markdown Rendering
77
+ # ============================================================================
78
+
79
+ # Cached renderer (set on first call)
80
+ _LACY_MD_RENDERER=""
81
+
82
+ # Render markdown for terminal display.
83
+ # Uses glow if available, otherwise a basic sed fallback for bold/headers/code.
84
+ # Usage: _lacy_render_markdown "$text"
85
+ _lacy_render_markdown() {
86
+ local text="$1"
87
+ [[ -z "$text" ]] && return
88
+
89
+ # Auto-detect on first call
90
+ if [[ -z "$_LACY_MD_RENDERER" ]]; then
91
+ if command -v glow >/dev/null 2>&1; then
92
+ _LACY_MD_RENDERER="glow"
93
+ else
94
+ _LACY_MD_RENDERER="basic"
95
+ fi
96
+ fi
97
+
98
+ case "$_LACY_MD_RENDERER" in
99
+ glow) printf '%s\n' "$text" | glow -s dark ;;
100
+ basic) _lacy_render_markdown_basic "$text" ;;
101
+ esac
102
+ }
103
+
104
+ # Minimal markdown rendering via sed — bold, headers, inline code, rules.
105
+ # Uses literal escape chars (via $'') for BSD/GNU sed portability.
106
+ _lacy_render_markdown_basic() {
107
+ local bold=$'\e[1m' nobold=$'\e[22m'
108
+ local cyan=$'\e[36m' reset=$'\e[0m'
109
+ local dim=$'\e[38;5;238m'
110
+ local cols; cols=$(tput cols 2>/dev/null || echo 80)
111
+ local hr="${dim}$(printf '%*s' "$cols" | tr ' ' '─')${reset}"
112
+
113
+ printf '%s\n' "$1" | sed -E \
114
+ -e "s/^#{1,6}[[:space:]]+(.*)/${bold}\1${nobold}/" \
115
+ -e "s/^---*$/${hr}/" \
116
+ -e "s/^\*\*\*.*$/${hr}/" \
117
+ -e "s/\*\*([^*]*)\*\*/${bold}\1${nobold}/g" \
118
+ -e "s/\`([^\`]*)\`/${cyan}\1${reset}/g"
119
+ }
120
+
121
+ # ============================================================================
122
+ # Tool Command Execution
123
+ # ============================================================================
124
+
125
+ # Run a tool command safely — splits command string into array to avoid eval.
126
+ # Usage: _lacy_run_tool_cmd "cmd string" "query"
127
+ _lacy_run_tool_cmd() {
128
+ local cmd_str="$1"
129
+ local query="$2"
130
+ local -a cmd_parts
131
+ if [[ "$LACY_SHELL_TYPE" == "zsh" ]]; then
132
+ cmd_parts=( ${=cmd_str} )
133
+ else
134
+ read -ra cmd_parts <<< "$cmd_str"
135
+ fi
136
+ "${cmd_parts[@]}" "$query"
137
+ }
138
+
139
+ # Internal helper to build and run a Gemini query with session context and spinner.
140
+ # Returns 0 on success, non-zero on error. Outputs tool response to stdout.
141
+ # NOTE: Uses LACY_GEMINI_SESSION_ID which is managed in lib/core/preheat.sh.
142
+ _lacy_gemini_query_exec() {
143
+ local query="$1"
144
+ local gemini_cmd
145
+ gemini_cmd=$(lacy_preheat_gemini_build_cmd)
146
+
147
+ # Only include context on the first message of a session (when ID is empty)
148
+ local gemini_query
149
+ if [[ -z "$LACY_GEMINI_SESSION_ID" ]]; then
150
+ local _gemini_ctx
151
+ _gemini_ctx="${LACY_GEMINI_CONTEXT//\{cwd\}/$(pwd 2>/dev/null)}"
152
+ gemini_query="$_gemini_ctx $query"
153
+ else
154
+ gemini_query="$query"
155
+ fi
156
+
157
+ if [[ -t 0 ]]; then
158
+ _lacy_run_tool_cmd "$gemini_cmd" "$gemini_query" </dev/tty 2>/dev/null
159
+ else
160
+ _lacy_run_tool_cmd "$gemini_cmd" "$gemini_query" 2>/dev/null
161
+ fi
162
+ }
163
+
164
+ # Tool registry — function-based for maximum portability
165
+ # Usage: cmd=$(lacy_tool_cmd <tool_name>)
166
+ lacy_tool_cmd() {
167
+ case "$1" in
168
+ lash) echo "lash run -c" ;;
169
+ claude) echo "claude -p" ;;
170
+ opencode) echo "opencode run -c" ;;
171
+ gemini) echo "gemini -p" ;;
172
+ codex) echo "codex exec resume --last" ;;
173
+ hermes) echo "hermes chat -q" ;;
174
+ copilot) echo "copilot -p" ;;
175
+ goose) echo "goose run -t" ;;
176
+ amp) echo "amp -x" ;;
177
+ aider) echo "aider --no-auto-commits --message" ;;
178
+ *) echo "" ;;
179
+ esac
180
+ }
181
+
182
+ # Active tool (set during install or via config)
183
+ : "${LACY_ACTIVE_TOOL:=""}"
184
+
185
+ # Last resume command (set after each successful agent query)
186
+ LACY_LAST_RESUME_CMD=""
187
+
188
+ # Resume command registry — returns the command to resume a conversation
189
+ # Usage: cmd=$(lacy_resume_cmd <tool_name>)
190
+ lacy_resume_cmd() {
191
+ case "$1" in
192
+ claude)
193
+ [[ -n "$LACY_PREHEAT_CLAUDE_SESSION_ID" ]] && \
194
+ echo "claude --resume $LACY_PREHEAT_CLAUDE_SESSION_ID"
195
+ ;;
196
+ lash)
197
+ [[ -n "$LACY_PREHEAT_SERVER_SESSION_ID" ]] && \
198
+ echo "lash --session $LACY_PREHEAT_SERVER_SESSION_ID"
199
+ ;;
200
+ opencode)
201
+ [[ -n "$LACY_PREHEAT_SERVER_SESSION_ID" ]] && \
202
+ echo "opencode --session $LACY_PREHEAT_SERVER_SESSION_ID"
203
+ ;;
204
+ gemini)
205
+ [[ -n "$LACY_GEMINI_SESSION_ID" ]] && \
206
+ echo "gemini --resume $LACY_GEMINI_SESSION_ID"
207
+ ;;
208
+ codex) echo "codex exec resume --last" ;;
209
+ hermes) echo "hermes --continue" ;;
210
+ copilot) echo "copilot --resume" ;;
211
+ goose) echo "goose session resume" ;;
212
+ amp) echo "amp --continue" ;;
213
+ esac
214
+ }
215
+
216
+ # Print resume hint after successful agent query
217
+ # Usage: _lacy_print_resume_hint <tool_name>
218
+ _lacy_print_resume_hint() {
219
+ local tool="$1"
220
+ local resume_cmd
221
+ resume_cmd=$(lacy_resume_cmd "$tool")
222
+
223
+ if [[ -n "$resume_cmd" ]]; then
224
+ LACY_LAST_RESUME_CMD="$resume_cmd"
225
+ lacy_print_color 238 "$resume_cmd"
226
+ # Persist for cross-shell resume (lacy /resume in a new shell)
227
+ _lacy_save_last_session
228
+ fi
229
+ }
230
+
231
+ # Format tool error output — detects JSON error blobs and prints a clean message.
232
+ # Returns 0 if an error was detected and formatted, 1 if output is not a tool error.
233
+ # Usage: lacy_format_tool_error "$output" "$tool_name"
234
+ lacy_format_tool_error() {
235
+ local output="$1"
236
+ local tool="${2:-agent}"
237
+
238
+ # Quick check: does it look like JSON with an error?
239
+ [[ "$output" == "{"* ]] || return 1
240
+
241
+ local is_error="" result_text=""
242
+ is_error=$(_lacy_json_get "$output" "is_error")
243
+ result_text=$(_lacy_json_get "$output" "result")
244
+
245
+ [[ "$is_error" == "true" ]] || return 1
246
+
247
+ # We have an error — format it nicely
248
+ local red=196
249
+ local dim=238
250
+ local yellow=220
251
+
252
+ echo ""
253
+ lacy_print_color "$red" " Error from ${tool}"
254
+ echo ""
255
+ if [[ -n "$result_text" ]]; then
256
+ # Split on " · " delimiter that Claude uses
257
+ local IFS_BAK="$IFS"
258
+ local msg="$result_text"
259
+ local main_msg="" hint_msg=""
260
+ if [[ "$msg" == *" · "* ]]; then
261
+ main_msg="${msg%% · *}"
262
+ hint_msg="${msg#* · }"
263
+ else
264
+ main_msg="$msg"
265
+ fi
266
+ lacy_print_color "$yellow" " ${main_msg}"
267
+ if [[ -n "$hint_msg" ]]; then
268
+ echo ""
269
+ lacy_print_color "$dim" " ${hint_msg}"
270
+ fi
271
+ else
272
+ lacy_print_color "$yellow" " The agent returned an error (no details available)"
273
+ fi
274
+ echo ""
275
+ return 0
276
+ }
277
+
278
+ # Strip non-JSON leading lines from captured output.
279
+ # Agent CLIs (e.g. claude) emit startup/build text to stderr which gets
280
+ # merged into stdout by 2>&1. This strips everything before the first '{'.
281
+ _lacy_strip_leading_noise() {
282
+ local output="$1"
283
+ while [[ -n "$output" && "$output" != "{"* ]]; do
284
+ # Remove everything up to and including the first newline
285
+ local rest="${output#*$'\n'}"
286
+ # If no newline found, the whole string is noise
287
+ [[ "$rest" == "$output" ]] && output="" && break
288
+ output="$rest"
289
+ done
290
+ printf '%s' "$output"
291
+ }
292
+
293
+ # Normalize claude JSON output — handles startup noise, JSON arrays, and NDJSON.
294
+ # Claude --output-format json wraps all events in a JSON array: [{init},{assistant},{result}]
295
+ # This extracts the last element (the result object) so downstream parsing works.
296
+ _lacy_claude_normalize_output() {
297
+ local output="$1"
298
+
299
+ # Strip non-JSON leading lines (agent startup text on stderr merged via 2>&1)
300
+ local stripped="$output"
301
+ while [[ -n "$stripped" && "$stripped" != "{"* && "$stripped" != "["* ]]; do
302
+ local rest="${stripped#*$'\n'}"
303
+ [[ "$rest" == "$stripped" ]] && stripped="" && break
304
+ stripped="$rest"
305
+ done
306
+ [[ -z "$stripped" ]] && stripped="$output"
307
+
308
+ # JSON array — extract the last element (the result object)
309
+ if [[ "$stripped" == "["* ]]; then
310
+ local last_obj=""
311
+ if command -v jq >/dev/null 2>&1; then
312
+ last_obj=$(printf '%s\n' "$stripped" | jq -c '.[-1]' 2>/dev/null)
313
+ elif command -v python3 >/dev/null 2>&1; then
314
+ last_obj=$(printf '%s\n' "$stripped" | python3 -c "
315
+ import json, sys
316
+ try:
317
+ d = json.loads(sys.stdin.read())
318
+ if isinstance(d, list): print(json.dumps(d[-1]))
319
+ except: pass" 2>/dev/null)
320
+ fi
321
+ [[ -n "$last_obj" ]] && stripped="$last_obj"
322
+ fi
323
+
324
+ printf '%s' "$stripped"
325
+ }
326
+
327
+ # Append a query entry to the query log (rotates at ~1000 lines or ~1 MB).
328
+ # Usage: _lacy_log_query "tool_name" "query_text"
329
+ _lacy_log_query() {
330
+ local tool="$1"
331
+ local query="$2"
332
+ local log_dir="${LACY_SHELL_HOME}/logs"
333
+ local log_file="${log_dir}/queries.log"
334
+
335
+ mkdir -p "$log_dir" 2>/dev/null || return 0
336
+
337
+ local ts
338
+ ts=$(date '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "unknown")
339
+ local escaped_query="${query//$'\n'/\\n}"
340
+ printf '%s\t%s\t%s\n' "$ts" "$tool" "$escaped_query" >> "$log_file" 2>/dev/null || true
341
+
342
+ # Rotate: keep last 1000 lines if file is large
343
+ local size
344
+ if [[ -f "$log_file" ]]; then
345
+ size=$(wc -c < "$log_file" 2>/dev/null || echo 0)
346
+ if (( size > 1048576 )); then
347
+ local tmp
348
+ tmp=$(mktemp) && tail -n 1000 "$log_file" > "$tmp" && mv "$tmp" "$log_file" 2>/dev/null || true
349
+ fi
350
+ fi
351
+ }
352
+
353
+ # Send query to AI agent (configurable tool or fallback)
354
+ lacy_shell_query_agent() {
355
+ local query="$1"
356
+ local tool="${LACY_ACTIVE_TOOL}"
357
+
358
+ # Prepend delta-based terminal context (cwd, git, exit code, recent commands).
359
+ # Only includes what changed since the last query — zero overhead when idle.
360
+ # Uses result variable (not subshell) so state resets propagate.
361
+ _lacy_build_query_context "$query"
362
+ query="$_LACY_CTX_RESULT"
363
+
364
+ # Auto-detect if not set
365
+ local _auto_detected=false
366
+ if [[ -z "$tool" ]]; then
367
+ local t
368
+ for t in "${LACY_TOOL_LIST[@]}"; do
369
+ if command -v "$t" >/dev/null 2>&1; then
370
+ tool="$t"
371
+ _auto_detected=true
372
+ break
373
+ fi
374
+ done
375
+ fi
376
+
377
+ # If still no tool, try API fallback
378
+ if [[ -z "$tool" ]]; then
379
+ if lacy_shell_check_api_keys; then
380
+ local temp_file
381
+ temp_file=$(mktemp)
382
+ cat > "$temp_file" << EOF
383
+ Query: $query
384
+ EOF
385
+ echo ""
386
+ lacy_start_spinner
387
+ lacy_shell_send_to_ai_streaming "$temp_file" "$query"
388
+ local exit_code=$?
389
+ lacy_stop_spinner
390
+ rm -f "$temp_file"
391
+ echo ""
392
+ return $exit_code
393
+ fi
394
+
395
+ echo ""
396
+ printf '\e[38;5;196m No AI tool detected.\e[0m Lacy needs an AI CLI to handle queries.\n'
397
+ echo ""
398
+ printf '\e[1m Supported tools:\e[0m\n'
399
+ echo ""
400
+ printf ' \e[38;5;34m%-12s\e[0m %s\n' "lash" "npm install -g lashcode (recommended)"
401
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "claude" "brew install claude"
402
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "opencode" "brew install opencode"
403
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "gemini" "brew install gemini"
404
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "codex" "npm install -g @openai/codex"
405
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "hermes" "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
406
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "copilot" "gh extension install github/gh-copilot"
407
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "goose" "brew install goose"
408
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "amp" "npm install -g @sourcegraph/amp"
409
+ printf ' \e[38;5;238m%-12s\e[0m %s\n' "aider" "pipx install aider-chat"
410
+ echo ""
411
+ printf ' \e[38;5;75mThen run:\e[0m lacy setup\n'
412
+ printf ' \e[38;5;75mDocs:\e[0m %s\n' "$LACY_DOCS_URL"
413
+ echo ""
414
+
415
+ # Offer to install lash interactively if terminal is available
416
+ local can_prompt=false
417
+ if [[ -t 0 ]]; then
418
+ can_prompt=true
419
+ elif [[ -c /dev/tty ]]; then
420
+ can_prompt=true
421
+ fi
422
+
423
+ if [[ "$can_prompt" == true ]]; then
424
+ local install_now=""
425
+ printf ' Install \e[38;5;34mlash\e[0m now? (AI coding agent — lash.lacy.sh)\n'
426
+ echo ""
427
+ if [[ -t 0 ]]; then
428
+ read -p " [Y/n]: " install_now
429
+ else
430
+ read -p " [Y/n]: " install_now < /dev/tty 2>/dev/null || install_now="n"
431
+ fi
432
+
433
+ if [[ ! "$install_now" =~ ^[Nn]$ ]]; then
434
+ echo ""
435
+ if command -v npm >/dev/null 2>&1; then
436
+ echo " Installing lash..."
437
+ if npm install -g lashcode; then
438
+ echo ""
439
+ printf ' \e[38;5;34m✓\e[0m lash installed! Re-running your query...\n'
440
+ echo ""
441
+ tool="lash"
442
+ else
443
+ echo ""
444
+ printf ' \e[38;5;196m✗\e[0m Installation failed. Try manually: npm install -g lashcode\n'
445
+ return 1
446
+ fi
447
+ elif command -v brew >/dev/null 2>&1; then
448
+ echo " Installing lash..."
449
+ if brew tap lacymorrow/tap && brew install lash; then
450
+ echo ""
451
+ printf ' \e[38;5;34m✓\e[0m lash installed! Re-running your query...\n'
452
+ echo ""
453
+ tool="lash"
454
+ else
455
+ echo ""
456
+ printf ' \e[38;5;196m✗\e[0m Installation failed. Try manually: brew install lacymorrow/tap/lash\n'
457
+ return 1
458
+ fi
459
+ else
460
+ printf ' \e[38;5;196m✗\e[0m Neither npm nor brew found. Install one, then run:\n'
461
+ echo " npm install -g lashcode"
462
+ return 1
463
+ fi
464
+ else
465
+ return 1
466
+ fi
467
+ else
468
+ return 1
469
+ fi
470
+ fi
471
+
472
+ local cmd
473
+ if [[ "$tool" == "custom" ]]; then
474
+ if [[ -z "$LACY_CUSTOM_TOOL_CMD" ]]; then
475
+ echo "Error: custom tool selected but no command configured."
476
+ echo "Set one with: tool set custom \"your-command -flags\""
477
+ echo "Or add to ~/.lacy/config.yaml:"
478
+ echo " agent_tools:"
479
+ echo " active: custom"
480
+ echo " custom_command: \"your-command -flags\""
481
+ return 1
482
+ fi
483
+ cmd="$LACY_CUSTOM_TOOL_CMD"
484
+ else
485
+ cmd=$(lacy_tool_cmd "$tool")
486
+ fi
487
+
488
+ # Log the query (tool name + input text, not the AI response)
489
+ _lacy_log_query "$tool" "$query"
490
+
491
+ # Show which tool was auto-detected
492
+ if [[ "$_auto_detected" == true ]]; then
493
+ lacy_print_color 238 " Using $tool (auto-detected)"
494
+ fi
495
+
496
+ # === Preheat: lash/opencode background server ===
497
+ if [[ "$tool" == "lash" || "$tool" == "opencode" ]]; then
498
+ if lacy_preheat_server_is_healthy || lacy_preheat_server_start "$tool"; then
499
+ echo ""
500
+ lacy_start_spinner
501
+ local server_result
502
+ server_result=$(lacy_preheat_server_query "$query")
503
+ local exit_code=$?
504
+ lacy_stop_spinner
505
+ # Restore session ID from file (lost in subshell)
506
+ lacy_preheat_server_restore_session
507
+ if [[ $exit_code -eq 0 && -n "$server_result" ]]; then
508
+ while [[ "$server_result" == $'\n'* ]]; do server_result="${server_result#$'\n'}"; done
509
+ _lacy_render_markdown "$server_result"
510
+ _lacy_print_resume_hint "$tool"
511
+ echo ""
512
+ return 0
513
+ fi
514
+ # Server query failed — fall through to single-shot
515
+ fi
516
+ fi
517
+
518
+ # === Preheat: claude session reuse ===
519
+ if [[ "$tool" == "claude" ]]; then
520
+ local claude_cmd
521
+ claude_cmd=$(lacy_preheat_claude_build_cmd)
522
+ echo ""
523
+ lacy_start_spinner
524
+ local json_output
525
+ json_output=$(unset CLAUDECODE; _lacy_run_tool_cmd "$claude_cmd" "$query" </dev/tty 2>&1)
526
+ local exit_code=$?
527
+ lacy_stop_spinner
528
+
529
+ # Normalize: strip noise, extract last element from JSON array
530
+ json_output=$(_lacy_claude_normalize_output "$json_output")
531
+
532
+ if [[ $exit_code -eq 0 ]]; then
533
+ # Check for structured errors (e.g. invalid API key)
534
+ if lacy_format_tool_error "$json_output" "$tool"; then
535
+ return 1
536
+ fi
537
+ local result_text
538
+ result_text=$(lacy_preheat_claude_extract_result "$json_output")
539
+ while [[ "$result_text" == $'\n'* ]]; do result_text="${result_text#$'\n'}"; done
540
+ if [[ -n "$result_text" ]]; then
541
+ _lacy_render_markdown "$result_text"
542
+ else
543
+ printf '%s\n' "$json_output"
544
+ fi
545
+ lacy_preheat_claude_capture_session "$json_output"
546
+ _lacy_print_resume_hint "$tool"
547
+ echo ""
548
+ return 0
549
+ elif [[ -n "$LACY_PREHEAT_CLAUDE_SESSION_ID" ]]; then
550
+ lacy_preheat_claude_reset_session
551
+ claude_cmd=$(lacy_preheat_claude_build_cmd)
552
+ lacy_start_spinner
553
+ json_output=$(unset CLAUDECODE; _lacy_run_tool_cmd "$claude_cmd" "$query" </dev/tty 2>&1)
554
+ exit_code=$?
555
+ lacy_stop_spinner
556
+
557
+ # Normalize: strip noise, extract last element from JSON array
558
+ json_output=$(_lacy_claude_normalize_output "$json_output")
559
+
560
+ # Check for structured errors before processing
561
+ if lacy_format_tool_error "$json_output" "$tool"; then
562
+ return 1
563
+ fi
564
+
565
+ if [[ $exit_code -eq 0 ]]; then
566
+ local result_text
567
+ result_text=$(lacy_preheat_claude_extract_result "$json_output")
568
+ while [[ "$result_text" == $'\n'* ]]; do result_text="${result_text#$'\n'}"; done
569
+ if [[ -n "$result_text" ]]; then
570
+ _lacy_render_markdown "$result_text"
571
+ else
572
+ printf '%s\n' "$json_output"
573
+ fi
574
+ lacy_preheat_claude_capture_session "$json_output"
575
+ _lacy_print_resume_hint "$tool"
576
+ echo ""
577
+ return 0
578
+ fi
579
+ lacy_format_tool_error "$json_output" "$tool" || printf '%s\n' "$json_output"
580
+ echo ""
581
+ return $exit_code
582
+ else
583
+ lacy_format_tool_error "$json_output" "$tool" || printf '%s\n' "$json_output"
584
+ echo ""
585
+ return $exit_code
586
+ fi
587
+ fi
588
+
589
+ # === Gemini session reuse ===
590
+ if [[ "$tool" == "gemini" ]]; then
591
+ echo ""
592
+ local json_output
593
+ lacy_start_spinner
594
+ json_output=$(_lacy_gemini_query_exec "$query")
595
+ local exit_code=$?
596
+ lacy_stop_spinner
597
+ # Restore session ID lost in subshell
598
+ lacy_preheat_gemini_restore_session
599
+
600
+ if [[ $exit_code -ne 0 && -n "$LACY_GEMINI_SESSION_ID" ]]; then
601
+ # --resume failed (session expired/missing) — retry without it
602
+ lacy_preheat_gemini_reset_session
603
+ lacy_start_spinner
604
+ json_output=$(_lacy_gemini_query_exec "$query")
605
+ exit_code=$?
606
+ lacy_stop_spinner
607
+ fi
608
+
609
+ if [[ $exit_code -eq 0 ]]; then
610
+ local result_text
611
+ result_text=$(lacy_preheat_gemini_extract_result "$json_output")
612
+ while [[ "$result_text" == $'\n'* ]]; do result_text="${result_text#$'\n'}"; done
613
+ if [[ -n "$result_text" ]]; then
614
+ _lacy_render_markdown "$result_text"
615
+ else
616
+ printf '%s\n' "$json_output"
617
+ fi
618
+ lacy_preheat_gemini_capture_session "$json_output"
619
+ _lacy_print_resume_hint "$tool"
620
+ fi
621
+ echo ""
622
+ return $exit_code
623
+ fi
624
+
625
+ # === Generic path (codex, custom, and fallback) ===
626
+ echo ""
627
+ lacy_start_spinner
628
+ _lacy_run_tool_cmd "$cmd" "$query" </dev/tty 2>&1 | {
629
+ local _spinner_killed=false
630
+ local _full_output=""
631
+ local _line_count=0
632
+ while IFS= read -r line; do
633
+ # Skip agent startup noise (e.g. "> build · big-pickle", "exit_code=0")
634
+ [[ "$line" =~ ^'> '[a-z]+' · ' ]] && continue
635
+ [[ "$line" =~ ^exit_code= ]] && continue
636
+ if ! $_spinner_killed; then
637
+ if [[ -n "$LACY_SPINNER_PID" ]] && kill -0 "$LACY_SPINNER_PID" 2>/dev/null; then
638
+ kill "$LACY_SPINNER_PID" 2>/dev/null
639
+ sleep "$LACY_TERMINAL_FLUSH_DELAY"
640
+ printf '\e[2K\r\e[?25h\e[?7h'
641
+ fi
642
+ _spinner_killed=true
643
+ fi
644
+ _full_output+="$line"
645
+ (( _line_count++ ))
646
+ # Only buffer first line to check for JSON errors
647
+ if (( _line_count > 1 )); then
648
+ # Multi-line output — not a JSON error blob, flush everything
649
+ if [[ $_line_count -eq 2 ]]; then
650
+ printf '%s\n' "$_full_output"
651
+ fi
652
+ printf '%s\n' "$line"
653
+ fi
654
+ done
655
+ if ! $_spinner_killed && [[ -n "$LACY_SPINNER_PID" ]]; then
656
+ kill "$LACY_SPINNER_PID" 2>/dev/null
657
+ sleep "$LACY_TERMINAL_FLUSH_DELAY"
658
+ printf '\e[2K\r\e[?25h\e[?7h'
659
+ fi
660
+ # Single-line output — check if it's a JSON error
661
+ if (( _line_count <= 1 )); then
662
+ lacy_format_tool_error "$_full_output" "$tool" || printf '%s\n' "$_full_output"
663
+ fi
664
+ }
665
+ local exit_code
666
+ if [[ "$LACY_SHELL_TYPE" == "zsh" ]]; then
667
+ exit_code=${pipestatus[1]}
668
+ else
669
+ exit_code=${PIPESTATUS[0]}
670
+ fi
671
+ lacy_stop_spinner
672
+ if [[ $exit_code -eq 0 ]]; then
673
+ _lacy_print_resume_hint "$tool"
674
+ fi
675
+ echo ""
676
+ return $exit_code
677
+ }
678
+
679
+ # Check if API keys are configured (used by mcp.sh)
680
+ lacy_shell_check_api_keys() {
681
+ [[ -n "$LACY_SHELL_API_OPENAI" || -n "$LACY_SHELL_API_ANTHROPIC" || -n "$OPENAI_API_KEY" || -n "$ANTHROPIC_API_KEY" ]]
682
+ }
683
+
684
+ # ============================================================================
685
+ # Direct API Fallback (when no CLI tool installed)
686
+ # ============================================================================
687
+
688
+ lacy_shell_send_to_ai_streaming() {
689
+ local input_file="$1"
690
+ local query="$2"
691
+
692
+ local provider="${LACY_SHELL_PROVIDER:-$LACY_SHELL_DEFAULT_PROVIDER}"
693
+ local api_key_openai="${LACY_SHELL_API_OPENAI:-$OPENAI_API_KEY}"
694
+ local api_key_anthropic="${LACY_SHELL_API_ANTHROPIC:-$ANTHROPIC_API_KEY}"
695
+
696
+ if [[ "$provider" == "anthropic" && -n "$api_key_anthropic" ]]; then
697
+ lacy_shell_query_anthropic "$input_file" "$api_key_anthropic"
698
+ elif [[ -n "$api_key_openai" ]]; then
699
+ lacy_shell_query_openai "$input_file" "$api_key_openai"
700
+ elif [[ -n "$api_key_anthropic" ]]; then
701
+ lacy_shell_query_anthropic "$input_file" "$api_key_anthropic"
702
+ else
703
+ echo "Error: No API keys configured"
704
+ return 1
705
+ fi
706
+ }
707
+
708
+ lacy_shell_query_openai() {
709
+ local input_file="$1"
710
+ local api_key="$2"
711
+ local content
712
+ content=$(_lacy_json_escape_str "$(cat "$input_file")")
713
+
714
+ local response
715
+ response=$(curl -s -H "Content-Type: application/json" \
716
+ -H "Authorization: Bearer $api_key" \
717
+ -d "{\"model\":\"${LACY_API_MODEL_OPENAI}\",\"messages\":[{\"role\":\"user\",\"content\":\"$content\"}],\"max_tokens\":1500}" \
718
+ "$LACY_API_URL_OPENAI")
719
+
720
+ _lacy_json_query "$response" '.choices[0].message.content'
721
+ }
722
+
723
+ lacy_shell_query_anthropic() {
724
+ local input_file="$1"
725
+ local api_key="$2"
726
+ local content
727
+ content=$(_lacy_json_escape_str "$(cat "$input_file")")
728
+
729
+ local response
730
+ response=$(curl -s -H "Content-Type: application/json" \
731
+ -H "x-api-key: $api_key" \
732
+ -H "anthropic-version: 2023-06-01" \
733
+ -d "{\"model\":\"${LACY_API_MODEL_ANTHROPIC}\",\"max_tokens\":1500,\"messages\":[{\"role\":\"user\",\"content\":\"$content\"}]}" \
734
+ "$LACY_API_URL_ANTHROPIC")
735
+
736
+ _lacy_json_query "$response" '.content[0].text'
737
+ }
738
+
739
+ # Stub for MCP init (no-op, lash handles MCP)
740
+ lacy_shell_init_mcp() { :; }
741
+ lacy_shell_cleanup_mcp() { :; }