scry-run 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (45) hide show
  1. scry_run-0.2.0/.claude/settings.local.json +20 -0
  2. {scry_run-0.1.2 → scry_run-0.2.0}/PKG-INFO +10 -5
  3. {scry_run-0.1.2 → scry_run-0.2.0}/README.md +8 -4
  4. {scry_run-0.1.2 → scry_run-0.2.0}/pyproject.toml +2 -1
  5. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/__init__.py +1 -1
  6. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/__init__.py +2 -0
  7. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/init.py +30 -19
  8. scry_run-0.2.0/src/scry_run/cli/log.py +373 -0
  9. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/run.py +14 -2
  10. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/config.py +4 -6
  11. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/console.py +10 -2
  12. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/packages.py +20 -3
  13. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli.py +1 -1
  14. scry_run-0.2.0/tests/test_cli_log.py +175 -0
  15. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_run.py +64 -4
  16. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_config.py +24 -7
  17. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_generator.py +14 -0
  18. {scry_run-0.1.2 → scry_run-0.2.0}/uv.lock +106 -1
  19. scry_run-0.1.2/.claude/settings.local.json +0 -13
  20. {scry_run-0.1.2 → scry_run-0.2.0}/.gitignore +0 -0
  21. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/__init__.py +0 -0
  22. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/base.py +0 -0
  23. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/claude.py +0 -0
  24. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/frozen.py +0 -0
  25. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/registry.py +0 -0
  26. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cache.py +0 -0
  27. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/apps.py +0 -0
  28. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/cache.py +0 -0
  29. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/config_cmd.py +0 -0
  30. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/env.py +0 -0
  31. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/context.py +0 -0
  32. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/generator.py +0 -0
  33. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/home.py +0 -0
  34. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/logging.py +0 -0
  35. {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/meta.py +0 -0
  36. {scry_run-0.1.2 → scry_run-0.2.0}/tests/conftest.py +0 -0
  37. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cache.py +0 -0
  38. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_default.py +0 -0
  39. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_env.py +0 -0
  40. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_context.py +0 -0
  41. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_home.py +0 -0
  42. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_integration.py +0 -0
  43. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_logging.py +0 -0
  44. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_meta.py +0 -0
  45. {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_packages.py +0 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(find:*)",
5
+ "Bash(wc:*)",
6
+ "Bash(uv run pytest:*)",
7
+ "Bash(uv run python -m pytest:*)",
8
+ "Bash(uv sync:*)",
9
+ "Bash(uv lock:*)",
10
+ "Bash(uv run:*)",
11
+ "Bash(SCRY_BACKEND=claude uv run:*)",
12
+ "Bash(SCRY_RUN_HOME=/tmp/test_scry_home uv run:*)",
13
+ "Bash(SCRY_HOME=/tmp/test_scry_home uv run scry-run:*)",
14
+ "Bash(SCRY_HOME=/tmp/test_scry_home SCRY_MODEL=haiku uv run:*)",
15
+ "Bash(vhs:*)",
16
+ "WebFetch(domain:github.com)",
17
+ "WebFetch(domain:raw.githubusercontent.com)"
18
+ ]
19
+ }
20
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scry-run
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: LLM-powered dynamic code generation via metaclasses. Define classes with docstrings, call any method—code generates automatically, caches persistently, and executes instantly.
5
5
  Project-URL: Homepage, https://github.com/Tener/scry-run
6
6
  Project-URL: Repository, https://github.com/Tener/scry-run
@@ -21,6 +21,7 @@ Requires-Dist: click>=8.0.0
21
21
  Requires-Dist: jinja2>=3.0.0
22
22
  Requires-Dist: rich>=13.0.0
23
23
  Requires-Dist: tomli>=2.0.0; python_version < '3.11'
24
+ Requires-Dist: watchfiles>=0.21.0
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
26
27
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
@@ -58,13 +59,17 @@ scry-run run todoist
58
59
 
59
60
  ## Demos
60
61
 
61
- ### Creating a simple Hello World app
62
+ ### Greet app
62
63
 
63
- ![Hello Demo](demos/hello.gif)
64
+ [![Greet Demo](demos/out/greet.gif)](demos/out/greet.webm)
64
65
 
65
- ### Building a maze game with PyGame
66
+ ### Timer app
66
67
 
67
- ![Maze Demo](demos/maze.gif)
68
+ [![Timer Demo](demos/out/timer.gif)](demos/out/timer.webm)
69
+
70
+ ### Maze app
71
+
72
+ [![Maze Demo](demos/out/maze.gif)](demos/out/maze.webm)
68
73
 
69
74
  ## CLI Commands
70
75
 
@@ -28,13 +28,17 @@ scry-run run todoist
28
28
 
29
29
  ## Demos
30
30
 
31
- ### Creating a simple Hello World app
31
+ ### Greet app
32
32
 
33
- ![Hello Demo](demos/hello.gif)
33
+ [![Greet Demo](demos/out/greet.gif)](demos/out/greet.webm)
34
34
 
35
- ### Building a maze game with PyGame
35
+ ### Timer app
36
36
 
37
- ![Maze Demo](demos/maze.gif)
37
+ [![Timer Demo](demos/out/timer.gif)](demos/out/timer.webm)
38
+
39
+ ### Maze app
40
+
41
+ [![Maze Demo](demos/out/maze.gif)](demos/out/maze.webm)
38
42
 
39
43
  ## CLI Commands
40
44
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scry-run"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "LLM-powered dynamic code generation via metaclasses. Define classes with docstrings, call any method—code generates automatically, caches persistently, and executes instantly."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "jinja2>=3.0.0",
25
25
  "tomli>=2.0.0;python_version<'3.11'",
26
26
  "claude-agent-sdk>=0.1.0",
27
+ "watchfiles>=0.21.0",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
@@ -72,7 +72,7 @@ def is_generated(obj: Any) -> bool:
72
72
  return getattr(obj, marker, False)
73
73
 
74
74
 
75
- __version__ = "0.1.0"
75
+ __version__ = "0.2.0"
76
76
  __all__ = [
77
77
  "ScryClass",
78
78
  "ScryMeta",
@@ -13,6 +13,7 @@ from scry_run.cli.run import run
13
13
  from scry_run.cli.env import env
14
14
  from scry_run.cli.apps import list_apps, which_app, rm_app, reset_app, info_app
15
15
  from scry_run.cli.config_cmd import config_cmd
16
+ from scry_run.cli.log import log_cmd
16
17
  from scry_run.generator import CodeGenerator, ScryRunError
17
18
 
18
19
 
@@ -131,6 +132,7 @@ main.add_command(rm_app, name="rm")
131
132
  main.add_command(reset_app, name="reset")
132
133
  main.add_command(info_app, name="info")
133
134
  main.add_command(config_cmd, name="config")
135
+ main.add_command(log_cmd, name="log")
134
136
 
135
137
 
136
138
  if __name__ == "__main__":
@@ -278,10 +278,17 @@ def to_class_name(name: str) -> str:
278
278
  default=True,
279
279
  help="Automatically expand description with features and examples (default: enabled)",
280
280
  )
281
+ @click.option(
282
+ "--yes", "-y",
283
+ is_flag=True,
284
+ default=False,
285
+ help="Auto-approve all prompts (expanded description, overwrite existing app)",
286
+ )
281
287
  def init(
282
288
  name: str | None,
283
289
  description: str | None,
284
290
  auto_expand: bool,
291
+ yes: bool,
285
292
  ) -> None:
286
293
  """Initialize a new scry-run app.
287
294
 
@@ -343,26 +350,30 @@ def init(
343
350
 
344
351
  console.print()
345
352
 
346
- # Let user confirm, edit, or reject
347
- console.print("[dim](y)es, (n)o, or (e)dit in $EDITOR[/dim]")
348
- choice = Prompt.ask(
349
- "Use this description?",
350
- choices=["y", "n", "e"],
351
- default="y",
352
- )
353
- if choice == "y":
353
+ # Auto-approve or let user confirm
354
+ if yes:
354
355
  description = expanded_desc
355
- elif choice == "e":
356
- # Open in editor
357
- edited = click.edit(expanded_desc)
358
- if edited:
359
- description = edited.strip()
360
- console.print("[dim]Using edited description.[/dim]")
361
- else:
362
- console.print("[dim]Editor returned empty, using original.[/dim]")
363
356
  else:
364
- console.print("[dim]Using original description.[/dim]")
365
- recommended_flags = {} # Reset flags if user rejected expansion
357
+ # Let user confirm, edit, or reject
358
+ console.print("[dim](y)es, (n)o, or (e)dit in $EDITOR[/dim]")
359
+ choice = Prompt.ask(
360
+ "Use this description?",
361
+ choices=["y", "n", "e"],
362
+ default="y",
363
+ )
364
+ if choice == "y":
365
+ description = expanded_desc
366
+ elif choice == "e":
367
+ # Open in editor
368
+ edited = click.edit(expanded_desc)
369
+ if edited:
370
+ description = edited.strip()
371
+ console.print("[dim]Using edited description.[/dim]")
372
+ else:
373
+ console.print("[dim]Editor returned empty, using original.[/dim]")
374
+ else:
375
+ console.print("[dim]Using original description.[/dim]")
376
+ recommended_flags = {} # Reset flags if user rejected expansion
366
377
  else:
367
378
  console.print("[dim]Using original description.[/dim]")
368
379
 
@@ -379,7 +390,7 @@ def init(
379
390
 
380
391
  # Check for existing app
381
392
  if app_dir.exists():
382
- if not Confirm.ask(f"[yellow]App '{name}' already exists[/yellow]. Overwrite?"):
393
+ if not yes and not Confirm.ask(f"[yellow]App '{name}' already exists[/yellow]. Overwrite?"):
383
394
  console.print("[yellow]Aborted.[/yellow]")
384
395
  raise SystemExit(0)
385
396
 
@@ -0,0 +1,373 @@
1
+ """Log command for scry-run.
2
+
3
+ Watches an app's log directory and tails log files as they update.
4
+ """
5
+
6
+ import datetime
7
+ import re
8
+ import sys
9
+ import time
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import click
15
+ from rich.console import Console
16
+ from rich.text import Text
17
+
18
+ from scry_run.home import get_app_dir, get_app_logs
19
+
20
+ console = Console()
21
+
22
+ _COLOR_PALETTE = [
23
+ "cyan",
24
+ "magenta",
25
+ "green",
26
+ "yellow",
27
+ "blue",
28
+ "bright_cyan",
29
+ "bright_magenta",
30
+ "bright_green",
31
+ "bright_yellow",
32
+ "bright_blue",
33
+ ]
34
+
35
+ _LEVEL_COLORS = {
36
+ "DEBUG": "dim",
37
+ "INFO": "green",
38
+ "WARN": "yellow",
39
+ "WARNING": "yellow",
40
+ "ERROR": "red",
41
+ }
42
+
43
+ _LOG_HEADER_RE = re.compile(r"^\[(?P<ts>[^\]]+)\]\s+\[(?P<level>[A-Z]+)\]\s+")
44
+
45
+
46
+ _DURATION_RE = re.compile(r"^\d+(?:\.\d+)?[smh]?$")
47
+
48
+
49
+ def _parse_duration(value: str) -> float:
50
+ """Parse a duration like '30s', '10m', '1h' into seconds."""
51
+ s = value.strip().lower()
52
+ if not s:
53
+ raise ValueError("duration cannot be empty")
54
+
55
+ unit = s[-1]
56
+ if unit.isdigit():
57
+ # seconds by default
58
+ return float(s)
59
+
60
+ number = s[:-1]
61
+ if not number:
62
+ raise ValueError(f"invalid duration: {value}")
63
+
64
+ try:
65
+ qty = float(number)
66
+ except ValueError as exc:
67
+ raise ValueError(f"invalid duration: {value}") from exc
68
+
69
+ if unit == "s":
70
+ return qty
71
+ if unit == "m":
72
+ return qty * 60.0
73
+ if unit == "h":
74
+ return qty * 3600.0
75
+
76
+ raise ValueError(f"invalid duration unit: {value}")
77
+
78
+
79
+ def _normalize_dt(value: datetime.datetime) -> datetime.datetime:
80
+ if value.tzinfo is None:
81
+ return value
82
+ return value.astimezone().replace(tzinfo=None)
83
+
84
+
85
+ def _parse_since(value: str, now: datetime.datetime) -> datetime.datetime:
86
+ s = value.strip()
87
+ if not s:
88
+ raise ValueError("since cannot be empty")
89
+
90
+ if _DURATION_RE.match(s.lower()):
91
+ seconds = _parse_duration(s)
92
+ return now - datetime.timedelta(seconds=seconds)
93
+
94
+ try:
95
+ parsed = datetime.datetime.fromisoformat(s)
96
+ except ValueError as exc:
97
+ raise ValueError(f"invalid since value: {value}") from exc
98
+
99
+ return _normalize_dt(parsed)
100
+
101
+
102
+ def _parse_log_timestamp(line: str) -> datetime.datetime | None:
103
+ raw = line.lstrip()
104
+ if not raw.startswith("["):
105
+ return None
106
+ end = raw.find("]")
107
+ if end == -1:
108
+ return None
109
+ ts_str = raw[1:end]
110
+ try:
111
+ return _normalize_dt(datetime.datetime.fromisoformat(ts_str))
112
+ except ValueError:
113
+ return None
114
+
115
+
116
+ @dataclass
117
+ class _TailState:
118
+ path: Path
119
+ position: int
120
+ buffer: str
121
+
122
+
123
+ class _OutputLock:
124
+ def __init__(self) -> None:
125
+ self._lock = threading.Lock()
126
+
127
+ def write(self, data: Text | str) -> None:
128
+ with self._lock:
129
+ if isinstance(data, Text):
130
+ console.print(data, end="")
131
+ else:
132
+ sys.stdout.write(data)
133
+ sys.stdout.flush()
134
+
135
+
136
+ def _iter_recent_logs(log_dir: Path, since_seconds: float) -> list[Path]:
137
+ now = time.time()
138
+ paths: list[Path] = []
139
+ for path in sorted(log_dir.glob("*.log")):
140
+ try:
141
+ if (now - path.stat().st_mtime) <= since_seconds:
142
+ paths.append(path)
143
+ except FileNotFoundError:
144
+ continue
145
+ return paths
146
+
147
+
148
+ def _read_new_data(state: _TailState) -> list[str]:
149
+ try:
150
+ size = state.path.stat().st_size
151
+ except FileNotFoundError:
152
+ return []
153
+
154
+ if size < state.position:
155
+ # File truncated or rotated; start over
156
+ state.position = 0
157
+ state.buffer = ""
158
+
159
+ try:
160
+ with state.path.open("r", encoding="utf-8", errors="replace") as f:
161
+ f.seek(state.position)
162
+ data = f.read()
163
+ state.position = f.tell()
164
+ except FileNotFoundError:
165
+ return []
166
+
167
+ if not data:
168
+ return []
169
+
170
+ chunk = state.buffer + data
171
+ lines = chunk.splitlines(keepends=True)
172
+ output: list[str] = []
173
+ state.buffer = ""
174
+ for line in lines:
175
+ if line.endswith("\n") or line.endswith("\r"):
176
+ output.append(line)
177
+ else:
178
+ state.buffer = line
179
+ return output
180
+
181
+
182
+ def _color_for_name(name: str) -> str:
183
+ return _COLOR_PALETTE[hash(name) % len(_COLOR_PALETTE)]
184
+
185
+
186
+ def _build_colored_lines(path: Path, lines: list[str], short_mode: bool = False) -> Text:
187
+ text = Text()
188
+ prefix = "> " if short_mode else f"[{path.name}] "
189
+ prefix_style = "dim" if short_mode else _color_for_name(path.name)
190
+
191
+ for line in lines:
192
+ text.append(prefix, style=prefix_style)
193
+
194
+ stripped = line.strip()
195
+ if stripped and all(ch == "=" for ch in stripped):
196
+ text.append(line, style="dim")
197
+ continue
198
+ if stripped and all(ch == "-" for ch in stripped):
199
+ text.append(line, style="dim")
200
+ continue
201
+
202
+ match = _LOG_HEADER_RE.match(line)
203
+ if match:
204
+ ts = match.group("ts")
205
+ level = match.group("level")
206
+ level_style = _LEVEL_COLORS.get(level, "white")
207
+ header_len = match.end()
208
+ rest = line[header_len:]
209
+ if not short_mode:
210
+ text.append(f"[{ts}] ", style="dim")
211
+ text.append(f"[{level}] ", style=level_style)
212
+ text.append(rest)
213
+ continue
214
+
215
+ text.append(line)
216
+
217
+ return text
218
+
219
+
220
+ def _find_offset_for_since(path: Path, cutoff: datetime.datetime) -> int:
221
+ try:
222
+ with path.open("r", encoding="utf-8", errors="replace") as f:
223
+ prev_pos = 0
224
+ prev_line_sep = False
225
+ while True:
226
+ pos = f.tell()
227
+ line = f.readline()
228
+ if not line:
229
+ return f.tell()
230
+
231
+ ts = _parse_log_timestamp(line)
232
+ if ts and ts >= cutoff:
233
+ return prev_pos if prev_line_sep else pos
234
+
235
+ prev_line_sep = line.strip() == "=" * 80
236
+ prev_pos = pos
237
+ except FileNotFoundError:
238
+ return 0
239
+
240
+
241
+ def _wait_for_log_dir(
242
+ app_name: str,
243
+ *,
244
+ poll_interval: float = 0.2,
245
+ max_wait_seconds: float | None = None,
246
+ ) -> Path:
247
+ """Wait for an app's logs directory to be created.
248
+
249
+ By default this waits indefinitely. ``max_wait_seconds`` is only intended
250
+ for tests to avoid hanging.
251
+ """
252
+ app_dir = get_app_dir(app_name)
253
+ log_dir = get_app_logs(app_name)
254
+
255
+ warned = False
256
+ started = time.monotonic()
257
+
258
+ while True:
259
+ if log_dir.exists() and log_dir.is_dir():
260
+ if warned:
261
+ console.print(f"[dim]Found logs directory: {log_dir}[/dim]")
262
+ return log_dir
263
+
264
+ if not warned:
265
+ if not app_dir.exists():
266
+ console.print(f"[yellow]Warning:[/yellow] App '{app_name}' not found yet.")
267
+ console.print(f"[dim]Waiting for logs directory: {log_dir}[/dim]")
268
+ else:
269
+ console.print(f"[yellow]Warning:[/yellow] Logs directory for app '{app_name}' not found yet.")
270
+ console.print(f"[dim]Waiting for logs directory: {log_dir}[/dim]")
271
+ warned = True
272
+
273
+ if max_wait_seconds is not None and (time.monotonic() - started) >= max_wait_seconds:
274
+ raise TimeoutError(f"Timed out waiting for logs directory: {log_dir}")
275
+
276
+ time.sleep(poll_interval)
277
+
278
+
279
+ @click.command("log")
280
+ @click.argument("app_name")
281
+ @click.option(
282
+ "--since",
283
+ default="1m",
284
+ show_default=True,
285
+ help="Start from entries since this time (e.g., 30m, 1h, 10s, or 2026-02-04T12:30:00).",
286
+ )
287
+ @click.option(
288
+ "--debounce",
289
+ default=0.2,
290
+ show_default=True,
291
+ type=float,
292
+ help="Debounce time in seconds for filesystem events.",
293
+ )
294
+ @click.option(
295
+ "--short",
296
+ "short_mode",
297
+ is_flag=True,
298
+ help="Use compact output prefix ('> ') instead of log filename.",
299
+ )
300
+ def log_cmd(app_name: str, since: str, debounce: float, short_mode: bool) -> None:
301
+ """Watch an app's log directory and tail log files."""
302
+ try:
303
+ from watchfiles import Change, watch
304
+ except ModuleNotFoundError:
305
+ console.print("[red]Error:[/red] Missing dependency: watchfiles")
306
+ console.print("[dim]Install/update scry-run to include watchfiles, then retry.[/dim]")
307
+ sys.exit(1)
308
+
309
+ try:
310
+ log_dir = _wait_for_log_dir(app_name)
311
+ except KeyboardInterrupt:
312
+ console.print("\n[dim]Stopped waiting for logs.[/dim]")
313
+ return
314
+
315
+ now = datetime.datetime.now()
316
+ try:
317
+ since_cutoff = _parse_since(since, now)
318
+ except ValueError as exc:
319
+ console.print(f"[red]Error:[/red] {exc}")
320
+ sys.exit(1)
321
+
322
+ if debounce < 0:
323
+ console.print("[red]Error:[/red] debounce must be >= 0")
324
+ sys.exit(1)
325
+
326
+ debounce_ms = int(debounce * 1000)
327
+
328
+ console.print(f"[cyan]Watching logs in:[/cyan] {log_dir}")
329
+ console.print(f"[dim]Since:[/dim] {since} [dim]Press Ctrl+C to stop[/dim]")
330
+
331
+ states: dict[Path, _TailState] = {}
332
+ output_lock = _OutputLock()
333
+
334
+ # Seed with recent log files based on mtime, then seek to cutoff by entry timestamp
335
+ for path in _iter_recent_logs(log_dir, (now - since_cutoff).total_seconds()):
336
+ offset = _find_offset_for_since(path, since_cutoff)
337
+ states[path] = _TailState(path=path, position=offset, buffer="")
338
+
339
+ # Print existing entries since cutoff on startup
340
+ for path, state in states.items():
341
+ lines = _read_new_data(state)
342
+ if not lines:
343
+ continue
344
+ output_lock.write(_build_colored_lines(path, lines, short_mode=short_mode))
345
+
346
+ try:
347
+ for changes in watch(log_dir, debounce=debounce_ms):
348
+ paths_to_read: set[Path] = set()
349
+
350
+ for change, raw_path in changes:
351
+ path = Path(raw_path)
352
+ if path.suffix != ".log":
353
+ continue
354
+
355
+ if change == Change.deleted:
356
+ states.pop(path, None)
357
+ continue
358
+
359
+ if path not in states:
360
+ offset = _find_offset_for_since(path, since_cutoff)
361
+ states[path] = _TailState(path=path, position=offset, buffer="")
362
+ paths_to_read.add(path)
363
+
364
+ for path in paths_to_read:
365
+ state = states.get(path)
366
+ if not state:
367
+ continue
368
+ lines = _read_new_data(state)
369
+ if not lines:
370
+ continue
371
+ output_lock.write(_build_colored_lines(path, lines, short_mode=short_mode))
372
+ except KeyboardInterrupt:
373
+ console.print("\n[dim]Stopped watching logs.[/dim]")
@@ -10,6 +10,7 @@ from rich.console import Console
10
10
  from scry_run.home import get_app_dir
11
11
  from scry_run.config import load_config, get_env_vars
12
12
  from scry_run.packages import ensure_scry_run_installed
13
+ from scry_run.logging import get_logger
13
14
 
14
15
  console = Console()
15
16
 
@@ -87,9 +88,20 @@ def run(ctx, quiet: bool, app_name: str, args: tuple[str, ...]) -> None:
87
88
  # Ensure app has scry-run installed
88
89
  ensure_scry_run_installed(app_dir)
89
90
 
91
+ logger = get_logger()
92
+ logger.set_app_context(app_dir)
93
+ logger.info(f"App starting: {app_name}")
94
+
90
95
  # Build command
91
96
  cmd = ["uv", "run", "--directory", str(app_dir), "python", str(app_py)] + list(args)
92
97
 
93
98
  # Run app
94
- result = subprocess.run(cmd, env=env)
95
- ctx.exit(result.returncode)
99
+ return_code = 0
100
+ try:
101
+ result = subprocess.run(cmd, env=env)
102
+ return_code = result.returncode
103
+ except KeyboardInterrupt:
104
+ return_code = 130
105
+ finally:
106
+ logger.info(f"App shutting down: {app_name} (exit_code={return_code})")
107
+ ctx.exit(return_code)
@@ -101,6 +101,8 @@ def load_config() -> Config:
101
101
  kwargs["full_context"] = env_full_context.lower() in ("true", "1", "yes")
102
102
  if env_log_level := os.environ.get("SCRY_LOG_LEVEL"):
103
103
  kwargs["log_level"] = env_log_level
104
+ if env_model := os.environ.get("SCRY_MODEL"):
105
+ kwargs["claude_model"] = env_model
104
106
 
105
107
  return Config(**kwargs)
106
108
 
@@ -131,11 +133,7 @@ def get_env_vars(config: Config) -> dict[str, str]:
131
133
  set_if_value("SCRY_FULL_CONTEXT", config.full_context)
132
134
  set_if_value("SCRY_LOG_LEVEL", config.log_level)
133
135
 
134
- # Model env vars - export for selected backend, but always preserve existing env vars
135
- if config.backend == "claude":
136
- set_if_value("SCRY_MODEL", config.claude_model)
137
- # For "auto" or other: still preserve any existing env vars
138
- if "SCRY_MODEL" in os.environ:
139
- env_vars["SCRY_MODEL"] = os.environ["SCRY_MODEL"]
136
+ # Model env var - export if configured (works for claude and auto backends)
137
+ set_if_value("SCRY_MODEL", config.claude_model)
140
138
 
141
139
  return env_vars
@@ -24,9 +24,17 @@ def _set_title(msg: str) -> None:
24
24
  sys.stderr.flush()
25
25
 
26
26
 
27
+ _START_TIME = datetime.now()
28
+
29
+
27
30
  def _timestamp() -> str:
28
- """Return current time as HH:MM:SS."""
29
- return datetime.now().strftime("%H:%M:%S")
31
+ """Return time since app start as HH:MM:SS."""
32
+ elapsed = datetime.now() - _START_TIME
33
+ total_seconds = int(elapsed.total_seconds())
34
+ hours = total_seconds // 3600
35
+ minutes = (total_seconds % 3600) // 60
36
+ seconds = total_seconds % 60
37
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
30
38
 
31
39
 
32
40
  def status(msg: str) -> None: