ostruct-cli 0.7.2__py3-none-any.whl → 0.8.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.
Files changed (46) hide show
  1. ostruct/cli/__init__.py +21 -3
  2. ostruct/cli/base_errors.py +1 -1
  3. ostruct/cli/cli.py +66 -1983
  4. ostruct/cli/click_options.py +460 -28
  5. ostruct/cli/code_interpreter.py +238 -0
  6. ostruct/cli/commands/__init__.py +32 -0
  7. ostruct/cli/commands/list_models.py +128 -0
  8. ostruct/cli/commands/quick_ref.py +50 -0
  9. ostruct/cli/commands/run.py +137 -0
  10. ostruct/cli/commands/update_registry.py +71 -0
  11. ostruct/cli/config.py +277 -0
  12. ostruct/cli/cost_estimation.py +134 -0
  13. ostruct/cli/errors.py +310 -6
  14. ostruct/cli/exit_codes.py +1 -0
  15. ostruct/cli/explicit_file_processor.py +548 -0
  16. ostruct/cli/field_utils.py +69 -0
  17. ostruct/cli/file_info.py +42 -9
  18. ostruct/cli/file_list.py +301 -102
  19. ostruct/cli/file_search.py +455 -0
  20. ostruct/cli/file_utils.py +47 -13
  21. ostruct/cli/mcp_integration.py +541 -0
  22. ostruct/cli/model_creation.py +150 -1
  23. ostruct/cli/model_validation.py +204 -0
  24. ostruct/cli/progress_reporting.py +398 -0
  25. ostruct/cli/registry_updates.py +14 -9
  26. ostruct/cli/runner.py +1418 -0
  27. ostruct/cli/schema_utils.py +113 -0
  28. ostruct/cli/services.py +626 -0
  29. ostruct/cli/template_debug.py +748 -0
  30. ostruct/cli/template_debug_help.py +162 -0
  31. ostruct/cli/template_env.py +15 -6
  32. ostruct/cli/template_filters.py +55 -3
  33. ostruct/cli/template_optimizer.py +474 -0
  34. ostruct/cli/template_processor.py +1080 -0
  35. ostruct/cli/template_rendering.py +69 -34
  36. ostruct/cli/token_validation.py +286 -0
  37. ostruct/cli/types.py +78 -0
  38. ostruct/cli/unattended_operation.py +269 -0
  39. ostruct/cli/validators.py +386 -3
  40. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/LICENSE +2 -0
  41. ostruct_cli-0.8.0.dist-info/METADATA +633 -0
  42. ostruct_cli-0.8.0.dist-info/RECORD +69 -0
  43. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/WHEEL +1 -1
  44. ostruct_cli-0.7.2.dist-info/METADATA +0 -370
  45. ostruct_cli-0.7.2.dist-info/RECORD +0 -45
  46. {ostruct_cli-0.7.2.dist-info → ostruct_cli-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,269 @@
1
+ """Unattended operation safeguards for CI/CD compatibility."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, Callable, List, Optional
6
+
7
+ from .errors import CLIError, ContainerExpiredError, ExitCode
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class UnattendedOperationTimeoutError(CLIError):
13
+ """Operation timed out during unattended execution."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ timeout_seconds: int,
19
+ exit_code: ExitCode = ExitCode.OPERATION_TIMEOUT,
20
+ context: Optional[dict] = None,
21
+ ):
22
+ """Initialize timeout error with automation guidance.
23
+
24
+ Args:
25
+ message: Error message
26
+ timeout_seconds: The timeout that was exceeded
27
+ exit_code: Exit code for automation
28
+ context: Additional context information
29
+ """
30
+ enhanced_message = (
31
+ f"{message}\n\n"
32
+ f"💡 For CI/CD environments:\n"
33
+ f" • Increase timeout with --timeout {timeout_seconds * 2}\n"
34
+ f" • Split large operations into smaller chunks\n"
35
+ f" • Use --dry-run to validate before actual execution\n"
36
+ f" • Check server/tool availability before running"
37
+ )
38
+
39
+ enhanced_context = {
40
+ "timeout_seconds": timeout_seconds,
41
+ "automation_friendly": True,
42
+ **(context or {}),
43
+ }
44
+
45
+ super().__init__(
46
+ enhanced_message, exit_code=exit_code, context=enhanced_context
47
+ )
48
+
49
+
50
+ class UnattendedOperationManager:
51
+ """Manager for unattended operations with timeout and error handling."""
52
+
53
+ def __init__(self, timeout_seconds: int = 3600): # 1 hour default
54
+ """Initialize unattended operation manager.
55
+
56
+ Args:
57
+ timeout_seconds: Maximum operation timeout in seconds
58
+ """
59
+ self.timeout = timeout_seconds
60
+ logger.debug(
61
+ f"UnattendedOperationManager initialized with {timeout_seconds}s timeout"
62
+ )
63
+
64
+ async def execute_with_safeguards(
65
+ self, operation: Callable, operation_name: str = "operation"
66
+ ) -> Any:
67
+ """Execute operation with timeout and error handling.
68
+
69
+ Args:
70
+ operation: Async callable to execute
71
+ operation_name: Human-readable name for logging and errors
72
+
73
+ Returns:
74
+ Result of the operation
75
+
76
+ Raises:
77
+ UnattendedOperationTimeoutError: If operation times out
78
+ ContainerExpiredError: If container expires (fail fast)
79
+ CLIError: For other operation failures
80
+ """
81
+ logger.debug(
82
+ f"Starting unattended {operation_name} with {self.timeout}s timeout"
83
+ )
84
+
85
+ try:
86
+ result = await asyncio.wait_for(operation(), timeout=self.timeout)
87
+ logger.debug(f"Unattended {operation_name} completed successfully")
88
+ return result
89
+
90
+ except asyncio.TimeoutError:
91
+ error_msg = f"Unattended {operation_name} timed out after {self.timeout} seconds"
92
+ logger.error(error_msg)
93
+ raise UnattendedOperationTimeoutError(
94
+ error_msg,
95
+ timeout_seconds=self.timeout,
96
+ context={"operation": operation_name},
97
+ )
98
+
99
+ except ContainerExpiredError:
100
+ # Fail fast for container expiration - these are unrecoverable
101
+ logger.error(
102
+ f"Container expired during {operation_name} - failing fast"
103
+ )
104
+ raise
105
+
106
+ except CLIError:
107
+ # Re-raise CLI errors as-is (they're already properly formatted)
108
+ raise
109
+
110
+ except Exception as e:
111
+ # Wrap unexpected errors for better automation handling
112
+ error_msg = (
113
+ f"Unexpected error during unattended {operation_name}: {e}"
114
+ )
115
+ logger.error(error_msg)
116
+ raise CLIError(
117
+ error_msg,
118
+ exit_code=ExitCode.INTERNAL_ERROR,
119
+ context={
120
+ "operation": operation_name,
121
+ "original_error": str(e),
122
+ },
123
+ )
124
+
125
+ def set_timeout(self, timeout_seconds: int) -> None:
126
+ """Update operation timeout.
127
+
128
+ Args:
129
+ timeout_seconds: New timeout in seconds
130
+ """
131
+ old_timeout = self.timeout
132
+ self.timeout = timeout_seconds
133
+ logger.debug(f"Timeout updated: {old_timeout}s -> {timeout_seconds}s")
134
+
135
+
136
+ class UnattendedCompatibilityValidator:
137
+ """Validator for ensuring operations can run without user interaction."""
138
+
139
+ @staticmethod
140
+ def validate_mcp_servers(servers: List[dict]) -> List[str]:
141
+ """Pre-validate MCP servers don't require interaction.
142
+
143
+ Args:
144
+ servers: List of MCP server configurations
145
+
146
+ Returns:
147
+ List of validation errors, empty if all servers are compatible
148
+ """
149
+ errors = []
150
+
151
+ for server in servers:
152
+ server_url = server.get("url", "unknown")
153
+
154
+ # Check approval requirement
155
+ require_approval = server.get("require_approval", "user")
156
+ if require_approval != "never":
157
+ errors.append(
158
+ f"MCP server {server_url} requires approval ('{require_approval}') - "
159
+ f"incompatible with unattended CLI usage. Set require_approval='never'."
160
+ )
161
+
162
+ # Check for interactive features that break automation
163
+ if server.get("interactive_mode", False):
164
+ errors.append(
165
+ f"MCP server {server_url} has interactive_mode enabled - "
166
+ f"incompatible with unattended operation"
167
+ )
168
+
169
+ # Check for user prompt features
170
+ if server.get("user_prompts", False):
171
+ errors.append(
172
+ f"MCP server {server_url} enables user_prompts - "
173
+ f"incompatible with unattended operation"
174
+ )
175
+
176
+ return errors
177
+
178
+ @staticmethod
179
+ def validate_tool_configurations(tool_configs: List[dict]) -> List[str]:
180
+ """Validate tool configurations for unattended operation.
181
+
182
+ Args:
183
+ tool_configs: List of tool configurations
184
+
185
+ Returns:
186
+ List of validation errors, empty if all tools are compatible
187
+ """
188
+ errors = []
189
+
190
+ for tool_config in tool_configs:
191
+ tool_type = tool_config.get("type", "unknown")
192
+
193
+ # Validate MCP tools
194
+ if tool_type == "mcp":
195
+ if tool_config.get("require_approval") != "never":
196
+ errors.append(
197
+ f"MCP tool {tool_config.get('server_label', 'unknown')} "
198
+ f"requires approval - incompatible with unattended operation"
199
+ )
200
+
201
+ # Validate Code Interpreter - should be fine for unattended use
202
+ elif tool_type == "code_interpreter":
203
+ # Code Interpreter is inherently unattended-compatible
204
+ pass
205
+
206
+ # Validate File Search - should be fine for unattended use
207
+ elif tool_type == "file_search":
208
+ # File Search is inherently unattended-compatible
209
+ pass
210
+
211
+ # Unknown tool types - warn but don't fail
212
+ else:
213
+ logger.warning(
214
+ f"Unknown tool type '{tool_type}' - cannot validate for unattended operation"
215
+ )
216
+
217
+ return errors
218
+
219
+ @staticmethod
220
+ def get_automation_recommendations() -> List[str]:
221
+ """Get recommendations for better automation compatibility.
222
+
223
+ Returns:
224
+ List of recommendations for CI/CD usage
225
+ """
226
+ return [
227
+ "Use --dry-run to validate configuration before actual execution",
228
+ "Set explicit timeouts with --timeout for long-running operations",
229
+ "Configure MCP servers with require_approval='never'",
230
+ "Use structured logging for better CI/CD integration",
231
+ "Test operations locally before deploying to CI/CD",
232
+ "Monitor container expiration limits (20min runtime, 2min idle)",
233
+ "Use retry logic for transient network failures",
234
+ "Validate API keys and permissions before execution",
235
+ ]
236
+
237
+
238
+ def ensure_unattended_compatibility(
239
+ mcp_servers: Optional[List[dict]] = None,
240
+ tool_configs: Optional[List[dict]] = None,
241
+ timeout_seconds: int = 3600,
242
+ ) -> tuple[UnattendedOperationManager, List[str]]:
243
+ """Ensure complete unattended operation compatibility.
244
+
245
+ Args:
246
+ mcp_servers: Optional list of MCP server configurations
247
+ tool_configs: Optional list of tool configurations
248
+ timeout_seconds: Operation timeout in seconds
249
+
250
+ Returns:
251
+ Tuple of (operation_manager, validation_errors)
252
+ """
253
+ validator = UnattendedCompatibilityValidator()
254
+ all_errors = []
255
+
256
+ # Validate MCP servers
257
+ if mcp_servers:
258
+ mcp_errors = validator.validate_mcp_servers(mcp_servers)
259
+ all_errors.extend(mcp_errors)
260
+
261
+ # Validate tool configurations
262
+ if tool_configs:
263
+ tool_errors = validator.validate_tool_configurations(tool_configs)
264
+ all_errors.extend(tool_errors)
265
+
266
+ # Create operation manager
267
+ operation_manager = UnattendedOperationManager(timeout_seconds)
268
+
269
+ return operation_manager, all_errors
ostruct/cli/validators.py CHANGED
@@ -1,12 +1,36 @@
1
1
  """Validators for CLI options and arguments."""
2
2
 
3
3
  import json
4
+ import logging
5
+ import os
4
6
  from pathlib import Path
5
- from typing import Any, List, Optional, Tuple, Union
7
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
8
 
7
9
  import click
10
+ import jinja2
8
11
 
9
- from .errors import InvalidJSONError, VariableNameError
12
+ from .errors import (
13
+ DirectoryNotFoundError,
14
+ InvalidJSONError,
15
+ SchemaFileError,
16
+ SchemaValidationError,
17
+ VariableNameError,
18
+ VariableValueError,
19
+ )
20
+ from .explicit_file_processor import ExplicitFileProcessor
21
+ from .security import SecurityManager
22
+ from .template_env import create_jinja_env
23
+ from .template_processor import (
24
+ create_template_context_from_routing,
25
+ validate_task_template,
26
+ )
27
+ from .template_utils import validate_json_schema
28
+ from .types import CLIParams
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Type alias for file routing results
33
+ FileRoutingResult = List[Tuple[Optional[str], Union[str, Path]]]
10
34
 
11
35
 
12
36
  def validate_name_path_pair(
@@ -38,6 +62,68 @@ def validate_name_path_pair(
38
62
  return result
39
63
 
40
64
 
65
+ def validate_file_routing_spec(
66
+ ctx: click.Context,
67
+ param: click.Parameter,
68
+ value: List[str],
69
+ ) -> FileRoutingResult:
70
+ """Validate file routing specifications supporting multiple syntaxes.
71
+
72
+ Supports two syntaxes currently:
73
+ - Simple path: -ft config.yaml (auto-generates variable name)
74
+ - Equals syntax: -ft code_file=src/main.py (custom variable name)
75
+
76
+ Note: Two-argument syntax (-ft name path) requires special handling at the CLI level
77
+ and is not supported in this validator due to Click's argument processing.
78
+
79
+ Args:
80
+ ctx: Click context
81
+ param: Click parameter
82
+ value: List of file specifications from multiple options
83
+
84
+ Returns:
85
+ List of (variable_name, path) tuples where variable_name=None means auto-generate
86
+
87
+ Raises:
88
+ click.BadParameter: If validation fails
89
+ """
90
+ if not value:
91
+ return []
92
+
93
+ result: FileRoutingResult = []
94
+
95
+ for spec in value:
96
+ if "=" in spec:
97
+ # Equals syntax: -ft code_file=src/main.py
98
+ if spec.count("=") != 1:
99
+ raise click.BadParameter(
100
+ f"Invalid format '{spec}'. Use name=path or just path."
101
+ )
102
+ name, path = spec.split("=", 1)
103
+ if not name.strip():
104
+ raise click.BadParameter(f"Empty variable name in '{spec}'")
105
+ if not path.strip():
106
+ raise click.BadParameter(f"Empty path in '{spec}'")
107
+ name = name.strip()
108
+ path = path.strip()
109
+ if not name.isidentifier():
110
+ raise click.BadParameter(f"Invalid variable name: {name}")
111
+ if not Path(path).exists():
112
+ raise click.BadParameter(f"File not found: {path}")
113
+ result.append((name, Path(path)))
114
+ else:
115
+ # Simple path: -ft config.yaml
116
+ path = spec.strip()
117
+ if not path:
118
+ raise click.BadParameter("Empty file path")
119
+ if not Path(path).exists():
120
+ raise click.BadParameter(f"File not found: {path}")
121
+ # Mark as auto-name with None, will be processed later
122
+ result.append((None, Path(path)))
123
+
124
+ return result
125
+
126
+
41
127
  def validate_variable(
42
128
  ctx: click.Context, param: click.Parameter, value: Optional[List[str]]
43
129
  ) -> Optional[List[Tuple[str, str]]]:
@@ -95,7 +181,7 @@ def validate_json_variable(
95
181
  for var in value:
96
182
  if "=" not in var:
97
183
  raise InvalidJSONError(
98
- f'JSON variable must be in format name=\'{"json":"value"}\': {var}'
184
+ f"JSON variable must be in format name='{'json':\"value\"}': {var}"
99
185
  )
100
186
  name, json_str = var.split("=", 1)
101
187
  name = name.strip()
@@ -111,3 +197,300 @@ def validate_json_variable(
111
197
  context={"variable_name": name},
112
198
  ) from e
113
199
  return result
200
+
201
+
202
+ def parse_var(var_str: str) -> Tuple[str, str]:
203
+ """Parse a simple variable string in the format 'name=value'.
204
+
205
+ Args:
206
+ var_str: Variable string in format 'name=value'
207
+
208
+ Returns:
209
+ Tuple of (name, value)
210
+
211
+ Raises:
212
+ VariableNameError: If variable name is empty or invalid
213
+ VariableValueError: If variable format is invalid
214
+ """
215
+ try:
216
+ name, value = var_str.split("=", 1)
217
+ if not name:
218
+ raise VariableNameError("Empty name in variable mapping")
219
+ if not name.isidentifier():
220
+ raise VariableNameError(
221
+ f"Invalid variable name: {name}. Must be a valid Python identifier"
222
+ )
223
+ return name, value
224
+ except ValueError as e:
225
+ if "not enough values to unpack" in str(e):
226
+ raise VariableValueError(
227
+ f"Invalid variable mapping (expected name=value format): {var_str!r}"
228
+ )
229
+ raise
230
+
231
+
232
+ def parse_json_var(var_str: str) -> Tuple[str, Any]:
233
+ """Parse a JSON variable string in the format 'name=json_value'.
234
+
235
+ Args:
236
+ var_str: Variable string in format 'name=json_value'
237
+
238
+ Returns:
239
+ Tuple of (name, parsed_value)
240
+
241
+ Raises:
242
+ VariableNameError: If variable name is empty or invalid
243
+ VariableValueError: If variable format is invalid
244
+ InvalidJSONError: If JSON value is invalid
245
+ """
246
+ try:
247
+ name, json_str = var_str.split("=", 1)
248
+ if not name:
249
+ raise VariableNameError("Empty name in JSON variable mapping")
250
+ if not name.isidentifier():
251
+ raise VariableNameError(
252
+ f"Invalid variable name: {name}. Must be a valid Python identifier"
253
+ )
254
+
255
+ try:
256
+ value = json.loads(json_str)
257
+ except json.JSONDecodeError as e:
258
+ raise InvalidJSONError(
259
+ f"Error parsing JSON for variable '{name}': {str(e)}. Input was: {json_str}",
260
+ context={"variable_name": name},
261
+ )
262
+
263
+ return name, value
264
+
265
+ except ValueError as e:
266
+ if "not enough values to unpack" in str(e):
267
+ raise VariableValueError(
268
+ f"Invalid JSON variable mapping (expected name=json format): {var_str!r}"
269
+ )
270
+ raise
271
+
272
+
273
+ def validate_variable_mapping(
274
+ mapping: str, is_json: bool = False
275
+ ) -> tuple[str, Any]:
276
+ """Validate a variable mapping in name=value format."""
277
+ try:
278
+ name, value = mapping.split("=", 1)
279
+ if not name:
280
+ raise VariableNameError(
281
+ f"Empty name in {'JSON ' if is_json else ''}variable mapping"
282
+ )
283
+
284
+ if is_json:
285
+ try:
286
+ value = json.loads(value)
287
+ except json.JSONDecodeError as e:
288
+ raise InvalidJSONError(
289
+ f"Invalid JSON value for variable {name!r}: {value!r}",
290
+ context={"variable_name": name},
291
+ ) from e
292
+
293
+ return name, value
294
+
295
+ except ValueError as e:
296
+ if "not enough values to unpack" in str(e):
297
+ raise VariableValueError(
298
+ f"Invalid {'JSON ' if is_json else ''}variable mapping "
299
+ f"(expected name=value format): {mapping!r}"
300
+ )
301
+ raise
302
+
303
+
304
+ def validate_schema_file(
305
+ path: str,
306
+ verbose: bool = False,
307
+ ) -> Dict[str, Any]:
308
+ """Validate and load a JSON schema file.
309
+
310
+ Args:
311
+ path: Path to the schema file
312
+ verbose: Whether to log additional information
313
+
314
+ Returns:
315
+ Dictionary containing the schema data
316
+
317
+ Raises:
318
+ SchemaFileError: If the schema file is invalid
319
+ """
320
+ try:
321
+ with Path(path).open("r") as f:
322
+ schema_data = json.load(f)
323
+
324
+ # Validate basic schema structure
325
+ if not isinstance(schema_data, dict):
326
+ raise SchemaFileError(
327
+ f"Schema file must contain a JSON object, got {type(schema_data).__name__}",
328
+ schema_path=path,
329
+ )
330
+
331
+ # Must have either "schema" key or be a direct schema
332
+ if "schema" in schema_data:
333
+ # Wrapped schema format
334
+ actual_schema = schema_data["schema"]
335
+ else:
336
+ # Direct schema format
337
+ actual_schema = schema_data
338
+
339
+ if not isinstance(actual_schema, dict):
340
+ raise SchemaFileError(
341
+ f"Schema must be a JSON object, got {type(actual_schema).__name__}",
342
+ schema_path=path,
343
+ )
344
+
345
+ # Validate that root type is object for structured output
346
+ if actual_schema.get("type") != "object":
347
+ raise SchemaFileError(
348
+ f"Schema root type must be 'object', got '{actual_schema.get('type')}'",
349
+ schema_path=path,
350
+ )
351
+
352
+ return schema_data
353
+
354
+ except json.JSONDecodeError as e:
355
+ raise InvalidJSONError(
356
+ f"Invalid JSON in schema file: {e}",
357
+ source=path,
358
+ ) from e
359
+ except FileNotFoundError as e:
360
+ raise SchemaFileError(
361
+ f"Schema file not found: {path}",
362
+ schema_path=path,
363
+ ) from e
364
+ except Exception as e:
365
+ raise SchemaFileError(
366
+ f"Error reading schema file: {e}",
367
+ schema_path=path,
368
+ ) from e
369
+
370
+
371
+ def validate_security_manager(
372
+ base_dir: Optional[str] = None,
373
+ allowed_dirs: Optional[List[str]] = None,
374
+ allowed_dir_file: Optional[str] = None,
375
+ ) -> SecurityManager:
376
+ """Validate and create security manager.
377
+
378
+ Args:
379
+ base_dir: Base directory for file access. Defaults to current working directory.
380
+ allowed_dirs: Optional list of additional allowed directories
381
+ allowed_dir_file: Optional file containing allowed directories
382
+
383
+ Returns:
384
+ Configured SecurityManager instance
385
+
386
+ Raises:
387
+ PathSecurityError: If any paths violate security constraints
388
+ DirectoryNotFoundError: If any directories do not exist
389
+ """
390
+ # Use current working directory if base_dir is None
391
+ if base_dir is None:
392
+ base_dir = os.getcwd()
393
+
394
+ # Create security manager with base directory
395
+ security_manager = SecurityManager(base_dir)
396
+
397
+ # Add explicitly allowed directories
398
+ if allowed_dirs:
399
+ for dir_path in allowed_dirs:
400
+ security_manager.add_allowed_directory(dir_path)
401
+
402
+ # Add directories from file if specified
403
+ if allowed_dir_file:
404
+ try:
405
+ with open(allowed_dir_file, "r", encoding="utf-8") as f:
406
+ for line in f:
407
+ line = line.strip()
408
+ if line and not line.startswith("#"):
409
+ security_manager.add_allowed_directory(line)
410
+ except OSError as e:
411
+ raise DirectoryNotFoundError(
412
+ f"Failed to read allowed directories file: {e}"
413
+ )
414
+
415
+ return security_manager
416
+
417
+
418
+ async def validate_inputs(
419
+ args: CLIParams,
420
+ ) -> Tuple[
421
+ SecurityManager,
422
+ str,
423
+ Dict[str, Any],
424
+ Dict[str, Any],
425
+ jinja2.Environment,
426
+ Optional[str],
427
+ ]:
428
+ """Validate all input parameters and return validated components.
429
+
430
+ Args:
431
+ args: Command line arguments
432
+
433
+ Returns:
434
+ Tuple containing:
435
+ - SecurityManager instance
436
+ - Task template string
437
+ - Schema dictionary
438
+ - Template context dictionary
439
+ - Jinja2 environment
440
+ - Template file path (if from file)
441
+
442
+ Raises:
443
+ CLIError: For various validation errors
444
+ SchemaValidationError: When schema is invalid
445
+ """
446
+ logger.debug("=== Input Validation Phase ===")
447
+ security_manager = validate_security_manager(
448
+ base_dir=args.get("base_dir"),
449
+ allowed_dirs=args.get("allowed_dirs"),
450
+ allowed_dir_file=args.get("allowed_dir_file"),
451
+ )
452
+
453
+ # Process explicit file routing (T2.4)
454
+ logger.debug("Processing explicit file routing")
455
+ file_processor = ExplicitFileProcessor(security_manager)
456
+ routing_result = await file_processor.process_file_routing(args) # type: ignore[arg-type]
457
+
458
+ # Display auto-enablement feedback to user
459
+ if routing_result.auto_enabled_feedback:
460
+ print(routing_result.auto_enabled_feedback)
461
+
462
+ # Store routing result in args for use by tool processors
463
+ args["_routing_result"] = routing_result # type: ignore[typeddict-unknown-key]
464
+
465
+ task_template = validate_task_template(
466
+ args.get("task"), args.get("task_file")
467
+ )
468
+
469
+ # Load and validate schema
470
+ logger.debug("Validating schema from %s", args["schema_file"])
471
+ try:
472
+ schema = validate_schema_file(
473
+ args["schema_file"], args.get("verbose", False)
474
+ )
475
+
476
+ # Validate schema structure before any model creation
477
+ validate_json_schema(
478
+ schema
479
+ ) # This will raise SchemaValidationError if invalid
480
+ except SchemaValidationError as e:
481
+ logger.error("Schema validation error: %s", str(e))
482
+ raise # Re-raise the SchemaValidationError to preserve the error chain
483
+
484
+ template_context = await create_template_context_from_routing(
485
+ args, security_manager, routing_result
486
+ )
487
+ env = create_jinja_env()
488
+
489
+ return (
490
+ security_manager,
491
+ task_template,
492
+ schema,
493
+ template_context,
494
+ env,
495
+ args.get("task_file"),
496
+ )
@@ -1,5 +1,7 @@
1
1
  MIT License
2
2
 
3
+ SPDX-License-Identifier: MIT
4
+
3
5
  Copyright (c) 2024 Yaniv Golan
4
6
 
5
7
  Permission is hereby granted, free of charge, to any person obtaining a copy