mango-tui 0.2.0__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.
- mango/__init__.py +0 -0
- mango/config.default.yaml +81 -0
- mango/config.py +128 -0
- mango/main.py +23 -0
- mango/merger.py +113 -0
- mango/runner.py +51 -0
- mango/tui/__init__.py +0 -0
- mango/tui/app.py +368 -0
- mango_tui-0.2.0.dist-info/METADATA +142 -0
- mango_tui-0.2.0.dist-info/RECORD +14 -0
- mango_tui-0.2.0.dist-info/WHEEL +5 -0
- mango_tui-0.2.0.dist-info/entry_points.txt +2 -0
- mango_tui-0.2.0.dist-info/licenses/LICENSE +21 -0
- mango_tui-0.2.0.dist-info/top_level.txt +1 -0
mango/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
categories:
|
|
2
|
+
git:
|
|
3
|
+
shortcut: "g"
|
|
4
|
+
macros:
|
|
5
|
+
create-branch-push:
|
|
6
|
+
shortcut: "cb"
|
|
7
|
+
description: "Create a new branch and push it to origin"
|
|
8
|
+
params:
|
|
9
|
+
- name: branch
|
|
10
|
+
prompt: "Branch name"
|
|
11
|
+
steps:
|
|
12
|
+
- git checkout -b {branch}
|
|
13
|
+
- git push -u origin {branch}
|
|
14
|
+
new-tag-and-push:
|
|
15
|
+
shortcut: "nt"
|
|
16
|
+
description: "Create a new tag and push it to origin"
|
|
17
|
+
params:
|
|
18
|
+
- name: tag
|
|
19
|
+
prompt: "Tag name"
|
|
20
|
+
steps:
|
|
21
|
+
- git tag {tag}
|
|
22
|
+
- git push --tags
|
|
23
|
+
switch-and-pull:
|
|
24
|
+
shortcut: "su"
|
|
25
|
+
description: "Switch branch, fetch and pull"
|
|
26
|
+
params:
|
|
27
|
+
- name: branch
|
|
28
|
+
prompt: "Branch name"
|
|
29
|
+
steps:
|
|
30
|
+
- git checkout {branch}
|
|
31
|
+
- git fetch
|
|
32
|
+
- git pull
|
|
33
|
+
delete-branch-local-remote-and-prune:
|
|
34
|
+
shortcut: "db"
|
|
35
|
+
description: "Delete branch in local and remote, and prune"
|
|
36
|
+
params:
|
|
37
|
+
- name: branch
|
|
38
|
+
prompt: "Branch name"
|
|
39
|
+
steps:
|
|
40
|
+
- git push origin --delete {branch}
|
|
41
|
+
- git branch -D {branch}
|
|
42
|
+
- git fetch --prune
|
|
43
|
+
status:
|
|
44
|
+
shortcut: "st"
|
|
45
|
+
description: "Show git status"
|
|
46
|
+
steps:
|
|
47
|
+
- git status
|
|
48
|
+
log:
|
|
49
|
+
shortcut: "lo"
|
|
50
|
+
description: "Show recent commits"
|
|
51
|
+
steps:
|
|
52
|
+
- git log --oneline -10
|
|
53
|
+
docker:
|
|
54
|
+
shortcut: "d"
|
|
55
|
+
macros:
|
|
56
|
+
up:
|
|
57
|
+
shortcut: "up"
|
|
58
|
+
description: "Start containers"
|
|
59
|
+
steps:
|
|
60
|
+
- docker compose up -d
|
|
61
|
+
down:
|
|
62
|
+
shortcut: "dn"
|
|
63
|
+
description: "Stop containers"
|
|
64
|
+
steps:
|
|
65
|
+
- docker compose down
|
|
66
|
+
logs:
|
|
67
|
+
shortcut: "lg"
|
|
68
|
+
description: "Follow logs for a service"
|
|
69
|
+
params:
|
|
70
|
+
- name: service
|
|
71
|
+
prompt: "Service name"
|
|
72
|
+
steps:
|
|
73
|
+
- docker compose logs -f {service}
|
|
74
|
+
mango:
|
|
75
|
+
shortcut: "m"
|
|
76
|
+
macros:
|
|
77
|
+
upgrade-mango:
|
|
78
|
+
shortcut: "up"
|
|
79
|
+
description: "Upgrade mango"
|
|
80
|
+
steps:
|
|
81
|
+
- pip install --force-reinstall --no-cache-dir git+https://github.com/juanleon8581/mango-assistant.git
|
mango/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Param:
|
|
11
|
+
name: str
|
|
12
|
+
prompt: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Macro:
|
|
17
|
+
shortcut: str
|
|
18
|
+
description: str
|
|
19
|
+
steps: list[str]
|
|
20
|
+
params: list[Param] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Category:
|
|
25
|
+
name: str
|
|
26
|
+
shortcut: str
|
|
27
|
+
macros: dict[str, Macro]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
categories: dict[str, Category]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _file_sha256(path: Path) -> str:
|
|
37
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config_path() -> Path:
|
|
41
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME", "")
|
|
42
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
|
43
|
+
return base / "mango" / "commands.yaml"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_config(config_dir: Path) -> None:
|
|
47
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
resource = files("mango").joinpath("config.default.yaml")
|
|
49
|
+
content = resource.read_bytes()
|
|
50
|
+
resource_hash = hashlib.sha256(content).hexdigest()
|
|
51
|
+
dest = config_dir / "config.default.yaml"
|
|
52
|
+
if not dest.exists() or _file_sha256(dest) != resource_hash:
|
|
53
|
+
dest.write_bytes(content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_param(data: object, macro_name: str, idx: int) -> Param:
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
raise ValueError(f"Macro '{macro_name}': param[{idx}] must be a mapping")
|
|
59
|
+
name = data.get("name")
|
|
60
|
+
prompt = data.get("prompt")
|
|
61
|
+
if not name:
|
|
62
|
+
raise ValueError(f"Macro '{macro_name}': param[{idx}] missing 'name'")
|
|
63
|
+
if not prompt:
|
|
64
|
+
raise ValueError(f"Macro '{macro_name}': param[{idx}] missing 'prompt'")
|
|
65
|
+
return Param(name=str(name), prompt=str(prompt))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_macro(data: object, macro_key: str, cat_name: str) -> Macro:
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
raise ValueError(f"Category '{cat_name}': macro '{macro_key}' must be a mapping")
|
|
71
|
+
shortcut = data.get("shortcut")
|
|
72
|
+
description = data.get("description")
|
|
73
|
+
steps = data.get("steps")
|
|
74
|
+
if not shortcut:
|
|
75
|
+
raise ValueError(f"'{cat_name}.{macro_key}': missing 'shortcut'")
|
|
76
|
+
if not description:
|
|
77
|
+
raise ValueError(f"'{cat_name}.{macro_key}': missing 'description'")
|
|
78
|
+
if not steps or not isinstance(steps, list):
|
|
79
|
+
raise ValueError(f"'{cat_name}.{macro_key}': missing or empty 'steps'")
|
|
80
|
+
params = [_parse_param(p, macro_key, i) for i, p in enumerate(data.get("params") or [])]
|
|
81
|
+
return Macro(
|
|
82
|
+
shortcut=str(shortcut),
|
|
83
|
+
description=str(description),
|
|
84
|
+
steps=[str(s) for s in steps],
|
|
85
|
+
params=params,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_category(data: object, cat_key: str) -> Category:
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
raise ValueError(f"Category '{cat_key}' must be a mapping")
|
|
92
|
+
shortcut = data.get("shortcut")
|
|
93
|
+
macros_raw = data.get("macros") or {}
|
|
94
|
+
if not shortcut:
|
|
95
|
+
raise ValueError(f"Category '{cat_key}': missing 'shortcut'")
|
|
96
|
+
if not isinstance(macros_raw, dict):
|
|
97
|
+
raise ValueError(f"Category '{cat_key}': 'macros' must be a mapping")
|
|
98
|
+
macros: dict[str, Macro] = {}
|
|
99
|
+
seen_shortcuts: set[str] = set()
|
|
100
|
+
for mk, mv in macros_raw.items():
|
|
101
|
+
macro = _parse_macro(mv, mk, cat_key)
|
|
102
|
+
if macro.shortcut in seen_shortcuts:
|
|
103
|
+
raise ValueError(f"Category '{cat_key}': duplicate macro shortcut '{macro.shortcut}'")
|
|
104
|
+
seen_shortcuts.add(macro.shortcut)
|
|
105
|
+
macros[mk] = macro
|
|
106
|
+
return Category(name=cat_key, shortcut=str(shortcut), macros=macros)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_config(path: Path) -> Config:
|
|
110
|
+
raw = yaml.safe_load(path.read_text())
|
|
111
|
+
if not isinstance(raw, dict) or "categories" not in raw:
|
|
112
|
+
raise ValueError("Config must have a top-level 'categories' key")
|
|
113
|
+
cats_raw = raw["categories"]
|
|
114
|
+
if not isinstance(cats_raw, dict):
|
|
115
|
+
raise ValueError("'categories' must be a mapping")
|
|
116
|
+
categories: dict[str, Category] = {}
|
|
117
|
+
seen_shortcuts: set[str] = set()
|
|
118
|
+
for ck, cv in cats_raw.items():
|
|
119
|
+
cat = _parse_category(cv, ck)
|
|
120
|
+
if cat.shortcut in seen_shortcuts:
|
|
121
|
+
raise ValueError(f"Duplicate category shortcut '{cat.shortcut}'")
|
|
122
|
+
seen_shortcuts.add(cat.shortcut)
|
|
123
|
+
categories[ck] = cat
|
|
124
|
+
return Config(categories=categories)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def interpolate(template: str, params: dict[str, str]) -> str:
|
|
128
|
+
return template.format_map(params)
|
mango/main.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .config import ensure_config, get_config_path, load_config
|
|
5
|
+
from .merger import merge_configs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
config_path = get_config_path()
|
|
10
|
+
config_dir = config_path.parent
|
|
11
|
+
try:
|
|
12
|
+
ensure_config(config_dir)
|
|
13
|
+
merge_configs(config_dir)
|
|
14
|
+
config = load_config(config_path)
|
|
15
|
+
except Exception as exc:
|
|
16
|
+
print(f"mango: config error — {exc}", file=sys.stderr)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
from .tui.app import MangoApp
|
|
20
|
+
|
|
21
|
+
cwd = str(Path.cwd())
|
|
22
|
+
app = MangoApp(config=config, cwd=cwd)
|
|
23
|
+
app.run()
|
mango/merger.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
_MERGE_STATE_FILE = ".merge-state.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_merge_state(config_dir: Path) -> dict:
|
|
12
|
+
state_path = config_dir / _MERGE_STATE_FILE
|
|
13
|
+
if not state_path.exists():
|
|
14
|
+
return {}
|
|
15
|
+
return json.loads(state_path.read_text())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _save_merge_state(config_dir: Path, default_hash: str, local_hash: str | None) -> None:
|
|
19
|
+
state_path = config_dir / _MERGE_STATE_FILE
|
|
20
|
+
state_path.write_text(json.dumps({"default_hash": default_hash, "local_hash": local_hash}))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sha256_file(path: Path) -> str:
|
|
24
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def should_merge(config_dir: Path) -> bool:
|
|
28
|
+
default_path = config_dir / "config.default.yaml"
|
|
29
|
+
local_path = config_dir / "config.local.yaml"
|
|
30
|
+
|
|
31
|
+
state = _load_merge_state(config_dir)
|
|
32
|
+
if not state:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
if _sha256_file(default_path) != state.get("default_hash"):
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
current_local_hash = _sha256_file(local_path) if local_path.exists() else None
|
|
39
|
+
return current_local_hash != state.get("local_hash")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def merge_configs(config_dir: Path) -> list[str]:
|
|
43
|
+
default_path = config_dir / "config.default.yaml"
|
|
44
|
+
local_path = config_dir / "config.local.yaml"
|
|
45
|
+
commands_path = config_dir / "commands.yaml"
|
|
46
|
+
|
|
47
|
+
if not should_merge(config_dir):
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
warnings: list[str] = []
|
|
51
|
+
|
|
52
|
+
default_raw = yaml.safe_load(default_path.read_text())
|
|
53
|
+
default_cats: dict = (default_raw or {}).get("categories", {})
|
|
54
|
+
|
|
55
|
+
merged_cats: dict = {}
|
|
56
|
+
for key, data in default_cats.items():
|
|
57
|
+
merged_cats[key] = {"shortcut": data["shortcut"], "macros": dict(data.get("macros") or {})}
|
|
58
|
+
|
|
59
|
+
if local_path.exists():
|
|
60
|
+
local_raw = yaml.safe_load(local_path.read_text())
|
|
61
|
+
local_cats: dict = (local_raw or {}).get("categories", {})
|
|
62
|
+
|
|
63
|
+
default_shortcuts: set[str] = {str(v["shortcut"]) for v in default_cats.values()}
|
|
64
|
+
|
|
65
|
+
for local_key, local_data in local_cats.items():
|
|
66
|
+
local_shortcut = str(local_data.get("shortcut", ""))
|
|
67
|
+
|
|
68
|
+
if local_key in default_cats:
|
|
69
|
+
default_shortcut = str(default_cats[local_key].get("shortcut", ""))
|
|
70
|
+
if local_shortcut == default_shortcut:
|
|
71
|
+
# Exact match — merge macros
|
|
72
|
+
default_macros: dict = merged_cats[local_key]["macros"]
|
|
73
|
+
default_macro_shortcuts = {str(m.get("shortcut", "")) for m in default_macros.values()}
|
|
74
|
+
for macro_key, macro_data in (local_data.get("macros") or {}).items():
|
|
75
|
+
macro_shortcut = str(macro_data.get("shortcut", ""))
|
|
76
|
+
if macro_key in default_macros:
|
|
77
|
+
warnings.append(
|
|
78
|
+
f"[mango] config conflict: macro '{local_key}>{macro_key}' — "
|
|
79
|
+
f"key already exists in default (skipped)"
|
|
80
|
+
)
|
|
81
|
+
elif macro_shortcut in default_macro_shortcuts:
|
|
82
|
+
warnings.append(
|
|
83
|
+
f"[mango] config conflict: macro '{local_key}>{macro_key}' — "
|
|
84
|
+
f"shortcut '{macro_shortcut}' already used by a default macro (skipped)"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
merged_cats[local_key]["macros"][macro_key] = macro_data
|
|
88
|
+
else:
|
|
89
|
+
warnings.append(
|
|
90
|
+
f"[mango] config conflict: category '{local_key}' — "
|
|
91
|
+
f"shortcut '{local_shortcut}' conflicts with default shortcut '{default_shortcut}' (skipped)"
|
|
92
|
+
)
|
|
93
|
+
elif local_shortcut in default_shortcuts:
|
|
94
|
+
conflicting_key = next(k for k, v in default_cats.items() if str(v["shortcut"]) == local_shortcut)
|
|
95
|
+
warnings.append(
|
|
96
|
+
f"[mango] config conflict: category '{local_key}' — "
|
|
97
|
+
f"shortcut '{local_shortcut}' already used by '{conflicting_key}' (skipped)"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
merged_cats[local_key] = local_data
|
|
101
|
+
|
|
102
|
+
commands_path.write_text(
|
|
103
|
+
yaml.dump({"categories": merged_cats}, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
default_hash = _sha256_file(default_path)
|
|
107
|
+
local_hash = _sha256_file(local_path) if local_path.exists() else None
|
|
108
|
+
_save_merge_state(config_dir, default_hash, local_hash)
|
|
109
|
+
|
|
110
|
+
for warning in warnings:
|
|
111
|
+
print(warning, file=sys.stderr)
|
|
112
|
+
|
|
113
|
+
return warnings
|
mango/runner.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from .config import Macro, interpolate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _validate_steps(macro: Macro, params: dict[str, str]) -> None:
|
|
9
|
+
param_names = set(params.keys())
|
|
10
|
+
for step in macro.steps:
|
|
11
|
+
for match in re.finditer(r"\{(\w+)\}", step):
|
|
12
|
+
name = match.group(1)
|
|
13
|
+
if name not in param_names:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
f"Step '{step}' references undefined param '{name}'"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_step(command: str, cwd: str, on_output: Callable[[str], None]) -> int:
|
|
20
|
+
proc = subprocess.Popen(
|
|
21
|
+
command,
|
|
22
|
+
shell=True,
|
|
23
|
+
cwd=cwd,
|
|
24
|
+
stdout=subprocess.PIPE,
|
|
25
|
+
stderr=subprocess.STDOUT,
|
|
26
|
+
stdin=subprocess.DEVNULL,
|
|
27
|
+
text=True,
|
|
28
|
+
bufsize=1,
|
|
29
|
+
)
|
|
30
|
+
assert proc.stdout is not None
|
|
31
|
+
for line in proc.stdout:
|
|
32
|
+
on_output(line.rstrip())
|
|
33
|
+
proc.wait()
|
|
34
|
+
return proc.returncode
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_macro(
|
|
38
|
+
macro: Macro,
|
|
39
|
+
params: dict[str, str],
|
|
40
|
+
cwd: str,
|
|
41
|
+
on_output: Callable[[str], None],
|
|
42
|
+
) -> tuple[bool, int | None, str | None]:
|
|
43
|
+
_validate_steps(macro, params)
|
|
44
|
+
total = len(macro.steps)
|
|
45
|
+
for i, step_template in enumerate(macro.steps, 1):
|
|
46
|
+
cmd = interpolate(step_template, params)
|
|
47
|
+
on_output(f"[bold cyan][step {i}/{total}][/] $ {cmd}")
|
|
48
|
+
rc = run_step(cmd, cwd, on_output)
|
|
49
|
+
if rc != 0:
|
|
50
|
+
return False, rc, cmd
|
|
51
|
+
return True, None, None
|
mango/tui/__init__.py
ADDED
|
File without changes
|
mango/tui/app.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
from textual import on, work
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.binding import Binding
|
|
4
|
+
from textual.containers import Horizontal, Vertical
|
|
5
|
+
from textual.screen import ModalScreen, Screen
|
|
6
|
+
from textual.widgets import Input, Label, ListItem, ListView, RichLog, Static
|
|
7
|
+
|
|
8
|
+
from ..config import Category, Config, Macro, Param
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── Custom ListItem subclasses ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CategoryItem(ListItem):
|
|
15
|
+
def __init__(self, category: Category) -> None:
|
|
16
|
+
super().__init__(Label(f"\\[{category.shortcut}] {category.name}"))
|
|
17
|
+
self.category = category
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MacroItem(ListItem):
|
|
21
|
+
def __init__(self, macro: Macro) -> None:
|
|
22
|
+
params_hint = (
|
|
23
|
+
" " + " ".join(f"<{p.name}>" for p in macro.params) if macro.params else ""
|
|
24
|
+
)
|
|
25
|
+
super().__init__(Label(f"\\[{macro.shortcut}] {macro.description}{params_hint}"))
|
|
26
|
+
self.macro = macro
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Parameter input dialog ────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParamDialog(ModalScreen[dict[str, str] | None]):
|
|
33
|
+
DEFAULT_CSS = """
|
|
34
|
+
ParamDialog {
|
|
35
|
+
align: center middle;
|
|
36
|
+
}
|
|
37
|
+
#dialog {
|
|
38
|
+
background: $surface;
|
|
39
|
+
border: solid $primary;
|
|
40
|
+
width: 60;
|
|
41
|
+
height: auto;
|
|
42
|
+
padding: 1 2;
|
|
43
|
+
}
|
|
44
|
+
#dialog-title {
|
|
45
|
+
text-style: bold;
|
|
46
|
+
padding-bottom: 1;
|
|
47
|
+
color: $text;
|
|
48
|
+
}
|
|
49
|
+
#param-label {
|
|
50
|
+
color: $text-muted;
|
|
51
|
+
padding-bottom: 1;
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
56
|
+
|
|
57
|
+
def __init__(self, params: list[Param]) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self._params = params
|
|
60
|
+
self._values: dict[str, str] = {}
|
|
61
|
+
self._idx = 0
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
with Vertical(id="dialog"):
|
|
65
|
+
yield Label("Parameters", id="dialog-title")
|
|
66
|
+
yield Label(self._params[0].prompt if self._params else "", id="param-label")
|
|
67
|
+
yield Input(
|
|
68
|
+
placeholder=self._params[0].prompt if self._params else "",
|
|
69
|
+
id="param-input",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def on_mount(self) -> None:
|
|
73
|
+
self.query_one("#param-input", Input).focus()
|
|
74
|
+
|
|
75
|
+
@on(Input.Submitted, "#param-input")
|
|
76
|
+
def _on_submitted(self, event: Input.Submitted) -> None:
|
|
77
|
+
param = self._params[self._idx]
|
|
78
|
+
self._values[param.name] = event.value
|
|
79
|
+
self._idx += 1
|
|
80
|
+
if self._idx >= len(self._params):
|
|
81
|
+
self.dismiss(self._values)
|
|
82
|
+
return
|
|
83
|
+
next_param = self._params[self._idx]
|
|
84
|
+
self.query_one("#param-label", Label).update(next_param.prompt)
|
|
85
|
+
inp = self.query_one("#param-input", Input)
|
|
86
|
+
inp.value = ""
|
|
87
|
+
inp.focus()
|
|
88
|
+
|
|
89
|
+
def action_cancel(self) -> None:
|
|
90
|
+
self.dismiss(None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Main screen ───────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MainScreen(Screen):
|
|
97
|
+
DEFAULT_CSS = """
|
|
98
|
+
MainScreen {
|
|
99
|
+
layout: vertical;
|
|
100
|
+
}
|
|
101
|
+
#panels {
|
|
102
|
+
layout: horizontal;
|
|
103
|
+
height: 2fr;
|
|
104
|
+
}
|
|
105
|
+
#category-panel {
|
|
106
|
+
width: 1fr;
|
|
107
|
+
border: solid $primary;
|
|
108
|
+
}
|
|
109
|
+
#category-panel > Label {
|
|
110
|
+
background: $primary;
|
|
111
|
+
color: $text;
|
|
112
|
+
text-align: center;
|
|
113
|
+
height: 1;
|
|
114
|
+
width: 1fr;
|
|
115
|
+
}
|
|
116
|
+
#macro-panel {
|
|
117
|
+
width: 3fr;
|
|
118
|
+
border: solid $accent;
|
|
119
|
+
}
|
|
120
|
+
#macro-panel > Label {
|
|
121
|
+
background: $accent;
|
|
122
|
+
color: $text;
|
|
123
|
+
text-align: center;
|
|
124
|
+
height: 1;
|
|
125
|
+
width: 1fr;
|
|
126
|
+
}
|
|
127
|
+
#output-log {
|
|
128
|
+
height: 1fr;
|
|
129
|
+
border: solid $warning;
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
#footer {
|
|
133
|
+
dock: bottom;
|
|
134
|
+
height: 4;
|
|
135
|
+
layout: vertical;
|
|
136
|
+
border-top: solid $primary;
|
|
137
|
+
}
|
|
138
|
+
#shortcut-row {
|
|
139
|
+
height: 3;
|
|
140
|
+
layout: horizontal;
|
|
141
|
+
align: left middle;
|
|
142
|
+
}
|
|
143
|
+
#shortcut-label {
|
|
144
|
+
width: auto;
|
|
145
|
+
padding: 0 1;
|
|
146
|
+
color: $text-muted;
|
|
147
|
+
content-align: center middle;
|
|
148
|
+
}
|
|
149
|
+
#shortcut-input {
|
|
150
|
+
width: 1fr;
|
|
151
|
+
border: none;
|
|
152
|
+
}
|
|
153
|
+
#status-bar {
|
|
154
|
+
height: 1;
|
|
155
|
+
padding: 0 1;
|
|
156
|
+
color: $text-muted;
|
|
157
|
+
}
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
BINDINGS = [
|
|
161
|
+
Binding("q", "quit_app", "Quit", show=True),
|
|
162
|
+
Binding("escape", "quit_app", "Quit", show=False),
|
|
163
|
+
Binding("tab", "focus_next", "Next panel", show=True),
|
|
164
|
+
Binding("shift+tab", "focus_previous", "Prev panel", show=False),
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def __init__(self, config: Config, cwd: str) -> None:
|
|
168
|
+
super().__init__()
|
|
169
|
+
self._config = config
|
|
170
|
+
self._cwd = cwd
|
|
171
|
+
self._categories = list(config.categories.values())
|
|
172
|
+
self._selected_cat_idx = 0
|
|
173
|
+
|
|
174
|
+
def compose(self) -> ComposeResult:
|
|
175
|
+
with Horizontal(id="panels"):
|
|
176
|
+
with Vertical(id="category-panel"):
|
|
177
|
+
yield Label(" Categories ")
|
|
178
|
+
yield ListView(id="category-list")
|
|
179
|
+
with Vertical(id="macro-panel"):
|
|
180
|
+
yield Label(" Macros ")
|
|
181
|
+
yield ListView(id="macro-list")
|
|
182
|
+
yield RichLog(id="output-log", highlight=True, markup=True)
|
|
183
|
+
with Vertical(id="footer"):
|
|
184
|
+
with Horizontal(id="shortcut-row"):
|
|
185
|
+
yield Static("shortcut> ", id="shortcut-label")
|
|
186
|
+
yield Input(placeholder="g>su", id="shortcut-input")
|
|
187
|
+
yield Label("", id="status-bar")
|
|
188
|
+
|
|
189
|
+
def on_mount(self) -> None:
|
|
190
|
+
cat_list = self.query_one("#category-list", ListView)
|
|
191
|
+
for cat in self._categories:
|
|
192
|
+
cat_list.append(CategoryItem(cat))
|
|
193
|
+
if self._categories:
|
|
194
|
+
self._populate_macros(self._categories[0])
|
|
195
|
+
cat_list.focus()
|
|
196
|
+
|
|
197
|
+
# ── Category navigation ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
@on(ListView.Highlighted, "#category-list")
|
|
200
|
+
def _on_category_highlighted(self, event: ListView.Highlighted) -> None:
|
|
201
|
+
if isinstance(event.item, CategoryItem):
|
|
202
|
+
self._populate_macros(event.item.category)
|
|
203
|
+
|
|
204
|
+
@on(ListView.Selected, "#category-list")
|
|
205
|
+
def _on_category_selected(self, event: ListView.Selected) -> None:
|
|
206
|
+
self.query_one("#macro-list", ListView).focus()
|
|
207
|
+
|
|
208
|
+
def _populate_macros(self, cat: Category) -> None:
|
|
209
|
+
macro_list = self.query_one("#macro-list", ListView)
|
|
210
|
+
macro_list.clear()
|
|
211
|
+
for macro in cat.macros.values():
|
|
212
|
+
macro_list.append(MacroItem(macro))
|
|
213
|
+
|
|
214
|
+
# ── Macro execution via list selection ────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
@on(ListView.Selected, "#macro-list")
|
|
217
|
+
def _on_macro_selected(self, event: ListView.Selected) -> None:
|
|
218
|
+
if isinstance(event.item, MacroItem):
|
|
219
|
+
self._trigger_macro(event.item.macro)
|
|
220
|
+
|
|
221
|
+
# ── Shortcut bar ──────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
@on(Input.Submitted, "#shortcut-input")
|
|
224
|
+
def _on_shortcut_submitted(self, event: Input.Submitted) -> None:
|
|
225
|
+
shortcut = event.value.strip()
|
|
226
|
+
self.query_one("#shortcut-input", Input).value = ""
|
|
227
|
+
if not shortcut:
|
|
228
|
+
return
|
|
229
|
+
self._dispatch_shortcut(shortcut)
|
|
230
|
+
|
|
231
|
+
def _dispatch_shortcut(self, shortcut: str) -> None:
|
|
232
|
+
parts = shortcut.split(">", 1)
|
|
233
|
+
if len(parts) != 2:
|
|
234
|
+
self._set_status(
|
|
235
|
+
f"[red]Invalid format: '{shortcut}'. Use cat>mac (e.g. g>su)[/]"
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
cat_sc, macro_sc = parts[0].strip(), parts[1].strip()
|
|
239
|
+
cat = next((c for c in self._categories if c.shortcut == cat_sc), None)
|
|
240
|
+
if cat is None:
|
|
241
|
+
self._set_status(f"[red]Unknown category shortcut: '{cat_sc}'[/]")
|
|
242
|
+
return
|
|
243
|
+
macro = next((m for m in cat.macros.values() if m.shortcut == macro_sc), None)
|
|
244
|
+
if macro is None:
|
|
245
|
+
self._set_status(
|
|
246
|
+
f"[red]Unknown macro shortcut: '{macro_sc}' in '{cat.name}'[/]"
|
|
247
|
+
)
|
|
248
|
+
return
|
|
249
|
+
self._trigger_macro(macro)
|
|
250
|
+
|
|
251
|
+
# ── Macro trigger + param dialog ─────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def _trigger_macro(self, macro: Macro) -> None:
|
|
254
|
+
self._set_status(f"Selected: {macro.description}")
|
|
255
|
+
if macro.params:
|
|
256
|
+
self.app.push_screen(
|
|
257
|
+
ParamDialog(macro.params),
|
|
258
|
+
callback=lambda values: (
|
|
259
|
+
self._execute_macro(macro, values)
|
|
260
|
+
if values is not None
|
|
261
|
+
else self._set_status("Cancelled")
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
self._execute_macro(macro, {})
|
|
266
|
+
|
|
267
|
+
# ── Execution ─────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def _execute_macro(self, macro: Macro, params: dict[str, str]) -> None:
|
|
270
|
+
output_log = self.query_one("#output-log", RichLog)
|
|
271
|
+
output_log.clear()
|
|
272
|
+
output_log.display = True
|
|
273
|
+
self._set_status(f"Running: {macro.description}…")
|
|
274
|
+
self._run_worker(macro, params, output_log)
|
|
275
|
+
|
|
276
|
+
@work(thread=True)
|
|
277
|
+
def _run_worker(
|
|
278
|
+
self, macro: Macro, params: dict[str, str], output_log: RichLog
|
|
279
|
+
) -> None:
|
|
280
|
+
from ..runner import run_macro
|
|
281
|
+
|
|
282
|
+
def on_output(line: str) -> None:
|
|
283
|
+
self.app.call_from_thread(output_log.write, line)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
success, exit_code, failed_step = run_macro(macro, params, self._cwd, on_output)
|
|
287
|
+
except ValueError as exc:
|
|
288
|
+
self.app.call_from_thread(self._on_config_error, str(exc))
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if success:
|
|
292
|
+
self.app.call_from_thread(self._on_success, macro, output_log)
|
|
293
|
+
else:
|
|
294
|
+
self.app.call_from_thread(
|
|
295
|
+
self._on_failure, failed_step or "", exit_code or 1, output_log
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _on_success(self, macro: Macro, output_log: RichLog) -> None:
|
|
299
|
+
output_log.write(f"\n[bold green]✓ '{macro.description}' completed[/]")
|
|
300
|
+
self._set_status(f"[green]✓ Done: {macro.description}[/]")
|
|
301
|
+
|
|
302
|
+
def _on_failure(self, step: str, exit_code: int, output_log: RichLog) -> None:
|
|
303
|
+
output_log.write(
|
|
304
|
+
f"\n[bold red]✗ Step failed (exit code {exit_code}):[/] {step}"
|
|
305
|
+
)
|
|
306
|
+
self._set_status(f"[red]✗ Failed (exit {exit_code}): {step}[/]")
|
|
307
|
+
|
|
308
|
+
def _on_config_error(self, message: str) -> None:
|
|
309
|
+
self._set_status(f"[red]Config error: {message}[/]")
|
|
310
|
+
|
|
311
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def _set_status(self, message: str) -> None:
|
|
314
|
+
self.query_one("#status-bar", Label).update(message)
|
|
315
|
+
|
|
316
|
+
_FOCUS_CYCLE = ["#category-list", "#macro-list", "#shortcut-input"]
|
|
317
|
+
|
|
318
|
+
def action_focus_next(self) -> None:
|
|
319
|
+
focused_id = self.focused.id if self.focused else None
|
|
320
|
+
ids = self._FOCUS_CYCLE
|
|
321
|
+
try:
|
|
322
|
+
idx = ids.index(f"#{focused_id}")
|
|
323
|
+
except ValueError:
|
|
324
|
+
idx = -1
|
|
325
|
+
self.query_one(ids[(idx + 1) % len(ids)]).focus()
|
|
326
|
+
|
|
327
|
+
def action_focus_previous(self) -> None:
|
|
328
|
+
focused_id = self.focused.id if self.focused else None
|
|
329
|
+
ids = self._FOCUS_CYCLE
|
|
330
|
+
try:
|
|
331
|
+
idx = ids.index(f"#{focused_id}")
|
|
332
|
+
except ValueError:
|
|
333
|
+
idx = 0
|
|
334
|
+
self.query_one(ids[(idx - 1) % len(ids)]).focus()
|
|
335
|
+
|
|
336
|
+
def action_quit_app(self) -> None:
|
|
337
|
+
if isinstance(self.focused, Input):
|
|
338
|
+
return
|
|
339
|
+
self.app.exit()
|
|
340
|
+
|
|
341
|
+
def on_key(self, event) -> None:
|
|
342
|
+
if isinstance(self.focused, Input):
|
|
343
|
+
return
|
|
344
|
+
if event.key == "q":
|
|
345
|
+
event.stop()
|
|
346
|
+
self.app.exit()
|
|
347
|
+
elif event.key == "right" and self.focused and self.focused.id == "category-list":
|
|
348
|
+
event.stop()
|
|
349
|
+
self.query_one("#macro-list", ListView).focus()
|
|
350
|
+
elif event.key == "left" and self.focused and self.focused.id == "macro-list":
|
|
351
|
+
event.stop()
|
|
352
|
+
self.query_one("#category-list", ListView).focus()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── App ───────────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class MangoApp(App):
|
|
359
|
+
TITLE = "mango"
|
|
360
|
+
SUB_TITLE = "macro runner"
|
|
361
|
+
|
|
362
|
+
def __init__(self, config: Config, cwd: str) -> None:
|
|
363
|
+
super().__init__()
|
|
364
|
+
self._config = config
|
|
365
|
+
self._cwd = cwd
|
|
366
|
+
|
|
367
|
+
def on_mount(self) -> None:
|
|
368
|
+
self.push_screen(MainScreen(config=self._config, cwd=self._cwd))
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mango-tui
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Keyboard-driven TUI for running multi-step shell command sequences defined in YAML
|
|
5
|
+
Author-email: Juan Pablo Leon <juanpabloleonmaya.dev@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/juanleon8581/mango-assistant
|
|
8
|
+
Project-URL: Repository, https://github.com/juanleon8581/mango-assistant
|
|
9
|
+
Project-URL: Issues, https://github.com/juanleon8581/mango-assistant/issues
|
|
10
|
+
Keywords: tui,terminal,cli,macros,shell,automation,textual
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Terminals
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: textual>=0.60.0
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# mango
|
|
29
|
+
|
|
30
|
+
A keyboard-driven terminal UI for running multi-step shell command sequences defined in YAML.
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
mango lets you define "macros" — named sequences of shell commands — grouped into categories. You run them by navigating the TUI or typing shortcut combos like `g>su` (category `g`, macro `su`). Macros can prompt for parameters before running.
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.10+
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python3 -m venv .venv
|
|
44
|
+
source .venv/bin/activate
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
mango
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Navigate with arrow keys or `j`/`k`. Press `Enter` to run a macro. If the macro has params, a dialog prompts for them before execution. Output streams to a panel at the bottom. Press `q` to quit.
|
|
55
|
+
|
|
56
|
+
**Shortcut mode:** type `<category_shortcut>><macro_shortcut>` (e.g. `g>su`) to jump directly to a macro from anywhere in the TUI.
|
|
57
|
+
|
|
58
|
+
## Config
|
|
59
|
+
|
|
60
|
+
mango manages three files under `~/.config/mango/` (respects `$XDG_CONFIG_HOME`):
|
|
61
|
+
|
|
62
|
+
| File | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `config.default.yaml` | Macros bundled with the package — updated automatically on each startup |
|
|
65
|
+
| `config.local.yaml` | Your personal macros — optional, persists across package updates |
|
|
66
|
+
| `commands.yaml` | Merge output read by the app — **do not edit manually** |
|
|
67
|
+
|
|
68
|
+
On each startup mango propagates the built-in defaults and merges them with your local config into `commands.yaml`. The merge is lazy: it only runs when either source file changes.
|
|
69
|
+
|
|
70
|
+
### Adding your own macros
|
|
71
|
+
|
|
72
|
+
Create `~/.config/mango/config.local.yaml` with the same YAML schema:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
categories:
|
|
76
|
+
git:
|
|
77
|
+
shortcut: "g" # must match the default exactly to add macros into it
|
|
78
|
+
macros:
|
|
79
|
+
my-cleanup:
|
|
80
|
+
shortcut: "cl"
|
|
81
|
+
description: "Delete merged branches"
|
|
82
|
+
steps:
|
|
83
|
+
- git branch --merged | grep -v main | xargs git branch -d
|
|
84
|
+
my-tools: # entirely new category — key and shortcut must not exist in defaults
|
|
85
|
+
shortcut: "t"
|
|
86
|
+
macros:
|
|
87
|
+
hello:
|
|
88
|
+
shortcut: "hi"
|
|
89
|
+
description: "Say hello"
|
|
90
|
+
steps:
|
|
91
|
+
- echo "hello"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Merge rules:**
|
|
95
|
+
|
|
96
|
+
- To add macros into an existing default category: the category `key` and `shortcut` must match the default exactly.
|
|
97
|
+
- To add a new category: both the `key` and `shortcut` must not exist in the defaults.
|
|
98
|
+
- Within a shared category, each local macro must have a `key` and `shortcut` not already used by the defaults.
|
|
99
|
+
|
|
100
|
+
Conflicts are skipped and reported to stderr before the TUI opens:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
[mango] config conflict: category 'tools' — shortcut 'g' already used by 'git' (skipped)
|
|
104
|
+
[mango] config conflict: macro 'git>status' — key already defined in default (skipped)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Schema reference
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
categories:
|
|
111
|
+
git:
|
|
112
|
+
shortcut: "g"
|
|
113
|
+
macros:
|
|
114
|
+
switch-and-pull:
|
|
115
|
+
shortcut: "su"
|
|
116
|
+
description: "Switch branch, fetch and pull"
|
|
117
|
+
params:
|
|
118
|
+
- name: branch
|
|
119
|
+
prompt: "Branch name"
|
|
120
|
+
steps:
|
|
121
|
+
- git checkout {branch}
|
|
122
|
+
- git fetch
|
|
123
|
+
- git pull
|
|
124
|
+
status:
|
|
125
|
+
shortcut: "st"
|
|
126
|
+
description: "Show git status"
|
|
127
|
+
steps:
|
|
128
|
+
- git status
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `shortcut` — unique within its scope (category shortcuts must be globally unique; macro shortcuts must be unique within their category)
|
|
132
|
+
- `params` — optional list of `{name, prompt}` pairs; referenced in steps as `{name}`
|
|
133
|
+
- `steps` — shell commands run sequentially; first non-zero exit code aborts the sequence
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# Test with a local config instead of ~/.config/mango/
|
|
139
|
+
XDG_CONFIG_HOME=.test-config mango
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Dependencies: [`textual`](https://github.com/Textualize/textual), [`pyyaml`](https://pyyaml.org/)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mango/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mango/config.default.yaml,sha256=YFsvxOvcXnEb6alLD78C93qKv6K_lMSQRprAzUnWcMk,2169
|
|
3
|
+
mango/config.py,sha256=oOpGncSijPmK-P2jECvmzDq2Nu8cO0mKRl99wTjO4Do,4297
|
|
4
|
+
mango/main.py,sha256=BPN729Te-_d8d4lq4dN5tihWwQ7CQfSrTWg5x9c4CBM,581
|
|
5
|
+
mango/merger.py,sha256=hS5dJ8Z2FjQjgHudozH0pINZj4RyaPSvSlyf8VUOzHg,4575
|
|
6
|
+
mango/runner.py,sha256=KwwuUaw7PJrYnQpUpeCz-e7ZM5UAiK5_6QE93aBEeZA,1445
|
|
7
|
+
mango/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
mango/tui/app.py,sha256=IvVbZEYPWwV_z1nPw5QhY_VJ28LggqWFfs5miNiVihU,13200
|
|
9
|
+
mango_tui-0.2.0.dist-info/licenses/LICENSE,sha256=X-XOYNvd72ib6RFohoDkM5OogaUDN-0WTnfHVCK5yvE,1072
|
|
10
|
+
mango_tui-0.2.0.dist-info/METADATA,sha256=zwooJuaLo__m8FoQcBy0FqJfyuGYMvJGAzFMi-OzqmE,4776
|
|
11
|
+
mango_tui-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
mango_tui-0.2.0.dist-info/entry_points.txt,sha256=r8d0unMGAKMJjyQ9v3t85nh3smtSs3gOIz5Ei80bupM,42
|
|
13
|
+
mango_tui-0.2.0.dist-info/top_level.txt,sha256=NLfoE4WG_NC9Nwva22JeEStU-TieeO3uSbcb9mClGxE,6
|
|
14
|
+
mango_tui-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Juan Pablo Leon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mango
|