-
Notifications
You must be signed in to change notification settings - Fork 0
Add explain interactive CLI for offline command summaries #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
knechtling
wants to merge
1
commit into
master
Choose a base branch
from
codex/develop-terminal-based-explain-application
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,368 @@ | ||
| #!/usr/bin/env python3 | ||
| import json | ||
| import re | ||
| import shlex | ||
| import subprocess | ||
| import sys | ||
| import textwrap | ||
| from typing import Dict, List, Optional, Tuple | ||
|
|
||
| try: | ||
| import yaml # type: ignore | ||
| except Exception: # pragma: no cover - optional dependency | ||
| yaml = None # type: ignore | ||
|
|
||
| COLOR_RESET = "\033[0m" | ||
| COLOR_PROMPT = "\033[38;5;39m" | ||
| COLOR_TITLE = "\033[1;38;5;220m" | ||
| COLOR_NAME = "\033[1;38;5;70m" | ||
| COLOR_DESC = "\033[38;5;244m" | ||
| COLOR_ERROR = "\033[1;31m" | ||
|
|
||
| CONTROL_TASK_KEYS = { | ||
| "name", | ||
| "vars", | ||
| "register", | ||
| "when", | ||
| "become", | ||
| "delegate_to", | ||
| "with_items", | ||
| "loop", | ||
| "loop_control", | ||
| "environment", | ||
| "tags", | ||
| "notify", | ||
| "ignore_errors", | ||
| "args", | ||
| } | ||
|
|
||
|
|
||
| def color(text: str, code: str) -> str: | ||
| return f"{code}{text}{COLOR_RESET}" | ||
|
|
||
|
|
||
| def run_subprocess(command: List[str]) -> Tuple[int, str, str]: | ||
| try: | ||
| completed = subprocess.run( | ||
| command, | ||
| check=False, | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| text=True, | ||
| ) | ||
| except FileNotFoundError: | ||
| return 127, "", f"Command not found: {' '.join(command)}" | ||
| return completed.returncode, completed.stdout, completed.stderr | ||
|
|
||
|
|
||
| def detect_input_type(text: str) -> str: | ||
| stripped = text.strip() | ||
| if not stripped: | ||
| return "unknown" | ||
|
|
||
| if looks_like_yaml(stripped): | ||
| return "ansible" | ||
| return "shell" | ||
|
|
||
|
|
||
| def looks_like_yaml(text: str) -> bool: | ||
| if yaml is None: | ||
| # Heuristic fallback: look for colon-separated key/value pairs and indentation | ||
| colon_lines = sum(1 for line in text.splitlines() if ":" in line) | ||
| dash_lines = sum(1 for line in text.splitlines() if line.lstrip().startswith("-")) | ||
| return colon_lines > 1 or dash_lines > 1 | ||
| try: | ||
| data = yaml.safe_load(text) | ||
| except Exception: | ||
| return False | ||
| return isinstance(data, (dict, list)) | ||
|
|
||
|
|
||
| def get_man_page(command: str) -> Optional[str]: | ||
| ret, out, err = run_subprocess(["man", "-P", "cat", command]) | ||
| if ret == 0 and out.strip(): | ||
| return out | ||
| return None | ||
|
|
||
|
|
||
| def get_command_help(command: str) -> Optional[str]: | ||
| ret, out, err = run_subprocess([command, "--help"]) | ||
| if ret == 0 or out: | ||
| return out | ||
| return None | ||
|
|
||
|
|
||
| def extract_name_section(man_text: str) -> Optional[str]: | ||
| if not man_text: | ||
| return None | ||
| pattern = re.compile(r"^NAME\n(.+?)(?:\n\n|$)", re.DOTALL) | ||
| match = pattern.search(man_text) | ||
| if match: | ||
| return match.group(1).strip() | ||
| return None | ||
|
|
||
|
|
||
| def parse_help_options(help_text: str) -> Dict[str, str]: | ||
| options: Dict[str, str] = {} | ||
| if not help_text: | ||
| return options | ||
| lines = help_text.splitlines() | ||
| i = 0 | ||
| while i < len(lines): | ||
| line = lines[i] | ||
| option_match = re.match(r"\s{0,6}(-{1,2}[\w\-?]+(?:,\s*-{1,2}[\w\-?]+)*)\s+(.*)", line) | ||
| if option_match: | ||
| names = option_match.group(1) | ||
| desc = option_match.group(2).strip() | ||
| i += 1 | ||
| while i < len(lines) and lines[i].startswith(" " * 8): | ||
| desc += " " + lines[i].strip() | ||
| i += 1 | ||
| for name in re.split(r",\s*", names): | ||
| normalized = name.strip() | ||
| if normalized: | ||
| options[normalized] = desc | ||
| continue | ||
| i += 1 | ||
| return options | ||
|
|
||
|
|
||
| def describe_command(command_line: str) -> None: | ||
| try: | ||
| parts = shlex.split(command_line) | ||
| except ValueError as exc: | ||
| print(color(f"Could not parse command: {exc}", COLOR_ERROR)) | ||
| return | ||
|
|
||
| if not parts: | ||
| print(color("No command to explain.", COLOR_ERROR)) | ||
| return | ||
|
|
||
| command = parts[0] | ||
| man_text = get_man_page(command) | ||
| help_text = get_command_help(command) | ||
| option_help = parse_help_options(help_text or man_text or "") | ||
|
|
||
| print(color("Command", COLOR_TITLE)) | ||
| print(f" {color(command, COLOR_NAME)}") | ||
|
|
||
| if man_text: | ||
| name_section = extract_name_section(man_text) | ||
| if name_section: | ||
| print(color("Summary", COLOR_TITLE)) | ||
| for line in textwrap.wrap(name_section, width=80): | ||
| print(f" {line}") | ||
| elif help_text: | ||
| first_line = help_text.strip().splitlines()[0] | ||
| print(color("Summary", COLOR_TITLE)) | ||
| for line in textwrap.wrap(first_line, width=80): | ||
| print(f" {line}") | ||
|
|
||
| if len(parts) > 1: | ||
| print(color("Arguments", COLOR_TITLE)) | ||
| for arg in parts[1:]: | ||
| name = arg | ||
| desc = "" | ||
| lookup_key = arg | ||
| if "=" in lookup_key: | ||
| lookup_key = lookup_key.split("=", 1)[0] | ||
| if lookup_key.startswith("--") and lookup_key not in option_help: | ||
| # try to match prefix (e.g., --option=value) | ||
| for opt_name in option_help: | ||
| if opt_name.startswith(lookup_key): | ||
| desc = option_help[opt_name] | ||
| break | ||
| if not desc: | ||
| desc = option_help.get(lookup_key) or option_help.get(arg[:2]) or "" | ||
| if not desc and arg.startswith("-") and len(arg) > 2 and not arg.startswith("--"): | ||
| # short options squashed together | ||
| letters = arg[1:] | ||
| combined_desc = [] | ||
| for letter in letters: | ||
| opt = f"-{letter}" | ||
| if opt in option_help: | ||
| combined_desc.append(f"{opt}: {option_help[opt]}") | ||
| if combined_desc: | ||
| desc = "; ".join(combined_desc) | ||
| if desc: | ||
| for line in textwrap.wrap(desc, width=74): | ||
| print(f" {color(name, COLOR_NAME)} {line}") | ||
| else: | ||
| print(f" {color(name, COLOR_NAME)} (no description found)") | ||
|
|
||
| overall = build_command_summary(command, parts[1:], man_text, help_text) | ||
| if overall: | ||
| print(color("Overall", COLOR_TITLE)) | ||
| for line in textwrap.wrap(overall, width=80): | ||
| print(f" {line}") | ||
|
|
||
|
|
||
| def build_command_summary(command: str, args: List[str], man_text: Optional[str], help_text: Optional[str]) -> Optional[str]: | ||
| if man_text: | ||
| name_section = extract_name_section(man_text) | ||
| if name_section: | ||
| parts = name_section.split(" - ", 1) | ||
| if len(parts) == 2: | ||
| return f"Runs `{command}` to {parts[1].strip()} with arguments: {' '.join(args)}".strip() | ||
| if help_text: | ||
| first_line = help_text.strip().splitlines()[0] | ||
| return f"Runs `{command}`. {first_line}" | ||
| if args: | ||
| return f"Runs `{command}` with arguments: {' '.join(args)}" | ||
| return f"Runs `{command}`" | ||
|
|
||
|
|
||
| def describe_ansible_task(text: str) -> None: | ||
| if yaml is None: | ||
| print(color("YAML support is not available (PyYAML is missing).", COLOR_ERROR)) | ||
| return | ||
| try: | ||
| data = yaml.safe_load(text) | ||
| except Exception as exc: | ||
| print(color(f"Unable to parse YAML: {exc}", COLOR_ERROR)) | ||
| return | ||
|
|
||
| tasks: List[dict] | ||
| if isinstance(data, list): | ||
| tasks = [task for task in data if isinstance(task, dict)] | ||
| elif isinstance(data, dict): | ||
| if "tasks" in data and isinstance(data["tasks"], list): | ||
| tasks = [task for task in data["tasks"] if isinstance(task, dict)] | ||
| else: | ||
| tasks = [data] | ||
| else: | ||
| print(color("No Ansible tasks found in the provided YAML.", COLOR_ERROR)) | ||
| return | ||
|
|
||
| if not tasks: | ||
| print(color("No Ansible tasks found in the provided YAML.", COLOR_ERROR)) | ||
| return | ||
|
|
||
| for index, task in enumerate(tasks, start=1): | ||
| print(color(f"Task {index}", COLOR_TITLE)) | ||
| task_name = task.get("name") | ||
| if task_name: | ||
| print(f" {color(task_name, COLOR_NAME)}") | ||
|
|
||
| module_name, module_args = determine_module(task) | ||
| if not module_name: | ||
| print(" Could not determine module for this task.") | ||
| continue | ||
|
|
||
| print(color(" Module", COLOR_DESC)) | ||
| print(f" {color(module_name, COLOR_NAME)}") | ||
|
|
||
| doc_info = fetch_ansible_doc(module_name) | ||
| if doc_info: | ||
| short_desc = doc_info.get("short_description") | ||
| if short_desc: | ||
| print(color(" Summary", COLOR_DESC)) | ||
| for line in textwrap.wrap(short_desc, width=76): | ||
| print(f" {line}") | ||
|
|
||
| if module_args: | ||
| print(color(" Arguments", COLOR_DESC)) | ||
| options_doc = (doc_info or {}).get("options", {}) if doc_info else {} | ||
| for key, value in module_args.items(): | ||
| desc = "" | ||
| if isinstance(value, (dict, list)): | ||
| value_repr = json.dumps(value, indent=2) | ||
| else: | ||
| value_repr = str(value) | ||
| if isinstance(options_doc, dict) and key in options_doc: | ||
| option_entry = options_doc[key] | ||
| desc = option_entry.get("description") | ||
| if isinstance(desc, list): | ||
| desc = " ".join(d.strip() for d in desc) | ||
| if desc: | ||
| wrapped_desc = textwrap.wrap(desc, width=70) | ||
| if wrapped_desc: | ||
| print(f" {color(key, COLOR_NAME)} = {value_repr}") | ||
| for line in wrapped_desc: | ||
| print(f" {line}") | ||
| continue | ||
| print(f" {color(key, COLOR_NAME)} = {value_repr}") | ||
|
|
||
| if doc_info: | ||
| long_desc = doc_info.get("description") | ||
| if isinstance(long_desc, list) and long_desc: | ||
| print(color(" Overall", COLOR_DESC)) | ||
| for line in textwrap.wrap(" ".join(long_desc), width=76): | ||
| print(f" {line}") | ||
| print() | ||
|
|
||
|
|
||
| def determine_module(task: dict) -> Tuple[Optional[str], Dict[str, object]]: | ||
| for key, value in task.items(): | ||
| if key in CONTROL_TASK_KEYS: | ||
| continue | ||
| if isinstance(value, dict): | ||
| return key, value | ||
| if isinstance(value, list): | ||
| return key, {"items": value} | ||
| return key, {"value": value} | ||
| return None, {} | ||
|
|
||
|
|
||
| def fetch_ansible_doc(module: str) -> Optional[dict]: | ||
| ret, out, err = run_subprocess(["ansible-doc", "-j", module]) | ||
| if ret != 0 or not out.strip(): | ||
| if err.strip(): | ||
| print(color(err.strip(), COLOR_ERROR)) | ||
| return None | ||
| try: | ||
| doc = json.loads(out) | ||
| except json.JSONDecodeError as exc: | ||
| print(color(f"Failed to parse ansible-doc output: {exc}", COLOR_ERROR)) | ||
| return None | ||
| return doc.get(module) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| print(color("Explain - Offline command and Ansible task explainer", COLOR_TITLE)) | ||
| print("Paste commands or YAML. Submit with an empty line. Type 'exit' or 'quit' to leave.") | ||
|
|
||
| buffer: List[str] = [] | ||
| while True: | ||
| prompt = color("explain> ", COLOR_PROMPT) if not buffer else color("... ", COLOR_PROMPT) | ||
| try: | ||
| line = input(prompt) | ||
| except EOFError: | ||
| print() | ||
| break | ||
| except KeyboardInterrupt: | ||
| print() | ||
| break | ||
|
|
||
| if not buffer and line.strip().lower() in {"exit", "quit"}: | ||
| break | ||
|
|
||
| if not line.strip() and buffer: | ||
| text = "\n".join(buffer).strip() | ||
| buffer = [] | ||
| if not text: | ||
| continue | ||
| input_type = detect_input_type(text) | ||
| if input_type == "shell": | ||
| describe_command(text) | ||
| elif input_type == "ansible": | ||
| describe_ansible_task(text) | ||
| else: | ||
| print(color("Could not determine input type.", COLOR_ERROR)) | ||
| continue | ||
|
|
||
| if not line.strip(): | ||
| continue | ||
|
|
||
| buffer.append(line) | ||
|
|
||
| print(color("Goodbye!", COLOR_TITLE)) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| try: | ||
| main() | ||
| except KeyboardInterrupt: | ||
| print() | ||
| print(color("Goodbye!", COLOR_TITLE)) | ||
| sys.exit(0) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When rendering module arguments, any dict or list is passed directly to
json.dumpswithout error handling. YAMLsafe_loadconverts timestamps and other tagged values into Python objects (e.g.,datetime.date), which are not JSON serializable; encountering such a value raisesTypeErrorand exits the REPL. An Ansible task containingexpires: 2024-01-01inside a nested dict is enough to trigger the crash. Wrap the conversion in atry/exceptand fall back tostr(value)so unsupported types don’t terminate the CLI.Useful? React with 👍 / 👎.