falyx 0.1.22__tar.gz → 0.1.23__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 (43) hide show
  1. {falyx-0.1.22 → falyx-0.1.23}/PKG-INFO +1 -1
  2. {falyx-0.1.22 → falyx-0.1.23}/falyx/__main__.py +3 -8
  3. {falyx-0.1.22 → falyx-0.1.23}/falyx/action_factory.py +1 -0
  4. falyx-0.1.23/falyx/config.py +214 -0
  5. falyx-0.1.23/falyx/config_schema.py +76 -0
  6. {falyx-0.1.22 → falyx-0.1.23}/falyx/execution_registry.py +3 -2
  7. {falyx-0.1.22 → falyx-0.1.23}/falyx/falyx.py +44 -26
  8. falyx-0.1.23/falyx/init.py +136 -0
  9. {falyx-0.1.22 → falyx-0.1.23}/falyx/io_action.py +24 -11
  10. {falyx-0.1.22 → falyx-0.1.23}/falyx/prompt_utils.py +1 -0
  11. {falyx-0.1.22 → falyx-0.1.23}/falyx/protocols.py +1 -0
  12. {falyx-0.1.22 → falyx-0.1.23}/falyx/select_file_action.py +36 -4
  13. {falyx-0.1.22 → falyx-0.1.23}/falyx/selection.py +3 -3
  14. {falyx-0.1.22 → falyx-0.1.23}/falyx/selection_action.py +2 -2
  15. {falyx-0.1.22 → falyx-0.1.23}/falyx/signal_action.py +1 -0
  16. falyx-0.1.23/falyx/version.py +1 -0
  17. {falyx-0.1.22 → falyx-0.1.23}/pyproject.toml +1 -1
  18. falyx-0.1.22/falyx/config.py +0 -144
  19. falyx-0.1.22/falyx/init.py +0 -76
  20. falyx-0.1.22/falyx/version.py +0 -1
  21. {falyx-0.1.22 → falyx-0.1.23}/LICENSE +0 -0
  22. {falyx-0.1.22 → falyx-0.1.23}/README.md +0 -0
  23. {falyx-0.1.22 → falyx-0.1.23}/falyx/.pytyped +0 -0
  24. {falyx-0.1.22 → falyx-0.1.23}/falyx/__init__.py +0 -0
  25. {falyx-0.1.22 → falyx-0.1.23}/falyx/action.py +0 -0
  26. {falyx-0.1.22 → falyx-0.1.23}/falyx/bottom_bar.py +0 -0
  27. {falyx-0.1.22 → falyx-0.1.23}/falyx/command.py +0 -0
  28. {falyx-0.1.22 → falyx-0.1.23}/falyx/context.py +0 -0
  29. {falyx-0.1.22 → falyx-0.1.23}/falyx/debug.py +0 -0
  30. {falyx-0.1.22 → falyx-0.1.23}/falyx/exceptions.py +0 -0
  31. {falyx-0.1.22 → falyx-0.1.23}/falyx/hook_manager.py +0 -0
  32. {falyx-0.1.22 → falyx-0.1.23}/falyx/hooks.py +0 -0
  33. {falyx-0.1.22 → falyx-0.1.23}/falyx/http_action.py +0 -0
  34. {falyx-0.1.22 → falyx-0.1.23}/falyx/menu_action.py +0 -0
  35. {falyx-0.1.22 → falyx-0.1.23}/falyx/options_manager.py +0 -0
  36. {falyx-0.1.22 → falyx-0.1.23}/falyx/parsers.py +0 -0
  37. {falyx-0.1.22 → falyx-0.1.23}/falyx/retry.py +0 -0
  38. {falyx-0.1.22 → falyx-0.1.23}/falyx/retry_utils.py +0 -0
  39. {falyx-0.1.22 → falyx-0.1.23}/falyx/signals.py +0 -0
  40. {falyx-0.1.22 → falyx-0.1.23}/falyx/tagged_table.py +0 -0
  41. {falyx-0.1.22 → falyx-0.1.23}/falyx/themes/colors.py +0 -0
  42. {falyx-0.1.22 → falyx-0.1.23}/falyx/utils.py +0 -0
  43. {falyx-0.1.22 → falyx-0.1.23}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.22
3
+ Version: 0.1.23
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -6,6 +6,7 @@ Licensed under the MIT License. See LICENSE file for details.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import os
9
10
  import sys
10
11
  from argparse import Namespace
11
12
  from pathlib import Path
@@ -14,7 +15,6 @@ from typing import Any
14
15
  from falyx.config import loader
15
16
  from falyx.falyx import Falyx
16
17
  from falyx.parsers import FalyxParsers, get_arg_parsers
17
- from falyx.themes.colors import OneColors
18
18
 
19
19
 
20
20
  def find_falyx_config() -> Path | None:
@@ -23,6 +23,7 @@ def find_falyx_config() -> Path | None:
23
23
  Path.cwd() / "falyx.toml",
24
24
  Path.cwd() / ".falyx.yaml",
25
25
  Path.cwd() / ".falyx.toml",
26
+ Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")),
26
27
  Path.home() / ".config" / "falyx" / "falyx.yaml",
27
28
  Path.home() / ".config" / "falyx" / "falyx.toml",
28
29
  Path.home() / ".falyx.yaml",
@@ -68,13 +69,7 @@ def run(args: Namespace) -> Any:
68
69
  print("No Falyx config file found. Exiting.")
69
70
  return None
70
71
 
71
- flx = Falyx(
72
- title="🛠️ Config-Driven CLI",
73
- cli_args=args,
74
- columns=4,
75
- prompt=[(OneColors.BLUE_b, "FALYX > ")],
76
- )
77
- flx.add_commands(loader(bootstrap_path))
72
+ flx: Falyx = loader(bootstrap_path)
78
73
  return asyncio.run(flx.run())
79
74
 
80
75
 
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from typing import Any
2
3
 
3
4
  from rich.tree import Tree
@@ -0,0 +1,214 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """config.py
3
+ Configuration loader for Falyx CLI commands."""
4
+ from __future__ import annotations
5
+
6
+ import importlib
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ import toml
12
+ import yaml
13
+ from pydantic import BaseModel, Field, field_validator, model_validator
14
+ from rich.console import Console
15
+
16
+ from falyx.action import Action, BaseAction
17
+ from falyx.command import Command
18
+ from falyx.falyx import Falyx
19
+ from falyx.retry import RetryPolicy
20
+ from falyx.themes.colors import OneColors
21
+ from falyx.utils import logger
22
+
23
+ console = Console(color_system="auto")
24
+
25
+
26
+ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
27
+ if isinstance(obj, (BaseAction, Command)):
28
+ return obj
29
+ elif callable(obj):
30
+ return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
31
+ else:
32
+ raise TypeError(
33
+ f"Cannot wrap object of type '{type(obj).__name__}'. "
34
+ "Expected a function or BaseAction."
35
+ )
36
+
37
+
38
+ def import_action(dotted_path: str) -> Any:
39
+ """Dynamically imports a callable from a dotted path like 'my.module.func'."""
40
+ module_path, _, attr = dotted_path.rpartition(".")
41
+ if not module_path:
42
+ console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
43
+ sys.exit(1)
44
+ try:
45
+ module = importlib.import_module(module_path)
46
+ except ModuleNotFoundError as error:
47
+ logger.error("Failed to import module '%s': %s", module_path, error)
48
+ console.print(
49
+ f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
50
+ f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
51
+ )
52
+ sys.exit(1)
53
+ try:
54
+ action = getattr(module, attr)
55
+ except AttributeError as error:
56
+ logger.error(
57
+ "Module '%s' does not have attribute '%s': %s", module_path, attr, error
58
+ )
59
+ console.print(
60
+ f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
61
+ )
62
+ sys.exit(1)
63
+ return action
64
+
65
+
66
+ class RawCommand(BaseModel):
67
+ key: str
68
+ description: str
69
+ action: str
70
+
71
+ args: tuple[Any, ...] = ()
72
+ kwargs: dict[str, Any] = {}
73
+ aliases: list[str] = []
74
+ tags: list[str] = []
75
+ style: str = "white"
76
+
77
+ confirm: bool = False
78
+ confirm_message: str = "Are you sure?"
79
+ preview_before_confirm: bool = True
80
+
81
+ spinner: bool = False
82
+ spinner_message: str = "Processing..."
83
+ spinner_type: str = "dots"
84
+ spinner_style: str = "cyan"
85
+ spinner_kwargs: dict[str, Any] = {}
86
+
87
+ before_hooks: list[Callable] = []
88
+ success_hooks: list[Callable] = []
89
+ error_hooks: list[Callable] = []
90
+ after_hooks: list[Callable] = []
91
+ teardown_hooks: list[Callable] = []
92
+
93
+ logging_hooks: bool = False
94
+ retry: bool = False
95
+ retry_all: bool = False
96
+ retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
97
+ requires_input: bool | None = None
98
+ hidden: bool = False
99
+ help_text: str = ""
100
+
101
+ @field_validator("retry_policy")
102
+ @classmethod
103
+ def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy:
104
+ if isinstance(value, RetryPolicy):
105
+ return value
106
+ if not isinstance(value, dict):
107
+ raise ValueError("retry_policy must be a dictionary.")
108
+ return RetryPolicy(**value)
109
+
110
+
111
+ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
112
+ commands = []
113
+ for entry in raw_commands:
114
+ raw_command = RawCommand(**entry)
115
+ commands.append(
116
+ Command.model_validate(
117
+ {
118
+ **raw_command.model_dump(exclude={"action"}),
119
+ "action": wrap_if_needed(
120
+ import_action(raw_command.action), name=raw_command.description
121
+ ),
122
+ }
123
+ )
124
+ )
125
+ return commands
126
+
127
+
128
+ class FalyxConfig(BaseModel):
129
+ title: str = "Falyx CLI"
130
+ prompt: str | list[tuple[str, str]] | list[list[str]] = [
131
+ (OneColors.BLUE_b, "FALYX > ")
132
+ ]
133
+ columns: int = 4
134
+ welcome_message: str = ""
135
+ exit_message: str = ""
136
+ commands: list[Command] | list[dict] = []
137
+
138
+ @model_validator(mode="after")
139
+ def validate_prompt_format(self) -> FalyxConfig:
140
+ if isinstance(self.prompt, list):
141
+ for pair in self.prompt:
142
+ if not isinstance(pair, (list, tuple)) or len(pair) != 2:
143
+ raise ValueError(
144
+ "Prompt list must contain 2-element (style, text) pairs"
145
+ )
146
+ return self
147
+
148
+ def to_falyx(self) -> Falyx:
149
+ flx = Falyx(
150
+ title=self.title,
151
+ prompt=self.prompt,
152
+ columns=self.columns,
153
+ welcome_message=self.welcome_message,
154
+ exit_message=self.exit_message,
155
+ )
156
+ flx.add_commands(self.commands)
157
+ return flx
158
+
159
+
160
+ def loader(file_path: Path | str) -> Falyx:
161
+ """
162
+ Load command definitions from a YAML or TOML file.
163
+
164
+ Each command should be defined as a dictionary with at least:
165
+ - key: a unique single-character key
166
+ - description: short description
167
+ - action: dotted import path to the action function/class
168
+
169
+ Args:
170
+ file_path (str): Path to the config file (YAML or TOML).
171
+
172
+ Returns:
173
+ Falyx: An instance of the Falyx CLI with loaded commands.
174
+
175
+ Raises:
176
+ ValueError: If the file format is unsupported or file cannot be parsed.
177
+ """
178
+ if isinstance(file_path, (str, Path)):
179
+ path = Path(file_path)
180
+ else:
181
+ raise TypeError("file_path must be a string or Path object.")
182
+
183
+ if not path.is_file():
184
+ raise FileNotFoundError(f"No such config file: {file_path}")
185
+
186
+ suffix = path.suffix
187
+ with path.open("r", encoding="UTF-8") as config_file:
188
+ if suffix in (".yaml", ".yml"):
189
+ raw_config = yaml.safe_load(config_file)
190
+ elif suffix == ".toml":
191
+ raw_config = toml.load(config_file)
192
+ else:
193
+ raise ValueError(f"Unsupported config format: {suffix}")
194
+
195
+ if not isinstance(raw_config, dict):
196
+ raise ValueError(
197
+ "Configuration file must contain a dictionary with a list of commands.\n"
198
+ "Example:\n"
199
+ "title: 'My CLI'\n"
200
+ "commands:\n"
201
+ " - key: 'a'\n"
202
+ " description: 'Example command'\n"
203
+ " action: 'my_module.my_function'"
204
+ )
205
+
206
+ commands = convert_commands(raw_config["commands"])
207
+ return FalyxConfig(
208
+ title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
209
+ prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
210
+ columns=raw_config.get("columns", 4),
211
+ welcome_message=raw_config.get("welcome_message", ""),
212
+ exit_message=raw_config.get("exit_message", ""),
213
+ commands=commands,
214
+ ).to_falyx()
@@ -0,0 +1,76 @@
1
+ FALYX_CONFIG_SCHEMA = {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Falyx CLI Config",
4
+ "type": "object",
5
+ "properties": {
6
+ "title": {"type": "string", "description": "Title shown at top of menu"},
7
+ "prompt": {
8
+ "oneOf": [
9
+ {"type": "string"},
10
+ {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "array",
14
+ "prefixItems": [
15
+ {
16
+ "type": "string",
17
+ "description": "Style string (e.g., 'bold #ff0000 italic')",
18
+ },
19
+ {"type": "string", "description": "Text content"},
20
+ ],
21
+ "minItems": 2,
22
+ "maxItems": 2,
23
+ },
24
+ },
25
+ ]
26
+ },
27
+ "columns": {
28
+ "type": "integer",
29
+ "minimum": 1,
30
+ "description": "Number of menu columns",
31
+ },
32
+ "welcome_message": {"type": "string"},
33
+ "exit_message": {"type": "string"},
34
+ "commands": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "required": ["key", "description", "action"],
39
+ "properties": {
40
+ "key": {"type": "string", "minLength": 1},
41
+ "description": {"type": "string"},
42
+ "action": {
43
+ "type": "string",
44
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$",
45
+ "description": "Dotted import path (e.g., mymodule.task)",
46
+ },
47
+ "args": {"type": "array"},
48
+ "kwargs": {"type": "object"},
49
+ "aliases": {"type": "array", "items": {"type": "string"}},
50
+ "tags": {"type": "array", "items": {"type": "string"}},
51
+ "style": {"type": "string"},
52
+ "confirm": {"type": "boolean"},
53
+ "confirm_message": {"type": "string"},
54
+ "preview_before_confirm": {"type": "boolean"},
55
+ "spinner": {"type": "boolean"},
56
+ "spinner_message": {"type": "string"},
57
+ "spinner_type": {"type": "string"},
58
+ "spinner_style": {"type": "string"},
59
+ "logging_hooks": {"type": "boolean"},
60
+ "retry": {"type": "boolean"},
61
+ "retry_all": {"type": "boolean"},
62
+ "retry_policy": {
63
+ "type": "object",
64
+ "properties": {
65
+ "enabled": {"type": "boolean"},
66
+ "max_retries": {"type": "integer"},
67
+ "delay": {"type": "number"},
68
+ "backoff": {"type": "number"},
69
+ },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ "required": ["commands"],
76
+ }
@@ -9,6 +9,7 @@ from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
11
  from falyx.context import ExecutionContext
12
+ from falyx.themes.colors import OneColors
12
13
  from falyx.utils import logger
13
14
 
14
15
 
@@ -66,10 +67,10 @@ class ExecutionRegistry:
66
67
  duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
67
68
 
68
69
  if ctx.exception:
69
- status = "[bold red]❌ Error"
70
+ status = f"[{OneColors.DARK_RED}]❌ Error"
70
71
  result = repr(ctx.exception)
71
72
  else:
72
- status = "[green]✅ Success"
73
+ status = f"[{OneColors.GREEN}]✅ Success"
73
74
  result = repr(ctx.result)
74
75
  if len(result) > 1000:
75
76
  result = f"{result[:1000]}..."
@@ -291,7 +291,7 @@ class Falyx:
291
291
  """Returns the help command for the menu."""
292
292
  return Command(
293
293
  key="H",
294
- aliases=["HELP"],
294
+ aliases=["HELP", "?"],
295
295
  description="Help",
296
296
  action=self._show_help,
297
297
  style=OneColors.LIGHT_YELLOW,
@@ -560,10 +560,24 @@ class Falyx:
560
560
  self.add_command(key, description, submenu.menu, style=style)
561
561
  submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
562
562
 
563
- def add_commands(self, commands: list[dict]) -> None:
564
- """Adds multiple commands to the menu."""
563
+ def add_commands(self, commands: list[Command] | list[dict]) -> None:
564
+ """Adds a list of Command instances or config dicts."""
565
565
  for command in commands:
566
- self.add_command(**command)
566
+ if isinstance(command, dict):
567
+ self.add_command(**command)
568
+ elif isinstance(command, Command):
569
+ self.add_command_from_command(command)
570
+ else:
571
+ raise FalyxError(
572
+ "Command must be a dictionary or an instance of Command."
573
+ )
574
+
575
+ def add_command_from_command(self, command: Command) -> None:
576
+ """Adds a command to the menu from an existing Command object."""
577
+ if not isinstance(command, Command):
578
+ raise FalyxError("command must be an instance of Command.")
579
+ self._validate_command_key(command.key)
580
+ self.commands[command.key] = command
567
581
 
568
582
  def add_command(
569
583
  self,
@@ -696,7 +710,10 @@ class Falyx:
696
710
  ) -> tuple[bool, Command | None]:
697
711
  """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
698
712
  is_preview, choice = self.parse_preview_command(choice)
699
- if is_preview and not choice:
713
+ if is_preview and not choice and self.help_command:
714
+ is_preview = False
715
+ choice = "?"
716
+ elif is_preview and not choice:
700
717
  if not from_validate:
701
718
  self.console.print(
702
719
  f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
@@ -891,28 +908,29 @@ class Falyx:
891
908
  self.debug_hooks()
892
909
  if self.welcome_message:
893
910
  self.print_message(self.welcome_message)
894
- while True:
895
- if callable(self.render_menu):
896
- self.render_menu(self)
897
- else:
898
- self.console.print(self.table, justify="center")
899
- try:
900
- task = asyncio.create_task(self.process_command())
901
- should_continue = await task
902
- if not should_continue:
911
+ try:
912
+ while True:
913
+ if callable(self.render_menu):
914
+ self.render_menu(self)
915
+ else:
916
+ self.console.print(self.table, justify="center")
917
+ try:
918
+ task = asyncio.create_task(self.process_command())
919
+ should_continue = await task
920
+ if not should_continue:
921
+ break
922
+ except (EOFError, KeyboardInterrupt):
923
+ logger.info("EOF or KeyboardInterrupt. Exiting menu.")
903
924
  break
904
- except (EOFError, KeyboardInterrupt):
905
- logger.info("EOF or KeyboardInterrupt. Exiting menu.")
906
- break
907
- except QuitSignal:
908
- logger.info("QuitSignal received. Exiting menu.")
909
- break
910
- except BackSignal:
911
- logger.info("BackSignal received.")
912
- finally:
913
- logger.info(f"Exiting menu: {self.get_title()}")
914
- if self.exit_message:
915
- self.print_message(self.exit_message)
925
+ except QuitSignal:
926
+ logger.info("QuitSignal received. Exiting menu.")
927
+ break
928
+ except BackSignal:
929
+ logger.info("BackSignal received.")
930
+ finally:
931
+ logger.info(f"Exiting menu: {self.get_title()}")
932
+ if self.exit_message:
933
+ self.print_message(self.exit_message)
916
934
 
917
935
  async def run(self) -> None:
918
936
  """Run Falyx CLI with structured subcommands."""
@@ -0,0 +1,136 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ from pathlib import Path
3
+
4
+ from rich.console import Console
5
+
6
+ TEMPLATE_TASKS = """\
7
+ # This file is used by falyx.yaml to define CLI actions.
8
+ # You can run: falyx run [key] or falyx list to see available commands.
9
+
10
+ import asyncio
11
+ import json
12
+
13
+ from falyx.action import Action, ChainedAction
14
+ from falyx.io_action import ShellAction
15
+ from falyx.selection_action import SelectionAction
16
+
17
+
18
+ post_ids = ["1", "2", "3", "4", "5"]
19
+
20
+ pick_post = SelectionAction(
21
+ name="Pick Post ID",
22
+ selections=post_ids,
23
+ title="Choose a Post ID",
24
+ prompt_message="Select a post > ",
25
+ )
26
+
27
+ fetch_post = ShellAction(
28
+ name="Fetch Post via curl",
29
+ command_template="curl https://jsonplaceholder.typicode.com/posts/{}",
30
+ )
31
+
32
+ async def get_post_title(last_result):
33
+ return json.loads(last_result).get("title", "No title found")
34
+
35
+ post_flow = ChainedAction(
36
+ name="Fetch and Parse Post",
37
+ actions=[pick_post, fetch_post, get_post_title],
38
+ auto_inject=True,
39
+ )
40
+
41
+ async def hello():
42
+ print("👋 Hello from Falyx!")
43
+ return "Hello Complete!"
44
+
45
+ async def some_work():
46
+ await asyncio.sleep(2)
47
+ print("Work Finished!")
48
+ return "Work Complete!"
49
+
50
+ work_action = Action(
51
+ name="Work Action",
52
+ action=some_work,
53
+ )
54
+ """
55
+
56
+ TEMPLATE_CONFIG = """\
57
+ # falyx.yaml — Config-driven CLI definition
58
+ # Define your commands here and point to Python callables in tasks.py
59
+ title: Sample CLI Project
60
+ prompt:
61
+ - ["#61AFEF bold", "FALYX > "]
62
+ columns: 3
63
+ welcome_message: "🚀 Welcome to your new CLI project!"
64
+ exit_message: "👋 See you next time!"
65
+ commands:
66
+ - key: S
67
+ description: Say Hello
68
+ action: tasks.hello
69
+ aliases: [hi, hello]
70
+ tags: [example]
71
+
72
+ - key: P
73
+ description: Get Post Title
74
+ action: tasks.post_flow
75
+ aliases: [submit]
76
+ preview_before_confirm: true
77
+ confirm: true
78
+ tags: [demo, network]
79
+
80
+ - key: G
81
+ description: Do Some Work
82
+ action: tasks.work_action
83
+ aliases: [work]
84
+ spinner: true
85
+ spinner_message: "Working..."
86
+ """
87
+
88
+ GLOBAL_TEMPLATE_TASKS = """\
89
+ async def cleanup():
90
+ print("🧹 Cleaning temp files...")
91
+ """
92
+
93
+ GLOBAL_CONFIG = """\
94
+ title: Global Falyx Config
95
+ commands:
96
+ - key: C
97
+ description: Cleanup temp files
98
+ action: tasks.cleanup
99
+ aliases: [clean, cleanup]
100
+ """
101
+
102
+ console = Console(color_system="auto")
103
+
104
+
105
+ def init_project(name: str = ".") -> None:
106
+ target = Path(name).resolve()
107
+ target.mkdir(parents=True, exist_ok=True)
108
+
109
+ tasks_path = target / "tasks.py"
110
+ config_path = target / "falyx.yaml"
111
+
112
+ if tasks_path.exists() or config_path.exists():
113
+ console.print(f"⚠️ Project already initialized at {target}")
114
+ return None
115
+
116
+ tasks_path.write_text(TEMPLATE_TASKS)
117
+ config_path.write_text(TEMPLATE_CONFIG)
118
+
119
+ console.print(f"✅ Initialized Falyx project in {target}")
120
+
121
+
122
+ def init_global() -> None:
123
+ config_dir = Path.home() / ".config" / "falyx"
124
+ config_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ tasks_path = config_dir / "tasks.py"
127
+ config_path = config_dir / "falyx.yaml"
128
+
129
+ if tasks_path.exists() or config_path.exists():
130
+ console.print("⚠️ Global Falyx config already exists at ~/.config/falyx")
131
+ return None
132
+
133
+ tasks_path.write_text(GLOBAL_TEMPLATE_TASKS)
134
+ config_path.write_text(GLOBAL_CONFIG)
135
+
136
+ console.print("✅ Initialized global Falyx config at ~/.config/falyx")
@@ -16,6 +16,7 @@ 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
19
20
  import subprocess
20
21
  import sys
21
22
  from typing import Any
@@ -183,13 +184,13 @@ class ShellAction(BaseIOAction):
183
184
 
184
185
  Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc.
185
186
 
186
- ⚠️ Warning:
187
- Be cautious when using ShellAction with untrusted user input. Since it uses
188
- `shell=True`, unsanitized input can lead to command injection vulnerabilities.
189
- Avoid passing raw user input directly unless the template or use case is secure.
187
+ ⚠️ Security Warning:
188
+ By default, ShellAction uses `shell=True`, which can be dangerous with unsanitized input.
189
+ To mitigate this, set `safe_mode=True` to use `shell=False` with `shlex.split()`.
190
190
 
191
191
  Features:
192
192
  - Automatically handles input parsing (str/bytes)
193
+ - `safe_mode=True` disables shell interpretation and runs with `shell=False`
193
194
  - Captures stdout and stderr from shell execution
194
195
  - Raises on non-zero exit codes with stderr as the error
195
196
  - Result is returned as trimmed stdout string
@@ -199,11 +200,15 @@ class ShellAction(BaseIOAction):
199
200
  name (str): Name of the action.
200
201
  command_template (str): Shell command to execute. Must include `{}` to include input.
201
202
  If no placeholder is present, the input is not included.
203
+ safe_mode (bool): If True, runs with `shell=False` using shlex parsing (default: False).
202
204
  """
203
205
 
204
- def __init__(self, name: str, command_template: str, **kwargs):
206
+ def __init__(
207
+ self, name: str, command_template: str, safe_mode: bool = False, **kwargs
208
+ ):
205
209
  super().__init__(name=name, **kwargs)
206
210
  self.command_template = command_template
211
+ self.safe_mode = safe_mode
207
212
 
208
213
  def from_input(self, raw: str | bytes) -> str:
209
214
  if not isinstance(raw, (str, bytes)):
@@ -215,7 +220,11 @@ class ShellAction(BaseIOAction):
215
220
  async def _run(self, parsed_input: str) -> str:
216
221
  # Replace placeholder in template, or use raw input as full command
217
222
  command = self.command_template.format(parsed_input)
218
- result = subprocess.run(command, shell=True, text=True, capture_output=True)
223
+ if self.safe_mode:
224
+ args = shlex.split(command)
225
+ result = subprocess.run(args, capture_output=True, text=True)
226
+ else:
227
+ result = subprocess.run(command, shell=True, text=True, capture_output=True)
219
228
  if result.returncode != 0:
220
229
  raise RuntimeError(result.stderr.strip())
221
230
  return result.stdout.strip()
@@ -225,14 +234,18 @@ class ShellAction(BaseIOAction):
225
234
 
226
235
  async def preview(self, parent: Tree | None = None):
227
236
  label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"]
237
+ label.append(f"\n[dim]Template:[/] {self.command_template}")
238
+ label.append(
239
+ f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}"
240
+ )
228
241
  if self.inject_last_result:
229
242
  label.append(f" [dim](injects '{self.inject_into}')[/dim]")
230
- if parent:
231
- parent.add("".join(label))
232
- else:
233
- self.console.print(Tree("".join(label)))
243
+ tree = parent.add("".join(label)) if parent else Tree("".join(label))
244
+ if not parent:
245
+ self.console.print(tree)
234
246
 
235
247
  def __str__(self):
236
248
  return (
237
- f"ShellAction(name={self.name!r}, command_template={self.command_template!r})"
249
+ f"ShellAction(name={self.name!r}, command_template={self.command_template!r}, "
250
+ f"safe_mode={self.safe_mode})"
238
251
  )
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from falyx.options_manager import OptionsManager
2
3
 
3
4
 
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from __future__ import annotations
2
3
 
3
4
  from typing import Any, Protocol
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from __future__ import annotations
2
3
 
3
4
  import csv
@@ -33,8 +34,30 @@ class FileReturnType(Enum):
33
34
  TOML = "toml"
34
35
  YAML = "yaml"
35
36
  CSV = "csv"
37
+ TSV = "tsv"
36
38
  XML = "xml"
37
39
 
40
+ @classmethod
41
+ def _get_alias(cls, value: str) -> str:
42
+ aliases = {
43
+ "yml": "yaml",
44
+ "txt": "text",
45
+ "file": "path",
46
+ "filepath": "path",
47
+ }
48
+ return aliases.get(value, value)
49
+
50
+ @classmethod
51
+ def _missing_(cls, value: object) -> FileReturnType:
52
+ if isinstance(value, str):
53
+ normalized = value.lower()
54
+ alias = cls._get_alias(normalized)
55
+ for member in cls:
56
+ if member.value == alias:
57
+ return member
58
+ valid = ", ".join(member.value for member in cls)
59
+ raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
60
+
38
61
 
39
62
  class SelectFileAction(BaseAction):
40
63
  """
@@ -42,7 +65,7 @@ class SelectFileAction(BaseAction):
42
65
  - file content (as text, JSON, CSV, etc.)
43
66
  - or the file path itself.
44
67
 
45
- Supported formats: text, json, yaml, toml, csv, xml.
68
+ Supported formats: text, json, yaml, toml, csv, tsv, xml.
46
69
 
47
70
  Useful for:
48
71
  - dynamically loading config files
@@ -72,7 +95,7 @@ class SelectFileAction(BaseAction):
72
95
  prompt_message: str = "Choose > ",
73
96
  style: str = OneColors.WHITE,
74
97
  suffix_filter: str | None = None,
75
- return_type: FileReturnType = FileReturnType.PATH,
98
+ return_type: FileReturnType | str = FileReturnType.PATH,
76
99
  console: Console | None = None,
77
100
  prompt_session: PromptSession | None = None,
78
101
  ):
@@ -83,9 +106,14 @@ class SelectFileAction(BaseAction):
83
106
  self.prompt_message = prompt_message
84
107
  self.suffix_filter = suffix_filter
85
108
  self.style = style
86
- self.return_type = return_type
87
109
  self.console = console or Console(color_system="auto")
88
110
  self.prompt_session = prompt_session or PromptSession()
111
+ self.return_type = self._coerce_return_type(return_type)
112
+
113
+ def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType:
114
+ if isinstance(return_type, FileReturnType):
115
+ return return_type
116
+ return FileReturnType(return_type)
89
117
 
90
118
  def get_options(self, files: list[Path]) -> dict[str, SelectionOption]:
91
119
  value: Any
@@ -106,6 +134,10 @@ class SelectFileAction(BaseAction):
106
134
  with open(file, newline="", encoding="UTF-8") as csvfile:
107
135
  reader = csv.reader(csvfile)
108
136
  value = list(reader)
137
+ elif self.return_type == FileReturnType.TSV:
138
+ with open(file, newline="", encoding="UTF-8") as tsvfile:
139
+ reader = csv.reader(tsvfile, delimiter="\t")
140
+ value = list(reader)
109
141
  elif self.return_type == FileReturnType.XML:
110
142
  tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8"))
111
143
  root = tree.getroot()
@@ -183,7 +215,7 @@ class SelectFileAction(BaseAction):
183
215
  if len(files) > 10:
184
216
  file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
185
217
  except Exception as error:
186
- tree.add(f"[bold red]⚠️ Error scanning directory: {error}[/]")
218
+ tree.add(f"[{OneColors.DARK_RED_b}]⚠️ Error scanning directory: {error}[/]")
187
219
 
188
220
  if not parent:
189
221
  self.console.print(tree)
@@ -216,7 +216,7 @@ async def prompt_for_index(
216
216
  console = console or Console(color_system="auto")
217
217
 
218
218
  if show_table:
219
- console.print(table)
219
+ console.print(table, justify="center")
220
220
 
221
221
  selection = await prompt_session.prompt_async(
222
222
  message=prompt_message,
@@ -318,7 +318,7 @@ async def select_key_from_dict(
318
318
  prompt_session = prompt_session or PromptSession()
319
319
  console = console or Console(color_system="auto")
320
320
 
321
- console.print(table)
321
+ console.print(table, justify="center")
322
322
 
323
323
  return await prompt_for_selection(
324
324
  selections.keys(),
@@ -343,7 +343,7 @@ async def select_value_from_dict(
343
343
  prompt_session = prompt_session or PromptSession()
344
344
  console = console or Console(color_system="auto")
345
345
 
346
- console.print(table)
346
+ console.print(table, justify="center")
347
347
 
348
348
  selection_key = await prompt_for_selection(
349
349
  selections.keys(),
@@ -28,7 +28,7 @@ class SelectionAction(BaseAction):
28
28
  selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
29
29
  *,
30
30
  title: str = "Select an option",
31
- columns: int = 2,
31
+ columns: int = 5,
32
32
  prompt_message: str = "Select > ",
33
33
  default_selection: str = "",
34
34
  inject_last_result: bool = False,
@@ -186,7 +186,7 @@ class SelectionAction(BaseAction):
186
186
  if len(self.selections) > 10:
187
187
  sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]")
188
188
  else:
189
- tree.add("[bold red]Invalid selections type[/]")
189
+ tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]")
190
190
  return
191
191
 
192
192
  tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from falyx.action import Action
2
3
  from falyx.signals import FlowSignal
3
4
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.23"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.22"
3
+ version = "0.1.23"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -1,144 +0,0 @@
1
- # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
- """config.py
3
- Configuration loader for Falyx CLI commands."""
4
-
5
- import importlib
6
- import sys
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- import toml
11
- import yaml
12
- from rich.console import Console
13
-
14
- from falyx.action import Action, BaseAction
15
- from falyx.command import Command
16
- from falyx.retry import RetryPolicy
17
- from falyx.themes.colors import OneColors
18
- from falyx.utils import logger
19
-
20
- console = Console(color_system="auto")
21
-
22
-
23
- def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
24
- if isinstance(obj, (BaseAction, Command)):
25
- return obj
26
- elif callable(obj):
27
- return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
28
- else:
29
- raise TypeError(
30
- f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. "
31
- "It must be a callable or an instance of BaseAction."
32
- )
33
-
34
-
35
- def import_action(dotted_path: str) -> Any:
36
- """Dynamically imports a callable from a dotted path like 'my.module.func'."""
37
- module_path, _, attr = dotted_path.rpartition(".")
38
- if not module_path:
39
- console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
40
- sys.exit(1)
41
- try:
42
- module = importlib.import_module(module_path)
43
- except ModuleNotFoundError as error:
44
- logger.error("Failed to import module '%s': %s", module_path, error)
45
- console.print(
46
- f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
47
- f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
48
- )
49
- sys.exit(1)
50
- try:
51
- action = getattr(module, attr)
52
- except AttributeError as error:
53
- logger.error(
54
- "Module '%s' does not have attribute '%s': %s", module_path, attr, error
55
- )
56
- console.print(
57
- f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
58
- )
59
- sys.exit(1)
60
- return action
61
-
62
-
63
- def loader(file_path: Path | str) -> list[dict[str, Any]]:
64
- """
65
- Load command definitions from a YAML or TOML file.
66
-
67
- Each command should be defined as a dictionary with at least:
68
- - key: a unique single-character key
69
- - description: short description
70
- - action: dotted import path to the action function/class
71
-
72
- Args:
73
- file_path (str): Path to the config file (YAML or TOML).
74
-
75
- Returns:
76
- list[dict[str, Any]]: A list of command configuration dictionaries.
77
-
78
- Raises:
79
- ValueError: If the file format is unsupported or file cannot be parsed.
80
- """
81
- if isinstance(file_path, str):
82
- path = Path(file_path)
83
- elif isinstance(file_path, Path):
84
- path = file_path
85
- else:
86
- raise TypeError("file_path must be a string or Path object.")
87
-
88
- if not path.is_file():
89
- raise FileNotFoundError(f"No such config file: {file_path}")
90
-
91
- suffix = path.suffix
92
- with path.open("r", encoding="UTF-8") as config_file:
93
- if suffix in (".yaml", ".yml"):
94
- raw_config = yaml.safe_load(config_file)
95
- elif suffix == ".toml":
96
- raw_config = toml.load(config_file)
97
- else:
98
- raise ValueError(f"Unsupported config format: {suffix}")
99
-
100
- if not isinstance(raw_config, list):
101
- raise ValueError("Configuration file must contain a list of command definitions.")
102
-
103
- required = ["key", "description", "action"]
104
- commands = []
105
- for entry in raw_config:
106
- for field in required:
107
- if field not in entry:
108
- raise ValueError(f"Missing '{field}' in command entry: {entry}")
109
-
110
- command_dict = {
111
- "key": entry["key"],
112
- "description": entry["description"],
113
- "action": wrap_if_needed(
114
- import_action(entry["action"]), name=entry["description"]
115
- ),
116
- "args": tuple(entry.get("args", ())),
117
- "kwargs": entry.get("kwargs", {}),
118
- "hidden": entry.get("hidden", False),
119
- "aliases": entry.get("aliases", []),
120
- "help_text": entry.get("help_text", ""),
121
- "style": entry.get("style", "white"),
122
- "confirm": entry.get("confirm", False),
123
- "confirm_message": entry.get("confirm_message", "Are you sure?"),
124
- "preview_before_confirm": entry.get("preview_before_confirm", True),
125
- "spinner": entry.get("spinner", False),
126
- "spinner_message": entry.get("spinner_message", "Processing..."),
127
- "spinner_type": entry.get("spinner_type", "dots"),
128
- "spinner_style": entry.get("spinner_style", "cyan"),
129
- "spinner_kwargs": entry.get("spinner_kwargs", {}),
130
- "before_hooks": entry.get("before_hooks", []),
131
- "success_hooks": entry.get("success_hooks", []),
132
- "error_hooks": entry.get("error_hooks", []),
133
- "after_hooks": entry.get("after_hooks", []),
134
- "teardown_hooks": entry.get("teardown_hooks", []),
135
- "retry": entry.get("retry", False),
136
- "retry_all": entry.get("retry_all", False),
137
- "retry_policy": RetryPolicy(**entry.get("retry_policy", {})),
138
- "tags": entry.get("tags", []),
139
- "logging_hooks": entry.get("logging_hooks", False),
140
- "requires_input": entry.get("requires_input", None),
141
- }
142
- commands.append(command_dict)
143
-
144
- return commands
@@ -1,76 +0,0 @@
1
- # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
- from pathlib import Path
3
-
4
- from rich.console import Console
5
-
6
- TEMPLATE_TASKS = """\
7
- async def build():
8
- print("🔨 Building project...")
9
- return "Build complete!"
10
-
11
- async def test():
12
- print("🧪 Running tests...")
13
- return "Tests complete!"
14
- """
15
-
16
- TEMPLATE_CONFIG = """\
17
- - key: B
18
- description: Build the project
19
- action: tasks.build
20
- aliases: [build]
21
- spinner: true
22
-
23
- - key: T
24
- description: Run tests
25
- action: tasks.test
26
- aliases: [test]
27
- spinner: true
28
- """
29
-
30
- GLOBAL_TEMPLATE_TASKS = """\
31
- async def cleanup():
32
- print("🧹 Cleaning temp files...")
33
- """
34
-
35
- GLOBAL_CONFIG = """\
36
- - key: C
37
- description: Cleanup temp files
38
- action: tasks.cleanup
39
- aliases: [clean, cleanup]
40
- """
41
-
42
- console = Console(color_system="auto")
43
-
44
-
45
- def init_project(name: str = ".") -> None:
46
- target = Path(name).resolve()
47
- target.mkdir(parents=True, exist_ok=True)
48
-
49
- tasks_path = target / "tasks.py"
50
- config_path = target / "falyx.yaml"
51
-
52
- if tasks_path.exists() or config_path.exists():
53
- console.print(f"⚠️ Project already initialized at {target}")
54
- return None
55
-
56
- tasks_path.write_text(TEMPLATE_TASKS)
57
- config_path.write_text(TEMPLATE_CONFIG)
58
-
59
- print(f"✅ Initialized Falyx project in {target}")
60
-
61
-
62
- def init_global() -> None:
63
- config_dir = Path.home() / ".config" / "falyx"
64
- config_dir.mkdir(parents=True, exist_ok=True)
65
-
66
- tasks_path = config_dir / "tasks.py"
67
- config_path = config_dir / "falyx.yaml"
68
-
69
- if tasks_path.exists() or config_path.exists():
70
- console.print("⚠️ Global Falyx config already exists at ~/.config/falyx")
71
- return None
72
-
73
- tasks_path.write_text(GLOBAL_TEMPLATE_TASKS)
74
- config_path.write_text(GLOBAL_CONFIG)
75
-
76
- console.print("✅ Initialized global Falyx config at ~/.config/falyx")
@@ -1 +0,0 @@
1
- __version__ = "0.1.22"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes