#!/usr/bin/env python3
#
#    corindo-cli, a simple REPL AI agent.
#    Copyright (C) 2026 Ying-Chun Liu (PaulLiu) <paulliu@debian.org>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU 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 General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import ipaddress
import json
import os
import pathlib
import readline
import signal
import socket
import subprocess
import sys
import tempfile
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
from openai import OpenAI


def load_config():
  """Loads configuration from ~/.config/corindo-cli/config.toml,

  with fallback to legacy ~/.debgpt/config.toml.
  """
  config_path = pathlib.Path.home() / ".config" / "corindo-cli" / "config.toml"

  if not config_path.exists():
    # Fallback to legacy path
    legacy_path = pathlib.Path.home() / ".debgpt" / "config.toml"
    if legacy_path.exists():
      config_path = legacy_path
    else:
      print(f"❌ Configuration file not found:")
      print(f"   - Tried: ~/.config/corindo-cli/config.toml")
      print(f"   - Tried: ~/.debgpt/config.toml")
      print(
          "Please create one of them and fill in 'openai_api_key',"
          " 'openai_base_url', and 'openai_model'."
      )
      sys.exit(1)

  try:
    with open(config_path, "rb") as f:
      return tomllib.load(f), config_path
  except Exception as e:
    print(f"❌ Failed to parse TOML config file ({config_path}): {e}")
    sys.exit(1)


# Load configuration and initialize the OpenAI client
config, loaded_path = load_config()
API_KEY = config.get("openai_api_key")
BASE_URL = config.get("openai_base_url")
MODEL_NAME = config.get("openai_model", "gpt-4o")

if not API_KEY:
  print("❌ Configuration error: Missing required field 'openai_api_key'")
  sys.exit(1)

client_kwargs = {"api_key": API_KEY}
if BASE_URL:
  client_kwargs["base_url"] = BASE_URL

client = OpenAI(**client_kwargs)


# Give up after this many prune-and-retry rounds instead of spinning forever.
MAX_CONTEXT_OVERFLOW_RETRIES = 3

# Tool outputs shorter than this are not worth pruning.
PRUNE_MIN_CHARS = 200


def prune_tool_outputs(messages):
  """Shrinks bulky tool outputs, preserving the system prompt and first turn.

  The newest message is never touched: it is the freshest piece of context and
  usually the reason the request was just made. Returns how many messages were
  actually shrunk, so the caller can detect that pruning no longer buys us
  anything and stop retrying.
  """
  last_index = len(messages) - 1
  pruned_count = 0
  for idx, m in enumerate(messages):
    if idx == 0 or idx < 10 or idx == last_index:
      continue
    if not isinstance(m, dict) or m.get("role") != "tool":
      continue
    content = m.get("content") or ""
    if len(content) > PRUNE_MIN_CHARS:
      m["content"] = (
          "[Pruned old tool output to resolve 413 error."
          f" Original length: {len(content)} chars]"
      )
      pruned_count += 1
  return pruned_count


# Helper to robustly call LLM and handle 413 Request Entity Too Large errors
def call_llm(messages, tools=None, tool_choice=None):
  """Calls OpenAI API and prunes old tool output if the context overflows.

  Pruning happens in place, so every assistant message keeps its tool_calls and
  every tool_calls entry keeps its matching tool response. If there is nothing
  left to prune, we stop instead of re-sending the very same request forever.
  """
  overflow_retries = 0
  while True:
    try:
      kwargs = {"model": MODEL_NAME, "messages": messages}
      if tools is not None:
        kwargs["tools"] = tools
      if tool_choice is not None:
        kwargs["tool_choice"] = tool_choice
      return client.chat.completions.create(**kwargs)
    except Exception as e:
      err_str = str(e)
      if "413" not in err_str and "Request Entity Too Large" not in err_str:
        raise

      print("\n⚠️ [Context Overflow] Server returned 413"
            " (Request Entity Too Large).")
      if len(messages) <= 1:
        raise

      if overflow_retries >= MAX_CONTEXT_OVERFLOW_RETRIES:
        raise RuntimeError(
            "Context is still too large after pruning old tool output"
            f" {overflow_retries} times. Use /save-session and start a new"
            " session."
        ) from e

      print("🧹 Pruning old tool output (preserving system prompt and first"
            " 5 turns)...")
      pruned_count = prune_tool_outputs(messages)
      if pruned_count == 0:
        raise RuntimeError(
            "Context is too large and there is no old tool output left to"
            " prune (the newest messages alone exceed the limit). Use"
            " /save-session and start a new session."
        ) from e

      overflow_retries += 1
      print(f"🔄 Pruned {pruned_count} tool output(s),"
            " retrying with optimized context...\n")


def describe_exit_status(returncode):
  """Formats a process exit status so the model can tell success from failure.

  Without this the model only ever sees the command's text output, so it has to
  guess: a failing build and a passing build look equally fine, and a command
  that prints nothing looks perfect.
  """
  if returncode is None:
    return "[exit status: unknown]"
  if returncode < 0:
    try:
      signal_name = signal.Signals(-returncode).name
    except ValueError:
      signal_name = "unknown signal"
    return f"[exit status: killed by signal {-returncode} ({signal_name})]"
  if returncode == 0:
    return "[exit status: 0 (success)]"
  return f"[exit status: {returncode} (failure)]"


# Strict user confirmation helper (requires explicit Y or N)
def get_user_confirmation(prompt_text: str) -> bool:
  while True:
    try:
      user_input = input(prompt_text).strip().upper()
      if user_input == "Y":
        return True
      elif user_input == "N":
        return False
      else:
        print(
            "⚠️ Invalid input: Please enter 'Y' or 'N' (case-insensitive)."
        )
    except (KeyboardInterrupt, EOFError):
      print("\nOperation cancelled by user.")
      return False


# Smart truncation helper to protect the model context window
def truncate_output(output: str, max_chars: int = 8000) -> str:
  """Truncates output by preserving the head and tail if it exceeds max_chars."""
  if len(output) <= max_chars:
    return output

  half = max_chars // 2
  head = output[:half]
  tail = output[-half:]

  return (
      f"{head}\n\n... [Output truncated: omitted approx"
      f" {len(output) - max_chars} characters in between] ...\n\n{tail}"
  )


# Scheme whitelist for fetch_webpage. urllib ships a FileHandler, so without
# this check a "url" of file:///home/you/.ssh/id_rsa would be served happily and
# would bypass every Y/N confirmation prompt in this program.
ALLOWED_URL_SCHEMES = ("http", "https")

# Ports used when the URL does not spell one out, needed to resolve the host.
DEFAULT_PORTS = {"http": 80, "https": 443}


def check_url_allowed(url):
  """Decides whether fetch_webpage may talk to this URL.

  Returns (True, "") when the URL is an http(s) URL whose host resolves to
  public addresses only, otherwise (False, reason).

  Two separate threats are covered here:
    * file://, data:, ftp:// etc. would read local resources, which is exactly
      what the confirmation prompts are supposed to gate.
    * loopback / private / link-local targets (127.0.0.1, 192.168.x.x,
      169.254.169.254 cloud metadata, ::1, ...) would let the agent poke at
      services that are not reachable from outside.

  Known limitation: the host is resolved once for this check and again by
  urllib when it connects, so a DNS server that answers differently the second
  time (DNS rebinding) could still slip through. Closing that would require
  pinning the resolved address into the connection.
  """
  try:
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    host = parsed.hostname
    port = parsed.port
  except ValueError as exc:
    return False, f"the URL cannot be parsed: {exc}"

  if scheme not in ALLOWED_URL_SCHEMES:
    shown = scheme or "(none)"
    return False, (
        f"scheme '{shown}' is not allowed, only http and https are"
        " (local file access is blocked on purpose)"
    )

  if not host:
    return False, "the URL has no host name"

  try:
    address_infos = socket.getaddrinfo(
        host,
        port or DEFAULT_PORTS[scheme],
        proto=socket.IPPROTO_TCP,
    )
  except socket.gaierror as exc:
    return False, f"host '{host}' cannot be resolved: {exc}"

  if not address_infos:
    return False, f"host '{host}' did not resolve to any address"

  for info in address_infos:
    try:
      ip = ipaddress.ip_address(info[4][0])
    except ValueError:
      return False, f"host '{host}' resolved to an unparsable address"
    # ::ffff:127.0.0.1 must be judged as the IPv4 address it wraps.
    if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
      ip = ip.ipv4_mapped
    if not ip.is_global or ip.is_multicast:
      return False, (
          f"host '{host}' resolves to the non-public address {ip}, which is"
          " blocked on purpose"
      )

  return True, ""


class PublicOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
  """Applies check_url_allowed() to every redirect target as well.

  Checking only the URL the model asked for is not enough: a public page is
  free to answer 302 Location: http://169.254.169.254/ and urllib would follow
  it. (Stock urllib also still permits ftp:// redirects.)
  """

  def redirect_request(self, req, fp, code, msg, headers, newurl):
    allowed, reason = check_url_allowed(newurl)
    if not allowed:
      print(f"🚫 Blocked redirect to {newurl}: {reason}")
      raise urllib.error.HTTPError(
          newurl, code, f"Blocked redirect: {reason}", headers, fp)
    return super().redirect_request(req, fp, code, msg, headers, newurl)


# Define function calling tools
tools = [
    {
        "type": "function",
        "function": {
            "name": "run_shell_command",
            "description": (
                "Executes a shell command, such as debuild, git status, cargo"
                " build, etc. stdout and stderr are merged into one stream, and"
                " the exit status is appended at the end as"
                " '[exit status: N (success|failure)]', so read that line before"
                " concluding whether the command worked; output is truncated to"
                " roughly 8000 characters. IMPORTANT: Do NOT run commands in the"
                " background using '&' (e.g., no 'cmd &'), as this breaks the REPL"
                " stdin stream and corrupts terminal interaction. Always run"
                " synchronously."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The bash command to execute",
                    }
                },
                "required": ["command"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": (
                "Reads content of a specified file path. Optionally accepts"
                " start_line and end_line (1-indexed) to read a specific range."
                " Output is truncated to roughly 8000 characters, keeping the"
                " head and the tail with the middle omitted, so pass"
                " start_line/end_line when reading a large file."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to read",
                    },
                    "start_line": {
                        "type": "integer",
                        "description": "Optional 1-indexed starting line number (inclusive)",
                    },
                    "end_line": {
                        "type": "integer",
                        "description": "Optional 1-indexed ending line number (inclusive)",
                    },
                },
                "required": ["filepath"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": (
                "Edits an existing text file by replacing an exact string."
                " Prefer this over write_file when changing a few lines: the"
                " rest of the file is left untouched. old_string must match"
                " the file byte for byte, including indentation, and must"
                " occur exactly once unless replace_all is true."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to edit",
                    },
                    "old_string": {
                        "type": "string",
                        "description": (
                            "The exact text to replace, including indentation."
                            " Include enough surrounding lines to make it"
                            " unique in the file."
                        ),
                    },
                    "new_string": {
                        "type": "string",
                        "description": (
                            "The replacement text. Pass an empty string to"
                            " delete the matched text."
                        ),
                    },
                    "replace_all": {
                        "type": "boolean",
                        "description": (
                            "Replace every occurrence instead of requiring a"
                            " unique match. Defaults to false."
                        ),
                    },
                },
                "required": ["filepath", "old_string", "new_string"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": (
                "Writes or overwrites content into a specified file path"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to write",
                    },
                    "content": {
                        "type": "string",
                        "description": "Content to write",
                    },
                },
                "required": ["filepath", "content"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "fetch_webpage",
            "description": (
                "Fetches and extracts the text content of a public webpage given a URL."
                " Only http:// and https:// URLs on public hosts are accepted;"
                " file:// and private/loopback addresses are refused."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "The public URL to fetch",
                    }
                },
                "required": ["url"],
            },
        },
    },
]


def iter_occurrences(haystack, needle):
  """Yields the index of every non-overlapping occurrence of needle."""
  start = 0
  while True:
    index = haystack.find(needle, start)
    if index < 0:
      return
    yield index
    start = index + len(needle)


def format_edit_preview(text, marker, max_lines=6):
  """Renders the first few lines of an edit so the prompt stays readable."""
  lines = text.splitlines() or [""]
  rendered = "\n".join(f"      {marker} {line}" for line in lines[:max_lines])
  if len(lines) > max_lines:
    rendered += f"\n      {marker} ... ({len(lines) - max_lines} more lines)"
  return rendered


def write_file_atomically(filepath, content):
  """Writes content next to filepath, then renames it into place.

  A plain open(filepath, "w") truncates first, so any failure halfway through
  leaves the original file destroyed. Writing a sibling temporary file and
  renaming it means the file is either fully old or fully new. The original
  mode (including the executable bit) is carried over.
  """
  directory = os.path.dirname(os.path.abspath(filepath))
  original_mode = os.stat(filepath).st_mode & 0o7777

  fd, temp_path = tempfile.mkstemp(
      dir=directory, prefix=".corindo-edit-", suffix=".tmp")
  try:
    with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
      f.write(content)
      f.flush()
      os.fsync(f.fileno())
    os.chmod(temp_path, original_mode)
    os.replace(temp_path, filepath)
  except BaseException:
    try:
      os.unlink(temp_path)
    except OSError:
      pass
    raise


class ToolDenied(Exception):
  """Raised when the user refuses a tool call.

  A dedicated exception is used instead of a magic string in the tool output,
  because that output is arbitrary content: a file that merely *mentions* the
  old "Execution cancelled" phrase used to be mistaken for a refusal, which
  silently dropped the remaining tool calls of the same batch.
  """


class ToolInterrupted(Exception):
  """Raised when the user stops a running command with Ctrl-C.

  Deliberately not a denial: the command was approved, the user just did not
  want to wait for it any more.
  """


# Commands that are refused outright, before the user is even asked.
#
# This is a speed bump for obvious slips, NOT a security boundary: text
# matching can never be one. It misses real destruction ("rm -rf /*",
# "dd of=/dev/sda", "curl ... | sh") and, while it used to be a substring
# match, it blocked innocent commands that merely mentioned a pattern
# ("grep -rn mkfs /usr/share/doc"). Matching the *whole* command after
# whitespace normalisation keeps the false positives near zero; the coverage
# stays intentionally tiny.
#
# A real boundary is a sandbox, not a list. See README note
# about running this agent inside one.
CATASTROPHIC_COMMANDS = frozenset({
    "rm -rf /",
    "rm -fr /",
    "rm -rf /*",
    "rm -rf --no-preserve-root /",
    "mkfs",
    ":(){ :|:& };:",
})


# Tool execution logic
def execute_tool(name: str, arguments: dict) -> str:
  if name == "run_shell_command":
    cmd = arguments.get("command", "")
    normalised_cmd = " ".join(cmd.split())
    if normalised_cmd in CATASTROPHIC_COMMANDS:
      print(f"\n🚫 Refused a command on the do-not-run list: {normalised_cmd}")
      return (
          f"Error: {normalised_cmd!r} is on the do-not-run list, so it was not"
          " executed. Ask the user to run it by hand if it is really intended."
      )

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to execute shell"
        f" command:\n  > {cmd}\nAllow execution? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied command execution.")

    print(f"\n🚀 Executing: {cmd}\n" + "-" * 40)
    process = None
    try:
      # Popen for real-time streaming, no timeout to support long builds
      # (e.g. debuild). errors="replace" keeps a stray non-UTF-8 byte in a
      # build log from killing the read loop and leaving the child running.
      process = subprocess.Popen(
          cmd,
          shell=True,
          stdout=subprocess.PIPE,
          stderr=subprocess.STDOUT,
          text=True,
          errors="replace",
      )

      output_lines = []
      for line in process.stdout:
        print(line, end="")
        output_lines.append(line)

      process.wait()
      print("-" * 40)

      full_output = "".join(output_lines)
      exit_status = describe_exit_status(process.returncode)
      print(f"↩️  {exit_status}")

      if not full_output.strip():
        # Empty output does not mean success: `false`, `test -f missing` and
        # `grep -q` all print nothing and can still fail.
        return f"Command produced no output.\n{exit_status}"

      # Smart truncation before sending back to LLM context. The status line is
      # appended afterwards so truncation can never cut it away.
      return f"{truncate_output(full_output, max_chars=8000)}\n{exit_status}"

    except KeyboardInterrupt:
      if process is not None and process.poll() is None:
        process.terminate()
      print("\n⚠️ Command interrupted by user (Ctrl + C).")
      raise ToolInterrupted() from None
    except Exception as e:
      return f"Execution error: {str(e)}"

  elif name == "write_file":
    filepath = arguments.get("filepath", "")
    content = arguments.get("content", "")

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to write/modify"
        f" file:\n  > {filepath}\nAllow file write? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied file write.")

    try:
      with open(filepath, "w", encoding="utf-8") as f:
        f.write(content)
      return f"Successfully wrote content to {filepath}"
    except Exception as e:
      return f"Error writing to file: {str(e)}"

  elif name == "edit_file":
    filepath = arguments.get("filepath", "")
    old_string = arguments.get("old_string", "")
    new_string = arguments.get("new_string", "")
    replace_all = bool(arguments.get("replace_all", False))

    if not filepath:
      return "Error: 'filepath' is required."
    if old_string == "":
      return (
          "Error: 'old_string' must not be empty, give the exact text to"
          " replace."
      )
    if old_string == new_string:
      return "Error: 'old_string' and 'new_string' are identical, nothing to do."

    try:
      with open(filepath, "r", encoding="utf-8", newline="") as f:
        original = f.read()
    except FileNotFoundError:
      return f"Error: no such file: {filepath}"
    except IsADirectoryError:
      return f"Error: {filepath} is a directory, not a file."
    except UnicodeDecodeError:
      return f"Error: {filepath} is not valid UTF-8 text, refusing to edit it."
    except OSError as exc:
      return f"Error reading {filepath}: {exc}"

    positions = list(iter_occurrences(original, old_string))
    if not positions:
      return (
          f"Error: the text to replace was not found in {filepath}. Read the"
          " file again and copy the exact text, including indentation."
      )
    if len(positions) > 1 and not replace_all:
      line_numbers = [
          original.count("\n", 0, index) + 1 for index in positions
      ]
      shown_lines = ", ".join(str(n) for n in line_numbers)
      return (
          f"Error: the text occurs {len(positions)} times in {filepath}"
          f" (lines {shown_lines}). Add more surrounding context so it is"
          " unique, or pass replace_all=true."
      )

    if replace_all:
      updated = original.replace(old_string, new_string)
    else:
      updated = original.replace(old_string, new_string, 1)

    first_line = original.count("\n", 0, positions[0]) + 1
    replaced_count = len(positions) if replace_all else 1

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to edit file:"
        f"\n  > {filepath} (line {first_line},"
        f" {replaced_count} replacement(s))"
        f"\n    replace:\n{format_edit_preview(old_string, '-')}"
        f"\n    with:\n{format_edit_preview(new_string, '+')}"
        f"\nAllow this edit? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied the file edit.")

    try:
      write_file_atomically(filepath, updated)
    except OSError as exc:
      return f"Error writing {filepath}: {exc}"

    if replaced_count == 1:
      where = f"at line {first_line}"
      quantity = "1 occurrence"
    else:
      where = f"from line {first_line}"
      quantity = f"{replaced_count} occurrences"
    return f"Successfully replaced {quantity} in {filepath} {where}."

  elif name == "read_file":
    filepath = arguments.get("filepath", "")
    start_line = arguments.get("start_line")
    end_line = arguments.get("end_line")

    try:
      with open(filepath, "r", encoding="utf-8") as f:
        lines = f.readlines()

      if start_line is not None or end_line is not None:
        start_idx = (start_line - 1) if (start_line is not None and start_line > 0) else 0
        end_idx = end_line if end_line is not None else len(lines)
        
        selected_lines = lines[start_idx:end_idx]
        actual_start = start_idx + 1
        actual_end = start_idx + len(selected_lines)
        
        header = f"[Lines {actual_start}-{actual_end} of {filepath} (total {len(lines)} lines)]\n"
        content = header + "".join(selected_lines)
        return truncate_output(content, max_chars=8000)
      else:
        return truncate_output("".join(lines), max_chars=8000)

    except Exception as e:
      return f"Error reading file: {str(e)}"

  elif name == "fetch_webpage":
    url = arguments.get("url", "")

    allowed, reason = check_url_allowed(url)
    if not allowed:
      print(f"\n🚫 Refused to fetch {url}: {reason}")
      return f"Error: refused to fetch '{url}' because {reason}."

    print(f"\n🌐 Fetching webpage: {url}\n" + "-" * 40)
    try:
      req = urllib.request.Request(
          url,
          headers={"User-Agent": "Mozilla/5.0 (compatible; CorindoCLI/1.0)"}
      )
      opener = urllib.request.build_opener(PublicOnlyRedirectHandler)
      with opener.open(req, timeout=15) as response:
        html_content = response.read().decode("utf-8", errors="ignore")

      class HTMLFilter(HTMLParser):
        def __init__(self):
          super().__init__()
          self.text = []
        def handle_data(self, data):
          self.text.append(data)

      parser = HTMLFilter()
      parser.feed(html_content)
      text_content = "".join(parser.text)
      cleaned_text = "\n".join([line.strip() for line in text_content.splitlines() if line.strip()])

      print("-" * 40)
      return truncate_output(cleaned_text, max_chars=8000)
    except Exception as e:
      return f"Error fetching webpage: {str(e)}"

  return f"Error: Unknown tool '{name}'"


# Session management helpers
def save_session(messages, filename):
  """Serializes and saves messages list to a JSON session file."""
  try:
    serializable_messages = []
    for m in messages:
      if hasattr(m, "model_dump"):
        serializable_messages.append(m.model_dump())
      elif hasattr(m, "dict"):
        serializable_messages.append(m.dict())
      elif isinstance(m, dict):
        serializable_messages.append(m)
      else:
        serializable_messages.append(dict(m))

    path = pathlib.Path(filename)
    with open(path, "w", encoding="utf-8") as f:
      json.dump(serializable_messages, f, ensure_ascii=False, indent=2)
    print(f"💾 Session successfully saved to {path}")
  except Exception as e:
    print(f"❌ Failed to save session: {e}")


def resume_session(filename):
  """Loads messages list from a JSON session file."""
  try:
    path = pathlib.Path(filename)
    if not path.exists():
      alt_path = pathlib.Path.home() / ".config" / "corindo-cli" / filename
      if alt_path.exists():
        path = alt_path
      else:
        print(f"❌ Session file not found: {filename}")
        return None

    with open(path, "r", encoding="utf-8") as f:
      data = json.load(f)
    print(f"📂 Session successfully loaded from {path}")
    return data
  except Exception as e:
    print(f"❌ Failed to resume session: {e}")
    return None


def print_help():
  """Prints available slash commands."""
  print("\n📖 Corindo CLI Help - Available Commands:")
  print("  /help                     Show this help message")
  print("  /save-session <filename>  Save current conversation session to file")
  print("  /resume <filename>        Resume a conversation session from file")
  print("  /exit, /quit              Exit the CLI agent\n")


# REPL Main Loop
def main():
  # Setup config and history directory under ~/.config/corindo-cli/
  config_dir = pathlib.Path.home() / ".config" / "corindo-cli"
  config_dir.mkdir(parents=True, exist_ok=True)
  history_file = config_dir / ".history"

  if history_file.exists():
    readline.read_history_file(history_file)

  print(f"🤖 Corindo CLI Agent started! Using model: {MODEL_NAME}")
  print(f"📁 Config loaded from: {loaded_path}")
  print("Type '/exit' or '/quit' to exit.")
  print("Type '/help' to view available commands.\n")

  messages = [
      {
          "role": "system",
          "content": (
              "You are a capable terminal and development assistant"
              " agent. You can read/write files, fetch web pages, and run shell commands (e.g.,"
              " debuild, git, cargo) to help the user complete software"
              " engineering tasks. Always reason clearly before taking"
              " actions."
          ),
      }
  ]

  while True:
    try:
      user_input = input("\n👉 You: ")
      if user_input.lower() in ["/exit", "/quit"]:
        break
      
      # Handle /help command
      if user_input.lower() == "/help":
        print_help()
        continue

      # Handle /save-session command
      if user_input.startswith("/save-session"):
        parts = user_input.split(maxsplit=1)
        if len(parts) < 2:
          print("❌ Usage: /save-session <filename>")
        else:
          filename = parts[1].strip()
          save_session(messages, filename)
        continue

      # Handle /resume command
      if user_input.startswith("/resume"):
        parts = user_input.split(maxsplit=1)
        if len(parts) < 2:
          print("❌ Usage: /resume <filename>")
        else:
          filename = parts[1].strip()
          loaded = resume_session(filename)
          if loaded is not None:
            messages = loaded
            print(f"✨ Resumed session with {len(messages)} messages loaded.")
        continue

      if not user_input.strip():
        continue

      messages.append({"role": "user", "content": user_input})

      # Ensure that the tool_denied state is reset at the start of
      # each new dialogue round.
      tool_denied = False

      response = call_llm(messages=messages, tools=tools, tool_choice="auto")

      response_message = response.choices[0].message
      messages.append(response_message)

      while response_message.tool_calls:
        tool_denied = False

        denied_in_batch = False
        interrupted_in_batch = False
        tool_interrupted = False

        for tool_call in response_message.tool_calls:
          function_name = tool_call.function.name

          # Every tool_call of this assistant message must be answered, even
          # the ones we never run: the API rejects the conversation with 400
          # if a tool_call is left without its matching tool message.
          if denied_in_batch or interrupted_in_batch:
            if denied_in_batch:
              skip_reason = (
                  "Skipped: the user denied an earlier tool call in this"
                  " batch."
              )
            else:
              skip_reason = (
                  "Skipped: the user interrupted an earlier command in this"
                  " batch."
              )
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": skip_reason,
            })
            continue

          try:
            function_args = json.loads(tool_call.function.arguments or "{}")
          except json.JSONDecodeError as exc:
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": (
                    f"Error: tool arguments are not valid JSON: {exc}"
                ),
            })
            continue

          print(
              f"⚙️  Calling tool: {function_name}("
              f"{json.dumps(function_args, ensure_ascii=False)})"
          )

          try:
            tool_output = execute_tool(function_name, function_args)
          except ToolDenied as exc:
            tool_output = f"Execution cancelled: {exc}"
            tool_denied = True
            denied_in_batch = True
          except ToolInterrupted:
            tool_output = (
                "Execution interrupted: the user stopped this command with"
                " Ctrl-C. This was not a denial."
            )
            tool_interrupted = True
            interrupted_in_batch = True

          messages.append({
              "role": "tool",
              "tool_call_id": tool_call.id,
              "name": function_name,
              "content": tool_output,
          })

        if tool_denied:
          response = call_llm(messages=messages)
          response_message = response.choices[0].message
          messages.append(response_message)

          # Ensure that the AI's response to refusals is printed.
          if response_message.content:
            print(f"\n🤖 Agent:\n{response_message.content}")

          break

        if tool_interrupted:
          # Being interrupted is not a refusal: keep the tools so the model can
          # react, for example retry with a narrower command. The user can
          # always interrupt again.
          response = call_llm(messages=messages, tools=tools, tool_choice="auto")
          response_message = response.choices[0].message
          messages.append(response_message)
          continue

        response = call_llm(messages=messages, tools=tools, tool_choice="auto")
        response_message = response.choices[0].message
        messages.append(response_message)

      if response_message.content and not tool_denied:
        print(f"\n🤖 Agent:\n{response_message.content}")

      readline.write_history_file(history_file)

    except KeyboardInterrupt:
      print("\nGoodbye!")
      break
    except Exception as e:
      print(f"\n❌ Error occurred: {e}")


if __name__ == "__main__":
  main()
