import hashlib
import json
import os
import shlex
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from harbor.agents.installed.base import BaseInstalledAgent, CliFlag, ExecInput
from harbor.models.agent.context import AgentContext
from harbor.models.agent.name import AgentName


class ClineCli(BaseInstalledAgent):
    """
    Cline CLI agent for Harbor.
    Based on the TerminalBench Cline agent and Harbor's Cursor CLI pattern.
    Updated for new Cline CLI from bee/cli branch.

    Supports custom builds via agent kwargs:
      --agent-kwarg tarball-url=<url>           Pre-built CLI tarball URL (from pack-cli.yml workflow)
      --agent-kwarg github-user=<username>      GitHub user/org that owns the Cline fork
      --agent-kwarg commit-hash=<ref>           Branch, tag, or commit hash (default: main)
      --agent-kwarg cline-version=<version>     npm version to install (e.g., nightly, 3.57.1)
      --agent-kwarg thinking=<tokens>           Passes --thinking <tokens> to Cline CLI
      --agent-kwarg timeout=<seconds>           Passes -t <seconds> to Cline CLI
      --agent-kwarg timeout-sec=<seconds>       Alias of timeout
      --agent-kwarg cline-timeout-sec=<seconds> Alias of timeout
      --agent-kwarg reasoning-effort=<effort>   Passes --reasoning-effort where
                                                 effort is none|low|medium|high|xhigh
      --agent-kwarg double-check-completion=<bool> Passes
                                                 --double-check-completion when true
      --agent-kwarg double-check-completions=<bool> Backward-compatible alias
      --agent-kwarg max-consecutive-mistakes=<int> Passes
                                                 --max-consecutive-mistakes <int>

    Snake_case aliases are also accepted (tarball_url, github_user, commit_hash,
    cline_version, reasoning_effort, double_check_completion, max_consecutive_mistakes,
    timeout_sec, cline_timeout_sec).

    Priority: tarball_url > github_user+commit_hash > cline@nightly

    tarball_url is an optional path for pre-built CLI installs when you have
    access to the pack-cli.yml workflow or a published tarball URL.

    When github_user is provided, the install script clones from
    github.com/<github_user>/cline and checks out <commit_hash>.
    Otherwise, it installs cline@nightly from npm (default behavior).
    """

    CLI_FLAGS = [
        CliFlag(
            "thinking",
            cli="--thinking",
            type="int",
        ),
        CliFlag(
            "reasoning_effort",
            cli="--reasoning-effort",
            type="enum",
            choices=["none", "low", "medium", "high", "xhigh"],
        ),
        CliFlag(
            "double_check_completion",
            cli="--double-check-completion",
            type="bool",
        ),
        CliFlag(
            "max_consecutive_mistakes",
            cli="--max-consecutive-mistakes",
            type="int",
        ),
    ]

    def __init__(
        self,
        logs_dir: Path,
        tarball_url: str | None = None,
        github_user: str | None = None,
        commit_hash: str | None = None,
        cline_version: str | None = None,
        thinking: int | str | None = None,
        timeout: int | float | str | None = None,
        timeout_sec: int | float | str | None = None,
        cline_timeout_sec: int | float | str | None = None,
        agent_timeout_sec: int | float | str | None = None,
        reasoning_effort: str | None = None,
        double_check_completion: bool | str | None = None,
        max_consecutive_mistakes: int | str | None = None,
        *args,
        **kwargs,
    ):
        # Normalize common kebab-case aliases from --agent-kwarg key=value.
        if tarball_url is None:
            tarball_url = kwargs.pop("tarball-url", None)
        else:
            kwargs.pop("tarball-url", None)
        if github_user is None:
            github_user = kwargs.pop("github-user", None)
        else:
            kwargs.pop("github-user", None)

        if commit_hash is None:
            commit_hash = kwargs.pop("commit-hash", None)
        else:
            kwargs.pop("commit-hash", None)

        if cline_version is None:
            cline_version = kwargs.pop("cline-version", None)
        else:
            kwargs.pop("cline-version", None)

        if timeout is None:
            timeout = kwargs.pop("timeout-seconds", None)
        else:
            kwargs.pop("timeout-seconds", None)

        if timeout_sec is None:
            timeout_sec = kwargs.pop("timeout-sec", None)
        else:
            kwargs.pop("timeout-sec", None)

        if cline_timeout_sec is None:
            cline_timeout_sec = kwargs.pop("cline-timeout-sec", None)
            if cline_timeout_sec is None:
                cline_timeout_sec = kwargs.pop("cline-timeout", None)
        else:
            kwargs.pop("cline-timeout-sec", None)
            kwargs.pop("cline-timeout", None)

        # Normalize kebab-case aliases for descriptor-managed params
        if reasoning_effort is None:
            reasoning_effort = kwargs.pop("reasoning-effort", None)
        else:
            kwargs.pop("reasoning-effort", None)

        if double_check_completion is None:
            double_check_completion = kwargs.pop("double-check-completion", None)
            if double_check_completion is None:
                double_check_completion = kwargs.pop("double_check_completions", None)
            if double_check_completion is None:
                double_check_completion = kwargs.pop("double-check-completions", None)
        else:
            kwargs.pop("double-check-completion", None)
            kwargs.pop("double_check_completions", None)
            kwargs.pop("double-check-completions", None)

        if max_consecutive_mistakes is None:
            max_consecutive_mistakes = kwargs.pop("max-consecutive-mistakes", None)
        else:
            kwargs.pop("max-consecutive-mistakes", None)

        # Pass descriptor-managed params through to base class for coercion/validation
        super().__init__(
            logs_dir,
            *args,
            thinking=thinking,
            reasoning_effort=reasoning_effort,
            double_check_completion=double_check_completion,
            max_consecutive_mistakes=max_consecutive_mistakes,
            **kwargs,
        )

        # Post-resolution validation: non-negative checks
        thinking_val = self._resolved_flags.get("thinking")
        if thinking_val is not None and thinking_val < 0:
            raise ValueError(
                f"Invalid value for 'thinking': {thinking_val}. Must be >= 0."
            )
        max_mistakes_val = self._resolved_flags.get("max_consecutive_mistakes")
        if max_mistakes_val is not None and max_mistakes_val < 0:
            raise ValueError(
                f"Invalid value for 'max_consecutive_mistakes': {max_mistakes_val}. Must be >= 0."
            )

        # Default to cline/cline repo if commit_hash is provided without github_user
        if commit_hash and not github_user:
            github_user = "cline"
        self._tarball_url = tarball_url
        self._github_user = github_user
        self._commit_hash = commit_hash or "main"
        self._cline_version = cline_version

        self._harbor_agent_timeout_sec = self._parse_timeout_seconds(
            agent_timeout_sec, field_name="agent_timeout_sec"
        )
        timeout_sources = [
            ("cline_timeout_sec", cline_timeout_sec),
            ("timeout_sec", timeout_sec),
            ("timeout", timeout),
        ]
        explicit_timeout = next(
            (value for _, value in timeout_sources if value is not None), None
        )
        if explicit_timeout is not None:
            source_name = next(
                name for name, value in timeout_sources if value is not None
            )
            self._cline_timeout_sec = self._parse_timeout_seconds(
                explicit_timeout, field_name=source_name
            )
        else:
            self._cline_timeout_sec = self._harbor_agent_timeout_sec

    @staticmethod
    def _parse_timeout_seconds(
        raw_timeout: int | float | str | None, field_name: str
    ) -> int | None:
        if raw_timeout is None:
            return None

        if isinstance(raw_timeout, bool):
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
            )

        timeout_value: int
        if isinstance(raw_timeout, int):
            timeout_value = raw_timeout
        elif isinstance(raw_timeout, float):
            if not raw_timeout.is_integer():
                raise ValueError(
                    f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                )
            timeout_value = int(raw_timeout)
        elif isinstance(raw_timeout, str):
            normalized_timeout = raw_timeout.strip()
            if not normalized_timeout:
                raise ValueError(
                    f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                )
            try:
                timeout_value = int(normalized_timeout)
            except ValueError as exc:
                try:
                    timeout_float = float(normalized_timeout)
                except ValueError:
                    raise ValueError(
                        f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                    ) from exc
                if not timeout_float.is_integer():
                    raise ValueError(
                        f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
                    ) from exc
                timeout_value = int(timeout_float)
        else:
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be a positive integer."
            )

        if timeout_value <= 0:
            raise ValueError(
                f"Invalid {field_name} value: '{raw_timeout}'. Must be > 0 seconds."
            )

        return timeout_value

    @staticmethod
    def name() -> str:
        return AgentName.CLINE_CLI.value

    def get_version_command(self) -> str | None:
        return ". ~/.nvm/nvm.sh 2>/dev/null; cline --version || cline version"

    @property
    def _install_agent_template_path(self) -> Path:
        return Path(__file__).parent / "install-cline.sh.j2"

    @property
    def _template_variables(self) -> dict[str, str]:
        variables = super()._template_variables
        if self._tarball_url:
            variables["tarball_url"] = self._tarball_url
        if self._github_user:
            variables["github_user"] = self._github_user
            variables["commit_hash"] = self._commit_hash
        if self._cline_version:
            variables["cline_version"] = self._cline_version
        return variables

    def _setup_env(self) -> dict[str, str]:
        setup_env = super()._setup_env()
        for token_env_var in ("GITHUB_TOKEN", "GH_TOKEN"):
            token_value = os.environ.get(token_env_var)
            if token_value:
                setup_env[token_env_var] = token_value
        return setup_env

    @staticmethod
    def _coerce_int(value: Any) -> int | None:
        if value is None or isinstance(value, bool):
            return None
        if isinstance(value, int):
            return value
        if isinstance(value, float):
            if value.is_integer():
                return int(value)
            return None
        if isinstance(value, str):
            normalized = value.strip()
            if not normalized:
                return None
            try:
                parsed = float(normalized)
            except ValueError:
                return None
            if parsed.is_integer():
                return int(parsed)
        return None

    @staticmethod
    def _coerce_float(value: Any) -> float | None:
        if value is None or isinstance(value, bool):
            return None
        if isinstance(value, (int, float)):
            return float(value)
        if isinstance(value, str):
            normalized = value.strip()
            if not normalized:
                return None
            try:
                return float(normalized)
            except ValueError:
                return None
        return None

    def _load_cline_json_records(self) -> list[dict[str, Any]]:
        output_path = self.logs_dir / "cline.txt"
        if not output_path.exists():
            return []

        records: list[dict[str, Any]] = []
        for raw_line in output_path.read_text(
            encoding="utf-8", errors="replace"
        ).splitlines():
            line = raw_line.strip()
            if not line or not line.startswith("{"):
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(record, dict):
                records.append(record)
        return records

    @staticmethod
    def _extract_task_id_from_records(records: list[dict[str, Any]]) -> str | None:
        for record in reversed(records):
            if record.get("type") != "task_started":
                continue

            raw_task_id = record.get("taskId")
            if raw_task_id is None:
                continue

            normalized_task_id = str(raw_task_id).strip()
            if normalized_task_id:
                return normalized_task_id

        return None

    def _load_task_history_entries(self) -> list[dict[str, Any]]:
        task_history_path = self.logs_dir / "taskHistory.json"
        if not task_history_path.exists():
            return []

        try:
            payload = json.loads(
                task_history_path.read_text(encoding="utf-8", errors="replace")
            )
        except json.JSONDecodeError:
            return []

        if not isinstance(payload, list):
            return []

        return [entry for entry in payload if isinstance(entry, dict)]

    def _extract_usage_from_run_result(
        self, records: list[dict[str, Any]]
    ) -> dict[str, int | float | None] | None:
        for record in reversed(records):
            if record.get("type") != "run_result":
                continue

            usage = record.get("usage")
            if not isinstance(usage, dict):
                continue

            input_tokens = self._coerce_int(usage.get("inputTokens"))
            output_tokens = self._coerce_int(usage.get("outputTokens"))
            cache_read_tokens = self._coerce_int(usage.get("cacheReadTokens"))
            cache_write_tokens = self._coerce_int(usage.get("cacheWriteTokens"))
            total_cost = self._coerce_float(usage.get("totalCost"))
            if total_cost is None:
                total_cost = self._coerce_float(usage.get("cost"))

            if (
                input_tokens is None
                and output_tokens is None
                and cache_read_tokens is None
                and cache_write_tokens is None
                and total_cost is None
            ):
                continue

            return {
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "cache_read_tokens": cache_read_tokens,
                "cache_write_tokens": cache_write_tokens,
                "cost_usd": total_cost,
            }
        return None

    def _extract_usage_from_agent_events(
        self, records: list[dict[str, Any]]
    ) -> dict[str, int | float | None] | None:
        total_input_tokens: int | None = None
        total_output_tokens: int | None = None
        total_cost_usd: float | None = None
        summed_turn_cost: float = 0.0
        saw_turn_cost = False

        cache_read_tokens = 0
        cache_write_tokens = 0
        saw_cache_tokens = False

        for record in records:
            if record.get("type") != "agent_event":
                continue

            event = record.get("event")
            if not isinstance(event, dict) or event.get("type") != "usage":
                continue

            maybe_total_input = self._coerce_int(event.get("totalInputTokens"))
            if maybe_total_input is not None:
                total_input_tokens = maybe_total_input

            maybe_total_output = self._coerce_int(event.get("totalOutputTokens"))
            if maybe_total_output is not None:
                total_output_tokens = maybe_total_output

            maybe_total_cost = self._coerce_float(event.get("totalCost"))
            if maybe_total_cost is not None:
                total_cost_usd = maybe_total_cost
            else:
                maybe_turn_cost = self._coerce_float(event.get("cost"))
                if maybe_turn_cost is not None:
                    summed_turn_cost += maybe_turn_cost
                    saw_turn_cost = True

            maybe_cache_read = self._coerce_int(event.get("cacheReadTokens"))
            if maybe_cache_read is not None:
                cache_read_tokens += maybe_cache_read
                saw_cache_tokens = True

            maybe_cache_write = self._coerce_int(event.get("cacheWriteTokens"))
            if maybe_cache_write is not None:
                cache_write_tokens += maybe_cache_write
                saw_cache_tokens = True

        if (
            total_input_tokens is None
            and total_output_tokens is None
            and total_cost_usd is None
            and not saw_turn_cost
            and not saw_cache_tokens
        ):
            return None

        if total_cost_usd is None and saw_turn_cost:
            total_cost_usd = summed_turn_cost

        return {
            "input_tokens": total_input_tokens,
            "output_tokens": total_output_tokens,
            "cache_read_tokens": cache_read_tokens if saw_cache_tokens else None,
            "cache_write_tokens": cache_write_tokens if saw_cache_tokens else None,
            "cost_usd": total_cost_usd,
        }

    def _extract_usage_from_task_history(
        self, records: list[dict[str, Any]]
    ) -> dict[str, int | float | str | None] | None:
        entries = self._load_task_history_entries()
        if not entries:
            return None

        selected_entry: dict[str, Any] | None = None
        task_id = self._extract_task_id_from_records(records)

        if task_id is not None:
            for entry in reversed(entries):
                entry_id = str(entry.get("id", "")).strip()
                if entry_id == task_id:
                    selected_entry = entry
                    break

        if selected_entry is None:
            selected_entry = entries[-1]

        input_tokens = self._coerce_int(selected_entry.get("tokensIn"))
        output_tokens = self._coerce_int(selected_entry.get("tokensOut"))
        cache_read_tokens = self._coerce_int(selected_entry.get("cacheReads"))
        cache_write_tokens = self._coerce_int(selected_entry.get("cacheWrites"))
        total_cost = self._coerce_float(selected_entry.get("totalCost"))

        if (
            input_tokens is None
            and output_tokens is None
            and cache_read_tokens is None
            and cache_write_tokens is None
            and total_cost is None
        ):
            return None

        selected_task_id = str(selected_entry.get("id", "")).strip() or None

        return {
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "cache_read_tokens": cache_read_tokens,
            "cache_write_tokens": cache_write_tokens,
            "cost_usd": total_cost,
            "task_id": selected_task_id,
        }

    def populate_context_post_run(self, context: AgentContext) -> None:
        records = self._load_cline_json_records()

        usage_source = "cline-json"
        usage = self._extract_usage_from_run_result(records)
        if usage is None:
            usage = self._extract_usage_from_agent_events(records)
        if usage is None:
            usage = self._extract_usage_from_task_history(records)
            usage_source = "cline-task-history"
        if usage is None:
            return

        input_tokens = usage.get("input_tokens")
        output_tokens = usage.get("output_tokens")
        cache_read_tokens = usage.get("cache_read_tokens")
        cache_write_tokens = usage.get("cache_write_tokens")
        cost_usd = usage.get("cost_usd")

        if isinstance(input_tokens, int):
            context.n_input_tokens = input_tokens
        if isinstance(output_tokens, int):
            context.n_output_tokens = output_tokens

        if isinstance(cache_read_tokens, int) or isinstance(cache_write_tokens, int):
            context.n_cache_tokens = (
                cache_read_tokens if isinstance(cache_read_tokens, int) else 0
            ) + (cache_write_tokens if isinstance(cache_write_tokens, int) else 0)

        if isinstance(cost_usd, float):
            context.cost_usd = cost_usd

        if any(
            value is not None
            for value in (
                input_tokens,
                output_tokens,
                cache_read_tokens,
                cache_write_tokens,
                cost_usd,
            )
        ):
            usage_metadata = {
                "source": usage_source,
                "cache_read_tokens": cache_read_tokens,
                "cache_write_tokens": cache_write_tokens,
            }
            if usage_source == "cline-task-history":
                task_id = usage.get("task_id")
                if isinstance(task_id, str):
                    usage_metadata["task_id"] = task_id
            existing_metadata = context.metadata or {}
            context.metadata = {
                **existing_metadata,
                "cline_usage": usage_metadata,
            }

    @staticmethod
    def _infer_task_id_from_trial_id(trial_id: str | None) -> str | None:
        """
        Trial names are typically <task_id>__<suffix>. If available, infer task_id.
        """
        if not trial_id or "__" not in trial_id:
            return None

        task_id = trial_id.split("__", 1)[0]
        return task_id or None

    def _build_pre_run_metadata(
        self,
        instruction: str,
        provider: str,
        model: str,
        cline_provider: str,
    ) -> dict[str, str | int | bool | None]:
        trial_id = os.environ.get("HARBOR_TRIAL_ID") or os.environ.get("TRIAL_ID")
        if not trial_id and self.logs_dir.name == "agent":
            trial_id = self.logs_dir.parent.name

        task_id = os.environ.get("HARBOR_TASK_ID") or os.environ.get("TASK_ID")
        if not task_id:
            task_id = self._infer_task_id_from_trial_id(trial_id)

        prompt_template_path = (
            str(self._prompt_template_path) if self._prompt_template_path else None
        )

        return {
            "schema_version": "1",
            "timestamp_utc": datetime.now(timezone.utc).isoformat(),
            "agent_name": self.name(),
            "provider": provider,
            "cline_provider": cline_provider,
            "model_id": model,
            "model_name": self.model_name,
            "github_user": self._github_user,
            "commit_hash": self._commit_hash,
            "cline_version": self._cline_version or self.version(),
            "prompt_template_path": prompt_template_path,
            "prompt_artifact_dir": "/logs/agent",
            "prompt_artifact_system_prompt_glob": "task-*.system_prompt.md",
            "prompt_artifact_manifest_glob": "task-*.manifest.json",
            "thinking_tokens": self._resolved_flags.get("thinking"),
            "harbor_agent_timeout_sec": self._harbor_agent_timeout_sec,
            "cline_timeout_sec": self._cline_timeout_sec,
            "reasoning_effort": self._resolved_flags.get("reasoning_effort"),
            "double_check_completion": self._resolved_flags.get(
                "double_check_completion"
            ),
            "max_consecutive_mistakes": self._resolved_flags.get(
                "max_consecutive_mistakes"
            ),
            "trial_id": trial_id,
            "task_id": task_id,
            "instruction_sha256": hashlib.sha256(
                instruction.encode("utf-8")
            ).hexdigest(),
        }

    def _build_register_skills_command(self) -> str | None:
        """Return a shell command that copies skills to Cline's workflows directory."""
        if not self.skills_dir:
            return None
        return (
            f"mkdir -p ~/.cline/workflows && "
            f"(cp -r {shlex.quote(self.skills_dir)}/* "
            f"~/.cline/workflows/ 2>/dev/null || true)"
        )

    def _build_register_mcp_servers_command(self) -> str | None:
        """Return a shell command that writes MCP config to ~/.cline/data/settings/cline_mcp_settings.json."""
        if not self.mcp_servers:
            return None
        servers: dict[str, dict[str, Any]] = {}
        for server in self.mcp_servers:
            if server.transport == "stdio":
                servers[server.name] = {
                    "command": server.command,
                    "args": server.args,
                    "disabled": False,
                }
            elif server.transport == "streamable-http":
                servers[server.name] = {
                    "url": server.url,
                    "type": "streamableHttp",
                    "disabled": False,
                }
            else:  # sse
                servers[server.name] = {"url": server.url, "disabled": False}
        config = json.dumps({"mcpServers": servers}, indent=2)
        escaped = shlex.quote(config)
        return (
            "mkdir -p ~/.cline/data/settings && "
            f"echo {escaped} > ~/.cline/data/settings/cline_mcp_settings.json"
        )

    def create_run_agent_commands(self, instruction: str) -> list[ExecInput]:
        """
        Create commands to run Cline CLI in headless mode.

        Model name format: provider:model-id
        - provider: openrouter, anthropic, openai, etc.
        - model-id: the specific model (can contain slashes, colons, etc.)

        Examples:
          openrouter:anthropic/claude-opus-4.5
          anthropic:claude-sonnet-4-5:1m
          openai:gpt-4o

        Requires environment variables:
        - API_KEY: Your API key for the provider
        - BASE_URL: (optional, required for openai provider only)
        """
        escaped_instruction = shlex.quote(instruction)

        # Parse provider from model_name (format: provider:model-id)
        # Use split(":", 1) to only split on FIRST colon (model IDs can have colons too)
        if not self.model_name or ":" not in self.model_name:
            raise ValueError(
                f"model_name must be in format 'provider:model-id', got: '{self.model_name}'\n"
                f"Examples:\n"
                f"  openrouter:anthropic/claude-opus-4.5\n"
                f"  anthropic:claude-sonnet-4-5:1m\n"
                f"  openai:gpt-4o"
            )

        provider, model = self.model_name.split(":", 1)

        # Validate required environment variable
        if "API_KEY" not in os.environ:
            raise ValueError("API_KEY environment variable is required")

        api_key = os.environ["API_KEY"]

        # Validate provider (exhaustive list from Cline's mapProviderStringToEnum)
        valid_providers = [
            "anthropic",
            "openai",
            "openai-compatible",  # alias for openai
            "openai-native",
            "openrouter",
            "xai",
            "bedrock",
            "gemini",
            "ollama",
            "cerebras",
            "cline",
            "oca",
            "hicap",
            "nousresearch",
            "vercel",
            "vercel-ai-gateway",  # cline CLI name for vercel
        ]
        if provider not in valid_providers:
            raise ValueError(
                f"Invalid provider: '{provider}'. Valid values: {', '.join(valid_providers)}"
            )

        # Map provider aliases to what cline CLI expects
        provider_mapping = {
            "vercel": "vercel-ai-gateway",  # cline CLI uses vercel-ai-gateway
        }
        cline_provider = provider_mapping.get(provider, provider)

        # Build environment variables for agent execution
        env = {
            "PROVIDER": provider,
            "API_KEY": api_key,
            "MODELID": model,  # Everything after the first colon
            "CLINE_WRITE_PROMPT_ARTIFACTS": "1",
            "CLINE_PROMPT_ARTIFACT_DIR": "/logs/agent",
        }

        # Add BASE_URL for openai provider with custom endpoints
        base_url = ""
        if provider == "openai":
            if "BASE_URL" not in os.environ:
                raise ValueError(
                    "BASE_URL environment variable is required for openai provider"
                )
            env["BASE_URL"] = os.environ["BASE_URL"]
            base_url = os.environ["BASE_URL"]

        # Command 0: Write minimal globalState.json to skip welcome wizard
        global_state_json = shlex.quote(
            '{"welcomeViewCompleted": true, "isNewUser": false}'
        )
        setup_command = (
            f"mkdir -p ~/.cline/data && "
            f"echo {global_state_json} > ~/.cline/data/globalState.json"
        )

        skills_command = self._build_register_skills_command()
        if skills_command:
            setup_command += f" && {skills_command}"

        mcp_command = self._build_register_mcp_servers_command()
        if mcp_command:
            setup_command += f" && {mcp_command}"

        setup_config_cmd = ExecInput(
            command=setup_command,
            env=env,
        )

        pre_run_metadata = self._build_pre_run_metadata(
            instruction=instruction,
            provider=provider,
            model=model,
            cline_provider=cline_provider,
        )
        pre_run_metadata_json = json.dumps(pre_run_metadata, indent=2, sort_keys=True)

        write_pre_run_metadata_cmd = ExecInput(
            command=(
                "mkdir -p /logs/agent && "
                "cat > /logs/agent/prompt-meta.pre.json <<'EOF'\n"
                f"{pre_run_metadata_json}\n"
                "EOF"
            ),
            env=env,
        )

        # Source nvm when present, but do not require it for system-level node/npm installs.
        nvm_setup_command = (
            'export NVM_DIR="$HOME/.nvm"; '
            'if [ -s "$NVM_DIR/nvm.sh" ]; then '
            '. "$NVM_DIR/nvm.sh"; '
            "nvm use 22 >/dev/null 2>&1 || true; "
            "fi"
        )

        # Command 1: Auth and run task in a single shell command so run only executes
        # after successful auth.
        if provider == "openai" and base_url:
            auth_command = 'cline auth --provider openai --apikey "$API_KEY" --modelid "$MODELID" --baseurl "$BASE_URL"'
        else:
            auth_command = f'cline auth --provider {cline_provider} --apikey "$API_KEY" --modelid "$MODELID"'

        run_flags = ["-y", "--verbose", "--json"]
        if self._cline_timeout_sec is not None:
            run_flags.extend(["-t", str(self._cline_timeout_sec)])
        descriptor_flags = self.build_cli_flags()
        if descriptor_flags:
            run_flags.append(descriptor_flags)
        run_flags_str = " ".join(run_flags)

        # Command 1: Run task with -y flag (auto-approve) and --verbose
        run_cline_cmd = ExecInput(
            command=(
                f"{nvm_setup_command}; "
                f"set -o pipefail; "
                f"{auth_command} && "
                f"cline {run_flags_str} -- {escaped_instruction} 2>&1 | "
                f"stdbuf -oL tee /logs/agent/cline.txt"
            ),
            env=env,
        )

        return [setup_config_cmd, write_pre_run_metadata_cmd, run_cline_cmd]

    def create_cleanup_commands(self) -> list[ExecInput]:
        return [
            ExecInput(
                command=(
                    "if [ -f ~/.cline/data/state/taskHistory.json ]; then cp ~/.cline/data/state/taskHistory.json /logs/agent/taskHistory.json; fi; "
                    'LATEST_PROMPT="$(ls -1t /logs/agent/task-*.system_prompt.md 2>/dev/null | head -n 1)"; '
                    'if [ -n "$LATEST_PROMPT" ]; then cp "$LATEST_PROMPT" /logs/agent/system_prompt.latest.md; fi; '
                    'LATEST_MANIFEST="$(ls -1t /logs/agent/task-*.manifest.json 2>/dev/null | head -n 1)"; '
                    'if [ -n "$LATEST_MANIFEST" ]; then cp "$LATEST_MANIFEST" /logs/agent/prompt_manifest.latest.json; fi; '
                    "ls -lah /logs/agent > /logs/agent/artifacts.index.txt 2>&1 || true"
                ),
            ),
        ]
