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/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
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)
|