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,304 @@
1
+ """Generate command for producing input datasets."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from dotenv import dotenv_values
12
+
13
+ from ..config_loader import load_experiment_config
14
+ from ..input_generator import GenerationSettings, generate_inputs
15
+ from ..llm_generator import DEFAULT_STRATEGIES
16
+ from ..validators import parse_variation_strategies
17
+ from ..constants import DEFAULT_CONFIG_PATH, DEFAULT_ROOT_DIR_NAME
18
+ from ..project_paths import (
19
+ resolve_config_path,
20
+ resolve_project_relative,
21
+ resolve_root_dir,
22
+ )
23
+ from fluxloop.schemas import InputGenerationMode, VariationStrategy
24
+
25
+ app = typer.Typer()
26
+ console = Console()
27
+
28
+
29
+ @app.command()
30
+ def inputs(
31
+ config_file: Path = typer.Option(
32
+ DEFAULT_CONFIG_PATH,
33
+ "--config",
34
+ "-c",
35
+ help="Path to experiment configuration file",
36
+ ),
37
+ project: Optional[str] = typer.Option(
38
+ None,
39
+ "--project",
40
+ help="Project name under the FluxLoop root",
41
+ ),
42
+ root: Path = typer.Option(
43
+ Path(DEFAULT_ROOT_DIR_NAME),
44
+ "--root",
45
+ help="FluxLoop root directory",
46
+ ),
47
+ output_file: Optional[Path] = typer.Option(
48
+ None,
49
+ "--output",
50
+ "-o",
51
+ help="Path to write generated inputs file (defaults to setting.yaml -> inputs_file)",
52
+ ),
53
+ limit: Optional[int] = typer.Option(
54
+ None,
55
+ "--limit",
56
+ "-l",
57
+ help="Maximum number of inputs to generate",
58
+ ),
59
+ dry_run: bool = typer.Option(
60
+ False,
61
+ "--dry-run",
62
+ help="Print planned generation without creating a file",
63
+ ),
64
+ overwrite: bool = typer.Option(
65
+ False,
66
+ "--overwrite",
67
+ help="Allow overwriting an existing output file",
68
+ ),
69
+ mode: Optional[InputGenerationMode] = typer.Option(
70
+ None,
71
+ "--mode",
72
+ case_sensitive=False,
73
+ help="Generation mode: deterministic or llm",
74
+ ),
75
+ strategy: List[str] = typer.Option( # type: ignore[assignment]
76
+ None,
77
+ "--strategy",
78
+ "-s",
79
+ help="Variation strategy to request (repeatable)",
80
+ ),
81
+ llm_provider: Optional[str] = typer.Option(
82
+ None,
83
+ "--llm-provider",
84
+ help="Override LLM provider for input generation",
85
+ ),
86
+ llm_model: Optional[str] = typer.Option(
87
+ None,
88
+ "--llm-model",
89
+ help="Override LLM model identifier",
90
+ ),
91
+ llm_api_key: Optional[str] = typer.Option(
92
+ None,
93
+ "--llm-api-key",
94
+ help="API key for LLM provider (falls back to FLUXLOOP_LLM_API_KEY)",
95
+ ),
96
+ from_recording: Optional[Path] = typer.Option(
97
+ None,
98
+ "--from-recording",
99
+ help="Use a recorded call (JSONL) as template for generated inputs",
100
+ ),
101
+ ):
102
+ """Generate input variations for review before running experiments."""
103
+ resolved_config = resolve_config_path(config_file, project, root)
104
+ if not resolved_config.exists():
105
+ console.print(f"[red]Error:[/red] Configuration file not found: {config_file}")
106
+ raise typer.Exit(1)
107
+
108
+ # Generating inputs should not require the inputs file to exist yet.
109
+ require_inputs_file = False
110
+
111
+ # Load environment variables (project-level overrides root-level)
112
+ env_values: Dict[str, str] = {}
113
+
114
+ resolved_root = resolve_root_dir(root)
115
+ root_env_path = resolved_root / ".env"
116
+ if root_env_path.exists():
117
+ env_values.update(
118
+ {
119
+ key: value
120
+ for key, value in dotenv_values(root_env_path).items()
121
+ if value is not None
122
+ }
123
+ )
124
+
125
+ if project:
126
+ project_env_path = resolve_project_relative(Path(".env"), project, root)
127
+ if project_env_path.exists():
128
+ env_values.update(
129
+ {
130
+ key: value
131
+ for key, value in dotenv_values(project_env_path).items()
132
+ if value is not None
133
+ }
134
+ )
135
+
136
+ try:
137
+ config = load_experiment_config(
138
+ resolved_config,
139
+ require_inputs_file=require_inputs_file,
140
+ )
141
+ except Exception as exc:
142
+ console.print(f"[red]Error loading configuration:[/red] {exc}")
143
+ raise typer.Exit(1)
144
+
145
+ # Determine output path (CLI option overrides config)
146
+ output_path = output_file or Path(config.inputs_file or "inputs/generated.yaml")
147
+ resolved_output = resolve_project_relative(output_path, project, root)
148
+
149
+ if resolved_output.exists() and not overwrite and not dry_run:
150
+ console.print(
151
+ f"[red]Error:[/red] Output file already exists: {resolved_output}\n"
152
+ "Use --overwrite to replace it."
153
+ )
154
+ raise typer.Exit(1)
155
+
156
+ console.print(f"📋 Loading configuration from: [cyan]{resolved_config}[/cyan]")
157
+
158
+ strategies: Optional[List[VariationStrategy]] = None
159
+ if strategy:
160
+ strategies = parse_variation_strategies(strategy)
161
+
162
+ # Apply CLI overrides to configuration
163
+ if mode:
164
+ config.input_generation.mode = mode
165
+ if mode == InputGenerationMode.LLM:
166
+ config.input_generation.llm.enabled = True
167
+
168
+ if llm_provider:
169
+ config.input_generation.llm.provider = llm_provider
170
+ config.input_generation.llm.enabled = True
171
+
172
+ if llm_model:
173
+ config.input_generation.llm.model = llm_model
174
+ config.input_generation.llm.enabled = True
175
+
176
+ resolved_api_key = (
177
+ llm_api_key
178
+ or config.input_generation.llm.api_key
179
+ or env_values.get("FLUXLOOP_LLM_API_KEY")
180
+ or env_values.get("OPENAI_API_KEY")
181
+ or os.getenv("FLUXLOOP_LLM_API_KEY")
182
+ or os.getenv("OPENAI_API_KEY")
183
+ )
184
+
185
+ if resolved_api_key:
186
+ config.input_generation.llm.api_key = resolved_api_key
187
+
188
+ if (
189
+ config.input_generation.mode == InputGenerationMode.LLM
190
+ and not config.input_generation.llm.api_key
191
+ ):
192
+ console.print(
193
+ "[yellow]Warning:[/yellow] LLM mode requested but no API key provided."
194
+ )
195
+
196
+ settings = GenerationSettings(
197
+ limit=limit,
198
+ dry_run=dry_run,
199
+ mode=mode,
200
+ strategies=strategies,
201
+ llm_api_key_override=llm_api_key,
202
+ )
203
+
204
+ recording_template = None
205
+ if from_recording:
206
+ try:
207
+ recording_template = _load_recording_template(from_recording, config)
208
+ console.print(
209
+ "📝 Generating inputs from recording"
210
+ f"\n → Base content: [cyan]{recording_template['base_content'][:60]}[/cyan]"
211
+ )
212
+ except Exception as exc:
213
+ console.print(f"[red]Failed to load recording template:[/red] {exc}")
214
+ raise typer.Exit(1)
215
+
216
+ try:
217
+ result = generate_inputs(config, settings, recording_template=recording_template)
218
+ except Exception as exc:
219
+ console.print(f"[red]Generation failed:[/red] {exc}")
220
+ if "--debug" in sys.argv:
221
+ console.print_exception()
222
+ raise typer.Exit(1)
223
+
224
+ if dry_run:
225
+ console.print("\n[yellow]Dry run mode - no file written[/yellow]")
226
+ console.print(f"Planned inputs: {len(result.entries)}")
227
+ return
228
+
229
+ resolved_output.parent.mkdir(parents=True, exist_ok=True)
230
+ resolved_output.write_text(result.to_yaml(), encoding="utf-8")
231
+
232
+ strategies_used = result.metadata.get("strategies") or [s.value for s in DEFAULT_STRATEGIES]
233
+
234
+ console.print(
235
+ "\n[bold green]Generation complete![/bold green]"
236
+ f"\n📝 Inputs written to: [cyan]{resolved_output}[/cyan]"
237
+ f"\n✨ Total inputs: [green]{len(result.entries)}[/green]"
238
+ f"\n🧠 Mode: [magenta]{result.metadata.get('generation_mode', 'deterministic')}[/magenta]"
239
+ f"\n🎯 Strategies: [cyan]{', '.join(strategy for strategy in strategies_used)}[/cyan]"
240
+ )
241
+
242
+
243
+ def _load_recording_template(path: Path, config: "ExperimentConfig") -> Dict[str, object]:
244
+ target_path = path
245
+ if not target_path.is_absolute():
246
+ source_dir = config.get_source_dir()
247
+ if source_dir:
248
+ target_path = (source_dir / target_path).resolve()
249
+ else:
250
+ target_path = target_path.resolve()
251
+
252
+ if not target_path.exists():
253
+ raise FileNotFoundError(f"Recording file not found: {target_path}")
254
+
255
+ with target_path.open("r", encoding="utf-8") as handle:
256
+ first_line = handle.readline()
257
+
258
+ if not first_line:
259
+ raise ValueError(f"Recording file is empty: {target_path}")
260
+
261
+ try:
262
+ record = json.loads(first_line)
263
+ except json.JSONDecodeError as exc:
264
+ raise ValueError(f"Invalid JSON in recording file {target_path}: {exc}")
265
+
266
+ kwargs = record.get("kwargs", {})
267
+ base_content = _extract_content(kwargs)
268
+ if not base_content:
269
+ sample = json.dumps(kwargs, indent=2)[:500]
270
+ raise ValueError(
271
+ "Unable to locate textual content in recording. "
272
+ "Expected keys such as data.content, input, input_text, message, query, text, or content." \
273
+ f"\nRecording snapshot:\n{sample}"
274
+ )
275
+
276
+ return {
277
+ "base_content": base_content,
278
+ "full_kwargs": kwargs,
279
+ "target": record.get("target"),
280
+ }
281
+
282
+
283
+ def _extract_content(kwargs: Dict[str, Any]) -> Optional[str]:
284
+ content_paths = [
285
+ "data.content",
286
+ "input",
287
+ "input_text",
288
+ "message",
289
+ "query",
290
+ "text",
291
+ "content",
292
+ ]
293
+
294
+ for path in content_paths:
295
+ try:
296
+ current: Any = kwargs
297
+ for part in path.split('.'):
298
+ current = current[part]
299
+ if isinstance(current, str) and current:
300
+ return current
301
+ except (KeyError, TypeError):
302
+ continue
303
+
304
+ return None
@@ -0,0 +1,225 @@
1
+ """
2
+ Initialize command for creating new FluxLoop projects.
3
+ """
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.prompt import Confirm
11
+ from rich.tree import Tree
12
+
13
+ from ..templates import (
14
+ create_experiment_config,
15
+ create_sample_agent,
16
+ create_gitignore,
17
+ create_env_file,
18
+ )
19
+ from ..project_paths import resolve_root_dir, resolve_project_dir
20
+ from ..constants import DEFAULT_ROOT_DIR_NAME
21
+
22
+ app = typer.Typer()
23
+ console = Console()
24
+
25
+
26
+ @app.command()
27
+ def project(
28
+ path: Path = typer.Argument(
29
+ Path(DEFAULT_ROOT_DIR_NAME),
30
+ help="Root directory for FluxLoop projects",
31
+ ),
32
+ name: Optional[str] = typer.Option(
33
+ None,
34
+ "--name",
35
+ "-n",
36
+ help="Project name",
37
+ ),
38
+ with_example: bool = typer.Option(
39
+ True,
40
+ "--with-example/--no-example",
41
+ help="Include example agent",
42
+ ),
43
+ force: bool = typer.Option(
44
+ False,
45
+ "--force",
46
+ "-f",
47
+ help="Overwrite existing files",
48
+ ),
49
+ ):
50
+ """
51
+ Initialize a new FluxLoop project.
52
+
53
+ This command creates:
54
+ - setting.yaml: Default experiment configuration
55
+ - .env: Environment variables template
56
+ - examples/: Sample agent code (optional)
57
+ """
58
+ # Resolve path
59
+ root_dir = resolve_root_dir(path)
60
+
61
+ if not root_dir.exists():
62
+ console.print(f"[dim]Creating FluxLoop root directory at {root_dir}[/dim]")
63
+ root_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ if not name:
66
+ current = Path.cwd()
67
+ if current.parent == root_dir:
68
+ project_name = current.name
69
+ else:
70
+ console.print(
71
+ "[red]Error:[/red] Project name must be provided when running outside the FluxLoop root directory."
72
+ )
73
+ raise typer.Exit(1)
74
+ else:
75
+ project_name = name
76
+ project_path = resolve_project_dir(project_name, root_dir)
77
+
78
+ console.print(f"\n[bold blue]Initializing FluxLoop project:[/bold blue] {project_name}")
79
+ console.print(f"[dim]Location: {project_path}[/dim]\n")
80
+
81
+ console.print(f"\n[bold blue]Initializing FluxLoop project:[/bold blue] {project_name}")
82
+ console.print(f"[dim]Location: {project_path}[/dim]\n")
83
+
84
+ # Check if directory exists
85
+ if not project_path.exists():
86
+ if Confirm.ask(f"Directory {project_path} doesn't exist. Create it?"):
87
+ project_path.mkdir(parents=True)
88
+ else:
89
+ raise typer.Abort()
90
+
91
+ # Check for existing files
92
+ config_file = project_path / "setting.yaml"
93
+ env_file = project_path / ".env"
94
+ gitignore_file = project_path / ".gitignore"
95
+
96
+ if not force:
97
+ existing_files = []
98
+ if config_file.exists():
99
+ existing_files.append("setting.yaml")
100
+ if env_file.exists():
101
+ existing_files.append(".env")
102
+
103
+ if existing_files:
104
+ console.print(
105
+ f"[yellow]Warning:[/yellow] The following files already exist: {', '.join(existing_files)}"
106
+ )
107
+ if not Confirm.ask("Overwrite existing files?", default=False):
108
+ raise typer.Abort()
109
+
110
+ # Create configuration file
111
+ console.print("📝 Creating experiment configuration...")
112
+ config_content = create_experiment_config(project_name)
113
+ config_file.write_text(config_content)
114
+
115
+ # Ensure root .env exists, create project override template
116
+ root_env_file = root_dir / ".env"
117
+ if not root_env_file.exists():
118
+ console.print("🔐 Creating root environment template...")
119
+ root_env_file.write_text(create_env_file())
120
+
121
+ console.print("🔐 Creating project .env template...")
122
+ recordings_dir = project_path / "recordings"
123
+ recordings_dir.mkdir(exist_ok=True)
124
+
125
+ project_env_content = "# Project-specific overrides\n"
126
+ env_file.write_text(project_env_content)
127
+
128
+ # Update .gitignore
129
+ if not gitignore_file.exists():
130
+ console.print("📄 Creating .gitignore...")
131
+ gitignore_content = create_gitignore()
132
+ gitignore_file.write_text(gitignore_content)
133
+
134
+ # Create example agent if requested
135
+ if with_example:
136
+ console.print("🤖 Creating example agent...")
137
+ examples_dir = project_path / "examples"
138
+ examples_dir.mkdir(exist_ok=True)
139
+
140
+ agent_file = examples_dir / "simple_agent.py"
141
+ agent_content = create_sample_agent()
142
+ agent_file.write_text(agent_content)
143
+
144
+ # Display created structure
145
+ console.print("\n[bold green]✓ Project initialized successfully![/bold green]\n")
146
+
147
+ tree = Tree(f"[bold]{project_name}/[/bold]")
148
+ tree.add("📄 setting.yaml")
149
+ tree.add("🔐 .env")
150
+ tree.add("📄 .gitignore")
151
+ tree.add("📁 recordings/")
152
+
153
+ if with_example:
154
+ examples_tree = tree.add("📁 examples/")
155
+ examples_tree.add("🐍 simple_agent.py")
156
+
157
+ console.print(tree)
158
+
159
+ # Show next steps
160
+ console.print("\n[bold]Next steps:[/bold]")
161
+ console.print("1. Edit [cyan]setting.yaml[/cyan] to configure your experiment")
162
+ console.print("2. Set up environment variables in [cyan].env[/cyan]")
163
+ if with_example:
164
+ console.print("3. Try running: [green]fluxloop run experiment[/green]")
165
+ else:
166
+ console.print("3. Add your agent code")
167
+ console.print("4. Run: [green]fluxloop run experiment[/green]")
168
+
169
+
170
+ @app.command()
171
+ def agent(
172
+ name: str = typer.Argument(
173
+ ...,
174
+ help="Name of the agent module",
175
+ ),
176
+ path: Path = typer.Option(
177
+ Path.cwd(),
178
+ "--path",
179
+ "-p",
180
+ help="Directory to create the agent in",
181
+ ),
182
+ template: str = typer.Option(
183
+ "simple",
184
+ "--template",
185
+ "-t",
186
+ help="Agent template to use (simple, langchain, langgraph)",
187
+ ),
188
+ ):
189
+ """
190
+ Create a new agent from a template.
191
+ """
192
+ # Validate template
193
+ valid_templates = ["simple", "langchain", "langgraph"]
194
+ if template not in valid_templates:
195
+ console.print(
196
+ f"[red]Error:[/red] Invalid template '{template}'. "
197
+ f"Choose from: {', '.join(valid_templates)}"
198
+ )
199
+ raise typer.Exit(1)
200
+
201
+ # Create agent file
202
+ agent_dir = path / "agents"
203
+ agent_dir.mkdir(exist_ok=True)
204
+
205
+ agent_file = agent_dir / f"{name}.py"
206
+
207
+ if agent_file.exists():
208
+ if not Confirm.ask(f"Agent {name}.py already exists. Overwrite?", default=False):
209
+ raise typer.Abort()
210
+
211
+ # Create agent based on template
212
+ console.print(f"🤖 Creating {template} agent: {name}")
213
+
214
+ if template == "simple":
215
+ content = create_sample_agent()
216
+ else:
217
+ # TODO: Add more templates
218
+ content = create_sample_agent()
219
+
220
+ agent_file.write_text(content)
221
+
222
+ console.print(f"[green]✓[/green] Agent created: {agent_file}")
223
+ console.print("\nTo use this agent, update your setting.yaml:")
224
+ console.print(f" runner.module_path: agents.{name}")
225
+ console.print(" runner.function_name: run")