Skip to content

Commit b15e96e

Browse files
authored
feat: Full shell completions for global and task argument in zsh and bash (#355)
Bumnp vesion to 0.41.0 - Requires reinstallation of completions scripts - Backwards compatible with previous completion me - Add testing harnesses for zsh and bash completion APIs - Update compeletions installation docs - Add testing README - Fix some linting issues
1 parent 58aaa8b commit b15e96e

31 files changed

+7653
-159
lines changed

.claude/settings.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"hooks": {
3+
"PostToolUse":
4+
[
5+
{
6+
"matcher": "Edit|Write",
7+
"hooks":
8+
[
9+
{
10+
"type": "command",
11+
"command": "poe format"
12+
}
13+
]
14+
}
15+
]
16+
},
17+
"permissions": {
18+
"allow":
19+
[
20+
"Bash(poe *)"
21+
]
22+
}
23+
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
- ✅ Tasks are run in poetry or uv's virtualenv ([or another env](https://poethepoet.natn.io/index.html#usage-without-poetry) you specify)
2121

22-
-[Shell completion of task names](https://poethepoet.natn.io/installation.html#shell-completion) (and global options too for zsh)
22+
-[Shell completion of task names and arguments](https://poethepoet.natn.io/installation.html#shell-completion)
2323

2424
- ✅ The poe CLI can be used standalone, or as a [plugin for poetry](https://poethepoet.natn.io/poetry_plugin.html)
2525

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Top features
4848

4949
|V| Tasks are run in poetry, or uv's virtualenv (or another env you specify)
5050

51-
|V| :ref:`Shell completion of task names<shell_completion>` (and global options too for zsh)
51+
|V| :ref:`Shell completion of task names and arguments<shell_completion>`
5252

5353
|V| The poe CLI can be used standalone, or as a :doc:`plugin for poetry<./poetry_plugin>`
5454

docs/installation.rst

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,47 @@ Zsh
122122
mkdir -p ~/.zfunc/
123123
poe _zsh_completion > ~/.zfunc/_poe
124124
125+
Zsh completion includes:
126+
127+
- Global CLI options (``-v``, ``-C``, etc.)
128+
- Task names with help text descriptions
129+
- Task-specific arguments (options and positional args)
130+
125131
.. tip::
126132
127133
You'll need to start a new shell for the new completion script to be loaded. If it still doesn't work try adding a call to :sh:`compinit` to the end of your zshrc file.
128134
135+
In some cases when upgrading to a newer version of the completion script it may be necessary to clean the zsh completions cache with `rm ~/.zcompdump*`
136+
129137
Bash
130138
~~~~
131139
132140
.. code-block:: bash
133141
134-
# System bash
135-
poe _bash_completion > /etc/bash_completion.d/poe.bash-completion
142+
# Quick setup - add to ~/.bashrc
143+
eval "$(poe _bash_completion)"
144+
145+
# Or install to a file (requires new shell to take effect):
146+
147+
# User local (recommended)
148+
mkdir -p ~/.local/share/bash-completion/completions
149+
poe _bash_completion > ~/.local/share/bash-completion/completions/poe
150+
151+
# System-wide
152+
poe _bash_completion | sudo tee /etc/bash_completion.d/poe > /dev/null
136153
137154
# Homebrew bash
138-
poe _bash_completion > $(brew --prefix)/etc/bash_completion.d/poe.bash-completion
155+
poe _bash_completion > $(brew --prefix)/etc/bash_completion.d/poe
156+
157+
Bash completion includes:
139158
159+
- Global CLI options (``-v``, ``-C``, etc.)
160+
- Task names
161+
- Task-specific arguments and choices
162+
163+
.. tip::
140164
141-
How to ensure installed bash completions are enabled may vary depending on your system.
165+
If completions don't work after installing to a file, ensure you have the ``bash-completion`` package installed and that it's sourced in your ``~/.bashrc``. You may need to start a new shell session.
142166
143167
Fish
144168
~~~~

poethepoet/__init__.py

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2838
def _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+
80146
def _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

poethepoet/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.40.0"
1+
__version__ = "0.41.0"

0 commit comments

Comments
 (0)