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/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,13 +298,43 @@ 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):
292
320
  """Raised when there are issues with system prompt loading or processing."""
293
321
 
294
- pass
322
+ def __init__(
323
+ self,
324
+ message: str,
325
+ context: Optional[Dict[str, Any]] = None,
326
+ ) -> None:
327
+ """Initialize system prompt error.
328
+
329
+ Args:
330
+ message: Error message
331
+ context: Additional error context
332
+ """
333
+ super().__init__(
334
+ message,
335
+ context=context,
336
+ exit_code=ExitCode.VALIDATION_ERROR,
337
+ )
295
338
 
296
339
 
297
340
  class SchemaError(CLIError):
@@ -561,7 +604,7 @@ class APIErrorMapper:
561
604
  ):
562
605
  return PromptTooLargeError(
563
606
  f"Prompt exceeds model context window (128,000 token limit). "
564
- 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). "
565
608
  f"Original error: {error}"
566
609
  )
567
610
 
@@ -630,7 +673,7 @@ class APIErrorMapper:
630
673
  if "upload" in error_msg or "vector_store" in error_msg:
631
674
  return FileSearchUploadError(
632
675
  f"File Search upload failed: {error}. "
633
- f"This can be intermittent - retry with --file-search-retry-count option."
676
+ f"This can be intermittent - retry with --fs-retries option."
634
677
  )
635
678
  return FileSearchError(f"File Search error: {error}")
636
679
 
@@ -733,7 +776,17 @@ def handle_error(e: Exception) -> None:
733
776
  msg = f"Model creation error: {str(e)}"
734
777
  exit_code = ExitCode.SCHEMA_ERROR
735
778
  elif isinstance(e, click.UsageError):
736
- 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
+
737
790
  exit_code = ExitCode.USAGE_ERROR
738
791
  elif isinstance(e, SchemaFileError):
739
792
  msg = str(e) # Use existing __str__ formatting
@@ -800,3 +853,135 @@ __all__ = [
800
853
  "InvalidResponseFormatError",
801
854
  "handle_error",
802
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.
@@ -156,25 +203,52 @@ class FileInfo:
156
203
  Returns a path relative to the security manager's base directory.
157
204
  This ensures consistent path handling across the entire codebase.
158
205
 
206
+ For paths outside the base directory but within allowed directories,
207
+ returns the absolute path.
208
+
159
209
  Example:
160
210
  security_manager = SecurityManager(base_dir="/base")
161
211
  file_info = FileInfo("/base/file.txt", security_manager)
162
212
  print(file_info.path) # Outputs: "file.txt"
163
213
 
214
+ # With allowed directory outside base:
215
+ file_info = FileInfo("/tmp/file.txt", security_manager)
216
+ print(file_info.path) # Outputs: "/tmp/file.txt"
217
+
164
218
  Returns:
165
- str: Path relative to security manager's base directory
219
+ str: Path relative to security manager's base directory, or absolute path
220
+ if outside base directory but within allowed directories
166
221
 
167
222
  Raises:
168
- ValueError: If the path is not within the base directory
223
+ ValueError: If the path is not within the base directory or allowed directories
169
224
  """
225
+ abs_path = Path(self.abs_path)
226
+ base_dir = Path(self.__security_manager.base_dir)
227
+
170
228
  try:
171
- abs_path = Path(self.abs_path)
172
- base_dir = Path(self.__security_manager.base_dir)
173
229
  return str(abs_path.relative_to(base_dir))
174
- except ValueError as e:
175
- logger.error("Error making path relative: %s", e)
230
+ except ValueError:
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
243
+
244
+ # Should never reach here if SecurityManager validation was done properly
245
+ logger.error(
246
+ "Error making path relative: %s is not within base directory %s",
247
+ abs_path,
248
+ base_dir,
249
+ )
176
250
  raise ValueError(
177
- f"Path {abs_path} must be within base directory {base_dir}"
251
+ f"Path {abs_path} is not within base directory {base_dir}"
178
252
  )
179
253
 
180
254
  @path.setter
@@ -280,7 +354,7 @@ class FileInfo:
280
354
  f"File '{self.path}' ({self.size / 1024:.1f}KB) was routed for template-only access "
281
355
  f"but its .content is being accessed. This will include the entire file content "
282
356
  f"in the prompt sent to the AI. For large files intended for analysis or search, "
283
- 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, "
284
358
  f"cost, and avoid exceeding model context limits."
285
359
  )
286
360
 
@@ -411,6 +485,11 @@ class FileInfo:
411
485
  security_manager: SecurityManager,
412
486
  routing_type: Optional[str] = None,
413
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",
414
493
  ) -> "FileInfo":
415
494
  """Create FileInfo instance from path.
416
495
 
@@ -419,6 +498,11 @@ class FileInfo:
419
498
  security_manager: Security manager for path validation
420
499
  routing_type: How the file was routed (e.g., 'template', 'code-interpreter')
421
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)
422
506
 
423
507
  Returns:
424
508
  FileInfo instance
@@ -427,13 +511,22 @@ class FileInfo:
427
511
  FileNotFoundError: If file does not exist
428
512
  PathSecurityError: If path is not allowed
429
513
  """
430
- return cls(
514
+ file_info = cls(
431
515
  path,
432
516
  security_manager,
433
517
  routing_type=routing_type,
434
518
  routing_intent=routing_intent,
435
519
  )
436
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
+
437
530
  def __str__(self) -> str:
438
531
  """String representation showing path."""
439
532
  return f"FileInfo({self.__path})"
@@ -459,6 +552,17 @@ class FileInfo:
459
552
  object.__setattr__(self, name, value)
460
553
  return
461
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
+
462
566
  # Allow setting private attributes from internal methods
463
567
  if name.startswith("_FileInfo__") and self._is_internal_call():
464
568
  object.__setattr__(self, name, value)