@@ -25,6 +25,16 @@ def main():
2525 raise SystemExit (result )
2626
2727
28+ def iter_tasks (target_path : str = "." ):
29+ from .config import PoeConfig
30+
31+ config = PoeConfig ()
32+ config .load_sync (target_path , strict = False )
33+ for task in config .tasks .keys ():
34+ if task and task [0 ] != "_" :
35+ yield task
36+
37+
2838def _run_builtin_task (
2939 task_name : str , second_arg : str = "" , third_arg : str = ""
3040) -> bool :
@@ -45,6 +55,22 @@ def _run_builtin_task(
4555 _list_tasks (target_path = target_path )
4656 return True
4757
58+ if task_name == "_zsh_describe_tasks" :
59+ target_path = (
60+ str (Path (second_arg ).expanduser ().resolve ()) if second_arg else None
61+ )
62+ _zsh_describe_tasks (target_path = target_path )
63+ return True
64+
65+ if task_name == "_describe_task_args" :
66+ # second_arg is task name, third_arg is optional target path
67+ if second_arg :
68+ target_path = (
69+ str (Path (third_arg ).expanduser ().resolve ()) if third_arg else None
70+ )
71+ _describe_task_args (task_name = second_arg , target_path = target_path )
72+ return True
73+
4874 target_path = ""
4975 if second_arg :
5076 if not second_arg .isalnum ():
@@ -65,7 +91,7 @@ def _run_builtin_task(
6591 if task_name == "_bash_completion" :
6692 from .completion .bash import get_bash_completion_script
6793
68- print (get_bash_completion_script (name = second_arg , target_path = target_path ))
94+ print (get_bash_completion_script (name = second_arg ))
6995 return True
7096
7197 if task_name == "_fish_completion" :
@@ -77,20 +103,160 @@ def _run_builtin_task(
77103 return False
78104
79105
106+ def _format_help (text : str | None , max_len : int = 60 ) -> str :
107+ """
108+ Format help text for shell completion output.
109+
110+ - Takes first line only
111+ - Truncates with ellipsis if too long
112+ - Escapes special characters (backslash, colon, tab)
113+ """
114+ if not text :
115+ return " " # Space placeholder - empty descriptions can confuse _describe
116+ # First line only, strip whitespace
117+ text = text .split ("\n " )[0 ].strip ()
118+
119+ # Truncate with ellipsis if too long
120+ if len (text ) > max_len :
121+ text = text [: max_len - 3 ].rstrip () + "..."
122+
123+ # Escape special characters for
124+ return text .replace ("\\ " , "\\ \\ " ).replace (":" , "\\ :" ).replace ("\t " , " " )
125+
126+
127+ def _escape_choice (value : str ) -> str :
128+ """
129+ Escape a choice value for shell completion output.
130+
131+ Quotes the value with single quotes if it contains special characters
132+ (spaces, tabs, newlines, quotes, backslash, $, backtick).
133+ Single quotes within the value are escaped as '\\ '' (end quote, escaped
134+ quote, start quote).
135+ """
136+ if not value :
137+ return value
138+ # Characters that require quoting
139+ if any (c in value for c in " \t \n \" '\\ $`" ):
140+ # Escape single quotes: end quote, add escaped quote, start new quote
141+ escaped = value .replace ("'" , "'\\ ''" )
142+ return f"'{ escaped } '"
143+ return value
144+
145+
80146def _list_tasks (target_path : str | None = None ):
81147 """
82148 A special task accessible via `poe _list_tasks` for use in shell completion
83149
84150 Note this code path should include minimal imports to avoid slowing down the shell
85151 """
152+ try : # noqa: SIM105
153+ print (" " .join (iter_tasks (target_path or "" )))
154+ except Exception :
155+ # this happens if there's no pyproject.toml present
156+ pass
157+
86158
159+ def _zsh_describe_tasks (target_path : str | None = None ):
160+ """
161+ Output task names with descriptions in zsh _describe format.
162+
163+ Format: one task per line as "name:description"
164+ - Colons in descriptions are escaped as \\ :
165+ - Descriptions truncated to 60 chars with ...
166+ - Tasks without help get empty description (name:)
167+ """
87168 try :
88169 from .config import PoeConfig
89170
90171 config = PoeConfig ()
91172 config .load_sync (target_path , strict = False )
92- task_names = (task for task in config .task_names if task and task [0 ] != "_" )
93- print (" " .join (task_names ))
173+ tasks = config .tasks
174+
175+ for task_name in config .task_names :
176+ if not task_name or task_name .startswith ("_" ):
177+ continue
178+
179+ task_def = tasks .get (task_name , {})
180+
181+ # Extract help text - handle both dict and simple string task definitions
182+ if isinstance (task_def , dict ):
183+ help_text = task_def .get ("help" , "" ) or ""
184+ else :
185+ help_text = ""
186+
187+ help_text = _format_help (help_text )
188+ print (f"{ task_name } :{ help_text } " )
189+
94190 except Exception :
95191 # this happens if there's no pyproject.toml present
96192 pass
193+
194+
195+ def _describe_task_args (task_name : str , target_path : str | None = None ):
196+ """
197+ Output argument specs for a specific task in a shell-agnostic format.
198+
199+ Used by both bash and zsh completion scripts.
200+
201+ Format: tab-separated fields per line:
202+ <options> <type> <help> <choices>
203+
204+ Where:
205+ - options: comma-separated option strings (e.g., "--greeting,-g")
206+ - type: "boolean", "string", "integer", "float", or "positional"
207+ - help: description text (colons escaped as \\ :)
208+ - choices: space-separated list of allowed values ("_" if no choices)
209+
210+ Example output:
211+ --greeting,-g string The greeting to use _
212+ --verbose,-v boolean Verbose mode _
213+ --flavor,-f string Flavor vanilla chocolate strawberry
214+ name positional The name argument _
215+ """
216+ try :
217+ from .config import PoeConfig
218+ from .task .args import ArgSpec
219+
220+ config = PoeConfig ()
221+ config .load_sync (target_path , strict = False )
222+
223+ task_def = config .tasks .get (task_name , {})
224+ if not isinstance (task_def , dict ):
225+ return
226+
227+ args_def = task_def .get ("args" )
228+ if not args_def :
229+ return
230+
231+ for arg in ArgSpec .normalize (args_def , strict = False ):
232+ help_text = _format_help (arg .get ("help" ))
233+
234+ # Format choices as space-separated values with proper escaping
235+ # Use "_" as placeholder for empty (shell read may skip consecutive tabs)
236+ choices_list = [
237+ _escape_choice (str_choice )
238+ for choice in (arg .get ("choices" ) or [])
239+ if (str_choice := str (choice ))
240+ ]
241+ choices = " " .join (choices_list ) if choices_list else "_"
242+
243+ arg_details : list [str ] = []
244+
245+ if arg .get ("positional" ):
246+ if name := arg .get ("name" , "" ):
247+ arg_details = [name , "positional" , help_text , choices ]
248+ else :
249+ # Join all option strings for this arg
250+ arg_details = [
251+ "," .join (arg .get ("options" )),
252+ arg .get ("type" , "string" ),
253+ help_text ,
254+ choices ,
255+ ]
256+
257+ if arg_details :
258+ print ("\t " .join (arg_details ))
259+
260+ except Exception :
261+ # Silently fail - no completions is better than breaking the shell
262+ pass
0 commit comments