'''
Copyright (C) 2024-2026 Mo Zhou <lumin@debian.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
'''
import difflib
import os
import pty
import re
import select
import shlex
import subprocess
import tempfile
from collections import deque

from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.text import Text

def render_diff(old_text: str, new_text: str, path: str,
                max_chars: int = 4000) -> str:
    """Return a unified diff between old_text and new_text, syntax
    highlighted with Pygments when available, ready to print raw to a
    terminal.  Returns "(no changes)" when the two are identical."""
    try:
        from pygments import highlight as _pyg_highlight
        from pygments.lexers import DiffLexer
        from pygments.formatters import TerminalTrueColorFormatter
        _has_pygments = True
    except ImportError:
        _has_pygments = False

    base = os.path.basename(path)
    diff_lines = list(difflib.unified_diff(
        old_text.splitlines(), new_text.splitlines(),
        fromfile=f"a/{base}", tofile=f"b/{base}", lineterm="",
    ))
    if not diff_lines:
        return "(no changes)"

    diff_text = "\n".join(diff_lines)
    if len(diff_text) > max_chars:
        diff_text = diff_text[:max_chars] + "\n... (diff truncated)"

    if _has_pygments:
        return _pyg_highlight(
            diff_text, DiffLexer(),
            TerminalTrueColorFormatter(style="monokai"),
        ).rstrip()
    return diff_text


def _short(value, limit: int = 50) -> str:
    """Render a value on a single line, truncating if it is too long.

    Used to keep tool-call summaries concise: newlines are collapsed and
    long blobs (e.g. file contents or edit strings) are cut with an
    ellipsis so the summary stays on one tidy line.
    """
    s = ' '.join(str(value).split())
    if len(s) > limit:
        s = s[:limit - 3] + '...'
    return s


def _diff_stat(old: str, new: str) -> tuple:
    """Return (added, removed) line counts between two strings, git --stat style."""
    added = removed = 0
    for line in difflib.ndiff(old.splitlines(), new.splitlines()):
        if line.startswith('+ '):
            added += 1
        elif line.startswith('- '):
            removed += 1
    return added, removed


class Tool:
    """Base class for all tools."""
    name = ""
    description = ""

    def __call__(self, *args, **kwargs):
        raise NotImplementedError

    parameters = {}

    def to_openai_tool(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": self.parameters,
            }
        }

    def needs_confirmation(self, args_dict: dict) -> tuple:
        """Return (needs_confirm: bool, reason: str) for the given call args.

        Subclasses should override this to require user confirmation for
        dangerous or destructive operations.  The default is no confirmation.
        """
        return False, ""

    def confirmation_context(self, args_dict: dict) -> str:
        """Return extra context to display when asking for confirmation.

        Subclasses can override this to show, e.g., the existing content
        that will be overwritten.  Return an empty string for no extra context.
        """
        return ""

    def summary(self, args_dict: dict) -> str:
        """Return a concise, one-line summary of this tool call for display.

        This is what the user sees in non-verbose mode instead of the full
        JSON arguments (which may include large blobs such as file contents
        or edit strings).  The generic default renders the arguments
        compactly, collapsing whitespace and truncating long values;
        subclasses override it for a prettier, tool-specific rendering.
        """
        parts = [f"{k}={_short(v)}" for k, v in args_dict.items()
                 if v is not None]
        return f"{self.name}({', '.join(parts)})"


class ManPageReader(Tool):
    """Tool to read man pages."""
    name = "man_page_reader"
    description = "Reads a man page for a given command."

    def summary(self, args_dict: dict) -> str:
        return f"man {args_dict.get('command', '')}"

    def __call__(self, command: str) -> str:
        try:
            # We use PAGER=cat to avoid interactive pagination
            result = subprocess.run(
                ["man", command],
                env={"PAGER": "cat", "MANPAGER": "cat", "LC_ALL": "C"},
                capture_output=True,
                text=True,
            )
            if result.returncode != 0:
                return f"Error reading man page for {command}: {result.stderr.strip()}"
            
            # Remove overstriking (e.g., '_\x08x' -> 'x', 'x\x08x' -> 'x')
            text = re.sub(r'.\x08', '', result.stdout)
            return text
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "command": {
            "type": "string",
            "description": "The command to read the manual page for.",
        }
    },
    "required": ["command"],
        }


class FileReader(Tool):
    """Tool to read file contents, optionally within a line number range."""
    name = "file_reader"
    description = ("Read the contents of a file. Optionally specify "
                    "start_line and/or end_line (1-indexed, inclusive) "
                    "to read only a range of lines.")

    def summary(self, args_dict: dict) -> str:
        path = args_dict.get("path", "")
        start, end = args_dict.get("start_line"), args_dict.get("end_line")
        if start is not None or end is not None:
            return f"read {path}:{start or 1}-{end or ''}"
        return f"read {path}"

    def __call__(self, path: str, start_line: int = None,
                 end_line: int = None) -> str:
        try:
            path = os.path.expanduser(path)
            if not os.path.isfile(path):
                return f"Error: '{path}' is not a file or does not exist."

            with open(path, "r") as f:
                lines = f.readlines()

            total = len(lines)

            # Validate / default the range (1-indexed, inclusive)
            if start_line is not None:
                start_line = int(start_line)
                if start_line < 1:
                    return "Error: start_line must be >= 1."
            else:
                start_line = 1

            if end_line is not None:
                end_line = int(end_line)
                if end_line < start_line:
                    return "Error: end_line must be >= start_line."
            else:
                end_line = total

            if start_line > total:
                return (f"Error: start_line ({start_line}) is beyond the "
                        f"end of the file ({total} lines).")

            # Clamp end_line to the actual file length
            end_line = min(end_line, total)

            selected = lines[start_line - 1 : end_line]
            numbered = [
                f"{start_line + i}: {line.rstrip()}"
                for i, line in enumerate(selected)
            ]
            header = (f"File: {path}  (showing lines {start_line}-{end_line}"
                      f" of {total})")
            return header + "\n" + "\n".join(numbered)
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Absolute or relative path to "
                           "the file to read.",
        },
        "start_line": {
            "type": "integer",
            "description": "First line to read (1-indexed, "
                           "inclusive). Defaults to 1.",
        },
        "end_line": {
            "type": "integer",
            "description": "Last line to read (1-indexed, "
                           "inclusive). Defaults to the last "
                           "line of the file.",
        },
    },
    "required": ["path"],
        }


class FileWriter(Tool):
    """Tool to write or replace content in a file, optionally within a
    line range."""
    name = "file_writer"
    description = ("Write content to a file. When start_line and end_line "
                    "are omitted the entire file is overwritten (or created). "
                    "When start_line and/or end_line are provided, only that "
                    "range of lines (1-indexed, inclusive) is replaced with "
                    "the new content. Prefer this tool for creating new files "
                    "or rewriting a file in full; to modify part of an "
                    "existing file, prefer file_editor (string-replacement), "
                    "which does not depend on line numbers that may be stale. "
                    "You should skip and continue working "
                    "on other files if the user rejects the write on a file.")

    def summary(self, args_dict: dict) -> str:
        path = args_dict.get("path", "")
        nlines = len(args_dict.get("content", "").splitlines())
        start, end = args_dict.get("start_line"), args_dict.get("end_line")
        if start is not None or end is not None:
            return f"write {path}:{start or 1}-{end or ''} ({nlines} lines)"
        return f"write {path} ({nlines} lines)"

    def needs_confirmation(self, args_dict: dict) -> tuple:
        path = os.path.expanduser(args_dict.get("path", ""))
        start = args_dict.get("start_line")
        end = args_dict.get("end_line")
        if start is None and end is None:
            if os.path.isfile(path):
                return True, f"Overwrite entire file: {path}"
            else:
                return True, f"Create new file: {path}"
        else:
            return True, f"Replace lines in: {path}"

    def confirmation_context(self, args_dict: dict) -> str:
        """Build a unified diff between old and new content, highlighted with
        Pygments, and return it as a pre-formatted string for the terminal."""

        path = os.path.expanduser(args_dict.get("path", ""))
        new_content = args_dict.get("content", "")
        start = args_dict.get("start_line")
        end = args_dict.get("end_line")

        # --- Determine old lines ----
        if os.path.isfile(path):
            try:
                with open(path, "r") as f:
                    old_lines = [l.rstrip("\n") for l in f.readlines()]
            except Exception as e:
                return f"(unable to read existing file: {e})"
        else:
            old_lines = []

        # --- Determine new lines (simulate what __call__ would produce) ---
        if start is None and end is None:
            # Full-file write / create
            new_lines = new_content.splitlines()
        else:
            # Range replacement
            total = len(old_lines)
            s = int(start) if start is not None else 1
            e = int(end) if end is not None else total
            e = min(e, total)

            new_lines = list(old_lines)
            new_lines[s - 1 : e] = new_content.splitlines()

        return render_diff("\n".join(old_lines), "\n".join(new_lines), path)

    def __call__(self, path: str, content: str, start_line: int = None,
                 end_line: int = None) -> str:
        try:
            path = os.path.expanduser(path)

            # Full-file write / create
            if start_line is None and end_line is None:
                os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
                with open(path, "w") as f:
                    f.write(content)
                line_count = content.count("\n") + (
                    0 if content.endswith("\n") else 1
                )
                return (f"Successfully wrote {line_count} lines to {path}.")

            # Range replacement
            if not os.path.isfile(path):
                return (f"Error: '{path}' does not exist. Cannot do a "
                        "range write on a non-existent file.")

            with open(path, "r") as f:
                lines = f.readlines()

            total = len(lines)

            if start_line is not None:
                start_line = int(start_line)
                if start_line < 1:
                    return "Error: start_line must be >= 1."
            else:
                start_line = 1

            if end_line is not None:
                end_line = int(end_line)
                if end_line < start_line:
                    return "Error: end_line must be >= start_line."
            else:
                end_line = total

            if start_line > total + 1:
                return (f"Error: start_line ({start_line}) is beyond the "
                        f"end of the file ({total} lines).")

            # Clamp end_line
            end_line = min(end_line, total)

            # Build the replacement lines, ensuring each ends with '\n'
            new_lines = content.splitlines(True)
            if new_lines and not new_lines[-1].endswith("\n"):
                new_lines[-1] += "\n"

            lines[start_line - 1 : end_line] = new_lines

            with open(path, "w") as f:
                f.writelines(lines)

            new_total = len(lines)
            return (f"Successfully replaced lines {start_line}-{end_line} "
                    f"in {path}. File now has {new_total} lines.")
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Absolute or relative path to "
                           "the file to write.",
        },
        "content": {
            "type": "string",
            "description": "The text content to write.",
        },
        "start_line": {
            "type": "integer",
            "description": "First line of the range to "
                           "replace (1-indexed, inclusive). "
                           "Omit for a full-file write.",
        },
        "end_line": {
            "type": "integer",
            "description": "Last line of the range to "
                           "replace (1-indexed, inclusive). "
                           "Omit for a full-file write.",
        },
    },
    "required": ["path", "content"],
        }


class FileEditor(Tool):
    """Tool to edit a file by exact string replacement (diff-based).

    This is the preferred way to modify an existing file: instead of
    addressing lines by number (which drift as soon as an earlier edit
    changes the line count), the caller supplies the exact text to find
    (old_string) and its replacement (new_string).  The edit is anchored to
    content, so it either applies to the intended spot or fails loudly."""

    name = "file_editor"
    description = (
        "Edit an existing file by replacing an exact string. Provide "
        "old_string (the exact text currently in the file, including "
        "whitespace and indentation) and new_string (its replacement). "
        "old_string must match exactly once, unless replace_all is true; "
        "if it is missing or ambiguous the edit fails without changing the "
        "file, so include enough surrounding context to make it unique. "
        "This is preferred over file_writer for modifying part of a file "
        "because it does not rely on line numbers. To delete text, use an "
        "empty new_string. Skip and continue on other files if the user "
        "rejects the edit."
    )

    def _load(self, path: str):
        """Return (old_content, error). error is None on success."""
        if not os.path.isfile(path):
            return None, f"Error: '{path}' is not a file or does not exist."
        try:
            with open(path, "r") as f:
                return f.read(), None
        except Exception as e:
            return None, f"Error: {str(e)}"

    def _compute(self, path: str, old_string: str, new_string: str,
                 replace_all: bool):
        """Validate and produce (new_content, error)."""
        old_content, err = self._load(path)
        if err:
            return None, err
        if old_string == new_string:
            return None, "Error: old_string and new_string are identical."
        count = old_content.count(old_string)
        if count == 0:
            return None, ("Error: old_string was not found in the file. It "
                          "must match the file contents exactly, including "
                          "whitespace and indentation.")
        if count > 1 and not replace_all:
            return None, (f"Error: old_string appears {count} times in the "
                          "file. Provide more surrounding context to make it "
                          "unique, or set replace_all=true to replace every "
                          "occurrence.")
        new_content = old_content.replace(old_string, new_string)
        return new_content, None

    def summary(self, args_dict: dict) -> str:
        path = args_dict.get("path", "")
        added, removed = _diff_stat(
            args_dict.get("old_string", ""),
            args_dict.get("new_string", ""),
        )
        return f"edit {path} (+{added} -{removed})"

    def needs_confirmation(self, args_dict: dict) -> tuple:
        path = os.path.expanduser(args_dict.get("path", ""))
        return True, f"Edit file: {path}"

    def confirmation_context(self, args_dict: dict) -> str:
        path = os.path.expanduser(args_dict.get("path", ""))
        old_content, err = self._load(path)
        if err:
            return err
        new_content, err = self._compute(
            path,
            args_dict.get("old_string", ""),
            args_dict.get("new_string", ""),
            args_dict.get("replace_all", False),
        )
        if err:
            return err
        return render_diff(old_content, new_content, path)

    def __call__(self, path: str, old_string: str, new_string: str,
                 replace_all: bool = False) -> str:
        try:
            path = os.path.expanduser(path)
            old_content, err = self._load(path)
            if err:
                return err
            new_content, err = self._compute(
                path, old_string, new_string, replace_all)
            if err:
                return err

            with open(path, "w") as f:
                f.write(new_content)

            n = (old_content.count(old_string)
                 if replace_all else 1)
            plural = "occurrence" if n == 1 else "occurrences"
            return (f"Successfully edited {path} "
                    f"({n} {plural} replaced).")
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Absolute or relative path to the file to edit.",
        },
        "old_string": {
            "type": "string",
            "description": "The exact text to find and replace, including "
                           "whitespace and indentation. Must be unique in "
                           "the file unless replace_all is true.",
        },
        "new_string": {
            "type": "string",
            "description": "The text to replace old_string with. Use an "
                           "empty string to delete the matched text.",
        },
        "replace_all": {
            "type": "boolean",
            "description": "If true, replace every occurrence of old_string. "
                           "Defaults to false (require a unique match).",
        },
    },
    "required": ["path", "old_string", "new_string"],
        }


class DirectoryLister(Tool):
    """Tool to list directory contents (like the ls command)."""
    name = "directory_lister"
    description = ("List the contents of a directory. Supports options for "
                    "showing hidden files, long format with details, and "
                    "recursive listing.")

    def summary(self, args_dict: dict) -> str:
        return f"ls {args_dict.get('path', '.')}"

    def __call__(self, path: str = ".", all: bool = False,
                 long: bool = False, recursive: bool = False) -> str:
        try:
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                return f"Error: '{path}' does not exist."
            if not os.path.isdir(path):
                return f"Error: '{path}' is not a directory."

            cmd = ["ls"]
            if all:
                cmd.append("-a")
            if long:
                cmd.append("-lh")
            if recursive:
                cmd.append("-R")
            cmd.append(path)

            result = subprocess.run(
                cmd, capture_output=True, text=True
            )
            if result.returncode != 0:
                return f"Error: {result.stderr.strip()}"
            return result.stdout
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Path to the directory to list. "
                           "Defaults to the current directory.",
        },
        "all": {
            "type": "boolean",
            "description": "If true, include hidden files "
                           "(those starting with '.'). "
                           "Defaults to false.",
        },
        "long": {
            "type": "boolean",
            "description": "If true, use long listing format "
                           "showing permissions, size, and "
                           "modification time. Defaults to "
                           "false.",
        },
        "recursive": {
            "type": "boolean",
            "description": "If true, list subdirectories "
                           "recursively. Defaults to false.",
        },
    },
    "required": [],
        }


class SbuildRunner(Tool):
    """Tool to build a Debian package using sbuild."""
    name = "sbuild_runner"
    description = ("Builds a Debian package using sbuild. By default it runs "
                   "sbuild in the current directory. You should prefer --no-clean-source by default.")

    def needs_confirmation(self, args_dict: dict) -> tuple:
        return False, ""

    def summary(self, args_dict: dict) -> str:
        dist = args_dict.get("dist")
        return f"sbuild {args_dict.get('path', '.')}" + (f" ({dist})" if dist else "")

    def __call__(self, path: str = ".", dist: str = None, arch: str = None, extra_args: str = None) -> str:


        try:
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                return f"Error: '{path}' does not exist."

            cmd = ["sbuild"]
            if dist:
                cmd.extend(["-d", dist])
            if arch:
                cmd.extend(["--arch", arch])
            
            if extra_args:
                cmd.extend(shlex.split(extra_args))
            
            if path != ".":
                cmd.append(path)
                
            log_fd, log_path = tempfile.mkstemp(prefix="sbuild_", suffix=".log")
            os.close(log_fd)
            

            master_fd, slave_fd = pty.openpty()
            
            with open(log_path, "w") as log_file:
                process = subprocess.Popen(
                    cmd, stdout=slave_fd, stderr=slave_fd, stdin=slave_fd, close_fds=True
                )
                os.close(slave_fd)
                

                console = Console()
                term_height = console.size.height
                max_lines = max(10, term_height - 2)
                
                output_buffer = ""
                output_lines = deque(maxlen=max_lines)
                
                with Live(
                    Panel("Starting sbuild...", title="sbuild", border_style="blue"),
                    refresh_per_second=10,
                    transient=True,
                    console=console
                ) as live:
                    while True:
                        try:
                            r, _, _ = select.select([master_fd], [], [], 0.1)
                        except Exception:
                            break
                            
                        if master_fd in r:
                            try:
                                chunk = os.read(master_fd, 4096)
                            except OSError:
                                break
                                
                            if not chunk:
                                break
                                
                            chunk_str = chunk.decode('utf-8', errors='replace')
                            log_file.write(chunk_str)
                            log_file.flush()
                            
                            output_buffer += chunk_str
                            lines = output_buffer.split('\n')
                            if len(lines) > 1:
                                for line in lines[:-1]:
                                    line = line.rstrip('\r')
                                    if '\r' in line:
                                        line = line.split('\r')[-1]
                                    output_lines.append(line)
                                output_buffer = lines[-1]
                                
                                display_text = "\n".join(list(output_lines) + [output_buffer])
                                live.update(Panel(Text.from_ansi(display_text), title="sbuild", border_style="blue"))
                        elif process.poll() is not None:
                            break
                            
                os.close(master_fd)
                process.wait()
            
            with open(log_path, "r") as f:
                content = f.read()
                
            out = content
            if len(out) > 4000:
                out = "...(truncated log)...\n" + out[-4000:]
                
            ret = f"Status: {process.returncode}\n"
            ret += f"Log path: {log_path}\n"
            if out:
                ret += f"STDOUT:\n{out}\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Path to the package source directory or .dsc file. Defaults to '.'.",
        },
        "dist": {
            "type": "string",
            "description": "Distribution to build for (e.g. 'unstable'). Maps to sbuild -d.",
        },
        "arch": {
            "type": "string",
            "description": "Architecture to build for (e.g. 'amd64'). Maps to sbuild --arch.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to sbuild.",
        },
    },
    "required": [],
        }


class FileFinder(Tool):
    """Tool to find files and directories (wraps the Unix find command)."""
    name = "file_finder"
    description = ("Search for files and directories using the Unix find "
                    "command. Supports filtering by name pattern, file type, "
                    "and max depth. An extra_args parameter is available for "
                    "any additional find options.")

    # Flags that can modify or execute on the filesystem
    _DANGEROUS_FLAGS = {
        "-delete", "-exec", "-execdir", "-ok", "-okdir",
    }

    def needs_confirmation(self, args_dict: dict) -> tuple:
        extra = args_dict.get("extra_args", "") or ""

        tokens = shlex.split(extra)
        found = [t for t in tokens if t in self._DANGEROUS_FLAGS]
        if found:
            flags = ", ".join(found)
            return True, f"Dangerous find flag(s) detected: {flags}"
        return False, ""

    def summary(self, args_dict: dict) -> str:
        path = args_dict.get("path", ".")
        name = args_dict.get("name")
        return f"find {_short(name)} in {path}" if name else f"find in {path}"

    def __call__(self, path: str = ".", name: str = None,
                 type: str = None, maxdepth: int = None,
                 extra_args: str = None) -> str:
        try:
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                return f"Error: '{path}' does not exist."

            cmd = ["find", path]

            if maxdepth is not None:
                cmd.extend(["-maxdepth", str(int(maxdepth))])
            if type is not None:
                cmd.extend(["-type", str(type)])
            if name is not None:
                cmd.extend(["-name", str(name)])
            if extra_args is not None:

                cmd.extend(shlex.split(extra_args))

            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=30
            )
            if result.returncode != 0 and result.stderr.strip():
                return f"Error: {result.stderr.strip()}"
            return result.stdout if result.stdout else "(no results)"
        except subprocess.TimeoutExpired:
            return "Error: find command timed out after 30 seconds."
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Starting directory for the "
                           "search. Defaults to '.'.",
        },
        "name": {
            "type": "string",
            "description": "Filename pattern to match "
                           "(supports shell wildcards, e.g. "
                           "'*.py'). Maps to find -name.",
        },
        "type": {
            "type": "string",
            "enum": ["f", "d", "l"],
            "description": "File type filter: 'f' for "
                           "regular files, 'd' for "
                           "directories, 'l' for symbolic "
                           "links.",
        },
        "maxdepth": {
            "type": "integer",
            "description": "Maximum directory depth to "
                           "descend. 0 means only the "
                           "starting path itself.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed "
                           "directly to find (e.g. "
                           "'-mtime -1 -size +1M').",
        },
    },
    "required": [],
        }

class LintianRunner(Tool):
    """Tool to run lintian on a Debian package."""
    name = "lintian_runner"
    description = ("Run lintian, a static analysis tool for Debian packages, "
                   "to dissect packages and report bugs and policy violations. "
                   "By default it checks the package in the current directory.")

    def summary(self, args_dict: dict) -> str:
        return f"lintian {args_dict.get('path', '.')}"

    def __call__(self, path: str = ".", info: bool = False, display_info: bool = False,
                 pedantic: bool = False, extra_args: str = None) -> str:

        try:
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                return f"Error: '{path}' does not exist."

            cmd = ["lintian"]
            if info:
                cmd.append("-i")
            if display_info:
                cmd.append("-I")
            if pedantic:
                cmd.append("--pedantic")
            
            if extra_args:
                cmd.extend(shlex.split(extra_args))
                
            cmd.append(path)

            console = Console()
            with console.status(f"Running lintian on {path}..."):
                result = subprocess.run(
                    cmd, capture_output=True, text=True
                )
            
            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
                
            if len(out) > 4000:
                out = out[:2000] + "\n...(truncated output)...\n" + out[-2000:]
                
            ret = f"Status: {result.returncode}\n"
            if out.strip():
                ret += f"Output:\n{out}\n"
            else:
                ret += "No output (no issues found).\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "path": {
            "type": "string",
            "description": "Path to the .changes, .deb, .dsc file or package source directory. Defaults to '.'.",
        },
        "info": {
            "type": "boolean",
            "description": "If true, print explanatory information about each problem (lintian -i).",
        },
        "display_info": {
            "type": "boolean",
            "description": "If true, display informational tags (lintian -I).",
        },
        "pedantic": {
            "type": "boolean",
            "description": "If true, display pedantic tags (lintian --pedantic).",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to lintian.",
        },
    },
    "required": [],
        }


class BashRunner(Tool):
    """Tool to execute arbitrary bash commands."""
    name = "bash_runner"
    description = "Execute an arbitrary bash command. This always requires user confirmation."

    def summary(self, args_dict: dict) -> str:
        return f"bash: {_short(args_dict.get('command', ''), 60)}"

    def needs_confirmation(self, args_dict: dict) -> tuple:
        cmd = args_dict.get("command", "")
        return True, f"Execute bash command: {cmd}"

    def __call__(self, command: str, cwd: str = ".") -> str:

        try:
            cwd = os.path.expanduser(cwd)
            if not os.path.exists(cwd):
                return f"Error: '{cwd}' does not exist."

            result = subprocess.run(
                command, shell=True, executable="/bin/bash",
                cwd=cwd, capture_output=True, text=True
            )
            
            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
                
            if len(out) > 4000:
                out = out[:2000] + "\n...(truncated output)...\n" + out[-2000:]
                
            ret = f"Status: {result.returncode}\n"
            if out.strip():
                ret += f"Output:\n{out}\n"
            else:
                ret += "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "command": {
            "type": "string",
            "description": "The bash command to execute.",
        },
        "cwd": {
            "type": "string",
            "description": "The current working directory. Defaults to '.'.",
        },
    },
    "required": ["command"],
        }


class CurlRunner(Tool):
    """Tool to execute curl commands."""
    name = "curl_runner"
    description = ("Execute a curl command to make HTTP requests. Plain GET/HEAD "
                   "requests run directly; requests that change server state "
                   "(other methods or a request body) or write to the local "
                   "filesystem require user confirmation.")

    # curl flags that write to the filesystem, upload data, override the
    # method, or read configuration from disk.
    _DANGEROUS_FLAGS = {
        "-o", "--output", "-O", "--remote-name", "--remote-name-all",
        "--output-dir", "--create-dirs", "-D", "--dump-header",
        "-T", "--upload-file", "-K", "--config",
        "-X", "--request",
    }

    _SAFE_METHODS = {"GET", "HEAD"}

    def _build_cmd(self, url: str, method: str = "GET", headers: list = None,
                   data: str = None, extra_args: str = None) -> list:
        cmd = ["curl", "-s", "-S"]
        if method != "GET":
            cmd.extend(["-X", method])
        if headers:
            for header in headers:
                cmd.extend(["-H", header])
        if data is not None:
            cmd.extend(["-d", data])
        if extra_args:
            cmd.extend(shlex.split(extra_args))
        cmd.append(url)
        return cmd

    def _risk_reasons(self, args_dict: dict) -> list:
        reasons = []
        method = (args_dict.get("method") or "GET").upper()
        if method not in self._SAFE_METHODS:
            reasons.append(f"HTTP method '{method}' may change server state")
        if args_dict.get("data") is not None:
            reasons.append("request sends a body (-d/--data)")
        extra = args_dict.get("extra_args", "") or ""
        try:
            tokens = shlex.split(extra)
        except ValueError:
            tokens = extra.split()
        found = [t for t in tokens if t in self._DANGEROUS_FLAGS]
        if found:
            reasons.append("dangerous curl flag(s): " + ", ".join(found))
        return reasons

    def needs_confirmation(self, args_dict: dict) -> tuple:
        reasons = self._risk_reasons(args_dict)
        if reasons:
            return True, "; ".join(reasons)
        return False, ""

    def confirmation_context(self, args_dict: dict) -> str:
        cmd = self._build_cmd(
            args_dict.get("url", ""),
            args_dict.get("method", "GET"),
            args_dict.get("headers"),
            args_dict.get("data"),
            args_dict.get("extra_args"),
        )
        return "Command to run:\n" + " ".join(shlex.quote(c) for c in cmd)

    def summary(self, args_dict: dict) -> str:
        return f"curl {args_dict.get('method', 'GET')} {args_dict.get('url', '')}"

    def __call__(self, url: str, method: str = "GET", headers: list = None, data: str = None, max_output_chars: int = 4000, extra_args: str = None) -> str:

        try:
            cmd = self._build_cmd(url, method, headers, data, extra_args)

            result = subprocess.run(
                cmd, capture_output=True, text=True
            )
            
            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
                
            limit = int(max_output_chars) if max_output_chars else 4000
            if len(out) > limit:
                half = limit // 2
                out = out[:half] + "\n...(truncated output)...\n" + out[-half:]
                
            ret = f"Status: {result.returncode}\n"
            if out.strip():
                ret += f"Output:\n{out}\n"
            else:
                ret += "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "url": {
            "type": "string",
            "description": "The URL to request.",
        },
        "method": {
            "type": "string",
            "description": "The HTTP method to use. Defaults to 'GET'.",
        },
        "headers": {
            "type": "array",
            "items": {"type": "string"},
            "description": "A list of HTTP headers to include (e.g. ['Content-Type: application/json']).",
        },
        "data": {
            "type": "string",
            "description": "Data to send in the request body.",
        },
        "max_output_chars": {
            "type": "integer",
            "description": "Maximum number of characters to "
                           "return before truncating the "
                           "output. Defaults to 4000.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to curl.",
        },
    },
    "required": ["url"],
        }


class RipgrepSearcher(Tool):
    """Tool to search file contents using ripgrep (rg)."""
    name = "ripgrep_searcher"
    description = ("Search file contents for a pattern using ripgrep (rg). "
                    "Supports literal and regex patterns, glob filtering, "
                    "case-insensitive search, context lines, file type "
                    "filters, and a files-only mode that lists matching "
                    "filenames without showing the matched lines.")

    def summary(self, args_dict: dict) -> str:
        return f"rg {_short(args_dict.get('query', ''))} in {args_dict.get('path', '.')}"

    def __call__(self, query: str, path: str = ".",
                 glob: str = None, case_insensitive: bool = False,
                 regex: bool = True, files_only: bool = False,
                 context_lines: int = None, max_count: int = None,
                 type_filter: str = None,
                 max_output_chars: int = 4000,
                 extra_args: str = None) -> str:

        try:
            path = os.path.expanduser(path)
            if not os.path.exists(path):
                return f"Error: '{path}' does not exist."

            cmd = ["rg", "--no-heading", "--line-number", "--color=never"]

            if not regex:
                cmd.append("--fixed-strings")
            if case_insensitive:
                cmd.append("--ignore-case")
            if files_only:
                cmd.append("--files-with-matches")
            if context_lines is not None:
                cmd.extend(["--context", str(int(context_lines))])
            if max_count is not None:
                cmd.extend(["--max-count", str(int(max_count))])
            if glob is not None:
                cmd.extend(["--glob", str(glob)])
            if type_filter is not None:
                cmd.extend(["--type", str(type_filter)])
            if extra_args is not None:
                cmd.extend(shlex.split(extra_args))

            cmd.append(query)
            cmd.append(path)

            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=30
            )

            # rg returns 1 for "no matches" (not an error)
            if result.returncode == 2:
                return f"Error: {result.stderr.strip()}"
            if result.returncode == 1:
                return "(no matches)"

            out = result.stdout
            if not out.strip():
                return "(no matches)"

            limit = int(max_output_chars) if max_output_chars else 4000
            if len(out) > limit:
                out = out[:limit] + "\n...(output truncated)..."
            return out
        except subprocess.TimeoutExpired:
            return "Error: ripgrep timed out after 30 seconds."
        except FileNotFoundError:
            return ("Error: 'rg' (ripgrep) is not installed. "
                    "Install it with: apt install ripgrep")
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "query": {
            "type": "string",
            "description": "The search pattern (regex by "
                           "default, or literal if regex "
                           "is false).",
        },
        "path": {
            "type": "string",
            "description": "File or directory to search in. "
                           "Defaults to '.'.",
        },
        "glob": {
            "type": "string",
            "description": "Glob pattern to filter files "
                           "(e.g. '*.py'). Prefix with '!' "
                           "to exclude (e.g. '!*.min.js').",
        },
        "case_insensitive": {
            "type": "boolean",
            "description": "If true, search case-"
                           "insensitively. Defaults to false.",
        },
        "regex": {
            "type": "boolean",
            "description": "If true (default), treat query "
                           "as a regex. If false, treat it "
                           "as a literal string.",
        },
        "files_only": {
            "type": "boolean",
            "description": "If true, only list filenames "
                           "that contain a match, without "
                           "showing the matched lines. "
                           "Defaults to false.",
        },
        "context_lines": {
            "type": "integer",
            "description": "Number of lines of context to "
                           "show before and after each match.",
        },
        "max_count": {
            "type": "integer",
            "description": "Maximum number of matches to "
                           "show per file.",
        },
        "type_filter": {
            "type": "string",
            "description": "Restrict search to a known file "
                           "type (e.g. 'py', 'js', 'c', "
                           "'html'). Maps to rg --type.",
        },
        "max_output_chars": {
            "type": "integer",
            "description": "Maximum number of characters to "
                           "return before truncating the "
                           "output. Defaults to 4000.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed "
                           "directly to rg.",
        },
    },
    "required": ["query"],
        }


class AptQuery(Tool):
    """Tool to query the APT package database (read-only)."""
    name = "apt_query"
    description = (
        "Query the APT package database (read-only). Useful for inspecting "
        "package metadata, versions, and dependency relationships in the "
        "Debian archive. Supported operations: 'show' (apt-cache show: package "
        "metadata), 'policy' (apt-cache policy: installed/candidate versions "
        "and priorities), 'depends' (apt-cache depends: dependencies), "
        "'rdepends' (apt-cache rdepends: reverse dependencies), 'search' "
        "(apt-cache search: full-text search by regex over names and "
        "descriptions), 'madison' (apt-cache madison: available versions per "
        "suite), 'file-search' (apt-file search: which package provides a "
        "given file path; requires apt-file), 'file-list' (apt-file list: "
        "files shipped by a package; requires apt-file)."
    )

    OPERATIONS = {
        "show": ["apt-cache", "show"],
        "policy": ["apt-cache", "policy"],
        "depends": ["apt-cache", "depends"],
        "rdepends": ["apt-cache", "rdepends"],
        "search": ["apt-cache", "search"],
        "madison": ["apt-cache", "madison"],
        "file-search": ["apt-file", "search"],
        "file-list": ["apt-file", "list"],
    }

    def summary(self, args_dict: dict) -> str:
        return f"apt {args_dict.get('operation', '')} {args_dict.get('argument', '')}".rstrip()

    def __call__(self, operation: str, argument: str, extra_args: str = None,
                 max_output_chars: int = 6000) -> str:
        try:
            if operation not in self.OPERATIONS:
                return (f"Error: unknown operation '{operation}'. "
                        f"Valid operations: {', '.join(self.OPERATIONS)}.")
            if not argument or not argument.strip():
                return "Error: 'argument' must not be empty."

            cmd = list(self.OPERATIONS[operation])
            if extra_args:
                cmd.extend(shlex.split(extra_args))
            cmd.append(argument)

            try:
                result = subprocess.run(
                    cmd, env={**os.environ, "LC_ALL": "C"},
                    capture_output=True, text=True
                )
            except FileNotFoundError:
                return (f"Error: '{cmd[0]}' is not installed. "
                        "Install the 'apt-file' package for file-search / "
                        "file-list operations.")

            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr

            limit = int(max_output_chars) if max_output_chars else 6000
            if len(out) > limit:
                half = limit // 2
                out = out[:half] + "\n...(truncated output)...\n" + out[-half:]

            ret = f"Status: {result.returncode}\n"
            if out.strip():
                ret += f"Output:\n{out}\n"
            else:
                ret += "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "operation": {
            "type": "string",
            "enum": ["show", "policy", "depends", "rdepends", "search",
                     "madison", "file-search", "file-list"],
            "description": "The query operation to perform.",
        },
        "argument": {
            "type": "string",
            "description": "The package name, search pattern, or file path to "
                           "query, depending on the operation.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to the "
                           "underlying apt-cache/apt-file command.",
        },
        "max_output_chars": {
            "type": "integer",
            "description": "Maximum number of characters to return before "
                           "truncating the output. Defaults to 6000.",
        },
    },
    "required": ["operation", "argument"],
        }


class DpkgQuery(Tool):
    """Tool to query the local dpkg database and .deb files (read-only)."""
    name = "dpkg_query"
    description = (
        "Query the local dpkg database and .deb files (read-only). Useful for "
        "inspecting installed packages on the current system. Supported "
        "operations: 'list-files' (dpkg-query -L: files installed by a "
        "package), 'search' (dpkg-query -S: which installed package owns a "
        "given file path), 'status' (dpkg-query -s: status and metadata of an "
        "installed package), 'list' (dpkg -l: list installed packages, "
        "optionally filtered by a glob pattern), 'contents' (dpkg-deb -c: list "
        "the contents of a .deb file given its path)."
    )

    OPERATIONS = {
        "list-files": (["dpkg-query", "-L"], True),
        "search": (["dpkg-query", "-S"], True),
        "status": (["dpkg-query", "-s"], True),
        "list": (["dpkg", "-l"], False),
        "contents": (["dpkg-deb", "-c"], True),
    }

    def summary(self, args_dict: dict) -> str:
        return f"dpkg {args_dict.get('operation', '')} {args_dict.get('argument') or ''}".rstrip()

    def __call__(self, operation: str, argument: str = None,
                 max_output_chars: int = 6000) -> str:
        try:
            if operation not in self.OPERATIONS:
                return (f"Error: unknown operation '{operation}'. "
                        f"Valid operations: {', '.join(self.OPERATIONS)}.")

            base, arg_required = self.OPERATIONS[operation]
            if arg_required and (not argument or not argument.strip()):
                return f"Error: operation '{operation}' requires an 'argument'."

            if operation == "contents":
                argument = os.path.expanduser(argument)

            cmd = list(base)
            if argument and argument.strip():
                cmd.append(argument)

            try:
                result = subprocess.run(
                    cmd, env={**os.environ, "LC_ALL": "C"},
                    capture_output=True, text=True
                )
            except FileNotFoundError:
                return f"Error: '{cmd[0]}' is not installed."

            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr

            limit = int(max_output_chars) if max_output_chars else 6000
            if len(out) > limit:
                half = limit // 2
                out = out[:half] + "\n...(truncated output)...\n" + out[-half:]

            ret = f"Status: {result.returncode}\n"
            if out.strip():
                ret += f"Output:\n{out}\n"
            else:
                ret += "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "operation": {
            "type": "string",
            "enum": ["list-files", "search", "status", "list", "contents"],
            "description": "The query operation to perform.",
        },
        "argument": {
            "type": "string",
            "description": "The package name, file path, or glob pattern to "
                           "query. Optional only for 'list'; required for all "
                           "other operations.",
        },
        "max_output_chars": {
            "type": "integer",
            "description": "Maximum number of characters to return before "
                           "truncating the output. Defaults to 6000.",
        },
    },
    "required": ["operation"],
        }


class DchRunner(Tool):
    """Tool to edit debian/changelog via dch (devscripts)."""
    name = "dch_runner"
    description = (
        "Add or modify entries in debian/changelog using dch (debchange). "
        "Runs non-interactively (no editor) by using the provided message. "
        "Supported operations: 'increment' (dch -i: new entry with an "
        "incremented version), 'newversion' (dch -v: new entry with an "
        "explicit version, supplied via 'version'), 'append' (dch -a: add a "
        "line to the current, unreleased entry), 'release' (dch -r: finalize "
        "the top entry for release, setting the distribution and timestamp). "
        "This modifies debian/changelog and requires user confirmation."
    )

    def needs_confirmation(self, args_dict: dict) -> tuple:
        op = args_dict.get("operation", "")
        return True, f"Modify debian/changelog (dch {op})"

    def confirmation_context(self, args_dict: dict) -> str:
        path = os.path.expanduser(args_dict.get("path", ".") or ".")
        changelog = os.path.join(path, "debian", "changelog")
        cmd = self._build_cmd(args_dict)
        ctx = "Command to run:\n" + " ".join(shlex.quote(c) for c in cmd)
        try:
            with open(changelog) as f:
                head = "".join(f.readlines()[:15])
            ctx += f"\n\nCurrent top of {changelog}:\n{head}"
        except OSError:
            ctx += f"\n\n(Could not read {changelog}.)"
        return ctx

    def _build_cmd(self, args_dict: dict) -> list:
        operation = args_dict.get("operation", "")
        cmd = ["dch"]
        if operation == "increment":
            cmd.append("-i")
        elif operation == "newversion":
            cmd.extend(["-v", args_dict.get("version") or ""])
        elif operation == "append":
            cmd.append("-a")
        elif operation == "release":
            cmd.append("-r")
        if args_dict.get("distribution"):
            cmd.extend(["--distribution", args_dict["distribution"]])
        if args_dict.get("extra_args"):
            cmd.extend(shlex.split(args_dict["extra_args"]))
        # A trailing message argument keeps dch from launching an editor.
        message = args_dict.get("message")
        if operation == "release":
            cmd.append(message if message else "")
        elif message:
            cmd.append(message)
        return cmd

    def summary(self, args_dict: dict) -> str:
        return f"dch {args_dict.get('operation', '')}".rstrip()

    def __call__(self, operation: str, message: str = None, version: str = None,
                 distribution: str = None, path: str = ".",
                 extra_args: str = None) -> str:
        try:
            if operation not in ("increment", "newversion", "append", "release"):
                return (f"Error: unknown operation '{operation}'. Valid: "
                        "increment, newversion, append, release.")
            if operation == "newversion" and not version:
                return "Error: operation 'newversion' requires 'version'."
            if operation in ("increment", "newversion", "append") and not message:
                return f"Error: operation '{operation}' requires 'message'."

            cwd = os.path.expanduser(path)
            if not os.path.isfile(os.path.join(cwd, "debian", "changelog")):
                return (f"Error: no debian/changelog found under '{cwd}'. "
                        "Run this from a package source tree.")

            args_dict = {"operation": operation, "message": message,
                         "version": version, "distribution": distribution,
                         "extra_args": extra_args}
            cmd = self._build_cmd(args_dict)

            # EDITOR/VISUAL are neutralized so dch can never block on an editor.
            env = {**os.environ, "LC_ALL": "C",
                   "EDITOR": "true", "VISUAL": "true"}
            try:
                result = subprocess.run(
                    cmd, env=env, cwd=cwd, capture_output=True,
                    text=True, timeout=60
                )
            except FileNotFoundError:
                return "Error: 'dch' is not installed (package: devscripts)."
            except subprocess.TimeoutExpired:
                return "Error: dch timed out after 60s."

            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
            if len(out) > 4000:
                out = out[:2000] + "\n...(truncated output)...\n" + out[-2000:]

            ret = f"Status: {result.returncode}\n"
            ret += f"Output:\n{out}\n" if out.strip() else "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "operation": {
            "type": "string",
            "enum": ["increment", "newversion", "append", "release"],
            "description": "The changelog operation to perform.",
        },
        "message": {
            "type": "string",
            "description": "The changelog entry text. Required for increment, "
                           "newversion, and append; optional for release.",
        },
        "version": {
            "type": "string",
            "description": "The new version string. Required for 'newversion'.",
        },
        "distribution": {
            "type": "string",
            "description": "Target distribution (dch --distribution), e.g. "
                           "'unstable' or 'experimental'.",
        },
        "path": {
            "type": "string",
            "description": "Package source directory containing debian/. "
                           "Defaults to '.'.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to dch.",
        },
    },
    "required": ["operation"],
        }


class QuiltRunner(Tool):
    """Tool to manage Debian patches with quilt."""
    name = "quilt_runner"
    description = (
        "Manage the Debian patch queue (debian/patches) with quilt. "
        "Read-only operations run directly: 'series' (list all patches), "
        "'applied' (list applied patches), 'unapplied' (list unapplied "
        "patches), 'top' (name of the topmost applied patch), 'header' (show a "
        "patch's header). State-changing operations require confirmation: "
        "'push' (apply patches; use all=true for -a), 'pop' (unapply patches; "
        "all=true for -a), 'new' (create a new patch named by 'argument'), "
        "'refresh' (regenerate the top patch after edits), 'import' (import an "
        "external patch file given by 'argument'), 'delete' (remove a patch). "
        "QUILT_PATCHES defaults to debian/patches."
    )

    READONLY_OPS = {"series", "applied", "unapplied", "top", "header"}
    ARG_REQUIRED_OPS = {"new", "import", "delete", "header"}
    ALL_OPS = READONLY_OPS | {"push", "pop", "new", "refresh", "import", "delete"}

    def needs_confirmation(self, args_dict: dict) -> tuple:
        op = args_dict.get("operation", "")
        if op in self.READONLY_OPS:
            return False, ""
        return True, f"Modify the patch queue (quilt {op})"

    def confirmation_context(self, args_dict: dict) -> str:
        cmd = self._build_cmd(args_dict)
        return "Command to run:\n" + " ".join(shlex.quote(c) for c in cmd)

    def _build_cmd(self, args_dict: dict) -> list:
        op = args_dict.get("operation", "")
        argument = args_dict.get("argument")
        cmd = ["quilt", op]
        if op in ("push", "pop") and args_dict.get("all"):
            cmd.append("-a")
        if args_dict.get("extra_args"):
            cmd.extend(shlex.split(args_dict["extra_args"]))
        if argument:
            cmd.append(argument)
        return cmd

    def summary(self, args_dict: dict) -> str:
        return f"quilt {args_dict.get('operation', '')} {args_dict.get('argument') or ''}".rstrip()

    def __call__(self, operation: str, argument: str = None, all: bool = False,
                 path: str = ".", extra_args: str = None) -> str:
        try:
            if operation not in self.ALL_OPS:
                return (f"Error: unknown operation '{operation}'. Valid: "
                        f"{', '.join(sorted(self.ALL_OPS))}.")
            if operation in self.ARG_REQUIRED_OPS and (not argument or not argument.strip()):
                return f"Error: operation '{operation}' requires an 'argument'."

            cwd = os.path.expanduser(path)
            if not os.path.isdir(cwd):
                return f"Error: '{cwd}' does not exist."

            if operation == "import" and argument:
                argument = os.path.expanduser(argument)

            args_dict = {"operation": operation, "argument": argument,
                         "all": all, "extra_args": extra_args}
            cmd = self._build_cmd(args_dict)

            env = {**os.environ, "LC_ALL": "C"}
            env.setdefault("QUILT_PATCHES", "debian/patches")
            try:
                result = subprocess.run(
                    cmd, env=env, cwd=cwd, capture_output=True,
                    text=True, timeout=60
                )
            except FileNotFoundError:
                return "Error: 'quilt' is not installed (package: quilt)."
            except subprocess.TimeoutExpired:
                return "Error: quilt timed out after 60s."

            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
            if len(out) > 4000:
                out = out[:2000] + "\n...(truncated output)...\n" + out[-2000:]

            ret = f"Status: {result.returncode}\n"
            ret += f"Output:\n{out}\n" if out.strip() else "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "operation": {
            "type": "string",
            "enum": ["series", "applied", "unapplied", "top", "header",
                     "push", "pop", "new", "refresh", "import", "delete"],
            "description": "The quilt operation to perform.",
        },
        "argument": {
            "type": "string",
            "description": "Patch name (new/delete/header) or path to a patch "
                           "file (import). Required for new, import, delete, "
                           "and header.",
        },
        "all": {
            "type": "boolean",
            "description": "For push/pop, operate on all patches (-a).",
        },
        "path": {
            "type": "string",
            "description": "Package source directory. Defaults to '.'.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to quilt.",
        },
    },
    "required": ["operation"],
        }


class DgetRunner(Tool):
    """Tool to download Debian source/binary packages with dget."""
    name = "dget_runner"
    description = (
        "Download Debian packages with dget (devscripts). Given a .dsc or "
        ".changes URL, downloads all referenced files and, by default, unpacks "
        "the source. Given a plain package name, fetches its current source "
        "from the archive. Downloads files into the working directory and "
        "accesses the network, so it requires user confirmation."
    )

    def needs_confirmation(self, args_dict: dict) -> tuple:
        target = args_dict.get("target", "")
        return True, f"Download package with dget: {target}"

    def confirmation_context(self, args_dict: dict) -> str:
        cmd = self._build_cmd(args_dict)
        return "Command to run:\n" + " ".join(shlex.quote(c) for c in cmd)

    def _build_cmd(self, args_dict: dict) -> list:
        cmd = ["dget", "-q"]
        if args_dict.get("download_only"):
            cmd.append("-d")
        if args_dict.get("extra_args"):
            cmd.extend(shlex.split(args_dict["extra_args"]))
        cmd.append(args_dict.get("target", ""))
        return cmd

    def summary(self, args_dict: dict) -> str:
        return f"dget {_short(args_dict.get('target', ''), 60)}"

    def __call__(self, target: str, download_only: bool = False,
                 path: str = ".", extra_args: str = None) -> str:
        try:
            if not target or not target.strip():
                return "Error: 'target' must not be empty."

            cwd = os.path.expanduser(path)
            if not os.path.isdir(cwd):
                return f"Error: '{cwd}' does not exist."

            args_dict = {"target": target, "download_only": download_only,
                         "extra_args": extra_args}
            cmd = self._build_cmd(args_dict)

            try:
                result = subprocess.run(
                    cmd, env={**os.environ, "LC_ALL": "C"}, cwd=cwd,
                    capture_output=True, text=True, timeout=600
                )
            except FileNotFoundError:
                return "Error: 'dget' is not installed (package: devscripts)."
            except subprocess.TimeoutExpired:
                return "Error: dget timed out after 600s."

            out = result.stdout
            if result.stderr:
                out += "\nSTDERR:\n" + result.stderr
            if len(out) > 4000:
                out = out[:2000] + "\n...(truncated output)...\n" + out[-2000:]

            ret = f"Status: {result.returncode}\n"
            ret += f"Output:\n{out}\n" if out.strip() else "No output.\n"
            return ret
        except Exception as e:
            return f"Error: {str(e)}"

    parameters = {
    "type": "object",
    "properties": {
        "target": {
            "type": "string",
            "description": "A .dsc/.changes URL, a bare package name, or any "
                           "URL to fetch.",
        },
        "download_only": {
            "type": "boolean",
            "description": "If true, download files without unpacking the "
                           "source (dget -d).",
        },
        "path": {
            "type": "string",
            "description": "Directory to download into. Defaults to '.'.",
        },
        "extra_args": {
            "type": "string",
            "description": "Additional arguments passed directly to dget.",
        },
    },
    "required": ["target"],
        }


AVAILABLE_TOOLS = [
    ManPageReader(),
    FileReader(),
    FileWriter(),
    FileEditor(),
    DirectoryLister(),
    FileFinder(),
    SbuildRunner(),
    LintianRunner(),
    BashRunner(),
    CurlRunner(),
    RipgrepSearcher(),
    AptQuery(),
    DpkgQuery(),
    DchRunner(),
    QuiltRunner(),
    DgetRunner(),
]

def get_tool_by_name(name: str) -> Tool:
    for tool in AVAILABLE_TOOLS:
        if tool.name == name:
            return tool
    return None

def get_openai_tools() -> list:
    return [tool.to_openai_tool() for tool in AVAILABLE_TOOLS]

