ostruct-cli 0.8.29__py3-none-any.whl → 1.0.1__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.1.dist-info}/METADATA +231 -128
  43. ostruct_cli-1.0.1.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.1.dist-info}/LICENSE +0 -0
  48. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/WHEEL +0 -0
  49. {ostruct_cli-0.8.29.dist-info → ostruct_cli-1.0.1.dist-info}/entry_points.txt +0 -0
ostruct/cli/config.py CHANGED
@@ -8,6 +8,8 @@ from typing import Any, Dict, Optional, Union
8
8
  import yaml
9
9
  from pydantic import BaseModel, Field, field_validator, model_validator
10
10
 
11
+ from .constants import DefaultConfig, DefaultSecurity
12
+
11
13
  logger = logging.getLogger(__name__)
12
14
 
13
15
 
@@ -40,14 +42,10 @@ class ToolsConfig(BaseModel):
40
42
  """Configuration for tool-specific settings."""
41
43
 
42
44
  code_interpreter: Dict[str, Any] = Field(
43
- default_factory=lambda: {
44
- "auto_download": True,
45
- "output_directory": "./output",
46
- "download_strategy": "single_pass", # "single_pass" | "two_pass_sentinel"
47
- }
45
+ default_factory=lambda: DefaultConfig.CODE_INTERPRETER.copy()
48
46
  )
49
47
  file_search: Dict[str, Any] = Field(
50
- default_factory=lambda: {"max_results": 10}
48
+ default_factory=lambda: DefaultConfig.FILE_SEARCH.copy()
51
49
  )
52
50
  web_search: WebSearchToolConfig = Field(
53
51
  default_factory=WebSearchToolConfig
@@ -57,30 +55,33 @@ class ToolsConfig(BaseModel):
57
55
  class ModelsConfig(BaseModel):
58
56
  """Configuration for model settings."""
59
57
 
60
- default: str = "gpt-4o"
58
+ default: str = DefaultConfig.DEFAULT_MODEL
61
59
 
62
60
 
63
61
  class OperationConfig(BaseModel):
64
62
  """Configuration for operation settings."""
65
63
 
66
- timeout_minutes: int = 60
67
- retry_attempts: int = 3
68
- require_approval: str = "never"
64
+ timeout_minutes: int = DefaultConfig.OPERATION_TIMEOUT_MINUTES
65
+ retry_attempts: int = DefaultConfig.OPERATION_RETRY_ATTEMPTS
66
+ require_approval: str = DefaultConfig.OPERATION_REQUIRE_APPROVAL
69
67
 
70
68
  @field_validator("require_approval")
71
69
  @classmethod
72
70
  def validate_approval_setting(cls, v: str) -> str:
73
- valid_values = ["never", "always", "expensive"]
74
- if v not in valid_values:
75
- raise ValueError(f"require_approval must be one of {valid_values}")
71
+ if v not in DefaultSecurity.VALID_APPROVAL_SETTINGS:
72
+ raise ValueError(
73
+ f"require_approval must be one of {DefaultSecurity.VALID_APPROVAL_SETTINGS}"
74
+ )
76
75
  return v
77
76
 
78
77
 
79
78
  class LimitsConfig(BaseModel):
80
79
  """Configuration for cost and operation limits."""
81
80
 
82
- max_cost_per_run: float = 10.00
83
- warn_expensive_operations: bool = True
81
+ max_cost_per_run: float = DefaultConfig.LIMITS_MAX_COST_PER_RUN
82
+ warn_expensive_operations: bool = (
83
+ DefaultConfig.LIMITS_WARN_EXPENSIVE_OPERATIONS
84
+ )
84
85
 
85
86
 
86
87
  class OstructConfig(BaseModel):
@@ -173,12 +174,12 @@ class OstructConfig(BaseModel):
173
174
  # MCP server URLs from environment
174
175
  mcp_config = config_data.setdefault("mcp", {})
175
176
 
176
- # Look for MCP_* environment variables
177
+ # Look for OSTRUCT_MCP_URL_* environment variables
177
178
  for key, value in os.environ.items():
178
- if key.startswith("MCP_") and key.endswith("_URL"):
179
+ if key.startswith("OSTRUCT_MCP_URL_"):
179
180
  server_name = key[
180
- 4:-4
181
- ].lower() # Remove MCP_ prefix and _URL suffix
181
+ 16:
182
+ ].lower() # Remove OSTRUCT_MCP_URL_ prefix
182
183
  mcp_config[server_name] = value
183
184
 
184
185
  # Built-in MCP server shortcuts
@@ -189,7 +190,7 @@ class OstructConfig(BaseModel):
189
190
 
190
191
  for name, url in builtin_servers.items():
191
192
  if name not in mcp_config:
192
- env_key = f"MCP_{name.upper()}_URL"
193
+ env_key = f"OSTRUCT_MCP_URL_{name}"
193
194
  if os.getenv(env_key):
194
195
  mcp_config[name] = os.getenv(env_key)
195
196
 
@@ -251,7 +252,7 @@ models:
251
252
  tools:
252
253
  code_interpreter:
253
254
  auto_download: true
254
- output_directory: "./output"
255
+ output_directory: "./downloads"
255
256
 
256
257
  file_search:
257
258
  max_results: 10
@@ -287,7 +288,7 @@ limits:
287
288
 
288
289
  # Environment Variables for Secrets:
289
290
  # OPENAI_API_KEY - Your OpenAI API key
290
- # MCP_<NAME>_URL - URL for custom MCP servers (e.g., MCP_STRIPE_URL)
291
+ # OSTRUCT_MCP_URL_<name> - URL for custom MCP servers (e.g., OSTRUCT_MCP_URL_stripe)
291
292
  """
292
293
 
293
294
 
@@ -0,0 +1,89 @@
1
+ """Centralized constants for ostruct CLI."""
2
+
3
+ from typing import Any, Dict
4
+
5
+
6
+ class DefaultPaths:
7
+ """Default paths and directories for ostruct."""
8
+
9
+ # Code Interpreter defaults
10
+ CODE_INTERPRETER_OUTPUT_DIR = "./downloads"
11
+
12
+ # Configuration file paths
13
+ CONFIG_FILE_NAME = "ostruct.yaml"
14
+ HOME_CONFIG_DIR = ".ostruct"
15
+ HOME_CONFIG_FILE = "config.yaml"
16
+
17
+
18
+ class DefaultConfig:
19
+ """Default configuration values for ostruct."""
20
+
21
+ # Model defaults
22
+ DEFAULT_MODEL: str = "gpt-4.1"
23
+
24
+ # Code Interpreter defaults
25
+ CODE_INTERPRETER: Dict[str, Any] = {
26
+ "auto_download": True,
27
+ "output_directory": DefaultPaths.CODE_INTERPRETER_OUTPUT_DIR,
28
+ "download_strategy": "single_pass",
29
+ }
30
+
31
+ # File Search defaults
32
+ FILE_SEARCH: Dict[str, Any] = {
33
+ "max_results": 10,
34
+ }
35
+
36
+ # Web Search defaults
37
+ WEB_SEARCH: Dict[str, Any] = {
38
+ "enable_by_default": False,
39
+ "search_context_size": None,
40
+ "user_location": None,
41
+ }
42
+
43
+ # Operation defaults - individual values for direct access
44
+ OPERATION_TIMEOUT_MINUTES: int = 60
45
+ OPERATION_RETRY_ATTEMPTS: int = 3
46
+ OPERATION_REQUIRE_APPROVAL: str = "never"
47
+
48
+ # Limits defaults - individual values for direct access
49
+ LIMITS_MAX_COST_PER_RUN: float = 10.00
50
+ LIMITS_WARN_EXPENSIVE_OPERATIONS: bool = True
51
+
52
+ # Template processing defaults
53
+ TEMPLATE: Dict[str, Any] = {
54
+ "system_prompt": "You are a helpful assistant.",
55
+ "file_limit": 65536, # 64KB
56
+ "total_limit": 1048576, # 1MB
57
+ "preview_limit": 4096, # 4KB
58
+ }
59
+
60
+
61
+ class DefaultTimeouts:
62
+ """Default timeout values."""
63
+
64
+ API_TIMEOUT = 60.0
65
+ MAX_API_TIMEOUT = 300.0 # 5 minutes cap
66
+
67
+
68
+ class DefaultSecurity:
69
+ """Default security settings."""
70
+
71
+ PATH_SECURITY_MODE = "permissive"
72
+ VALID_SECURITY_MODES = ["permissive", "warn", "strict"]
73
+ VALID_APPROVAL_SETTINGS = ["never", "always", "expensive"]
74
+
75
+
76
+ # Environment variable names (for consistency)
77
+ class EnvVars:
78
+ """Environment variable names."""
79
+
80
+ OPENAI_API_KEY = "OPENAI_API_KEY"
81
+ OSTRUCT_DISABLE_REGISTRY_UPDATE_CHECKS = (
82
+ "OSTRUCT_DISABLE_REGISTRY_UPDATE_CHECKS"
83
+ )
84
+ OSTRUCT_TEMPLATE_FILE_LIMIT = "OSTRUCT_TEMPLATE_FILE_LIMIT"
85
+ OSTRUCT_TEMPLATE_TOTAL_LIMIT = "OSTRUCT_TEMPLATE_TOTAL_LIMIT"
86
+ OSTRUCT_TEMPLATE_PREVIEW_LIMIT = "OSTRUCT_TEMPLATE_PREVIEW_LIMIT"
87
+
88
+ # MCP URL pattern: OSTRUCT_MCP_URL_<name>
89
+ MCP_URL_PREFIX = "OSTRUCT_MCP_URL_"
ostruct/cli/errors.py CHANGED
@@ -252,7 +252,20 @@ class PathSecurityError(SecurityErrorBase):
252
252
  class TaskTemplateError(CLIError):
253
253
  """Base class for task template-related errors."""
254
254
 
255
- pass
255
+ def __init__(
256
+ self,
257
+ message: str,
258
+ context: Optional[Dict[str, Any]] = None,
259
+ exit_code: int = ExitCode.VALIDATION_ERROR,
260
+ ) -> None:
261
+ """Initialize task template error.
262
+
263
+ Args:
264
+ message: Error message
265
+ context: Additional error context
266
+ exit_code: Exit code (defaults to VALIDATION_ERROR)
267
+ """
268
+ super().__init__(message, context=context, exit_code=exit_code)
256
269
 
257
270
 
258
271
  class TaskTemplateSyntaxError(TaskTemplateError):
@@ -285,7 +298,22 @@ class TaskTemplateVariableError(TaskTemplateError):
285
298
  class TemplateValidationError(TaskTemplateError):
286
299
  """Raised when template validation fails."""
287
300
 
288
- pass
301
+ def __init__(
302
+ self,
303
+ message: str,
304
+ context: Optional[Dict[str, Any]] = None,
305
+ ) -> None:
306
+ """Initialize template validation error.
307
+
308
+ Args:
309
+ message: Error message
310
+ context: Additional error context
311
+ """
312
+ super().__init__(
313
+ message,
314
+ context=context,
315
+ exit_code=ExitCode.VALIDATION_ERROR,
316
+ )
289
317
 
290
318
 
291
319
  class SystemPromptError(TaskTemplateError):
@@ -576,7 +604,7 @@ class APIErrorMapper:
576
604
  ):
577
605
  return PromptTooLargeError(
578
606
  f"Prompt exceeds model context window (128,000 token limit). "
579
- f"Tip: Use explicit file routing (-fc for code, -fs for docs, -ft for config). "
607
+ f"Tip: Use explicit file routing (--file ci:data for code, --file fs:docs for docs, --file config for config). "
580
608
  f"Original error: {error}"
581
609
  )
582
610
 
@@ -645,7 +673,7 @@ class APIErrorMapper:
645
673
  if "upload" in error_msg or "vector_store" in error_msg:
646
674
  return FileSearchUploadError(
647
675
  f"File Search upload failed: {error}. "
648
- f"This can be intermittent - retry with --file-search-retry-count option."
676
+ f"This can be intermittent - retry with --fs-retries option."
649
677
  )
650
678
  return FileSearchError(f"File Search error: {error}")
651
679
 
@@ -748,7 +776,17 @@ def handle_error(e: Exception) -> None:
748
776
  msg = f"Model creation error: {str(e)}"
749
777
  exit_code = ExitCode.SCHEMA_ERROR
750
778
  elif isinstance(e, click.UsageError):
751
- msg = f"Usage error: {str(e)}"
779
+ error_msg = str(e)
780
+
781
+ # Enhance usage error messages with helpful guidance
782
+ if "Missing parameter" in error_msg:
783
+ if "task_template" in error_msg:
784
+ msg = f"Usage error: {error_msg}\n\nTry 'ostruct run --help' for usage information, 'ostruct --quick-ref' for examples, or 'ostruct run --help-debug' for troubleshooting help."
785
+ else:
786
+ msg = f"Usage error: {error_msg}\n\nTry 'ostruct run --help' for usage information or 'ostruct --quick-ref' for examples."
787
+ else:
788
+ msg = f"Usage error: {error_msg}"
789
+
752
790
  exit_code = ExitCode.USAGE_ERROR
753
791
  elif isinstance(e, SchemaFileError):
754
792
  msg = str(e) # Use existing __str__ formatting
@@ -815,3 +853,135 @@ __all__ = [
815
853
  "InvalidResponseFormatError",
816
854
  "handle_error",
817
855
  ]
856
+
857
+ # Download-specific error classes for Task 3
858
+
859
+
860
+ class DownloadError(CLIError):
861
+ """Base class for download-related errors."""
862
+
863
+ def __init__(
864
+ self,
865
+ message: str,
866
+ context: Optional[Dict[str, Any]] = None,
867
+ exit_code: int = ExitCode.API_ERROR,
868
+ ) -> None:
869
+ """Initialize download error.
870
+
871
+ Args:
872
+ message: Error message
873
+ context: Additional error context
874
+ exit_code: Exit code for the error
875
+ """
876
+ super().__init__(message, context=context, exit_code=exit_code)
877
+
878
+
879
+ class DownloadPermissionError(DownloadError):
880
+ """Raised when download fails due to permission issues."""
881
+
882
+ def __init__(
883
+ self,
884
+ directory: str,
885
+ context: Optional[Dict[str, Any]] = None,
886
+ ) -> None:
887
+ """Initialize permission error.
888
+
889
+ Args:
890
+ directory: Directory that caused the permission error
891
+ context: Additional error context
892
+ """
893
+ context = context or {}
894
+ context.update(
895
+ {
896
+ "directory": directory,
897
+ "details": "Unable to write to the specified download directory",
898
+ "troubleshooting": [
899
+ f"Check write permissions for directory: {directory}",
900
+ "Verify the directory exists and is accessible",
901
+ "Try using a different download directory with --ci-download-dir",
902
+ "Check if the parent directory exists and is writable",
903
+ "Ensure sufficient disk space is available",
904
+ ],
905
+ }
906
+ )
907
+
908
+ message = f"Permission denied when writing to download directory: {directory}"
909
+ super().__init__(
910
+ message, context=context, exit_code=ExitCode.FILE_ERROR
911
+ )
912
+
913
+
914
+ class DownloadNetworkError(DownloadError):
915
+ """Raised when download fails due to network issues."""
916
+
917
+ def __init__(
918
+ self,
919
+ file_id: str,
920
+ original_error: Optional[Exception] = None,
921
+ context: Optional[Dict[str, Any]] = None,
922
+ ) -> None:
923
+ """Initialize network error.
924
+
925
+ Args:
926
+ file_id: File ID that failed to download
927
+ original_error: Original exception that caused the failure
928
+ context: Additional error context
929
+ """
930
+ context = context or {}
931
+ context.update(
932
+ {
933
+ "file_id": file_id,
934
+ "details": "Network error occurred while downloading file from OpenAI",
935
+ "troubleshooting": [
936
+ "Check your internet connection",
937
+ "Verify OpenAI API is accessible",
938
+ "Try the download again in a few moments",
939
+ "Check if your API key has the necessary permissions",
940
+ "Ensure the file ID is valid and not expired",
941
+ ],
942
+ }
943
+ )
944
+
945
+ if original_error:
946
+ context["original_error"] = str(original_error)
947
+
948
+ message = f"Network error downloading file {file_id}"
949
+ if original_error:
950
+ message += f": {original_error}"
951
+
952
+ super().__init__(message, context=context)
953
+
954
+
955
+ class DownloadFileNotFoundError(DownloadError):
956
+ """Raised when a file to download is not found."""
957
+
958
+ def __init__(
959
+ self,
960
+ file_id: str,
961
+ context: Optional[Dict[str, Any]] = None,
962
+ ) -> None:
963
+ """Initialize file not found error.
964
+
965
+ Args:
966
+ file_id: File ID that was not found
967
+ context: Additional error context
968
+ """
969
+ context = context or {}
970
+ context.update(
971
+ {
972
+ "file_id": file_id,
973
+ "details": "The requested file was not found or is no longer available",
974
+ "troubleshooting": [
975
+ "Verify the file ID is correct",
976
+ "Check if the file was generated in this session",
977
+ "Ensure the Code Interpreter execution completed successfully",
978
+ "Try running the analysis again to regenerate the file",
979
+ "Check if the file has expired (files have limited lifetime)",
980
+ ],
981
+ }
982
+ )
983
+
984
+ message = f"File not found for download: {file_id}"
985
+ super().__init__(
986
+ message, context=context, exit_code=ExitCode.FILE_ERROR
987
+ )
@@ -135,21 +135,6 @@ class ExplicitFileProcessor:
135
135
  """
136
136
  routing = ExplicitRouting()
137
137
 
138
- # Legacy options (-f, -d) are handled separately in create_template_context_from_routing
139
- # to preserve their custom variable naming semantics
140
- legacy_files = args.get("files", [])
141
- legacy_dirs = args.get("dir", [])
142
-
143
- if legacy_files:
144
- logger.debug(
145
- f"Legacy -f flag detected: {len(legacy_files)} files (handled separately)"
146
- )
147
-
148
- if legacy_dirs:
149
- logger.debug(
150
- f"Legacy -d flag detected: {len(legacy_dirs)} dirs (handled separately)"
151
- )
152
-
153
138
  # Handle explicit tool routing - file options now have different formats
154
139
 
155
140
  # Template files (from -ft) - now single-argument auto-naming
ostruct/cli/file_info.py CHANGED
@@ -5,7 +5,7 @@ import logging
5
5
  import os
6
6
  from enum import Enum
7
7
  from pathlib import Path
8
- from typing import Any, Optional
8
+ from typing import Any, Iterator, Optional
9
9
 
10
10
  from .errors import FileReadError, OstructFileNotFoundError, PathSecurityError
11
11
  from .security import SecurityManager
@@ -18,9 +18,9 @@ class FileRoutingIntent(Enum):
18
18
  to provide appropriate warnings and optimizations.
19
19
  """
20
20
 
21
- TEMPLATE_ONLY = "template_only" # -ft, --fta, legacy -f, -d
22
- CODE_INTERPRETER = "code_interpreter" # -fc, --fca
23
- FILE_SEARCH = "file_search" # -fs, --fsa
21
+ TEMPLATE_ONLY = "template_only" # --file [alias] (template access only)
22
+ CODE_INTERPRETER = "code_interpreter" # --file ci:[alias]
23
+ FILE_SEARCH = "file_search" # --file fs:[alias]
24
24
 
25
25
 
26
26
  logger = logging.getLogger(__name__)
@@ -30,7 +30,8 @@ class FileInfo:
30
30
  """Represents a file with metadata and content.
31
31
 
32
32
  This class provides access to file metadata (path, size, etc.) and content,
33
- with caching support for efficient access.
33
+ with caching support for efficient access. Implements the file-sequence protocol
34
+ by being iterable (yields itself) while maintaining scalar access to properties.
34
35
 
35
36
  Args:
36
37
  path: Path to the file
@@ -77,6 +78,19 @@ class FileInfo:
77
78
  self.routing_type = routing_type
78
79
  self.routing_intent = routing_intent
79
80
 
81
+ # TSES v2.0 fields for alias tracking
82
+ self.parent_alias: Optional[str] = (
83
+ None # CLI alias this file came from
84
+ )
85
+ self.relative_path: Optional[str] = (
86
+ None # Path relative to attachment root
87
+ )
88
+ self.base_path: Optional[str] = None # Base path of attachment
89
+ self.from_collection: bool = False # Whether file came from --collect
90
+ self.attachment_type: str = (
91
+ "file" # Original attachment type: "file", "dir", or "collection"
92
+ )
93
+
80
94
  logger.debug(
81
95
  "Creating FileInfo for path: %s, routing_type: %s",
82
96
  path,
@@ -149,6 +163,39 @@ class FileInfo:
149
163
  f"Permission denied: {os.path.basename(str(path))}"
150
164
  ) from e
151
165
 
166
+ def __iter__(self) -> Iterator["FileInfo"]:
167
+ """Make FileInfo iterable by yielding itself.
168
+
169
+ This implements the file-sequence protocol, allowing single files
170
+ to be treated uniformly with file collections in templates.
171
+
172
+ Returns:
173
+ Iterator that yields this FileInfo instance
174
+ """
175
+ yield self
176
+
177
+ @property
178
+ def first(self) -> "FileInfo":
179
+ """Get the first file in the sequence (itself for single files).
180
+
181
+ This provides a uniform interface with FileInfoList.first,
182
+ allowing templates to use .first regardless of whether they're
183
+ dealing with a single file or a collection.
184
+
185
+ Returns:
186
+ This FileInfo instance
187
+ """
188
+ return self
189
+
190
+ @property
191
+ def is_collection(self) -> bool:
192
+ """Indicate whether this is a collection of files.
193
+
194
+ Returns:
195
+ False, since FileInfo represents a single file
196
+ """
197
+ return False
198
+
152
199
  @property
153
200
  def path(self) -> str:
154
201
  """Get the path relative to security manager's base directory.
@@ -181,13 +228,18 @@ class FileInfo:
181
228
  try:
182
229
  return str(abs_path.relative_to(base_dir))
183
230
  except ValueError:
184
- # Path is outside base_dir, check if it's in allowed directories
185
- if self.__security_manager.is_path_allowed(abs_path):
186
- logger.debug(
187
- "Path outside base_dir but allowed, returning absolute path: %s",
188
- abs_path,
189
- )
190
- return str(abs_path)
231
+ # Path is outside base_dir, check if it's allowed by enhanced security model
232
+ try:
233
+ if self.__security_manager.is_path_allowed_enhanced(abs_path):
234
+ logger.debug(
235
+ "Path outside base_dir but allowed, returning absolute path: %s",
236
+ abs_path,
237
+ )
238
+ return str(abs_path)
239
+ except Exception:
240
+ # In strict mode, is_path_allowed_enhanced() raises exceptions
241
+ # If we reach this except block, the path is not allowed
242
+ pass
191
243
 
192
244
  # Should never reach here if SecurityManager validation was done properly
193
245
  logger.error(
@@ -196,7 +248,7 @@ class FileInfo:
196
248
  base_dir,
197
249
  )
198
250
  raise ValueError(
199
- f"Path {abs_path} must be within base directory {base_dir}"
251
+ f"Path {abs_path} is not within base directory {base_dir}"
200
252
  )
201
253
 
202
254
  @path.setter
@@ -302,7 +354,7 @@ class FileInfo:
302
354
  f"File '{self.path}' ({self.size / 1024:.1f}KB) was routed for template-only access "
303
355
  f"but its .content is being accessed. This will include the entire file content "
304
356
  f"in the prompt sent to the AI. For large files intended for analysis or search, "
305
- f"consider using -fc (Code Interpreter) or -fs (File Search) to optimize token usage, "
357
+ f"consider using --file ci:data (Code Interpreter) or --file fs:docs (File Search) to optimize token usage, "
306
358
  f"cost, and avoid exceeding model context limits."
307
359
  )
308
360
 
@@ -433,6 +485,11 @@ class FileInfo:
433
485
  security_manager: SecurityManager,
434
486
  routing_type: Optional[str] = None,
435
487
  routing_intent: Optional[FileRoutingIntent] = None,
488
+ parent_alias: Optional[str] = None,
489
+ relative_path: Optional[str] = None,
490
+ base_path: Optional[str] = None,
491
+ from_collection: bool = False,
492
+ attachment_type: str = "file",
436
493
  ) -> "FileInfo":
437
494
  """Create FileInfo instance from path.
438
495
 
@@ -441,6 +498,11 @@ class FileInfo:
441
498
  security_manager: Security manager for path validation
442
499
  routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
443
500
  routing_intent: The intended use of the file in the pipeline
501
+ parent_alias: CLI alias this file came from (for TSES)
502
+ relative_path: Path relative to attachment root (for TSES)
503
+ base_path: Base path of attachment (for TSES)
504
+ from_collection: Whether file came from --collect (for TSES)
505
+ attachment_type: Original attachment type: "file", "dir", or "collection" (for TSES)
444
506
 
445
507
  Returns:
446
508
  FileInfo instance
@@ -449,13 +511,22 @@ class FileInfo:
449
511
  FileNotFoundError: If file does not exist
450
512
  PathSecurityError: If path is not allowed
451
513
  """
452
- return cls(
514
+ file_info = cls(
453
515
  path,
454
516
  security_manager,
455
517
  routing_type=routing_type,
456
518
  routing_intent=routing_intent,
457
519
  )
458
520
 
521
+ # Set TSES fields
522
+ file_info.parent_alias = parent_alias
523
+ file_info.relative_path = relative_path
524
+ file_info.base_path = base_path
525
+ file_info.from_collection = from_collection
526
+ file_info.attachment_type = attachment_type
527
+
528
+ return file_info
529
+
459
530
  def __str__(self) -> str:
460
531
  """String representation showing path."""
461
532
  return f"FileInfo({self.__path})"
@@ -481,6 +552,17 @@ class FileInfo:
481
552
  object.__setattr__(self, name, value)
482
553
  return
483
554
 
555
+ # Allow setting TSES fields
556
+ if name in (
557
+ "parent_alias",
558
+ "relative_path",
559
+ "base_path",
560
+ "from_collection",
561
+ "attachment_type",
562
+ ):
563
+ object.__setattr__(self, name, value)
564
+ return
565
+
484
566
  # Allow setting private attributes from internal methods
485
567
  if name.startswith("_FileInfo__") and self._is_internal_call():
486
568
  object.__setattr__(self, name, value)