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/file_list.py CHANGED
@@ -30,6 +30,9 @@ class FileInfoList(List[FileInfo]):
30
30
  handling of multi-file scenarios through indexing (files[0].content) or the
31
31
  |single filter (files|single.content).
32
32
 
33
+ Implements the file-sequence protocol by being iterable (already inherits from list)
34
+ and providing a .first property for uniform access patterns.
35
+
33
36
  This class is thread-safe. All operations that access or modify the internal list
34
37
  are protected by a reentrant lock (RLock). This allows nested method calls while
35
38
  holding the lock, preventing deadlocks in cases like:
@@ -49,6 +52,12 @@ class FileInfoList(List[FileInfo]):
49
52
  content = files[0].content # Access first file explicitly
50
53
  content = files|single.content # Use |single filter for validation
51
54
 
55
+ Uniform iteration (file-sequence protocol):
56
+ for file in files: # Works for both single and multiple files
57
+ print(file.content)
58
+
59
+ first_file = files.first # Get first file uniformly
60
+
52
61
  Properties:
53
62
  content: File content - only for single file from file mapping (not directory)
54
63
  path: File path - only for single file from file mapping
@@ -56,6 +65,8 @@ class FileInfoList(List[FileInfo]):
56
65
  size: File size in bytes - only for single file from file mapping
57
66
  name: Filename without directory path - only for single file from file mapping
58
67
  names: Always returns list of all filenames (safe for multi-file access)
68
+ first: Returns the first FileInfo object (uniform access)
69
+ is_collection: Always returns True (indicates this is a collection)
59
70
 
60
71
  Raises:
61
72
  ValueError: When accessing scalar properties on empty list, multiple files, or directory mappings
@@ -85,6 +96,37 @@ class FileInfoList(List[FileInfo]):
85
96
  self._from_dir = from_dir
86
97
  self._var_alias = var_alias
87
98
 
99
+ @property
100
+ def first(self) -> FileInfo:
101
+ """Get the first file in the collection.
102
+
103
+ This provides a uniform interface with FileInfo.first,
104
+ allowing templates to use .first regardless of whether they're
105
+ dealing with a single file or a collection.
106
+
107
+ Returns:
108
+ The first FileInfo object in the list
109
+
110
+ Raises:
111
+ ValueError: If the list is empty
112
+ """
113
+ with self._lock:
114
+ if not self:
115
+ var_name = self._var_alias or "file_list"
116
+ raise ValueError(
117
+ f"No files in '{var_name}'. Cannot access .first property."
118
+ )
119
+ return self[0]
120
+
121
+ @property
122
+ def is_collection(self) -> bool:
123
+ """Indicate whether this is a collection of files.
124
+
125
+ Returns:
126
+ True, since FileInfoList represents a collection of files
127
+ """
128
+ return True
129
+
88
130
  @property
89
131
  def content(self) -> str:
90
132
  """Get the content of a single file.
@@ -443,7 +485,7 @@ class FileInfoList(List[FileInfo]):
443
485
  if not self:
444
486
  return "FileInfoList([])"
445
487
 
446
- # For single file from file mapping (--fta, -ft, etc.)
488
+ # For single file from file mapping (--file alias, etc.)
447
489
  if len(self) == 1 and not self._from_dir:
448
490
  var_name = self._var_alias or "file_var"
449
491
  return f"[File '{self[0].path}' - Use {{ {var_name}.content }} to access file content]"
@@ -10,25 +10,34 @@ import logging
10
10
  import os
11
11
  import time
12
12
  from pathlib import Path
13
- from typing import Any, Dict, List
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
14
14
 
15
15
  from openai import AsyncOpenAI
16
16
 
17
+ if TYPE_CHECKING:
18
+ from .upload_manager import SharedUploadManager
19
+
17
20
  logger = logging.getLogger(__name__)
18
21
 
19
22
 
20
23
  class FileSearchManager:
21
24
  """Manager for File Search vector store operations with retry logic."""
22
25
 
23
- def __init__(self, client: AsyncOpenAI):
26
+ def __init__(
27
+ self,
28
+ client: AsyncOpenAI,
29
+ upload_manager: Optional["SharedUploadManager"] = None,
30
+ ) -> None:
24
31
  """Initialize File Search manager.
25
32
 
26
33
  Args:
27
34
  client: AsyncOpenAI client instance
35
+ upload_manager: Optional shared upload manager for deduplication
28
36
  """
29
37
  self.client = client
30
38
  self.uploaded_file_ids: List[str] = []
31
39
  self.created_vector_stores: List[str] = []
40
+ self.upload_manager = upload_manager
32
41
 
33
42
  async def create_vector_store_with_retry(
34
43
  self,
@@ -321,6 +330,63 @@ class FileSearchManager:
321
330
  "vector_store_ids": [vector_store_id],
322
331
  }
323
332
 
333
+ async def create_vector_store_from_shared_manager(
334
+ self,
335
+ vector_store_name: str = "ostruct_vector_store",
336
+ max_retries: int = 3,
337
+ retry_delay: float = 1.0,
338
+ ) -> str:
339
+ """Create vector store and populate with files from shared upload manager.
340
+
341
+ Args:
342
+ vector_store_name: Name for the vector store
343
+ max_retries: Maximum retry attempts
344
+ retry_delay: Delay between retries
345
+
346
+ Returns:
347
+ Vector store ID
348
+
349
+ Raises:
350
+ Exception: If vector store creation or file upload fails
351
+ """
352
+ if not self.upload_manager:
353
+ logger.warning("No shared upload manager available")
354
+ # Fall back to creating empty vector store
355
+ return await self.create_vector_store_with_retry(
356
+ vector_store_name, max_retries, retry_delay
357
+ )
358
+
359
+ # Get file IDs from shared manager
360
+ await self.upload_manager.upload_for_tool("file-search")
361
+ file_ids = self.upload_manager.get_files_for_tool("file-search")
362
+
363
+ if not file_ids:
364
+ logger.debug(
365
+ "No files for file-search, creating empty vector store"
366
+ )
367
+ return await self.create_vector_store_with_retry(
368
+ vector_store_name, max_retries, retry_delay
369
+ )
370
+
371
+ # Create vector store
372
+ vector_store_id = await self.create_vector_store_with_retry(
373
+ vector_store_name, max_retries, retry_delay
374
+ )
375
+
376
+ # Add files to vector store
377
+ await self._add_files_to_vector_store_with_retry(
378
+ vector_store_id, file_ids, max_retries, retry_delay
379
+ )
380
+
381
+ # Track uploaded files for cleanup
382
+ self.uploaded_file_ids.extend(file_ids)
383
+
384
+ logger.debug(
385
+ f"Created vector store {vector_store_id} with {len(file_ids)} files from shared manager"
386
+ )
387
+
388
+ return vector_store_id
389
+
324
390
  async def cleanup_resources(self) -> None:
325
391
  """Clean up uploaded files and created vector stores.
326
392
 
@@ -0,0 +1,235 @@
1
+ """Unified JSON help system for ostruct CLI."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List
5
+
6
+ import click
7
+
8
+ from .. import __version__
9
+
10
+
11
+ def generate_attachment_system_info() -> Dict[str, Any]:
12
+ """Generate structured attachment system information from codebase constants."""
13
+ from .params import TARGET_NORMALISE
14
+
15
+ # Build structured target information
16
+ canonical_targets = set(TARGET_NORMALISE.values())
17
+ aliases: Dict[str, List[str]] = {}
18
+
19
+ for alias, canonical in TARGET_NORMALISE.items():
20
+ if alias != canonical: # Only include actual aliases
21
+ if canonical not in aliases:
22
+ aliases[canonical] = []
23
+ aliases[canonical].append(alias)
24
+
25
+ targets = {}
26
+ for canonical in sorted(canonical_targets):
27
+ targets[canonical] = {
28
+ "canonical_name": canonical,
29
+ "aliases": sorted(aliases.get(canonical, [])),
30
+ "description": _get_target_description(canonical),
31
+ "type": "file_routing_target",
32
+ }
33
+
34
+ return {
35
+ "format_spec": "[targets:]alias path",
36
+ "targets": targets,
37
+ "examples": [
38
+ {
39
+ "syntax": "--file data file.txt",
40
+ "targets": ["prompt"],
41
+ "description": "Template access only (default target)",
42
+ },
43
+ {
44
+ "syntax": "--file ci:analysis data.csv",
45
+ "targets": ["code-interpreter"],
46
+ "description": "Code execution & analysis",
47
+ },
48
+ {
49
+ "syntax": "--dir fs:docs ./documentation",
50
+ "targets": ["file-search"],
51
+ "description": "Document search & retrieval",
52
+ },
53
+ {
54
+ "syntax": "--file ci,fs:shared data.json",
55
+ "targets": ["code-interpreter", "file-search"],
56
+ "description": "Multi-target routing",
57
+ },
58
+ ],
59
+ }
60
+
61
+
62
+ def _get_target_description(target: str) -> str:
63
+ """Get description for a target."""
64
+ descriptions = {
65
+ "prompt": "Template access only (default)",
66
+ "code-interpreter": "Code execution & analysis",
67
+ "file-search": "Document search & retrieval",
68
+ }
69
+ return descriptions.get(target, f"Unknown target: {target}")
70
+
71
+
72
+ def generate_json_output_modes() -> Dict[str, Any]:
73
+ """Generate structured JSON output mode information."""
74
+ return {
75
+ "help_json": {
76
+ "description": "Output command help in JSON format",
77
+ "output_destination": "stdout",
78
+ "exit_behavior": "exits_after_output",
79
+ "scope": "single_command_or_full_cli",
80
+ },
81
+ "dry_run_json": {
82
+ "description": "Output execution plan as JSON with --dry-run",
83
+ "output_destination": "stdout",
84
+ "requires": ["--dry-run"],
85
+ "exit_behavior": "exits_after_output",
86
+ "scope": "execution_plan",
87
+ },
88
+ "run_summary_json": {
89
+ "description": "Output run summary as JSON to stderr after execution",
90
+ "output_destination": "stderr",
91
+ "requires": [],
92
+ "conflicts_with": ["--dry-run"],
93
+ "exit_behavior": "continues_execution",
94
+ "scope": "execution_summary",
95
+ },
96
+ }
97
+
98
+
99
+ def enhance_param_info(
100
+ param_info: Dict[str, Any], param: click.Parameter
101
+ ) -> Dict[str, Any]:
102
+ """Enhance parameter info with dynamic data."""
103
+ # Import here to avoid circular imports
104
+ try:
105
+ from .click_options import ModelChoice
106
+
107
+ model_choice_class = ModelChoice
108
+ except ImportError:
109
+ model_choice_class = None
110
+
111
+ # For model parameter, add dynamic choices metadata
112
+ if (
113
+ param.name == "model"
114
+ and model_choice_class is not None
115
+ and isinstance(param.type, model_choice_class)
116
+ ):
117
+ param_info["dynamic_choices"] = True
118
+ param_info["choices_source"] = "openai_model_registry"
119
+
120
+ # Add registry metadata if available
121
+ try:
122
+ from openai_model_registry import ModelRegistry
123
+
124
+ registry = ModelRegistry.get_instance()
125
+ choices_list = list(param.type.choices)
126
+ param_info["registry_metadata"] = {
127
+ "total_models": len(list(registry.models)),
128
+ "structured_output_models": len(choices_list),
129
+ "registry_path": str(
130
+ getattr(registry.config, "registry_path", "unknown")
131
+ ),
132
+ }
133
+ except Exception:
134
+ param_info["registry_metadata"] = {"status": "unavailable"}
135
+
136
+ return param_info
137
+
138
+
139
+ def generate_usage_patterns_from_commands(
140
+ commands: Dict[str, Any],
141
+ ) -> Dict[str, str]:
142
+ """Generate usage patterns from actual command definitions instead of hardcoding."""
143
+ # This would ideally inspect the actual commands and generate examples
144
+ # For now, we'll keep the patterns but mark them as generated
145
+ patterns = {}
146
+
147
+ if "run" in commands:
148
+ patterns.update(
149
+ {
150
+ "basic_template": "ostruct run TEMPLATE.j2 SCHEMA.json -V name=value",
151
+ "file_attachment": "ostruct run TEMPLATE.j2 SCHEMA.json --file ci:data DATA.csv --file fs:docs DOCS.pdf",
152
+ "mcp_integration": "ostruct run TEMPLATE.j2 SCHEMA.json --mcp-server label@https://server.com/sse",
153
+ "dry_run": "ostruct run TEMPLATE.j2 SCHEMA.json --dry-run",
154
+ "json_output": "ostruct run TEMPLATE.j2 SCHEMA.json --dry-run-json",
155
+ }
156
+ )
157
+
158
+ return patterns
159
+
160
+
161
+ def print_command_help_json(
162
+ ctx: click.Context, param: click.Parameter, value: Any
163
+ ) -> None:
164
+ """Print single command help in JSON format."""
165
+ if not value or ctx.resilient_parsing:
166
+ return
167
+
168
+ # Use Click's built-in to_info_dict() method
169
+ help_data = ctx.to_info_dict() # type: ignore[attr-defined]
170
+
171
+ # Enhance parameter info with dynamic data
172
+ if "command" in help_data and "params" in help_data["command"]:
173
+ for param_info in help_data["command"]["params"]:
174
+ # Find the corresponding Click parameter
175
+ param_name = param_info.get("name")
176
+ if param_name:
177
+ for click_param in ctx.command.params:
178
+ if click_param.name == param_name:
179
+ enhance_param_info(param_info, click_param)
180
+ break
181
+
182
+ # Add ostruct-specific metadata
183
+ help_data.update(
184
+ {
185
+ "ostruct_version": __version__,
186
+ "help_type": "single_command",
187
+ "attachment_system": generate_attachment_system_info(),
188
+ "json_output_modes": generate_json_output_modes(),
189
+ }
190
+ )
191
+
192
+ click.echo(json.dumps(help_data, indent=2))
193
+ ctx.exit(0)
194
+
195
+
196
+ def print_full_cli_help_json(
197
+ ctx: click.Context, param: click.Parameter, value: Any
198
+ ) -> None:
199
+ """Print comprehensive help for all commands in JSON format."""
200
+ if not value or ctx.resilient_parsing:
201
+ return
202
+
203
+ # Get main group help
204
+ main_help = ctx.to_info_dict() # type: ignore[attr-defined]
205
+
206
+ # Get all commands help
207
+ commands_help = {}
208
+ if hasattr(ctx.command, "commands"):
209
+ for cmd_name, cmd in ctx.command.commands.items():
210
+ try:
211
+ cmd_ctx = cmd.make_context(
212
+ cmd_name, [], parent=ctx, resilient_parsing=True
213
+ )
214
+ commands_help[cmd_name] = cmd_ctx.to_info_dict()
215
+ except Exception as e:
216
+ commands_help[cmd_name] = {
217
+ "name": cmd_name,
218
+ "help": getattr(cmd, "help", None)
219
+ or getattr(cmd, "short_help", None),
220
+ "error": f"Could not generate full help: {str(e)}",
221
+ }
222
+
223
+ # Build comprehensive help structure
224
+ full_help = {
225
+ "ostruct_version": __version__,
226
+ "help_type": "full_cli",
227
+ "main_command": main_help,
228
+ "commands": commands_help,
229
+ "usage_patterns": generate_usage_patterns_from_commands(commands_help),
230
+ "attachment_system": generate_attachment_system_info(),
231
+ "json_output_modes": generate_json_output_modes(),
232
+ }
233
+
234
+ click.echo(json.dumps(full_help, indent=2))
235
+ ctx.exit(0)
@@ -7,7 +7,7 @@ with the OpenAI Responses API for enhanced functionality in ostruct.
7
7
  import logging
8
8
  import re
9
9
  import time
10
- from typing import TYPE_CHECKING, Any, Dict, List, Optional
10
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
11
11
  from urllib.parse import urlparse
12
12
 
13
13
  # Import requests for HTTP functionality (used in production)
@@ -16,6 +16,11 @@ try:
16
16
  except ImportError:
17
17
  requests = None # type: ignore[assignment]
18
18
 
19
+ try:
20
+ import bleach # type: ignore[import-untyped]
21
+ except ImportError:
22
+ bleach = None # type: ignore[assignment]
23
+
19
24
  if TYPE_CHECKING:
20
25
  from .services import ServiceHealth
21
26
 
@@ -161,23 +166,15 @@ class MCPClient:
161
166
  if not isinstance(text, str):
162
167
  return text
163
168
 
164
- # Remove script tags
165
- text = re.sub(
166
- r"<script[^>]*>.*?</script>",
167
- "",
168
- text,
169
- flags=re.IGNORECASE | re.DOTALL,
170
- )
171
- text = re.sub(r"<script[^>]*>", "", text, flags=re.IGNORECASE)
169
+ if bleach:
170
+ # Use bleach to strip all HTML tags, attributes, and styles.
171
+ # This is the safest way to prevent XSS.
172
+ text = bleach.clean(text, tags=[], attributes={}, strip=True)
172
173
 
173
- # Remove javascript: URLs
174
+ # bleach.clean doesn't handle javascript: URIs that are not in an
175
+ # href attribute, so we remove them explicitly as a safeguard.
174
176
  text = re.sub(r"javascript:", "", text, flags=re.IGNORECASE)
175
177
 
176
- # Remove other dangerous patterns
177
- text = re.sub(
178
- r"on\w+\s*=", "", text, flags=re.IGNORECASE
179
- ) # Event handlers
180
-
181
178
  return text
182
179
 
183
180
  def sanitize_dict(data: Any) -> Any:
@@ -193,7 +190,7 @@ class MCPClient:
193
190
  else:
194
191
  return data
195
192
 
196
- return sanitize_dict(response) # type: ignore[no-any-return]
193
+ return cast(Dict[str, Any], sanitize_dict(response))
197
194
 
198
195
  def _check_rate_limit(self) -> None:
199
196
  """Check and enforce rate limiting."""
ostruct/cli/params.py ADDED
@@ -0,0 +1,217 @@
1
+ """Parameter handling and validation for CLI attachment syntax."""
2
+
3
+ from typing import Any, Dict, Optional, Set, Tuple, TypedDict, Union
4
+
5
+ import click
6
+
7
+ # Target mapping with explicit aliases
8
+ TARGET_NORMALISE = {
9
+ "prompt": "prompt",
10
+ "code-interpreter": "code-interpreter",
11
+ "ci": "code-interpreter",
12
+ "file-search": "file-search",
13
+ "fs": "file-search",
14
+ }
15
+
16
+
17
+ class AttachmentSpec(TypedDict):
18
+ """Type definition for attachment specifications."""
19
+
20
+ alias: str
21
+ path: Union[
22
+ str, Tuple[str, str]
23
+ ] # str or ("@", "filelist.txt") for collect
24
+ targets: Set[str]
25
+ recursive: bool
26
+ pattern: Optional[str]
27
+
28
+
29
+ def normalise_targets(raw: str) -> Set[str]:
30
+ """Normalize comma-separated target list with aliases.
31
+
32
+ Args:
33
+ raw: Comma-separated string of targets (e.g., "prompt,ci,fs")
34
+
35
+ Returns:
36
+ Set of normalized target names
37
+
38
+ Raises:
39
+ click.BadParameter: If any target is unknown
40
+
41
+ Examples:
42
+ >>> normalise_targets("prompt")
43
+ {"prompt"}
44
+ >>> normalise_targets("ci,fs")
45
+ {"code-interpreter", "file-search"}
46
+ >>> normalise_targets("")
47
+ {"prompt"}
48
+ """
49
+ if not raw.strip(): # Guard against empty string edge case
50
+ return {"prompt"}
51
+
52
+ tokens = [t.strip().lower() for t in raw.split(",") if t.strip()]
53
+ if not tokens: # After stripping, no valid tokens remain
54
+ return {"prompt"}
55
+
56
+ # Normalize all tokens and check for unknown ones
57
+ normalized = set()
58
+ bad_tokens = set()
59
+
60
+ for token in tokens:
61
+ if token in TARGET_NORMALISE:
62
+ normalized.add(TARGET_NORMALISE[token])
63
+ else:
64
+ bad_tokens.add(token)
65
+
66
+ if bad_tokens:
67
+ valid_targets = ", ".join(sorted(TARGET_NORMALISE.keys()))
68
+ raise click.BadParameter(
69
+ f"Unknown target(s): {', '.join(sorted(bad_tokens))}. "
70
+ f"Valid targets: {valid_targets}"
71
+ )
72
+
73
+ return normalized or {"prompt"} # Fallback to prompt if somehow empty
74
+
75
+
76
+ def validate_attachment_alias(alias: str) -> str:
77
+ """Validate and normalize attachment alias.
78
+
79
+ Args:
80
+ alias: The attachment alias to validate
81
+
82
+ Returns:
83
+ The validated alias
84
+
85
+ Raises:
86
+ click.BadParameter: If alias is invalid
87
+ """
88
+ if not alias or not alias.strip():
89
+ raise click.BadParameter("Attachment alias cannot be empty")
90
+
91
+ alias = alias.strip()
92
+
93
+ # Basic validation - no whitespace, reasonable length
94
+ if " " in alias or "\t" in alias:
95
+ raise click.BadParameter("Attachment alias cannot contain whitespace")
96
+
97
+ if len(alias) > 64:
98
+ raise click.BadParameter(
99
+ "Attachment alias too long (max 64 characters)"
100
+ )
101
+
102
+ return alias
103
+
104
+
105
+ class AttachParam(click.ParamType):
106
+ """Custom Click parameter type for parsing attachment specifications.
107
+
108
+ Supports space-form syntax: '[targets:]alias path'
109
+
110
+ Examples:
111
+ --attach data ./file.txt
112
+ --attach ci:analysis ./data.csv
113
+ --collect ci,fs:mixed @file-list.txt
114
+ """
115
+
116
+ name = "attach-spec"
117
+
118
+ def __init__(self, multi: bool = False) -> None:
119
+ """Initialize AttachParam.
120
+
121
+ Args:
122
+ multi: If True, supports @filelist syntax for collect operations
123
+ """
124
+ self.multi = multi
125
+
126
+ def convert(
127
+ self,
128
+ value: Any,
129
+ param: Optional[click.Parameter],
130
+ ctx: Optional[click.Context],
131
+ ) -> Dict[str, Any]:
132
+ """Convert Click parameter value to AttachmentSpec.
133
+
134
+ Args:
135
+ value: Parameter value from Click (tuple for nargs=2)
136
+ param: Click parameter object
137
+ ctx: Click context
138
+
139
+ Returns:
140
+ Dict representing an AttachmentSpec
141
+
142
+ Raises:
143
+ click.BadParameter: If value format is invalid
144
+ """
145
+ # Space form only (nargs=2) - Click passes tuple
146
+ if not isinstance(value, tuple) or len(value) != 2:
147
+ self._fail_with_usage_examples(
148
+ "Attachment must use space form syntax", param, ctx
149
+ )
150
+
151
+ spec, path = value
152
+
153
+ # Parse spec part: [targets:]alias
154
+ if ":" in spec:
155
+ # Check for Windows drive letter false positive (C:\path)
156
+ if len(spec) == 2 and spec[1] == ":" and spec[0].isalpha():
157
+ # This is likely a drive letter, treat as alias only
158
+ prefix, alias = "prompt", spec
159
+ else:
160
+ prefix, alias = spec.split(":", 1)
161
+ else:
162
+ prefix, alias = "prompt", spec
163
+
164
+ # Normalize targets using the existing function
165
+ try:
166
+ targets = normalise_targets(prefix)
167
+ except click.BadParameter:
168
+ # Re-raise with context about attachment parsing
169
+ self._fail_with_usage_examples(
170
+ f"Invalid target(s) in '{prefix}'. Use comma-separated valid targets",
171
+ param,
172
+ ctx,
173
+ )
174
+
175
+ # Validate alias
176
+ try:
177
+ alias = validate_attachment_alias(alias)
178
+ except click.BadParameter as e:
179
+ self._fail_with_usage_examples(str(e), param, ctx)
180
+
181
+ # Handle collect @filelist syntax
182
+ if self.multi and path.startswith("@"):
183
+ filelist_path = path[1:] # Remove @
184
+ if not filelist_path:
185
+ self._fail_with_usage_examples(
186
+ "Filelist path cannot be empty after @", param, ctx
187
+ )
188
+ path = ("@", filelist_path)
189
+
190
+ return {
191
+ "alias": alias,
192
+ "path": path,
193
+ "targets": targets,
194
+ "recursive": False, # Set by flag processing
195
+ "pattern": None, # Set by flag processing
196
+ }
197
+
198
+ def _fail_with_usage_examples(
199
+ self,
200
+ message: str,
201
+ param: Optional[click.Parameter],
202
+ ctx: Optional[click.Context],
203
+ ) -> None:
204
+ """Provide helpful usage examples in error messages."""
205
+ examples = [
206
+ "--file data ./file.txt",
207
+ "--file ci:analysis ./data.csv",
208
+ "--dir fs:docs ./documentation",
209
+ ]
210
+
211
+ if self.multi:
212
+ examples.append("--collect ci,fs:mixed @file-list.txt")
213
+
214
+ full_message = f"{message}\n\nExamples:\n" + "\n".join(
215
+ f" {ex}" for ex in examples
216
+ )
217
+ self.fail(full_message, param, ctx)