sagex-cli 1.0.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.
sagex/api/store.py ADDED
@@ -0,0 +1,52 @@
1
+ """Secure storage for the API key using the OS keychain (via `keyring`).
2
+
3
+ Windows Credential Manager / macOS Keychain / Linux Secret Service — the key
4
+ lives in the OS's secret store, never in a plaintext file.
5
+
6
+ SAGEX_API_KEY, when set, takes precedence over the keychain. That covers CI jobs
7
+ and headless machines with no keychain, without ever writing the key to disk.
8
+ """
9
+
10
+ import os
11
+
12
+ import keyring
13
+
14
+ _SERVICE = "sagex-cli"
15
+ _ACCOUNT = "api_key"
16
+ ENV_VAR = "SAGEX_API_KEY"
17
+
18
+
19
+ class KeyStoreError(Exception):
20
+ """The OS keychain couldn't store the key (e.g. no backend on a headless box)."""
21
+
22
+
23
+ def key_from_env() -> str | None:
24
+ """The key from SAGEX_API_KEY, or None when it's unset/blank."""
25
+ return os.environ.get(ENV_VAR, "").strip() or None
26
+
27
+
28
+ def get_key() -> str | None:
29
+ """Return the API key (env first, then keychain), or None if there isn't one."""
30
+ env_key = key_from_env()
31
+ if env_key:
32
+ return env_key
33
+ try:
34
+ return keyring.get_password(_SERVICE, _ACCOUNT)
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ def set_key(key: str) -> None:
40
+ """Store the API key in the OS keychain; KeyStoreError if there's no usable one."""
41
+ try:
42
+ keyring.set_password(_SERVICE, _ACCOUNT, key)
43
+ except Exception as exc: # NoKeyringError, locked/absent Secret Service, …
44
+ raise KeyStoreError(str(exc)) from exc
45
+
46
+
47
+ def delete_key() -> None:
48
+ """Remove the stored API key. No error if nothing was stored."""
49
+ try:
50
+ keyring.delete_password(_SERVICE, _ACCOUNT)
51
+ except Exception:
52
+ pass
sagex/app.py ADDED
@@ -0,0 +1,378 @@
1
+ """The sagex terminal application: layout and event wiring.
2
+
3
+ Appearance lives in app.tcss; status formatting in formatting.py. This file
4
+ focuses on WHAT widgets exist and HOW they react.
5
+ """
6
+
7
+ import os
8
+
9
+ from rich.text import Text
10
+ from textual import events, work
11
+ from textual.app import App, ComposeResult
12
+ from textual.containers import Horizontal, Vertical, VerticalScroll
13
+ from textual.widgets import Header, Footer, Tree, Input, Static, DirectoryTree
14
+
15
+ from sagex import config, runview, shell
16
+ from sagex.api import ApiError, build_client, resources, store
17
+ from sagex.formatting import ACCENT, run_label, trigger_label
18
+ from sagex.widgets.activity import ActivityLine
19
+ from sagex.widgets.message import ChatMessage
20
+ from sagex.widgets.run_graph import RunGraph
21
+ from sagex.widgets.prompt_input import PromptInput
22
+
23
+
24
+ class SagexApp(App):
25
+ """The whole application. Everything hangs off this one class."""
26
+
27
+ CSS_PATH = "app.tcss" # styling loaded from the file next to this one
28
+
29
+ BINDINGS = [
30
+ # Not a bare "q": you type straight into the transcript, so a letter key must
31
+ # never quit (e.g. keystrokes while a command runs and the prompt is hidden).
32
+ ("ctrl+q", "quit", "Quit"),
33
+ ("f2", "cycle_shell", "Shell"),
34
+ ("f3", "toggle_env", "Environment"),
35
+ ("f5", "refresh", "Refresh"),
36
+ ("escape", "stop_command", "Stop"),
37
+ ]
38
+
39
+ def __init__(self) -> None:
40
+ super().__init__()
41
+ self.config = config.load() # persisted settings (~/.sagex)
42
+ self.session = shell.ShellSession(preferred=self.config.get("shell"))
43
+ self._busy = False # is a command currently running?
44
+ self._selected_label: str | None = None # the highlighted resource, if any
45
+ self._run_graph: RunGraph | None = None # live graph of the running command
46
+ self._load_gen = 0 # bumps on every (re)load
47
+ self._pending_loads: set = set() # branches still loading (this gen)
48
+
49
+ # Workspace = the local folder shown in the "local" environment.
50
+ ws = self.config.get("workspace")
51
+ self._workspace = ws if ws and os.path.isdir(ws) else os.getcwd()
52
+ # Start in the server environment if a key is stored, else local-only.
53
+ self._env = "server" if store.get_key() else "local"
54
+
55
+ def compose(self) -> ComposeResult:
56
+ """Declare WHAT is on screen, top to bottom."""
57
+ yield Header(show_clock=True)
58
+
59
+ # Horizontal split: resources on the left, Autobot panel on the right.
60
+ with Horizontal():
61
+ # Left: the tree, plus a one-line status bar docked at its bottom.
62
+ with Vertical(id="resources-panel"):
63
+ yield Input( # local: set the folder
64
+ value=self._workspace,
65
+ placeholder="workspace path (relative or absolute)",
66
+ id="workspace-input",
67
+ )
68
+ yield Tree("Resources", id="resources") # server environment
69
+ yield DirectoryTree(self._workspace, id="workspace-tree") # local environment
70
+ yield ActivityLine(id="resources-activity") # while branches load
71
+ yield Static(id="resources-status")
72
+
73
+ # Right: a terminal-style transcript. You type on the prompt line at its
74
+ # end; new entries are inserted above it, so the prompt always stays last.
75
+ with VerticalScroll(id="chat-log"):
76
+ yield Static(self._welcome(), id="welcome")
77
+ yield ActivityLine(id="command-activity") # while a command runs
78
+ with Horizontal(id="prompt-row"):
79
+ yield Static("❯ ", id="prompt-glyph")
80
+ yield PromptInput(
81
+ placeholder="Start with ! to run a command — e.g. !sagex list workflow",
82
+ id="chat-input",
83
+ compact=True,
84
+ )
85
+ yield Static(id="prompt-status")
86
+
87
+ yield Footer()
88
+
89
+ @staticmethod
90
+ def _welcome() -> Text:
91
+ text = Text()
92
+ text.append("✻ ", style=ACCENT)
93
+ text.append("Welcome to sagex", style="bold")
94
+ text.append(" · Autosage in your terminal\n\n", style="bright_black")
95
+ text.append("Start with ", style="bright_black")
96
+ text.append("!", style="bold")
97
+ text.append(" to run a command, e.g. ", style="bright_black")
98
+ text.append("!sagex list workflow", style="bold")
99
+ text.append(" · Autobot chat arrives in sagex v2\n", style="bright_black")
100
+ text.append("Esc stops a run · ↑/↓ history · F2 shell · F3 server/local · F5 refresh · Ctrl+Q quit",
101
+ style="bright_black")
102
+ return text
103
+
104
+ def on_mount(self) -> None:
105
+ """Runs once, right after the widgets exist. Good place for setup."""
106
+ tree = self.query_one("#resources", Tree)
107
+ tree.show_root = False # show categories at the top level
108
+
109
+ # Build the categories once; a loader (re)fills each. Runs get status icons.
110
+ workflows = tree.root.add("Workflows")
111
+ scripts = tree.root.add("Scripts")
112
+ vault = tree.root.add("Vault")
113
+ runs = tree.root.add("Runs")
114
+ triggers = tree.root.add("Triggers")
115
+ tree.root.expand_all()
116
+
117
+ # (branch node, fetch function) pairs — reused by refresh.
118
+ self._loaders = [
119
+ (workflows, resources.list_workflows),
120
+ (scripts, resources.list_scripts),
121
+ (vault, resources.list_vault_resources),
122
+ (runs, lambda c: [run_label(*r) for r in resources.list_recent_runs(c)]),
123
+ (triggers, lambda c: [trigger_label(*t) for t in resources.list_triggers(c)]),
124
+ ]
125
+
126
+ self._refresh_prompt() # "shell · cwd" under the prompt line
127
+ self._apply_env() # show the right tree + status for the current env
128
+ if self._env == "server":
129
+ self._start_loaders() # fetch server resources (concurrently, off-thread)
130
+ self.query_one("#chat-input", PromptInput).focus() # ready to type straight away
131
+
132
+ def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
133
+ """Fires whenever the highlight lands on a tree node (arrows or mouse)."""
134
+ node = event.node
135
+ # A branch (category) CAN be expanded; a leaf (real item) CANNOT. A real item
136
+ # becomes the "context" shown under the prompt.
137
+ self._selected_label = None if node.allow_expand else str(node.label)
138
+ self._refresh_prompt()
139
+
140
+ def on_click(self, event: events.Click) -> None:
141
+ """A click anywhere in the transcript puts the cursor back on the prompt."""
142
+ log = self.query_one("#chat-log", VerticalScroll)
143
+ if not self._busy and event.widget is not None and log in event.widget.ancestors_with_self:
144
+ self.query_one("#chat-input", PromptInput).focus()
145
+
146
+ def on_input_submitted(self, event: Input.Submitted) -> None:
147
+ """Fires when the user presses Enter in an input box."""
148
+ if event.input.id == "workspace-input": # the local-env path box
149
+ self._set_workspace(event.value)
150
+ return
151
+
152
+ text = event.value.strip()
153
+ event.input.value = "" # clear the line either way
154
+ if not text: # ignore empty / whitespace-only lines
155
+ return
156
+ self.query_one("#chat-input", PromptInput).add_to_history(text) # for ↑/↓ recall
157
+
158
+ if text.startswith("!"): # "!" prefix -> run as a shell command
159
+ command = text[1:].strip()
160
+ if not command:
161
+ return
162
+ if command.lower() in ("clear", "cls"): # UI builtin: wipe the transcript
163
+ log = self.query_one("#chat-log", VerticalScroll)
164
+ for entry in [*log.query(ChatMessage), *log.query(RunGraph)]:
165
+ entry.remove()
166
+ return
167
+ self.add_message(command, "command") # echo it, like a terminal does
168
+ out = self._new_output_message() # its output streams in below
169
+ self._set_busy(True)
170
+ self.execute_command(command, out)
171
+ else: # otherwise -> agent prompt (Autobot lands in v2)
172
+ self.add_message(text, role="user")
173
+ self.add_message(
174
+ "Autobot isn't available in the terminal yet — it's coming in sagex v2.\n"
175
+ "Meanwhile, start a line with ! to run a command (try !sagex --help), "
176
+ f"or chat with Autobot on the web: {config.WEB_URL}/ai/autobot",
177
+ role="notice",
178
+ )
179
+
180
+ @work(thread=True)
181
+ def execute_command(self, command: str, out: ChatMessage) -> None:
182
+ """Run a shell command in a BACKGROUND THREAD, streaming its output into `out`.
183
+
184
+ This runs off the main thread, so every UI touch goes through
185
+ `call_from_thread`, which hands the work back to Textual's main loop.
186
+ """
187
+ def on_line(line: str) -> None:
188
+ event = runview.parse_event_line(line) # progress from `sagex run workflow`
189
+ if event is not None:
190
+ self.call_from_thread(self._on_run_event, out, event)
191
+ return
192
+ self.call_from_thread(self._append_output, out, line)
193
+
194
+ try:
195
+ code = self.session.run_streaming(command, on_line)
196
+ except Exception as exc: # e.g. the shell itself couldn't start
197
+ self.call_from_thread(self._append_output, out, f"Failed to run command: {exc}")
198
+ code = -1
199
+
200
+ self.call_from_thread(self._finish_command, out, code)
201
+
202
+ def _new_output_message(self) -> ChatMessage:
203
+ """(main thread) Insert an empty cmd_output entry and return it."""
204
+ widget = ChatMessage("", "cmd_output")
205
+ self._mount_entry(widget)
206
+ return widget
207
+
208
+ def _mount_entry(self, widget) -> None:
209
+ """(main thread) Insert a transcript entry above the prompt, then scroll to it."""
210
+ log = self.query_one("#chat-log", VerticalScroll)
211
+ log.mount(widget, before=self.query_one("#command-activity", ActivityLine))
212
+ self.call_after_refresh(log.scroll_end, animate=False)
213
+
214
+ def _set_busy(self, busy: bool) -> None:
215
+ """While a command runs, the prompt line gives way to the activity line — as
216
+ in a terminal — and comes back (with the cursor on it) when it finishes."""
217
+ self._busy = busy
218
+ activity = self.query_one("#command-activity", ActivityLine)
219
+ self.query_one("#prompt-row").display = not busy
220
+ self.query_one("#prompt-status").display = not busy
221
+ log = self.query_one("#chat-log", VerticalScroll)
222
+ if busy:
223
+ activity.start("Running", "esc to stop")
224
+ log.focus() # keys scroll the output instead of typing nowhere
225
+ else:
226
+ activity.stop()
227
+ self.query_one("#chat-input", PromptInput).focus()
228
+ self.call_after_refresh(log.scroll_end, animate=False)
229
+
230
+ def _on_run_event(self, out: ChatMessage, event: dict) -> None:
231
+ """(main thread) Create, then update, the live graph of a workflow run."""
232
+ if event.get("t") == "start":
233
+ view = runview.WorkflowRunView.from_snapshot(event.get("view") or {})
234
+ self._run_graph = RunGraph(view)
235
+ self.query_one("#chat-log", VerticalScroll).mount(self._run_graph, before=out)
236
+ elif self._run_graph is not None:
237
+ self._run_graph.apply(event)
238
+
239
+ def _append_output(self, widget: ChatMessage, line: str) -> None:
240
+ """(main thread) Append a line to an output entry and keep it in view."""
241
+ widget.append_line(line)
242
+ self.query_one("#chat-log", VerticalScroll).scroll_end(animate=False)
243
+
244
+ def add_message(self, text: str, role: str) -> None:
245
+ """Add an entry to the transcript (above the prompt) and scroll it into view."""
246
+ self._mount_entry(ChatMessage(text, role))
247
+
248
+ def action_stop_command(self) -> None:
249
+ """Esc: stop the currently running command, if any."""
250
+ if self._busy:
251
+ self.query_one("#command-activity", ActivityLine).set("Stopping", "")
252
+ self.session.cancel() # kills the process; the worker then finishes
253
+
254
+ def _finish_command(self, out: ChatMessage, code: int) -> None:
255
+ """(main thread) A command finished: show how it ended and bring the prompt back."""
256
+ out.set_exit_code(code)
257
+ if out.is_empty: # a silent success leaves no empty block behind
258
+ out.remove()
259
+ if self._run_graph is not None: # e.g. detached or crashed before a final event
260
+ self._run_graph.freeze()
261
+ self._run_graph = None
262
+ self._set_busy(False)
263
+ self._refresh_prompt() # the working dir may have changed
264
+
265
+ def action_cycle_shell(self) -> None:
266
+ """F2: switch to the next installed shell (and remember the choice)."""
267
+ self.session.cycle_shell()
268
+ self.config["shell"] = self.session.shell # persist across launches
269
+ config.save(self.config)
270
+ self._refresh_prompt()
271
+
272
+ def _refresh_prompt(self) -> None:
273
+ """The dim line under the prompt: 'shell · cwd', plus the highlighted resource."""
274
+ status = Text(self.session.prompt, style="bright_black")
275
+ if self._selected_label:
276
+ status.append(" · context: ", style="bright_black")
277
+ status.append(self._selected_label)
278
+ self.query_one("#prompt-status", Static).update(status)
279
+
280
+ def _start_loaders(self) -> None:
281
+ """(Re)load every resource branch, resetting each to 'Loading…' first."""
282
+ self._set_status("") # clear any previous error
283
+ self._load_gen += 1
284
+ self._pending_loads = {id(node) for node, _ in self._loaders}
285
+ self.query_one("#resources-activity", ActivityLine).start("Loading resources")
286
+ for node, fetch in self._loaders:
287
+ node.remove_children()
288
+ node.add_leaf("Loading…")
289
+ self._load_branch(node, fetch, self._load_gen)
290
+
291
+ def action_refresh(self) -> None:
292
+ """F5: reload the current environment (also the retry for a failed load)."""
293
+ if self._env == "server":
294
+ self._start_loaders()
295
+ else:
296
+ self.query_one("#workspace-tree", DirectoryTree).reload()
297
+
298
+ def action_toggle_env(self) -> None:
299
+ """F3: switch between the server and local environments (only when logged in)."""
300
+ if not store.get_key():
301
+ return # no toggle when not authenticated
302
+ self._env = "local" if self._env == "server" else "server"
303
+ self._apply_env()
304
+ if self._env == "server":
305
+ self._start_loaders()
306
+
307
+ def check_action(self, action: str, parameters):
308
+ """Hide the environment toggle from the footer when not logged in."""
309
+ if action == "toggle_env":
310
+ return True if store.get_key() else None # None -> hidden + disabled
311
+ return True
312
+
313
+ def _apply_env(self) -> None:
314
+ """Show the tree for the current environment; update the panel title + status."""
315
+ server = self._env == "server"
316
+ self.query_one("#resources", Tree).display = server
317
+ self.query_one("#workspace-tree", DirectoryTree).display = not server
318
+ self.query_one("#workspace-input", Input).display = not server
319
+ self.query_one("#resources-panel", Vertical).border_title = (
320
+ "Resources" if server else "Workspace"
321
+ )
322
+ self._update_env_status()
323
+
324
+ def _set_workspace(self, path: str) -> None:
325
+ """Validate + persist a new workspace path, then reload the local tree."""
326
+ try:
327
+ resolved = config.resolve_workspace(path)
328
+ except ValueError as exc:
329
+ self._set_status(f"⚠ {exc}")
330
+ return
331
+ self._workspace = resolved
332
+ self.config["workspace"] = resolved
333
+ config.save(self.config)
334
+ self.query_one("#workspace-input", Input).value = resolved
335
+ self.query_one("#workspace-tree", DirectoryTree).path = resolved # reactive -> reloads
336
+ self._update_env_status() # refresh "local · <path>"
337
+
338
+ def _update_env_status(self) -> None:
339
+ """Set the bottom status line based on auth state and current environment."""
340
+ if not store.get_key():
341
+ self._set_status("○ Not authenticated · sagex auth login")
342
+ elif self._env == "local":
343
+ self._set_status(f"local · {self._workspace}")
344
+ else:
345
+ self._set_status("") # server ok; a load error will overwrite this
346
+
347
+ def _set_status(self, text: str) -> None:
348
+ """Set the one-line status bar under the resources tree."""
349
+ self.query_one("#resources-status", Static).update(text)
350
+
351
+ @work(thread=True)
352
+ def _load_branch(self, node, fetch, gen: int) -> None:
353
+ """Fetch display labels for a tree branch in a background thread.
354
+
355
+ `fetch` takes an ApiClient and returns a list of labels (str or Text).
356
+ """
357
+ try:
358
+ labels = fetch(build_client())
359
+ except ApiError as exc:
360
+ self.call_from_thread(self._fill_branch, node, None, exc.message, gen)
361
+ return
362
+ self.call_from_thread(self._fill_branch, node, labels, None, gen)
363
+
364
+ def _fill_branch(self, node, labels, error, gen: int) -> None:
365
+ """(main thread) Fill a branch with results; route any error to one status line."""
366
+ if gen == self._load_gen: # a stale load (before an F5) doesn't count
367
+ self._pending_loads.discard(id(node))
368
+ if not self._pending_loads:
369
+ self.query_one("#resources-activity", ActivityLine).stop()
370
+ node.remove_children() # clear the "Loading…" placeholder
371
+ if error:
372
+ self._set_status(f"⚠ {error} · F5 to retry") # one shared message, not per-branch
373
+ return
374
+ if not labels:
375
+ node.add_leaf("(none)")
376
+ else:
377
+ for label in labels:
378
+ node.add_leaf(label) # names already trimmed at construction (metadata kept)
sagex/app.tcss ADDED
@@ -0,0 +1,63 @@
1
+ /* sagex styling (Textual CSS). Loaded by SagexApp via CSS_PATH = "app.tcss". */
2
+
3
+ Horizontal {
4
+ height: 1fr; /* fill space between header and footer */
5
+ }
6
+
7
+ /* One uniform background: make the header, footer, AND the tree all
8
+ transparent so the single screen background shows through everywhere. */
9
+ Header, Footer, Tree {
10
+ background: transparent;
11
+ }
12
+
13
+ #resources-panel {
14
+ width: 30%; /* left panel takes 30% of the width */
15
+ border: round $primary;
16
+ }
17
+ #resources {
18
+ height: 1fr; /* tree fills the panel above the status bar */
19
+ }
20
+ #workspace-input {
21
+ height: auto; /* the local-env path box (shown only in local) */
22
+ }
23
+ #workspace-tree {
24
+ height: 1fr; /* local file tree fills the same space */
25
+ }
26
+ #resources-status {
27
+ height: auto; /* 0 rows when empty, one line when it has text */
28
+ color: $text-muted;
29
+ padding: 0 1;
30
+ }
31
+
32
+ /* Right: the terminal-style transcript — entries flow down to the prompt line. */
33
+ #chat-log {
34
+ width: 70%; /* the remaining 70% */
35
+ height: 1fr;
36
+ padding: 0 1;
37
+ }
38
+ #welcome {
39
+ width: auto; /* hug the banner text */
40
+ border: round #d77757;
41
+ padding: 0 1;
42
+ }
43
+ #command-activity {
44
+ margin-top: 1;
45
+ }
46
+ #prompt-row {
47
+ height: auto;
48
+ margin-top: 1; /* a blank line above the prompt, like a fresh turn */
49
+ }
50
+ #prompt-glyph {
51
+ width: 2;
52
+ color: #d77757; /* the same accent as the ❯ on past entries */
53
+ text-style: bold;
54
+ }
55
+ #chat-input, #chat-input:focus {
56
+ width: 1fr;
57
+ background: transparent; /* no box: you type straight onto the line */
58
+ background-tint: transparent;
59
+ }
60
+ #prompt-status {
61
+ height: auto;
62
+ color: $text-muted;
63
+ }
sagex/config.py ADDED
@@ -0,0 +1,88 @@
1
+ """Persistent user settings, stored at ~/.sagex/config.json.
2
+
3
+ A tiny load/save layer over a JSON file. Deliberately fail-soft: a missing or
4
+ corrupt file falls back to defaults, and a failed write never crashes the app —
5
+ losing a preference is not worth interrupting the user.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ CONFIG_DIR = Path.home() / ".sagex"
13
+ CONFIG_FILE = CONFIG_DIR / "config.json"
14
+
15
+ try:
16
+ from sagex._env import VALUES as _BAKED # written from .env at build time (setup.py)
17
+ except ImportError: # a checkout that was never built/installed
18
+ _BAKED = {}
19
+
20
+
21
+ def _baked(name: str) -> str:
22
+ value = _BAKED.get(name)
23
+ if not value:
24
+ raise RuntimeError(
25
+ f"{name} isn't configured. In the sagex checkout: cp .env.example .env, "
26
+ "fill it in, then run: pip install -e ."
27
+ )
28
+ return value.rstrip("/")
29
+
30
+
31
+ # The hosted Autosage API. Override per-shell with SAGEX_API_URL (see api_url()), or
32
+ # persistently with "api_url" in config.json — e.g. a local dev stack on :8080.
33
+ DEFAULT_API_URL = _baked("SAGEX_API_URL")
34
+
35
+ # The Autosage web app — where users request a key and (until sagex v2) chat with Autobot.
36
+ WEB_URL = _baked("SAGEX_WEB_URL")
37
+
38
+ # Every setting the app understands, with its default value.
39
+ DEFAULTS = {
40
+ "shell": None, # None = auto-detect the default shell
41
+ "api_url": DEFAULT_API_URL,
42
+ }
43
+
44
+
45
+ def load() -> dict:
46
+ """Return settings (file values merged over defaults). Never raises."""
47
+ settings = dict(DEFAULTS)
48
+ try:
49
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
50
+ settings.update(json.load(f))
51
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
52
+ pass # missing/broken file -> just use defaults
53
+ return settings
54
+
55
+
56
+ def save(settings: dict) -> None:
57
+ """Write settings to disk, creating ~/.sagex if needed. Best-effort."""
58
+ try:
59
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
60
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
61
+ json.dump(settings, f, indent=2)
62
+ except OSError:
63
+ pass # can't write? oh well — don't crash
64
+
65
+
66
+ def api_url() -> str:
67
+ """The backend base URL: SAGEX_API_URL, else config.json, else the hosted API.
68
+
69
+ The env override is read here rather than merged in load(), so a later save()
70
+ (e.g. the TUI remembering your shell) never writes it into config.json.
71
+ """
72
+ return os.environ.get("SAGEX_API_URL", "").strip() or load()["api_url"]
73
+
74
+
75
+ def resolve_workspace(path: str) -> str:
76
+ """Expand + resolve a workspace path to an absolute one, or raise ValueError.
77
+
78
+ Relative paths resolve against the current directory. Env vars and ~ expand.
79
+ """
80
+ resolved = os.path.abspath(os.path.expanduser(os.path.expandvars((path or "").strip())))
81
+ if not os.path.isdir(resolved):
82
+ raise ValueError(f"Not a folder: {resolved}")
83
+ return resolved
84
+
85
+
86
+ def workspace_dir() -> str:
87
+ """The effective workspace folder: the saved one, else the current directory."""
88
+ return load().get("workspace") or os.getcwd()