scry-run 0.1.1__py3-none-any.whl → 0.2.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.
- scry_run/__init__.py +1 -1
- scry_run/cli/__init__.py +2 -0
- scry_run/cli/init.py +101 -28
- scry_run/cli/log.py +373 -0
- scry_run/cli/run.py +39 -3
- scry_run/config.py +4 -6
- scry_run/console.py +54 -9
- scry_run/logging.py +12 -9
- scry_run/packages.py +20 -3
- {scry_run-0.1.1.dist-info → scry_run-0.2.0.dist-info}/METADATA +10 -5
- {scry_run-0.1.1.dist-info → scry_run-0.2.0.dist-info}/RECORD +13 -12
- {scry_run-0.1.1.dist-info → scry_run-0.2.0.dist-info}/WHEEL +0 -0
- {scry_run-0.1.1.dist-info → scry_run-0.2.0.dist-info}/entry_points.txt +0 -0
scry_run/__init__.py
CHANGED
scry_run/cli/__init__.py
CHANGED
|
@@ -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__":
|
scry_run/cli/init.py
CHANGED
|
@@ -39,7 +39,7 @@ IMPORTANT GUIDELINES:
|
|
|
39
39
|
- STICK TO WHAT THE USER ASKED FOR - don't add unrelated features
|
|
40
40
|
- Less is more - a focused app is better than a bloated one
|
|
41
41
|
- If the user said "todo list", make a todo list - not a project management suite
|
|
42
|
-
- Total length: 100-200 words (keep it concise!)
|
|
42
|
+
- Total description length: 100-200 words (keep it concise!)
|
|
43
43
|
|
|
44
44
|
**OPEN WORLD PRINCIPLE**: This app uses scry-run for dynamic code generation. Methods are generated on-demand at runtime, meaning the set of commands is effectively unlimited - but inputs must still be STRUCTURED like a normal CLI:
|
|
45
45
|
- Use standard CLI patterns: commands, subcommands, flags, and positional arguments
|
|
@@ -52,10 +52,23 @@ IMPORTANT GUIDELINES:
|
|
|
52
52
|
- GOOD: `todo add "buy milk"`, `todo list --due=today`, `todo done 3`
|
|
53
53
|
- BAD: `todo what's due today?` (question form)
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
**APP FLAGS** - Recommend runtime flags for the app:
|
|
56
|
+
- "quiet": true - For TUI/interactive apps (curses, textual, rich.live, pygame, etc.)
|
|
57
|
+
This shows scry-run status in terminal title bar instead of stderr, preventing display disruption.
|
|
58
|
+
Only set to true if the app uses a TUI or graphical library.
|
|
56
59
|
|
|
60
|
+
Return a JSON object with this structure:
|
|
61
|
+
{{
|
|
62
|
+
"description": "The expanded description text here...",
|
|
63
|
+
"flags": {{
|
|
64
|
+
"quiet": false
|
|
65
|
+
}}
|
|
66
|
+
}}
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
Return ONLY the JSON object, no markdown code fences or explanation.'''
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def expand_description(name: str, description: str) -> dict | None:
|
|
59
72
|
"""Use LLM to expand a brief description into a detailed one.
|
|
60
73
|
|
|
61
74
|
Args:
|
|
@@ -63,7 +76,7 @@ def expand_description(name: str, description: str) -> str | None:
|
|
|
63
76
|
description: User's brief description
|
|
64
77
|
|
|
65
78
|
Returns:
|
|
66
|
-
|
|
79
|
+
Dict with 'description' and 'flags' keys, or None if expansion fails
|
|
67
80
|
"""
|
|
68
81
|
try:
|
|
69
82
|
from scry_run.generator import CodeGenerator, ScryRunError
|
|
@@ -72,8 +85,32 @@ def expand_description(name: str, description: str) -> str | None:
|
|
|
72
85
|
prompt = EXPAND_PROMPT.format(name=name, description=description)
|
|
73
86
|
|
|
74
87
|
# Use generate_freeform for text generation
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
response = generator.generate_freeform(prompt)
|
|
89
|
+
response = response.strip()
|
|
90
|
+
|
|
91
|
+
# Parse JSON response
|
|
92
|
+
# Handle potential markdown code fences
|
|
93
|
+
if response.startswith("```"):
|
|
94
|
+
lines = response.split("\n")
|
|
95
|
+
# Remove first and last lines (code fences)
|
|
96
|
+
response = "\n".join(lines[1:-1])
|
|
97
|
+
|
|
98
|
+
result = json.loads(response)
|
|
99
|
+
|
|
100
|
+
# Validate structure
|
|
101
|
+
if "description" not in result:
|
|
102
|
+
console.print("[yellow]Warning:[/yellow] LLM response missing 'description' field")
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Ensure flags exists with defaults
|
|
106
|
+
if "flags" not in result:
|
|
107
|
+
result["flags"] = {}
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
except json.JSONDecodeError as e:
|
|
112
|
+
console.print(f"[yellow]Warning:[/yellow] Could not parse LLM response as JSON: {e}")
|
|
113
|
+
return None
|
|
77
114
|
except ScryRunError as e:
|
|
78
115
|
console.print(f"[yellow]Warning:[/yellow] Could not expand description: {e.message}")
|
|
79
116
|
return None
|
|
@@ -241,10 +278,17 @@ def to_class_name(name: str) -> str:
|
|
|
241
278
|
default=True,
|
|
242
279
|
help="Automatically expand description with features and examples (default: enabled)",
|
|
243
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
|
+
)
|
|
244
287
|
def init(
|
|
245
288
|
name: str | None,
|
|
246
289
|
description: str | None,
|
|
247
290
|
auto_expand: bool,
|
|
291
|
+
yes: bool,
|
|
248
292
|
) -> None:
|
|
249
293
|
"""Initialize a new scry-run app.
|
|
250
294
|
|
|
@@ -279,41 +323,57 @@ def init(
|
|
|
279
323
|
)
|
|
280
324
|
|
|
281
325
|
# Auto-expand description using LLM
|
|
326
|
+
# Track recommended flags from LLM
|
|
327
|
+
recommended_flags: dict = {}
|
|
328
|
+
|
|
282
329
|
if auto_expand:
|
|
283
330
|
console.print()
|
|
284
331
|
console.print("[dim]Expanding description with AI...[/dim]")
|
|
285
|
-
|
|
332
|
+
result = expand_description(name, description)
|
|
333
|
+
|
|
334
|
+
if result:
|
|
335
|
+
expanded_desc = result["description"]
|
|
336
|
+
recommended_flags = result.get("flags", {})
|
|
286
337
|
|
|
287
|
-
if expanded:
|
|
288
338
|
# Show the expanded description
|
|
289
339
|
console.print()
|
|
290
340
|
panel = Panel(
|
|
291
|
-
|
|
341
|
+
expanded_desc,
|
|
292
342
|
title="[bold]Expanded Description[/bold]",
|
|
293
343
|
border_style="blue",
|
|
294
344
|
)
|
|
295
345
|
console.print(panel)
|
|
346
|
+
|
|
347
|
+
# Show recommended flags if any are non-default
|
|
348
|
+
if recommended_flags.get("quiet"):
|
|
349
|
+
console.print("[dim]Recommended: quiet mode (TUI app)[/dim]")
|
|
350
|
+
|
|
296
351
|
console.print()
|
|
297
352
|
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"Use this description?",
|
|
302
|
-
choices=["y", "n", "e"],
|
|
303
|
-
default="y",
|
|
304
|
-
)
|
|
305
|
-
if choice == "y":
|
|
306
|
-
description = expanded
|
|
307
|
-
elif choice == "e":
|
|
308
|
-
# Open in editor
|
|
309
|
-
edited = click.edit(expanded)
|
|
310
|
-
if edited:
|
|
311
|
-
description = edited.strip()
|
|
312
|
-
console.print("[dim]Using edited description.[/dim]")
|
|
313
|
-
else:
|
|
314
|
-
console.print("[dim]Editor returned empty, using original.[/dim]")
|
|
353
|
+
# Auto-approve or let user confirm
|
|
354
|
+
if yes:
|
|
355
|
+
description = expanded_desc
|
|
315
356
|
else:
|
|
316
|
-
|
|
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
|
|
317
377
|
else:
|
|
318
378
|
console.print("[dim]Using original description.[/dim]")
|
|
319
379
|
|
|
@@ -330,7 +390,7 @@ def init(
|
|
|
330
390
|
|
|
331
391
|
# Check for existing app
|
|
332
392
|
if app_dir.exists():
|
|
333
|
-
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?"):
|
|
334
394
|
console.print("[yellow]Aborted.[/yellow]")
|
|
335
395
|
raise SystemExit(0)
|
|
336
396
|
|
|
@@ -356,6 +416,15 @@ def init(
|
|
|
356
416
|
cache_file = app_dir / "cache.json"
|
|
357
417
|
cache_file.write_text(json.dumps({}))
|
|
358
418
|
|
|
419
|
+
# Create settings.json with default flags from LLM recommendation
|
|
420
|
+
settings = {
|
|
421
|
+
"default_flags": {
|
|
422
|
+
"quiet": recommended_flags.get("quiet", False),
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
settings_file = app_dir / "settings.json"
|
|
426
|
+
settings_file.write_text(json.dumps(settings, indent=2))
|
|
427
|
+
|
|
359
428
|
# Create logs directory
|
|
360
429
|
logs_dir = app_dir / "logs"
|
|
361
430
|
logs_dir.mkdir(exist_ok=True)
|
|
@@ -365,7 +434,11 @@ def init(
|
|
|
365
434
|
console.print()
|
|
366
435
|
console.print(f" [dim]Location:[/dim] {app_dir}")
|
|
367
436
|
console.print(f" [dim]Main file:[/dim] {app_file}")
|
|
437
|
+
console.print(f" [dim]Settings:[/dim] {settings_file}")
|
|
368
438
|
console.print()
|
|
439
|
+
if recommended_flags.get("quiet"):
|
|
440
|
+
console.print("[dim]Quiet mode enabled (TUI app) - status shown in title bar.[/dim]")
|
|
441
|
+
console.print()
|
|
369
442
|
console.print("[bold]Useful commands:[/bold]")
|
|
370
443
|
console.print(f" [cyan]scry-run run {name}[/cyan] Run your app")
|
|
371
444
|
console.print(f" [cyan]scry-run info {name}[/cyan] View app details and cache stats")
|
scry_run/cli/log.py
ADDED
|
@@ -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]")
|
scry_run/cli/run.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Run command for executing scry-run apps."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
|
|
@@ -9,15 +10,28 @@ from rich.console import Console
|
|
|
9
10
|
from scry_run.home import get_app_dir
|
|
10
11
|
from scry_run.config import load_config, get_env_vars
|
|
11
12
|
from scry_run.packages import ensure_scry_run_installed
|
|
13
|
+
from scry_run.logging import get_logger
|
|
12
14
|
|
|
13
15
|
console = Console()
|
|
14
16
|
|
|
15
17
|
|
|
18
|
+
def load_app_settings(app_dir) -> dict:
|
|
19
|
+
"""Load per-app settings from settings.json."""
|
|
20
|
+
settings_file = app_dir / "settings.json"
|
|
21
|
+
if settings_file.exists():
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(settings_file.read_text())
|
|
24
|
+
except (json.JSONDecodeError, OSError):
|
|
25
|
+
return {}
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
|
|
16
29
|
@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
|
|
30
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress status messages (for TUI apps)")
|
|
17
31
|
@click.argument("app_name")
|
|
18
32
|
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
19
33
|
@click.pass_context
|
|
20
|
-
def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
34
|
+
def run(ctx, quiet: bool, app_name: str, args: tuple[str, ...]) -> None:
|
|
21
35
|
"""Run an scry-run app.
|
|
22
36
|
|
|
23
37
|
Loads config from ~/.scry-run/config.toml, converts to env vars,
|
|
@@ -28,6 +42,7 @@ def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
|
28
42
|
scry-run run todo-app
|
|
29
43
|
scry-run run todo-app add "Buy milk"
|
|
30
44
|
scry-run run todo-app --help
|
|
45
|
+
scry-run run --quiet my-tui-app
|
|
31
46
|
"""
|
|
32
47
|
# Find app
|
|
33
48
|
app_dir = get_app_dir(app_name)
|
|
@@ -41,6 +56,10 @@ def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
|
41
56
|
console.print(f" [cyan]scry-run init --name {app_name} --description '...'[/cyan]")
|
|
42
57
|
ctx.exit(1)
|
|
43
58
|
|
|
59
|
+
# Load per-app settings
|
|
60
|
+
app_settings = load_app_settings(app_dir)
|
|
61
|
+
default_flags = app_settings.get("default_flags", {})
|
|
62
|
+
|
|
44
63
|
# Load config and convert to env vars
|
|
45
64
|
config = load_config()
|
|
46
65
|
env_vars = get_env_vars(config)
|
|
@@ -49,6 +68,12 @@ def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
|
49
68
|
env = os.environ.copy()
|
|
50
69
|
env.update(env_vars)
|
|
51
70
|
|
|
71
|
+
# Set quiet mode if requested via flag OR app default
|
|
72
|
+
# CLI flag takes precedence over app settings
|
|
73
|
+
use_quiet = quiet or default_flags.get("quiet", False)
|
|
74
|
+
if use_quiet:
|
|
75
|
+
env["SCRY_QUIET"] = "1"
|
|
76
|
+
|
|
52
77
|
# Clear venv-related environment variables to prevent interference
|
|
53
78
|
# from an activated venv (uv run --directory will use the app's venv)
|
|
54
79
|
env.pop("VIRTUAL_ENV", None)
|
|
@@ -63,9 +88,20 @@ def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
|
63
88
|
# Ensure app has scry-run installed
|
|
64
89
|
ensure_scry_run_installed(app_dir)
|
|
65
90
|
|
|
91
|
+
logger = get_logger()
|
|
92
|
+
logger.set_app_context(app_dir)
|
|
93
|
+
logger.info(f"App starting: {app_name}")
|
|
94
|
+
|
|
66
95
|
# Build command
|
|
67
96
|
cmd = ["uv", "run", "--directory", str(app_dir), "python", str(app_py)] + list(args)
|
|
68
97
|
|
|
69
98
|
# Run app
|
|
70
|
-
|
|
71
|
-
|
|
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)
|
scry_run/config.py
CHANGED
|
@@ -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
|
scry_run/console.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Console output utilities for consistent styling."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
3
5
|
from datetime import datetime
|
|
4
6
|
|
|
5
7
|
from rich.console import Console
|
|
@@ -8,23 +10,54 @@ from rich.console import Console
|
|
|
8
10
|
err_console = Console(stderr=True, highlight=False)
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
def _is_quiet() -> bool:
|
|
14
|
+
"""Check if quiet mode is enabled (SCRY_QUIET=1)."""
|
|
15
|
+
return os.environ.get("SCRY_QUIET", "").lower() in ("1", "true", "yes", "on")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _set_title(msg: str) -> None:
|
|
19
|
+
"""Set terminal title bar. Used in quiet mode to show progress."""
|
|
20
|
+
# OSC escape sequence to set window title
|
|
21
|
+
# \033]0; sets both window title and icon name
|
|
22
|
+
# \007 is the bell character that terminates the sequence
|
|
23
|
+
sys.stderr.write(f"\033]0;[scry-run] {msg}\007")
|
|
24
|
+
sys.stderr.flush()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_START_TIME = datetime.now()
|
|
28
|
+
|
|
29
|
+
|
|
11
30
|
def _timestamp() -> str:
|
|
12
|
-
"""Return
|
|
13
|
-
|
|
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}"
|
|
14
38
|
|
|
15
39
|
|
|
16
40
|
def status(msg: str) -> None:
|
|
17
|
-
"""Print a dim status message."""
|
|
41
|
+
"""Print a dim status message. Uses title bar in quiet mode."""
|
|
42
|
+
if _is_quiet():
|
|
43
|
+
_set_title(msg)
|
|
44
|
+
return
|
|
18
45
|
err_console.print(f"[dim]{_timestamp()} \\[scry-run][/dim] {msg}")
|
|
19
46
|
|
|
20
47
|
|
|
21
48
|
def info(msg: str) -> None:
|
|
22
|
-
"""Print an info message (cyan)."""
|
|
49
|
+
"""Print an info message (cyan). Uses title bar in quiet mode."""
|
|
50
|
+
if _is_quiet():
|
|
51
|
+
_set_title(msg)
|
|
52
|
+
return
|
|
23
53
|
err_console.print(f"[dim]{_timestamp()}[/dim] [cyan]\\[scry-run][/cyan] {msg}")
|
|
24
54
|
|
|
25
55
|
|
|
26
56
|
def success(msg: str) -> None:
|
|
27
|
-
"""Print a success message (green)."""
|
|
57
|
+
"""Print a success message (green). Uses title bar in quiet mode."""
|
|
58
|
+
if _is_quiet():
|
|
59
|
+
_set_title(f"✓ {msg}")
|
|
60
|
+
return
|
|
28
61
|
err_console.print(f"[dim]{_timestamp()}[/dim] [green]\\[scry-run][/green] {msg}")
|
|
29
62
|
|
|
30
63
|
|
|
@@ -39,21 +72,33 @@ def error(msg: str) -> None:
|
|
|
39
72
|
|
|
40
73
|
|
|
41
74
|
def generating(class_name: str, attr_name: str) -> None:
|
|
42
|
-
"""Print a 'generating' message."""
|
|
75
|
+
"""Print a 'generating' message. Uses title bar in quiet mode."""
|
|
76
|
+
if _is_quiet():
|
|
77
|
+
_set_title(f"Generating {class_name}.{attr_name}...")
|
|
78
|
+
return
|
|
43
79
|
err_console.print(f"[dim]{_timestamp()}[/dim] [cyan]\\[scry-run][/cyan] Generating {class_name}.{attr_name}...")
|
|
44
80
|
|
|
45
81
|
|
|
46
82
|
def generated(class_name: str, attr_name: str) -> None:
|
|
47
|
-
"""Print a 'generated' success message."""
|
|
83
|
+
"""Print a 'generated' success message. Uses title bar in quiet mode."""
|
|
84
|
+
if _is_quiet():
|
|
85
|
+
_set_title(f"✓ Generated {class_name}.{attr_name}")
|
|
86
|
+
return
|
|
48
87
|
err_console.print(f"[dim]{_timestamp()}[/dim] [green]\\[scry-run][/green] Generated {class_name}.{attr_name} ✓")
|
|
49
88
|
|
|
50
89
|
|
|
51
90
|
def using_cached(class_name: str, attr_name: str) -> None:
|
|
52
|
-
"""Print a 'using cached' message."""
|
|
91
|
+
"""Print a 'using cached' message. Uses title bar in quiet mode."""
|
|
92
|
+
if _is_quiet():
|
|
93
|
+
_set_title(f"Using cached {class_name}.{attr_name}")
|
|
94
|
+
return
|
|
53
95
|
err_console.print(f"[dim]{_timestamp()} \\[scry-run][/dim] Using cached {class_name}.{attr_name}")
|
|
54
96
|
|
|
55
97
|
|
|
56
98
|
def backend_selected(backend_name: str, model: str | None, reason: str) -> None:
|
|
57
|
-
"""Print backend selection message."""
|
|
99
|
+
"""Print backend selection message. Uses title bar in quiet mode."""
|
|
100
|
+
if _is_quiet():
|
|
101
|
+
_set_title(f"Backend: {backend_name}")
|
|
102
|
+
return
|
|
58
103
|
model_str = f" (model={model})" if model else ""
|
|
59
104
|
err_console.print(f"[dim]{_timestamp()} \\[scry-run][/dim] Using backend: {backend_name}{model_str} ({reason})")
|
scry_run/logging.py
CHANGED
|
@@ -17,14 +17,14 @@ class ScryRunLogger:
|
|
|
17
17
|
Log file is created in:
|
|
18
18
|
1. The app's logs/ directory (if running in an app context)
|
|
19
19
|
2. Directory specified by SCRY_LOG_DIR
|
|
20
|
-
3.
|
|
20
|
+
3. ~/.scry-run/logs/ (fallback for CLI commands)
|
|
21
21
|
"""
|
|
22
|
-
|
|
22
|
+
|
|
23
23
|
_instance: Optional["ScryRunLogger"] = None
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
def __init__(self, log_dir: Optional[Path] = None):
|
|
26
26
|
"""Initialize logger.
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
Args:
|
|
29
29
|
log_dir: Directory for log file. If None, uses auto-detection.
|
|
30
30
|
"""
|
|
@@ -32,24 +32,27 @@ class ScryRunLogger:
|
|
|
32
32
|
self._log_file: Optional[Path] = None
|
|
33
33
|
# Session timestamp for unique log files per run
|
|
34
34
|
self._session_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
35
|
-
|
|
35
|
+
|
|
36
36
|
if log_dir:
|
|
37
37
|
self._log_dir = log_dir
|
|
38
38
|
else:
|
|
39
39
|
env_dir = os.environ.get("SCRY_LOG_DIR")
|
|
40
40
|
if env_dir:
|
|
41
41
|
self._log_dir = Path(env_dir)
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
# Debug logging is ON by default. Set SCRY_DEBUG=0 to disable.
|
|
44
44
|
debug_env = os.environ.get("SCRY_DEBUG", "").lower()
|
|
45
45
|
self._enabled = debug_env not in ("0", "false", "no", "off")
|
|
46
|
-
|
|
46
|
+
|
|
47
47
|
@property
|
|
48
48
|
def log_dir(self) -> Path:
|
|
49
|
-
"""Get log directory, with fallback to
|
|
49
|
+
"""Get log directory, with fallback to ~/.scry-run/logs/."""
|
|
50
50
|
if self._log_dir:
|
|
51
51
|
return self._log_dir
|
|
52
|
-
|
|
52
|
+
# Fallback to global scry-run logs directory (not cwd)
|
|
53
|
+
fallback = Path.home() / ".scry-run" / "logs"
|
|
54
|
+
fallback.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return fallback
|
|
53
56
|
|
|
54
57
|
@property
|
|
55
58
|
def log_file(self) -> Path:
|
scry_run/packages.py
CHANGED
|
@@ -60,6 +60,23 @@ def is_scry_run_installed(app_dir: Path) -> bool:
|
|
|
60
60
|
return result.returncode == 0
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def has_required_runtime_deps(app_dir: Path) -> bool:
|
|
64
|
+
"""Check if runtime dependencies for scry-run are installed in the app's venv."""
|
|
65
|
+
python_path = get_venv_python(app_dir)
|
|
66
|
+
if not python_path.exists():
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
check = (
|
|
70
|
+
"import scry_run\n"
|
|
71
|
+
"import watchfiles\n"
|
|
72
|
+
)
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
[str(python_path), "-c", check],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
)
|
|
77
|
+
return result.returncode == 0
|
|
78
|
+
|
|
79
|
+
|
|
63
80
|
def ensure_scry_run_installed(app_dir: Path) -> None:
|
|
64
81
|
"""Ensure scry-run is installed in the app's virtual environment.
|
|
65
82
|
|
|
@@ -71,7 +88,7 @@ def ensure_scry_run_installed(app_dir: Path) -> None:
|
|
|
71
88
|
"""
|
|
72
89
|
ensure_venv(app_dir)
|
|
73
90
|
|
|
74
|
-
if is_scry_run_installed(app_dir):
|
|
91
|
+
if is_scry_run_installed(app_dir) and has_required_runtime_deps(app_dir):
|
|
75
92
|
return
|
|
76
93
|
|
|
77
94
|
source_path = get_scry_run_source_path()
|
|
@@ -80,11 +97,11 @@ def ensure_scry_run_installed(app_dir: Path) -> None:
|
|
|
80
97
|
if source_path:
|
|
81
98
|
# Development mode - editable install from source
|
|
82
99
|
status(f"Installing scry-run from source ({source_path.name})...")
|
|
83
|
-
cmd = ["uv", "pip", "install", "--python", str(python_path), "-e", str(source_path)]
|
|
100
|
+
cmd = ["uv", "pip", "install", "--upgrade", "--python", str(python_path), "-e", str(source_path)]
|
|
84
101
|
else:
|
|
85
102
|
# Production mode - install from PyPI
|
|
86
103
|
status("Installing scry-run from PyPI...")
|
|
87
|
-
cmd = ["uv", "pip", "install", "--python", str(python_path), "scry-run"]
|
|
104
|
+
cmd = ["uv", "pip", "install", "--upgrade", "--python", str(python_path), "scry-run"]
|
|
88
105
|
|
|
89
106
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
90
107
|
if result.returncode != 0:
|
|
@@ -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
|
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
scry_run/__init__.py,sha256=
|
|
1
|
+
scry_run/__init__.py,sha256=T2IAIvDiA3Zz_qzDvQNwJrK2AdcwHhiJGW6Zz17PWAU,2875
|
|
2
2
|
scry_run/cache.py,sha256=gzWNCRva4d1KOqrYx0eo0T9o8lbsBYmcxgnXPUfc9Dg,14163
|
|
3
|
-
scry_run/config.py,sha256=
|
|
4
|
-
scry_run/console.py,sha256=
|
|
3
|
+
scry_run/config.py,sha256=TsbmoNJCA_bZxYVtlCRZ2IIavzoWgRvFNPYKtsrKxrY,3970
|
|
4
|
+
scry_run/console.py,sha256=Nk9rN5_dFb7QrCuDHEU0YrP5sfWnluwKQ30UtcRjV0E,3589
|
|
5
5
|
scry_run/context.py,sha256=os3kdrYxcgzrkPPiPzIy9Lrlp4b964eishDb7KSCaCY,10256
|
|
6
6
|
scry_run/generator.py,sha256=GjDGBgbes58L4Amo-tuXodfs7Tx2vGn_CO1YVdJMVjo,26422
|
|
7
7
|
scry_run/home.py,sha256=-ZOgvsaJ0udH_a0_A0YYBZHjtLzGMVaU_t5NqmcBNIQ,1364
|
|
8
|
-
scry_run/logging.py,sha256=
|
|
8
|
+
scry_run/logging.py,sha256=58xegPlh6nD7iE-UR-HtVC-UPqAqhseCkORhtRjHBYY,5838
|
|
9
9
|
scry_run/meta.py,sha256=shKzrDc-lUWIS8MOjouoVzPuOt3rP5zpWv5BvjIGWC4,71598
|
|
10
|
-
scry_run/packages.py,sha256=
|
|
10
|
+
scry_run/packages.py,sha256=obZythlwYOirrSYxS9bXGPP6hJOHxpcI_W3o3-Sizc4,5678
|
|
11
11
|
scry_run/backends/__init__.py,sha256=q52KwLy2KsJLxpUJKbLsCTH0ELLLQGOz_14OnhDYOig,245
|
|
12
12
|
scry_run/backends/base.py,sha256=bedYMpVL19dE_ei85wWRj8g5zsJsH607G8nAgsDpfwI,1693
|
|
13
13
|
scry_run/backends/claude.py,sha256=fqYK1F1PsqCM2-ssvmdduhwjrsVURcLeCTJLxTbAKPE,15038
|
|
14
14
|
scry_run/backends/frozen.py,sha256=69vqlztysFXuX7Nv5nu-bDqhan8F-0mHgGCC_4kOiQA,2912
|
|
15
15
|
scry_run/backends/registry.py,sha256=pAOrZcaWHJhIn6ai-O6lBzb0QeICa3MV-kH5zcDcRrM,2180
|
|
16
|
-
scry_run/cli/__init__.py,sha256=
|
|
16
|
+
scry_run/cli/__init__.py,sha256=GXpl__TEX099JhMCLDYN7odR3Xpw77vynKdJab3AcTY,5460
|
|
17
17
|
scry_run/cli/apps.py,sha256=AP016GpSH4_mPpXq3k8r4hjBE5Kizxk9mDQNZpdAt0c,12609
|
|
18
18
|
scry_run/cli/cache.py,sha256=UkxSFZUqiyDxn2KrFojh8UT03d_lBxJmolsV7w7qTmU,10774
|
|
19
19
|
scry_run/cli/config_cmd.py,sha256=Ge8Xw8R3_LpEfCOrXgaeD-0KG4WrVslMpNdZhtReOp8,2436
|
|
20
20
|
scry_run/cli/env.py,sha256=Jxw5I6TY5sJ0Rsha86OZDPRHDS850FA4n30sg4yFKhQ,745
|
|
21
|
-
scry_run/cli/init.py,sha256=
|
|
22
|
-
scry_run/cli/
|
|
23
|
-
scry_run
|
|
24
|
-
scry_run-0.
|
|
25
|
-
scry_run-0.
|
|
26
|
-
scry_run-0.
|
|
21
|
+
scry_run/cli/init.py,sha256=D8jijsY9kI0NAUln8uHaxb3eh2PlS7ao0XAm3AsR40E,16135
|
|
22
|
+
scry_run/cli/log.py,sha256=4zbOR-IPBYrBou2Vumrpx7acxM3LnFc7J9PKWoXVgZk,10614
|
|
23
|
+
scry_run/cli/run.py,sha256=JjgRl6_JCEc3LGEHYkvgRDuYaCTF4mQ95vlXWiwzjxs,3525
|
|
24
|
+
scry_run-0.2.0.dist-info/METADATA,sha256=WP2TVmNOzD7jn69Uui1xjbcQITnIvdWOQZN49AbXCFw,2767
|
|
25
|
+
scry_run-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
26
|
+
scry_run-0.2.0.dist-info/entry_points.txt,sha256=edV5nv1PT_pjWsOcLR3DMNkV-MUBsCY-jORR4dee-Ho,47
|
|
27
|
+
scry_run-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|