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.
- {falyx-0.1.50 → falyx-0.1.52}/PKG-INFO +1 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/__main__.py +1 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/__init__.py +2 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action.py +2 -2
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action_group.py +3 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/chained_action.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/fallback_action.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/io_action.py +1 -97
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/literal_input_action.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/menu_action.py +5 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/mixins.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/process_action.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/process_pool_action.py +3 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/select_file_action.py +32 -4
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/selection_action.py +85 -23
- falyx-0.1.52/falyx/action/shell_action.py +105 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/types.py +2 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/user_input_action.py +5 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/command.py +2 -2
- {falyx-0.1.50 → falyx-0.1.52}/falyx/context.py +1 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/execution_registry.py +4 -4
- {falyx-0.1.50 → falyx-0.1.52}/falyx/falyx.py +8 -2
- {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/__init__.py +3 -1
- falyx-0.1.52/falyx/parser/argument.py +98 -0
- falyx-0.1.52/falyx/parser/argument_action.py +27 -0
- falyx-0.1.50/falyx/parsers/argparse.py → falyx-0.1.52/falyx/parser/command_argument_parser.py +4 -116
- {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/signature.py +1 -0
- {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/utils.py +2 -1
- {falyx-0.1.50 → falyx-0.1.52}/falyx/selection.py +73 -11
- falyx-0.1.52/falyx/validators.py +135 -0
- falyx-0.1.52/falyx/version.py +1 -0
- {falyx-0.1.50 → falyx-0.1.52}/pyproject.toml +1 -1
- falyx-0.1.50/falyx/.coverage +0 -0
- falyx-0.1.50/falyx/validators.py +0 -47
- falyx-0.1.50/falyx/version.py +0 -1
- {falyx-0.1.50 → falyx-0.1.52}/LICENSE +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/README.md +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/.pytyped +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/__init__.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/.pytyped +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/action_factory.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/base.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/http_action.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/prompt_menu_action.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/action/signal_action.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/bottom_bar.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/config.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/debug.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/exceptions.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/hook_manager.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/hooks.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/init.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/logger.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/menu.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/options_manager.py +0 -0
- {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/.pytyped +0 -0
- {falyx-0.1.50/falyx/parsers → falyx-0.1.52/falyx/parser}/parsers.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/prompt_utils.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/protocols.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/retry.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/retry_utils.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/signals.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/tagged_table.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/themes/__init__.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/themes/colors.py +0 -0
- {falyx-0.1.50 → falyx-0.1.52}/falyx/utils.py +0 -0
@@ -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.
|
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
|
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"
|
161
|
-
f"
|
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.
|
16
|
+
from falyx.parser.utils import same_argument_definitions
|
15
17
|
from falyx.themes.colors import OneColors
|
16
18
|
|
17
19
|
|
@@ -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
|
-
|
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
|
-
)
|
@@ -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
|
-
|
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
|
+
"""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.
|
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
|
-
|
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
|
175
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
220
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
246
|
-
if
|
319
|
+
keys = effective_default
|
320
|
+
if keys == self.cancel_key:
|
247
321
|
raise CancelSignal("User cancelled the selection.")
|
248
|
-
|
249
|
-
|
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
|
+
"""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.
|
38
|
-
from falyx.
|
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.
|
132
|
+
return f"{self.action} ({signature})"
|
133
133
|
|
134
134
|
def as_dict(self) -> dict:
|
135
135
|
return {
|