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/__init__.py +3 -0
- sagex/__main__.py +2907 -0
- sagex/_env.py +7 -0
- sagex/api/__init__.py +3 -0
- sagex/api/client.py +195 -0
- sagex/api/resources.py +763 -0
- sagex/api/store.py +52 -0
- sagex/app.py +378 -0
- sagex/app.tcss +63 -0
- sagex/config.py +88 -0
- sagex/copier.py +277 -0
- sagex/formatting.py +126 -0
- sagex/pusher.py +350 -0
- sagex/render.py +1046 -0
- sagex/runner.py +198 -0
- sagex/runview.py +247 -0
- sagex/shell.py +231 -0
- sagex/widgets/__init__.py +1 -0
- sagex/widgets/activity.py +56 -0
- sagex/widgets/message.py +78 -0
- sagex/widgets/prompt_input.py +54 -0
- sagex/widgets/run_graph.py +58 -0
- sagex_cli-1.0.0.dist-info/METADATA +210 -0
- sagex_cli-1.0.0.dist-info/RECORD +28 -0
- sagex_cli-1.0.0.dist-info/WHEEL +5 -0
- sagex_cli-1.0.0.dist-info/entry_points.txt +2 -0
- sagex_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- sagex_cli-1.0.0.dist-info/top_level.txt +1 -0
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()
|