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.
- scry_run-0.2.0/.claude/settings.local.json +20 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/PKG-INFO +10 -5
- {scry_run-0.1.2 → scry_run-0.2.0}/README.md +8 -4
- {scry_run-0.1.2 → scry_run-0.2.0}/pyproject.toml +2 -1
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/__init__.py +1 -1
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/__init__.py +2 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/init.py +30 -19
- scry_run-0.2.0/src/scry_run/cli/log.py +373 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/run.py +14 -2
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/config.py +4 -6
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/console.py +10 -2
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/packages.py +20 -3
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli.py +1 -1
- scry_run-0.2.0/tests/test_cli_log.py +175 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_run.py +64 -4
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_config.py +24 -7
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_generator.py +14 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/uv.lock +106 -1
- scry_run-0.1.2/.claude/settings.local.json +0 -13
- {scry_run-0.1.2 → scry_run-0.2.0}/.gitignore +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/__init__.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/base.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/claude.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/frozen.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/backends/registry.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cache.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/apps.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/cache.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/config_cmd.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/cli/env.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/context.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/generator.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/home.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/logging.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/src/scry_run/meta.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/conftest.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cache.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_default.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_cli_env.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_context.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_home.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_integration.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_logging.py +0 -0
- {scry_run-0.1.2 → scry_run-0.2.0}/tests/test_meta.py +0 -0
- {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.
|
|
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
|
-
###
|
|
62
|
+
### Greet app
|
|
62
63
|
|
|
63
|
-
](demos/out/greet.webm)
|
|
64
65
|
|
|
65
|
-
###
|
|
66
|
+
### Timer app
|
|
66
67
|
|
|
67
|
-
](demos/out/timer.webm)
|
|
69
|
+
|
|
70
|
+
### Maze app
|
|
71
|
+
|
|
72
|
+
[](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
|
-
###
|
|
31
|
+
### Greet app
|
|
32
32
|
|
|
33
|
-
](demos/out/greet.webm)
|
|
34
34
|
|
|
35
|
-
###
|
|
35
|
+
### Timer app
|
|
36
36
|
|
|
37
|
-
](demos/out/timer.webm)
|
|
38
|
+
|
|
39
|
+
### Maze app
|
|
40
|
+
|
|
41
|
+
[](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.
|
|
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]
|
|
@@ -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
|
-
#
|
|
347
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
135
|
-
|
|
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
|
|
29
|
-
|
|
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:
|