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/check_models.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Snapshot the currently-configured GGUFs and what's recommended.
|
|
2
|
+
|
|
3
|
+
For every tier in ``models.ini``, prints a row per file (current + upgrade
|
|
4
|
+
target if defined) with:
|
|
5
|
+
|
|
6
|
+
- filename
|
|
7
|
+
- HuggingFace size + last-modified
|
|
8
|
+
- direct URL to the GGUF on HF
|
|
9
|
+
- DRIFT marker when ``models.ini`` and ``llama-swap.yaml`` disagree about
|
|
10
|
+
the currently-configured file
|
|
11
|
+
|
|
12
|
+
Read-only -- no side effects. Invoked by ``llmstack check``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from huggingface_hub import HfApi
|
|
23
|
+
|
|
24
|
+
from llmstack.paths import resolve
|
|
25
|
+
from llmstack.tiers import load_tiers
|
|
26
|
+
|
|
27
|
+
HF_RE = re.compile(r"-hf\s+(\S+/\S+)")
|
|
28
|
+
HFF_RE = re.compile(r"-hff\s+(\S+\.gguf)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_yaml(yaml_path: Path) -> dict[str, tuple[str, str]]:
|
|
32
|
+
"""Map tier-name -> (repo, file) by reading ``llama-swap.yaml``.
|
|
33
|
+
|
|
34
|
+
Strips comment lines from each ``cmd:`` block before regex-matching so
|
|
35
|
+
commented-out ``-hf`` examples don't pollute the result.
|
|
36
|
+
"""
|
|
37
|
+
if not yaml_path.exists():
|
|
38
|
+
return {}
|
|
39
|
+
cfg = yaml.safe_load(yaml_path.read_text())
|
|
40
|
+
out: dict[str, tuple[str, str]] = {}
|
|
41
|
+
for tier, m in (cfg.get("models") or {}).items():
|
|
42
|
+
cmd_lines = [
|
|
43
|
+
line for line in (m.get("cmd") or "").splitlines()
|
|
44
|
+
if not line.strip().startswith("#")
|
|
45
|
+
]
|
|
46
|
+
cmd = "\n".join(cmd_lines)
|
|
47
|
+
repo_m = HF_RE.search(cmd)
|
|
48
|
+
file_m = HFF_RE.search(cmd)
|
|
49
|
+
if repo_m and file_m:
|
|
50
|
+
out[tier] = (repo_m.group(1), file_m.group(1))
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hf_meta(api: HfApi, repo: str, fname: str) -> tuple[str, str]:
|
|
55
|
+
"""Fetch (size_human, last_modified_iso) from HF for a single file."""
|
|
56
|
+
try:
|
|
57
|
+
info = api.model_info(repo, files_metadata=True)
|
|
58
|
+
size = next((s.size for s in info.siblings if s.rfilename == fname), None)
|
|
59
|
+
size_s = f"{size / 1024 / 1024 / 1024:.1f} GB" if size else "?"
|
|
60
|
+
mod = info.last_modified.strftime("%Y-%m-%d") if info.last_modified else "?"
|
|
61
|
+
return size_s, mod
|
|
62
|
+
except Exception as e: # network / 404 / auth - keep going for the next row
|
|
63
|
+
return "ERR", str(e)[:24]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
api = HfApi()
|
|
68
|
+
tiers = load_tiers()
|
|
69
|
+
yaml_cfg = parse_yaml(resolve().llama_swap_yaml)
|
|
70
|
+
|
|
71
|
+
fmt = "{:<18} {:<8} {:<70} {:>10} {:>12} {}"
|
|
72
|
+
print(fmt.format("tier", "label", "file / model-id", "size", "updated", "url / region"))
|
|
73
|
+
print("-" * 165)
|
|
74
|
+
|
|
75
|
+
drift = []
|
|
76
|
+
for tier in tiers.values():
|
|
77
|
+
if tier.is_bedrock and tier.bedrock is not None:
|
|
78
|
+
b = tier.bedrock
|
|
79
|
+
scope_parts = [p for p in (b.region, b.profile) if p]
|
|
80
|
+
scope = " / ".join(scope_parts) if scope_parts else "(default chain)"
|
|
81
|
+
print(fmt.format(tier.name, "bedrock", b.model_id, "-", "-", scope))
|
|
82
|
+
if b.has_next:
|
|
83
|
+
next_scope_parts = [p for p in (b.region_next or b.region, b.profile) if p]
|
|
84
|
+
next_scope = " / ".join(next_scope_parts) if next_scope_parts else "(default chain)"
|
|
85
|
+
print(fmt.format(tier.name, "next", b.model_id_next or "", "-", "-", next_scope))
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
for tf in tier.files():
|
|
89
|
+
size_s, mod = hf_meta(api, tf.repo, tf.file)
|
|
90
|
+
url = f"https://huggingface.co/{tf.repo}/blob/main/{tf.file}"
|
|
91
|
+
label = tf.label
|
|
92
|
+
if tf.label == "current":
|
|
93
|
+
actual = yaml_cfg.get(tier.name)
|
|
94
|
+
if actual and actual != (tf.repo, tf.file):
|
|
95
|
+
label = "DRIFT!"
|
|
96
|
+
drift.append((tier.name, (tf.repo, tf.file), actual))
|
|
97
|
+
print(fmt.format(tier.name, label, tf.file, size_s, mod, url))
|
|
98
|
+
|
|
99
|
+
if drift:
|
|
100
|
+
print()
|
|
101
|
+
print("[!] DRIFT detected between models.ini (recommended) and llama-swap.yaml (active):")
|
|
102
|
+
for tier, want, got in drift:
|
|
103
|
+
print(f" [{tier}] ini wants {want[0]} / {want[1]}")
|
|
104
|
+
print(f" yaml has {got[0]} / {got[1]}")
|
|
105
|
+
print(" Reconcile by editing one of the two so they match.")
|
|
106
|
+
|
|
107
|
+
print()
|
|
108
|
+
print("To look for upgrades, browse:")
|
|
109
|
+
print(" https://huggingface.co/models?library=gguf&sort=trending")
|
|
110
|
+
print(" https://huggingface.co/bartowski (general GGUF maintainer)")
|
|
111
|
+
print(" https://huggingface.co/unsloth (Qwen + UD dynamic quants)")
|
|
112
|
+
print(" https://huggingface.co/mradermacher (i1 + abliterated/heretic)")
|
|
113
|
+
print()
|
|
114
|
+
print("Then: see UPGRADING.md for the workflow.")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
sys.exit(main(sys.argv[1:]))
|
llmstack/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""``llmstack`` console-script entry point.
|
|
2
|
+
|
|
3
|
+
This is the Python replacement for ``llmstack.sh``. It does only one
|
|
4
|
+
thing: parse the action word, look up the matching ``commands.<action>``
|
|
5
|
+
module, and call its ``run(args)`` function. Every action implements its
|
|
6
|
+
own flag parsing so help text and error messages stay close to the
|
|
7
|
+
behaviour they describe.
|
|
8
|
+
|
|
9
|
+
llmstack <action> [args...]
|
|
10
|
+
|
|
11
|
+
For machine readers / shell completions, ``llmstack help`` prints the
|
|
12
|
+
full action table; ``llmstack <action> -h`` prints per-action help.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from llmstack import __version__
|
|
21
|
+
|
|
22
|
+
USAGE = """\
|
|
23
|
+
llmstack - multi-tier local LLM stack (llama-swap + auto-router + opencode wiring)
|
|
24
|
+
|
|
25
|
+
Does NOT touch ~/.config/opencode/opencode.json. Instead, the generated
|
|
26
|
+
opencode config lives at <work-dir>/.llmstack/opencode.json, and the
|
|
27
|
+
activate hook (`llmstack activate <shell>`) auto-exports OPENCODE_CONFIG
|
|
28
|
+
whenever you cd into a project that has a `.llmstack/`. Inside that
|
|
29
|
+
hooked shell, `opencode` picks up our config; in any other terminal,
|
|
30
|
+
opencode keeps using your global setup unchanged.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
llmstack <action> [options]
|
|
34
|
+
|
|
35
|
+
Actions:
|
|
36
|
+
setup [--skip-download] [--skip-wait]
|
|
37
|
+
First-time walkthrough: kick off GGUF downloads, wait for them, install
|
|
38
|
+
the llama-swap binary, print the shell activation hook, check opencode.
|
|
39
|
+
|
|
40
|
+
install [--print] [--current | --next | --external [URL]]
|
|
41
|
+
Regenerate .llmstack/opencode.json (+ AGENTS.md copy) and pin
|
|
42
|
+
the default channel for the next `start`. The source of tier
|
|
43
|
+
config depends on channel:
|
|
44
|
+
|
|
45
|
+
--current / --next (local)
|
|
46
|
+
Read <work-dir>/.llmstack/models.ini, seeding it from the
|
|
47
|
+
bundled template on first run. llama-swap.yaml is NOT
|
|
48
|
+
touched -- `start` owns that and regenerates it for the
|
|
49
|
+
chosen channel on each launch.
|
|
50
|
+
|
|
51
|
+
--external [URL] (thin client)
|
|
52
|
+
Fetch models.ini live from the router (`GET URL/models.ini`)
|
|
53
|
+
and render opencode.json against that -- no local
|
|
54
|
+
models.ini is created or kept. Re-run `install` any time to
|
|
55
|
+
pick up router-side edits. URL precedence:
|
|
56
|
+
flag arg > $LLMSTACK_REMOTE_URL > the local router
|
|
57
|
+
(http://127.0.0.1:10101).
|
|
58
|
+
The localhost default is what makes "two projects, one
|
|
59
|
+
host" work zero-config: install one project local and the
|
|
60
|
+
others --external.
|
|
61
|
+
|
|
62
|
+
$LLMSTACK_REMOTE_URL set without --external still implies
|
|
63
|
+
--external (back-compat). The activate hook re-exports this
|
|
64
|
+
var when you `cd` into an external project, so re-running
|
|
65
|
+
`install` from inside an active shell never needs the URL or
|
|
66
|
+
the flag again.
|
|
67
|
+
|
|
68
|
+
`--print` writes the rendered opencode.json to stdout instead
|
|
69
|
+
of files (still fetches the remote in external mode).
|
|
70
|
+
|
|
71
|
+
install-llama-swap [--force]
|
|
72
|
+
(Re-)download the llama-swap Go binary into $LLMSTACK_BIN_DIR (default
|
|
73
|
+
$XDG_DATA_HOME/llmstack/bin/). Setup runs this for you.
|
|
74
|
+
|
|
75
|
+
download
|
|
76
|
+
Download every GGUF named in models.ini (current + queued next) to
|
|
77
|
+
the standard llama.cpp cache, in parallel, in the background.
|
|
78
|
+
|
|
79
|
+
start [--current | --next] [--detach]
|
|
80
|
+
Generate .llmstack/llama-swap.yaml for the chosen channel, bring up
|
|
81
|
+
llama-swap (:10102) + auto-router (:10101). Default channel =
|
|
82
|
+
whatever `install` pinned, else `current`. `--next` swaps any tier
|
|
83
|
+
with hf_file_next. The yaml is regenerated on each fresh launch
|
|
84
|
+
so it always matches the live models.ini; if the daemons are
|
|
85
|
+
already up the running yaml is left alone.
|
|
86
|
+
|
|
87
|
+
Subshell behaviour: if LLMSTACK_ACTIVE is already set (i.e. the
|
|
88
|
+
activate hook has wired this shell up) `start` just brings up
|
|
89
|
+
daemons and returns. Only when the env is not set does `start`
|
|
90
|
+
drop you into a subshell with OPENCODE_CONFIG exported -- as a
|
|
91
|
+
fallback for users who haven't run the activate hook yet.
|
|
92
|
+
`--detach` skips the subshell unconditionally.
|
|
93
|
+
|
|
94
|
+
When the project is installed with channel=external (see
|
|
95
|
+
`install --external`), no daemons are launched: this just
|
|
96
|
+
verifies the pinned remote `GET /models.ini` (which doubles as
|
|
97
|
+
the router's health check -- there's no separate /health route).
|
|
98
|
+
|
|
99
|
+
activate <zsh|bash|powershell>
|
|
100
|
+
Write the auto-activation hook to ~/.<shell>_llmstack_hook and
|
|
101
|
+
print a `source` line to stdout, so
|
|
102
|
+
|
|
103
|
+
eval "$(llmstack activate zsh)"
|
|
104
|
+
|
|
105
|
+
both regenerates the file and turns the hook on in the current
|
|
106
|
+
shell. Paste the same line into your shell rc to make it stick:
|
|
107
|
+
# ~/.zshrc
|
|
108
|
+
eval "$(llmstack activate zsh)"
|
|
109
|
+
The hook walks up from $PWD on every prompt, finds the nearest
|
|
110
|
+
.llmstack/opencode.json, and exports OPENCODE_CONFIG +
|
|
111
|
+
LLMSTACK_WORK_DIR + LLMSTACK_CHANNEL accordingly. Walks back out
|
|
112
|
+
when you cd away. There is no separate `shell` action -- this is
|
|
113
|
+
the shell action.
|
|
114
|
+
|
|
115
|
+
stop
|
|
116
|
+
Stop the router + llama-swap (and any orphaned llama-server children).
|
|
117
|
+
|
|
118
|
+
restart [--current | --next] [--detach]
|
|
119
|
+
stop + start. Convenient for cycling channels.
|
|
120
|
+
|
|
121
|
+
reload
|
|
122
|
+
Emit shell commands that re-export LLMSTACK_CHANNEL +
|
|
123
|
+
OPENCODE_CONFIG and re-render the [llmstack:<project>] prompt
|
|
124
|
+
prefix for the current channel marker. Pipe through eval to
|
|
125
|
+
apply in-place (no nested subshell):
|
|
126
|
+
eval "$(llmstack reload)"
|
|
127
|
+
Useful after `start --next` switches channels in an
|
|
128
|
+
already-active shell -- the activate hook only refreshes on
|
|
129
|
+
chpwd, so without this the prompt would lag until your next cd.
|
|
130
|
+
|
|
131
|
+
status
|
|
132
|
+
Show channel, pids, /v1/models, loaded llama-server processes.
|
|
133
|
+
|
|
134
|
+
check [args]
|
|
135
|
+
Snapshot configured GGUFs + flag drift between models.ini and
|
|
136
|
+
llama-swap.yaml.
|
|
137
|
+
|
|
138
|
+
help | -h | --help
|
|
139
|
+
This message.
|
|
140
|
+
|
|
141
|
+
version | --version
|
|
142
|
+
Print the package version and exit.
|
|
143
|
+
|
|
144
|
+
Environment overrides:
|
|
145
|
+
LLMSTACK_REMOTE_URL base URL of a *remote* llmstack router (e.g.
|
|
146
|
+
`http://10.0.0.5:10101`). Picked up by
|
|
147
|
+
`install` as an alternative to passing
|
|
148
|
+
`--external <url>`; once `install` runs, the
|
|
149
|
+
channel + URL are persisted in
|
|
150
|
+
.llmstack/default-channel and that file is
|
|
151
|
+
the source of truth (the env var is only
|
|
152
|
+
re-exported by the activate hook for
|
|
153
|
+
downstream callers).
|
|
154
|
+
LLMSTACK_MODELS_INI path to models.ini (default:
|
|
155
|
+
<work-dir>/.llmstack/models.ini).
|
|
156
|
+
LLMSTACK_WORK_DIR where .llmstack/ + logs/ live (default: $PWD
|
|
157
|
+
when invoked). Auto-exported by the activate
|
|
158
|
+
hook (`llmstack activate <shell>`) and by the
|
|
159
|
+
subshell `start` spawns, set to the project
|
|
160
|
+
root -- so commands work from any subdirectory
|
|
161
|
+
of an installed project. Without the hook,
|
|
162
|
+
run from the project root (or set this var).
|
|
163
|
+
Local daemons are singleton (ports 10101/10102);
|
|
164
|
+
to consume them from a second project on the
|
|
165
|
+
same host, install that project --external.
|
|
166
|
+
LLMSTACK_DATA_DIR persistent user-data root (default:
|
|
167
|
+
$XDG_DATA_HOME/llmstack). Where the binary lives.
|
|
168
|
+
LLMSTACK_BIN_DIR override just the binary location.
|
|
169
|
+
OPENCODE_CONFIG_DIR where to write opencode.json (default: .llmstack/).
|
|
170
|
+
LLAMA_SWAP_VERSION pin a specific llama-swap release (e.g. v211).
|
|
171
|
+
HF_TOKEN authenticate model downloads (faster rate limits).
|
|
172
|
+
LLMSTACK_SHELL shell to spawn from `start` when no active env
|
|
173
|
+
is detected (default: $SHELL).
|
|
174
|
+
|
|
175
|
+
Channel labels (LLMSTACK_CHANNEL):
|
|
176
|
+
current local stack, canonical channel (steel-blue prompt prefix)
|
|
177
|
+
next local stack, queued-upgrade channel (orange prompt prefix)
|
|
178
|
+
external thin client of an llmstack router (medium-purple prompt
|
|
179
|
+
prefix; the URL is shown alongside the project name in the
|
|
180
|
+
prompt: `[llmstack:<project> <url>]`). The URL is pinned at
|
|
181
|
+
install time -- typically a remote host, but defaults to
|
|
182
|
+
the local router so two projects on one host can share a
|
|
183
|
+
single set of daemons cleanly.
|
|
184
|
+
|
|
185
|
+
Channel markers on disk (.llmstack/active-channel, .llmstack/default-channel):
|
|
186
|
+
one line, format `<channel>[ <url>]`. The URL is only present for
|
|
187
|
+
channel=external; the activate hook re-exports it as
|
|
188
|
+
LLMSTACK_REMOTE_URL when you cd into the project, so you don't have to
|
|
189
|
+
put the URL in your shell rc.
|
|
190
|
+
|
|
191
|
+
Variables exported by the activate hook (and the start fallback subshell):
|
|
192
|
+
OPENCODE_CONFIG path to the generated .llmstack/opencode.json
|
|
193
|
+
LLMSTACK_WORK_DIR absolute path to the project root (auto-detected
|
|
194
|
+
by walking up from $PWD looking for .llmstack/)
|
|
195
|
+
LLMSTACK_CHANNEL current | next | external
|
|
196
|
+
LLMSTACK_ACTIVE "1" while the env is wired up
|
|
197
|
+
LLMSTACK_REMOTE_URL set when channel == external, from the marker file
|
|
198
|
+
LLMSTACK_ROOT absolute path to the llmstack package (start only)
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _print_help() -> None:
|
|
203
|
+
sys.stdout.write(USAGE)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _print_version() -> None:
|
|
207
|
+
print(f"llmstack {__version__}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_action(action: str) -> Callable[[list[str]], int]:
|
|
211
|
+
"""Resolve ``action`` to a ``run(args)`` callable, lazy-importing the module."""
|
|
212
|
+
aliases = {
|
|
213
|
+
"download-models": "download",
|
|
214
|
+
"check-models": "check",
|
|
215
|
+
}
|
|
216
|
+
name = aliases.get(action, action)
|
|
217
|
+
name = name.replace("-", "_")
|
|
218
|
+
target = f"llmstack.commands.{name}"
|
|
219
|
+
|
|
220
|
+
from importlib import import_module
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
module = import_module(target)
|
|
224
|
+
except ModuleNotFoundError as e:
|
|
225
|
+
# Only swallow the error when the *action module itself* is missing.
|
|
226
|
+
# Transitive ImportErrors (e.g. an uninstalled third-party dep) must
|
|
227
|
+
# surface, otherwise we mislead the user with "unknown action".
|
|
228
|
+
if e.name == target:
|
|
229
|
+
raise SystemExit(f"[!] unknown action: {action}\n\nrun: llmstack help") from None
|
|
230
|
+
raise SystemExit(
|
|
231
|
+
f"[!] action '{action}' failed to load: missing dependency '{e.name}'\n"
|
|
232
|
+
f" hint: pip install -e . (or pipx install .) to install llmstack's deps"
|
|
233
|
+
) from e
|
|
234
|
+
|
|
235
|
+
run = getattr(module, "run", None)
|
|
236
|
+
if not callable(run):
|
|
237
|
+
raise SystemExit(f"[!] action '{action}' is missing run() -- bug in llmstack")
|
|
238
|
+
return run
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main(argv: list[str] | None = None) -> int:
|
|
242
|
+
args = sys.argv[1:] if argv is None else list(argv)
|
|
243
|
+
|
|
244
|
+
if not args or args[0] in ("help", "-h", "--help"):
|
|
245
|
+
_print_help()
|
|
246
|
+
return 0
|
|
247
|
+
if args[0] in ("version", "-V", "--version"):
|
|
248
|
+
_print_version()
|
|
249
|
+
return 0
|
|
250
|
+
|
|
251
|
+
action, rest = args[0], args[1:]
|
|
252
|
+
run = _load_action(action)
|
|
253
|
+
try:
|
|
254
|
+
rc = run(rest)
|
|
255
|
+
except SystemExit:
|
|
256
|
+
raise
|
|
257
|
+
except KeyboardInterrupt:
|
|
258
|
+
print("\n[!] interrupted", file=sys.stderr)
|
|
259
|
+
return 130
|
|
260
|
+
return rc if isinstance(rc, int) else 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""One module per CLI action.
|
|
2
|
+
|
|
3
|
+
Each module exports a single ``run(args: list[str]) -> int`` callable
|
|
4
|
+
that the dispatcher in :mod:`llmstack.cli` invokes after stripping the
|
|
5
|
+
action name. ``args`` is the rest of ``sys.argv``; modules do their own
|
|
6
|
+
argparse / manual parsing -- the commands are small enough that one
|
|
7
|
+
shared parser would be more friction than it's worth.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Shared helpers for the start/stop/status commands.
|
|
2
|
+
|
|
3
|
+
Just process lifecycle plumbing -- pid files, port probes, daemon
|
|
4
|
+
spawning, kill-by-pattern. Kept separate from the command modules so the
|
|
5
|
+
control-flow per command stays readable. The actual platform-specific
|
|
6
|
+
process bits (POSIX signals vs Windows ``taskkill`` etc.) live in
|
|
7
|
+
:mod:`llmstack._platform` so this module stays portable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from llmstack._platform import (
|
|
17
|
+
describe_matching,
|
|
18
|
+
detached_popen,
|
|
19
|
+
find_pids,
|
|
20
|
+
kill_matching,
|
|
21
|
+
pid_alive,
|
|
22
|
+
terminate_pid,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_running(pid_file: Path) -> bool:
|
|
27
|
+
"""``True`` iff ``pid_file`` exists and points at a live process."""
|
|
28
|
+
if not pid_file.is_file():
|
|
29
|
+
return False
|
|
30
|
+
try:
|
|
31
|
+
pid = int(pid_file.read_text().strip())
|
|
32
|
+
except (ValueError, OSError):
|
|
33
|
+
return False
|
|
34
|
+
if pid <= 0:
|
|
35
|
+
return False
|
|
36
|
+
return pid_alive(pid)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_pid(pid_file: Path) -> int | None:
|
|
40
|
+
if not pid_file.is_file():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
return int(pid_file.read_text().strip())
|
|
44
|
+
except (ValueError, OSError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def port_responds(url: str, *, timeout: float = 2.0) -> bool:
|
|
49
|
+
"""Probe ``url`` for any 2xx response. Used to detect external daemons."""
|
|
50
|
+
try:
|
|
51
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
52
|
+
return 200 <= resp.status < 300
|
|
53
|
+
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def spawn_daemon(
|
|
58
|
+
argv: list[str],
|
|
59
|
+
*,
|
|
60
|
+
log: Path,
|
|
61
|
+
pid_file: Path,
|
|
62
|
+
env: dict[str, str] | None = None,
|
|
63
|
+
) -> int:
|
|
64
|
+
"""Spawn ``argv`` detached, redirect stdio to ``log``, write the pid."""
|
|
65
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
fp = log.open("ab")
|
|
68
|
+
proc = detached_popen(argv, stdout=fp, stderr=fp, env=env)
|
|
69
|
+
fp.close()
|
|
70
|
+
pid_file.write_text(f"{proc.pid}\n")
|
|
71
|
+
return proc.pid
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def kill_pid(pid: int, *, grace: float = 5.0) -> None:
|
|
75
|
+
"""SIGTERM (or taskkill), wait up to ``grace`` seconds, then hard-kill."""
|
|
76
|
+
terminate_pid(pid, grace=grace)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pgrep(pattern: str) -> list[int]:
|
|
80
|
+
"""Return PIDs whose full command-line matches ``pattern``."""
|
|
81
|
+
return find_pids(pattern)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pkill(pattern: str, *, grace: float = 5.0) -> int:
|
|
85
|
+
"""Terminate every process matching ``pattern``."""
|
|
86
|
+
return kill_matching(pattern, grace=grace)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pgrep_describe(pattern: str) -> str:
|
|
90
|
+
"""``pgrep -af``-style multi-line summary (empty when nothing matches)."""
|
|
91
|
+
return describe_matching(pattern)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``llmstack activate <shell>`` -- install + source the auto-activation hook.
|
|
2
|
+
|
|
3
|
+
Writes the hook to ``~/.<shell>_llmstack_hook`` and prints the matching
|
|
4
|
+
``source`` line to **stdout** so a one-shot
|
|
5
|
+
|
|
6
|
+
eval "$(llmstack activate zsh)"
|
|
7
|
+
|
|
8
|
+
both regenerates the file and turns on the hook in the current shell.
|
|
9
|
+
Pasting the same line into your shell rc keeps it on for every new
|
|
10
|
+
shell. All informational output goes to stderr so it doesn't get
|
|
11
|
+
captured by ``eval``.
|
|
12
|
+
|
|
13
|
+
Once the hook is installed, ``cd`` into any project with ``.llmstack/``
|
|
14
|
+
and the env (``OPENCODE_CONFIG``, ``LLMSTACK_WORK_DIR``,
|
|
15
|
+
``LLMSTACK_CHANNEL``) is set up automatically -- there is no separate
|
|
16
|
+
``llmstack shell`` action.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from llmstack.shell_env import activate_hook
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _print_help() -> None:
|
|
28
|
+
print("usage: llmstack activate <zsh|bash|powershell>", file=sys.stderr)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hook_path(shell: str) -> Path:
|
|
32
|
+
"""``~/.<shell>_llmstack_hook`` -- ``pwsh`` is normalised to ``powershell``
|
|
33
|
+
so the user doesn't end up with two redundant files."""
|
|
34
|
+
name = "powershell" if shell in ("powershell", "pwsh") else shell
|
|
35
|
+
return Path.home() / f".{name}_llmstack_hook"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _source_line(shell: str, path: Path) -> str:
|
|
39
|
+
"""Shell-specific incantation to load the hook file."""
|
|
40
|
+
if shell in ("powershell", "pwsh"):
|
|
41
|
+
return f". '{path}'"
|
|
42
|
+
return f'source "{path}"'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_hook(shell: str) -> tuple[Path, str]:
|
|
46
|
+
"""Render the hook for ``shell``, write it to disk, return ``(path, source_line)``.
|
|
47
|
+
|
|
48
|
+
Shared by ``llmstack activate`` (CLI surface) and ``llmstack setup``
|
|
49
|
+
(first-run walkthrough) so they install the hook the same way.
|
|
50
|
+
"""
|
|
51
|
+
body = activate_hook(shell) # raises SystemExit on unknown shell
|
|
52
|
+
path = _hook_path(shell)
|
|
53
|
+
path.write_text(body)
|
|
54
|
+
return path, _source_line(shell, path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(args: list[str]) -> int:
|
|
58
|
+
if not args or args[0] in ("-h", "--help"):
|
|
59
|
+
_print_help()
|
|
60
|
+
return 0
|
|
61
|
+
shell = args[0]
|
|
62
|
+
|
|
63
|
+
path, src = write_hook(shell)
|
|
64
|
+
|
|
65
|
+
eval_line = f'eval "$(llmstack activate {shell})"'
|
|
66
|
+
print(f"[OK] hook written: {path}", file=sys.stderr)
|
|
67
|
+
print( " activate in this shell now (and for every new shell:", file=sys.stderr)
|
|
68
|
+
print(f" paste into your rc): {eval_line}", file=sys.stderr)
|
|
69
|
+
|
|
70
|
+
print(src)
|
|
71
|
+
return 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""``llmstack check`` -- snapshot configured GGUFs + flag drift.
|
|
2
|
+
|
|
3
|
+
Thin wrapper around :mod:`llmstack.check_models` so the action stays in
|
|
4
|
+
the standard commands/ tree rather than special-cased in the dispatcher.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from llmstack import check_models
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(args: list[str]) -> int:
|
|
13
|
+
return check_models.main(args)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""``llmstack download`` -- queue every GGUF in models.ini in the background."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from llmstack.download.ggufs import download_all
|
|
6
|
+
from llmstack.paths import is_remote, remote_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _print_help() -> None:
|
|
10
|
+
print("usage: llmstack download")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: list[str]) -> int:
|
|
14
|
+
for arg in args:
|
|
15
|
+
if arg in ("-h", "--help"):
|
|
16
|
+
_print_help()
|
|
17
|
+
return 0
|
|
18
|
+
print(f"[!] unknown arg to download: {arg}")
|
|
19
|
+
return 2
|
|
20
|
+
|
|
21
|
+
if is_remote():
|
|
22
|
+
print(f"[!] this project is wired as a thin client of {remote_url()} (channel: external);")
|
|
23
|
+
print(" GGUFs live on the remote. `llmstack download` is a local-only command.")
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
download_all()
|
|
27
|
+
return 0
|