mango-tui 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,115 @@
1
+ # mango
2
+
3
+ A keyboard-driven terminal UI for running multi-step shell command sequences defined in YAML.
4
+
5
+ ## What it does
6
+
7
+ 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.
8
+
9
+ ## Requirements
10
+
11
+ - Python 3.10+
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ python3 -m venv .venv
17
+ source .venv/bin/activate
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```bash
24
+ mango
25
+ ```
26
+
27
+ 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.
28
+
29
+ **Shortcut mode:** type `<category_shortcut>><macro_shortcut>` (e.g. `g>su`) to jump directly to a macro from anywhere in the TUI.
30
+
31
+ ## Config
32
+
33
+ mango manages three files under `~/.config/mango/` (respects `$XDG_CONFIG_HOME`):
34
+
35
+ | File | Purpose |
36
+ |---|---|
37
+ | `config.default.yaml` | Macros bundled with the package — updated automatically on each startup |
38
+ | `config.local.yaml` | Your personal macros — optional, persists across package updates |
39
+ | `commands.yaml` | Merge output read by the app — **do not edit manually** |
40
+
41
+ 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.
42
+
43
+ ### Adding your own macros
44
+
45
+ Create `~/.config/mango/config.local.yaml` with the same YAML schema:
46
+
47
+ ```yaml
48
+ categories:
49
+ git:
50
+ shortcut: "g" # must match the default exactly to add macros into it
51
+ macros:
52
+ my-cleanup:
53
+ shortcut: "cl"
54
+ description: "Delete merged branches"
55
+ steps:
56
+ - git branch --merged | grep -v main | xargs git branch -d
57
+ my-tools: # entirely new category — key and shortcut must not exist in defaults
58
+ shortcut: "t"
59
+ macros:
60
+ hello:
61
+ shortcut: "hi"
62
+ description: "Say hello"
63
+ steps:
64
+ - echo "hello"
65
+ ```
66
+
67
+ **Merge rules:**
68
+
69
+ - To add macros into an existing default category: the category `key` and `shortcut` must match the default exactly.
70
+ - To add a new category: both the `key` and `shortcut` must not exist in the defaults.
71
+ - Within a shared category, each local macro must have a `key` and `shortcut` not already used by the defaults.
72
+
73
+ Conflicts are skipped and reported to stderr before the TUI opens:
74
+
75
+ ```
76
+ [mango] config conflict: category 'tools' — shortcut 'g' already used by 'git' (skipped)
77
+ [mango] config conflict: macro 'git>status' — key already defined in default (skipped)
78
+ ```
79
+
80
+ ### Schema reference
81
+
82
+ ```yaml
83
+ categories:
84
+ git:
85
+ shortcut: "g"
86
+ macros:
87
+ switch-and-pull:
88
+ shortcut: "su"
89
+ description: "Switch branch, fetch and pull"
90
+ params:
91
+ - name: branch
92
+ prompt: "Branch name"
93
+ steps:
94
+ - git checkout {branch}
95
+ - git fetch
96
+ - git pull
97
+ status:
98
+ shortcut: "st"
99
+ description: "Show git status"
100
+ steps:
101
+ - git status
102
+ ```
103
+
104
+ - `shortcut` — unique within its scope (category shortcuts must be globally unique; macro shortcuts must be unique within their category)
105
+ - `params` — optional list of `{name, prompt}` pairs; referenced in steps as `{name}`
106
+ - `steps` — shell commands run sequentially; first non-zero exit code aborts the sequence
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ # Test with a local config instead of ~/.config/mango/
112
+ XDG_CONFIG_HOME=.test-config mango
113
+ ```
114
+
115
+ Dependencies: [`textual`](https://github.com/Textualize/textual), [`pyyaml`](https://pyyaml.org/)
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mango-tui"
7
+ version = "0.2.0"
8
+ description = "Keyboard-driven TUI for running multi-step shell command sequences defined in YAML"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [
14
+ { name = "Juan Pablo Leon", email = "juanpabloleonmaya.dev@gmail.com" },
15
+ ]
16
+ keywords = ["tui", "terminal", "cli", "macros", "shell", "automation", "textual"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Terminals",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = [
30
+ "textual>=0.60.0",
31
+ "pyyaml>=6.0",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/juanleon8581/mango-assistant"
36
+ Repository = "https://github.com/juanleon8581/mango-assistant"
37
+ Issues = "https://github.com/juanleon8581/mango-assistant/issues"
38
+
39
+ [project.scripts]
40
+ mango = "mango.main:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.setuptools.package-data]
46
+ mango = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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
@@ -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)
@@ -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()
@@ -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
@@ -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
File without changes
@@ -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,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mango/__init__.py
5
+ src/mango/config.default.yaml
6
+ src/mango/config.py
7
+ src/mango/main.py
8
+ src/mango/merger.py
9
+ src/mango/runner.py
10
+ src/mango/tui/__init__.py
11
+ src/mango/tui/app.py
12
+ src/mango_tui.egg-info/PKG-INFO
13
+ src/mango_tui.egg-info/SOURCES.txt
14
+ src/mango_tui.egg-info/dependency_links.txt
15
+ src/mango_tui.egg-info/entry_points.txt
16
+ src/mango_tui.egg-info/requires.txt
17
+ src/mango_tui.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mango = mango.main:main
@@ -0,0 +1,2 @@
1
+ textual>=0.60.0
2
+ pyyaml>=6.0