opencode-llmstack 0.6.0__py3-none-any.whl

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.
llmstack/shell_env.py ADDED
@@ -0,0 +1,927 @@
1
+ """Spawn the env-prepared subshell + emit shell activation hooks.
2
+
3
+ Two distinct UX surfaces live here:
4
+
5
+ :func:`spawn_subshell`
6
+ Fallback path used by ``llmstack start`` when ``LLMSTACK_ACTIVE`` is
7
+ *not* already set (i.e. the user hasn't installed/sourced the
8
+ activate hook yet). ``execvp``s into the user's ``$SHELL`` with
9
+ ``OPENCODE_CONFIG`` + ``LLMSTACK_WORK_DIR`` + friends exported so
10
+ ``opencode`` picks up our local stack inside that one terminal
11
+ only. For zsh and bash we also write a transient rcfile that
12
+ sources the user's normal rc and prefixes the prompt with
13
+ ``[llmstack:<channel>]``.
14
+
15
+ :func:`activate_hook`
16
+ Used by ``llmstack activate <shell>``. Emits a self-contained
17
+ snippet that ``llmstack activate`` writes to
18
+ ``~/.<shell>_llmstack_hook`` and prints a ``source`` line for, so
19
+ ``eval "$(llmstack activate zsh)"`` regenerates and turns the hook
20
+ on in the current shell. The hook walks up from ``$PWD`` on every
21
+ prompt to find the nearest ``.llmstack/opencode.json`` and toggles
22
+ the env vars + prompt prefix accordingly. With the hook installed
23
+ the spawn fallback above is unused.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shutil
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ from llmstack._platform import IS_WINDOWS, default_shell, shell_family
34
+ from llmstack.paths import PACKAGE_DIR, remote_url, resolve
35
+
36
+ # 256-colour palette:
37
+ # current = steel-blue (38) -- local stack, canonical channel
38
+ # next = orange (208) -- local stack, queued-upgrade channel
39
+ # external = medium-purple (135) -- thin client of a remote (or
40
+ # same-host) llmstack router. URL
41
+ # is pinned at install time and
42
+ # re-exported as LLMSTACK_REMOTE_URL.
43
+ COLOR_CODE = {"current": 38, "next": 208, "external": 135}
44
+
45
+
46
+ def _user_shell() -> tuple[str, str]:
47
+ """Return ``(absolute path to shell, basename like ``zsh`` / ``pwsh``)``.
48
+
49
+ Resolution lives in :func:`llmstack._platform.default_shell` so the
50
+ POSIX vs Windows differences (``$SHELL`` vs ``$ComSpec``,
51
+ ``/bin/bash`` vs ``pwsh.exe`` fallback) stay in one place.
52
+ """
53
+ return default_shell()
54
+
55
+
56
+ def _llmstack_alias_target() -> str:
57
+ """Best path to invoke ``llmstack`` from inside the spawned shell.
58
+
59
+ Prefers the console-script on ``PATH`` (``pip install`` provides one);
60
+ falls back to ``python -m llmstack`` so an editable / non-console
61
+ install still works.
62
+ """
63
+ found = shutil.which("llmstack")
64
+ if found:
65
+ return found
66
+ return f"{sys.executable} -m llmstack"
67
+
68
+
69
+ def _prompt_label(channel: str, url: str | None) -> str:
70
+ """Text inside ``[llmstack:...]`` -- ``external`` keeps the URL visible."""
71
+ if channel == "external" and url:
72
+ return f"{channel} {url}"
73
+ return channel
74
+
75
+
76
+ def _write_bash_rcfile(rcfile: Path, channel: str, color: int, alias_target: str, label: str) -> None:
77
+ rcfile.write_text(
78
+ "# AUTO-GENERATED by llmstack; sourced by the spawned subshell.\n"
79
+ '[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"\n'
80
+ f"alias llmstack='{alias_target}'\n"
81
+ f'PS1="\\[\\033[38;5;{color}m\\][llmstack:{label}]\\[\\033[0m\\] ${{PS1:-\\$ }}"\n'
82
+ )
83
+
84
+
85
+ def _write_zsh_rcfile(zdotdir: Path, channel: str, color: int, alias_target: str, label: str) -> None:
86
+ zdotdir.mkdir(parents=True, exist_ok=True)
87
+ (zdotdir / ".zshrc").write_text(
88
+ "# AUTO-GENERATED by llmstack; sourced by the spawned subshell.\n"
89
+ '[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc"\n'
90
+ f"alias llmstack='{alias_target}'\n"
91
+ f'PROMPT="%F{{{color}}}[llmstack:{label}]%f ${{PROMPT:-%# }}"\n'
92
+ )
93
+
94
+
95
+ # --- Windows: PowerShell host file + a tiny cmd batch wrapper -------------
96
+ #
97
+ # PowerShell wants a $PROFILE-shaped script to run on startup; we point
98
+ # the host's ``-NoExit -File`` at our generated profile so the user's
99
+ # normal $PROFILE still runs first, then we layer our prompt + alias on
100
+ # top. cmd.exe gets a much simpler treatment via ``/k`` + a one-shot
101
+ # batch script.
102
+
103
+ _POWERSHELL_PROFILE_TEMPLATE = """\
104
+ # AUTO-GENERATED by llmstack; loaded by the spawned subshell.
105
+ # Run the user's normal profile first so their aliases survive.
106
+ foreach ($p in @($PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost,
107
+ $PROFILE.CurrentUserAllHosts, $PROFILE.CurrentUserCurrentHost)) {{
108
+ if ($p -and (Test-Path $p)) {{ . $p }}
109
+ }}
110
+
111
+ function global:llmstack {{ {alias_invocation} }}
112
+
113
+ function global:prompt {{
114
+ $esc = [char]27
115
+ Write-Host -NoNewline "${{esc}}[38;5;{color}m[llmstack:{label}]${{esc}}[0m "
116
+ "PS " + (Get-Location) + "> "
117
+ }}
118
+ """
119
+
120
+
121
+ _CMD_BATCH_TEMPLATE = """\
122
+ @echo off
123
+ REM AUTO-GENERATED by llmstack; loaded by the spawned subshell.
124
+ doskey llmstack={alias_invocation} $*
125
+ prompt [llmstack:{label}] $P$G
126
+ """
127
+
128
+
129
+ def _alias_invocation_powershell(alias_target: str) -> str:
130
+ """Build the body of the PowerShell ``llmstack`` function."""
131
+ if alias_target.startswith(sys.executable):
132
+ # ``python -m llmstack`` -- pass through unchanged, args are forwarded.
133
+ return f"& '{sys.executable}' -m llmstack @args"
134
+ return f"& '{alias_target}' @args"
135
+
136
+
137
+ def _alias_invocation_cmd(alias_target: str) -> str:
138
+ """``doskey`` macro body. Quote the executable, strip the args sentinel."""
139
+ if alias_target.startswith(sys.executable):
140
+ return f'"{sys.executable}" -m llmstack'
141
+ return f'"{alias_target}"'
142
+
143
+
144
+ def _write_powershell_profile(target: Path, color: int, alias_target: str, label: str) -> None:
145
+ target.parent.mkdir(parents=True, exist_ok=True)
146
+ target.write_text(_POWERSHELL_PROFILE_TEMPLATE.format(
147
+ color=color,
148
+ label=label,
149
+ alias_invocation=_alias_invocation_powershell(alias_target),
150
+ ))
151
+
152
+
153
+ def _write_cmd_batch(target: Path, alias_target: str, label: str) -> None:
154
+ target.parent.mkdir(parents=True, exist_ok=True)
155
+ target.write_text(_CMD_BATCH_TEMPLATE.format(
156
+ label=label,
157
+ alias_invocation=_alias_invocation_cmd(alias_target),
158
+ ))
159
+
160
+
161
+ def spawn_subshell(channel: str) -> None:
162
+ """Replace the current process with an interactive shell carrying our env.
163
+
164
+ Only invoked by ``start`` when ``LLMSTACK_ACTIVE`` is *not* already
165
+ set; if a nested invocation does sneak in we still refuse rather
166
+ than silently stack subshells.
167
+ """
168
+ paths = resolve()
169
+ if not paths.opencode_json.is_file():
170
+ raise SystemExit(f"missing {paths.opencode_json} (run: llmstack install)")
171
+
172
+ if os.environ.get("LLMSTACK_ACTIVE") == "1":
173
+ print(
174
+ f"[!] already inside an llmstack-aware shell (channel: "
175
+ f"{os.environ.get('LLMSTACK_CHANNEL', '?')}); refusing to nest a subshell.",
176
+ file=sys.stderr,
177
+ )
178
+ raise SystemExit(1)
179
+
180
+ user_shell, shell_name = _user_shell()
181
+ family = shell_family(shell_name)
182
+ color = COLOR_CODE.get(channel, COLOR_CODE["current"])
183
+ alias_target = _llmstack_alias_target()
184
+
185
+ rurl = remote_url()
186
+ label = _prompt_label(channel, rurl)
187
+ print()
188
+ print(f"[*] entering llmstack subshell (channel: {channel}, shell: {shell_name})")
189
+ print(f" OPENCODE_CONFIG -> {paths.opencode_json}")
190
+ if rurl:
191
+ print(f" remote -> {rurl}")
192
+ print(f" alias 'llmstack' -> {alias_target}")
193
+ if channel == "external":
194
+ print(" remote daemons keep running on exit; nothing local to stop.")
195
+ else:
196
+ print(" daemons keep running on exit; stop them with: llmstack stop")
197
+ print(' open more terminals: install the activate hook once with')
198
+ print(f' eval "$(llmstack activate {shell_name})"')
199
+ print()
200
+
201
+ env = {**os.environ}
202
+ if family == "bash":
203
+ rcfile = paths.state_dir / "llmstack.bashrc"
204
+ _write_bash_rcfile(rcfile, channel, color, alias_target, label)
205
+ argv = [user_shell, "--rcfile", str(rcfile), "-i"]
206
+ elif family == "zsh":
207
+ zdotdir = paths.state_dir / "zdotdir"
208
+ _write_zsh_rcfile(zdotdir, channel, color, alias_target, label)
209
+ argv = [user_shell, "-i"]
210
+ env["ZDOTDIR"] = str(zdotdir)
211
+ elif family == "powershell":
212
+ profile = paths.state_dir / "llmstack.profile.ps1"
213
+ _write_powershell_profile(profile, color, alias_target, label)
214
+ argv = [
215
+ user_shell,
216
+ "-NoLogo",
217
+ "-NoExit",
218
+ "-ExecutionPolicy", "Bypass",
219
+ "-File", str(profile),
220
+ ]
221
+ elif family == "cmd":
222
+ batch = paths.state_dir / "llmstack.cmd"
223
+ _write_cmd_batch(batch, alias_target, label)
224
+ argv = [user_shell, "/k", str(batch)]
225
+ else:
226
+ # Unknown POSIX-shaped shell -- best-effort: just ``-i``, no prompt.
227
+ argv = [user_shell, "-i"]
228
+
229
+ env.update({
230
+ "OPENCODE_CONFIG": str(paths.opencode_json),
231
+ "LLMSTACK_WORK_DIR": str(paths.work_dir),
232
+ "LLMSTACK_CHANNEL": channel,
233
+ "LLMSTACK_ACTIVE": "1",
234
+ "LLMSTACK_ROOT": str(PACKAGE_DIR),
235
+ })
236
+ if rurl:
237
+ env["LLMSTACK_REMOTE_URL"] = rurl
238
+
239
+ if IS_WINDOWS:
240
+ # ``execvpe`` is emulated on Windows and behaves oddly when the
241
+ # parent is a non-console process; ``subprocess.run`` is more
242
+ # predictable and lets the user actually exit back to the
243
+ # parent shell.
244
+ import subprocess
245
+ rc = subprocess.run(argv, env=env).returncode
246
+ raise SystemExit(rc)
247
+ os.execvpe(argv[0], argv, env)
248
+
249
+
250
+ # --- activate hooks -------------------------------------------------------
251
+
252
+ _ZSH_HOOK = r"""# --- llmstack auto-activation hook (zsh) -----------------------------------
253
+ # Generated by `llmstack activate zsh`. Walks up from $PWD on each
254
+ # directory change to find the nearest .llmstack/opencode.json. Reads the
255
+ # project's channel marker (active-channel, falling back to
256
+ # default-channel) and exports OPENCODE_CONFIG, LLMSTACK_WORK_DIR,
257
+ # LLMSTACK_CHANNEL, and -- when channel == external -- LLMSTACK_REMOTE_URL.
258
+ # LLMSTACK_WORK_DIR is what `llmstack <action>` keys off, so commands work
259
+ # from any subdirectory of an installed project. Reverses everything when
260
+ # you step out.
261
+ #
262
+ # Tool-availability gate: before activating, we verify the tools needed
263
+ # for this channel are present:
264
+ # - `llmstack` (always required)
265
+ # - `llama-swap` (only for local channels: current / next)
266
+ # - `llama-server` or `llama-cli` (likewise local-only)
267
+ # external-mode projects skip the local-tool checks because llama-swap
268
+ # and llama-server live on the remote. If any required tool is missing
269
+ # we print "folder detected but tool not available" + install hints and
270
+ # DON'T activate -- the env stays clean so opencode keeps using the
271
+ # user's global config until they install the missing piece.
272
+ #
273
+ # Marker file format (one line):
274
+ # <channel>[ <url>]
275
+ #
276
+ # Prompt colour by channel:
277
+ # current -> steel-blue (local stack, canonical)
278
+ # next -> orange (local stack, queued upgrade)
279
+ # external -> medium-purple (thin client; URL pinned at install time)
280
+ # In external mode the URL is appended to the prompt label so you always
281
+ # see which router you're talking to: [llmstack:<project> <url>]
282
+
283
+ _llmstack_find_root() {
284
+ local dir="${1:-$PWD}"
285
+ while [[ "$dir" != "/" && -n "$dir" ]]; do
286
+ if [[ -f "$dir/.llmstack/opencode.json" ]]; then
287
+ print -r -- "$dir"
288
+ return 0
289
+ fi
290
+ dir="${dir:h}"
291
+ done
292
+ return 1
293
+ }
294
+
295
+ _llmstack_color() {
296
+ case "${1:-current}" in
297
+ next) print -r -- 208 ;;
298
+ external) print -r -- 135 ;;
299
+ *) print -r -- 38 ;;
300
+ esac
301
+ }
302
+
303
+ _llmstack_read_marker() {
304
+ # Read "<channel> [url]" from $1 into _ch / _url. Sets _ch="" on failure.
305
+ _ch=""; _url=""
306
+ [[ -f "$1" ]] || return 0
307
+ read -r _ch _url < "$1" || true
308
+ }
309
+
310
+ _llmstack_find_swap() {
311
+ # Mirror of llmstack.paths.bin_dir(): $LLMSTACK_BIN_DIR > $LLMSTACK_DATA_DIR/bin
312
+ # > $XDG_DATA_HOME/llmstack/bin (default ~/.local/share/llmstack/bin), then PATH.
313
+ local cands=()
314
+ [[ -n "${LLMSTACK_BIN_DIR:-}" ]] && cands+=("$LLMSTACK_BIN_DIR/llama-swap")
315
+ [[ -n "${LLMSTACK_DATA_DIR:-}" ]] && cands+=("$LLMSTACK_DATA_DIR/bin/llama-swap")
316
+ cands+=("${XDG_DATA_HOME:-$HOME/.local/share}/llmstack/bin/llama-swap")
317
+ local p
318
+ for p in "${cands[@]}"; do
319
+ [[ -x "$p" ]] && return 0
320
+ done
321
+ command -v llama-swap >/dev/null 2>&1
322
+ }
323
+
324
+ _llmstack_check_tools() {
325
+ # Populates _llmstack_missing array. Returns 0 iff nothing is missing.
326
+ _llmstack_missing=()
327
+ command -v llmstack >/dev/null 2>&1 || _llmstack_missing+=("llmstack")
328
+ if [[ "${1:-current}" != "external" ]]; then
329
+ _llmstack_find_swap || _llmstack_missing+=("llama-swap")
330
+ if ! command -v llama-server >/dev/null 2>&1 && ! command -v llama-cli >/dev/null 2>&1; then
331
+ _llmstack_missing+=("llama-server")
332
+ fi
333
+ fi
334
+ (( ${#_llmstack_missing[@]} == 0 ))
335
+ }
336
+
337
+ _llmstack_install_hint() {
338
+ case "$1" in
339
+ llmstack) print -r -- " llmstack pip install -e <repo> (or: pipx install llmstack)" ;;
340
+ llama-swap) print -r -- " llama-swap llmstack install-llama-swap" ;;
341
+ llama-server) print -r -- " llama-server brew install llama.cpp (or download from https://github.com/ggml-org/llama.cpp/releases)" ;;
342
+ esac
343
+ }
344
+
345
+ _llmstack_warn_missing() {
346
+ # $1 = project root; uses _llmstack_missing.
347
+ print -r -- ""
348
+ print -P -- "%F{220}[llmstack]%f detected $1/.llmstack but missing local tool(s):"
349
+ local t
350
+ for t in "${_llmstack_missing[@]}"; do
351
+ _llmstack_install_hint "$t"
352
+ done
353
+ print -r -- " not activating. install the missing tool(s) and \`cd\` back in to retry."
354
+ print -r -- ""
355
+ }
356
+
357
+ _llmstack_deactivate() {
358
+ if [[ -n "${LLMSTACK_WORK_DIR:-}" ]]; then
359
+ unset OPENCODE_CONFIG LLMSTACK_WORK_DIR LLMSTACK_ACTIVE LLMSTACK_CHANNEL LLMSTACK_REMOTE_URL
360
+ if [[ -n "${_LLMSTACK_PS1_BACKUP:-}" ]]; then
361
+ PROMPT="$_LLMSTACK_PS1_BACKUP"
362
+ unset _LLMSTACK_PS1_BACKUP
363
+ fi
364
+ fi
365
+ unset _LLMSTACK_WARNED_FOR
366
+ }
367
+
368
+ _llmstack_activate() {
369
+ local found
370
+ found="$(_llmstack_find_root)" || found=""
371
+
372
+ if [[ -z "$found" ]]; then
373
+ _llmstack_deactivate
374
+ return 0
375
+ fi
376
+ # Idempotency guards: same project, no re-work.
377
+ if [[ "${LLMSTACK_WORK_DIR:-}" == "$found" ]]; then
378
+ return 0
379
+ fi
380
+ if [[ "${_LLMSTACK_WARNED_FOR:-}" == "$found" ]]; then
381
+ return 0
382
+ fi
383
+ # Switching projects (or entering fresh) -- drop any prior activation first.
384
+ _llmstack_deactivate
385
+
386
+ local _ch _url
387
+ # Channel resolution: live (active-channel, written by `start`)
388
+ # > intent (default-channel, written by `install`) > "current".
389
+ if [[ -f "$found/.llmstack/active-channel" ]]; then
390
+ _llmstack_read_marker "$found/.llmstack/active-channel"
391
+ else
392
+ _llmstack_read_marker "$found/.llmstack/default-channel"
393
+ fi
394
+ : "${_ch:=current}"
395
+
396
+ # Tool gate -- bail before exporting anything if requirements aren't met.
397
+ if ! _llmstack_check_tools "$_ch"; then
398
+ _llmstack_warn_missing "$found"
399
+ export _LLMSTACK_WARNED_FOR="$found"
400
+ return 0
401
+ fi
402
+
403
+ export OPENCODE_CONFIG="$found/.llmstack/opencode.json"
404
+ export LLMSTACK_WORK_DIR="$found"
405
+ export LLMSTACK_ACTIVE="1"
406
+ export LLMSTACK_CHANNEL="$_ch"
407
+ if [[ "$_ch" == "external" && -n "$_url" ]]; then
408
+ export LLMSTACK_REMOTE_URL="$_url"
409
+ else
410
+ unset LLMSTACK_REMOTE_URL
411
+ fi
412
+
413
+ : "${_LLMSTACK_PS1_BACKUP:=$PROMPT}"
414
+ export _LLMSTACK_PS1_BACKUP
415
+ local label color suffix
416
+ label="${found:t}"
417
+ color="$(_llmstack_color "$_ch")"
418
+ if [[ "$_ch" == "external" && -n "$_url" ]]; then
419
+ suffix=" $_url"
420
+ else
421
+ suffix=""
422
+ fi
423
+ PROMPT="%F{${color}}[llmstack:${label}${suffix}]%f $_LLMSTACK_PS1_BACKUP"
424
+ }
425
+
426
+ # Hook on every directory change AND once for the current shell.
427
+ autoload -U add-zsh-hook
428
+ add-zsh-hook chpwd _llmstack_activate
429
+ _llmstack_activate
430
+ # --- end llmstack hook -----------------------------------------------------
431
+ """
432
+
433
+ _BASH_HOOK = r"""# --- llmstack auto-activation hook (bash) ----------------------------------
434
+ # Generated by `llmstack activate bash`. Walks up from $PWD on each prompt
435
+ # to find the nearest .llmstack/opencode.json. Reads the project's channel
436
+ # marker (active-channel, falling back to default-channel) and exports
437
+ # OPENCODE_CONFIG, LLMSTACK_WORK_DIR, LLMSTACK_CHANNEL, and -- when
438
+ # channel == external -- LLMSTACK_REMOTE_URL. LLMSTACK_WORK_DIR is what
439
+ # `llmstack <action>` keys off, so commands work from any subdirectory of
440
+ # an installed project. Reverses everything when you step out.
441
+ #
442
+ # Tool-availability gate: before activating we verify the tools needed
443
+ # for this channel are present:
444
+ # - `llmstack` (always required)
445
+ # - `llama-swap` (only for local channels: current / next)
446
+ # - `llama-server` or `llama-cli` (likewise local-only)
447
+ # If any required tool is missing we print a one-shot "folder detected
448
+ # but tool not available" warning + install hints and DON'T activate
449
+ # (env stays clean). The warning is suppressed on subsequent prompts in
450
+ # the same project via the _LLMSTACK_WARNED_FOR guard so we don't spam
451
+ # every PROMPT_COMMAND tick.
452
+ #
453
+ # Marker file format (one line):
454
+ # <channel>[ <url>]
455
+ #
456
+ # Prompt colour by channel:
457
+ # current -> steel-blue (local stack, canonical)
458
+ # next -> orange (local stack, queued upgrade)
459
+ # external -> medium-purple (thin client; URL pinned at install time)
460
+ # In external mode the URL is appended to the prompt label so you always
461
+ # see which router you're talking to: [llmstack:<project> <url>]
462
+
463
+ _llmstack_find_root() {
464
+ local dir="${1:-$PWD}"
465
+ while [[ "$dir" != "/" && -n "$dir" ]]; do
466
+ if [[ -f "$dir/.llmstack/opencode.json" ]]; then
467
+ printf '%s\n' "$dir"
468
+ return 0
469
+ fi
470
+ dir="$(dirname "$dir")"
471
+ done
472
+ return 1
473
+ }
474
+
475
+ _llmstack_color() {
476
+ case "${1:-current}" in
477
+ next) printf '%s' 208 ;;
478
+ external) printf '%s' 135 ;;
479
+ *) printf '%s' 38 ;;
480
+ esac
481
+ }
482
+
483
+ _llmstack_read_marker() {
484
+ _ch=""; _url=""
485
+ [[ -f "$1" ]] || return 0
486
+ read -r _ch _url < "$1" || true
487
+ }
488
+
489
+ _llmstack_find_swap() {
490
+ # Mirror of llmstack.paths.bin_dir(): $LLMSTACK_BIN_DIR > $LLMSTACK_DATA_DIR/bin
491
+ # > $XDG_DATA_HOME/llmstack/bin (default ~/.local/share/llmstack/bin), then PATH.
492
+ local cands=()
493
+ [[ -n "${LLMSTACK_BIN_DIR:-}" ]] && cands+=("$LLMSTACK_BIN_DIR/llama-swap")
494
+ [[ -n "${LLMSTACK_DATA_DIR:-}" ]] && cands+=("$LLMSTACK_DATA_DIR/bin/llama-swap")
495
+ cands+=("${XDG_DATA_HOME:-$HOME/.local/share}/llmstack/bin/llama-swap")
496
+ local p
497
+ for p in "${cands[@]}"; do
498
+ [[ -x "$p" ]] && return 0
499
+ done
500
+ command -v llama-swap >/dev/null 2>&1
501
+ }
502
+
503
+ _llmstack_check_tools() {
504
+ _llmstack_missing=()
505
+ command -v llmstack >/dev/null 2>&1 || _llmstack_missing+=("llmstack")
506
+ if [[ "${1:-current}" != "external" ]]; then
507
+ _llmstack_find_swap || _llmstack_missing+=("llama-swap")
508
+ if ! command -v llama-server >/dev/null 2>&1 && ! command -v llama-cli >/dev/null 2>&1; then
509
+ _llmstack_missing+=("llama-server")
510
+ fi
511
+ fi
512
+ [[ ${#_llmstack_missing[@]} -eq 0 ]]
513
+ }
514
+
515
+ _llmstack_install_hint() {
516
+ case "$1" in
517
+ llmstack) printf '%s\n' " llmstack pip install -e <repo> (or: pipx install llmstack)" ;;
518
+ llama-swap) printf '%s\n' " llama-swap llmstack install-llama-swap" ;;
519
+ llama-server) printf '%s\n' " llama-server brew install llama.cpp (or download from https://github.com/ggml-org/llama.cpp/releases)" ;;
520
+ esac
521
+ }
522
+
523
+ _llmstack_warn_missing() {
524
+ printf '\n'
525
+ printf '\033[38;5;220m[llmstack]\033[0m detected %s/.llmstack but missing local tool(s):\n' "$1"
526
+ local t
527
+ for t in "${_llmstack_missing[@]}"; do
528
+ _llmstack_install_hint "$t"
529
+ done
530
+ printf ' not activating. install the missing tool(s) and `cd` back in to retry.\n\n'
531
+ }
532
+
533
+ _llmstack_deactivate() {
534
+ if [[ -n "${LLMSTACK_WORK_DIR:-}" ]]; then
535
+ unset OPENCODE_CONFIG LLMSTACK_WORK_DIR LLMSTACK_ACTIVE LLMSTACK_CHANNEL LLMSTACK_REMOTE_URL
536
+ if [[ -n "${_LLMSTACK_PS1_BACKUP:-}" ]]; then
537
+ PS1="$_LLMSTACK_PS1_BACKUP"
538
+ unset _LLMSTACK_PS1_BACKUP
539
+ fi
540
+ fi
541
+ unset _LLMSTACK_WARNED_FOR
542
+ }
543
+
544
+ _llmstack_activate() {
545
+ local found
546
+ found="$(_llmstack_find_root)" || found=""
547
+
548
+ if [[ -z "$found" ]]; then
549
+ _llmstack_deactivate
550
+ return 0
551
+ fi
552
+ if [[ "${LLMSTACK_WORK_DIR:-}" == "$found" ]]; then
553
+ return 0
554
+ fi
555
+ if [[ "${_LLMSTACK_WARNED_FOR:-}" == "$found" ]]; then
556
+ return 0
557
+ fi
558
+ _llmstack_deactivate
559
+
560
+ local _ch _url
561
+ if [[ -f "$found/.llmstack/active-channel" ]]; then
562
+ _llmstack_read_marker "$found/.llmstack/active-channel"
563
+ else
564
+ _llmstack_read_marker "$found/.llmstack/default-channel"
565
+ fi
566
+ : "${_ch:=current}"
567
+
568
+ if ! _llmstack_check_tools "$_ch"; then
569
+ _llmstack_warn_missing "$found"
570
+ export _LLMSTACK_WARNED_FOR="$found"
571
+ return 0
572
+ fi
573
+
574
+ export OPENCODE_CONFIG="$found/.llmstack/opencode.json"
575
+ export LLMSTACK_WORK_DIR="$found"
576
+ export LLMSTACK_ACTIVE="1"
577
+ export LLMSTACK_CHANNEL="$_ch"
578
+ if [[ "$_ch" == "external" && -n "$_url" ]]; then
579
+ export LLMSTACK_REMOTE_URL="$_url"
580
+ else
581
+ unset LLMSTACK_REMOTE_URL
582
+ fi
583
+
584
+ : "${_LLMSTACK_PS1_BACKUP:=$PS1}"
585
+ export _LLMSTACK_PS1_BACKUP
586
+ local label color suffix
587
+ label="$(basename "$found")"
588
+ color="$(_llmstack_color "$_ch")"
589
+ if [[ "$_ch" == "external" && -n "$_url" ]]; then
590
+ suffix=" $_url"
591
+ else
592
+ suffix=""
593
+ fi
594
+ PS1="\[\033[38;5;${color}m\][llmstack:${label}${suffix}]\[\033[0m\] $_LLMSTACK_PS1_BACKUP"
595
+ }
596
+
597
+ # Bash has no chpwd hook, so we run on every prompt. Idempotent: the
598
+ # guards above (LLMSTACK_WORK_DIR or _LLMSTACK_WARNED_FOR matching
599
+ # $found) make repeated calls a no-op.
600
+ case ";${PROMPT_COMMAND:-};" in
601
+ *";_llmstack_activate;"*) ;;
602
+ *) PROMPT_COMMAND="_llmstack_activate;${PROMPT_COMMAND:-}" ;;
603
+ esac
604
+ _llmstack_activate
605
+ # --- end llmstack hook -----------------------------------------------------
606
+ """
607
+
608
+
609
+ _POWERSHELL_HOOK = r"""# --- llmstack auto-activation hook (PowerShell) ----------------------------
610
+ # Generated by `llmstack activate powershell`. Walks up from $PWD on each
611
+ # prompt to find the nearest .llmstack/opencode.json. Reads the project's
612
+ # channel marker (active-channel, falling back to default-channel) and
613
+ # exports OPENCODE_CONFIG, LLMSTACK_WORK_DIR, LLMSTACK_CHANNEL, and --
614
+ # when channel == external -- LLMSTACK_REMOTE_URL. LLMSTACK_WORK_DIR is
615
+ # what `llmstack <action>` keys off, so commands work from any
616
+ # subdirectory of an installed project. Reverses everything when you cd
617
+ # out of the project.
618
+ #
619
+ # Tool-availability gate: before activating we verify the tools needed
620
+ # for this channel are present:
621
+ # - llmstack (always required)
622
+ # - llama-swap (only for local channels: current / next)
623
+ # - llama-server or llama-cli (likewise local-only)
624
+ # If any required tool is missing we print a one-shot warning + install
625
+ # hints and DON'T activate. The _LLMSTACK_WARNED_FOR guard suppresses
626
+ # the warning on subsequent prompts in the same project.
627
+ #
628
+ # Add to your $PROFILE (one time):
629
+ # llmstack activate powershell | Out-String | Invoke-Expression
630
+ # or, to persist across sessions:
631
+ # "Invoke-Expression (& llmstack activate powershell | Out-String)" | Add-Content $PROFILE
632
+ #
633
+ # Marker file format (one line):
634
+ # <channel>[ <url>]
635
+ #
636
+ # Prompt colour by channel:
637
+ # current -> steel-blue (38)
638
+ # next -> orange (208)
639
+ # external -> medium-purple (135)
640
+
641
+ function global:_LlmstackFindRoot {
642
+ param([string]$Start = (Get-Location).Path)
643
+ $dir = $Start
644
+ while ($dir -and (Test-Path $dir)) {
645
+ if (Test-Path -LiteralPath (Join-Path $dir ".llmstack/opencode.json")) {
646
+ return $dir
647
+ }
648
+ $parent = Split-Path -Parent $dir
649
+ if ($parent -eq $dir) { break }
650
+ $dir = $parent
651
+ }
652
+ return $null
653
+ }
654
+
655
+ function global:_LlmstackChannelColor {
656
+ param([string]$Channel)
657
+ switch ($Channel) {
658
+ "next" { 208 }
659
+ "external" { 135 }
660
+ default { 38 }
661
+ }
662
+ }
663
+
664
+ function global:_LlmstackReadMarker {
665
+ param([string]$Path)
666
+ if (-not (Test-Path -LiteralPath $Path)) { return @{ channel = ""; url = "" } }
667
+ $line = (Get-Content -LiteralPath $Path -TotalCount 1) -as [string]
668
+ if (-not $line) { return @{ channel = ""; url = "" } }
669
+ $parts = $line.Trim() -split '\s+', 2
670
+ return @{
671
+ channel = $parts[0]
672
+ url = if ($parts.Length -gt 1) { $parts[1] } else { "" }
673
+ }
674
+ }
675
+
676
+ function global:_LlmstackFindSwap {
677
+ # Mirror of llmstack.paths.bin_dir(): $LLMSTACK_BIN_DIR > $LLMSTACK_DATA_DIR\bin
678
+ # > $LOCALAPPDATA\llmstack\bin (Windows) or $XDG_DATA_HOME/llmstack/bin (POSIX),
679
+ # then PATH.
680
+ $cands = @()
681
+ if ($env:LLMSTACK_BIN_DIR) { $cands += (Join-Path $env:LLMSTACK_BIN_DIR "llama-swap.exe") }
682
+ if ($env:LLMSTACK_DATA_DIR) { $cands += (Join-Path $env:LLMSTACK_DATA_DIR "bin\llama-swap.exe") }
683
+ if ($env:LOCALAPPDATA) { $cands += (Join-Path $env:LOCALAPPDATA "llmstack\bin\llama-swap.exe") }
684
+ if ($env:XDG_DATA_HOME) { $cands += (Join-Path $env:XDG_DATA_HOME "llmstack/bin/llama-swap") }
685
+ if ($env:HOME) { $cands += (Join-Path $env:HOME ".local/share/llmstack/bin/llama-swap") }
686
+ foreach ($p in $cands) {
687
+ if (Test-Path -LiteralPath $p) { return $true }
688
+ }
689
+ return [bool](Get-Command llama-swap -ErrorAction SilentlyContinue)
690
+ }
691
+
692
+ function global:_LlmstackCheckTools {
693
+ param([string]$Channel)
694
+ $missing = @()
695
+ if (-not (Get-Command llmstack -ErrorAction SilentlyContinue)) { $missing += "llmstack" }
696
+ if ($Channel -ne "external") {
697
+ if (-not (_LlmstackFindSwap)) { $missing += "llama-swap" }
698
+ if (-not (Get-Command llama-server -ErrorAction SilentlyContinue) -and `
699
+ -not (Get-Command llama-cli -ErrorAction SilentlyContinue)) {
700
+ $missing += "llama-server"
701
+ }
702
+ }
703
+ return ,$missing
704
+ }
705
+
706
+ function global:_LlmstackInstallHint {
707
+ param([string]$Tool)
708
+ switch ($Tool) {
709
+ "llmstack" { " llmstack pip install -e <repo> (or: pipx install llmstack)" }
710
+ "llama-swap" { " llama-swap llmstack install-llama-swap" }
711
+ "llama-server" { " llama-server download from https://github.com/ggml-org/llama.cpp/releases" }
712
+ }
713
+ }
714
+
715
+ function global:_LlmstackWarnMissing {
716
+ param([string]$Found, [string[]]$Missing)
717
+ Write-Host ""
718
+ $esc = [char]27
719
+ Write-Host -NoNewline "${esc}[38;5;220m[llmstack]${esc}[0m "
720
+ Write-Host "detected $Found\.llmstack but missing local tool(s):"
721
+ foreach ($t in $Missing) { Write-Host (_LlmstackInstallHint $t) }
722
+ Write-Host " not activating. install the missing tool(s) and cd back in to retry."
723
+ Write-Host ""
724
+ }
725
+
726
+ function global:_LlmstackDeactivate {
727
+ if ($env:LLMSTACK_WORK_DIR) {
728
+ Remove-Item Env:OPENCODE_CONFIG -ErrorAction SilentlyContinue
729
+ Remove-Item Env:LLMSTACK_WORK_DIR -ErrorAction SilentlyContinue
730
+ Remove-Item Env:LLMSTACK_ACTIVE -ErrorAction SilentlyContinue
731
+ Remove-Item Env:LLMSTACK_CHANNEL -ErrorAction SilentlyContinue
732
+ Remove-Item Env:LLMSTACK_REMOTE_URL -ErrorAction SilentlyContinue
733
+ $global:_LLMSTACK_PROMPT_ON = $false
734
+ }
735
+ Remove-Item Env:_LLMSTACK_WARNED_FOR -ErrorAction SilentlyContinue
736
+ }
737
+
738
+ function global:_LlmstackActivate {
739
+ $found = _LlmstackFindRoot
740
+ if (-not $found) {
741
+ _LlmstackDeactivate
742
+ return
743
+ }
744
+ if ($env:LLMSTACK_WORK_DIR -eq $found) { return }
745
+ if ($env:_LLMSTACK_WARNED_FOR -eq $found) { return }
746
+
747
+ _LlmstackDeactivate
748
+
749
+ $live = Join-Path $found ".llmstack/active-channel"
750
+ $intent = Join-Path $found ".llmstack/default-channel"
751
+ $marker = if (Test-Path -LiteralPath $live) { _LlmstackReadMarker $live } else { _LlmstackReadMarker $intent }
752
+ $channel = if ($marker.channel) { $marker.channel } else { "current" }
753
+
754
+ $missing = _LlmstackCheckTools $channel
755
+ if ($missing.Count -gt 0) {
756
+ _LlmstackWarnMissing $found $missing
757
+ $env:_LLMSTACK_WARNED_FOR = $found
758
+ return
759
+ }
760
+
761
+ $env:OPENCODE_CONFIG = Join-Path $found ".llmstack/opencode.json"
762
+ $env:LLMSTACK_WORK_DIR = $found
763
+ $env:LLMSTACK_ACTIVE = "1"
764
+ $env:LLMSTACK_CHANNEL = $channel
765
+ if ($channel -eq "external" -and $marker.url) {
766
+ $env:LLMSTACK_REMOTE_URL = $marker.url
767
+ } else {
768
+ Remove-Item Env:LLMSTACK_REMOTE_URL -ErrorAction SilentlyContinue
769
+ }
770
+
771
+ $global:_LLMSTACK_PROMPT_COLOR = _LlmstackChannelColor $channel
772
+ $global:_LLMSTACK_PROMPT_LABEL = Split-Path $found -Leaf
773
+ $global:_LLMSTACK_PROMPT_SUFFIX = if ($channel -eq "external" -and $marker.url) { " " + $marker.url } else { "" }
774
+ $global:_LLMSTACK_PROMPT_ON = $true
775
+ }
776
+
777
+ # Wrap the existing prompt once so every prompt cycle re-runs the
778
+ # activator and (when inside a project) prefixes the prompt. Idempotent
779
+ # via the _LLMSTACK_PROMPT_WRAPPED guard.
780
+ if (-not (Get-Variable -Name _LLMSTACK_PROMPT_WRAPPED -Scope Global -ErrorAction SilentlyContinue)) {
781
+ $global:_LLMSTACK_PROMPT_WRAPPED = $true
782
+ $global:_LLMSTACK_ORIG_PROMPT = (Get-Item Function:prompt).ScriptBlock
783
+ function global:prompt {
784
+ _LlmstackActivate
785
+ if ($global:_LLMSTACK_PROMPT_ON) {
786
+ $esc = [char]27
787
+ $color = $global:_LLMSTACK_PROMPT_COLOR
788
+ $label = $global:_LLMSTACK_PROMPT_LABEL
789
+ $suffix = $global:_LLMSTACK_PROMPT_SUFFIX
790
+ Write-Host -NoNewline "$esc[38;5;${color}m[llmstack:$label$suffix]$esc[0m "
791
+ }
792
+ & $global:_LLMSTACK_ORIG_PROMPT
793
+ }
794
+ }
795
+ _LlmstackActivate
796
+ # --- end llmstack hook -----------------------------------------------------
797
+ """
798
+
799
+
800
+ def activate_hook(shell: str) -> str:
801
+ """Return the shell-rc snippet for ``shell``.
802
+
803
+ Supported: ``zsh``, ``bash``, ``powershell`` / ``pwsh``.
804
+ """
805
+ if shell == "zsh":
806
+ return _ZSH_HOOK
807
+ if shell == "bash":
808
+ return _BASH_HOOK
809
+ if shell in ("powershell", "pwsh"):
810
+ return _POWERSHELL_HOOK
811
+ raise SystemExit(
812
+ f"activate: unknown shell '{shell}' (supported: zsh, bash, powershell)"
813
+ )
814
+
815
+
816
+ # --- in-shell channel/prompt refresh -------------------------------------
817
+ #
818
+ # Used by ``llmstack reload`` (and pointed at by ``start`` when it
819
+ # detects a channel switch inside an already-active shell). The activate
820
+ # hook normally re-evaluates env + PROMPT on every chpwd / prompt cycle,
821
+ # but a mid-shell `start --next` doesn't trigger that, so the prompt
822
+ # would otherwise stay on the old channel until the next cd. This
823
+ # function emits shell commands the user pipes through ``eval`` to apply
824
+ # the channel switch in-place, no nested subshell.
825
+ #
826
+ # We mirror the activate hook's PS1 format so the prompt looks identical
827
+ # to a fresh chpwd-driven re-render. ``_LLMSTACK_PS1_BACKUP`` is set by
828
+ # the hook on first activation; if it isn't (the user never sourced the
829
+ # hook, e.g. inside a ``start``-spawned subshell), we fall back to the
830
+ # current PROMPT/PS1 so we don't clobber their setup.
831
+
832
+
833
+ def _refresh_zsh(channel: str, color: int, label: str, project: str) -> None:
834
+ print(f'export LLMSTACK_CHANNEL="{channel}"')
835
+ rurl = remote_url()
836
+ if channel == "external" and rurl:
837
+ print(f'export LLMSTACK_REMOTE_URL="{rurl}"')
838
+ else:
839
+ print("unset LLMSTACK_REMOTE_URL")
840
+ suffix = f" {rurl}" if (channel == "external" and rurl) else ""
841
+ print(
842
+ f'PROMPT="%F{{{color}}}[llmstack:{project}{suffix}]%f '
843
+ f'${{_LLMSTACK_PS1_BACKUP:-$PROMPT}}"'
844
+ )
845
+ # Update LLMSTACK_WORK_DIR even though work_dir doesn't change here --
846
+ # if the user reloaded outside any cd-driven activation it ensures
847
+ # downstream commands see a consistent env.
848
+ print(f'export LLMSTACK_WORK_DIR="{label}"')
849
+ print('export LLMSTACK_ACTIVE="1"')
850
+
851
+
852
+ def _refresh_bash(channel: str, color: int, label: str, project: str) -> None:
853
+ print(f'export LLMSTACK_CHANNEL="{channel}"')
854
+ rurl = remote_url()
855
+ if channel == "external" and rurl:
856
+ print(f'export LLMSTACK_REMOTE_URL="{rurl}"')
857
+ else:
858
+ print("unset LLMSTACK_REMOTE_URL")
859
+ suffix = f" {rurl}" if (channel == "external" and rurl) else ""
860
+ print(
861
+ f'PS1="\\[\\033[38;5;{color}m\\][llmstack:{project}{suffix}]\\[\\033[0m\\] '
862
+ f'${{_LLMSTACK_PS1_BACKUP:-$PS1}}"'
863
+ )
864
+ print(f'export LLMSTACK_WORK_DIR="{label}"')
865
+ print('export LLMSTACK_ACTIVE="1"')
866
+
867
+
868
+ def _refresh_powershell(channel: str, color: int, label: str, project: str) -> None:
869
+ print(f'$env:LLMSTACK_CHANNEL = "{channel}"')
870
+ rurl = remote_url()
871
+ if channel == "external" and rurl:
872
+ print(f'$env:LLMSTACK_REMOTE_URL = "{rurl}"')
873
+ else:
874
+ print("Remove-Item Env:LLMSTACK_REMOTE_URL -ErrorAction SilentlyContinue")
875
+ suffix = f" {rurl}" if (channel == "external" and rurl) else ""
876
+ # Powershell's prompt() wrapper from the activate hook reads these
877
+ # globals on each render; setting them here is enough.
878
+ print(f"$global:_LLMSTACK_PROMPT_COLOR = {color}")
879
+ print(f'$global:_LLMSTACK_PROMPT_LABEL = "{project}"')
880
+ print(f'$global:_LLMSTACK_PROMPT_SUFFIX = "{suffix}"')
881
+ print("$global:_LLMSTACK_PROMPT_ON = $true")
882
+ print(f'$env:LLMSTACK_WORK_DIR = "{label}"')
883
+ print('$env:LLMSTACK_ACTIVE = "1"')
884
+
885
+
886
+ def emit_shell_refresh(channel: str) -> None:
887
+ """Print eval-able shell commands that apply ``channel`` to the
888
+ current interactive shell (env exports + PS1 update).
889
+
890
+ Detects the user's shell family via ``$SHELL`` and dispatches to a
891
+ family-specific emitter; ``cmd.exe`` only gets env exports because
892
+ its prompt model can't be redefined cleanly mid-session.
893
+
894
+ Output goes to ``sys.stdout`` so callers piped through ``eval`` /
895
+ ``Invoke-Expression`` apply the change in-place. All other output
896
+ (errors, hints) should go to ``stderr`` to keep stdout
897
+ eval-clean.
898
+ """
899
+ paths = resolve()
900
+ _, shell_name = _user_shell()
901
+ family = shell_family(shell_name)
902
+ color = COLOR_CODE.get(channel, COLOR_CODE["current"])
903
+ project = paths.work_dir.name
904
+ work_dir_str = str(paths.work_dir)
905
+
906
+ if family == "zsh":
907
+ _refresh_zsh(channel, color, work_dir_str, project)
908
+ elif family == "bash":
909
+ _refresh_bash(channel, color, work_dir_str, project)
910
+ elif family == "powershell":
911
+ _refresh_powershell(channel, color, work_dir_str, project)
912
+ elif family == "cmd":
913
+ # cmd.exe: env-only; the doskey-driven prompt from spawn_subshell
914
+ # isn't reachable from outside that one-shot session.
915
+ print(f'set "LLMSTACK_CHANNEL={channel}"')
916
+ rurl = remote_url()
917
+ if channel == "external" and rurl:
918
+ print(f'set "LLMSTACK_REMOTE_URL={rurl}"')
919
+ else:
920
+ print('set "LLMSTACK_REMOTE_URL="')
921
+ print(f'set "LLMSTACK_WORK_DIR={work_dir_str}"')
922
+ print('set "LLMSTACK_ACTIVE=1"')
923
+ else:
924
+ # Unknown shell -- emit POSIX-shaped exports so something useful
925
+ # comes out, but skip the prompt update.
926
+ print(f'export LLMSTACK_CHANNEL="{channel}"')
927
+ print(f'export LLMSTACK_WORK_DIR="{work_dir_str}"')