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 ADDED
@@ -0,0 +1,13 @@
1
+ # llmstack — agent notes
2
+
3
+ Agent modes: `plan` / `plan-uncensored` are read-only (describe changes;
4
+ the user runs `/build` to apply). `build` mode edits files directly.
5
+
6
+ Skip `.llmstack/` during search and reads — it's generated runtime state.
7
+ Only look there if the user explicitly points you at it.
8
+
9
+ To adjust models on the user's request, edit `.llmstack/models.ini`,
10
+ then run `llmstack install` (add `llmstack restart` if you changed
11
+ `sampler`, `ctx_size`, GGUF file, or Bedrock creds).
12
+
13
+ Be concise. Don't narrate edits.
llmstack/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """llmstack — multi-tier local LLM stack.
2
+
3
+ Public surface is the CLI (``llmstack <action>``). The package modules are
4
+ organised by concern:
5
+
6
+ llmstack.app FastAPI auto-router (uvicorn entry-point).
7
+ llmstack.tiers models.ini -> Tier dataclasses (data layer).
8
+ llmstack.paths state-dir / bin-dir / work-dir resolution.
9
+ llmstack.shell_env spawn the env-prepared subshell + activate hooks.
10
+ llmstack.generators render llama-swap.yaml + opencode.json from models.ini.
11
+ llmstack.download fetch GGUFs (via llama-completion) and the
12
+ llama-swap binary.
13
+ llmstack.commands one module per CLI action (setup / install / start ...).
14
+ llmstack.cli argparse dispatch (the `llmstack` console-script).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ __version__ = "0.1.0"
20
+ __all__ = ["__version__"]
llmstack/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m llmstack`` -> :func:`llmstack.cli.main`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from llmstack.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main(sys.argv[1:]))
llmstack/_platform.py ADDED
@@ -0,0 +1,420 @@
1
+ """Tiny cross-OS shim for the bits of the stack that touch the kernel.
2
+
3
+ Everything platform-specific (process lifecycle, detached daemon spawn,
4
+ default shell, executable suffix) lives behind this single module so the
5
+ rest of the package can stay portable. Three supported families:
6
+
7
+ * **macOS / Linux / FreeBSD** -- POSIX. ``os.kill`` for liveness +
8
+ SIGTERM/SIGKILL, ``pgrep -f`` for pattern lookup, ``start_new_session``
9
+ for detached spawn.
10
+ * **Windows** -- ``OpenProcess`` for liveness, ``taskkill /T /F`` for
11
+ pattern + tree kill, WMI/``tasklist`` for command-line probing,
12
+ ``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`` flags for daemon spawn.
13
+ ``signal.SIGKILL`` doesn't exist; SIGTERM == TerminateProcess.
14
+
15
+ The names exposed here mirror the POSIX ones so callers don't need to
16
+ care which branch runs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import shutil
24
+ import signal
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ from pathlib import Path
29
+
30
+ IS_WINDOWS = os.name == "nt"
31
+ IS_POSIX = not IS_WINDOWS
32
+
33
+ EXE_SUFFIX = ".exe" if IS_WINDOWS else ""
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # liveness + termination
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def pid_alive(pid: int) -> bool:
41
+ """True iff ``pid`` points at a live process the current user can see.
42
+
43
+ POSIX: ``os.kill(pid, 0)`` raises ``ProcessLookupError`` for dead
44
+ pids and ``PermissionError`` for someone else's process (still alive,
45
+ just not ours -- treat as alive).
46
+
47
+ Windows: ``os.kill(pid, 0)`` would dispatch a ``CTRL_C_EVENT``,
48
+ which is wrong; we ``OpenProcess`` instead and ignore the handle.
49
+ """
50
+ if pid <= 0:
51
+ return False
52
+ if IS_WINDOWS:
53
+ return _win_pid_alive(pid)
54
+ try:
55
+ os.kill(pid, 0)
56
+ except ProcessLookupError:
57
+ return False
58
+ except PermissionError:
59
+ # process exists, just not ours.
60
+ return True
61
+ except OSError:
62
+ return False
63
+ return True
64
+
65
+
66
+ def _win_pid_alive(pid: int) -> bool:
67
+ import ctypes
68
+ from ctypes import wintypes
69
+
70
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
71
+ STILL_ACTIVE = 259
72
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
73
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
74
+ if not handle:
75
+ return False
76
+ try:
77
+ code = wintypes.DWORD()
78
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
79
+ return False
80
+ return code.value == STILL_ACTIVE
81
+ finally:
82
+ kernel32.CloseHandle(handle)
83
+
84
+
85
+ def terminate_pid(pid: int, *, grace: float = 5.0) -> None:
86
+ """Best-effort terminate. SIGTERM (or taskkill), wait, SIGKILL on hold-out.
87
+
88
+ Silent on already-dead / not-ours pids -- callers don't need to care.
89
+ """
90
+ if pid <= 0 or not pid_alive(pid):
91
+ return
92
+ if IS_WINDOWS:
93
+ _win_terminate(pid, grace=grace)
94
+ return
95
+ try:
96
+ os.kill(pid, signal.SIGTERM)
97
+ except (ProcessLookupError, PermissionError, OSError):
98
+ return
99
+ waited = 0.0
100
+ while waited < grace:
101
+ if not pid_alive(pid):
102
+ return
103
+ time.sleep(0.5)
104
+ waited += 0.5
105
+ try:
106
+ os.kill(pid, signal.SIGKILL)
107
+ except (ProcessLookupError, PermissionError, OSError):
108
+ pass
109
+
110
+
111
+ def _win_terminate(pid: int, *, grace: float) -> None:
112
+ """``taskkill /T /F`` -- /T includes any children; /F is the hard kill.
113
+
114
+ We send a graceful taskkill first (no /F) and only escalate after
115
+ ``grace`` seconds, mirroring the POSIX SIGTERM-then-SIGKILL flow.
116
+ """
117
+ if not shutil.which("taskkill"):
118
+ return
119
+ subprocess.run(
120
+ ["taskkill", "/PID", str(pid), "/T"],
121
+ check=False,
122
+ stdout=subprocess.DEVNULL,
123
+ stderr=subprocess.DEVNULL,
124
+ )
125
+ waited = 0.0
126
+ while waited < grace:
127
+ if not pid_alive(pid):
128
+ return
129
+ time.sleep(0.5)
130
+ waited += 0.5
131
+ subprocess.run(
132
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
133
+ check=False,
134
+ stdout=subprocess.DEVNULL,
135
+ stderr=subprocess.DEVNULL,
136
+ )
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # pattern-based process lookup
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def find_pids(pattern: str) -> list[int]:
144
+ """PIDs whose full command line matches ``pattern`` (POSIX regex).
145
+
146
+ Returns ``[]`` when the underlying lookup tool isn't available; the
147
+ caller is expected to treat that as "no matches" rather than an
148
+ error -- this is best-effort housekeeping, not load-bearing.
149
+ """
150
+ if IS_WINDOWS:
151
+ return [pid for pid, _ in _win_proc_list_matching(pattern)]
152
+ if not shutil.which("pgrep"):
153
+ return []
154
+ try:
155
+ proc = subprocess.run(
156
+ ["pgrep", "-f", pattern],
157
+ check=False,
158
+ stdout=subprocess.PIPE,
159
+ stderr=subprocess.DEVNULL,
160
+ text=True,
161
+ )
162
+ except (OSError, subprocess.SubprocessError):
163
+ return []
164
+ if proc.returncode not in (0, 1):
165
+ return []
166
+ return [int(line) for line in proc.stdout.splitlines() if line.strip().isdigit()]
167
+
168
+
169
+ def find_processes(pattern: str) -> list[tuple[int, str]]:
170
+ """``[(pid, cmdline)]`` for every process whose command line matches.
171
+
172
+ POSIX: ``pgrep -af`` (full cmdline + pid). Windows: WMIC / PowerShell
173
+ Get-CimInstance, falling back to ``tasklist`` (which only knows the
174
+ image name, not the full cmdline).
175
+ """
176
+ if IS_WINDOWS:
177
+ return _win_proc_list_matching(pattern)
178
+ if not shutil.which("pgrep"):
179
+ return []
180
+ try:
181
+ proc = subprocess.run(
182
+ ["pgrep", "-af", pattern],
183
+ check=False,
184
+ stdout=subprocess.PIPE,
185
+ stderr=subprocess.DEVNULL,
186
+ text=True,
187
+ )
188
+ except (OSError, subprocess.SubprocessError):
189
+ return []
190
+ if proc.returncode not in (0, 1):
191
+ return []
192
+ out: list[tuple[int, str]] = []
193
+ for line in proc.stdout.splitlines():
194
+ line = line.rstrip()
195
+ if not line:
196
+ continue
197
+ head, _, tail = line.partition(" ")
198
+ if not head.isdigit():
199
+ continue
200
+ out.append((int(head), tail))
201
+ return out
202
+
203
+
204
+ def kill_matching(pattern: str, *, grace: float = 5.0) -> int:
205
+ """Terminate every process whose cmdline matches ``pattern``."""
206
+ n = 0
207
+ for pid in find_pids(pattern):
208
+ terminate_pid(pid, grace=grace)
209
+ n += 1
210
+ return n
211
+
212
+
213
+ def describe_matching(pattern: str) -> str:
214
+ """``pgrep -af``-style multi-line string (empty when nothing matches)."""
215
+ return "\n".join(f"{pid} {cmd}" for pid, cmd in find_processes(pattern))
216
+
217
+
218
+ # Windows process listing: prefer PowerShell's Get-CimInstance (richer
219
+ # output, present since PS 3.0), fall back to tasklist /v which only
220
+ # carries window title + image name.
221
+
222
+ def _win_proc_list_matching(pattern: str) -> list[tuple[int, str]]:
223
+ rx = re.compile(pattern)
224
+ rows = _win_proc_list_powershell() or _win_proc_list_tasklist()
225
+ return [(pid, cmd) for pid, cmd in rows if rx.search(cmd)]
226
+
227
+
228
+ def _win_proc_list_powershell() -> list[tuple[int, str]] | None:
229
+ pwsh = shutil.which("pwsh") or shutil.which("powershell")
230
+ if not pwsh:
231
+ return None
232
+ cmd = (
233
+ "Get-CimInstance Win32_Process | "
234
+ "Select-Object ProcessId, CommandLine | "
235
+ "ForEach-Object { '{0}|{1}' -f $_.ProcessId, $_.CommandLine }"
236
+ )
237
+ try:
238
+ proc = subprocess.run(
239
+ [pwsh, "-NoProfile", "-NonInteractive", "-Command", cmd],
240
+ check=False,
241
+ stdout=subprocess.PIPE,
242
+ stderr=subprocess.DEVNULL,
243
+ text=True,
244
+ timeout=10,
245
+ )
246
+ except (OSError, subprocess.SubprocessError):
247
+ return None
248
+ if proc.returncode != 0:
249
+ return None
250
+ rows: list[tuple[int, str]] = []
251
+ for line in proc.stdout.splitlines():
252
+ head, _, tail = line.partition("|")
253
+ head = head.strip()
254
+ if head.isdigit():
255
+ rows.append((int(head), tail.strip()))
256
+ return rows
257
+
258
+
259
+ def _win_proc_list_tasklist() -> list[tuple[int, str]]:
260
+ if not shutil.which("tasklist"):
261
+ return []
262
+ try:
263
+ proc = subprocess.run(
264
+ ["tasklist", "/FO", "CSV", "/NH"],
265
+ check=False,
266
+ stdout=subprocess.PIPE,
267
+ stderr=subprocess.DEVNULL,
268
+ text=True,
269
+ timeout=10,
270
+ )
271
+ except (OSError, subprocess.SubprocessError):
272
+ return []
273
+ if proc.returncode != 0:
274
+ return []
275
+ import csv
276
+ rows: list[tuple[int, str]] = []
277
+ for fields in csv.reader(proc.stdout.splitlines()):
278
+ if len(fields) < 2:
279
+ continue
280
+ image, pid_str = fields[0], fields[1]
281
+ if pid_str.isdigit():
282
+ rows.append((int(pid_str), image))
283
+ return rows
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # detached background spawn
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def detached_popen(
291
+ argv: list[str],
292
+ *,
293
+ stdout,
294
+ stderr,
295
+ env: dict[str, str] | None = None,
296
+ cwd: str | os.PathLike[str] | None = None,
297
+ ) -> subprocess.Popen:
298
+ """``subprocess.Popen`` with the right "detach from controlling tty" flags.
299
+
300
+ POSIX: ``start_new_session=True`` (calls ``setsid``).
301
+ Windows: ``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`` so closing the
302
+ parent console doesn't drag the daemon down with it.
303
+ """
304
+ kw: dict = {
305
+ "stdin": subprocess.DEVNULL,
306
+ "stdout": stdout,
307
+ "stderr": stderr,
308
+ "env": env if env is not None else os.environ.copy(),
309
+ "cwd": cwd,
310
+ }
311
+ if IS_WINDOWS:
312
+ DETACHED_PROCESS = 0x00000008
313
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
314
+ kw["creationflags"] = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
315
+ kw["close_fds"] = False
316
+ else:
317
+ kw["start_new_session"] = True
318
+ return subprocess.Popen(argv, **kw)
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # user shell discovery
323
+ # ---------------------------------------------------------------------------
324
+
325
+ def default_shell() -> tuple[str, str]:
326
+ """Return ``(absolute_path, basename_lower)`` for the shell to spawn.
327
+
328
+ Resolution: ``$LLMSTACK_SHELL`` → POSIX ``$SHELL`` / Windows
329
+ ``$ComSpec`` → hard-coded fallback.
330
+ """
331
+ raw = os.environ.get("LLMSTACK_SHELL")
332
+ if not raw:
333
+ if IS_WINDOWS:
334
+ raw = (
335
+ shutil.which("pwsh")
336
+ or shutil.which("powershell")
337
+ or os.environ.get("ComSpec")
338
+ or "cmd.exe"
339
+ )
340
+ else:
341
+ raw = os.environ.get("SHELL") or "/bin/bash"
342
+ base = os.path.basename(raw).lower()
343
+ if base.endswith(".exe"):
344
+ base = base[: -len(".exe")]
345
+ return raw, base
346
+
347
+
348
+ def shell_family(name: str) -> str:
349
+ """Coarse classification: ``"powershell"`` / ``"cmd"`` / ``"bash"`` /
350
+ ``"zsh"`` / ``"posix"`` (anything else POSIX-shaped)."""
351
+ n = name.lower()
352
+ if n in ("pwsh", "powershell", "powershell_ise"):
353
+ return "powershell"
354
+ if n in ("cmd",):
355
+ return "cmd"
356
+ if n in ("bash",):
357
+ return "bash"
358
+ if n in ("zsh",):
359
+ return "zsh"
360
+ return "posix"
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # data-directory roots
365
+ # ---------------------------------------------------------------------------
366
+
367
+ def user_data_root() -> Path:
368
+ """Where persistent per-user data (binaries, caches) should live.
369
+
370
+ POSIX: respects ``$XDG_DATA_HOME`` then falls back to
371
+ ``~/.local/share``.
372
+ Windows: prefers ``$LOCALAPPDATA``, falling back to
373
+ ``%USERPROFILE%/AppData/Local``.
374
+ """
375
+ if IS_WINDOWS:
376
+ raw = os.environ.get("LOCALAPPDATA")
377
+ if raw:
378
+ return Path(raw)
379
+ return Path.home() / "AppData" / "Local"
380
+ raw = os.environ.get("XDG_DATA_HOME") or ""
381
+ return Path(raw) if raw else Path.home() / ".local" / "share"
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # misc
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def make_executable(path: Path) -> None:
389
+ """Mark ``path`` runnable. No-op on Windows (file extension decides)."""
390
+ if IS_WINDOWS:
391
+ return
392
+ try:
393
+ path.chmod(0o755)
394
+ except OSError:
395
+ pass
396
+
397
+
398
+ __all__ = [
399
+ "EXE_SUFFIX",
400
+ "IS_POSIX",
401
+ "IS_WINDOWS",
402
+ "default_shell",
403
+ "describe_matching",
404
+ "detached_popen",
405
+ "find_pids",
406
+ "find_processes",
407
+ "kill_matching",
408
+ "make_executable",
409
+ "pid_alive",
410
+ "shell_family",
411
+ "terminate_pid",
412
+ "user_data_root",
413
+ ]
414
+
415
+
416
+ # best-effort import-time sanity: warn (not fail) if signal.SIGKILL is
417
+ # missing on a non-Windows host -- shouldn't happen, but the rest of
418
+ # the module assumes it.
419
+ if IS_POSIX and not hasattr(signal, "SIGKILL"): # pragma: no cover
420
+ print("[!] llmstack: signal.SIGKILL missing on this POSIX system", file=sys.stderr)