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/paths.py ADDED
@@ -0,0 +1,318 @@
1
+ """Centralised path & state-dir resolution.
2
+
3
+ Three classes of path are kept apart on purpose:
4
+
5
+ *Source* paths
6
+ Things that ship with the package (``AGENTS.md`` template). These are
7
+ read-only after ``pip install`` and live next to the package modules.
8
+
9
+ *User-data* paths (writable, persistent, shared across projects)
10
+ The ``llama-swap`` binary, mostly. Lives under
11
+ ``$XDG_DATA_HOME/llmstack/`` (defaults to ``~/.local/share/llmstack/``)
12
+ so a single download is reused regardless of which project the CLI
13
+ was invoked from. Override with ``$LLMSTACK_BIN_DIR`` or
14
+ ``$LLMSTACK_DATA_DIR``.
15
+
16
+ *Work-dir* paths (writable, per-project)
17
+ Generated configs, pid files, channel markers, prompt rcfiles and
18
+ daemon logs. Lives at ``<work-dir>/.llmstack/`` so each project gets
19
+ its own ``opencode.json`` + ``llama-swap.yaml``. ``work-dir`` is
20
+ ``$LLMSTACK_WORK_DIR`` if set, else ``$PWD``. The activate hook +
21
+ ``spawn_subshell`` are responsible for exporting ``LLMSTACK_WORK_DIR``
22
+ when the user is "inside" a project, so ``llmstack <action>`` works
23
+ from any subdirectory; outside the hook, the user is expected to be
24
+ in the project root (or set the env var explicitly).
25
+
26
+ The state-dir resolution uses a frozen :class:`Paths` instance constructed
27
+ once per CLI invocation so we don't accidentally pick up a different
28
+ ``$PWD`` mid-command.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from dataclasses import dataclass
35
+ from functools import lru_cache
36
+ from pathlib import Path
37
+
38
+ from llmstack._platform import EXE_SUFFIX, user_data_root
39
+
40
+ PACKAGE_DIR = Path(__file__).resolve().parent
41
+
42
+ AGENTS_TEMPLATE = PACKAGE_DIR / "AGENTS.md"
43
+ MODELS_INI_TEMPLATE = PACKAGE_DIR / "models.ini"
44
+
45
+ REPO_LLAMA_SWAP = "mostlygeek/llama-swap"
46
+ ROUTER_HOST = "127.0.0.1"
47
+ ROUTER_PORT = 10101
48
+ SWAP_PORT = 10102
49
+
50
+ # Default thin-client target: the local router on this host. Picked up by
51
+ # ``llmstack install --external`` when no URL is given. Match this to
52
+ # ``ROUTER_HOST`` / ``ROUTER_PORT`` so a host-local thin client and the
53
+ # (potential) local owner of the daemons agree on the endpoint.
54
+ DEFAULT_REMOTE_URL = f"http://{ROUTER_HOST}:{ROUTER_PORT}"
55
+
56
+
57
+ def env_remote_url() -> str | None:
58
+ """``LLMSTACK_REMOTE_URL`` from the environment, if set.
59
+
60
+ This is an *input* to ``llmstack install`` (and a one-shot fallback for
61
+ pre-install commands like ``setup``); it is **not** the source of
62
+ truth post-install. The activate hook re-exports the URL from the
63
+ persisted channel marker into this env var, so reading it from a
64
+ hook-active shell happens to agree with the marker -- but the marker
65
+ is canonical.
66
+ """
67
+ raw = (os.environ.get("LLMSTACK_REMOTE_URL") or "").strip().rstrip("/")
68
+ return raw or None
69
+
70
+
71
+ def remote_url() -> str | None:
72
+ """Authoritative remote-router URL (with no trailing slash, no ``/v1``).
73
+
74
+ Resolution order:
75
+
76
+ 1. ``default-channel`` marker, when channel == ``external`` (set by
77
+ ``llmstack install --external``). This is the post-install
78
+ source of truth.
79
+ 2. ``$LLMSTACK_REMOTE_URL`` env var. Used only as a fallback for
80
+ commands invoked *before* an external install has happened
81
+ (``setup``, ``install`` itself).
82
+
83
+ Returns ``None`` when neither says we're in external mode -- i.e. we
84
+ own (or will own) the local daemons.
85
+ """
86
+ try:
87
+ paths = resolve()
88
+ mark = read_marker(paths.default_marker)
89
+ except OSError:
90
+ mark = None
91
+ if mark and mark.channel == "external" and mark.url:
92
+ return mark.url.rstrip("/") or None
93
+ return env_remote_url()
94
+
95
+
96
+ def is_remote() -> bool:
97
+ """``True`` iff this project is wired as a thin client of some router.
98
+
99
+ Thin-wraps :func:`remote_url`; see there for the resolution order.
100
+ """
101
+ return remote_url() is not None
102
+
103
+
104
+ def _xdg_data_home() -> Path:
105
+ """Persistent per-user data root.
106
+
107
+ POSIX: ``$XDG_DATA_HOME`` with the spec-defined fallback
108
+ (``~/.local/share``). Windows: ``$LOCALAPPDATA`` with the standard
109
+ Windows fallback. The platform shim keeps the two branches in one
110
+ place.
111
+ """
112
+ return user_data_root()
113
+
114
+
115
+ def models_ini_path() -> Path:
116
+ """Locate ``models.ini``.
117
+
118
+ Canonical location is ``<work-dir>/.llmstack/models.ini`` (per-project,
119
+ sits next to the rest of the generated state but is itself the *input*
120
+ to ``install``). ``$LLMSTACK_MODELS_INI`` overrides this with an
121
+ explicit absolute or relative path. Returns the resolved path even
122
+ when the file doesn't exist; callers decide whether that's an error
123
+ or a seed opportunity.
124
+ """
125
+ explicit = os.environ.get("LLMSTACK_MODELS_INI")
126
+ if explicit:
127
+ return Path(explicit).expanduser().resolve()
128
+ return (work_dir() / ".llmstack" / "models.ini").resolve()
129
+
130
+
131
+ def work_dir() -> Path:
132
+ """Where per-project state (``.llmstack/``) lives.
133
+
134
+ ``$LLMSTACK_WORK_DIR`` if set, else ``$PWD``. The activate hook
135
+ walks up from the user's prompt to find the nearest installed
136
+ project (``.llmstack/opencode.json``) and exports
137
+ ``LLMSTACK_WORK_DIR`` so the CLI works from any subdirectory; the
138
+ spawned subshell does the same. We don't redo that walk here -- the
139
+ hook is the source of truth, and a user who hasn't installed it is
140
+ expected to be in the project root.
141
+ """
142
+ raw = os.environ.get("LLMSTACK_WORK_DIR")
143
+ return Path(raw).expanduser().resolve() if raw else Path.cwd().resolve()
144
+
145
+
146
+ def data_dir() -> Path:
147
+ """Persistent user-data root for the package (binary, etc.)."""
148
+ raw = os.environ.get("LLMSTACK_DATA_DIR")
149
+ return Path(raw).expanduser().resolve() if raw else (_xdg_data_home() / "llmstack").resolve()
150
+
151
+
152
+ def bin_dir() -> Path:
153
+ """Where ``llama-swap`` is installed. Falls back under ``data_dir()``."""
154
+ raw = os.environ.get("LLMSTACK_BIN_DIR")
155
+ return Path(raw).expanduser().resolve() if raw else (data_dir() / "bin").resolve()
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class Paths:
160
+ """Snapshot of the resolved paths for a single CLI invocation."""
161
+
162
+ work_dir: Path
163
+ data_dir: Path
164
+ bin_dir: Path
165
+ state_dir: Path # <work>/.llmstack
166
+ log_dir: Path # <state>/logs
167
+ llama_swap_bin: Path # <bin>/llama-swap
168
+ llama_swap_yaml: Path # <state>/llama-swap.yaml (was llmstack/llama-swap.yaml)
169
+ opencode_json: Path # <state>/opencode.json
170
+ agents_local: Path # <state>/AGENTS.md (copy of template)
171
+ active_marker: Path # <state>/active-channel
172
+ default_marker: Path # <state>/default-channel
173
+ router_pid: Path # <state>/router.pid
174
+ swap_pid: Path # <state>/llama-swap.pid
175
+
176
+ @property
177
+ def models_ini(self) -> Path:
178
+ return models_ini_path()
179
+
180
+
181
+ @lru_cache(maxsize=1)
182
+ def resolve() -> Paths:
183
+ """Build (and cache) the path snapshot for this process."""
184
+ work = work_dir()
185
+ state = work / ".llmstack"
186
+ data = data_dir()
187
+ bind = bin_dir()
188
+ opencode_dir = Path(os.environ["OPENCODE_CONFIG_DIR"]).expanduser().resolve() \
189
+ if "OPENCODE_CONFIG_DIR" in os.environ else state
190
+ return Paths(
191
+ work_dir=work,
192
+ data_dir=data,
193
+ bin_dir=bind,
194
+ state_dir=state,
195
+ log_dir=state / "logs",
196
+ llama_swap_bin=bind / f"llama-swap{EXE_SUFFIX}",
197
+ llama_swap_yaml=state / "llama-swap.yaml",
198
+ opencode_json=opencode_dir / "opencode.json",
199
+ agents_local=state / "AGENTS.md",
200
+ active_marker=state / "active-channel",
201
+ default_marker=state / "default-channel",
202
+ router_pid=state / "router.pid",
203
+ swap_pid=state / "llama-swap.pid",
204
+ )
205
+
206
+
207
+ def ensure_state_dirs() -> Paths:
208
+ """Create the per-project state dirs lazily, return the Paths snapshot.
209
+
210
+ Read-only commands (``help``, ``activate``) deliberately don't call
211
+ this -- otherwise running them from any directory would litter the
212
+ filesystem with empty ``.llmstack/`` folders.
213
+ """
214
+ p = resolve()
215
+ p.state_dir.mkdir(parents=True, exist_ok=True)
216
+ p.log_dir.mkdir(parents=True, exist_ok=True)
217
+ return p
218
+
219
+
220
+ def ensure_data_dirs() -> Paths:
221
+ """Create the user-data dirs (for the binary install)."""
222
+ p = resolve()
223
+ p.data_dir.mkdir(parents=True, exist_ok=True)
224
+ p.bin_dir.mkdir(parents=True, exist_ok=True)
225
+ return p
226
+
227
+
228
+ def require_models_ini() -> Path:
229
+ p = models_ini_path()
230
+ if not p.is_file():
231
+ raise SystemExit(
232
+ f"[!] models.ini not found at {p}\n"
233
+ " set $LLMSTACK_MODELS_INI or run `llmstack install` to seed one"
234
+ )
235
+ return p
236
+
237
+
238
+ def ensure_models_ini() -> tuple[Path, bool]:
239
+ """Resolve ``models.ini``, seeding the canonical location from the
240
+ bundled template when nothing exists yet. Returns ``(path, seeded)``
241
+ where ``seeded`` is ``True`` only when we just wrote the file.
242
+
243
+ Lookup follows :func:`models_ini_path`. When neither the canonical
244
+ nor the legacy location has a file, the seed is written to the
245
+ canonical path: ``<work-dir>/.llmstack/models.ini`` (or wherever
246
+ ``$LLMSTACK_MODELS_INI`` points).
247
+ """
248
+ p = models_ini_path()
249
+ if p.is_file():
250
+ return p, False
251
+ if not MODELS_INI_TEMPLATE.is_file():
252
+ raise SystemExit(
253
+ f"[!] models.ini not found at {p} and no bundled template at "
254
+ f"{MODELS_INI_TEMPLATE}\n"
255
+ " reinstall the llmstack package or set $LLMSTACK_MODELS_INI"
256
+ )
257
+ p.parent.mkdir(parents=True, exist_ok=True)
258
+ import shutil as _shutil
259
+ _shutil.copyfile(MODELS_INI_TEMPLATE, p)
260
+ return p, True
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # channel-marker on-disk format
265
+ #
266
+ # Both ``.llmstack/active-channel`` (live) and ``.llmstack/default-channel``
267
+ # (intent, written by install) use the same one-line format:
268
+ #
269
+ # <channel>[ <url>]\n
270
+ #
271
+ # For local channels (``current`` / ``next``) the line is just the
272
+ # channel name. For ``external`` we append the remote llmstack URL so
273
+ # the activate hook can re-export ``LLMSTACK_REMOTE_URL`` without the
274
+ # user having to set it in their shell rc -- entering a project is
275
+ # enough to wire the env back up.
276
+ #
277
+ # The format is deliberately whitespace-separated (not JSON / TSV) so a
278
+ # shell can parse it with ``read -r channel url < marker`` -- no jq, no
279
+ # python, just a builtin.
280
+ # ---------------------------------------------------------------------------
281
+
282
+ @dataclass(frozen=True)
283
+ class ChannelMark:
284
+ """One channel-marker file's worth of state."""
285
+
286
+ channel: str
287
+ url: str | None = None
288
+
289
+ def serialize(self) -> str:
290
+ if self.url:
291
+ return f"{self.channel} {self.url}\n"
292
+ return f"{self.channel}\n"
293
+
294
+ @classmethod
295
+ def parse(cls, text: str) -> ChannelMark | None:
296
+ line = text.strip()
297
+ if not line:
298
+ return None
299
+ parts = line.split(maxsplit=1)
300
+ return cls(parts[0], parts[1] if len(parts) > 1 else None)
301
+
302
+
303
+ def read_marker(path: Path) -> ChannelMark | None:
304
+ """Return the parsed marker, or ``None`` when the file is missing/empty."""
305
+ if not path.is_file():
306
+ return None
307
+ try:
308
+ return ChannelMark.parse(path.read_text())
309
+ except OSError:
310
+ return None
311
+
312
+
313
+ def write_marker(path: Path, mark: ChannelMark) -> None:
314
+ """Atomically write ``mark`` to ``path`` (creates parents as needed)."""
315
+ path.parent.mkdir(parents=True, exist_ok=True)
316
+ tmp = path.with_suffix(path.suffix + ".tmp")
317
+ tmp.write_text(mark.serialize())
318
+ os.replace(tmp, path)