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 CHANGED
@@ -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",
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
- Return ONLY the expanded description text, no JSON or markdown code blocks.'''
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
- def expand_description(name: str, description: str) -> str | None:
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
- Expanded description, or None if expansion fails
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
- expanded = generator.generate_freeform(prompt)
76
- return expanded.strip()
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
- expanded = expand_description(name, description)
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
- expanded,
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
- # Let user confirm, edit, or reject
299
- console.print("[dim](y)es, (n)o, or (e)dit in $EDITOR[/dim]")
300
- choice = Prompt.ask(
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
- console.print("[dim]Using original description.[/dim]")
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
- result = subprocess.run(cmd, env=env)
71
- 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)
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 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
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 current time as HH:MM:SS."""
13
- 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}"
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. Current working directory (fallback)
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 cwd."""
49
+ """Get log directory, with fallback to ~/.scry-run/logs/."""
50
50
  if self._log_dir:
51
51
  return self._log_dir
52
- return Path.cwd()
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.1.1
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
 
@@ -1,26 +1,27 @@
1
- scry_run/__init__.py,sha256=uAFNmvmMzEpsFNJ3Uew4RUSgNPXvn_cFkTU2Bx8RmWo,2875
1
+ scry_run/__init__.py,sha256=T2IAIvDiA3Zz_qzDvQNwJrK2AdcwHhiJGW6Zz17PWAU,2875
2
2
  scry_run/cache.py,sha256=gzWNCRva4d1KOqrYx0eo0T9o8lbsBYmcxgnXPUfc9Dg,14163
3
- scry_run/config.py,sha256=zBnxbzHKwWDpg0C1BmSm6SGv5PF7rq_cSctWIsm_oz0,4083
4
- scry_run/console.py,sha256=1lVWZ-7tfbapl1wSYZ7zOWBwgKSn6LgR0QZYneILsgQ,2044
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=zKB_-IgRT9GXVULaFJKepX4oUVXQlt3buqjzBoh9Uyo,5684
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=U6HxWoEkFisKwLAeM_RtFvTt2qrCmxmZD38pJNVQt7g,5156
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=Hje8gmqzBsbX5iW6lKVfv5ZOXamkUVx-fGnwRupgmuU,5385
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=awBdgL0gILM5RJBKp2ZfuvkqmMdew_drAPRqq5QSrRw,13509
22
- scry_run/cli/run.py,sha256=vH89DttVCQwNWWwvUNxo9IbjnCGU2F_1OeWrMxHsvc8,2327
23
- scry_run-0.1.1.dist-info/METADATA,sha256=gZKL-lU4-KH79pCG0hFXnW7WsMtt7ovQ92s9INCvm_A,2651
24
- scry_run-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
25
- scry_run-0.1.1.dist-info/entry_points.txt,sha256=edV5nv1PT_pjWsOcLR3DMNkV-MUBsCY-jORR4dee-Ho,47
26
- scry_run-0.1.1.dist-info/RECORD,,
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,,