noah-code 0.1.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.
@@ -0,0 +1,435 @@
1
+ """Textual full-screen UI for Noah Code - thin client of AgentHost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from typing import TYPE_CHECKING
8
+
9
+ from textual import on, work
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.message import Message
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import (
16
+ Button,
17
+ Footer,
18
+ Input,
19
+ Label,
20
+ RichLog,
21
+ Static,
22
+ TextArea,
23
+ )
24
+
25
+ from noah_code.approvals import ApprovalChoice, ApprovalRequest
26
+ from noah_code.commands import all_command_names, help_text
27
+ from noah_code.events import HostEvent, HostEventKind
28
+
29
+ if TYPE_CHECKING:
30
+ from noah_code.host import AgentHost
31
+
32
+
33
+ class HostEventMessage(Message):
34
+ """Posted when the host wants the TUI to render an event."""
35
+
36
+ def __init__(self, event: HostEvent) -> None:
37
+ super().__init__()
38
+ self.event = event
39
+
40
+
41
+ class ApprovalModal(ModalScreen[ApprovalChoice]):
42
+ """Ask once / session / reject for a permission decision."""
43
+
44
+ BINDINGS = [
45
+ Binding("1", "once", "Once", show=True),
46
+ Binding("2", "session", "Session", show=True),
47
+ Binding("3", "reject", "Reject", show=True),
48
+ Binding("escape", "reject", "Reject", show=False),
49
+ ]
50
+
51
+ def __init__(self, request: ApprovalRequest) -> None:
52
+ super().__init__()
53
+ self.request = request
54
+
55
+ def compose(self) -> ComposeResult:
56
+ d = self.request.decision
57
+ with Vertical(id="approval-dialog"):
58
+ yield Label(f"Approval {self.request.id[:8]}", id="approval-title")
59
+ yield Static(
60
+ f"[bold]{d.category}[/bold] {d.target}\n{d.reason}\n"
61
+ f"remember: {d.remember_pattern}",
62
+ id="approval-body",
63
+ )
64
+ with Horizontal(id="approval-buttons"):
65
+ yield Button("Once [1]", id="once", variant="primary")
66
+ yield Button("Session [2]", id="session", variant="success")
67
+ yield Button("Reject [3]", id="reject", variant="error")
68
+
69
+ def action_once(self) -> None:
70
+ self.dismiss(ApprovalChoice.ONCE)
71
+
72
+ def action_session(self) -> None:
73
+ self.dismiss(ApprovalChoice.SESSION)
74
+
75
+ def action_reject(self) -> None:
76
+ self.dismiss(ApprovalChoice.REJECT)
77
+
78
+ @on(Button.Pressed, "#once")
79
+ def _once(self) -> None:
80
+ self.dismiss(ApprovalChoice.ONCE)
81
+
82
+ @on(Button.Pressed, "#session")
83
+ def _session(self) -> None:
84
+ self.dismiss(ApprovalChoice.SESSION)
85
+
86
+ @on(Button.Pressed, "#reject")
87
+ def _reject(self) -> None:
88
+ self.dismiss(ApprovalChoice.REJECT)
89
+
90
+
91
+ class CommandPalette(ModalScreen[str | None]):
92
+ """Lightweight slash-command picker."""
93
+
94
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)]
95
+
96
+ def __init__(self, commands: list[str] | None = None) -> None:
97
+ super().__init__()
98
+ self._all = commands or []
99
+
100
+ def compose(self) -> ComposeResult:
101
+ with Vertical(id="palette-dialog"):
102
+ yield Label("Commands", id="palette-title")
103
+ yield Input(placeholder="filter…", id="palette-filter")
104
+ yield RichLog(id="palette-list", markup=True, highlight=False)
105
+ yield Static("Enter to insert · Esc to close", id="palette-hint")
106
+
107
+ def on_mount(self) -> None:
108
+ self._choices = list(self._all) if self._all else all_command_names()
109
+ self._filtered = list(self._choices)
110
+ self._refresh()
111
+ self.query_one("#palette-filter", Input).focus()
112
+
113
+ def _refresh(self) -> None:
114
+ log = self.query_one("#palette-list", RichLog)
115
+ log.clear()
116
+ for item in self._filtered[:40]:
117
+ log.write(item)
118
+
119
+ @on(Input.Changed, "#palette-filter")
120
+ def _filter(self, event: Input.Changed) -> None:
121
+ q = event.value.strip().lower().lstrip("/")
122
+ self._filtered = [c for c in self._choices if q in c.lower()] if q else list(self._choices)
123
+ self._refresh()
124
+
125
+ @on(Input.Submitted, "#palette-filter")
126
+ def _submit(self, event: Input.Submitted) -> None:
127
+ if self._filtered:
128
+ self.dismiss(self._filtered[0] + " ")
129
+ else:
130
+ self.dismiss(None)
131
+
132
+ def action_cancel(self) -> None:
133
+ self.dismiss(None)
134
+
135
+
136
+ class SessionPicker(ModalScreen[str | None]):
137
+ """Pick a session id to switch to."""
138
+
139
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=True)]
140
+
141
+ def __init__(self, rows: list[tuple[str, str]]) -> None:
142
+ super().__init__()
143
+ self._rows = rows # (id, label)
144
+
145
+ def compose(self) -> ComposeResult:
146
+ with Vertical(id="palette-dialog"):
147
+ yield Label("Sessions", id="palette-title")
148
+ yield Input(placeholder="filter or enter id…", id="palette-filter")
149
+ yield RichLog(id="palette-list", markup=True, highlight=False)
150
+ yield Static("Enter selects first match · Esc cancel", id="palette-hint")
151
+
152
+ def on_mount(self) -> None:
153
+ self._filtered = list(self._rows)
154
+ self._refresh()
155
+ self.query_one("#palette-filter", Input).focus()
156
+
157
+ def _refresh(self) -> None:
158
+ log = self.query_one("#palette-list", RichLog)
159
+ log.clear()
160
+ for sid, label in self._filtered[:40]:
161
+ log.write(f"{sid} {label}")
162
+
163
+ @on(Input.Changed, "#palette-filter")
164
+ def _filter(self, event: Input.Changed) -> None:
165
+ q = event.value.strip().lower()
166
+ if not q:
167
+ self._filtered = list(self._rows)
168
+ else:
169
+ self._filtered = [
170
+ (sid, label) for sid, label in self._rows if q in sid.lower() or q in label.lower()
171
+ ]
172
+ self._refresh()
173
+
174
+ @on(Input.Submitted, "#palette-filter")
175
+ def _submit(self, event: Input.Submitted) -> None:
176
+ typed = event.value.strip()
177
+ if self._filtered:
178
+ self.dismiss(self._filtered[0][0])
179
+ elif typed:
180
+ self.dismiss(typed)
181
+ else:
182
+ self.dismiss(None)
183
+
184
+ def action_cancel(self) -> None:
185
+ self.dismiss(None)
186
+
187
+
188
+ class TextualUI:
189
+ """HostUI implementation backed by NoahCodeApp."""
190
+
191
+ def __init__(self) -> None:
192
+ self._app: NoahCodeApp | None = None
193
+ self._status = ""
194
+ self._busy = False
195
+
196
+ def bind_app(self, app: NoahCodeApp) -> None:
197
+ self._app = app
198
+
199
+ def set_status(self, text: str) -> None:
200
+ self._status = text
201
+ if self._app is not None:
202
+ self._app.update_status_bar()
203
+
204
+ def set_busy(self, busy: bool) -> None:
205
+ self._busy = busy
206
+ if self._app is not None:
207
+ self._app.update_status_bar()
208
+
209
+ @property
210
+ def busy(self) -> bool:
211
+ return self._busy
212
+
213
+ def render(self, event: HostEvent) -> None:
214
+ if self._app is None:
215
+ return
216
+ # Safe from worker threads / async tasks on the app loop.
217
+ self._app.post_message(HostEventMessage(event))
218
+
219
+ async def ask_approval(self, request: ApprovalRequest) -> ApprovalChoice:
220
+ if self._app is None:
221
+ return ApprovalChoice.REJECT
222
+ return await self._app.request_approval(request)
223
+
224
+ async def prompt(self, status: str) -> str | None:
225
+ """Unused in TUI mode (app owns input); kept for HostUI protocol."""
226
+ self.set_status(status)
227
+ return None
228
+
229
+
230
+ class NoahCodeApp(App[None]):
231
+ """Full-screen coding session UI."""
232
+
233
+ TITLE = "Noah Code"
234
+ # Embedded CSS only - avoid CSS_PATH variables clobbering Textual design tokens.
235
+ CSS = """
236
+ Screen { background: #14161a; color: #e6e8eb; }
237
+ #status-bar {
238
+ dock: top; height: 1; background: #1c1f26; color: #e6e8eb;
239
+ text-style: bold; padding: 0 1; border-bottom: solid #2a303a;
240
+ }
241
+ #conversation {
242
+ height: 1fr; background: #14161a; border: none; padding: 0 1;
243
+ }
244
+ #input-hint { height: 1; color: #8b939e; padding: 0 1; background: #1c1f26; }
245
+ #composer {
246
+ height: 6; min-height: 4; max-height: 12;
247
+ border: solid #2a303a; background: #1c1f26; padding: 0 1;
248
+ }
249
+ #composer:focus { border: solid #5b9fd4; }
250
+ Footer { background: #1c1f26; color: #8b939e; }
251
+ ApprovalModal { align: center middle; }
252
+ #approval-dialog {
253
+ width: 72; max-width: 90%; height: auto; background: #1c1f26;
254
+ border: solid #5b9fd4; padding: 1 2;
255
+ }
256
+ #approval-title { text-style: bold; color: #5b9fd4; margin-bottom: 1; }
257
+ #approval-body { margin-bottom: 1; }
258
+ #approval-buttons { height: auto; align: center middle; }
259
+ #approval-buttons Button { margin: 0 1; }
260
+ CommandPalette { align: center middle; }
261
+ #palette-dialog {
262
+ width: 60; max-width: 90%; height: 20; background: #1c1f26;
263
+ border: solid #2a303a; padding: 1 2;
264
+ }
265
+ #palette-title { text-style: bold; margin-bottom: 1; }
266
+ #palette-filter { margin-bottom: 1; }
267
+ #palette-list { height: 1fr; border: none; background: #14161a; }
268
+ #palette-hint { color: #8b939e; margin-top: 1; }
269
+ """
270
+
271
+ BINDINGS = [
272
+ Binding("ctrl+q", "quit_app", "Quit", show=True),
273
+ Binding("ctrl+c", "cancel_or_quit", "Cancel", show=True),
274
+ Binding("ctrl+enter", "submit", "Send", show=True),
275
+ Binding("ctrl+p", "palette", "Commands", show=True),
276
+ Binding("ctrl+o", "sessions", "Sessions", show=True),
277
+ Binding("ctrl+n", "new_session", "New", show=True),
278
+ Binding("f1", "show_help", "Help", show=True),
279
+ Binding("question_mark", "show_help", "Help", show=False),
280
+ ]
281
+
282
+ def __init__(self, host: AgentHost, ui: TextualUI) -> None:
283
+ super().__init__()
284
+ self.host = host
285
+ self.ui = ui
286
+ self._turn_task: asyncio.Task[None] | None = None
287
+ self._interrupt_count = 0
288
+ host.on_session_changed = lambda _meta: self.call_later(self.update_status_bar)
289
+
290
+ def compose(self) -> ComposeResult:
291
+ yield Static("", id="status-bar")
292
+ yield RichLog(id="conversation", markup=True, highlight=True, wrap=True, auto_scroll=True)
293
+ yield Label(
294
+ "Ctrl+Enter send · Ctrl+P cmds · Ctrl+O sessions · Ctrl+N new · Ctrl+C cancel",
295
+ id="input-hint",
296
+ )
297
+ yield TextArea(id="composer", language=None, soft_wrap=True)
298
+ yield Footer()
299
+
300
+ def on_mount(self) -> None:
301
+ self.ui.bind_app(self)
302
+ self.update_status_bar()
303
+ self.query_one("#composer", TextArea).focus()
304
+ self.query_one("#conversation", RichLog).write(
305
+ "[dim]Noah Code TUI ready. Type a task or /help.[/dim]"
306
+ )
307
+
308
+ def update_status_bar(self) -> None:
309
+ meta = self.host.meta
310
+ mode = self.host.agent.mode if self.host._agent else self.host.config.mode
311
+ model = meta.model if meta else self.host.config.model
312
+ sid = meta.session_id[:8] if meta else "?"
313
+ title = ""
314
+ if meta and meta.title and meta.title != "untitled":
315
+ title = f" │ {meta.title[:24]}"
316
+ ws = self.host.workspace.root.name
317
+ busy = "busy" if self.ui.busy else "idle"
318
+ flag = ""
319
+ if getattr(self.host, "_last_turn_shell_bypass", False):
320
+ flag = " │ shell⚠"
321
+ text = f" {mode} │ {model} │ {sid}{title} │ {ws} │ {busy}{flag} "
322
+ with contextlib.suppress(Exception):
323
+ self.query_one("#status-bar", Static).update(text)
324
+
325
+ def _append_event(self, event: HostEvent) -> None:
326
+ log = self.query_one("#conversation", RichLog)
327
+ kind = event.kind
328
+ text = event.text.rstrip()
329
+ if kind == HostEventKind.MESSAGE:
330
+ log.write(text)
331
+ elif kind == HostEventKind.REASONING:
332
+ if self.host.config.ui.show_reasoning:
333
+ log.write(f"[dim]thinking:[/dim] {text}")
334
+ elif kind == HostEventKind.TOOL_START:
335
+ log.write(f"[cyan]→[/cyan] {text}")
336
+ elif kind == HostEventKind.TOOL_FINISH:
337
+ log.write(f"[green]✓[/green] {text}")
338
+ elif kind == HostEventKind.SHELL_CHUNK:
339
+ stream = event.meta.get("stream", "stdout")
340
+ style = "red" if stream == "stderr" else ("dim" if stream == "status" else "white")
341
+ log.write(f"[{style}]{text}[/{style}]")
342
+ elif kind == HostEventKind.ERROR:
343
+ log.write(f"[bold red]error:[/bold red] {text}")
344
+ elif kind == HostEventKind.SUMMARY:
345
+ log.write(f"[blue]summary:[/blue] {text}")
346
+ elif kind == HostEventKind.STATUS:
347
+ log.write(f"[dim]{text}[/dim]")
348
+ elif kind == HostEventKind.STOP:
349
+ log.write(f"[bold]stop:[/bold] {text}")
350
+ else:
351
+ log.write(text)
352
+ self.update_status_bar()
353
+
354
+ @on(HostEventMessage)
355
+ def _on_host_event(self, message: HostEventMessage) -> None:
356
+ self._append_event(message.event)
357
+
358
+ async def request_approval(self, request: ApprovalRequest) -> ApprovalChoice:
359
+ result = await self.push_screen_wait(ApprovalModal(request))
360
+ return result if result is not None else ApprovalChoice.REJECT
361
+
362
+ def action_show_help(self) -> None:
363
+ self.query_one("#conversation", RichLog).write(help_text(self.host._custom_commands))
364
+
365
+ @work(exclusive=True, group="palette")
366
+ async def action_palette(self) -> None:
367
+ cmds = all_command_names(self.host._custom_commands)
368
+ choice = await self.push_screen_wait(CommandPalette(cmds))
369
+ if choice:
370
+ composer = self.query_one("#composer", TextArea)
371
+ composer.text = choice
372
+ composer.focus()
373
+
374
+ @work(exclusive=True, group="sessions")
375
+ async def action_sessions(self) -> None:
376
+ rows = [(s.session_id, f"{s.mode} {s.title}") for s in self.host.list_session_metas()]
377
+ sid = await self.push_screen_wait(SessionPicker(rows))
378
+ if not sid:
379
+ return
380
+ try:
381
+ await self.host.switch_session(sid)
382
+ self.query_one("#conversation", RichLog).write(f"[dim]switched to session {sid}[/dim]")
383
+ self.update_status_bar()
384
+ except Exception as exc: # noqa: BLE001
385
+ self.query_one("#conversation", RichLog).write(f"[bold red]error:[/bold red] {exc}")
386
+
387
+ @work(exclusive=True, group="sessions")
388
+ async def action_new_session(self) -> None:
389
+ meta = await self.host.start_new_session()
390
+ self.query_one("#conversation", RichLog).write(f"[dim]new session {meta.session_id}[/dim]")
391
+ self.update_status_bar()
392
+
393
+ def action_quit_app(self) -> None:
394
+ self.exit()
395
+
396
+ def action_cancel_or_quit(self) -> None:
397
+ if self.ui.busy and self._turn_task and not self._turn_task.done():
398
+ self.host.cancel_active_turn()
399
+ self.ui.set_busy(False)
400
+ self.query_one("#conversation", RichLog).write("[dim]turn cancelled[/dim]")
401
+ self._interrupt_count = 0
402
+ self.update_status_bar()
403
+ return
404
+ self._interrupt_count += 1
405
+ if self._interrupt_count >= 2:
406
+ self.exit()
407
+ else:
408
+ self.query_one("#conversation", RichLog).write("[dim]Ctrl+C again to quit[/dim]")
409
+
410
+ def action_submit(self) -> None:
411
+ composer = self.query_one("#composer", TextArea)
412
+ text = composer.text.strip()
413
+ if not text or self.ui.busy:
414
+ return
415
+ composer.text = ""
416
+ log = self.query_one("#conversation", RichLog)
417
+ log.write(f"[bold reverse] you [/bold reverse] {text}")
418
+ self._interrupt_count = 0
419
+ self._run_turn(text)
420
+
421
+ @work(exclusive=True, group="turn")
422
+ async def _run_turn(self, text: str) -> None:
423
+ self._turn_task = asyncio.current_task()
424
+ try:
425
+ action = await self.host.handle_line(text)
426
+ if action == "exit":
427
+ self.exit()
428
+ except asyncio.CancelledError:
429
+ self.query_one("#conversation", RichLog).write("[dim]turn cancelled[/dim]")
430
+ except Exception as exc: # noqa: BLE001
431
+ self.query_one("#conversation", RichLog).write(f"[bold red]error:[/bold red] {exc}")
432
+ finally:
433
+ self._turn_task = None
434
+ self.update_status_bar()
435
+ self.query_one("#composer", TextArea).focus()
noah_code/updates.py ADDED
@@ -0,0 +1,184 @@
1
+ """Version checks and uv-managed self-updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from packaging.version import InvalidVersion, Version
19
+
20
+ from noah_code import __version__
21
+
22
+ PACKAGE_NAME = "noah-code"
23
+ PYPI_METADATA_URL = "https://pypi.org/pypi/noah-code/json"
24
+
25
+
26
+ class UpdateError(RuntimeError):
27
+ """An update check or installation could not be completed."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class UpdateStatus:
32
+ current: str
33
+ latest: str
34
+
35
+ @property
36
+ def available(self) -> bool:
37
+ try:
38
+ return Version(self.latest) > Version(self.current)
39
+ except InvalidVersion as exc:
40
+ raise UpdateError(f"invalid package version returned by index: {exc}") from exc
41
+
42
+
43
+ def _state_path() -> Path:
44
+ root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
45
+ return root.expanduser() / "noah-code" / "update.json"
46
+
47
+
48
+ def _read_state() -> dict[str, object]:
49
+ path = _state_path()
50
+ try:
51
+ value = json.loads(path.read_text())
52
+ except (OSError, json.JSONDecodeError):
53
+ return {}
54
+ return value if isinstance(value, dict) else {}
55
+
56
+
57
+ def _write_state(state: dict[str, object]) -> None:
58
+ path = _state_path()
59
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
60
+ path.parent.chmod(0o700)
61
+ fd, temporary = tempfile.mkstemp(prefix=".update-", dir=path.parent)
62
+ try:
63
+ with os.fdopen(fd, "w") as stream:
64
+ json.dump(state, stream, sort_keys=True)
65
+ stream.write("\n")
66
+ stream.flush()
67
+ os.fsync(stream.fileno())
68
+ Path(temporary).chmod(0o600)
69
+ os.replace(temporary, path)
70
+ finally:
71
+ with contextlib.suppress(FileNotFoundError):
72
+ Path(temporary).unlink()
73
+
74
+
75
+ def find_uv() -> Path | None:
76
+ configured = os.environ.get("NOAH_CODE_UV")
77
+ candidates = [
78
+ Path(configured).expanduser() if configured else None,
79
+ Path(found) if (found := shutil.which("uv")) else None,
80
+ Path.home() / ".local" / "bin" / "uv",
81
+ ]
82
+ for candidate in candidates:
83
+ if candidate and candidate.is_file() and os.access(candidate, os.X_OK):
84
+ return candidate.resolve()
85
+ return None
86
+
87
+
88
+ def is_uv_tool_install(uv: Path | None = None) -> bool:
89
+ executable = uv or find_uv()
90
+ if executable is None:
91
+ return False
92
+ try:
93
+ result = subprocess.run(
94
+ [str(executable), "tool", "dir"],
95
+ check=True,
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=10,
99
+ )
100
+ tools_dir = Path(result.stdout.strip()).expanduser().resolve()
101
+ Path(sys.prefix).resolve().relative_to(tools_dir)
102
+ except (OSError, subprocess.SubprocessError, ValueError):
103
+ return False
104
+ return True
105
+
106
+
107
+ def check_for_update(*, timeout: float = 5.0) -> UpdateStatus:
108
+ request = urllib.request.Request(
109
+ PYPI_METADATA_URL,
110
+ headers={"Accept": "application/json", "User-Agent": f"noah-code/{__version__}"},
111
+ )
112
+ try:
113
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
114
+ raw = response.read(1_000_001)
115
+ except (OSError, urllib.error.URLError) as exc:
116
+ raise UpdateError(f"could not query PyPI: {exc}") from exc
117
+ if len(raw) > 1_000_000:
118
+ raise UpdateError("PyPI response exceeded 1 MB")
119
+ try:
120
+ payload = json.loads(raw)
121
+ latest = payload["info"]["version"]
122
+ except (json.JSONDecodeError, KeyError, TypeError) as exc:
123
+ raise UpdateError("PyPI returned invalid package metadata") from exc
124
+ if not isinstance(latest, str):
125
+ raise UpdateError("PyPI returned an invalid version")
126
+ status = UpdateStatus(current=__version__, latest=latest)
127
+ _ = status.available # validate both versions before returning
128
+ return status
129
+
130
+
131
+ def upgrade(*, uv: Path | None = None, timeout: float = 300.0) -> str:
132
+ executable = uv or find_uv()
133
+ if executable is None:
134
+ raise UpdateError("uv was not found; reinstall noah-code with the documented installer")
135
+ if not is_uv_tool_install(executable):
136
+ raise UpdateError(
137
+ "this copy is not a uv tool install; update it with the package manager used "
138
+ "to install it"
139
+ )
140
+ try:
141
+ result = subprocess.run(
142
+ [str(executable), "tool", "upgrade", "--no-build", PACKAGE_NAME],
143
+ check=False,
144
+ capture_output=True,
145
+ text=True,
146
+ timeout=timeout,
147
+ )
148
+ except (OSError, subprocess.SubprocessError) as exc:
149
+ raise UpdateError(f"could not run uv: {exc}") from exc
150
+ output = "\n".join(part.strip() for part in (result.stdout, result.stderr) if part.strip())
151
+ if result.returncode != 0:
152
+ raise UpdateError(output or f"uv exited with status {result.returncode}")
153
+ return output or "uv completed the update"
154
+
155
+
156
+ def maybe_auto_update(*, interval_hours: int, timeout: float) -> str | None:
157
+ """Install a newer release at most once per interval for uv tool installs."""
158
+ uv = find_uv()
159
+ if uv is None or not is_uv_tool_install(uv):
160
+ return None
161
+ state = _read_state()
162
+ now = time.time()
163
+ checked_at = state.get("checked_at", 0)
164
+ if isinstance(checked_at, int | float) and now - checked_at < interval_hours * 3600:
165
+ return None
166
+
167
+ next_state: dict[str, object] = {"checked_at": now, "current": __version__}
168
+ try:
169
+ status = check_for_update(timeout=timeout)
170
+ next_state["latest"] = status.latest
171
+ if not status.available:
172
+ _write_state(next_state)
173
+ return None
174
+ output = upgrade(uv=uv)
175
+ next_state["updated_to"] = status.latest
176
+ _write_state(next_state)
177
+ return (
178
+ f"noah-code {status.current} was updated to {status.latest}; "
179
+ "rerun your command to use the new version\n" + output
180
+ )
181
+ except UpdateError as exc:
182
+ next_state["error"] = str(exc)
183
+ _write_state(next_state)
184
+ return None
noah_code/workspace.py ADDED
@@ -0,0 +1,49 @@
1
+ """Workspace path validation and identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ class WorkspaceError(ValueError):
11
+ """Invalid workspace selection."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Workspace:
16
+ """Canonical workspace root for a session."""
17
+
18
+ root: Path
19
+
20
+ @property
21
+ def identity(self) -> str:
22
+ """Stable identity for resume checks."""
23
+ resolved = str(self.root)
24
+ return hashlib.sha256(resolved.encode()).hexdigest()[:16]
25
+
26
+ def resolve(self, path: str | Path) -> Path:
27
+ """Resolve a path relative to the workspace without escaping."""
28
+ candidate = Path(path)
29
+ if not candidate.is_absolute():
30
+ candidate = self.root / candidate
31
+ resolved = candidate.resolve()
32
+ try:
33
+ resolved.relative_to(self.root)
34
+ except ValueError as exc:
35
+ raise WorkspaceError(f"path escapes workspace: {path}") from exc
36
+ return resolved
37
+
38
+ def relpath(self, path: Path) -> str:
39
+ return str(path.resolve().relative_to(self.root))
40
+
41
+
42
+ def open_workspace(path: str | Path | None = None) -> Workspace:
43
+ """Validate and open a workspace directory."""
44
+ root = Path(path or ".").expanduser().resolve()
45
+ if not root.exists():
46
+ raise WorkspaceError(f"workspace does not exist: {root}")
47
+ if not root.is_dir():
48
+ raise WorkspaceError(f"workspace is not a directory: {root}")
49
+ return Workspace(root=root)