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/AGENTS.md +13 -0
- llmstack/__init__.py +20 -0
- llmstack/__main__.py +10 -0
- llmstack/_platform.py +420 -0
- llmstack/app.py +644 -0
- llmstack/backends/__init__.py +19 -0
- llmstack/backends/bedrock.py +790 -0
- llmstack/check_models.py +119 -0
- llmstack/cli.py +264 -0
- llmstack/commands/__init__.py +10 -0
- llmstack/commands/_helpers.py +91 -0
- llmstack/commands/activate.py +71 -0
- llmstack/commands/check.py +13 -0
- llmstack/commands/download.py +27 -0
- llmstack/commands/install.py +365 -0
- llmstack/commands/install_llama_swap.py +36 -0
- llmstack/commands/reload.py +59 -0
- llmstack/commands/restart.py +12 -0
- llmstack/commands/setup.py +146 -0
- llmstack/commands/start.py +360 -0
- llmstack/commands/status.py +260 -0
- llmstack/commands/stop.py +73 -0
- llmstack/download/__init__.py +21 -0
- llmstack/download/binary.py +234 -0
- llmstack/download/ggufs.py +164 -0
- llmstack/generators/__init__.py +37 -0
- llmstack/generators/llama_swap.py +421 -0
- llmstack/generators/opencode.py +291 -0
- llmstack/models.ini +304 -0
- llmstack/paths.py +318 -0
- llmstack/shell_env.py +927 -0
- llmstack/tiers.py +394 -0
- opencode_llmstack-0.6.0.dist-info/METADATA +693 -0
- opencode_llmstack-0.6.0.dist-info/RECORD +37 -0
- opencode_llmstack-0.6.0.dist-info/WHEEL +5 -0
- opencode_llmstack-0.6.0.dist-info/entry_points.txt +2 -0
- opencode_llmstack-0.6.0.dist-info/top_level.txt +1 -0
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}"')
|