ostruct-cli 0.8.29__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 (49) 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 +157 -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 +175 -5
  12. ostruct/cli/explicit_file_processor.py +0 -15
  13. ostruct/cli/file_info.py +97 -15
  14. ostruct/cli/file_list.py +43 -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/validators.py +255 -54
  42. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/METADATA +230 -127
  43. ostruct_cli-1.0.0.dist-info/RECORD +80 -0
  44. ostruct/cli/commands/quick_ref.py +0 -54
  45. ostruct/cli/template_optimizer.py +0 -478
  46. ostruct_cli-0.8.29.dist-info/RECORD +0 -71
  47. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/LICENSE +0 -0
  48. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/WHEEL +0 -0
  49. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.0.dist-info}/entry_points.txt +0 -0
@@ -3,14 +3,14 @@
3
3
  import json
4
4
  import logging
5
5
  import re
6
- import sys
7
6
  from pathlib import Path
8
- from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast
7
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
9
8
 
10
9
  import click
11
10
  import jinja2
12
11
  import yaml
13
12
 
13
+ from .constants import DefaultConfig
14
14
  from .errors import (
15
15
  DirectoryNotFoundError,
16
16
  DuplicateFileMappingError,
@@ -23,101 +23,44 @@ from .errors import (
23
23
  VariableNameError,
24
24
  )
25
25
  from .explicit_file_processor import ProcessingResult
26
- from .file_info import FileRoutingIntent
27
- from .file_utils import FileInfoList, collect_files
26
+ from .file_utils import FileInfoList
28
27
  from .path_utils import validate_path_mapping
29
28
  from .security import SecurityManager
30
- from .template_optimizer import (
31
- is_optimization_beneficial,
32
- optimize_template_for_llm,
29
+ from .template_debug import (
30
+ TDCap,
31
+ is_capacity_active,
32
+ td_log_if_active,
33
+ td_log_preview,
34
+ td_log_vars,
33
35
  )
34
36
  from .template_utils import render_template
35
37
  from .types import CLIParams
36
38
 
37
39
  logger = logging.getLogger(__name__)
38
40
 
39
- DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
41
+ DEFAULT_SYSTEM_PROMPT = DefaultConfig.TEMPLATE["system_prompt"]
40
42
 
41
43
 
42
44
  def _render_template_with_debug(
43
45
  template_content: str,
44
46
  context: Dict[str, Any],
45
47
  env: jinja2.Environment,
46
- no_optimization: bool = False,
47
- show_optimization_diff: bool = False,
48
- show_optimization_steps: bool = False,
49
- optimization_step_detail: str = "summary",
48
+ debug_capacities: Optional[Set[TDCap]] = None,
50
49
  ) -> str:
51
- """Render template with optimization debugging support.
50
+ """Render template with debugging support.
52
51
 
53
52
  Args:
54
53
  template_content: Template content to render
55
54
  context: Template context variables
56
55
  env: Jinja2 environment
57
- no_optimization: Skip optimization entirely
58
- show_optimization_diff: Show before/after optimization comparison
59
- show_optimization_steps: Show detailed optimization step tracking
60
- optimization_step_detail: Level of detail for optimization steps
56
+ debug_capacities: Set of active debug capacities (unused but kept for compatibility)
61
57
 
62
58
  Returns:
63
59
  Rendered template string
64
60
  """
65
- from .template_debug import show_optimization_diff as show_diff
66
-
67
- if no_optimization:
68
- # Skip optimization entirely - render directly
69
- template = env.from_string(template_content)
70
- return template.render(**context)
71
-
72
- # Handle optimization debugging (diff and/or steps)
73
- if show_optimization_diff or show_optimization_steps:
74
- # Check if optimization would be beneficial
75
- if is_optimization_beneficial(template_content):
76
- # Create step tracker if step tracking is enabled
77
- step_tracker = None
78
- if show_optimization_steps:
79
- from .template_debug import OptimizationStepTracker
80
-
81
- step_tracker = OptimizationStepTracker(enabled=True)
82
-
83
- # Get optimization result with optional step tracking
84
- optimization_result = optimize_template_for_llm(
85
- template_content, step_tracker
86
- )
87
-
88
- if optimization_result.has_optimizations:
89
- # Show the diff if requested
90
- if show_optimization_diff:
91
- show_diff(
92
- template_content,
93
- optimization_result.optimized_template,
94
- )
95
-
96
- # Show optimization steps if requested
97
- if show_optimization_steps and step_tracker:
98
- if optimization_step_detail == "detailed":
99
- step_tracker.show_detailed_steps()
100
- else:
101
- step_tracker.show_step_summary()
102
-
103
- # Render the optimized version
104
- template = env.from_string(
105
- optimization_result.optimized_template
106
- )
107
- return template.render(**context)
108
-
109
- # No optimization was applied, show that too
110
- if show_optimization_diff:
111
- show_diff(template_content, template_content)
112
- if show_optimization_steps:
113
- from .template_debug import (
114
- show_optimization_steps as show_steps_func,
115
- )
116
-
117
- show_steps_func([], optimization_step_detail)
118
-
119
- # Fall back to standard rendering (which includes optimization)
120
- return render_template(template_content, context, env)
61
+ # Simple template rendering without optimization
62
+ template = env.from_string(template_content)
63
+ return template.render(**context)
121
64
 
122
65
 
123
66
  def process_system_prompt(
@@ -128,7 +71,7 @@ def process_system_prompt(
128
71
  env: jinja2.Environment,
129
72
  ignore_task_sysprompt: bool = False,
130
73
  template_path: Optional[str] = None,
131
- ) -> str:
74
+ ) -> Tuple[str, bool]:
132
75
  """Process system prompt from various sources.
133
76
 
134
77
  Args:
@@ -141,7 +84,7 @@ def process_system_prompt(
141
84
  template_path: Optional path to template file for include_system resolution
142
85
 
143
86
  Returns:
144
- The final system prompt string
87
+ Tuple of (final system prompt string, template_has_system_prompt)
145
88
 
146
89
  Raises:
147
90
  SystemPromptError: If the system prompt cannot be loaded or rendered
@@ -156,6 +99,27 @@ def process_system_prompt(
156
99
 
157
100
  # CLI system prompt takes precedence and stops further processing
158
101
  if system_prompt_file is not None:
102
+ # Check for conflict with YAML frontmatter system_prompt and warn
103
+ template_has_system_prompt = False
104
+ if task_template.startswith("---\n"):
105
+ end = task_template.find("\n---\n", 4)
106
+ if end != -1:
107
+ frontmatter = task_template[4:end]
108
+ try:
109
+ metadata = yaml.safe_load(frontmatter)
110
+ if (
111
+ isinstance(metadata, dict)
112
+ and "system_prompt" in metadata
113
+ ):
114
+ template_has_system_prompt = True
115
+ logger.warning(
116
+ "Template has YAML frontmatter with 'system_prompt' field, but --sys-file was also provided. "
117
+ "Using --sys-file and ignoring YAML frontmatter system_prompt."
118
+ )
119
+ except yaml.YAMLError:
120
+ # If YAML is invalid, we'll catch it later in template processing
121
+ pass
122
+
159
123
  try:
160
124
  name, path = validate_path_mapping(
161
125
  f"system_prompt={system_prompt_file}"
@@ -175,6 +139,9 @@ def process_system_prompt(
175
139
  except jinja2.TemplateError as e:
176
140
  raise SystemPromptError(f"Error rendering system prompt: {e}")
177
141
 
142
+ # Return the warning information along with the prompt
143
+ return base_prompt, template_has_system_prompt
144
+
178
145
  elif system_prompt is not None:
179
146
  try:
180
147
  template = env.from_string(system_prompt)
@@ -289,7 +256,7 @@ def process_system_prompt(
289
256
 
290
257
  base_prompt += ci_download_instructions
291
258
 
292
- return base_prompt
259
+ return base_prompt, False
293
260
 
294
261
 
295
262
  def validate_task_template(
@@ -362,133 +329,63 @@ async def process_templates(
362
329
  env: jinja2.Environment,
363
330
  template_path: Optional[str] = None,
364
331
  ) -> Tuple[str, str]:
365
- """Process system prompt and user prompt templates.
332
+ """Process system and user prompt templates.
366
333
 
367
334
  Args:
368
- args: Command line arguments
369
- task_template: Validated task template
370
- template_context: Template context dictionary
371
- env: Jinja2 environment
335
+ args: CLI parameters
336
+ task_template: Task template content
337
+ template_context: Template context variables
338
+ env: Jinja2 environment with file reference support already configured
339
+ template_path: Path to template file (for debugging)
372
340
 
373
341
  Returns:
374
342
  Tuple of (system_prompt, user_prompt)
375
343
 
376
344
  Raises:
377
- CLIError: For template processing errors
345
+ TemplateError: If template processing fails
346
+ ValidationError: If template validation fails
378
347
  """
379
- logger.debug("=== Template Processing Phase ===")
348
+ # Show original template content if PRE_EXPAND capacity is active
349
+ td_log_if_active(TDCap.PRE_EXPAND, "--- original template content ---")
350
+ td_log_if_active(TDCap.PRE_EXPAND, task_template)
351
+ td_log_if_active(TDCap.PRE_EXPAND, "--- end original template ---")
380
352
 
381
- # Add template debugging if enabled
353
+ # Check for debug mode
382
354
  debug_enabled = args.get("debug", False)
383
- debug_templates_enabled = args.get("debug_templates", False)
384
- show_context = args.get("show_context", False)
385
- show_context_detailed = args.get("show_context_detailed", False)
386
- show_pre_optimization = args.get("show_pre_optimization", False)
387
- show_optimization_diff = args.get("show_optimization_diff", False)
388
- no_optimization = args.get("no_optimization", False)
389
- show_optimization_steps = args.get("show_optimization_steps", False)
390
- optimization_step_detail = args.get("optimization_step_detail", "summary")
391
-
392
355
  debugger = None
393
- if debug_enabled or debug_templates_enabled:
394
- from .template_debug import (
395
- TemplateDebugger,
396
- log_template_expansion,
397
- show_file_content_expansions,
398
- )
399
-
400
- # Initialize template debugger
401
- debugger = TemplateDebugger(enabled=True)
402
-
403
- # Log template context
404
- show_file_content_expansions(template_context)
405
356
 
406
- # Log raw template before expansion
407
- logger.debug("Raw task template:")
408
- logger.debug(task_template)
357
+ if debug_enabled or is_capacity_active(TDCap.POST_EXPAND):
358
+ from .template_debug import TemplateDebugger
409
359
 
410
- # Log initial template state
411
- debugger.log_expansion_step(
412
- "Initial template loaded",
413
- "",
414
- task_template,
415
- {"template_path": template_path},
416
- )
417
-
418
- # Show context inspection if requested
419
- if show_context or show_context_detailed:
420
- from .template_debug import (
421
- display_context_detailed,
422
- display_context_summary,
423
- )
424
-
425
- if show_context_detailed:
426
- display_context_detailed(template_context)
427
- elif show_context:
428
- display_context_summary(template_context)
429
-
430
- # Check for undefined variables if context inspection is enabled
431
- from .template_debug import detect_undefined_variables
432
-
433
- undefined_vars = detect_undefined_variables(
434
- task_template, template_context
435
- )
436
- if undefined_vars:
437
- click.echo(
438
- f"⚠️ Potentially undefined variables: {', '.join(undefined_vars)}",
439
- err=True,
440
- )
441
- click.echo(
442
- f" Available variables: {', '.join(sorted(template_context.keys()))}",
443
- err=True,
444
- )
360
+ debugger = TemplateDebugger()
445
361
 
446
- system_prompt = process_system_prompt(
362
+ # System prompt processing
363
+ system_prompt, _ = process_system_prompt(
447
364
  task_template,
448
365
  args.get("system_prompt"),
449
366
  args.get("system_prompt_file"),
450
367
  template_context,
451
368
  env,
452
- args.get("ignore_task_sysprompt", False),
453
- template_path,
369
+ ignore_task_sysprompt=args.get("ignore_task_sysprompt", False),
370
+ template_path=template_path,
454
371
  )
455
372
 
456
- # Log system prompt processing step
457
- if debugger:
458
- debugger.log_expansion_step(
459
- "System prompt processed",
460
- task_template,
461
- system_prompt,
462
- {
463
- "system_prompt_source": (
464
- "task_template"
465
- if not args.get("system_prompt")
466
- else "custom"
467
- )
468
- },
469
- )
373
+ # Render user prompt template
374
+ user_prompt = render_template(task_template, template_context, env)
470
375
 
471
- # Handle pre-optimization template display
472
- if show_pre_optimization:
473
- from .template_debug import show_pre_optimization_template
376
+ # Generate XML appendix for referenced files if alias manager is available
377
+ alias_manager = args.get("_alias_manager")
378
+ if alias_manager:
379
+ from .template_filters import AliasManager, XMLAppendixBuilder
474
380
 
475
- show_pre_optimization_template(task_template)
381
+ # Type assertion since we know this is an AliasManager
382
+ assert isinstance(alias_manager, AliasManager)
383
+ appendix_builder = XMLAppendixBuilder(alias_manager)
384
+ appendix_content = appendix_builder.build_appendix()
476
385
 
477
- # Handle optimization debugging with custom rendering
478
- if no_optimization or show_optimization_diff or show_optimization_steps:
479
- # We need custom handling for optimization debugging
480
- user_prompt = _render_template_with_debug(
481
- task_template,
482
- template_context,
483
- env,
484
- no_optimization=bool(no_optimization),
485
- show_optimization_diff=bool(show_optimization_diff),
486
- show_optimization_steps=bool(show_optimization_steps),
487
- optimization_step_detail=str(optimization_step_detail),
488
- )
489
- else:
490
- # Standard rendering with optimization
491
- user_prompt = render_template(task_template, template_context, env)
386
+ # Append XML content if any aliases were referenced
387
+ if appendix_content:
388
+ user_prompt = user_prompt + "\n\n" + appendix_content
492
389
 
493
390
  # Log user prompt rendering step
494
391
  if debugger:
@@ -499,16 +396,23 @@ async def process_templates(
499
396
  template_context,
500
397
  )
501
398
 
399
+ # Add post-expand logging
400
+ td_log_if_active(TDCap.POST_EXPAND, "--- prompt 1/2 (system) ---")
401
+ td_log_if_active(TDCap.POST_EXPAND, system_prompt)
402
+ td_log_if_active(TDCap.POST_EXPAND, "--- prompt 2/2 (user) ---")
403
+ td_log_if_active(TDCap.POST_EXPAND, user_prompt)
404
+
502
405
  # Log template expansion if debug enabled
503
- if debug_enabled or debug_templates_enabled:
406
+ if debug_enabled or is_capacity_active(TDCap.STEPS):
504
407
  from .template_debug import log_template_expansion
505
408
 
506
- log_template_expansion(
507
- template_content=task_template,
508
- context=template_context,
509
- expanded=user_prompt,
510
- template_file=template_path,
511
- )
409
+ if debug_enabled:
410
+ log_template_expansion(
411
+ template_content=task_template,
412
+ context=template_context,
413
+ expanded=user_prompt,
414
+ template_file=template_path,
415
+ )
512
416
 
513
417
  # Show expansion summary and detailed steps
514
418
  if debugger:
@@ -615,78 +519,6 @@ def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
615
519
  return variables
616
520
 
617
521
 
618
- def collect_template_files(
619
- args: CLIParams,
620
- security_manager: SecurityManager,
621
- ) -> Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]:
622
- """Collect files from command line arguments.
623
-
624
- Args:
625
- args: Command line arguments
626
- security_manager: Security manager for path validation
627
-
628
- Returns:
629
- Dictionary mapping variable names to file info objects
630
-
631
- Raises:
632
- PathSecurityError: If any file paths violate security constraints
633
- ValueError: If file mappings are invalid or files cannot be accessed
634
- """
635
- try:
636
- # Get files, directories, and patterns from args - they are already tuples from Click's nargs=2
637
- files = list(
638
- args.get("files", [])
639
- ) # List of (name, path) tuples from Click
640
- dirs = args.get("dir", []) # List of (name, dir) tuples from Click
641
- patterns = args.get(
642
- "patterns", []
643
- ) # List of (name, pattern) tuples from Click
644
-
645
- # Collect files from directories and patterns
646
- dir_files = collect_files(
647
- file_mappings=cast(List[Tuple[str, Union[str, Path]]], files),
648
- dir_mappings=cast(List[Tuple[str, Union[str, Path]]], dirs),
649
- pattern_mappings=cast(
650
- List[Tuple[str, Union[str, Path]]], patterns
651
- ),
652
- dir_recursive=args.get("recursive", False),
653
- security_manager=security_manager,
654
- routing_type="template", # Indicate these are primarily for template access
655
- )
656
-
657
- # Combine results
658
- return cast(
659
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]],
660
- dir_files,
661
- )
662
-
663
- except Exception as e:
664
- # Check for nested security errors
665
- if hasattr(e, "__cause__") and hasattr(e.__cause__, "__class__"):
666
- if "SecurityError" in str(e.__cause__.__class__) and isinstance(
667
- e.__cause__, BaseException
668
- ):
669
- raise e.__cause__
670
- if "PathSecurityError" in str(
671
- e.__cause__.__class__
672
- ) and isinstance(e.__cause__, BaseException):
673
- raise e.__cause__
674
- # Check if this is a wrapped security error
675
- if isinstance(e.__cause__, PathSecurityError):
676
- raise e.__cause__
677
- # Don't wrap InvalidJSONError
678
- if isinstance(e, InvalidJSONError):
679
- raise
680
- # Don't wrap DuplicateFileMappingError
681
- if isinstance(e, DuplicateFileMappingError):
682
- raise
683
- # Catch broader exceptions and re-raise
684
- logger.error(
685
- "Error collecting template files: %s", str(e), exc_info=True
686
- )
687
- raise
688
-
689
-
690
522
  def extract_template_file_paths(template_context: Dict[str, Any]) -> List[str]:
691
523
  """Extract actual file paths from template context for token validation.
692
524
 
@@ -746,6 +578,89 @@ def create_template_context(
746
578
  return context
747
579
 
748
580
 
581
+ def _build_tool_context(
582
+ args: CLIParams, routing_result: ProcessingResult
583
+ ) -> Dict[str, Any]:
584
+ """Build tool-related context variables.
585
+
586
+ Args:
587
+ args: Command line arguments
588
+ routing_result: File routing result
589
+
590
+ Returns:
591
+ Dictionary with tool enablement and configuration context
592
+ """
593
+ from .config import OstructConfig
594
+
595
+ context: Dict[str, Any] = {}
596
+
597
+ # Load configuration
598
+ config_path = args.get("config")
599
+ config = OstructConfig.load(config_path) # type: ignore[arg-type]
600
+
601
+ # Universal tool toggle overrides
602
+ enabled_tools: set[str] = args.get("_enabled_tools", set()) # type: ignore[assignment]
603
+ disabled_tools: set[str] = args.get("_disabled_tools", set()) # type: ignore[assignment]
604
+
605
+ # Web search configuration
606
+ web_search_config = config.get_web_search_config()
607
+
608
+ if "web-search" in enabled_tools:
609
+ web_search_enabled = True
610
+ elif "web-search" in disabled_tools:
611
+ web_search_enabled = False
612
+ else:
613
+ web_search_enabled = web_search_config.enable_by_default
614
+
615
+ context["web_search_enabled"] = web_search_enabled
616
+
617
+ # Code interpreter configuration
618
+ ci_enabled_by_routing = "code-interpreter" in routing_result.enabled_tools
619
+
620
+ if "code-interpreter" in enabled_tools:
621
+ code_interpreter_enabled = True
622
+ elif "code-interpreter" in disabled_tools:
623
+ code_interpreter_enabled = False
624
+ else:
625
+ code_interpreter_enabled = ci_enabled_by_routing
626
+
627
+ context["code_interpreter_enabled"] = code_interpreter_enabled
628
+
629
+ # Add CI config if enabled
630
+ if code_interpreter_enabled:
631
+ ci_config = config.get_code_interpreter_config()
632
+ context["auto_download_enabled"] = ci_config.get("auto_download", True)
633
+
634
+ # Handle feature flags for CI config
635
+ effective_ci_config = dict(ci_config)
636
+ enabled_features = args.get("enabled_features", [])
637
+ disabled_features = args.get("disabled_features", [])
638
+
639
+ if enabled_features or disabled_features:
640
+ try:
641
+ from .click_options import parse_feature_flags
642
+
643
+ parsed_flags = parse_feature_flags(
644
+ tuple(enabled_features), tuple(disabled_features)
645
+ )
646
+ ci_hack_flag = parsed_flags.get("ci-download-hack")
647
+ if ci_hack_flag == "on":
648
+ effective_ci_config["download_strategy"] = (
649
+ "two_pass_sentinel"
650
+ )
651
+ elif ci_hack_flag == "off":
652
+ effective_ci_config["download_strategy"] = "single_pass"
653
+ except Exception as e:
654
+ logger.warning(f"Failed to parse feature flags: {e}")
655
+
656
+ context["code_interpreter_config"] = effective_ci_config
657
+ else:
658
+ context["auto_download_enabled"] = False
659
+ context["code_interpreter_config"] = {}
660
+
661
+ return context
662
+
663
+
749
664
  def _generate_template_variable_name(file_path: str) -> str:
750
665
  """Generate a template variable name from a file path.
751
666
 
@@ -796,393 +711,99 @@ async def create_template_context_from_routing(
796
711
  ValueError: If file mappings are invalid or files cannot be accessed
797
712
  """
798
713
  try:
799
- # Get files from routing result - include ALL routed files in template context
800
- template_files = routing_result.validated_files.get("template", [])
801
- code_interpreter_files = routing_result.validated_files.get(
802
- "code-interpreter", []
714
+ # Template context creation from new attachment system (T3.1)
715
+ logger.debug("Creating template context from new attachment system")
716
+
717
+ from .attachment_processor import (
718
+ AttachmentProcessor,
719
+ ProcessedAttachments,
720
+ _extract_attachments_from_args,
721
+ _has_new_attachment_syntax,
803
722
  )
804
- file_search_files = routing_result.validated_files.get(
805
- "file-search", []
723
+ from .attachment_template_bridge import (
724
+ build_template_context_from_attachments,
806
725
  )
807
726
 
808
- # Intent mapping is implemented directly in the file categorization logic below
809
- # This ensures files from different CLI flags get the correct routing intent
810
-
811
- # Process files by intent groups instead of lumping them all together
812
- files_dict = {}
813
-
814
- # Group files by their intent
815
- files_by_intent: Dict[FileRoutingIntent, List[Tuple[str, str]]] = {
816
- FileRoutingIntent.TEMPLATE_ONLY: [],
817
- FileRoutingIntent.CODE_INTERPRETER: [],
818
- FileRoutingIntent.FILE_SEARCH: [],
819
- }
820
-
821
- dirs_by_intent: Dict[FileRoutingIntent, List[Tuple[str, str]]] = {
822
- FileRoutingIntent.TEMPLATE_ONLY: [],
823
- FileRoutingIntent.CODE_INTERPRETER: [],
824
- FileRoutingIntent.FILE_SEARCH: [],
825
- }
826
-
827
- # Track processed files to avoid duplicates between CLI args and routing result
828
- seen_files: Set[str] = set()
829
-
830
- # Categorize files by their source and assign appropriate intent
831
- # Template files from CLI args
832
- template_file_paths = args.get("template_files", [])
833
- for template_file_path in template_file_paths:
834
- if isinstance(template_file_path, (str, Path)):
835
- file_name = _generate_template_variable_name(
836
- str(template_file_path)
837
- )
838
- file_path_str = str(template_file_path)
839
- if file_path_str not in seen_files:
840
- files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
841
- (file_name, file_path_str)
842
- )
843
- seen_files.add(file_path_str)
844
-
845
- # Template file aliases from CLI args
846
- template_file_aliases = args.get("template_file_aliases", [])
847
- for name_path_tuple in template_file_aliases:
848
- if (
849
- isinstance(name_path_tuple, tuple)
850
- and len(name_path_tuple) == 2
851
- ):
852
- custom_name, file_path_raw = name_path_tuple
853
- file_path_str = str(file_path_raw)
854
- if file_path_str not in seen_files:
855
- files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
856
- (str(custom_name), file_path_str)
857
- )
858
- seen_files.add(file_path_str)
859
-
860
- # Code interpreter files from CLI args
861
- code_interpreter_file_paths = args.get("code_interpreter_files", [])
862
- for ci_file_path in code_interpreter_file_paths:
863
- if isinstance(ci_file_path, (str, Path)):
864
- file_name = _generate_template_variable_name(str(ci_file_path))
865
- file_path_str = str(ci_file_path)
866
- if file_path_str not in seen_files:
867
- files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
868
- (file_name, file_path_str)
869
- )
870
- seen_files.add(file_path_str)
871
-
872
- # Code interpreter file aliases from CLI args
873
- code_interpreter_file_aliases = args.get(
874
- "code_interpreter_file_aliases", []
875
- )
876
- for name_path_tuple in code_interpreter_file_aliases:
877
- if (
878
- isinstance(name_path_tuple, tuple)
879
- and len(name_path_tuple) == 2
880
- ):
881
- custom_name, file_path_raw = name_path_tuple
882
- file_path_str = str(file_path_raw)
883
- if file_path_str not in seen_files:
884
- files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
885
- (str(custom_name), file_path_str)
886
- )
887
- seen_files.add(file_path_str)
888
-
889
- # File search files from CLI args
890
- file_search_file_paths = args.get("file_search_files", [])
891
- for fs_file_path in file_search_file_paths:
892
- if isinstance(fs_file_path, (str, Path)):
893
- file_name = _generate_template_variable_name(str(fs_file_path))
894
- file_path_str = str(fs_file_path)
895
- if file_path_str not in seen_files:
896
- files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
897
- (file_name, file_path_str)
898
- )
899
- seen_files.add(file_path_str)
900
-
901
- # File search file aliases from CLI args
902
- file_search_file_aliases = args.get("file_search_file_aliases", [])
903
- for name_path_tuple in file_search_file_aliases:
904
- if (
905
- isinstance(name_path_tuple, tuple)
906
- and len(name_path_tuple) == 2
907
- ):
908
- custom_name, file_path_raw = name_path_tuple
909
- file_path_str = str(file_path_raw)
910
- if file_path_str not in seen_files:
911
- files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
912
- (str(custom_name), file_path_str)
913
- )
914
- seen_files.add(file_path_str)
915
-
916
- # Process files from routing result and map to their proper intents
917
- # Only add files that haven't been processed from CLI args
918
- for template_file_item in template_files:
919
- if isinstance(template_file_item, (str, Path)):
920
- file_name = _generate_template_variable_name(
921
- str(template_file_item)
922
- )
923
- file_path_str = str(template_file_item)
924
- if file_path_str not in seen_files:
925
- files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
926
- (file_name, file_path_str)
927
- )
928
- seen_files.add(file_path_str)
929
- elif (
930
- isinstance(template_file_item, tuple)
931
- and len(template_file_item) == 2
932
- ):
933
- _, template_file_path = template_file_item
934
- file_path_str = str(template_file_path)
935
- file_name = _generate_template_variable_name(file_path_str)
936
- if file_path_str not in seen_files:
937
- files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
938
- (file_name, file_path_str)
939
- )
940
- seen_files.add(file_path_str)
941
-
942
- for ci_file_item in code_interpreter_files:
943
- if isinstance(ci_file_item, (str, Path)):
944
- file_name = _generate_template_variable_name(str(ci_file_item))
945
- file_path_str = str(ci_file_item)
946
- if file_path_str not in seen_files:
947
- files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
948
- (file_name, file_path_str)
949
- )
950
- seen_files.add(file_path_str)
951
- elif isinstance(ci_file_item, tuple) and len(ci_file_item) == 2:
952
- _, ci_file_path = ci_file_item
953
- file_path_str = str(ci_file_path)
954
- file_name = _generate_template_variable_name(file_path_str)
955
- if file_path_str not in seen_files:
956
- files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
957
- (file_name, file_path_str)
958
- )
959
- seen_files.add(file_path_str)
960
-
961
- for fs_file_item in file_search_files:
962
- if isinstance(fs_file_item, (str, Path)):
963
- file_name = _generate_template_variable_name(str(fs_file_item))
964
- file_path_str = str(fs_file_item)
965
- if file_path_str not in seen_files:
966
- files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
967
- (file_name, file_path_str)
968
- )
969
- seen_files.add(file_path_str)
970
- elif isinstance(fs_file_item, tuple) and len(fs_file_item) == 2:
971
- _, fs_file_path = fs_file_item
972
- file_path_str = str(fs_file_path)
973
- file_name = _generate_template_variable_name(file_path_str)
974
- if file_path_str not in seen_files:
975
- files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
976
- (file_name, file_path_str)
977
- )
978
- seen_files.add(file_path_str)
979
-
980
- # Categorize directories by their intent
981
- routing = routing_result.routing
982
- for alias_name, dir_path in routing.template_dir_aliases:
983
- dirs_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
984
- (alias_name, str(dir_path))
985
- )
986
- for alias_name, dir_path in routing.code_interpreter_dir_aliases:
987
- dirs_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
988
- (alias_name, str(dir_path))
989
- )
990
- for alias_name, dir_path in routing.file_search_dir_aliases:
991
- dirs_by_intent[FileRoutingIntent.FILE_SEARCH].append(
992
- (alias_name, str(dir_path))
993
- )
994
-
995
- # Auto-naming directories from CLI args
996
- template_dirs = args.get("template_dirs", [])
997
- for dir_path in template_dirs:
998
- dir_name = _generate_template_variable_name(str(dir_path))
999
- dirs_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
1000
- (dir_name, str(dir_path))
1001
- )
1002
-
1003
- code_interpreter_dirs = args.get("code_interpreter_dirs", [])
1004
- for dir_path in code_interpreter_dirs:
1005
- dir_name = _generate_template_variable_name(str(dir_path))
1006
- dirs_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
1007
- (dir_name, str(dir_path))
1008
- )
1009
-
1010
- file_search_dirs = args.get("file_search_dirs", [])
1011
- for dir_path in file_search_dirs:
1012
- dir_name = _generate_template_variable_name(str(dir_path))
1013
- dirs_by_intent[FileRoutingIntent.FILE_SEARCH].append(
1014
- (dir_name, str(dir_path))
1015
- )
1016
-
1017
- # Process files by intent groups with appropriate routing_intent
1018
- for intent, file_mappings in files_by_intent.items():
1019
- if file_mappings or dirs_by_intent[intent]:
1020
- intent_files_dict = collect_files(
1021
- file_mappings=cast(
1022
- List[Tuple[str, Union[str, Path]]], file_mappings
1023
- ),
1024
- dir_mappings=cast(
1025
- List[Tuple[str, Union[str, Path]]],
1026
- dirs_by_intent[intent],
1027
- ),
1028
- dir_recursive=args.get("recursive", False),
1029
- security_manager=security_manager,
1030
- routing_type="template", # Keep routing_type for template context accessibility
1031
- routing_intent=intent, # Use intent for warning logic
1032
- )
1033
- files_dict.update(intent_files_dict)
1034
-
1035
- # Handle legacy files and directories separately to preserve variable names
1036
- legacy_files = args.get("files", [])
1037
- legacy_dirs = args.get("dir", [])
1038
- legacy_patterns = args.get("patterns", [])
727
+ # Check if we have new attachment syntax
728
+ if _has_new_attachment_syntax(args):
729
+ # Re-process attachments for template context creation
730
+ # This ensures we have the full ProcessedAttachments structure
731
+ processor = AttachmentProcessor(security_manager)
732
+ attachments = _extract_attachments_from_args(args)
733
+ processed_attachments = processor.process_attachments(attachments)
734
+ else:
735
+ # No attachments specified - create empty processed attachments
736
+ processed_attachments = ProcessedAttachments()
1039
737
 
1040
- if legacy_files or legacy_dirs or legacy_patterns:
1041
- legacy_files_dict = collect_files(
1042
- file_mappings=cast(
1043
- List[Tuple[str, Union[str, Path]]], legacy_files
1044
- ),
1045
- dir_mappings=cast(
1046
- List[Tuple[str, Union[str, Path]]], legacy_dirs
1047
- ),
1048
- pattern_mappings=cast(
1049
- List[Tuple[str, Union[str, Path]]], legacy_patterns
1050
- ),
1051
- dir_recursive=args.get("recursive", False),
1052
- security_manager=security_manager,
1053
- routing_type="template", # Legacy flags are considered template-only
1054
- routing_intent=FileRoutingIntent.TEMPLATE_ONLY, # Legacy files use template-only intent
1055
- )
1056
- # Merge legacy results into the main template context
1057
- files_dict.update(legacy_files_dict)
738
+ # Build base context from variables
739
+ base_context = {}
1058
740
 
1059
741
  # Collect simple variables
1060
742
  variables = collect_simple_variables(args)
743
+ base_context.update(variables)
1061
744
 
1062
745
  # Collect JSON variables
1063
746
  json_variables = collect_json_variables(args)
747
+ base_context.update(json_variables)
748
+
749
+ # Add standard context variables
750
+ import sys
1064
751
 
1065
- # Get stdin content if available
1066
752
  stdin_content = None
1067
753
  try:
1068
754
  if not sys.stdin.isatty():
1069
755
  stdin_content = sys.stdin.read()
1070
756
  except (OSError, IOError):
1071
- # Skip stdin if it can't be read
1072
757
  pass
1073
758
 
1074
- context = create_template_context(
1075
- files=cast(
1076
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]],
1077
- files_dict,
1078
- ),
1079
- variables=variables,
1080
- json_variables=json_variables,
1081
- security_manager=security_manager,
1082
- stdin_content=stdin_content,
1083
- )
759
+ if stdin_content is not None:
760
+ base_context["stdin"] = stdin_content
1084
761
 
1085
762
  # Add current model to context
1086
- context["current_model"] = args["model"]
1087
-
1088
- # Add web search enabled flag to context
1089
- # Use the same logic as runner.py for consistency
1090
- web_search_from_cli = args.get("web_search", False)
1091
- no_web_search_from_cli = args.get("no_web_search", False)
1092
-
1093
- # Load configuration to check defaults
1094
- from .config import OstructConfig
1095
-
1096
- config_path = cast(Union[str, Path, None], args.get("config"))
1097
- config = OstructConfig.load(config_path)
1098
- web_search_config = config.get_web_search_config()
1099
-
1100
- # Apply universal tool toggle overrides (Step 3: Config override hook)
1101
- enabled_tools: set[str] = args.get("_enabled_tools", set()) # type: ignore[assignment]
1102
- disabled_tools: set[str] = args.get("_disabled_tools", set()) # type: ignore[assignment]
1103
-
1104
- # Determine if web search should be enabled
1105
- if "web-search" in enabled_tools:
1106
- # Universal --enable-tool web-search takes highest precedence
1107
- web_search_enabled = True
1108
- elif "web-search" in disabled_tools:
1109
- # Universal --disable-tool web-search takes highest precedence
1110
- web_search_enabled = False
1111
- elif web_search_from_cli:
1112
- # Explicit --web-search flag takes precedence
1113
- web_search_enabled = True
1114
- elif no_web_search_from_cli:
1115
- # Explicit --no-web-search flag disables
1116
- web_search_enabled = False
1117
- else:
1118
- # Use config default
1119
- web_search_enabled = web_search_config.enable_by_default
1120
-
1121
- context["web_search_enabled"] = web_search_enabled
1122
-
1123
- # Add Code Interpreter context variables
1124
- # Check if Code Interpreter is enabled by looking for CI files or tools
1125
- ci_enabled_by_routing = bool(
1126
- args.get("code_interpreter_files")
1127
- or args.get("code_interpreter_file_aliases")
1128
- or args.get("code_interpreter_dirs")
1129
- or args.get("code_interpreter_dir_aliases")
1130
- or args.get("code_interpreter", False)
763
+ base_context["current_model"] = args["model"]
764
+
765
+ # Add tool enablement flags
766
+ base_context.update(_build_tool_context(args, routing_result))
767
+
768
+ # Build attachment-based context
769
+ # In dry-run mode, use strict mode to fail fast on binary file errors
770
+ strict_mode = args.get("dry_run", False)
771
+ context = build_template_context_from_attachments(
772
+ processed_attachments,
773
+ security_manager,
774
+ base_context,
775
+ strict_mode=strict_mode,
1131
776
  )
1132
777
 
1133
- # Apply universal tool toggle overrides for code-interpreter
1134
- if "code-interpreter" in enabled_tools:
1135
- # Universal --enable-tool takes highest precedence
1136
- code_interpreter_enabled = True
1137
- elif "code-interpreter" in disabled_tools:
1138
- # Universal --disable-tool takes highest precedence
1139
- code_interpreter_enabled = False
1140
- else:
1141
- # Fall back to routing-based enablement
1142
- code_interpreter_enabled = ci_enabled_by_routing
1143
- context["code_interpreter_enabled"] = code_interpreter_enabled
1144
-
1145
- # Add auto_download setting from configuration
1146
- if code_interpreter_enabled:
1147
- ci_config = config.get_code_interpreter_config()
1148
- context["auto_download_enabled"] = ci_config.get(
1149
- "auto_download", True
778
+ # Add debugging support for new attachment system
779
+ debug_enabled = args.get("debug", False)
780
+
781
+ if (
782
+ debug_enabled
783
+ or is_capacity_active(TDCap.VARS)
784
+ or is_capacity_active(TDCap.PREVIEW)
785
+ ):
786
+ from .attachment_template_bridge import AttachmentTemplateContext
787
+
788
+ context_builder = AttachmentTemplateContext(security_manager)
789
+ context_builder.debug_attachment_context(
790
+ context,
791
+ processed_attachments,
792
+ show_detailed=(
793
+ debug_enabled or is_capacity_active(TDCap.PREVIEW)
794
+ ),
1150
795
  )
1151
796
 
1152
- # Determine effective download strategy (config + feature flags)
1153
- effective_ci_config = dict(
1154
- ci_config
1155
- ) # Copy to avoid modifying original
1156
- enabled_features = args.get("enabled_features", [])
1157
- disabled_features = args.get("disabled_features", [])
797
+ # Add proper template debug capacity logging with prefixes
798
+ if is_capacity_active(TDCap.VARS):
799
+ td_log_vars(context)
1158
800
 
1159
- if enabled_features or disabled_features:
1160
- try:
1161
- from .click_options import parse_feature_flags
1162
-
1163
- parsed_flags = parse_feature_flags(
1164
- tuple(enabled_features), tuple(disabled_features)
1165
- )
1166
- ci_hack_flag = parsed_flags.get("ci-download-hack")
1167
- if ci_hack_flag == "on":
1168
- effective_ci_config["download_strategy"] = (
1169
- "two_pass_sentinel"
1170
- )
1171
- elif ci_hack_flag == "off":
1172
- effective_ci_config["download_strategy"] = (
1173
- "single_pass"
1174
- )
1175
- except Exception as e:
1176
- logger.warning(
1177
- f"Failed to parse feature flags in template processor: {e}"
1178
- )
1179
-
1180
- # Add the effective CI config for template processing
1181
- context["code_interpreter_config"] = effective_ci_config
1182
- else:
1183
- context["auto_download_enabled"] = False
1184
- context["code_interpreter_config"] = {}
801
+ if is_capacity_active(TDCap.PREVIEW):
802
+ td_log_preview(context)
1185
803
 
804
+ logger.debug(
805
+ f"Built attachment-based template context with {len(context)} variables"
806
+ )
1186
807
  return context
1187
808
 
1188
809
  except PathSecurityError: