Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,12 @@ Full rationale in the header of `lib/zsh/keybindings.zsh`. Tested with zsh-synta

## Supported AI CLI Tools

From `lacy_tool_cmd()` in `lib/core/mcp.sh`. Order in `LACY_TOOL_LIST` is auto-detect order.
From `lacy_tool_cmd()` in `lib/core/mcp.sh`. Order in `LACY_TOOL_LIST` is auto-detect order: claude first (the default and "recommended" tool; its answers stream live), then lash.

| Tool | Command |
| -------- | ------------------------------------------- |
| lash | `lash run -c "query"` (recommended) |
| claude | `claude -p "query"` |
| claude | `claude -p "query"` (recommended, default) |
| lash | `lash run -c "query"` |
| opencode | `opencode run -c "query"` |
| gemini | `gemini -p "query"` |
| codex | `codex exec --skip-git-repo-check resume --last "query"` |
Expand All @@ -179,7 +179,7 @@ From `lacy_tool_cmd()` in `lib/core/mcp.sh`. Order in `LACY_TOOL_LIST` is auto-d

lash is an opencode fork by the same author (lash.lacy.sh). Tools handle their own auth. No API keys, no direct API fallback.

Execution paths in `lacy_shell_query_agent()`: server (lash, opencode via background `serve`), claude (JSON + `--resume`), gemini (session), generic (everything else).
Execution paths in `lacy_shell_query_agent()`: server (lash, opencode via background `serve`), claude (`--resume`; with python3 it runs `--output-format stream-json --include-partial-messages` through `lib/core/claude_stream.py`, which prints text as it arrives and hands the final `result` event back; without python3 it waits for `--output-format json`), gemini (session), generic (everything else).

Per-query output: no "Using X" line. A tool that exits 0 with no stdout gets the failure frame (`<tool> finished without an answer` + stderr tail); lash/opencode print errors only to stderr. A server reply that is 200 with no text and `.info.error` is shown as `Error from <tool>`. gemini: `_lacy_gemini_signed_in` checks for a login (API key env/.env, non-OAuth auth type, token file in ~/.gemini, macOS keychain `gemini-cli-oauth`) before running, since gemini's sign-in `[Y/n]` would be hidden behind the spinner. Resume hint (`Resume: <cmd>`) only after a failure. No tool installed: one `No AI tool found` message listing an install line per tool, no prompt.

Expand Down Expand Up @@ -319,7 +319,7 @@ LACY_NO_NODE=1 bin/lacy setup # bash fallback

`install.sh` (Bash 3.2+) and `packages/lacy/index.mjs` behave the same:

- Tool question: one tool installed → none asked. None → one question (install lash? Y/n). Several → picker. No TTY → nothing asked. Skipped when config.yaml exists.
- Tool question: one tool installed → none asked. None → one question (install Claude Code? Y/n). Several → picker. No TTY → nothing asked. Skipped when config.yaml exists.
- Already installed on a TTY (curl): menu Update / Reinstall / Uninstall / Cancel. npx: settings dashboard. npx without TTY: `install.sh --update`.
- Flags: `--update`, `--reinstall`, `--uninstall`, `--bash` (skip Node), `--shell zsh|bash|fish`, `--tool NAME|auto`, `--tool custom "CMD"`. `--beta` / `--channel` removed.
- Env: `LACY_REPO_URL`, `LACY_REF`, `LACY_TARBALL_URL`, `LACY_NO_NODE`, `NO_COLOR`, `DO_NOT_TRACK`, `LACY_NO_TELEMETRY`.
Expand Down
10 changes: 3 additions & 7 deletions bin/lacy
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ CONFIG_FILE="${INSTALL_DIR}/config.yaml"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"

# Keep in sync with LACY_TOOL_LIST in lib/core/constants.sh (tests check).
TOOL_LIST=(lash claude opencode gemini codex hermes copilot goose aider)
TOOL_LIST=(claude lash opencode gemini codex hermes copilot goose aider)

# Colors (ANSI-C quoting so escape bytes are real, not literal)
# A dumb terminal renders escapes as garbage: treat it like NO_COLOR.
Expand Down Expand Up @@ -420,11 +420,7 @@ cmd_doctor() {
success "AI tool available (${active})"
else
fail_line "Configured AI tool is not installed: ${active}"
if [[ "$active" == "lash" ]]; then
fix_line "${CYAN}npm install -g lashcode${NC}, or pick another tool with ${CYAN}lacy setup${NC}"
else
fix_line "install ${active}, or pick another tool with ${CYAN}lacy setup${NC}"
fi
fix_line "${CYAN}lacy setup${NC} installs it, or picks another tool"
issues=$((issues + 1))
fi
else
Expand All @@ -439,7 +435,7 @@ cmd_doctor() {
success "AI tool available (auto-detect: ${found})"
else
fail_line "No AI CLI tool installed"
fix_line "${CYAN}npm install -g lashcode${NC} (lash.lacy.sh)"
fix_line "${CYAN}npm install -g @anthropic-ai/claude-code${NC} (Claude Code), or ${CYAN}lacy setup${NC}"
issues=$((issues + 1))
fi
fi
Expand Down
2 changes: 1 addition & 1 deletion docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ curl -fsSL https://lacy.sh/install | bash -s -- [options]
| `--tool custom "CMD"` | Use your own command |
| `--bash` | Skip the Node installer |

The installer asks nothing when one AI tool is installed, asks which one to use when there are several, and offers to install lash when there are none. Without a terminal (CI, Docker) it asks nothing. It adds a `source` line and a `PATH` line to `~/.zshrc`, `~/.bashrc` (`~/.bash_profile` on macOS) or `~/.config/fish/conf.d/lacy.fish`.
The installer asks nothing when one AI tool is installed, asks which one to use when there are several, and offers to install Claude Code when there are none. Without a terminal (CI, Docker) it asks nothing. It adds a `source` line and a `PATH` line to `~/.zshrc`, `~/.bashrc` (`~/.bash_profile` on macOS) or `~/.config/fish/conf.d/lacy.fish`.

`npx lacy` does the same. Once Lacy is installed, `npx lacy` opens a settings menu, or updates without prompts when there is no terminal.

Expand Down
26 changes: 13 additions & 13 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ else
fi

# Keep in sync with LACY_TOOL_LIST in lib/core/constants.sh (tests check).
TOOL_LIST=(lash claude opencode gemini codex hermes copilot goose aider)
TOOL_LIST=(claude lash opencode gemini codex hermes copilot goose aider)

# Never let git stop to ask for credentials (a bad URL on GitHub asks).
export GIT_TERMINAL_PROMPT=0
Expand Down Expand Up @@ -339,21 +339,21 @@ check_prerequisites() {
# AI tool selection
# ============================================================================

install_lash() {
info "Installing lash..."
install_claude() {
info "Installing Claude Code..."
if command -v npm >/dev/null 2>&1; then
npm install -g lashcode && return 0
npm install -g @anthropic-ai/claude-code && return 0
elif command -v brew >/dev/null 2>&1; then
brew tap lacymorrow/tap && brew install lash && return 0
brew install --cask claude-code && return 0
else
error "Could not install lash: npm or Homebrew is needed."
error "Could not install Claude Code: npm or Homebrew is needed."
return 1
fi
error "lash did not install. You can retry later with: npm install -g lashcode"
error "Claude Code did not install. You can retry later with: npm install -g @anthropic-ai/claude-code"
return 1
}

# One tool installed: use it. None: offer lash. Several: ask which.
# One tool installed: use it. None: offer Claude Code. Several: ask which.
# Without a terminal nothing is asked and the default is used.
choose_tool() {
local found=() t reply i
Expand Down Expand Up @@ -388,18 +388,18 @@ choose_tool() {
# Nothing installed
if can_prompt; then
warn "No AI CLI tool found. Lacy needs one to answer questions."
if ! ask reply "Install lash (lash.lacy.sh)? [Y/n]: "; then
if ! ask reply "Install Claude Code (claude)? [Y/n]: "; then
reply="n"
fi
if [[ ! "$reply" =~ ^[Nn] ]] && install_lash && command -v lash >/dev/null 2>&1; then
SELECTED_TOOL="lash"
ok "Using lash"
if [[ ! "$reply" =~ ^[Nn] ]] && install_claude && command -v claude >/dev/null 2>&1; then
SELECTED_TOOL="claude"
ok "Using claude. Sign in once by running: claude"
return 0
fi
else
warn "No AI CLI tool found."
fi
printf "Install one later, for example: npm install -g lashcode\n"
printf "Install one later, for example: npm install -g @anthropic-ai/claude-code\n"
return 0
}

Expand Down
70 changes: 70 additions & 0 deletions lib/core/claude_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Stream a `claude -p --output-format stream-json` answer to the terminal.

Reads claude's NDJSON events on stdin and prints answer text the moment each
chunk arrives. Stops the spinner (argv[1], a PID or empty) at the first chunk.
Writes the final `result` event to argv[2] so the caller can read the session
ID and errors; when claude printed no result event, the non-JSON output
(startup noise, error text) is written there instead. Creates argv[2] +
".printed" when any answer text reached the terminal.
"""
import json
import os
import signal
import sys
import time

spinner = int(sys.argv[1]) if sys.argv[1].isdigit() else 0
result_path = sys.argv[2]

printed = False
at_line_start = True
result = None
other = []


def stop_spinner():
if spinner:
try:
os.kill(spinner, signal.SIGTERM)
except OSError:
pass
time.sleep(0.05) # let a frame in flight land before clearing it
sys.stderr.write("\x1b[2K\r\x1b[?25h")
sys.stderr.flush()


def write(text):
global printed, at_line_start
if not printed:
stop_spinner()
printed = True
sys.stdout.write(text)
sys.stdout.flush()
at_line_start = text.endswith("\n")


for line in sys.stdin:
try:
event = json.loads(line)
except ValueError:
other.append(line)
continue
kind = event.get("type")
if kind == "stream_event":
inner = event.get("event", {})
# A new assistant message after a tool call: keep it off the last line
if inner.get("type") == "message_start" and printed:
write("\n" if at_line_start else "\n\n")
delta = inner.get("delta", {})
if delta.get("type") == "text_delta" and delta.get("text"):
write(delta["text"])
elif kind == "result":
result = line

if printed and not at_line_start:
sys.stdout.write("\n")
with open(result_path, "w") as f:
f.write(result if result is not None else "".join(other))
if printed:
# Tells the caller not to print the answer a second time
open(result_path + ".printed", "w").close()
2 changes: 1 addition & 1 deletion lib/core/constants.sh
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ LACY_INDICATOR_CHAR="▌"
LACY_SPINNER_TEXT='Thinking'

# === Tool List (canonical order for detection and display) ===
LACY_TOOL_LIST=(lash claude opencode gemini codex hermes copilot goose aider)
LACY_TOOL_LIST=(claude lash opencode gemini codex hermes copilot goose aider)

# === URLs ===
LACY_DOCS_URL="https://lacy.sh/docs"
Expand Down
65 changes: 46 additions & 19 deletions lib/core/mcp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,39 @@ _lacy_strip_leading_noise() {
printf '%s' "$output"
}

# Run claude once. With python3 the answer streams to the terminal as it is
# written (stream-json); without it, claude runs to the end and the caller
# renders the result. Sets _LACY_CLAUDE_JSON (the normalized result object)
# and _LACY_CLAUDE_STREAMED (true when answer text was already printed).
# Returns claude's exit code.
_lacy_claude_run() {
local query="$1" stdin_src="$2" cmd rc
local streamer="${LACY_SHELL_DIR:-}/lib/core/claude_stream.py"
cmd=$(lacy_preheat_claude_build_cmd)
_LACY_CLAUDE_STREAMED=false
lacy_start_spinner
if [[ -n "${LACY_SHELL_DIR:-}" && -f "$streamer" ]] && command -v python3 >/dev/null 2>&1; then
local result_file
result_file=$(mktemp 2>/dev/null) || result_file="${LACY_SHELL_HOME}/.claude_result_$$"
cmd="${cmd/--output-format json/--output-format stream-json --include-partial-messages --verbose}"
local -a _ps
(unset CLAUDECODE; _lacy_run_tool_cmd "$cmd" "$query" <"$stdin_src" 2>&1) |
python3 -u "$streamer" "$LACY_SPINNER_PID" "$result_file"
_ps=("${pipestatus[@]}" "${PIPESTATUS[@]}")
rc="${_ps[$_LACY_ARR_OFFSET]}"
lacy_stop_spinner
_LACY_CLAUDE_JSON=$(_lacy_claude_normalize_output "$(cat "$result_file" 2>/dev/null)")
[[ -f "$result_file.printed" ]] && _LACY_CLAUDE_STREAMED=true
command rm -f "$result_file" "$result_file.printed"
else
_LACY_CLAUDE_JSON=$(unset CLAUDECODE; _lacy_run_tool_cmd "$cmd" "$query" <"$stdin_src" 2>&1)
rc=$?
lacy_stop_spinner
_LACY_CLAUDE_JSON=$(_lacy_claude_normalize_output "$_LACY_CLAUDE_JSON")
fi
return "$rc"
}

# Normalize claude JSON output: handles startup noise, JSON arrays, and NDJSON.
# Claude --output-format json wraps all events in a JSON array: [{init},{assistant},{result}]
# This extracts the last element (the result object) so downstream parsing works.
Expand Down Expand Up @@ -476,7 +509,7 @@ _lacy_print_no_tool() {
echo ""
for t in "${LACY_TOOL_LIST[@]}"; do
hint=$(lacy_tool_install_cmd "$t")
if [[ "$t" == "lash" ]]; then
if [[ "$t" == "claude" ]]; then
printf -v row ' %-10s %s (recommended)' "$t" "$hint"
lacy_print_color 34 "$row"
else
Expand Down Expand Up @@ -639,16 +672,13 @@ lacy_shell_query_agent() {

# === Preheat: claude session reuse ===
if [[ "$tool" == "claude" ]]; then
local claude_cmd json_output result_text
local json_output result_text
# Claude may prompt on the terminal; without one (CI, tests) use /dev/null
local _claude_stdin=/dev/null
( : </dev/tty ) 2>/dev/null && _claude_stdin=/dev/tty
claude_cmd=$(lacy_preheat_claude_build_cmd)
echo ""
lacy_start_spinner
json_output=$(unset CLAUDECODE; _lacy_run_tool_cmd "$claude_cmd" "$query" <"$_claude_stdin" 2>&1)
_lacy_claude_run "$query" "$_claude_stdin"
exit_code=$?
lacy_stop_spinner

# Ctrl+C or another signal: stop. Keep the session and never re-run.
if (( exit_code >= LACY_SIGNAL_EXIT_THRESHOLD )); then
Expand All @@ -659,19 +689,14 @@ lacy_shell_query_agent() {
# A failed --resume (expired or missing session) gets one fresh retry
if (( exit_code != 0 )) && [[ -n "$LACY_PREHEAT_CLAUDE_SESSION_ID" ]]; then
lacy_preheat_claude_reset_session
claude_cmd=$(lacy_preheat_claude_build_cmd)
lacy_start_spinner
json_output=$(unset CLAUDECODE; _lacy_run_tool_cmd "$claude_cmd" "$query" <"$_claude_stdin" 2>&1)
_lacy_claude_run "$query" "$_claude_stdin"
exit_code=$?
lacy_stop_spinner
if (( exit_code >= LACY_SIGNAL_EXIT_THRESHOLD )); then
echo ""
return "$exit_code"
fi
fi

# Normalize: strip noise, extract last element from JSON array
json_output=$(_lacy_claude_normalize_output "$json_output")
json_output="$_LACY_CLAUDE_JSON"

# Structured errors (e.g. invalid API key), even with exit code 0
if lacy_format_tool_error "$json_output" "$tool"; then
Expand All @@ -682,12 +707,14 @@ lacy_shell_query_agent() {
fi

if (( exit_code == 0 )); then
result_text=$(lacy_preheat_claude_extract_result "$json_output")
while [[ "$result_text" == $'\n'* ]]; do result_text="${result_text#$'\n'}"; done
if [[ -n "$result_text" ]]; then
_lacy_render_markdown "$result_text"
else
printf '%s\n' "$json_output"
if [[ "$_LACY_CLAUDE_STREAMED" != true ]]; then
result_text=$(lacy_preheat_claude_extract_result "$json_output")
while [[ "$result_text" == $'\n'* ]]; do result_text="${result_text#$'\n'}"; done
if [[ -n "$result_text" ]]; then
_lacy_render_markdown "$result_text"
else
printf '%s\n' "$json_output"
fi
fi
lacy_preheat_claude_capture_session "$json_output"
_lacy_save_last_session
Expand Down
2 changes: 1 addition & 1 deletion lib/fish/config.fish
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ set -q _LACY_FISH_MAJOR; or set -g _LACY_FISH_MAJOR (string match -r -- '^\d+' $
# BEGIN GENERATED TOOL LIST
# Generated from lib/core/constants.sh. Do not edit by hand.
set -g LACY_TOOL_LIST \
'lash' 'claude' 'opencode' 'gemini' 'codex' 'hermes' 'copilot' 'goose' \
'claude' 'lash' 'opencode' 'gemini' 'codex' 'hermes' 'copilot' 'goose' \
'aider'
# END GENERATED TOOL LIST

Expand Down
2 changes: 1 addition & 1 deletion lib/fish/execute.fish
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function _lacy_print_no_tool --description "Explain that no AI tool is installed
printf '%s Supported tools:%s\n\n' "$bold" "$off"
for t in $LACY_TOOL_LIST
set -l hint (_lacy_tool_install_cmd $t)
if test $t = lash
if test $t = claude
set hint "$hint (recommended)"
printf ' %s%-12s%s %s\n' "$green" $t "$off" "$hint"
else
Expand Down
2 changes: 1 addition & 1 deletion lib/zsh/completions.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ _lacy_complete() {
)

local -a tool_names
tool_names=(lash claude opencode gemini codex hermes copilot goose aider custom auto)
tool_names=(claude lash opencode gemini codex hermes copilot goose aider custom auto)

case "${words[2]}" in
config)
Expand Down
2 changes: 1 addition & 1 deletion packages/lacy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ npx lacy
- Installs the latest Lacy Shell release into `~/.lacy` and adds it to your zsh, Bash or fish config
- Uses your AI CLI tool without asking when exactly one is installed
- Asks which one to use when several are installed
- Offers to install lash when none is found
- Offers to install Claude Code when none is found
- Asks nothing when there is no terminal (CI, Docker)

Run `npx lacy` again after installing to change the tool or mode, edit the config, update, reinstall, or uninstall. Without a terminal it updates to the latest release instead.
Expand Down
Loading
Loading