ostruct-cli 0.8.8__py3-none-any.whl → 1.0.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 (50) hide show
  1. ostruct/cli/__init__.py +3 -15
  2. ostruct/cli/attachment_processor.py +455 -0
  3. ostruct/cli/attachment_template_bridge.py +973 -0
  4. ostruct/cli/cli.py +187 -33
  5. ostruct/cli/click_options.py +775 -692
  6. ostruct/cli/code_interpreter.py +195 -12
  7. ostruct/cli/commands/__init__.py +0 -3
  8. ostruct/cli/commands/run.py +289 -62
  9. ostruct/cli/config.py +23 -22
  10. ostruct/cli/constants.py +89 -0
  11. ostruct/cli/errors.py +191 -6
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +118 -14
  14. ostruct/cli/file_list.py +82 -1
  15. ostruct/cli/file_search.py +68 -2
  16. ostruct/cli/help_json.py +235 -0
  17. ostruct/cli/mcp_integration.py +13 -16
  18. ostruct/cli/params.py +217 -0
  19. ostruct/cli/plan_assembly.py +335 -0
  20. ostruct/cli/plan_printing.py +385 -0
  21. ostruct/cli/progress_reporting.py +8 -56
  22. ostruct/cli/quick_ref_help.py +128 -0
  23. ostruct/cli/rich_config.py +299 -0
  24. ostruct/cli/runner.py +397 -190
  25. ostruct/cli/security/__init__.py +2 -0
  26. ostruct/cli/security/allowed_checker.py +41 -0
  27. ostruct/cli/security/normalization.py +13 -9
  28. ostruct/cli/security/security_manager.py +558 -17
  29. ostruct/cli/security/types.py +15 -0
  30. ostruct/cli/template_debug.py +283 -261
  31. ostruct/cli/template_debug_help.py +233 -142
  32. ostruct/cli/template_env.py +46 -5
  33. ostruct/cli/template_filters.py +415 -8
  34. ostruct/cli/template_processor.py +240 -619
  35. ostruct/cli/template_rendering.py +49 -73
  36. ostruct/cli/template_validation.py +2 -1
  37. ostruct/cli/token_validation.py +35 -15
  38. ostruct/cli/types.py +15 -19
  39. ostruct/cli/unicode_compat.py +283 -0
  40. ostruct/cli/upload_manager.py +448 -0
  41. ostruct/cli/utils.py +30 -0
  42. ostruct/cli/validators.py +272 -54
  43. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +292 -126
  44. ostruct_cli-1.0.0.dist-info/RECORD +80 -0
  45. ostruct/cli/commands/quick_ref.py +0 -54
  46. ostruct/cli/template_optimizer.py +0 -478
  47. ostruct_cli-0.8.8.dist-info/RECORD +0 -71
  48. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
  49. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
  50. {ostruct_cli-0.8.8.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
ostruct/cli/validators.py CHANGED
@@ -9,7 +9,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
9
9
  import click
10
10
  import jinja2
11
11
 
12
+ from .constants import DefaultPaths
12
13
  from .errors import (
14
+ CLIError,
13
15
  DirectoryNotFoundError,
14
16
  InvalidJSONError,
15
17
  SchemaFileError,
@@ -17,7 +19,7 @@ from .errors import (
17
19
  VariableNameError,
18
20
  VariableValueError,
19
21
  )
20
- from .explicit_file_processor import ExplicitFileProcessor
22
+ from .exit_codes import ExitCode
21
23
  from .security import SecurityManager
22
24
  from .template_env import create_jinja_env
23
25
  from .template_processor import (
@@ -26,6 +28,7 @@ from .template_processor import (
26
28
  )
27
29
  from .template_utils import validate_json_schema
28
30
  from .types import CLIParams
31
+ from .utils import fix_surrogate_escapes
29
32
 
30
33
  logger = logging.getLogger(__name__)
31
34
 
@@ -33,35 +36,6 @@ logger = logging.getLogger(__name__)
33
36
  FileRoutingResult = List[Tuple[Optional[str], Union[str, Path]]]
34
37
 
35
38
 
36
- def validate_name_path_pair(
37
- ctx: click.Context,
38
- param: click.Parameter,
39
- value: List[Tuple[str, Union[str, Path]]],
40
- ) -> List[Tuple[str, Union[str, Path]]]:
41
- """Validate name/path pairs for files and directories.
42
-
43
- Args:
44
- ctx: Click context
45
- param: Click parameter
46
- value: List of (name, path) tuples
47
-
48
- Returns:
49
- List of validated (name, Path) tuples
50
-
51
- Raises:
52
- click.BadParameter: If validation fails
53
- """
54
- if not value:
55
- return value
56
-
57
- result: List[Tuple[str, Union[str, Path]]] = []
58
- for name, path in value:
59
- if not name.isidentifier():
60
- raise click.BadParameter(f"Invalid variable name: {name}")
61
- result.append((name, Path(path)))
62
- return result
63
-
64
-
65
39
  def validate_file_routing_spec(
66
40
  ctx: click.Context,
67
41
  param: click.Parameter,
@@ -69,23 +43,23 @@ def validate_file_routing_spec(
69
43
  ) -> FileRoutingResult:
70
44
  """Validate file routing specifications supporting multiple syntaxes.
71
45
 
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)
46
+ Supports two syntaxes currently:
47
+ - Simple path: --file config.yaml (auto-generates variable name)
48
+ - Equals syntax: --file code_file=src/main.py (custom variable name)
75
49
 
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.
50
+ Note: Two-argument syntax (--file name path) requires special handling at the CLI level
51
+ and is not supported in this validator due to Click's argument processing.
78
52
 
79
- Args:
80
- ctx: Click context
81
- param: Click parameter
82
- value: List of file specifications from multiple options
53
+ Args:
54
+ ctx: Click context
55
+ param: Click parameter
56
+ value: List of file specifications from multiple options
83
57
 
84
- Returns:
85
- List of (variable_name, path) tuples where variable_name=None means auto-generate
58
+ Returns:
59
+ List of (variable_name, path) tuples where variable_name=None means auto-generate
86
60
 
87
- Raises:
88
- click.BadParameter: If validation fails
61
+ Raises:
62
+ click.BadParameter: If validation fails
89
63
  """
90
64
  if not value:
91
65
  return []
@@ -94,7 +68,7 @@ def validate_file_routing_spec(
94
68
 
95
69
  for spec in value:
96
70
  if "=" in spec:
97
- # Equals syntax: -ft code_file=src/main.py
71
+ # Equals syntax: --file code_file=src/main.py
98
72
  if spec.count("=") != 1:
99
73
  raise click.BadParameter(
100
74
  f"Invalid format '{spec}'. Use name=path or just path."
@@ -112,7 +86,7 @@ def validate_file_routing_spec(
112
86
  raise click.BadParameter(f"File not found: {path}")
113
87
  result.append((name, Path(path)))
114
88
  else:
115
- # Simple path: -ft config.yaml
89
+ # Simple path: --file config.yaml
116
90
  path = spec.strip()
117
91
  if not path:
118
92
  raise click.BadParameter("Empty file path")
@@ -145,6 +119,9 @@ def validate_variable(
145
119
 
146
120
  result = []
147
121
  for var in value:
122
+ # Fix any surrogate escape issues in the variable string
123
+ var = fix_surrogate_escapes(var)
124
+
148
125
  if "=" not in var:
149
126
  raise click.BadParameter(
150
127
  f"Variable must be in format name=value: {var}"
@@ -152,6 +129,11 @@ def validate_variable(
152
129
  name, val = var.split("=", 1)
153
130
  name = name.strip()
154
131
  val = val.strip()
132
+
133
+ # Fix surrogate escapes in both name and value
134
+ name = fix_surrogate_escapes(name)
135
+ val = fix_surrogate_escapes(val)
136
+
155
137
  if not name.isidentifier():
156
138
  raise click.BadParameter(f"Invalid variable name: {name}")
157
139
  result.append((name, val))
@@ -179,13 +161,21 @@ def validate_json_variable(
179
161
 
180
162
  result = []
181
163
  for var in value:
164
+ # Fix any surrogate escape issues in the variable string
165
+ var = fix_surrogate_escapes(var)
166
+
182
167
  if "=" not in var:
183
168
  raise InvalidJSONError(
184
- f"JSON variable must be in format name='{'json':\"value\"}': {var}"
169
+ f'JSON variable must be in format name={{"json":"value"}}: {var}'
185
170
  )
186
171
  name, json_str = var.split("=", 1)
187
172
  name = name.strip()
188
173
  json_str = json_str.strip()
174
+
175
+ # Fix surrogate escapes in both name and JSON string
176
+ name = fix_surrogate_escapes(name)
177
+ json_str = fix_surrogate_escapes(json_str)
178
+
189
179
  if not name.isidentifier():
190
180
  raise VariableNameError(f"Invalid variable name: {name}")
191
181
  try:
@@ -372,6 +362,9 @@ def validate_security_manager(
372
362
  base_dir: Optional[str] = None,
373
363
  allowed_dirs: Optional[List[str]] = None,
374
364
  allowed_dir_file: Optional[str] = None,
365
+ allow_files: Optional[List[str]] = None,
366
+ allow_lists: Optional[List[str]] = None,
367
+ security_mode: str = "warn",
375
368
  ) -> SecurityManager:
376
369
  """Validate and create security manager.
377
370
 
@@ -379,6 +372,9 @@ def validate_security_manager(
379
372
  base_dir: Base directory for file access. Defaults to current working directory.
380
373
  allowed_dirs: Optional list of additional allowed directories
381
374
  allowed_dir_file: Optional file containing allowed directories
375
+ allow_files: Optional list of individual files to allow (tracked by inode)
376
+ allow_lists: Optional list of allow-list files to load
377
+ security_mode: Path security mode (permissive/warn/strict)
382
378
 
383
379
  Returns:
384
380
  Configured SecurityManager instance
@@ -391,8 +387,22 @@ def validate_security_manager(
391
387
  if base_dir is None:
392
388
  base_dir = os.getcwd()
393
389
 
394
- # Create security manager with base directory
395
- security_manager = SecurityManager(base_dir)
390
+ # Parse security mode
391
+ from .security.types import PathSecurity
392
+
393
+ # Ensure security_mode is not None (safety check)
394
+ if security_mode is None:
395
+ security_mode = "warn"
396
+
397
+ try:
398
+ parsed_security_mode = PathSecurity(security_mode.lower())
399
+ except ValueError:
400
+ parsed_security_mode = PathSecurity.WARN
401
+
402
+ # Create security manager with base directory and security mode
403
+ security_manager = SecurityManager(
404
+ base_dir, security_mode=parsed_security_mode
405
+ )
396
406
 
397
407
  # Add explicitly allowed directories
398
408
  if allowed_dirs:
@@ -412,9 +422,114 @@ def validate_security_manager(
412
422
  f"Failed to read allowed directories file: {e}"
413
423
  )
414
424
 
425
+ # Configure enhanced security features (allow_files and allow_lists)
426
+ if allow_files or allow_lists:
427
+ security_manager.configure_security_mode(
428
+ mode=parsed_security_mode,
429
+ allow_files=allow_files,
430
+ allow_lists=allow_lists,
431
+ )
432
+
415
433
  return security_manager
416
434
 
417
435
 
436
+ def validate_download_configuration(args: CLIParams) -> None:
437
+ """Validate download configuration without creating directories.
438
+
439
+ This function validates that the download directory can be written to
440
+ when Code Interpreter is being used, without actually creating the directory.
441
+
442
+ Args:
443
+ args: CLI parameters
444
+
445
+ Raises:
446
+ CLIError: If download directory validation fails
447
+ """
448
+ logger.debug("=== Download Directory Validation ===")
449
+
450
+ # Check if Code Interpreter is being used
451
+ routing_result = args.get("_routing_result")
452
+ logger.debug(f"Routing result: {routing_result}")
453
+
454
+ if not routing_result or not hasattr(routing_result, "enabled_tools"):
455
+ logger.debug(
456
+ "No routing result available, skipping download validation"
457
+ )
458
+ return # No routing result available, skip validation
459
+
460
+ logger.debug(f"Enabled tools: {routing_result.enabled_tools}")
461
+
462
+ if "code-interpreter" not in routing_result.enabled_tools:
463
+ logger.debug(
464
+ "Code Interpreter not being used, skipping download validation"
465
+ )
466
+ return # Code Interpreter not being used, skip validation
467
+
468
+ # Get resolved download directory using same logic as runner.py
469
+ from typing import Union, cast
470
+
471
+ from .config import OstructConfig
472
+
473
+ config_path = cast(Union[str, Path, None], args.get("config"))
474
+ config = OstructConfig.load(config_path)
475
+ ci_config = config.get_code_interpreter_config()
476
+
477
+ download_dir = (
478
+ args.get("ci_download_dir") # CLI flag has highest priority
479
+ or ci_config.get("output_directory") # Config file is second
480
+ or DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR # Default is last
481
+ )
482
+
483
+ logger.debug(f"Validating download directory: {download_dir}")
484
+
485
+ # Convert to Path object
486
+ download_path = Path(download_dir)
487
+
488
+ # Validate parent directory exists and is writable
489
+ parent_dir = download_path.parent
490
+ if not parent_dir.exists():
491
+ raise CLIError(
492
+ f"Parent directory does not exist: {parent_dir}\n"
493
+ f"💡 Try:\n"
494
+ f" • Create parent directory: mkdir -p {parent_dir}\n"
495
+ f" • Use different directory: --ci-download-dir ./my-downloads\n"
496
+ f" • Check current directory: pwd",
497
+ exit_code=ExitCode.USAGE_ERROR,
498
+ )
499
+
500
+ if not os.access(parent_dir, os.W_OK):
501
+ raise CLIError(
502
+ f"No write permission to parent directory: {parent_dir}\n"
503
+ f"💡 Try:\n"
504
+ f" • Check directory permissions: ls -la {parent_dir}\n"
505
+ f" • Fix permissions: chmod u+w {parent_dir}\n"
506
+ f" • Use different directory: --ci-download-dir ./my-downloads",
507
+ exit_code=ExitCode.USAGE_ERROR,
508
+ )
509
+
510
+ # Check if download directory exists and is writable (if it exists)
511
+ if download_path.exists():
512
+ if not download_path.is_dir():
513
+ raise CLIError(
514
+ f"Download path exists but is not a directory: {download_path}\n"
515
+ f"💡 Try:\n"
516
+ f" • Remove the file: rm {download_path}\n"
517
+ f" • Use different directory: --ci-download-dir ./my-downloads",
518
+ exit_code=ExitCode.USAGE_ERROR,
519
+ )
520
+
521
+ if not os.access(download_path, os.W_OK):
522
+ raise CLIError(
523
+ f"No write permission to download directory: {download_path}\n"
524
+ f"💡 Try:\n"
525
+ f" • Fix permissions: chmod u+w {download_path}\n"
526
+ f" • Use different directory: --ci-download-dir ./my-downloads",
527
+ exit_code=ExitCode.USAGE_ERROR,
528
+ )
529
+
530
+ logger.debug(f"Download directory validation passed: {download_dir}")
531
+
532
+
418
533
  async def validate_inputs(
419
534
  args: CLIParams,
420
535
  ) -> Tuple[
@@ -444,16 +559,37 @@ async def validate_inputs(
444
559
  SchemaValidationError: When schema is invalid
445
560
  """
446
561
  logger.debug("=== Input Validation Phase ===")
562
+ logger.debug(f"validate_inputs called with args keys: {list(args.keys())}")
447
563
  security_manager = validate_security_manager(
448
564
  base_dir=args.get("base_dir"),
449
- allowed_dirs=args.get("allowed_dirs"),
565
+ allowed_dirs=args.get("allow_dir"), # type: ignore[arg-type]
450
566
  allowed_dir_file=args.get("allowed_dir_file"),
567
+ allow_files=args.get("allow_file"), # type: ignore[arg-type]
568
+ allow_lists=args.get("allow_list"), # type: ignore[arg-type]
569
+ security_mode=args.get("path_security", "warn"), # type: ignore[arg-type]
451
570
  )
452
571
 
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]
572
+ # Process file routing using new attachment system only (T3.0)
573
+ logger.debug("Processing file routing with new attachment system")
574
+
575
+ from .attachment_processor import process_new_attachments
576
+
577
+ routing_result = process_new_attachments(args, security_manager)
578
+
579
+ # The new system is mandatory - no fallback to legacy file routing
580
+ if routing_result is None:
581
+ # Create empty routing result if no attachments specified
582
+ from .explicit_file_processor import ExplicitRouting, ProcessingResult
583
+
584
+ routing_result = ProcessingResult(
585
+ routing=ExplicitRouting(),
586
+ enabled_tools=set(),
587
+ validated_files={},
588
+ auto_enabled_feedback=None,
589
+ )
590
+ logger.debug("No attachments specified - created empty routing result")
591
+ else:
592
+ logger.debug("Processed new attachment system")
457
593
 
458
594
  # Display auto-enablement feedback to user
459
595
  if routing_result.auto_enabled_feedback:
@@ -462,6 +598,11 @@ async def validate_inputs(
462
598
  # Store routing result in args for use by tool processors
463
599
  args["_routing_result"] = routing_result # type: ignore[typeddict-unknown-key]
464
600
 
601
+ # Validate download directory configuration (after routing is processed)
602
+ logger.debug("About to call validate_download_configuration")
603
+ validate_download_configuration(args)
604
+ logger.debug("Finished validate_download_configuration")
605
+
465
606
  task_template = validate_task_template(
466
607
  args.get("task"), args.get("task_file")
467
608
  )
@@ -484,7 +625,27 @@ async def validate_inputs(
484
625
  template_context = await create_template_context_from_routing(
485
626
  args, security_manager, routing_result
486
627
  )
487
- env = create_jinja_env()
628
+
629
+ # Extract files from template context for file reference support
630
+ files = []
631
+ for key, value in template_context.items():
632
+ if hasattr(value, "__iter__") and not isinstance(value, (str, dict)):
633
+ # Check if this is a list of FileInfo objects
634
+ try:
635
+ for item in value:
636
+ if hasattr(
637
+ item, "parent_alias"
638
+ ): # FileInfo with TSES fields
639
+ files.append(item)
640
+ except (TypeError, AttributeError):
641
+ continue
642
+
643
+ # Create environment with file reference support
644
+ env, alias_manager = create_jinja_env(files=files)
645
+
646
+ # Store alias manager in args for use by template processor if it has any aliases
647
+ if alias_manager.aliases:
648
+ args["_alias_manager"] = alias_manager # type: ignore[typeddict-unknown-key]
488
649
 
489
650
  return (
490
651
  security_manager,
@@ -494,3 +655,60 @@ async def validate_inputs(
494
655
  env,
495
656
  args.get("task_file"),
496
657
  )
658
+
659
+
660
+ def parse_file_path_spec(
661
+ ctx: click.Context,
662
+ param: click.Parameter,
663
+ value: str,
664
+ ) -> Optional[Tuple[Optional[str], str]]:
665
+ """Parse file path specification with optional alias.
666
+
667
+ Supports multiple formats:
668
+ - Simple path: --file config.yaml (auto-generates variable name)
669
+ - Equals syntax: --file code_file=src/main.py (custom variable name)
670
+
671
+ Note: Two-argument syntax (--file name path) requires special handling at the CLI level
672
+ and is not processed by this validator.
673
+
674
+ Args:
675
+ ctx: Click context
676
+ param: Click parameter
677
+ value: Raw value from CLI
678
+
679
+ Returns:
680
+ Tuple of (alias, path) or None if value is None
681
+
682
+ Raises:
683
+ click.BadParameter: If the format is invalid
684
+ """
685
+ if not value:
686
+ return None
687
+
688
+ if "=" in value:
689
+ # Equals syntax: --file code_file=src/main.py
690
+ if value.count("=") != 1:
691
+ raise click.BadParameter(
692
+ f"Invalid format '{value}'. Use name=path or just path."
693
+ )
694
+ name, path = value.split("=", 1)
695
+ if not name.strip():
696
+ raise click.BadParameter(f"Empty variable name in '{value}'")
697
+ if not path.strip():
698
+ raise click.BadParameter(f"Empty path in '{value}'")
699
+ name = name.strip()
700
+ path = path.strip()
701
+ if not name.isidentifier():
702
+ raise click.BadParameter(f"Invalid variable name: {name}")
703
+ if not Path(path).exists():
704
+ raise click.BadParameter(f"File not found: {path}")
705
+ return (name, path)
706
+ else:
707
+ # Simple path: --file config.yaml
708
+ path = value.strip()
709
+ if not path:
710
+ raise click.BadParameter("Empty file path")
711
+ if not Path(path).exists():
712
+ raise click.BadParameter(f"File not found: {path}")
713
+ # Mark as auto-name with None, will be processed later
714
+ return (None, path)