falyx 0.1.50__tar.gz → 0.1.52__tar.gz

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 (66) hide show
  1. {falyx-0.1.50 → falyx-0.1.52}/PKG-INFO +1 -1
  2. {falyx-0.1.50 → falyx-0.1.52}/falyx/__main__.py +1 -1
  3. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/__init__.py +2 -1
  4. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action.py +2 -2
  5. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action_group.py +3 -1
  6. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/chained_action.py +2 -0
  7. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/fallback_action.py +2 -0
  8. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/io_action.py +1 -97
  9. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/literal_input_action.py +2 -0
  10. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/menu_action.py +5 -1
  11. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/mixins.py +2 -0
  12. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/process_action.py +2 -0
  13. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/process_pool_action.py +3 -1
  14. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/select_file_action.py +32 -4
  15. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/selection_action.py +85 -23
  16. falyx-0.1.52/falyx/action/shell_action.py +105 -0
  17. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/types.py +2 -0
  18. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/user_input_action.py +5 -0
  19. {falyx-0.1.50 → falyx-0.1.52}/falyx/command.py +2 -2
  20. {falyx-0.1.50 → falyx-0.1.52}/falyx/context.py +1 -1
  21. {falyx-0.1.50 → falyx-0.1.52}/falyx/execution_registry.py +4 -4
  22. {falyx-0.1.50 → falyx-0.1.52}/falyx/falyx.py +8 -2
  23. {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/__init__.py +3 -1
  24. falyx-0.1.52/falyx/parser/argument.py +98 -0
  25. falyx-0.1.52/falyx/parser/argument_action.py +27 -0
  26. falyx-0.1.50/falyx/parsers/argparse.py → falyx-0.1.52/falyx/parser/command_argument_parser.py +4 -116
  27. {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/signature.py +1 -0
  28. {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/utils.py +2 -1
  29. {falyx-0.1.50 → falyx-0.1.52}/falyx/selection.py +73 -11
  30. falyx-0.1.52/falyx/validators.py +135 -0
  31. falyx-0.1.52/falyx/version.py +1 -0
  32. {falyx-0.1.50 → falyx-0.1.52}/pyproject.toml +1 -1
  33. falyx-0.1.50/falyx/.coverage +0 -0
  34. falyx-0.1.50/falyx/validators.py +0 -47
  35. falyx-0.1.50/falyx/version.py +0 -1
  36. {falyx-0.1.50 → falyx-0.1.52}/LICENSE +0 -0
  37. {falyx-0.1.50 → falyx-0.1.52}/README.md +0 -0
  38. {falyx-0.1.50 → falyx-0.1.52}/falyx/.pytyped +0 -0
  39. {falyx-0.1.50 → falyx-0.1.52}/falyx/__init__.py +0 -0
  40. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/.pytyped +0 -0
  41. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action_factory.py +0 -0
  42. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/base.py +0 -0
  43. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/http_action.py +0 -0
  44. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/prompt_menu_action.py +0 -0
  45. {falyx-0.1.50 → falyx-0.1.52}/falyx/action/signal_action.py +0 -0
  46. {falyx-0.1.50 → falyx-0.1.52}/falyx/bottom_bar.py +0 -0
  47. {falyx-0.1.50 → falyx-0.1.52}/falyx/config.py +0 -0
  48. {falyx-0.1.50 → falyx-0.1.52}/falyx/debug.py +0 -0
  49. {falyx-0.1.50 → falyx-0.1.52}/falyx/exceptions.py +0 -0
  50. {falyx-0.1.50 → falyx-0.1.52}/falyx/hook_manager.py +0 -0
  51. {falyx-0.1.50 → falyx-0.1.52}/falyx/hooks.py +0 -0
  52. {falyx-0.1.50 → falyx-0.1.52}/falyx/init.py +0 -0
  53. {falyx-0.1.50 → falyx-0.1.52}/falyx/logger.py +0 -0
  54. {falyx-0.1.50 → falyx-0.1.52}/falyx/menu.py +0 -0
  55. {falyx-0.1.50 → falyx-0.1.52}/falyx/options_manager.py +0 -0
  56. {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/.pytyped +0 -0
  57. {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/parsers.py +0 -0
  58. {falyx-0.1.50 → falyx-0.1.52}/falyx/prompt_utils.py +0 -0
  59. {falyx-0.1.50 → falyx-0.1.52}/falyx/protocols.py +0 -0
  60. {falyx-0.1.50 → falyx-0.1.52}/falyx/retry.py +0 -0
  61. {falyx-0.1.50 → falyx-0.1.52}/falyx/retry_utils.py +0 -0
  62. {falyx-0.1.50 → falyx-0.1.52}/falyx/signals.py +0 -0
  63. {falyx-0.1.50 → falyx-0.1.52}/falyx/tagged_table.py +0 -0
  64. {falyx-0.1.50 → falyx-0.1.52}/falyx/themes/__init__.py +0 -0
  65. {falyx-0.1.50 → falyx-0.1.52}/falyx/themes/colors.py +0 -0
  66. {falyx-0.1.50 → falyx-0.1.52}/falyx/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.50
3
+ Version: 0.1.52
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  from falyx.config import loader
16
16
  from falyx.falyx import Falyx
17
- from falyx.parsers import CommandArgumentParser, get_root_parser, get_subparsers
17
+ from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers
18
18
 
19
19
 
20
20
  def find_falyx_config() -> Path | None:
@@ -12,7 +12,7 @@ from .base import BaseAction
12
12
  from .chained_action import ChainedAction
13
13
  from .fallback_action import FallbackAction
14
14
  from .http_action import HTTPAction
15
- from .io_action import BaseIOAction, ShellAction
15
+ from .io_action import BaseIOAction
16
16
  from .literal_input_action import LiteralInputAction
17
17
  from .menu_action import MenuAction
18
18
  from .process_action import ProcessAction
@@ -20,6 +20,7 @@ from .process_pool_action import ProcessPoolAction
20
20
  from .prompt_menu_action import PromptMenuAction
21
21
  from .select_file_action import SelectFileAction
22
22
  from .selection_action import SelectionAction
23
+ from .shell_action import ShellAction
23
24
  from .signal_action import SignalAction
24
25
  from .user_input_action import UserInputAction
25
26
 
@@ -157,6 +157,6 @@ class Action(BaseAction):
157
157
  return (
158
158
  f"Action(name={self.name!r}, action="
159
159
  f"{getattr(self._action, '__name__', repr(self._action))}, "
160
- f"args={self.args!r}, kwargs={self.kwargs!r}, "
161
- f"retry={self.retry_policy.enabled})"
160
+ f"retry={self.retry_policy.enabled}, "
161
+ f"rollback={self.rollback is not None})"
162
162
  )
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """action_group.py"""
1
3
  import asyncio
2
4
  import random
3
5
  from typing import Any, Callable
@@ -11,7 +13,7 @@ from falyx.context import ExecutionContext, SharedContext
11
13
  from falyx.execution_registry import ExecutionRegistry as er
12
14
  from falyx.hook_manager import Hook, HookManager, HookType
13
15
  from falyx.logger import logger
14
- from falyx.parsers.utils import same_argument_definitions
16
+ from falyx.parser.utils import same_argument_definitions
15
17
  from falyx.themes.colors import OneColors
16
18
 
17
19
 
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """chained_action.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  from typing import Any, Callable
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """fallback_action.py"""
1
3
  from functools import cached_property
2
4
  from typing import Any
3
5
 
@@ -16,8 +16,6 @@ Common usage includes shell-like filters, input transformers, or any tool that
16
16
  needs to consume input from another process or pipeline.
17
17
  """
18
18
  import asyncio
19
- import shlex
20
- import subprocess
21
19
  import sys
22
20
  from typing import Any, Callable
23
21
 
@@ -25,10 +23,8 @@ from rich.tree import Tree
25
23
 
26
24
  from falyx.action.base import BaseAction
27
25
  from falyx.context import ExecutionContext
28
- from falyx.exceptions import FalyxError
29
26
  from falyx.execution_registry import ExecutionRegistry as er
30
27
  from falyx.hook_manager import HookManager, HookType
31
- from falyx.logger import logger
32
28
  from falyx.themes import OneColors
33
29
 
34
30
 
@@ -93,10 +89,7 @@ class BaseIOAction(BaseAction):
93
89
  if self.inject_last_result and self.shared_context:
94
90
  return self.shared_context.last_result()
95
91
 
96
- logger.debug(
97
- "[%s] No input provided and no last result found for injection.", self.name
98
- )
99
- raise FalyxError("No input provided and no last result to inject.")
92
+ return ""
100
93
 
101
94
  def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
102
95
  return None, None
@@ -174,92 +167,3 @@ class BaseIOAction(BaseAction):
174
167
  parent.add("".join(label))
175
168
  else:
176
169
  self.console.print(Tree("".join(label)))
177
-
178
-
179
- class ShellAction(BaseIOAction):
180
- """
181
- ShellAction wraps a shell command template for CLI pipelines.
182
-
183
- This Action takes parsed input (from stdin, literal, or last_result),
184
- substitutes it into the provided shell command template, and executes
185
- the command asynchronously using subprocess.
186
-
187
- Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
188
-
189
- ⚠️ Security Warning:
190
- By default, ShellAction uses `shell=True`, which can be dangerous with
191
- unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
192
- with `shlex.split()`.
193
-
194
- Features:
195
- - Automatically handles input parsing (str/bytes)
196
- - `safe_mode=True` disables shell interpretation and runs with `shell=False`
197
- - Captures stdout and stderr from shell execution
198
- - Raises on non-zero exit codes with stderr as the error
199
- - Result is returned as trimmed stdout string
200
-
201
- Args:
202
- name (str): Name of the action.
203
- command_template (str): Shell command to execute. Must include `{}` to include
204
- input. If no placeholder is present, the input is not
205
- included.
206
- safe_mode (bool): If True, runs with `shell=False` using shlex parsing
207
- (default: False).
208
- """
209
-
210
- def __init__(
211
- self, name: str, command_template: str, safe_mode: bool = False, **kwargs
212
- ):
213
- super().__init__(name=name, **kwargs)
214
- self.command_template = command_template
215
- self.safe_mode = safe_mode
216
-
217
- def from_input(self, raw: str | bytes) -> str:
218
- if not isinstance(raw, (str, bytes)):
219
- raise TypeError(
220
- f"{self.name} expected str or bytes input, got {type(raw).__name__}"
221
- )
222
- return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
223
-
224
- def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
225
- if sys.stdin.isatty():
226
- return self._run, {"parsed_input": {"help": self.command_template}}
227
- return None, None
228
-
229
- async def _run(self, parsed_input: str) -> str:
230
- # Replace placeholder in template, or use raw input as full command
231
- command = self.command_template.format(parsed_input)
232
- if self.safe_mode:
233
- try:
234
- args = shlex.split(command)
235
- except ValueError as error:
236
- raise FalyxError(f"Invalid command template: {error}")
237
- result = subprocess.run(args, capture_output=True, text=True, check=True)
238
- else:
239
- result = subprocess.run(
240
- command, shell=True, text=True, capture_output=True, check=True
241
- )
242
- if result.returncode != 0:
243
- raise RuntimeError(result.stderr.strip())
244
- return result.stdout.strip()
245
-
246
- def to_output(self, result: str) -> str:
247
- return result
248
-
249
- async def preview(self, parent: Tree | None = None):
250
- label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
251
- label.append(f"\n[dim]Template:[/] {self.command_template}")
252
- label.append(
253
- f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
254
- )
255
- if self.inject_last_result:
256
- label.append(f" [dim](injects '{self.inject_into}')[/dim]")
257
- tree = parent.add("".join(label)) if parent else Tree("".join(label))
258
- if not parent:
259
- self.console.print(tree)
260
-
261
- def __str__(self):
262
- return (
263
- f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
264
- f" safe_mode={self.safe_mode})"
265
- )
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """literal_input_action.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  from functools import cached_property
@@ -111,7 +111,7 @@ class MenuAction(BaseAction):
111
111
  key = effective_default
112
112
  if not self.never_prompt:
113
113
  table = self._build_table()
114
- key = await prompt_for_selection(
114
+ key_ = await prompt_for_selection(
115
115
  self.menu_options.keys(),
116
116
  table,
117
117
  default_selection=self.default_selection,
@@ -120,6 +120,10 @@ class MenuAction(BaseAction):
120
120
  prompt_message=self.prompt_message,
121
121
  show_table=self.show_table,
122
122
  )
123
+ if isinstance(key_, str):
124
+ key = key_
125
+ else:
126
+ assert False, "Unreachable, MenuAction only supports single selection"
123
127
  option = self.menu_options[key]
124
128
  result = await option.action(*args, **kwargs)
125
129
  context.result = result
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """mixins.py"""
1
3
  from falyx.action.base import BaseAction
2
4
 
3
5
 
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """process_action.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  import asyncio
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """process_pool_action.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  import asyncio
@@ -14,7 +16,7 @@ from falyx.context import ExecutionContext, SharedContext
14
16
  from falyx.execution_registry import ExecutionRegistry as er
15
17
  from falyx.hook_manager import HookManager, HookType
16
18
  from falyx.logger import logger
17
- from falyx.parsers.utils import same_argument_definitions
19
+ from falyx.parser.utils import same_argument_definitions
18
20
  from falyx.themes import OneColors
19
21
 
20
22
 
@@ -66,6 +66,9 @@ class SelectFileAction(BaseAction):
66
66
  style: str = OneColors.WHITE,
67
67
  suffix_filter: str | None = None,
68
68
  return_type: FileReturnType | str = FileReturnType.PATH,
69
+ number_selections: int | str = 1,
70
+ separator: str = ",",
71
+ allow_duplicates: bool = False,
69
72
  console: Console | None = None,
70
73
  prompt_session: PromptSession | None = None,
71
74
  ):
@@ -76,6 +79,9 @@ class SelectFileAction(BaseAction):
76
79
  self.prompt_message = prompt_message
77
80
  self.suffix_filter = suffix_filter
78
81
  self.style = style
82
+ self.number_selections = number_selections
83
+ self.separator = separator
84
+ self.allow_duplicates = allow_duplicates
79
85
  if isinstance(console, Console):
80
86
  self.console = console
81
87
  elif console:
@@ -83,6 +89,21 @@ class SelectFileAction(BaseAction):
83
89
  self.prompt_session = prompt_session or PromptSession()
84
90
  self.return_type = self._coerce_return_type(return_type)
85
91
 
92
+ @property
93
+ def number_selections(self) -> int | str:
94
+ return self._number_selections
95
+
96
+ @number_selections.setter
97
+ def number_selections(self, value: int | str):
98
+ if isinstance(value, int) and value > 0:
99
+ self._number_selections: int | str = value
100
+ elif isinstance(value, str):
101
+ if value not in ("*"):
102
+ raise ValueError("number_selections string must be one of '*'")
103
+ self._number_selections = value
104
+ else:
105
+ raise ValueError("number_selections must be a positive integer or one of '*'")
106
+
86
107
  def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
87
108
  if isinstance(return_type, FileReturnType):
88
109
  return return_type
@@ -163,18 +184,25 @@ class SelectFileAction(BaseAction):
163
184
  title=self.title, selections=options | cancel_option, columns=self.columns
164
185
  )
165
186
 
166
- key = await prompt_for_selection(
187
+ keys = await prompt_for_selection(
167
188
  (options | cancel_option).keys(),
168
189
  table,
169
190
  console=self.console,
170
191
  prompt_session=self.prompt_session,
171
192
  prompt_message=self.prompt_message,
193
+ number_selections=self.number_selections,
194
+ separator=self.separator,
195
+ allow_duplicates=self.allow_duplicates,
196
+ cancel_key=cancel_key,
172
197
  )
173
198
 
174
- if key == cancel_key:
175
- raise CancelSignal("User canceled the selection.")
199
+ if isinstance(keys, str):
200
+ if keys == cancel_key:
201
+ raise CancelSignal("User canceled the selection.")
202
+ result = options[keys].value
203
+ elif isinstance(keys, list):
204
+ result = [options[key].value for key in keys]
176
205
 
177
- result = options[key].value
178
206
  context.result = result
179
207
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
180
208
  return result
@@ -48,6 +48,9 @@ class SelectionAction(BaseAction):
48
48
  columns: int = 5,
49
49
  prompt_message: str = "Select > ",
50
50
  default_selection: str = "",
51
+ number_selections: int | str = 1,
52
+ separator: str = ",",
53
+ allow_duplicates: bool = False,
51
54
  inject_last_result: bool = False,
52
55
  inject_into: str = "last_result",
53
56
  return_type: SelectionReturnType | str = "value",
@@ -73,9 +76,26 @@ class SelectionAction(BaseAction):
73
76
  raise ValueError("`console` must be an instance of `rich.console.Console`")
74
77
  self.prompt_session = prompt_session or PromptSession()
75
78
  self.default_selection = default_selection
79
+ self.number_selections = number_selections
80
+ self.separator = separator
81
+ self.allow_duplicates = allow_duplicates
76
82
  self.prompt_message = prompt_message
77
83
  self.show_table = show_table
78
- self.cancel_key = self._find_cancel_key()
84
+
85
+ @property
86
+ def number_selections(self) -> int | str:
87
+ return self._number_selections
88
+
89
+ @number_selections.setter
90
+ def number_selections(self, value: int | str):
91
+ if isinstance(value, int) and value > 0:
92
+ self._number_selections: int | str = value
93
+ elif isinstance(value, str):
94
+ if value not in ("*"):
95
+ raise ValueError("number_selections string must be '*'")
96
+ self._number_selections = value
97
+ else:
98
+ raise ValueError("number_selections must be a positive integer or '*'")
79
99
 
80
100
  def _coerce_return_type(
81
101
  self, return_type: SelectionReturnType | str
@@ -156,6 +176,38 @@ class SelectionAction(BaseAction):
156
176
  def get_infer_target(self) -> tuple[None, None]:
157
177
  return None, None
158
178
 
179
+ def _get_result_from_keys(self, keys: str | list[str]) -> Any:
180
+ if not isinstance(self.selections, dict):
181
+ raise TypeError("Selections must be a dictionary to get result by keys.")
182
+ if self.return_type == SelectionReturnType.KEY:
183
+ result: Any = keys
184
+ elif self.return_type == SelectionReturnType.VALUE:
185
+ if isinstance(keys, list):
186
+ result = [self.selections[key].value for key in keys]
187
+ elif isinstance(keys, str):
188
+ result = self.selections[keys].value
189
+ elif self.return_type == SelectionReturnType.ITEMS:
190
+ if isinstance(keys, list):
191
+ result = {key: self.selections[key] for key in keys}
192
+ elif isinstance(keys, str):
193
+ result = {keys: self.selections[keys]}
194
+ elif self.return_type == SelectionReturnType.DESCRIPTION:
195
+ if isinstance(keys, list):
196
+ result = [self.selections[key].description for key in keys]
197
+ elif isinstance(keys, str):
198
+ result = self.selections[keys].description
199
+ elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
200
+ if isinstance(keys, list):
201
+ result = {
202
+ self.selections[key].description: self.selections[key].value
203
+ for key in keys
204
+ }
205
+ elif isinstance(keys, str):
206
+ result = {self.selections[keys].description: self.selections[keys].value}
207
+ else:
208
+ raise ValueError(f"Unsupported return type: {self.return_type}")
209
+ return result
210
+
159
211
  async def _run(self, *args, **kwargs) -> Any:
160
212
  kwargs = self._maybe_inject_last_result(kwargs)
161
213
  context = ExecutionContext(
@@ -191,7 +243,7 @@ class SelectionAction(BaseAction):
191
243
  if self.never_prompt and not effective_default:
192
244
  raise ValueError(
193
245
  f"[{self.name}] 'never_prompt' is True but no valid default_selection "
194
- "was provided."
246
+ "or usable last_result was available."
195
247
  )
196
248
 
197
249
  context.start_timer()
@@ -206,7 +258,7 @@ class SelectionAction(BaseAction):
206
258
  formatter=self.cancel_formatter,
207
259
  )
208
260
  if not self.never_prompt:
209
- index: int | str = await prompt_for_index(
261
+ indices: int | list[int] = await prompt_for_index(
210
262
  len(self.selections),
211
263
  table,
212
264
  default_selection=effective_default,
@@ -214,12 +266,30 @@ class SelectionAction(BaseAction):
214
266
  prompt_session=self.prompt_session,
215
267
  prompt_message=self.prompt_message,
216
268
  show_table=self.show_table,
269
+ number_selections=self.number_selections,
270
+ separator=self.separator,
271
+ allow_duplicates=self.allow_duplicates,
272
+ cancel_key=self.cancel_key,
217
273
  )
218
274
  else:
219
- index = effective_default
220
- if int(index) == int(self.cancel_key):
275
+ if effective_default:
276
+ indices = int(effective_default)
277
+ else:
278
+ raise ValueError(
279
+ f"[{self.name}] 'never_prompt' is True but no valid "
280
+ "default_selection was provided."
281
+ )
282
+
283
+ if indices == int(self.cancel_key):
221
284
  raise CancelSignal("User cancelled the selection.")
222
- result: Any = self.selections[int(index)]
285
+ if isinstance(indices, list):
286
+ result: str | list[str] = [
287
+ self.selections[index] for index in indices
288
+ ]
289
+ elif isinstance(indices, int):
290
+ result = self.selections[indices]
291
+ else:
292
+ assert False, "unreachable"
223
293
  elif isinstance(self.selections, dict):
224
294
  cancel_option = {
225
295
  self.cancel_key: SelectionOption(
@@ -232,7 +302,7 @@ class SelectionAction(BaseAction):
232
302
  columns=self.columns,
233
303
  )
234
304
  if not self.never_prompt:
235
- key = await prompt_for_selection(
305
+ keys = await prompt_for_selection(
236
306
  (self.selections | cancel_option).keys(),
237
307
  table,
238
308
  default_selection=effective_default,
@@ -240,25 +310,17 @@ class SelectionAction(BaseAction):
240
310
  prompt_session=self.prompt_session,
241
311
  prompt_message=self.prompt_message,
242
312
  show_table=self.show_table,
313
+ number_selections=self.number_selections,
314
+ separator=self.separator,
315
+ allow_duplicates=self.allow_duplicates,
316
+ cancel_key=self.cancel_key,
243
317
  )
244
318
  else:
245
- key = effective_default
246
- if key == self.cancel_key:
319
+ keys = effective_default
320
+ if keys == self.cancel_key:
247
321
  raise CancelSignal("User cancelled the selection.")
248
- if self.return_type == SelectionReturnType.KEY:
249
- result = key
250
- elif self.return_type == SelectionReturnType.VALUE:
251
- result = self.selections[key].value
252
- elif self.return_type == SelectionReturnType.ITEMS:
253
- result = {key: self.selections[key]}
254
- elif self.return_type == SelectionReturnType.DESCRIPTION:
255
- result = self.selections[key].description
256
- elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
257
- result = {
258
- self.selections[key].description: self.selections[key].value
259
- }
260
- else:
261
- raise ValueError(f"Unsupported return type: {self.return_type}")
322
+
323
+ result = self._get_result_from_keys(keys)
262
324
  else:
263
325
  raise TypeError(
264
326
  "'selections' must be a list[str] or dict[str, Any], "
@@ -0,0 +1,105 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """shell_action.py
3
+ Execute shell commands with input substitution."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from typing import Any, Callable
11
+
12
+ from rich.tree import Tree
13
+
14
+ from falyx.action.io_action import BaseIOAction
15
+ from falyx.exceptions import FalyxError
16
+ from falyx.themes import OneColors
17
+
18
+
19
+ class ShellAction(BaseIOAction):
20
+ """
21
+ ShellAction wraps a shell command template for CLI pipelines.
22
+
23
+ This Action takes parsed input (from stdin, literal, or last_result),
24
+ substitutes it into the provided shell command template, and executes
25
+ the command asynchronously using subprocess.
26
+
27
+ Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
28
+
29
+ ⚠️ Security Warning:
30
+ By default, ShellAction uses `shell=True`, which can be dangerous with
31
+ unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False`
32
+ with `shlex.split()`.
33
+
34
+ Features:
35
+ - Automatically handles input parsing (str/bytes)
36
+ - `safe_mode=True` disables shell interpretation and runs with `shell=False`
37
+ - Captures stdout and stderr from shell execution
38
+ - Raises on non-zero exit codes with stderr as the error
39
+ - Result is returned as trimmed stdout string
40
+
41
+ Args:
42
+ name (str): Name of the action.
43
+ command_template (str): Shell command to execute. Must include `{}` to include
44
+ input. If no placeholder is present, the input is not
45
+ included.
46
+ safe_mode (bool): If True, runs with `shell=False` using shlex parsing
47
+ (default: False).
48
+ """
49
+
50
+ def __init__(
51
+ self, name: str, command_template: str, safe_mode: bool = False, **kwargs
52
+ ):
53
+ super().__init__(name=name, **kwargs)
54
+ self.command_template = command_template
55
+ self.safe_mode = safe_mode
56
+
57
+ def from_input(self, raw: str | bytes) -> str:
58
+ if not isinstance(raw, (str, bytes)):
59
+ raise TypeError(
60
+ f"{self.name} expected str or bytes input, got {type(raw).__name__}"
61
+ )
62
+ return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
63
+
64
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
65
+ if sys.stdin.isatty():
66
+ return self._run, {"parsed_input": {"help": self.command_template}}
67
+ return None, None
68
+
69
+ async def _run(self, parsed_input: str) -> str:
70
+ # Replace placeholder in template, or use raw input as full command
71
+ command = self.command_template.format(parsed_input)
72
+ if self.safe_mode:
73
+ try:
74
+ args = shlex.split(command)
75
+ except ValueError as error:
76
+ raise FalyxError(f"Invalid command template: {error}")
77
+ result = subprocess.run(args, capture_output=True, text=True, check=True)
78
+ else:
79
+ result = subprocess.run(
80
+ command, shell=True, text=True, capture_output=True, check=True
81
+ )
82
+ if result.returncode != 0:
83
+ raise RuntimeError(result.stderr.strip())
84
+ return result.stdout.strip()
85
+
86
+ def to_output(self, result: str) -> str:
87
+ return result
88
+
89
+ async def preview(self, parent: Tree | None = None):
90
+ label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
91
+ label.append(f"\n[dim]Template:[/] {self.command_template}")
92
+ label.append(
93
+ f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
94
+ )
95
+ if self.inject_last_result:
96
+ label.append(f" [dim](injects '{self.inject_into}')[/dim]")
97
+ tree = parent.add("".join(label)) if parent else Tree("".join(label))
98
+ if not parent:
99
+ self.console.print(tree)
100
+
101
+ def __str__(self):
102
+ return (
103
+ f"ShellAction(name={self.name!r}, command_template={self.command_template!r},"
104
+ f" safe_mode={self.safe_mode})"
105
+ )
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """types.py"""
1
3
  from __future__ import annotations
2
4
 
3
5
  from enum import Enum
@@ -1,3 +1,5 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """user_input_action.py"""
1
3
  from prompt_toolkit import PromptSession
2
4
  from prompt_toolkit.validation import Validator
3
5
  from rich.console import Console
@@ -29,6 +31,7 @@ class UserInputAction(BaseAction):
29
31
  name: str,
30
32
  *,
31
33
  prompt_text: str = "Input > ",
34
+ default_text: str = "",
32
35
  validator: Validator | None = None,
33
36
  console: Console | None = None,
34
37
  prompt_session: PromptSession | None = None,
@@ -45,6 +48,7 @@ class UserInputAction(BaseAction):
45
48
  elif console:
46
49
  raise ValueError("`console` must be an instance of `rich.console.Console`")
47
50
  self.prompt_session = prompt_session or PromptSession()
51
+ self.default_text = default_text
48
52
 
49
53
  def get_infer_target(self) -> tuple[None, None]:
50
54
  return None, None
@@ -67,6 +71,7 @@ class UserInputAction(BaseAction):
67
71
  answer = await self.prompt_session.prompt_async(
68
72
  prompt_text,
69
73
  validator=self.validator,
74
+ default=kwargs.get("default_text", self.default_text),
70
75
  )
71
76
  context.result = answer
72
77
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -34,8 +34,8 @@ from falyx.execution_registry import ExecutionRegistry as er
34
34
  from falyx.hook_manager import HookManager, HookType
35
35
  from falyx.logger import logger
36
36
  from falyx.options_manager import OptionsManager
37
- from falyx.parsers.argparse import CommandArgumentParser
38
- from falyx.parsers.signature import infer_args_from_func
37
+ from falyx.parser.command_argument_parser import CommandArgumentParser
38
+ from falyx.parser.signature import infer_args_from_func
39
39
  from falyx.prompt_utils import confirm_async, should_prompt_user
40
40
  from falyx.protocols import ArgParserProtocol
41
41
  from falyx.retry import RetryPolicy
@@ -129,7 +129,7 @@ class ExecutionContext(BaseModel):
129
129
  args = ", ".join(map(repr, self.args))
130
130
  kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
131
131
  signature = ", ".join(filter(None, [args, kwargs]))
132
- return f"{self.name} ({signature})"
132
+ return f"{self.action} ({signature})"
133
133
 
134
134
  def as_dict(self) -> dict:
135
135
  return {