fluxloop-cli 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.

Potentially problematic release.


This version of fluxloop-cli might be problematic. Click here for more details.

@@ -0,0 +1,9 @@
1
+ """
2
+ FluxLoop CLI - Command-line interface for running agent simulations.
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from .main import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,219 @@
1
+ """Argument binding utilities with replay support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Dict, Optional
9
+
10
+ from fluxloop.schemas import ExperimentConfig, ReplayArgsConfig
11
+
12
+
13
+ class _AwaitableNone:
14
+ """Simple awaitable that resolves to ``None``."""
15
+
16
+ def __await__(self): # type: ignore[override]
17
+ async def _noop() -> None:
18
+ return None
19
+
20
+ return _noop().__await__()
21
+
22
+
23
+ class ArgBinder:
24
+ """Bind call arguments using replay data when configured."""
25
+
26
+ def __init__(self, experiment_config: ExperimentConfig) -> None:
27
+ self.config = experiment_config
28
+ self.replay_config: Optional[ReplayArgsConfig] = experiment_config.replay_args
29
+ self._recording: Optional[Dict[str, Any]] = None
30
+
31
+ if self.replay_config and self.replay_config.enabled:
32
+ self._load_recording()
33
+
34
+ def _load_recording(self) -> None:
35
+ replay = self.replay_config
36
+ assert replay is not None
37
+
38
+ if not replay.recording_file:
39
+ raise ValueError("replay_args.recording_file must be provided when replay is enabled")
40
+
41
+ file_path = Path(replay.recording_file)
42
+ if not file_path.is_absolute():
43
+ source_dir = self.config.get_source_dir()
44
+ if source_dir:
45
+ file_path = (source_dir / file_path).resolve()
46
+ else:
47
+ file_path = file_path.resolve()
48
+
49
+ if not file_path.exists():
50
+ raise FileNotFoundError(
51
+ f"Recording file not found: {file_path}. Make sure it is available locally."
52
+ )
53
+
54
+ last_line: Optional[str] = None
55
+ with file_path.open("r", encoding="utf-8") as fp:
56
+ for line in fp:
57
+ if line.strip():
58
+ last_line = line
59
+
60
+ if not last_line:
61
+ raise ValueError(f"Recording file is empty: {file_path}")
62
+
63
+ try:
64
+ self._recording = json.loads(last_line)
65
+ except json.JSONDecodeError as exc:
66
+ raise ValueError(f"Invalid JSON in recording file {file_path}: {exc}")
67
+
68
+ recording_target = self._recording.get("target")
69
+ config_target = self._resolve_config_target()
70
+ if recording_target and recording_target != config_target:
71
+ print(
72
+ "⚠️ Recording target mismatch:"
73
+ f" recording={recording_target}, config={config_target}. Proceeding anyway."
74
+ )
75
+
76
+ def bind_call_args(
77
+ self,
78
+ func: Callable,
79
+ *,
80
+ runtime_input: str,
81
+ iteration: int = 0,
82
+ ) -> Dict[str, Any]:
83
+ """Construct kwargs for calling *func* based on replay or inspection."""
84
+
85
+ if self._recording:
86
+ kwargs = self._recording.get("kwargs", {}).copy()
87
+
88
+ replay = self.replay_config
89
+ assert replay is not None
90
+
91
+ if replay.override_param_path:
92
+ try:
93
+ self._set_by_path(kwargs, replay.override_param_path, runtime_input)
94
+ except (KeyError, TypeError):
95
+ # If path missing, fall back to plain binding
96
+ return self._bind_by_signature(func, runtime_input)
97
+
98
+ self._restore_callables(kwargs, replay)
99
+ self._ensure_no_unmapped_callables(kwargs, replay)
100
+ return kwargs
101
+
102
+ return self._bind_by_signature(func, runtime_input)
103
+
104
+ def _bind_by_signature(self, func: Callable, runtime_input: str) -> Dict[str, Any]:
105
+ signature = inspect.signature(func)
106
+ parameters = list(signature.parameters.values())
107
+
108
+ if parameters and parameters[0].name == "self":
109
+ parameters = parameters[1:]
110
+
111
+ candidate_names = [
112
+ "input",
113
+ "input_text",
114
+ "message",
115
+ "query",
116
+ "text",
117
+ "content",
118
+ ]
119
+
120
+ for param in parameters:
121
+ if param.name in candidate_names:
122
+ return {param.name: runtime_input}
123
+
124
+ if parameters:
125
+ return {parameters[0].name: runtime_input}
126
+
127
+ raise ValueError(
128
+ f"Cannot determine where to bind runtime input for function '{func.__name__}'."
129
+ )
130
+
131
+ def _restore_callables(self, kwargs: Dict[str, Any], replay: ReplayArgsConfig) -> None:
132
+ for param_name, provider in replay.callable_providers.items():
133
+ if param_name not in kwargs:
134
+ continue
135
+
136
+ marker = kwargs[param_name]
137
+ if isinstance(marker, str) and marker.startswith("<"):
138
+ kwargs[param_name] = self._resolve_builtin_callable(provider, marker)
139
+
140
+ def _ensure_no_unmapped_callables(self, kwargs: Dict[str, Any], replay: ReplayArgsConfig) -> None:
141
+ callable_markers = {
142
+ key: value
143
+ for key, value in kwargs.items()
144
+ if isinstance(value, str) and value.startswith("<")
145
+ }
146
+
147
+ if not callable_markers:
148
+ return
149
+
150
+ configured = set(replay.callable_providers.keys())
151
+ missing = [key for key in callable_markers if key not in configured]
152
+ if missing:
153
+ raise ValueError(
154
+ "Missing callable providers for recorded parameters: "
155
+ f"{', '.join(missing)}. Configure them under replay_args.callable_providers."
156
+ )
157
+
158
+ def _resolve_builtin_callable(self, provider: str, marker: str) -> Callable:
159
+ is_async = marker.endswith(":async>")
160
+
161
+ if provider == "builtin:collector.send":
162
+ messages: list = []
163
+
164
+ def _record(args: Any, kwargs: Any) -> None:
165
+ messages.append((args, kwargs))
166
+ pretty = args[0] if len(args) == 1 and not kwargs else {"args": args, "kwargs": kwargs}
167
+
168
+ def send(*args: Any, **kwargs: Any) -> _AwaitableNone:
169
+ _record(args, kwargs)
170
+ return _AwaitableNone()
171
+
172
+ async def send_async(*args: Any, **kwargs: Any) -> None:
173
+ _record(args, kwargs)
174
+
175
+ send.messages = messages
176
+ send_async.messages = messages
177
+ send.__fluxloop_builtin__ = "collector.send"
178
+ send_async.__fluxloop_builtin__ = "collector.send:async"
179
+ return send_async if is_async else send
180
+
181
+ if provider == "builtin:collector.error":
182
+ errors: list = []
183
+
184
+ def _record_error(args: Any, kwargs: Any) -> None:
185
+ errors.append((args, kwargs))
186
+ pretty = args[0] if len(args) == 1 and not kwargs else {"args": args, "kwargs": kwargs}
187
+ print(f"[ERROR] {pretty}")
188
+
189
+ def send_error(*args: Any, **kwargs: Any) -> _AwaitableNone:
190
+ _record_error(args, kwargs)
191
+ return _AwaitableNone()
192
+
193
+ async def send_error_async(*args: Any, **kwargs: Any) -> None:
194
+ _record_error(args, kwargs)
195
+
196
+ send_error.errors = errors
197
+ send_error_async.errors = errors
198
+ send_error.__fluxloop_builtin__ = "collector.error"
199
+ send_error_async.__fluxloop_builtin__ = "collector.error:async"
200
+ return send_error_async if is_async else send_error
201
+
202
+ raise ValueError(
203
+ f"Unknown callable provider '{provider}'. Supported providers: "
204
+ "builtin:collector.send, builtin:collector.error."
205
+ )
206
+
207
+ def _set_by_path(self, payload: Dict[str, Any], path: str, value: Any) -> None:
208
+ parts = path.split(".")
209
+ current = payload
210
+ for key in parts[:-1]:
211
+ current = current[key]
212
+ current[parts[-1]] = value
213
+
214
+ def _resolve_config_target(self) -> Optional[str]:
215
+ runner = self.config.runner
216
+ if runner.target:
217
+ return runner.target
218
+ return f"{runner.module_path}:{runner.function_name}"
219
+
@@ -0,0 +1,5 @@
1
+ """CLI commands."""
2
+
3
+ from . import config, generate, init, parse, run, status
4
+
5
+ __all__ = ["config", "generate", "init", "parse", "run", "status"]
@@ -0,0 +1,355 @@
1
+ """
2
+ Config command for managing configuration.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+ import typer
10
+ import yaml
11
+ from rich.console import Console
12
+ from rich.syntax import Syntax
13
+ from rich.table import Table
14
+
15
+ from ..config_loader import load_experiment_config
16
+ from ..templates import create_env_file, create_gitignore, create_sample_agent
17
+ from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
18
+ from ..project_paths import (
19
+ resolve_config_path,
20
+ resolve_env_path,
21
+ )
22
+
23
+ app = typer.Typer()
24
+ console = Console()
25
+
26
+
27
+ @app.command()
28
+ def show(
29
+ config_file: Path = typer.Option(
30
+ DEFAULT_CONFIG_PATH,
31
+ "--file",
32
+ "-f",
33
+ help="Configuration file to show",
34
+ ),
35
+ project: Optional[str] = typer.Option(
36
+ None,
37
+ "--project",
38
+ help="Project name under the FluxLoop root",
39
+ ),
40
+ root: Path = typer.Option(
41
+ Path(DEFAULT_ROOT_DIR_NAME),
42
+ "--root",
43
+ help="FluxLoop root directory",
44
+ ),
45
+ format: str = typer.Option(
46
+ "yaml",
47
+ "--format",
48
+ help="Output format (yaml, json)",
49
+ ),
50
+ ):
51
+ """
52
+ Show current configuration.
53
+ """
54
+ resolved_path = resolve_config_path(config_file, project, root)
55
+ if not resolved_path.exists():
56
+ console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
57
+ raise typer.Exit(1)
58
+
59
+ content = resolved_path.read_text()
60
+
61
+ if format == "json":
62
+ # Convert YAML to JSON
63
+ import json
64
+ data = yaml.safe_load(content)
65
+ content = json.dumps(data, indent=2)
66
+ lexer = "json"
67
+ else:
68
+ lexer = "yaml"
69
+
70
+ syntax = Syntax(content, lexer, theme="monokai", line_numbers=True)
71
+ console.print(syntax)
72
+
73
+
74
+ @app.command()
75
+ def set(
76
+ key: str = typer.Argument(
77
+ ...,
78
+ help="Configuration key to set (e.g., iterations, runner.timeout)",
79
+ ),
80
+ value: str = typer.Argument(
81
+ ...,
82
+ help="Value to set",
83
+ ),
84
+ config_file: Path = typer.Option(
85
+ DEFAULT_CONFIG_PATH,
86
+ "--file",
87
+ "-f",
88
+ help="Configuration file to update",
89
+ ),
90
+ project: Optional[str] = typer.Option(
91
+ None,
92
+ "--project",
93
+ help="Project name under the FluxLoop root",
94
+ ),
95
+ root: Path = typer.Option(
96
+ Path(DEFAULT_ROOT_DIR_NAME),
97
+ "--root",
98
+ help="FluxLoop root directory",
99
+ ),
100
+ ):
101
+ """
102
+ Set a configuration value.
103
+
104
+ Examples:
105
+ - fluxloop config set iterations 20
106
+ - fluxloop config set runner.timeout 300
107
+ """
108
+ resolved_path = resolve_config_path(config_file, project, root)
109
+ if not resolved_path.exists():
110
+ console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
111
+ raise typer.Exit(1)
112
+
113
+ # Load configuration
114
+ with open(resolved_path) as f:
115
+ config = yaml.safe_load(f) or {}
116
+
117
+ # Parse key path
118
+ keys = key.split(".")
119
+ current = config
120
+
121
+ # Navigate to the key
122
+ for k in keys[:-1]:
123
+ if k not in current:
124
+ current[k] = {}
125
+ current = current[k]
126
+
127
+ # Set the value
128
+ final_key = keys[-1]
129
+
130
+ # Try to parse value as appropriate type
131
+ try:
132
+ # Try as number
133
+ if "." in value:
134
+ parsed_value = float(value)
135
+ else:
136
+ parsed_value = int(value)
137
+ except ValueError:
138
+ # Try as boolean
139
+ if value.lower() in ("true", "false"):
140
+ parsed_value = value.lower() == "true"
141
+ else:
142
+ # Keep as string
143
+ parsed_value = value
144
+
145
+ current[final_key] = parsed_value
146
+
147
+ # Save configuration
148
+ with open(resolved_path, "w") as f:
149
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
150
+
151
+ console.print(f"[green]✓[/green] Set {key} = {parsed_value}")
152
+
153
+
154
+ @app.command()
155
+ def env(
156
+ show_values: bool = typer.Option(
157
+ False,
158
+ "--show-values",
159
+ "-s",
160
+ help="Show actual values (be careful with secrets)",
161
+ ),
162
+ project: Optional[str] = typer.Option(
163
+ None,
164
+ "--project",
165
+ help="Project name under the FluxLoop root",
166
+ ),
167
+ root: Path = typer.Option(
168
+ Path(DEFAULT_ROOT_DIR_NAME),
169
+ "--root",
170
+ help="FluxLoop root directory",
171
+ ),
172
+ ):
173
+ """
174
+ Show environment variables used by FluxLoop.
175
+ """
176
+ env_vars = [
177
+ ("FLUXLOOP_COLLECTOR_URL", "Collector service URL", "http://localhost:8000"),
178
+ ("FLUXLOOP_API_KEY", "API key for authentication", None),
179
+ ("FLUXLOOP_ENABLED", "Enable/disable tracing", "true"),
180
+ ("FLUXLOOP_DEBUG", "Enable debug mode", "false"),
181
+ ("FLUXLOOP_SAMPLE_RATE", "Trace sampling rate (0-1)", "1.0"),
182
+ ("FLUXLOOP_SERVICE_NAME", "Service name for traces", None),
183
+ ("FLUXLOOP_ENVIRONMENT", "Environment (dev/staging/prod)", "development"),
184
+ ("OPENAI_API_KEY", "OpenAI API key", None),
185
+ ("ANTHROPIC_API_KEY", "Anthropic API key", None),
186
+ ]
187
+
188
+ table = Table(title="FluxLoop Environment Variables")
189
+ table.add_column("Variable", style="cyan")
190
+ table.add_column("Description")
191
+ table.add_column("Current Value")
192
+ table.add_column("Default", style="dim")
193
+
194
+ for var_name, description, default in env_vars:
195
+ current = os.getenv(var_name)
196
+
197
+ if current:
198
+ if show_values or not var_name.endswith("_KEY"):
199
+ display_value = current
200
+ else:
201
+ # Mask API keys
202
+ display_value = "****" + current[-4:] if len(current) > 4 else "****"
203
+ display_value = f"[green]{display_value}[/green]"
204
+ else:
205
+ display_value = "[yellow]Not set[/yellow]"
206
+
207
+ table.add_row(
208
+ var_name,
209
+ description,
210
+ display_value,
211
+ default or "-"
212
+ )
213
+
214
+ console.print(table)
215
+
216
+ # Check for .env file
217
+ env_file = resolve_env_path(Path(".env"), project, root)
218
+ if env_file.exists():
219
+ console.print(f"\n[dim]Loading from:[/dim] {env_file}")
220
+ else:
221
+ console.print("\n[yellow]No .env file found[/yellow]")
222
+ console.print("Create one with: [cyan]fluxloop init project[/cyan]")
223
+
224
+
225
+ @app.command()
226
+ def set_llm(
227
+ provider: str = typer.Argument(..., help="LLM provider identifier (e.g., openai, anthropic, gemini)"),
228
+ api_key: str = typer.Argument(..., help="API key or token for the provider"),
229
+ model: Optional[str] = typer.Option(None, "--model", "-m", help="Default model to use"),
230
+ overwrite_env: bool = typer.Option(False, "--overwrite-env", help="Overwrite existing key in .env"),
231
+ config_file: Path = typer.Option(DEFAULT_CONFIG_PATH, "--file", "-f", help="Configuration file to update"),
232
+ env_file: Path = typer.Option(Path(".env"), "--env-file", help="Path to environment file"),
233
+ project: Optional[str] = typer.Option(None, "--project", help="Project name under the FluxLoop root"),
234
+ root: Path = typer.Option(Path(DEFAULT_ROOT_DIR_NAME), "--root", help="FluxLoop root directory"),
235
+ ):
236
+ """Configure LLM provider credentials and defaults."""
237
+
238
+ supported_providers: Dict[str, Dict[str, str]] = {
239
+ "openai": {
240
+ "env_var": "OPENAI_API_KEY",
241
+ "model": "gpt-5",
242
+ },
243
+ "anthropic": {
244
+ "env_var": "ANTHROPIC_API_KEY",
245
+ "model": "claude-3-haiku-20240307",
246
+ },
247
+ "gemini": {
248
+ "env_var": "GEMINI_API_KEY",
249
+ "model": "gemini-2.5-flash",
250
+ },
251
+ }
252
+
253
+ normalized_provider = provider.lower()
254
+ if normalized_provider not in supported_providers:
255
+ available = ", ".join(sorted(supported_providers.keys()))
256
+ console.print(
257
+ f"[red]Error:[/red] Unsupported provider '{provider}'. Available: {available}"
258
+ )
259
+ raise typer.Exit(1)
260
+
261
+ provider_info = supported_providers[normalized_provider]
262
+ env_var = provider_info["env_var"]
263
+
264
+ # Update .env file
265
+ env_path = resolve_env_path(env_file, project, root)
266
+ env_contents: Dict[str, str] = {}
267
+ if env_path.exists():
268
+ for line in env_path.read_text().splitlines():
269
+ if not line or line.startswith("#") or "=" not in line:
270
+ continue
271
+ key, value = line.split("=", 1)
272
+ env_contents[key.strip()] = value.strip()
273
+
274
+ if env_var in env_contents and not overwrite_env:
275
+ console.print(
276
+ f"[yellow]Warning:[/yellow] {env_var} already set. Use --overwrite-env to replace it."
277
+ )
278
+ else:
279
+ env_contents[env_var] = api_key
280
+ env_lines = [f"{key}={value}" for key, value in env_contents.items()]
281
+ env_path.write_text("\n".join(env_lines) + "\n")
282
+ console.print(f"[green]✓[/green] Saved {env_var} to {env_path}")
283
+
284
+ # Update configuration file
285
+ resolved_cfg = resolve_config_path(config_file, project, root)
286
+ with open(resolved_cfg) as f:
287
+ config = yaml.safe_load(f) or {}
288
+
289
+ input_generation = config.setdefault("input_generation", {})
290
+
291
+ llm_config = input_generation.setdefault("llm", {})
292
+ llm_config["enabled"] = True
293
+ llm_config["provider"] = normalized_provider
294
+ llm_config["model"] = model or llm_config.get("model", provider_info["model"])
295
+
296
+ with open(resolved_cfg, "w") as f:
297
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
298
+
299
+ display_config_path = resolved_cfg if project else config_file
300
+ console.print(
301
+ f"[green]✓[/green] Updated {display_config_path} with provider='{normalized_provider}' model='{llm_config['model']}'"
302
+ )
303
+
304
+
305
+ @app.command()
306
+ def validate(
307
+ config_file: Path = typer.Option(
308
+ DEFAULT_CONFIG_PATH,
309
+ "--file",
310
+ "-f",
311
+ help="Configuration file to validate",
312
+ ),
313
+ project: Optional[str] = typer.Option(
314
+ None,
315
+ "--project",
316
+ help="Project name under the FluxLoop root",
317
+ ),
318
+ root: Path = typer.Option(
319
+ Path(DEFAULT_ROOT_DIR_NAME),
320
+ "--root",
321
+ help="FluxLoop root directory",
322
+ ),
323
+ ):
324
+ """
325
+ Validate configuration file.
326
+ """
327
+ resolved_path = resolve_config_path(config_file, project, root)
328
+ if not resolved_path.exists():
329
+ console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
330
+ raise typer.Exit(1)
331
+
332
+ try:
333
+ config = load_experiment_config(config_file)
334
+
335
+ # Show validation results
336
+ console.print("[green]✓[/green] Configuration is valid!\n")
337
+
338
+ # Show summary
339
+ table = Table(show_header=False)
340
+ table.add_column("Property", style="cyan")
341
+ table.add_column("Value")
342
+
343
+ table.add_row("Experiment Name", config.name)
344
+ table.add_row("Iterations", str(config.iterations))
345
+ table.add_row("Personas", str(len(config.personas)))
346
+ table.add_row("Variations", str(config.variation_count))
347
+ table.add_row("Total Runs", str(config.estimate_total_runs()))
348
+ table.add_row("Runner Module", config.runner.module_path)
349
+ table.add_row("Evaluators", str(len(config.evaluators)))
350
+
351
+ console.print(table)
352
+
353
+ except Exception as e:
354
+ console.print(f"[red]✗ Validation failed:[/red] {e}")
355
+ raise typer.Exit(1)