summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp/tools.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/sailfish_devel_mcp/tools.py')
-rw-r--r--src/sailfish_devel_mcp/tools.py559
1 files changed, 541 insertions, 18 deletions
diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py
index 854cd00..979e699 100644
--- a/src/sailfish_devel_mcp/tools.py
+++ b/src/sailfish_devel_mcp/tools.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import base64
from dataclasses import dataclass
from datetime import datetime, timezone
import json
@@ -14,7 +15,7 @@ import time
from typing import Any, Callable
from urllib.parse import quote
-from .config import Config, DeviceConfig
+from .config import AndroidBuildHostConfig, Config, DeviceConfig
from .runner import (
CommandResult,
remote_command,
@@ -75,6 +76,18 @@ def build_registry(config: Config) -> dict[str, Tool]:
Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)),
Tool(_spec_build_status(), lambda args: handle_build_status(config, args)),
Tool(
+ _spec_android_build_hosts(),
+ lambda args: handle_android_build_hosts(config, args),
+ ),
+ Tool(
+ _spec_android_build(),
+ lambda args: handle_android_build(config, args),
+ ),
+ Tool(
+ _spec_android_build_status(),
+ lambda args: handle_android_build_status(config, args),
+ ),
+ Tool(
_spec_sdk_refresh_metadata(),
lambda args: handle_sdk_refresh_metadata(config, args),
),
@@ -364,29 +377,79 @@ def handle_device_user_session_command(config: Config, args: dict[str, Any]) ->
def handle_device_install_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
device = _device(config, args)
- rpm_path = _safe_input_path(config, _str_arg(args, "rpm_path"), allow_tmp=True)
- remote_path = _optional_str(args, "remote_path") or f"/tmp/{rpm_path.name}"
+ rpm_path_arg = _optional_str(args, "rpm_path")
+ rpm_paths_arg = args.get("rpm_paths")
+ if rpm_path_arg and rpm_paths_arg is not None:
+ return tool_error("use either rpm_path or rpm_paths, not both")
+ if rpm_paths_arg is not None:
+ rpm_path_values = _string_list_arg(args, "rpm_paths")
+ if not rpm_path_values:
+ return tool_error("rpm_paths must contain at least one RPM")
+ elif rpm_path_arg:
+ rpm_path_values = [rpm_path_arg]
+ else:
+ return tool_error("rpm_path or rpm_paths is required")
+
+ rpm_paths = [
+ _safe_input_path(config, value, allow_tmp=True)
+ for value in rpm_path_values
+ ]
+ remote_path = _optional_str(args, "remote_path")
+ remote_dir = _optional_str(args, "remote_dir")
+ if len(rpm_paths) > 1 and remote_path:
+ return tool_error("remote_path only supports a single RPM; use remote_dir with rpm_paths")
+ if len(rpm_paths) > 1 and len({path.name for path in rpm_paths}) != len(rpm_paths):
+ return tool_error("rpm_paths must not contain duplicate file names")
+
installer = _enum_arg(args, "installer", ["pkcon", "rpm"], default="pkcon")
timeout = _int_arg(args, "timeout", default=180, minimum=1, maximum=1200)
- copy_result = run(
- scp_to_argv(device, config.paths.ssh_config, rpm_path, remote_path),
- timeout=timeout,
- )
- if not copy_result.ok:
- return command_result("copy RPM to device", copy_result)
+ mkdir_result: CommandResult | None = None
+ if len(rpm_paths) == 1 and not remote_dir:
+ remote_paths = [remote_path or f"/tmp/{rpm_paths[0].name}"]
+ else:
+ remote_dir = _remote_absolute_path(remote_dir or _new_remote_rpm_dir(), "remote_dir")
+ mkdir_result = _run_ssh(config, device, ["mkdir", "-p", remote_dir], timeout=timeout)
+ if not mkdir_result.ok:
+ return command_result("prepare RPM directory", mkdir_result, {"remote_dir": remote_dir})
+ remote_paths = [
+ str(PurePosixPath(remote_dir) / rpm_path.name)
+ for rpm_path in rpm_paths
+ ]
+
+ copy_results = []
+ for rpm_path, target_path in zip(rpm_paths, remote_paths):
+ copy_result = run(
+ scp_to_argv(device, config.paths.ssh_config, rpm_path, target_path),
+ timeout=timeout,
+ )
+ copy_results.append(copy_result)
+ if not copy_result.ok:
+ return command_result(
+ "copy RPM to device",
+ copy_result,
+ {
+ "copies": [result.public_dict() for result in copy_results],
+ "remote_paths": remote_paths,
+ },
+ )
if installer == "pkcon":
- remote = ["pkcon", "install-local", "-y", remote_path]
+ remote = ["pkcon", "install-local", "-y", *remote_paths]
else:
- remote = ["rpm", "-Uvh", "--replacepkgs", remote_path]
+ remote = ["rpm", "-Uvh", "--replacepkgs", *remote_paths]
install_result = _run_ssh(config, device, remote, timeout=timeout)
structured = {
- "copy": copy_result.public_dict(),
+ "copy": copy_results[0].public_dict(),
+ "copies": [result.public_dict() for result in copy_results],
"install": install_result.public_dict(),
- "remote_path": remote_path,
+ "remote_path": remote_paths[0],
+ "remote_paths": remote_paths,
+ "remote_dir": remote_dir,
"installer": installer,
}
+ if mkdir_result is not None:
+ structured["mkdir"] = mkdir_result.public_dict()
return command_result("install RPM", install_result, structured)
@@ -523,6 +586,123 @@ def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
}
+def handle_android_build_hosts(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ text = "\n".join(
+ (
+ f"{name}{' (default)' if name == config.default_android_build_host else ''}: "
+ f"{host.ssh_target} project={host.project_dir} state={host.state_dir}"
+ )
+ for name, host in sorted(config.android_build_hosts.items(), key=lambda item: item[0])
+ )
+ structured = {
+ "default_android_build_host": config.default_android_build_host,
+ "android_build_hosts": {
+ name: host.public_dict()
+ for name, host in config.android_build_hosts.items()
+ },
+ }
+ return ok_text(text or "No configured Android build hosts", structured)
+
+
+def handle_android_build(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ host = _android_build_host(config, args)
+ project_dir_arg = _optional_str(args, "project_dir") or host.project_dir
+ if not project_dir_arg:
+ return tool_error(
+ "project_dir is required; configure android_build_hosts.<host>.project_dir "
+ "or pass project_dir"
+ )
+ project_dir = _remote_absolute_path(
+ project_dir_arg,
+ "project_dir",
+ )
+ state_dir = _remote_absolute_path(
+ _optional_str(args, "state_dir") or host.state_dir,
+ "state_dir",
+ )
+ shell_command = _str_arg(args, "shell_command")
+ shell = _enum_arg(args, "shell", ["bash", "sh"], default="bash")
+ timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=600)
+ job_id = _optional_str(args, "job_id") or _new_android_build_job_id()
+ _validate_remote_job_id(job_id)
+
+ job_dir = str(PurePosixPath(state_dir) / job_id)
+ log_path = str(PurePosixPath(job_dir) / "build.log")
+ remote = _android_build_start_command(
+ host=host,
+ project_dir=project_dir,
+ state_dir=state_dir,
+ job_id=job_id,
+ shell=shell,
+ shell_command=shell_command,
+ )
+ result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout)
+ structured = {
+ "host": host.public_dict(),
+ "project_dir": project_dir,
+ "state_dir": state_dir,
+ "job_id": job_id,
+ "job_dir": job_dir,
+ "log_path": log_path,
+ "shell": shell,
+ }
+ if not result.ok:
+ return command_result("start Android build", result, structured)
+
+ text = "\n".join(
+ [
+ f"started Android build job {job_id} on {host.name}",
+ f"host: {host.ssh_target}",
+ f"project: {project_dir}",
+ f"state: {job_dir}",
+ f"log: {log_path}",
+ "poll with sailfish_android_build_status",
+ ]
+ )
+ if result.stdout.strip():
+ text += "\n\n" + result.stdout.strip()
+ data = result.public_dict()
+ data.update(structured)
+ return {
+ "content": [{"type": "text", "text": text}],
+ "structuredContent": data,
+ "isError": False,
+ }
+
+
+def handle_android_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ host = _android_build_host(config, args)
+ state_dir = _remote_absolute_path(
+ _optional_str(args, "state_dir") or host.state_dir,
+ "state_dir",
+ )
+ job_id = _optional_str(args, "job_id")
+ lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000)
+ timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=600)
+
+ if job_id:
+ _validate_remote_job_id(job_id)
+ remote = _android_build_status_command(state_dir, job_id, lines)
+ structured: dict[str, Any] = {
+ "host": host.public_dict(),
+ "state_dir": state_dir,
+ "job_id": job_id,
+ }
+ else:
+ remote = _android_build_list_command(state_dir)
+ structured = {"host": host.public_dict(), "state_dir": state_dir}
+
+ result = run(_android_build_ssh_argv(config, host, remote), timeout=timeout)
+ if job_id:
+ structured.update(_parse_android_build_status(result.stdout))
+ response = command_result("Android build status", result, structured)
+ returncode = response["structuredContent"].get("returncode")
+ state = response["structuredContent"].get("state")
+ if result.ok and state == "finished" and returncode not in (0, None):
+ response["isError"] = True
+ return response
+
+
def _mcp_state_dir() -> Path:
value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR")
if value:
@@ -859,6 +1039,243 @@ def _run_ssh(
)
+def _android_build_host(config: Config, args: dict[str, Any]) -> AndroidBuildHostConfig:
+ return config.android_build_host(_optional_str(args, "host"))
+
+
+def _android_build_ssh_argv(
+ config: Config,
+ host: AndroidBuildHostConfig,
+ remote: str,
+) -> list[str]:
+ argv = ["ssh"]
+ if config.paths.ssh_config:
+ argv += ["-F", str(config.paths.ssh_config)]
+ argv += [host.ssh_target, remote]
+ return argv
+
+
+def _new_android_build_job_id() -> str:
+ now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
+ return f"android-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
+
+
+def _validate_remote_job_id(job_id: str) -> None:
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", job_id):
+ raise ValueError("job_id contains unsupported characters")
+
+
+def _remote_absolute_path(value: str, name: str) -> str:
+ if "\0" in value or "\n" in value:
+ raise ValueError(f"{name} contains unsupported characters")
+ if not value.startswith("/"):
+ raise ValueError(f"{name} must be an absolute remote path")
+ return value
+
+
+def _new_remote_rpm_dir() -> str:
+ now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
+ return f"/tmp/sailfish-devel-mcp-rpms-{now}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
+
+
+def _android_build_start_command(
+ *,
+ host: AndroidBuildHostConfig,
+ project_dir: str,
+ state_dir: str,
+ job_id: str,
+ shell: str,
+ shell_command: str,
+) -> str:
+ job_dir = str(PurePosixPath(state_dir) / job_id)
+ log_path = str(PurePosixPath(job_dir) / "build.log")
+ run_path = str(PurePosixPath(job_dir) / "run.sh")
+ command_path = str(PurePosixPath(job_dir) / "command")
+ pid_path = str(PurePosixPath(job_dir) / "pid")
+ created_at_path = str(PurePosixPath(job_dir) / "created_at")
+ started_at_path = str(PurePosixPath(job_dir) / "started_at")
+ finished_at_path = str(PurePosixPath(job_dir) / "finished_at")
+ returncode_path = str(PurePosixPath(job_dir) / "returncode")
+ run_script = f"""#!/bin/sh
+set +e
+project_dir={shlex.quote(project_dir)}
+log_path={shlex.quote(log_path)}
+started_at_path={shlex.quote(started_at_path)}
+finished_at_path={shlex.quote(finished_at_path)}
+returncode_path={shlex.quote(returncode_path)}
+host_name={shlex.quote(host.name)}
+shell_bin={shlex.quote(shell)}
+shell_command={shlex.quote(shell_command)}
+
+date -Is > "$started_at_path"
+printf '[%s] starting Android build on %s\\n' "$(date -Is)" "$host_name" >> "$log_path"
+printf '[%s] project_dir=%s\\n' "$(date -Is)" "$project_dir" >> "$log_path"
+printf '[%s] command=%s\\n' "$(date -Is)" "$shell_command" >> "$log_path"
+
+cd "$project_dir"
+cd_rc=$?
+if [ "$cd_rc" -ne 0 ]; then
+ printf '[%s] failed to cd to %s\\n' "$(date -Is)" "$project_dir" >> "$log_path"
+ printf '%s\\n' "$cd_rc" > "$returncode_path"
+ date -Is > "$finished_at_path"
+ exit "$cd_rc"
+fi
+
+"$shell_bin" -lc "$shell_command" >> "$log_path" 2>&1
+rc=$?
+printf '%s\\n' "$rc" > "$returncode_path"
+date -Is > "$finished_at_path"
+printf '[%s] finished returncode=%s\\n' "$(date -Is)" "$rc" >> "$log_path"
+exit "$rc"
+"""
+ encoded_run_script = base64.b64encode(run_script.encode("utf-8")).decode("ascii")
+ script = f"""
+set -eu
+state_dir={shlex.quote(state_dir)}
+job_id={shlex.quote(job_id)}
+job_dir={shlex.quote(job_dir)}
+run_path={shlex.quote(run_path)}
+log_path={shlex.quote(log_path)}
+command_path={shlex.quote(command_path)}
+pid_path={shlex.quote(pid_path)}
+created_at_path={shlex.quote(created_at_path)}
+mkdir -p "$state_dir" "$job_dir"
+if [ -e "$pid_path" ] || [ -e {shlex.quote(returncode_path)} ]; then
+ echo "job already exists: $job_id" >&2
+ exit 2
+fi
+umask 077
+printf '%s' {shlex.quote(encoded_run_script)} | base64 -d > "$run_path"
+chmod +x "$run_path"
+: > "$log_path"
+printf '%s\\n' {shlex.quote(shell_command)} > "$command_path"
+date -Is > "$created_at_path"
+nohup "$run_path" >/dev/null 2>&1 </dev/null &
+pid=$!
+printf '%s\\n' "$pid" > "$pid_path"
+printf 'job_id: %s\\n' "$job_id"
+printf 'pid: %s\\n' "$pid"
+printf 'host: %s\\n' {shlex.quote(host.name)}
+printf 'project_dir: %s\\n' {shlex.quote(project_dir)}
+printf 'job_dir: %s\\n' "$job_dir"
+printf 'log_path: %s\\n' "$log_path"
+"""
+ return remote_command(["sh", "-lc", script])
+
+
+def _android_build_list_command(state_dir: str) -> str:
+ script = f"""
+set -eu
+state_dir={shlex.quote(state_dir)}
+if [ ! -d "$state_dir" ]; then
+ echo "no android build jobs found"
+ exit 0
+fi
+output=$(
+ for job_dir in "$state_dir"/*; do
+ [ -d "$job_dir" ] || continue
+ job_id=${{job_dir##*/}}
+ pid=$(cat "$job_dir/pid" 2>/dev/null || true)
+ returncode=$(cat "$job_dir/returncode" 2>/dev/null || true)
+ created_at=$(cat "$job_dir/created_at" 2>/dev/null || true)
+ started_at=$(cat "$job_dir/started_at" 2>/dev/null || true)
+ timestamp=${{started_at:-$created_at}}
+ if [ -n "$returncode" ]; then
+ state=finished
+ elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ state=running
+ elif [ -n "$pid" ]; then
+ state=unknown
+ else
+ state=starting
+ fi
+ printf '%s\\t%s: %s returncode=%s pid=%s\\n' "$timestamp" "$job_id" "$state" "$returncode" "$pid"
+ done | sort | tail -n 20 | cut -f2-
+)
+if [ -n "$output" ]; then
+ printf '%s\\n' "$output"
+else
+ echo "no android build jobs found"
+fi
+"""
+ return remote_command(["sh", "-lc", script])
+
+
+def _android_build_status_command(state_dir: str, job_id: str, lines: int) -> str:
+ job_dir = str(PurePosixPath(state_dir) / job_id)
+ log_path = str(PurePosixPath(job_dir) / "build.log")
+ script = f"""
+set -eu
+job_id={shlex.quote(job_id)}
+job_dir={shlex.quote(job_dir)}
+log_path={shlex.quote(log_path)}
+lines={lines}
+if [ ! -d "$job_dir" ]; then
+ echo "unknown android build job: $job_id" >&2
+ exit 2
+fi
+pid=$(cat "$job_dir/pid" 2>/dev/null || true)
+returncode=$(cat "$job_dir/returncode" 2>/dev/null || true)
+created_at=$(cat "$job_dir/created_at" 2>/dev/null || true)
+started_at=$(cat "$job_dir/started_at" 2>/dev/null || true)
+finished_at=$(cat "$job_dir/finished_at" 2>/dev/null || true)
+command=$(cat "$job_dir/command" 2>/dev/null || true)
+if [ -n "$returncode" ]; then
+ state=finished
+elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ state=running
+elif [ -n "$pid" ]; then
+ state=unknown
+else
+ state=starting
+fi
+printf 'job_id: %s\\n' "$job_id"
+printf 'state: %s\\n' "$state"
+printf 'returncode: %s\\n' "$returncode"
+printf 'pid: %s\\n' "$pid"
+printf 'created_at: %s\\n' "$created_at"
+printf 'started_at: %s\\n' "$started_at"
+printf 'finished_at: %s\\n' "$finished_at"
+printf 'job_dir: %s\\n' "$job_dir"
+printf 'log_path: %s\\n' "$log_path"
+printf 'command: %s\\n' "$command"
+if [ "$lines" -gt 0 ] && [ -f "$log_path" ]; then
+ printf '\\n'
+ tail -n "$lines" "$log_path"
+fi
+"""
+ return remote_command(["sh", "-lc", script])
+
+
+def _parse_android_build_status(stdout: str) -> dict[str, Any]:
+ fields: dict[str, Any] = {}
+ for line in stdout.splitlines():
+ if ": " not in line:
+ continue
+ key, value = line.split(": ", 1)
+ if key not in {
+ "job_id",
+ "state",
+ "returncode",
+ "pid",
+ "created_at",
+ "started_at",
+ "finished_at",
+ "job_dir",
+ "log_path",
+ "command",
+ }:
+ continue
+ if key in {"returncode", "pid"} and value:
+ try:
+ fields[key] = int(value)
+ except ValueError:
+ fields[key] = value
+ else:
+ fields[key] = value or None
+ return fields
+
+
def _default_screenshot_path(device: DeviceConfig, label: str) -> str:
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
return f"{_device_home_path(device)}/Pictures/Screenshots/{label}-{timestamp}.png"
@@ -1752,16 +2169,30 @@ def _spec_device_install_rpm() -> dict[str, Any]:
return {
"name": "sailfish_device_install_rpm",
"title": "Install Device RPM",
- "description": "Copy a local RPM to a Sailfish OS device and install it.",
+ "description": "Copy one or more local RPMs to a Sailfish OS device and install them.",
"inputSchema": _object_schema(
{
"device": _device_prop(),
- "rpm_path": {"type": "string"},
- "remote_path": {"type": "string"},
+ "rpm_path": {
+ "type": "string",
+ "description": "Single local RPM path. Use rpm_paths for dependency sets.",
+ },
+ "rpm_paths": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Local RPM paths to copy and install in one transaction.",
+ },
+ "remote_path": {
+ "type": "string",
+ "description": "Remote file path override for a single rpm_path.",
+ },
+ "remote_dir": {
+ "type": "string",
+ "description": "Remote directory for copied RPMs. Created when missing.",
+ },
"installer": {"type": "string", "enum": ["pkcon", "rpm"], "default": "pkcon"},
"timeout": _timeout_prop(180),
- },
- ["rpm_path"],
+ }
),
"annotations": _mutating_annotations("Install Device RPM"),
}
@@ -1871,6 +2302,98 @@ def _spec_build_status() -> dict[str, Any]:
}
+def _spec_android_build_hosts() -> dict[str, Any]:
+ return {
+ "name": "sailfish_android_build_hosts",
+ "title": "Android Build Hosts",
+ "description": "List configured remote Android/AppSupport build hosts.",
+ "inputSchema": _object_schema({}),
+ "annotations": _read_only_annotations("Android Build Hosts"),
+ }
+
+
+def _spec_android_build() -> dict[str, Any]:
+ return {
+ "name": "sailfish_android_build",
+ "title": "Start Android Build",
+ "description": (
+ "Start a remote Android/AppSupport build under nohup on the configured "
+ "build host; project_dir must be configured for the host or supplied."
+ ),
+ "inputSchema": _object_schema(
+ {
+ "host": {
+ "type": "string",
+ "description": "Configured build host alias or ssh target.",
+ },
+ "project_dir": {
+ "type": "string",
+ "description": "Remote Android tree. Defaults to the host config.",
+ },
+ "state_dir": {
+ "type": "string",
+ "description": "Remote directory where job state and logs are stored.",
+ },
+ "shell_command": {
+ "type": "string",
+ "description": (
+ "Build command run from project_dir, for example "
+ "'source build/envsetup.sh && lunch ... && m ...'."
+ ),
+ },
+ "job_id": {
+ "type": "string",
+ "description": "Optional stable job id; autogenerated when omitted.",
+ },
+ "shell": {
+ "type": "string",
+ "enum": ["bash", "sh"],
+ "default": "bash",
+ },
+ "timeout": _timeout_prop(60),
+ },
+ ["shell_command"],
+ ),
+ "annotations": _mutating_annotations("Start Android Build"),
+ }
+
+
+def _spec_android_build_status() -> dict[str, Any]:
+ return {
+ "name": "sailfish_android_build_status",
+ "title": "Android Build Status",
+ "description": (
+ "List remote Android/AppSupport build jobs or read one job's status and "
+ "recent log output."
+ ),
+ "inputSchema": _object_schema(
+ {
+ "host": {
+ "type": "string",
+ "description": "Configured build host alias or ssh target.",
+ },
+ "job_id": {
+ "type": "string",
+ "description": "Job id returned by sailfish_android_build. Omit to list jobs.",
+ },
+ "state_dir": {
+ "type": "string",
+ "description": "Remote directory where job state and logs are stored.",
+ },
+ "lines": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000,
+ "default": 80,
+ "description": "Number of trailing log lines to include.",
+ },
+ "timeout": _timeout_prop(30),
+ }
+ ),
+ "annotations": _read_only_annotations("Android Build Status"),
+ }
+
+
def _spec_sdk_refresh_metadata() -> dict[str, Any]:
return {
"name": "sailfish_sdk_refresh_metadata",