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/__init__.py +102 -0
- scry_run/backends/__init__.py +6 -0
- scry_run/backends/base.py +65 -0
- scry_run/backends/claude.py +404 -0
- scry_run/backends/frozen.py +85 -0
- scry_run/backends/registry.py +72 -0
- scry_run/cache.py +441 -0
- scry_run/cli/__init__.py +137 -0
- scry_run/cli/apps.py +396 -0
- scry_run/cli/cache.py +342 -0
- scry_run/cli/config_cmd.py +84 -0
- scry_run/cli/env.py +27 -0
- scry_run/cli/init.py +375 -0
- scry_run/cli/run.py +71 -0
- scry_run/config.py +141 -0
- scry_run/console.py +52 -0
- scry_run/context.py +298 -0
- scry_run/generator.py +698 -0
- scry_run/home.py +60 -0
- scry_run/logging.py +171 -0
- scry_run/meta.py +1852 -0
- scry_run/packages.py +175 -0
- scry_run-0.1.0.dist-info/METADATA +282 -0
- scry_run-0.1.0.dist-info/RECORD +26 -0
- scry_run-0.1.0.dist-info/WHEEL +4 -0
- scry_run-0.1.0.dist-info/entry_points.txt +2 -0
scry_run/cli/init.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Init command for creating new scry-run projects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Prompt, Confirm
|
|
10
|
+
|
|
11
|
+
from scry_run.home import ensure_home_exists, get_app_dir
|
|
12
|
+
from scry_run.packages import ensure_scry_run_installed
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
EXPAND_PROMPT = '''You are helping design a CLI application. The user provided a brief description, and you need to expand it into a clear, detailed description that will guide code generation.
|
|
18
|
+
|
|
19
|
+
App name: {name}
|
|
20
|
+
User's description: {description}
|
|
21
|
+
|
|
22
|
+
Expand this into a clear app description that includes:
|
|
23
|
+
|
|
24
|
+
1. **Core Purpose** (1-2 sentences) - What the app does at its heart
|
|
25
|
+
|
|
26
|
+
2. **Key Features** (2-4 bullet points) - Features that directly support the core purpose:
|
|
27
|
+
- Focus on what the user actually asked for
|
|
28
|
+
- Only add features that are ESSENTIAL to the core purpose
|
|
29
|
+
- Do NOT invent extra features the user didn't mention
|
|
30
|
+
- Keep it minimal and focused
|
|
31
|
+
|
|
32
|
+
3. **Usage Examples** (2-3 examples) - Show concrete CLI invocations like:
|
|
33
|
+
```
|
|
34
|
+
{name} <command> [args]
|
|
35
|
+
```
|
|
36
|
+
Keep examples simple and directly relevant to the core purpose.
|
|
37
|
+
|
|
38
|
+
IMPORTANT GUIDELINES:
|
|
39
|
+
- STICK TO WHAT THE USER ASKED FOR - don't add unrelated features
|
|
40
|
+
- Less is more - a focused app is better than a bloated one
|
|
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!)
|
|
43
|
+
|
|
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
|
+
- Use standard CLI patterns: commands, subcommands, flags, and positional arguments
|
|
46
|
+
- The vocabulary of commands/arguments is open-ended, but the SYNTAX is structured
|
|
47
|
+
- NOT a natural language interface - avoid examples like "how do I..." or question-form inputs
|
|
48
|
+
- Example for "hello" app:
|
|
49
|
+
- GOOD: `hello korean`, `hello --lang=french --formal`, `hello random`
|
|
50
|
+
- BAD: `hello how do you say goodbye in korean?` (natural language query)
|
|
51
|
+
- Example for "todo" app:
|
|
52
|
+
- GOOD: `todo add "buy milk"`, `todo list --due=today`, `todo done 3`
|
|
53
|
+
- BAD: `todo what's due today?` (question form)
|
|
54
|
+
|
|
55
|
+
Return ONLY the expanded description text, no JSON or markdown code blocks.'''
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expand_description(name: str, description: str) -> str | None:
|
|
59
|
+
"""Use LLM to expand a brief description into a detailed one.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name: App name
|
|
63
|
+
description: User's brief description
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Expanded description, or None if expansion fails
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
from scry_run.generator import CodeGenerator, ScryRunError
|
|
70
|
+
|
|
71
|
+
generator = CodeGenerator()
|
|
72
|
+
prompt = EXPAND_PROMPT.format(name=name, description=description)
|
|
73
|
+
|
|
74
|
+
# Use generate_freeform for text generation
|
|
75
|
+
expanded = generator.generate_freeform(prompt)
|
|
76
|
+
return expanded.strip()
|
|
77
|
+
except ScryRunError as e:
|
|
78
|
+
console.print(f"[yellow]Warning:[/yellow] Could not expand description: {e.message}")
|
|
79
|
+
return None
|
|
80
|
+
except Exception as e:
|
|
81
|
+
console.print(f"[yellow]Warning:[/yellow] Could not expand description: {e}")
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
MAIN_TEMPLATE = '''#!/usr/bin/env python3
|
|
86
|
+
"""
|
|
87
|
+
{description}
|
|
88
|
+
|
|
89
|
+
Generated by scry-run. Run with: scry-run run {name}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
# =============================================================================
|
|
95
|
+
# scry-run: LLM-POWERED DYNAMIC CODE GENERATION
|
|
96
|
+
# =============================================================================
|
|
97
|
+
#
|
|
98
|
+
# This module uses automatic code generation. ANY METHOD CALLED WILL BE
|
|
99
|
+
# AUTO-GENERATED if it doesn't exist. Use this power!
|
|
100
|
+
#
|
|
101
|
+
# =============================================================================
|
|
102
|
+
# WHAT GETS AUTO-GENERATED
|
|
103
|
+
# =============================================================================
|
|
104
|
+
#
|
|
105
|
+
# 1. CONSTRUCTOR (__init__):
|
|
106
|
+
# - If you create an object with arguments, __init__ gets generated!
|
|
107
|
+
# - Example: app = MyClass("config.json", debug=True)
|
|
108
|
+
# - The LLM sees the args and generates appropriate initialization
|
|
109
|
+
#
|
|
110
|
+
# 2. INSTANCE METHODS (self.xxx):
|
|
111
|
+
# - Any method you call on an instance gets generated
|
|
112
|
+
# - Including _private_helpers - they work too!
|
|
113
|
+
#
|
|
114
|
+
# 3. CLASS METHODS (MyClass.xxx):
|
|
115
|
+
# - Class-level method calls also trigger generation
|
|
116
|
+
#
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# CORE PRINCIPLES - READ CAREFULLY!
|
|
119
|
+
# =============================================================================
|
|
120
|
+
#
|
|
121
|
+
# 1. KEEP FUNCTIONS SHORT (5-15 lines max!)
|
|
122
|
+
# - Each function should do ONE thing
|
|
123
|
+
# - Long functions are WRONG - break them up
|
|
124
|
+
# - Call helper methods freely - they'll be generated!
|
|
125
|
+
#
|
|
126
|
+
# 2. USE DYNAMIC DISPATCH instead of conditionals/maps:
|
|
127
|
+
# BAD: if lang == "french": return "Bonjour"
|
|
128
|
+
# elif lang == "spanish": return "Hola"
|
|
129
|
+
# GOOD: return self._greet_in_{{lang}}() # Dynamic method call!
|
|
130
|
+
#
|
|
131
|
+
# BAD: handlers = {{"add": self.add, "remove": self.remove}}
|
|
132
|
+
# GOOD: return getattr(self, f"_handle_{{command}}")(*args)
|
|
133
|
+
#
|
|
134
|
+
# 3. DELEGATE EVERYTHING:
|
|
135
|
+
# - Don't implement logic inline - call a method for it
|
|
136
|
+
# - self._parse_args(args) instead of inline parsing
|
|
137
|
+
# - self._format_output(result) instead of inline formatting
|
|
138
|
+
# - self._validate_input(x) instead of inline checks
|
|
139
|
+
#
|
|
140
|
+
# 4. USE METAPROGRAMMING PATTERNS:
|
|
141
|
+
# - getattr(self, f"_do_{{action}}")() for dynamic dispatch
|
|
142
|
+
# - self._handle_{{category}}_{{item}}() for 2-level dispatch
|
|
143
|
+
# - Call methods that don't exist yet - they WILL be generated!
|
|
144
|
+
#
|
|
145
|
+
# 5. PREFER RECURSION & COMPOSITION:
|
|
146
|
+
# - Process items one at a time via self._process_single(item)
|
|
147
|
+
# - Compose results: self._combine(self._step1(), self._step2())
|
|
148
|
+
# - Chain transformations: self._step3(self._step2(self._step1(x)))
|
|
149
|
+
#
|
|
150
|
+
# 6. NEVER HARDCODE LISTS/MAPS:
|
|
151
|
+
# BAD: LANGUAGES = ["english", "french", "spanish"]
|
|
152
|
+
# GOOD: Call self._get_supported_languages() - generated dynamically!
|
|
153
|
+
#
|
|
154
|
+
# BAD: COMMANDS = {{"help": ..., "run": ...}}
|
|
155
|
+
# GOOD: Check hasattr(self, f"_cmd_{{name}}") or just call it!
|
|
156
|
+
#
|
|
157
|
+
# 7. ERROR HANDLING VIA METHODS:
|
|
158
|
+
# - self._handle_error(e) instead of inline handling
|
|
159
|
+
# - self._fallback_for_{{operation}}() for graceful degradation
|
|
160
|
+
#
|
|
161
|
+
# 8. HANDLING CLI ARGUMENTS & FLEXIBILITY:
|
|
162
|
+
# - DESIGN FOR UNKNOWN FLAGS: The user might add new flags!
|
|
163
|
+
# - Use argparse.ArgumentParser().parse_known_args()
|
|
164
|
+
# - Return tuple (known_args, unknown_args) to preserve unknown flags
|
|
165
|
+
# - FLEXIBLE SCOPE: If a flag/command is outside the description scope,
|
|
166
|
+
# or involves "unseen-as-of-yet" flags, HANDLE IT FLEXIBLY.
|
|
167
|
+
# - INFER INTENT: Use the flag name to guess the desired feature.
|
|
168
|
+
#
|
|
169
|
+
# =============================================================================
|
|
170
|
+
# EXAMPLE PATTERNS
|
|
171
|
+
# =============================================================================
|
|
172
|
+
#
|
|
173
|
+
# Creating MULTIPLE CLASSES dynamically:
|
|
174
|
+
# from scry_run import llm_create
|
|
175
|
+
# TodoItem = llm_create("TodoItem", "A single todo with title and done status")
|
|
176
|
+
# Database = llm_create("Database", "SQLite database wrapper")
|
|
177
|
+
#
|
|
178
|
+
# item = TodoItem("Buy milk")
|
|
179
|
+
# item.mark_done() # auto-generated!
|
|
180
|
+
#
|
|
181
|
+
# Constructor with args (auto-generated __init__):
|
|
182
|
+
# app = TodoList("my_tasks.db") # __init__ generated with db_path param!
|
|
183
|
+
#
|
|
184
|
+
# Dynamic language handler:
|
|
185
|
+
# def greet(self, lang): return getattr(self, f"_greet_{{lang}}")()
|
|
186
|
+
#
|
|
187
|
+
# Command dispatcher:
|
|
188
|
+
# def run(self, cmd, *args): return getattr(self, f"_cmd_{{cmd}}")(*args)
|
|
189
|
+
#
|
|
190
|
+
# Recursive processing:
|
|
191
|
+
# def process(self, items):
|
|
192
|
+
# if not items: return []
|
|
193
|
+
# return [self._process_one(items[0])] + self.process(items[1:])
|
|
194
|
+
#
|
|
195
|
+
# Chained transformation:
|
|
196
|
+
# def transform(self, x):
|
|
197
|
+
# return self._finalize(self._enhance(self._prepare(x)))
|
|
198
|
+
#
|
|
199
|
+
# =============================================================================
|
|
200
|
+
from scry_run import ScryClass, scry_create
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class {class_name}(ScryClass):
|
|
204
|
+
"""{description}
|
|
205
|
+
|
|
206
|
+
Methods are auto-generated. Keep them SHORT and MODULAR!
|
|
207
|
+
Use dynamic dispatch: getattr(self, f"_handle_{{x}}")()
|
|
208
|
+
|
|
209
|
+
To create additional classes, use llm_create:
|
|
210
|
+
OtherClass = llm_create("OtherClass", "Description here")
|
|
211
|
+
"""
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
app = {class_name}()
|
|
217
|
+
result = app.main(sys.argv[1:])
|
|
218
|
+
if result is not None:
|
|
219
|
+
print(result)
|
|
220
|
+
'''
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def to_class_name(name: str) -> str:
|
|
224
|
+
"""Convert a project name to a valid Python class name."""
|
|
225
|
+
# Remove special characters and convert to PascalCase
|
|
226
|
+
parts = name.replace("-", "_").replace(" ", "_").split("_")
|
|
227
|
+
return "".join(part.capitalize() for part in parts if part)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@click.command()
|
|
231
|
+
@click.option(
|
|
232
|
+
"--name", "-n",
|
|
233
|
+
help="App name (used for the directory)",
|
|
234
|
+
)
|
|
235
|
+
@click.option(
|
|
236
|
+
"--description", "-d",
|
|
237
|
+
help="App description",
|
|
238
|
+
)
|
|
239
|
+
@click.option(
|
|
240
|
+
"--auto-expand/--no-auto-expand",
|
|
241
|
+
default=True,
|
|
242
|
+
help="Automatically expand description with features and examples (default: enabled)",
|
|
243
|
+
)
|
|
244
|
+
def init(
|
|
245
|
+
name: str | None,
|
|
246
|
+
description: str | None,
|
|
247
|
+
auto_expand: bool,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Initialize a new scry-run app.
|
|
250
|
+
|
|
251
|
+
Creates an app in ~/.scry-run/apps/<name>/ with:
|
|
252
|
+
- app.py: The main executable
|
|
253
|
+
- cache.json: Empty cache file
|
|
254
|
+
- logs/: Directory for log files
|
|
255
|
+
|
|
256
|
+
By default, your brief description is expanded using AI to include
|
|
257
|
+
features and usage examples. Use --no-auto-expand to disable.
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
|
|
261
|
+
# Interactive mode (recommended)
|
|
262
|
+
scry-run init
|
|
263
|
+
|
|
264
|
+
# Non-interactive with auto-expand
|
|
265
|
+
scry-run init --name=todoist --description='todo list app'
|
|
266
|
+
|
|
267
|
+
# Skip auto-expand
|
|
268
|
+
scry-run init --name=myapp --description='my app' --no-auto-expand
|
|
269
|
+
"""
|
|
270
|
+
# Interactive mode if options not provided
|
|
271
|
+
if not name:
|
|
272
|
+
console.print("[bold blue]scry-run App Setup[/bold blue]\n")
|
|
273
|
+
name = Prompt.ask("App name", default="myapp")
|
|
274
|
+
|
|
275
|
+
if not description:
|
|
276
|
+
description = Prompt.ask(
|
|
277
|
+
"App description (brief is fine - will be expanded)",
|
|
278
|
+
default=f"A {name} application"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Auto-expand description using LLM
|
|
282
|
+
if auto_expand:
|
|
283
|
+
console.print()
|
|
284
|
+
console.print("[dim]Expanding description with AI...[/dim]")
|
|
285
|
+
expanded = expand_description(name, description)
|
|
286
|
+
|
|
287
|
+
if expanded:
|
|
288
|
+
# Show the expanded description
|
|
289
|
+
console.print()
|
|
290
|
+
panel = Panel(
|
|
291
|
+
expanded,
|
|
292
|
+
title="[bold]Expanded Description[/bold]",
|
|
293
|
+
border_style="blue",
|
|
294
|
+
)
|
|
295
|
+
console.print(panel)
|
|
296
|
+
console.print()
|
|
297
|
+
|
|
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]")
|
|
315
|
+
else:
|
|
316
|
+
console.print("[dim]Using original description.[/dim]")
|
|
317
|
+
else:
|
|
318
|
+
console.print("[dim]Using original description.[/dim]")
|
|
319
|
+
|
|
320
|
+
# Validate name
|
|
321
|
+
if not name.replace("_", "").replace("-", "").isalnum():
|
|
322
|
+
console.print("[red]Error:[/red] App name must be alphanumeric (with - or _)")
|
|
323
|
+
raise SystemExit(1)
|
|
324
|
+
|
|
325
|
+
# Ensure home directory exists
|
|
326
|
+
ensure_home_exists()
|
|
327
|
+
|
|
328
|
+
# Get app directory
|
|
329
|
+
app_dir = get_app_dir(name)
|
|
330
|
+
|
|
331
|
+
# Check for existing app
|
|
332
|
+
if app_dir.exists():
|
|
333
|
+
if not Confirm.ask(f"[yellow]App '{name}' already exists[/yellow]. Overwrite?"):
|
|
334
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
335
|
+
raise SystemExit(0)
|
|
336
|
+
|
|
337
|
+
# Create app directory
|
|
338
|
+
app_dir.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
|
|
340
|
+
# Create virtual environment and install scry-run
|
|
341
|
+
ensure_scry_run_installed(app_dir)
|
|
342
|
+
|
|
343
|
+
class_name = to_class_name(name)
|
|
344
|
+
|
|
345
|
+
# Create app.py
|
|
346
|
+
app_file = app_dir / "app.py"
|
|
347
|
+
app_content = MAIN_TEMPLATE.format(
|
|
348
|
+
name=name,
|
|
349
|
+
description=description,
|
|
350
|
+
class_name=class_name,
|
|
351
|
+
)
|
|
352
|
+
app_file.write_text(app_content)
|
|
353
|
+
app_file.chmod(app_file.stat().st_mode | 0o111) # Make executable
|
|
354
|
+
|
|
355
|
+
# Create empty cache.json
|
|
356
|
+
cache_file = app_dir / "cache.json"
|
|
357
|
+
cache_file.write_text(json.dumps({}))
|
|
358
|
+
|
|
359
|
+
# Create logs directory
|
|
360
|
+
logs_dir = app_dir / "logs"
|
|
361
|
+
logs_dir.mkdir(exist_ok=True)
|
|
362
|
+
|
|
363
|
+
console.print()
|
|
364
|
+
console.print("[bold green]App created successfully![/bold green]")
|
|
365
|
+
console.print()
|
|
366
|
+
console.print(f" [dim]Location:[/dim] {app_dir}")
|
|
367
|
+
console.print(f" [dim]Main file:[/dim] {app_file}")
|
|
368
|
+
console.print()
|
|
369
|
+
console.print("[bold]Useful commands:[/bold]")
|
|
370
|
+
console.print(f" [cyan]scry-run run {name}[/cyan] Run your app")
|
|
371
|
+
console.print(f" [cyan]scry-run info {name}[/cyan] View app details and cache stats")
|
|
372
|
+
console.print(f" [cyan]scry-run cache list {name}[/cyan] List generated methods")
|
|
373
|
+
console.print(f" [cyan]scry-run bake {name}[/cyan] Export as standalone package")
|
|
374
|
+
console.print()
|
|
375
|
+
console.print("[dim]Methods are generated on first use using Claude CLI.[/dim]")
|
scry_run/cli/run.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Run command for executing scry-run apps."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from scry_run.home import get_app_dir
|
|
10
|
+
from scry_run.config import load_config, get_env_vars
|
|
11
|
+
from scry_run.packages import ensure_scry_run_installed
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
|
|
17
|
+
@click.argument("app_name")
|
|
18
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def run(ctx, app_name: str, args: tuple[str, ...]) -> None:
|
|
21
|
+
"""Run an scry-run app.
|
|
22
|
+
|
|
23
|
+
Loads config from ~/.scry-run/config.toml, converts to env vars,
|
|
24
|
+
and executes the app with the provided arguments.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
|
|
28
|
+
scry-run run todo-app
|
|
29
|
+
scry-run run todo-app add "Buy milk"
|
|
30
|
+
scry-run run todo-app --help
|
|
31
|
+
"""
|
|
32
|
+
# Find app
|
|
33
|
+
app_dir = get_app_dir(app_name)
|
|
34
|
+
app_py = app_dir / "app.py"
|
|
35
|
+
|
|
36
|
+
if not app_py.exists():
|
|
37
|
+
console.print(f"[red]Error:[/red] App '{app_name}' not found.")
|
|
38
|
+
console.print(f"[dim]Expected at: {app_py}[/dim]")
|
|
39
|
+
console.print()
|
|
40
|
+
console.print("Create it with:")
|
|
41
|
+
console.print(f" [cyan]scry-run init --name {app_name} --description '...'[/cyan]")
|
|
42
|
+
ctx.exit(1)
|
|
43
|
+
|
|
44
|
+
# Load config and convert to env vars
|
|
45
|
+
config = load_config()
|
|
46
|
+
env_vars = get_env_vars(config)
|
|
47
|
+
|
|
48
|
+
# Merge with current environment (env_vars already respects existing env vars)
|
|
49
|
+
env = os.environ.copy()
|
|
50
|
+
env.update(env_vars)
|
|
51
|
+
|
|
52
|
+
# Clear venv-related environment variables to prevent interference
|
|
53
|
+
# from an activated venv (uv run --directory will use the app's venv)
|
|
54
|
+
env.pop("VIRTUAL_ENV", None)
|
|
55
|
+
env.pop("PYTHONHOME", None)
|
|
56
|
+
# Remove activated venv's bin dir from PATH if present
|
|
57
|
+
if "VIRTUAL_ENV" in os.environ:
|
|
58
|
+
venv_bin = os.path.join(os.environ["VIRTUAL_ENV"], "bin")
|
|
59
|
+
path_parts = env.get("PATH", "").split(os.pathsep)
|
|
60
|
+
path_parts = [p for p in path_parts if p != venv_bin]
|
|
61
|
+
env["PATH"] = os.pathsep.join(path_parts)
|
|
62
|
+
|
|
63
|
+
# Ensure app has scry-run installed
|
|
64
|
+
ensure_scry_run_installed(app_dir)
|
|
65
|
+
|
|
66
|
+
# Build command
|
|
67
|
+
cmd = ["uv", "run", "--directory", str(app_dir), "python", str(app_py)] + list(args)
|
|
68
|
+
|
|
69
|
+
# Run app
|
|
70
|
+
result = subprocess.run(cmd, env=env)
|
|
71
|
+
ctx.exit(result.returncode)
|
scry_run/config.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Configuration management for scry-run.
|
|
2
|
+
|
|
3
|
+
Loads config from ~/.scry-run/config.toml and converts to env vars.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ImportError:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
from scry_run.home import get_config_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG = """\
|
|
20
|
+
# scry-run configuration
|
|
21
|
+
# https://github.com/scry-run/scry-run
|
|
22
|
+
|
|
23
|
+
[defaults]
|
|
24
|
+
backend = "auto" # auto, claude, frozen
|
|
25
|
+
quiet = false # Suppress generation messages
|
|
26
|
+
full_context = true # Use full codebase context
|
|
27
|
+
|
|
28
|
+
[logging]
|
|
29
|
+
level = "info" # debug, info, warn, error
|
|
30
|
+
|
|
31
|
+
[backend.claude]
|
|
32
|
+
# model = "opus" # sonnet, opus, haiku (or full model ID)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Config:
|
|
38
|
+
"""Configuration for scry-run."""
|
|
39
|
+
|
|
40
|
+
# Defaults
|
|
41
|
+
backend: str = "auto"
|
|
42
|
+
quiet: bool = False
|
|
43
|
+
full_context: bool = True
|
|
44
|
+
|
|
45
|
+
# Logging
|
|
46
|
+
log_level: str = "info"
|
|
47
|
+
|
|
48
|
+
# Backend: claude
|
|
49
|
+
claude_model: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config() -> Config:
|
|
53
|
+
"""Load configuration from file and environment.
|
|
54
|
+
|
|
55
|
+
Priority (highest to lowest):
|
|
56
|
+
1. Environment variables
|
|
57
|
+
2. Config file (~/.scry-run/config.toml)
|
|
58
|
+
3. Default values
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Config object with merged settings
|
|
62
|
+
"""
|
|
63
|
+
config_path = get_config_path()
|
|
64
|
+
|
|
65
|
+
# Start with defaults
|
|
66
|
+
kwargs = {}
|
|
67
|
+
|
|
68
|
+
# Load from file if exists
|
|
69
|
+
if config_path.exists():
|
|
70
|
+
with open(config_path, "rb") as f:
|
|
71
|
+
data = tomllib.load(f)
|
|
72
|
+
|
|
73
|
+
# Parse [defaults] section
|
|
74
|
+
defaults = data.get("defaults", {})
|
|
75
|
+
if "backend" in defaults:
|
|
76
|
+
kwargs["backend"] = defaults["backend"]
|
|
77
|
+
if "quiet" in defaults:
|
|
78
|
+
kwargs["quiet"] = defaults["quiet"]
|
|
79
|
+
if "full_context" in defaults:
|
|
80
|
+
kwargs["full_context"] = defaults["full_context"]
|
|
81
|
+
|
|
82
|
+
# Parse [logging] section
|
|
83
|
+
logging = data.get("logging", {})
|
|
84
|
+
if "level" in logging:
|
|
85
|
+
kwargs["log_level"] = logging["level"]
|
|
86
|
+
|
|
87
|
+
# Parse backend sections
|
|
88
|
+
backends = data.get("backend", {})
|
|
89
|
+
|
|
90
|
+
# [backend.claude]
|
|
91
|
+
claude = backends.get("claude", {})
|
|
92
|
+
if "model" in claude:
|
|
93
|
+
kwargs["claude_model"] = claude["model"]
|
|
94
|
+
|
|
95
|
+
# Environment variables OVERRIDE config file (highest priority)
|
|
96
|
+
if env_backend := os.environ.get("SCRY_BACKEND"):
|
|
97
|
+
kwargs["backend"] = env_backend
|
|
98
|
+
if env_quiet := os.environ.get("SCRY_QUIET"):
|
|
99
|
+
kwargs["quiet"] = env_quiet.lower() in ("true", "1", "yes")
|
|
100
|
+
if env_full_context := os.environ.get("SCRY_FULL_CONTEXT"):
|
|
101
|
+
kwargs["full_context"] = env_full_context.lower() in ("true", "1", "yes")
|
|
102
|
+
if env_log_level := os.environ.get("SCRY_LOG_LEVEL"):
|
|
103
|
+
kwargs["log_level"] = env_log_level
|
|
104
|
+
|
|
105
|
+
return Config(**kwargs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_env_vars(config: Config) -> dict[str, str]:
|
|
109
|
+
"""Convert config to environment variables dict for subprocess.
|
|
110
|
+
|
|
111
|
+
Existing environment variables take precedence.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config: Config object to convert
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dict of environment variable names to values
|
|
118
|
+
"""
|
|
119
|
+
env_vars = {}
|
|
120
|
+
|
|
121
|
+
def set_if_value(key: str, value) -> None:
|
|
122
|
+
"""Set env var if value is truthy, respecting existing env."""
|
|
123
|
+
if key in os.environ:
|
|
124
|
+
env_vars[key] = os.environ[key]
|
|
125
|
+
elif value is not None and value != "":
|
|
126
|
+
env_vars[key] = str(value).lower() if isinstance(value, bool) else str(value)
|
|
127
|
+
|
|
128
|
+
# Core settings (always have defaults)
|
|
129
|
+
set_if_value("SCRY_BACKEND", config.backend)
|
|
130
|
+
set_if_value("SCRY_QUIET", config.quiet)
|
|
131
|
+
set_if_value("SCRY_FULL_CONTEXT", config.full_context)
|
|
132
|
+
set_if_value("SCRY_LOG_LEVEL", config.log_level)
|
|
133
|
+
|
|
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"]
|
|
140
|
+
|
|
141
|
+
return env_vars
|
scry_run/console.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Console output utilities for consistent styling."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
# Stderr console for status messages
|
|
6
|
+
err_console = Console(stderr=True, highlight=False)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def status(msg: str) -> None:
|
|
10
|
+
"""Print a dim status message."""
|
|
11
|
+
err_console.print(f"[dim]\\[scry-run][/dim] {msg}")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def info(msg: str) -> None:
|
|
15
|
+
"""Print an info message (cyan)."""
|
|
16
|
+
err_console.print(f"[cyan]\\[scry-run][/cyan] {msg}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def success(msg: str) -> None:
|
|
20
|
+
"""Print a success message (green)."""
|
|
21
|
+
err_console.print(f"[green]\\[scry-run][/green] {msg}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def warning(msg: str) -> None:
|
|
25
|
+
"""Print a warning message (yellow)."""
|
|
26
|
+
err_console.print(f"[yellow]\\[scry-run][/yellow] {msg}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def error(msg: str) -> None:
|
|
30
|
+
"""Print an error message (red)."""
|
|
31
|
+
err_console.print(f"[red]\\[scry-run][/red] {msg}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generating(class_name: str, attr_name: str) -> None:
|
|
35
|
+
"""Print a 'generating' message."""
|
|
36
|
+
err_console.print(f"[cyan]\\[scry-run][/cyan] Generating {class_name}.{attr_name}...")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def generated(class_name: str, attr_name: str) -> None:
|
|
40
|
+
"""Print a 'generated' success message."""
|
|
41
|
+
err_console.print(f"[green]\\[scry-run][/green] Generated {class_name}.{attr_name} ✓")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def using_cached(class_name: str, attr_name: str) -> None:
|
|
45
|
+
"""Print a 'using cached' message."""
|
|
46
|
+
err_console.print(f"[dim]\\[scry-run][/dim] Using cached {class_name}.{attr_name}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def backend_selected(backend_name: str, model: str | None, reason: str) -> None:
|
|
50
|
+
"""Print backend selection message."""
|
|
51
|
+
model_str = f" (model={model})" if model else ""
|
|
52
|
+
err_console.print(f"[dim]\\[scry-run][/dim] Using backend: {backend_name}{model_str} ({reason})")
|