scry-run 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scry_run/cli/cache.py ADDED
@@ -0,0 +1,342 @@
1
+ """Cache management commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import click
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from scry_run.cache import ScryCache
11
+ from scry_run.home import get_app_dir, get_apps_dir
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ def cache() -> None:
18
+ """Manage the scry-run code cache."""
19
+ pass
20
+
21
+
22
+ @cache.command("list")
23
+ @click.argument("app_name", required=False)
24
+ @click.option("--keys", "-k", is_flag=True, help="Output keys only (Class.Attribute)")
25
+ def list_entries(app_name: Optional[str], keys: bool) -> None:
26
+ """List cached code entries.
27
+
28
+ Without app name, lists all apps and their entry counts.
29
+ With app name, lists entries for that specific app.
30
+ """
31
+ if app_name:
32
+ # List entries for specific app
33
+ app_dir = get_app_dir(app_name)
34
+ cache_file = app_dir / "cache.json"
35
+
36
+ if not cache_file.exists():
37
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
38
+ return
39
+
40
+ llm_cache = ScryCache(cache_file)
41
+ entries = llm_cache.list_entries()
42
+
43
+ if not entries:
44
+ if not keys:
45
+ console.print(f"[yellow]No cached entries for app '{app_name}'.[/yellow]")
46
+ return
47
+
48
+ if keys:
49
+ for entry in sorted(entries, key=lambda e: (e.class_name, e.attr_name)):
50
+ console.print(f"{entry.class_name}.{entry.attr_name}")
51
+ return
52
+
53
+ table = Table(title=f"Cached Code Entries ({app_name})")
54
+ table.add_column("Class", style="cyan")
55
+ table.add_column("Attribute", style="green")
56
+ table.add_column("Type", style="magenta")
57
+ table.add_column("Created", style="dim")
58
+ table.add_column("Description")
59
+
60
+ for entry in sorted(entries, key=lambda e: (e.class_name, e.attr_name)):
61
+ # Truncate long descriptions
62
+ desc = entry.docstring[:50] + "..." if len(entry.docstring) > 50 else entry.docstring
63
+
64
+ table.add_row(
65
+ entry.class_name,
66
+ entry.attr_name,
67
+ entry.code_type,
68
+ entry.created_at[:10], # Just the date
69
+ desc,
70
+ )
71
+
72
+ console.print(table)
73
+ console.print(f"\n[dim]Total: {len(entries)} entries[/dim]")
74
+ else:
75
+ # List all apps with entry counts
76
+ apps_dir = get_apps_dir()
77
+ if not apps_dir.exists():
78
+ console.print("[yellow]No apps found.[/yellow]")
79
+ return
80
+
81
+ app_paths = [p for p in apps_dir.iterdir() if p.is_dir()]
82
+ if not app_paths:
83
+ console.print("[yellow]No apps found.[/yellow]")
84
+ return
85
+
86
+ for app_path in sorted(app_paths):
87
+ cache_file = app_path / "cache.json"
88
+ if cache_file.exists():
89
+ llm_cache = ScryCache(cache_file)
90
+ entries = llm_cache.list_entries()
91
+ console.print(f"{app_path.name}: {len(entries)} entries")
92
+ else:
93
+ console.print(f"{app_path.name}: 0 entries")
94
+
95
+
96
+ @cache.command("show")
97
+ @click.argument("app_name")
98
+ @click.argument("key")
99
+ def show_entry(app_name: str, key: str) -> None:
100
+ """Show details of a specific cached entry.
101
+
102
+ KEY should be in format Class.method (e.g., MyApp.process_data).
103
+ """
104
+ app_dir = get_app_dir(app_name)
105
+ cache_file = app_dir / "cache.json"
106
+
107
+ if not cache_file.exists():
108
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
109
+ raise SystemExit(1)
110
+
111
+ # Parse key into class_name and attr_name
112
+ if "." not in key:
113
+ console.print(f"[red]Invalid key format:[/red] {key}")
114
+ console.print("[dim]Expected format: Class.method (e.g., MyApp.process_data)[/dim]")
115
+ raise SystemExit(1)
116
+
117
+ class_name, attr_name = key.split(".", 1)
118
+
119
+ llm_cache = ScryCache(cache_file)
120
+ entry = llm_cache.get(class_name, attr_name)
121
+
122
+ if not entry:
123
+ console.print(f"[red]Entry not found:[/red] {key} in app '{app_name}'")
124
+ raise SystemExit(1)
125
+
126
+ console.print(f"[bold cyan]{entry.class_name}[/bold cyan].[bold green]{entry.attr_name}[/bold green]")
127
+ console.print(f"[dim]Type:[/dim] {entry.code_type}")
128
+ console.print(f"[dim]Created:[/dim] {entry.created_at}")
129
+ console.print(f"[dim]Checksum:[/dim] {entry.checksum[:16]}...")
130
+ console.print()
131
+
132
+ if entry.docstring:
133
+ console.print(f"[bold]Description:[/bold] {entry.docstring}")
134
+ console.print()
135
+
136
+ if entry.dependencies:
137
+ console.print("[bold]Dependencies:[/bold]")
138
+ for dep in entry.dependencies:
139
+ console.print(f" {dep}")
140
+ console.print()
141
+
142
+ console.print("[bold]Code:[/bold]")
143
+ console.print("```python")
144
+ console.print(entry.code)
145
+ console.print("```")
146
+
147
+
148
+ @cache.command("export")
149
+ @click.argument("app_name")
150
+ @click.option(
151
+ "--output", "-o",
152
+ type=click.Path(dir_okay=False, path_type=Path),
153
+ help="Output file path (default: stdout as JSON)",
154
+ )
155
+ @click.option(
156
+ "--format", "-f",
157
+ type=click.Choice(["json", "python"]),
158
+ default="json",
159
+ help="Output format",
160
+ )
161
+ def export_cache(
162
+ app_name: str,
163
+ output: Optional[Path],
164
+ format: str,
165
+ ) -> None:
166
+ """Export cached code for an app."""
167
+ import json
168
+
169
+ app_dir = get_app_dir(app_name)
170
+ cache_file = app_dir / "cache.json"
171
+
172
+ if not cache_file.exists():
173
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
174
+ raise SystemExit(1)
175
+
176
+ llm_cache = ScryCache(cache_file)
177
+
178
+ if format == "python":
179
+ if not output:
180
+ console.print("[red]Error:[/red] --output required for Python format")
181
+ raise SystemExit(1)
182
+
183
+ llm_cache.export_to_file(output)
184
+ console.print(f"[green]Exported to:[/green] {output}")
185
+ else:
186
+ data = llm_cache.export()
187
+
188
+ if output:
189
+ with open(output, "w") as f:
190
+ json.dump(data, f, indent=2)
191
+ console.print(f"[green]Exported to:[/green] {output}")
192
+ else:
193
+ console.print_json(data=data)
194
+
195
+
196
+ @cache.command("prune")
197
+ @click.argument("app_name")
198
+ @click.option(
199
+ "--class", "class_name",
200
+ help="Only prune entries for this class",
201
+ )
202
+ @click.option(
203
+ "--attr", "attr_name",
204
+ help="Only prune this specific attribute (requires --class)",
205
+ )
206
+ @click.option(
207
+ "--all", "prune_all",
208
+ is_flag=True,
209
+ help="Prune all entries without confirmation",
210
+ )
211
+ def prune_cache(
212
+ app_name: str,
213
+ class_name: Optional[str],
214
+ attr_name: Optional[str],
215
+ prune_all: bool,
216
+ ) -> None:
217
+ """Remove cached entries for an app."""
218
+ from rich.prompt import Confirm
219
+
220
+ app_dir = get_app_dir(app_name)
221
+ cache_file = app_dir / "cache.json"
222
+
223
+ if not cache_file.exists():
224
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
225
+ raise SystemExit(1)
226
+
227
+ llm_cache = ScryCache(cache_file)
228
+
229
+ if attr_name and not class_name:
230
+ console.print("[red]Error:[/red] --attr requires --class")
231
+ raise SystemExit(1)
232
+
233
+ # Describe what will be pruned
234
+ if class_name and attr_name:
235
+ target = f"{class_name}.{attr_name}"
236
+ elif class_name:
237
+ target = f"all entries for {class_name}"
238
+ else:
239
+ target = "all cached entries"
240
+
241
+ # Confirm unless --all flag
242
+ if not prune_all:
243
+ entries = llm_cache.list_entries()
244
+ if class_name:
245
+ entries = [e for e in entries if e.class_name == class_name]
246
+ if attr_name:
247
+ entries = [e for e in entries if e.attr_name == attr_name]
248
+
249
+ if not entries:
250
+ console.print(f"[yellow]No matching entries found for app '{app_name}'.[/yellow]")
251
+ return
252
+
253
+ console.print(f"Will prune [bold]{len(entries)}[/bold] entries: {target}")
254
+
255
+ if not Confirm.ask("Continue?"):
256
+ console.print("[yellow]Aborted.[/yellow]")
257
+ return
258
+
259
+ # Prune
260
+ count = llm_cache.prune(class_name=class_name, attr_name=attr_name)
261
+
262
+ if count > 0:
263
+ console.print(f"[green]Pruned {count} entries.[/green]")
264
+ else:
265
+ console.print("[yellow]No entries to prune.[/yellow]")
266
+
267
+
268
+ @cache.command("rm")
269
+ @click.argument("app_name")
270
+ @click.argument("keys", nargs=-1)
271
+ def remove_entry(app_name: str, keys: tuple[str, ...]) -> None:
272
+ """Remove specific cache entries from an app.
273
+
274
+ KEY format:
275
+ - ClassName.method_name (remove specific method)
276
+ - ClassName (remove all entries for class)
277
+ """
278
+ if not keys:
279
+ console.print("[yellow]No keys provided. usage: scry-run cache rm <app-name> Class.method[/yellow]")
280
+ return
281
+
282
+ app_dir = get_app_dir(app_name)
283
+ cache_file = app_dir / "cache.json"
284
+
285
+ if not cache_file.exists():
286
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
287
+ raise SystemExit(1)
288
+
289
+ llm_cache = ScryCache(cache_file)
290
+ total_pruned = 0
291
+
292
+ for key in keys:
293
+ if "." in key:
294
+ # Remove specific attribute
295
+ class_name, attr_name = key.split(".", 1)
296
+ if llm_cache.delete(class_name, attr_name):
297
+ console.print(f"[green]Removed[/green] {class_name}.{attr_name}")
298
+ total_pruned += 1
299
+ else:
300
+ console.print(f"[red]Not found:[/red] {class_name}.{attr_name}")
301
+ else:
302
+ # Remove entire class
303
+ class_name = key
304
+ pruned = llm_cache.prune(class_name=class_name)
305
+ if pruned > 0:
306
+ console.print(f"[green]Removed[/green] {class_name} ({pruned} entries)")
307
+ total_pruned += pruned
308
+ else:
309
+ console.print(f"[red]Not found:[/red] {class_name}")
310
+
311
+ if total_pruned > 0:
312
+ console.print(f"\n[bold green]Total removed: {total_pruned}[/bold green]")
313
+
314
+
315
+ @cache.command("reset")
316
+ @click.argument("app_name")
317
+ @click.option(
318
+ "--force", "-f",
319
+ is_flag=True,
320
+ help="Force reset without confirmation",
321
+ )
322
+ def reset_cache(app_name: str, force: bool) -> None:
323
+ """Reset the entire cache for an app (delete all entries)."""
324
+ from rich.prompt import Confirm
325
+
326
+ app_dir = get_app_dir(app_name)
327
+ cache_file = app_dir / "cache.json"
328
+
329
+ if not cache_file.exists():
330
+ console.print(f"[red]App '{app_name}' not found or has no cache.[/red]")
331
+ raise SystemExit(1)
332
+
333
+ if not force:
334
+ console.print(f"[bold red]WARNING:[/bold red] This will delete ALL cached code for app '{app_name}'.")
335
+ if not Confirm.ask("Are you sure you want to reset the cache?"):
336
+ console.print("[yellow]Aborted.[/yellow]")
337
+ return
338
+
339
+ llm_cache = ScryCache(cache_file)
340
+ count = llm_cache.prune()
341
+
342
+ console.print(f"[bold green]Cache reset complete. Removed {count} entries.[/bold green]")
@@ -0,0 +1,84 @@
1
+ """Config command for scry-run."""
2
+
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from scry_run.home import get_config_path
13
+ from scry_run.config import DEFAULT_CONFIG
14
+
15
+ console = Console()
16
+
17
+
18
+ def get_editor() -> list[str] | None:
19
+ """Get the editor command to use, with sensible fallbacks.
20
+
21
+ Priority:
22
+ 1. VISUAL env var
23
+ 2. EDITOR env var
24
+ 3. First available: nano, vim, vi, notepad (Windows)
25
+
26
+ Returns:
27
+ List of command parts (e.g., ["code", "--wait"]) or None
28
+ """
29
+ # Check env vars first (may contain args like "code --wait")
30
+ if editor := os.environ.get("VISUAL"):
31
+ return shlex.split(editor)
32
+ if editor := os.environ.get("EDITOR"):
33
+ return shlex.split(editor)
34
+
35
+ # Sensible fallbacks
36
+ fallbacks = ["nano", "vim", "vi"]
37
+ if sys.platform == "win32":
38
+ fallbacks = ["notepad", "code", "vim"]
39
+
40
+ for editor in fallbacks:
41
+ if shutil.which(editor):
42
+ return [editor]
43
+
44
+ return None
45
+
46
+
47
+ @click.command("config")
48
+ def config_cmd() -> None:
49
+ """Open the scry-run config file in your editor.
50
+
51
+ Creates a default config file if it doesn't exist.
52
+
53
+ Editor selection (in order):
54
+ 1. $VISUAL
55
+ 2. $EDITOR
56
+ 3. nano, vim, vi (or notepad on Windows)
57
+ """
58
+ config_path = get_config_path()
59
+
60
+ # Create config file with defaults if it doesn't exist
61
+ if not config_path.exists():
62
+ config_path.parent.mkdir(parents=True, exist_ok=True)
63
+ config_path.write_text(DEFAULT_CONFIG)
64
+ console.print(f"[green]Created config file:[/green] {config_path}")
65
+
66
+ # Get editor
67
+ editor_cmd = get_editor()
68
+ if not editor_cmd:
69
+ console.print("[red]Error:[/red] No editor found.")
70
+ console.print("Set VISUAL or EDITOR environment variable, or install nano/vim.")
71
+ raise SystemExit(1)
72
+
73
+ editor_display = " ".join(editor_cmd)
74
+ console.print(f"[dim]Opening {config_path} with {editor_display}...[/dim]")
75
+
76
+ # Open editor (append file path to command)
77
+ try:
78
+ subprocess.run(editor_cmd + [str(config_path)], check=True)
79
+ except subprocess.CalledProcessError as e:
80
+ console.print(f"[red]Error:[/red] Editor exited with code {e.returncode}")
81
+ raise SystemExit(e.returncode)
82
+ except FileNotFoundError:
83
+ console.print(f"[red]Error:[/red] Editor '{editor_cmd[0]}' not found")
84
+ raise SystemExit(1)
scry_run/cli/env.py ADDED
@@ -0,0 +1,27 @@
1
+ """Env command for displaying configuration as environment variables."""
2
+
3
+ import click
4
+
5
+ from scry_run.config import load_config, get_env_vars
6
+
7
+
8
+ @click.command()
9
+ def env() -> None:
10
+ """Display configuration as shell environment variables.
11
+
12
+ Outputs export statements that can be eval'd in your shell:
13
+
14
+ eval "$(scry-run env)"
15
+
16
+ This allows running apps directly without scry-run run:
17
+
18
+ eval "$(scry-run env)"
19
+ ~/.scry-run/apps/todo-app/app.py add "Buy milk"
20
+ """
21
+ config = load_config()
22
+ env_vars = get_env_vars(config)
23
+
24
+ # Output as shell export statements
25
+ for key, value in sorted(env_vars.items()):
26
+ # Quote values to handle special characters
27
+ click.echo(f'export {key}="{value}"')