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 +3 -8
- falyx/action_factory.py +1 -0
- falyx/config.py +122 -52
- falyx/config_schema.py +76 -0
- falyx/execution_registry.py +3 -2
- falyx/falyx.py +44 -26
- falyx/init.py +82 -22
- falyx/io_action.py +24 -11
- falyx/prompt_utils.py +1 -0
- falyx/protocols.py +1 -0
- falyx/select_file_action.py +36 -4
- falyx/selection.py +3 -3
- falyx/selection_action.py +2 -2
- falyx/signal_action.py +1 -0
- falyx/version.py +1 -1
- {falyx-0.1.22.dist-info → falyx-0.1.23.dist-info}/METADATA +1 -1
- {falyx-0.1.22.dist-info → falyx-0.1.23.dist-info}/RECORD +20 -19
- {falyx-0.1.22.dist-info → falyx-0.1.23.dist-info}/LICENSE +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.23.dist-info}/WHEEL +0 -0
- {falyx-0.1.22.dist-info → falyx-0.1.23.dist-info}/entry_points.txt +0 -0
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 =
|
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
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__}'
|
31
|
-
"
|
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
|
-
|
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
|
-
|
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,
|
101
|
-
raise ValueError(
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
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
|
-
|
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
|
+
}
|
falyx/execution_registry.py
CHANGED
@@ -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 = "[
|
70
|
+
status = f"[{OneColors.DARK_RED}]❌ Error"
|
70
71
|
result = repr(ctx.exception)
|
71
72
|
else:
|
72
|
-
status = "[
|
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
|
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
|
-
|
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
|
-
|
895
|
-
|
896
|
-
self.render_menu
|
897
|
-
|
898
|
-
|
899
|
-
|
900
|
-
|
901
|
-
|
902
|
-
|
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
|
-
|
905
|
-
|
906
|
-
|
907
|
-
|
908
|
-
|
909
|
-
|
910
|
-
|
911
|
-
|
912
|
-
|
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
|
-
|
8
|
-
|
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
|
-
|
12
|
-
|
13
|
-
|
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
|
-
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
188
|
-
|
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__(
|
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
|
-
|
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
|
-
|
232
|
-
|
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
falyx/protocols.py
CHANGED
falyx/select_file_action.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
|
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"[
|
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 =
|
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("[
|
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
falyx/version.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.1.
|
1
|
+
__version__ = "0.1.23"
|
@@ -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=
|
3
|
+
falyx/__main__.py,sha256=g_LwJieofK3DJzCYtpkAMEeOXhzSLQenb7pRVUqcf-Y,2152
|
4
4
|
falyx/action.py,sha256=J-SG5zltYbqtdvTwBBUeEj4jp44DOKBR6G5rvmdkkTs,32147
|
5
|
-
falyx/action_factory.py,sha256=
|
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=
|
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=
|
13
|
-
falyx/falyx.py,sha256=
|
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=
|
18
|
-
falyx/io_action.py,sha256=
|
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=
|
23
|
-
falyx/protocols.py,sha256=
|
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=
|
27
|
-
falyx/selection.py,sha256=
|
28
|
-
falyx/selection_action.py,sha256=
|
29
|
-
falyx/signal_action.py,sha256=
|
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=
|
36
|
-
falyx-0.1.
|
37
|
-
falyx-0.1.
|
38
|
-
falyx-0.1.
|
39
|
-
falyx-0.1.
|
40
|
-
falyx-0.1.
|
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
|
File without changes
|
File without changes
|