falyx 0.1.22__py3-none-any.whl → 0.1.23__py3-none-any.whl

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/__main__.py CHANGED
@@ -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
 
falyx/action_factory.py CHANGED
@@ -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
falyx/config.py CHANGED
@@ -1,18 +1,21 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
2
  """config.py
3
3
  Configuration loader for Falyx CLI commands."""
4
+ from __future__ import annotations
4
5
 
5
6
  import importlib
6
7
  import sys
7
8
  from pathlib import Path
8
- from typing import Any
9
+ from typing import Any, Callable
9
10
 
10
11
  import toml
11
12
  import yaml
13
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
14
  from rich.console import Console
13
15
 
14
16
  from falyx.action import Action, BaseAction
15
17
  from falyx.command import Command
18
+ from falyx.falyx import Falyx
16
19
  from falyx.retry import RetryPolicy
17
20
  from falyx.themes.colors import OneColors
18
21
  from falyx.utils import logger
@@ -27,8 +30,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
27
30
  return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
28
31
  else:
29
32
  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."
33
+ f"Cannot wrap object of type '{type(obj).__name__}'. "
34
+ "Expected a function or BaseAction."
32
35
  )
33
36
 
34
37
 
@@ -60,7 +63,101 @@ def import_action(dotted_path: str) -> Any:
60
63
  return action
61
64
 
62
65
 
63
- def loader(file_path: Path | str) -> list[dict[str, Any]]:
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:
64
161
  """
65
162
  Load command definitions from a YAML or TOML file.
66
163
 
@@ -73,15 +170,13 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]:
73
170
  file_path (str): Path to the config file (YAML or TOML).
74
171
 
75
172
  Returns:
76
- list[dict[str, Any]]: A list of command configuration dictionaries.
173
+ Falyx: An instance of the Falyx CLI with loaded commands.
77
174
 
78
175
  Raises:
79
176
  ValueError: If the file format is unsupported or file cannot be parsed.
80
177
  """
81
- if isinstance(file_path, str):
178
+ if isinstance(file_path, (str, Path)):
82
179
  path = Path(file_path)
83
- elif isinstance(file_path, Path):
84
- path = file_path
85
180
  else:
86
181
  raise TypeError("file_path must be a string or Path object.")
87
182
 
@@ -97,48 +192,23 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]:
97
192
  else:
98
193
  raise ValueError(f"Unsupported config format: {suffix}")
99
194
 
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)
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
+ )
143
205
 
144
- return commands
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()
falyx/config_schema.py ADDED
@@ -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]}..."
falyx/falyx.py CHANGED
@@ -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."""
falyx/init.py CHANGED
@@ -4,27 +4,85 @@ from pathlib import Path
4
4
  from rich.console import Console
5
5
 
6
6
  TEMPLATE_TASKS = """\
7
- async def build():
8
- print("🔨 Building project...")
9
- return "Build complete!"
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.
10
9
 
11
- async def test():
12
- print("🧪 Running tests...")
13
- return "Tests complete!"
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
+ )
14
54
  """
15
55
 
16
56
  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
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..."
28
86
  """
29
87
 
30
88
  GLOBAL_TEMPLATE_TASKS = """\
@@ -33,10 +91,12 @@ async def cleanup():
33
91
  """
34
92
 
35
93
  GLOBAL_CONFIG = """\
36
- - key: C
37
- description: Cleanup temp files
38
- action: tasks.cleanup
39
- aliases: [clean, cleanup]
94
+ title: Global Falyx Config
95
+ commands:
96
+ - key: C
97
+ description: Cleanup temp files
98
+ action: tasks.cleanup
99
+ aliases: [clean, cleanup]
40
100
  """
41
101
 
42
102
  console = Console(color_system="auto")
@@ -56,7 +116,7 @@ def init_project(name: str = ".") -> None:
56
116
  tasks_path.write_text(TEMPLATE_TASKS)
57
117
  config_path.write_text(TEMPLATE_CONFIG)
58
118
 
59
- print(f"✅ Initialized Falyx project in {target}")
119
+ console.print(f"✅ Initialized Falyx project in {target}")
60
120
 
61
121
 
62
122
  def init_global() -> None:
falyx/io_action.py CHANGED
@@ -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
  )
falyx/prompt_utils.py CHANGED
@@ -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
 
falyx/protocols.py CHANGED
@@ -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)
falyx/selection.py CHANGED
@@ -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(),
falyx/selection_action.py CHANGED
@@ -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}'")
falyx/signal_action.py CHANGED
@@ -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
 
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.22"
1
+ __version__ = "0.1.23"
@@ -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
@@ -1,40 +1,41 @@
1
1
  falyx/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  falyx/__init__.py,sha256=dYRamQJlT1Zoy5Uu1uG4NCV05Xk98nN1LAQrSR1CT2A,643
3
- falyx/__main__.py,sha256=559pd3P1iScvC-V__gfXUbEQTxeV7PXlRcQI9kpMQcg,2286
3
+ falyx/__main__.py,sha256=g_LwJieofK3DJzCYtpkAMEeOXhzSLQenb7pRVUqcf-Y,2152
4
4
  falyx/action.py,sha256=J-SG5zltYbqtdvTwBBUeEj4jp44DOKBR6G5rvmdkkTs,32147
5
- falyx/action_factory.py,sha256=SMucCBuigKk3rlKXCEN69Sew4dVaBUxQqxyUUAHMZeo,3629
5
+ falyx/action_factory.py,sha256=VDy-EcdAs8-Xu-HVMeQi7NGZWmT2MP7AWYKhmcSsNDE,3693
6
6
  falyx/bottom_bar.py,sha256=NTen52Nfz32eWSBmJtEUuJO33u5sGQj-33IeudPVsqQ,7403
7
7
  falyx/command.py,sha256=8J3xeHw3fYiqf05EUbXd--aEIJso5bzxb5JECGuISQk,12199
8
- falyx/config.py,sha256=MnZeyti2TpeuOKrvXVaslIxYPHFyE4liREJsmUKlySg,5455
8
+ falyx/config.py,sha256=FyM9euPgEDS9kmL8foO-qAOeV8IqCEj2L4vc5SlzNA4,6959
9
+ falyx/config_schema.py,sha256=j5GQuHVlaU-VLxLF9t8idZRjqOP9MIKp1hyd9NhpAGU,3124
9
10
  falyx/context.py,sha256=Dm7HV-eigU-aTv5ERah6Ow9fIRdrOsB1G6ETPIu42Gw,10070
10
11
  falyx/debug.py,sha256=-jbTti29UC5zP9qQlWs3TbkOQR2f3zKSuNluh-r56wY,1551
11
12
  falyx/exceptions.py,sha256=YVbhPp2BNvZoO_xqeGSRKHVQ2rdLOLf1HCjH4JTj9w8,776
12
- falyx/execution_registry.py,sha256=IZvZr2hElzsYcMX3bAXg7sc7V2zi4RURn0OR7de2iCo,2864
13
- falyx/falyx.py,sha256=Yj7dYpEiqnswVRWSJyqS45wlRTUKvABIQR8Bh2axA9I,40453
13
+ falyx/execution_registry.py,sha256=lZVQBuuyijuOcFcYFmKmLe-wofKjYz2NiY3dhdRLuTI,2932
14
+ falyx/falyx.py,sha256=M6M8s4Rq-aYelDGH4NJYgdXKBNMnzfwux57GJLnDthY,41322
14
15
  falyx/hook_manager.py,sha256=E9Vk4bdoUTeXPQ_BQEvY2Jt-jUAusc40LI8JDy3NLUw,2381
15
16
  falyx/hooks.py,sha256=9zXk62DsJLJrmwTdyeNy5s-rVRvl8feuYRrfMmz6cVQ,2802
16
17
  falyx/http_action.py,sha256=JfopEleXJ0goVHi0VCn983c22GrmJhobnPIP7sTRqzU,5796
17
- falyx/init.py,sha256=jP4ZNw7ycDMKw4n1HDifxWSa0NYHaGLq7_LiFt85NpA,1832
18
- falyx/io_action.py,sha256=mBRX8rqQ11gkcUnJluZ87XT4QBA1oFkof6PaCtuK5o8,8997
18
+ falyx/init.py,sha256=RPD2CBIqjOGGjW545IaCKUbjGsE_XnScuuDSrP058uM,3379
19
+ falyx/io_action.py,sha256=hrMT2JKWvFDOPgwTJSHCz8OiGkxl-bULhm6NoIBLA1g,9602
19
20
  falyx/menu_action.py,sha256=kagtnn3djDxUm_Cyynp0lj-sZ9D_FZn4IEBYnFYqB74,7986
20
21
  falyx/options_manager.py,sha256=yYpn-moYN-bRYgMLccmi_de4mUzhTT7cv_bR2FFWZ8c,2798
21
22
  falyx/parsers.py,sha256=r2FZTN26PqrnEQG4hVPorzzTPQZihsb4ca23fQY4Lgo,5574
22
- falyx/prompt_utils.py,sha256=JOg3p8Juv6ZdY1srfy_HlMNYfE-ajggDWLqNsjZq87I,560
23
- falyx/protocols.py,sha256=yNtQEugq9poN-SbOJf5LL_j6HBWdglbTNghpyopLpTs,216
23
+ falyx/prompt_utils.py,sha256=l2uyem7f_lrvsh7L62BcJ9cAwoRSjo4NFsN43UgrBhs,624
24
+ falyx/protocols.py,sha256=c32UniP5SKeoxHINZyuXrpSAOjlOKjYJ-SvsVcrgFjg,280
24
25
  falyx/retry.py,sha256=GncBUiDDfDHUvLsWsWQw2Nq2XYL0TR0Fne3iXPzvQ48,3551
25
26
  falyx/retry_utils.py,sha256=SN5apcsg71IG2-KylysqdJd-PkPBLoCVwsgrSTF9wrQ,666
26
- falyx/select_file_action.py,sha256=5Pt9ThIfwqd8ZJLEVDapevV6SF_AQEQFkk-i9Y1to_Q,7315
27
- falyx/selection.py,sha256=aeJZPDwb5LR9e9iTjCYx4D5bh1mWyn3uBPA-M1AXdAw,10553
28
- falyx/selection_action.py,sha256=MAKZeDwfCEE3hOoL1hpiMVlFUEDYV6X0oNCVGEcT_ZU,8324
29
- falyx/signal_action.py,sha256=wfhW9miSUj9MUoc1WOyk4tU9CtYKAXusHxQdBPYLoyQ,829
27
+ falyx/select_file_action.py,sha256=EWM_qpHtzj5Ol7TSzKxDpevgym6QIwAzJFqLkg8x6IU,8610
28
+ falyx/selection.py,sha256=bgMYVHicE8aQXdvccxOK2i5ijtftUMlRyT-wsGSogHQ,10607
29
+ falyx/selection_action.py,sha256=oBAJ2r9-O27BYk4Lkx_vBUDNrHuC4JyIROfL0MGZOd8,8339
30
+ falyx/signal_action.py,sha256=wsG-Rmgif2Q1AACY-Ie7oyGdbk9AyYvAHSg7mFxNprI,893
30
31
  falyx/signals.py,sha256=tlUbz3x6z3rYlUggan_Ntoy4bU5RbOd8UfR4cNcV6kQ,694
31
32
  falyx/tagged_table.py,sha256=sn2kosRRpcpeMB8vKk47c9yjpffSz_9FXH_e6kw15mA,1019
32
33
  falyx/themes/colors.py,sha256=4aaeAHJetmeNInI0Zytg4E3YqKfPFelpf04vtjSvsS8,19776
33
34
  falyx/utils.py,sha256=b1GQ3ooz4Io3zPE7MsoDm7j42AioTG-ZcWH-N2TRpbI,7710
34
35
  falyx/validators.py,sha256=NMxqCk8Fr8HQGVDYpg8B_JRk5SKR41E_G9gj1YfQnxg,1316
35
- falyx/version.py,sha256=zmP2TRnzKPjZJ1eiBcT-cRInsji6FW-OVD3FafQFCc4,23
36
- falyx-0.1.22.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
37
- falyx-0.1.22.dist-info/METADATA,sha256=jXADXacZ3y4WPCwGkMSg5aGsseu-G98twHd2YIK5Hzg,5484
38
- falyx-0.1.22.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
39
- falyx-0.1.22.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
40
- falyx-0.1.22.dist-info/RECORD,,
36
+ falyx/version.py,sha256=0byemO6n6WCv41u9vBG2AIsOkVbxLvok7puvwy8EhfU,23
37
+ falyx-0.1.23.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
38
+ falyx-0.1.23.dist-info/METADATA,sha256=j9Eamk8lsuPAByILQpiGVB3hyVoDnqRzf7gOAxJB62E,5484
39
+ falyx-0.1.23.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
40
+ falyx-0.1.23.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
41
+ falyx-0.1.23.dist-info/RECORD,,
File without changes