summaryrefslogtreecommitdiff
path: root/src/sailfish_devel_mcp
diff options
context:
space:
mode:
Diffstat (limited to 'src/sailfish_devel_mcp')
-rw-r--r--src/sailfish_devel_mcp/config.py9
-rw-r--r--src/sailfish_devel_mcp/tools.py738
-rwxr-xr-xsrc/sailfish_devel_mcp/vendor/build_sailfishos.py27
3 files changed, 758 insertions, 16 deletions
diff --git a/src/sailfish_devel_mcp/config.py b/src/sailfish_devel_mcp/config.py
index 6515918..73740d1 100644
--- a/src/sailfish_devel_mcp/config.py
+++ b/src/sailfish_devel_mcp/config.py
@@ -39,6 +39,7 @@ class DeviceConfig:
@dataclass(frozen=True)
class PathConfig:
git_root: Path = Path.home() / "git"
+ obs_root: Path = Path.home() / "OBS"
ssh_config: Path = Path.home() / ".ssh" / "config"
build_sailfishos: Path = BUNDLED_BUILD_HELPER
local_sdk: Path | None = None
@@ -47,6 +48,7 @@ class PathConfig:
def public_dict(self) -> dict[str, str | None]:
return {
"git_root": str(self.git_root),
+ "obs_root": str(self.obs_root),
"ssh_config": str(self.ssh_config),
"build_sailfishos": str(self.build_sailfishos),
"local_sdk": str(self.local_sdk) if self.local_sdk else None,
@@ -155,6 +157,13 @@ def _load_paths(raw_paths: Any) -> PathConfig:
or defaults.git_root
)
).expanduser(),
+ obs_root=Path(
+ str(
+ raw_paths.get("obs_root")
+ or os.environ.get("SAILFISH_MCP_OBS_ROOT")
+ or defaults.obs_root
+ )
+ ).expanduser(),
ssh_config=Path(
str(
raw_paths.get("ssh_config")
diff --git a/src/sailfish_devel_mcp/tools.py b/src/sailfish_devel_mcp/tools.py
index 1061e6c..854cd00 100644
--- a/src/sailfish_devel_mcp/tools.py
+++ b/src/sailfish_devel_mcp/tools.py
@@ -1,12 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timezone
+import json
+import os
from pathlib import Path, PurePosixPath
import re
import shlex
import shutil
+import subprocess
+import sys
+import time
from typing import Any, Callable
+from urllib.parse import quote
from .config import Config, DeviceConfig
from .runner import (
@@ -19,6 +25,7 @@ from .runner import (
truncate,
user_bus_env,
)
+from .vendor import build_sailfishos
ToolHandler = Callable[[dict[str, Any]], dict[str, Any]]
@@ -45,15 +52,32 @@ def build_registry(config: Config) -> dict[str, Tool]:
lambda args: handle_device_touch(config, args),
),
Tool(
+ _spec_device_touch_workflow(),
+ lambda args: handle_device_touch_workflow(config, args),
+ ),
+ Tool(
_spec_device_user_bus_call(),
lambda args: handle_device_user_bus_call(config, args),
),
+ Tool(
+ _spec_device_user_session_command(),
+ lambda args: handle_device_user_session_command(config, args),
+ ),
Tool(_spec_device_install_rpm(), lambda args: handle_device_install_rpm(config, args)),
Tool(
_spec_device_restart_service(),
lambda args: handle_device_restart_service(config, args),
),
+ Tool(
+ _spec_device_browser_launch(),
+ lambda args: handle_device_browser_launch(config, args),
+ ),
Tool(_spec_build_rpm(), lambda args: handle_build_rpm(config, args)),
+ Tool(_spec_build_status(), lambda args: handle_build_status(config, args)),
+ Tool(
+ _spec_sdk_refresh_metadata(),
+ lambda args: handle_sdk_refresh_metadata(config, args),
+ ),
Tool(_spec_obs_results(), lambda args: handle_obs_results(config, args)),
Tool(_spec_obs_buildlog(), lambda args: handle_obs_buildlog(config, args)),
Tool(_spec_repo_status(), lambda args: handle_repo_status(config, args)),
@@ -152,7 +176,7 @@ def handle_device_proc_maps(config: Config, args: dict[str, Any]) -> dict[str, A
def handle_device_lipstick_screenshot(config: Config, args: dict[str, Any]) -> dict[str, Any]:
device = _device(config, args)
- timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
home_path = _device_home_path(device)
remote_path = _optional_str(args, "remote_path") or (
f"{home_path}/Pictures/Screenshots/lipstick-{timestamp}.png"
@@ -238,6 +262,66 @@ def handle_device_touch(config: Config, args: dict[str, Any]) -> dict[str, Any]:
)
+def handle_device_touch_workflow(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ device = _device(config, args)
+ action = _enum_arg(args, "action", ["tap", "swipe"])
+ timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=300)
+ screenshot_before = _bool_arg(args, "screenshot_before", default=True)
+ screenshot_after = _bool_arg(args, "screenshot_after", default=False)
+ discover_input = _bool_arg(args, "discover_input", default=True)
+ privileged = _bool_arg(args, "privileged", default=True)
+
+ steps: list[tuple[str, dict[str, Any]]] = []
+ base_args = {"device": _optional_str(args, "device") or device.name, "timeout": timeout}
+
+ def append_step(name: str, result: dict[str, Any]) -> bool:
+ steps.append((name, result))
+ return bool(result.get("isError", False))
+
+ if screenshot_before:
+ before_args: dict[str, Any] = {
+ **base_args,
+ "privileged": privileged,
+ "remote_path": _optional_str(args, "before_remote_path")
+ or _default_screenshot_path(device, "touch-before"),
+ }
+ before_local_path = _optional_str(args, "before_local_path") or _optional_str(args, "local_path")
+ if before_local_path:
+ before_args["local_path"] = before_local_path
+ if append_step("screenshot_before", handle_device_lipstick_screenshot(config, before_args)):
+ return combined_result("touch workflow", steps)
+
+ input_device = _optional_str(args, "input_device")
+ if discover_input and not input_device:
+ discover_args = {
+ **base_args,
+ "action": "discover",
+ "include_evdev_trace": _bool_arg(args, "include_evdev_trace", default=False),
+ }
+ if append_step("touchscreen_discovery", handle_device_touch(config, discover_args)):
+ return combined_result("touch workflow", steps)
+
+ touch_args = dict(args)
+ touch_args["action"] = action
+ touch_args["timeout"] = timeout
+ if append_step("touch", handle_device_touch(config, touch_args)):
+ return combined_result("touch workflow", steps)
+
+ if screenshot_after:
+ after_args: dict[str, Any] = {
+ **base_args,
+ "privileged": privileged,
+ "remote_path": _optional_str(args, "after_remote_path")
+ or _default_screenshot_path(device, "touch-after"),
+ }
+ after_local_path = _optional_str(args, "after_local_path")
+ if after_local_path:
+ after_args["local_path"] = after_local_path
+ append_step("screenshot_after", handle_device_lipstick_screenshot(config, after_args))
+
+ return combined_result("touch workflow", steps)
+
+
def handle_device_user_bus_call(config: Config, args: dict[str, Any]) -> dict[str, Any]:
device = _device(config, args)
destination = _str_arg(args, "destination")
@@ -260,6 +344,24 @@ def handle_device_user_bus_call(config: Config, args: dict[str, Any]) -> dict[st
return command_result("user bus call", _run_ssh(config, device, command, timeout=timeout))
+def handle_device_user_session_command(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ device = _device(config, args)
+ command = _command_list_arg(args, "command")
+ timeout = _int_arg(args, "timeout", default=30, minimum=1, maximum=300)
+ run_as_user = _bool_arg(args, "run_as_user", default=False)
+ session_command = user_bus_env(device) + command
+ if run_as_user:
+ remote = _run_as_user_command(device, remote_command(session_command))
+ result = run(ssh_argv(device, config.paths.ssh_config, remote), timeout=timeout)
+ else:
+ result = _run_ssh(config, device, session_command, timeout=timeout)
+ return command_result(
+ "user session command",
+ result,
+ {"run_as_user": run_as_user, "username": device.username},
+ )
+
+
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)
@@ -304,6 +406,21 @@ def handle_device_restart_service(config: Config, args: dict[str, Any]) -> dict[
)
+def handle_device_browser_launch(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ device = _device(config, args)
+ url = _str_arg(args, "url")
+ stop_stale = _bool_arg(args, "stop_stale", default=True)
+ wait_seconds = _int_arg(args, "wait_seconds", default=3, minimum=0, maximum=60)
+ timeout = _int_arg(args, "timeout", default=60, minimum=1, maximum=300)
+ remote = _browser_launch_command(device, url, stop_stale, wait_seconds)
+ result = run(ssh_argv(device, config.paths.ssh_config, remote), timeout=timeout)
+ return command_result(
+ "browser launch",
+ result,
+ {"url": url, "stop_stale": stop_stale, "wait_seconds": wait_seconds},
+ )
+
+
def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
project_path = _safe_input_path(config, _str_arg(args, "project_path"), allow_tmp=False)
device = config.device(_optional_str(args, "device")) if args.get("device") else None
@@ -317,6 +434,8 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
artifacts_dir = _optional_str(args, "artifacts_dir")
if config.paths.local_sdk:
command += ["--local-sdk", str(config.paths.local_sdk)]
+ if not release:
+ release = "live"
if release:
command += ["--release", release]
if isinstance(arches, str):
@@ -344,7 +463,248 @@ def handle_build_rpm(config: Config, args: dict[str, Any]) -> dict[str, Any]:
for local_dir in _string_list_arg(args, "local_rpms_dir"):
command += ["--local-rpms-dir", str(_safe_input_path(config, local_dir, allow_tmp=True))]
timeout = _int_arg(args, "timeout", default=3600, minimum=1, maximum=21600)
- return command_result("build Sailfish RPM", run(command, timeout=timeout))
+ if _bool_arg(args, "wait", default=False):
+ return command_result("build Sailfish RPM", run(command, timeout=timeout))
+ job = _start_background_command("build Sailfish RPM", command, timeout=timeout)
+ text = "\n".join(
+ [
+ f"started build job {job['job_id']}",
+ f"status: {job['status_path']}",
+ f"log: {job['log_path']}",
+ "poll with sailfish_build_status",
+ ]
+ )
+ return ok_text(text, job)
+
+
+def handle_build_status(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ job_id = _optional_str(args, "job_id")
+ lines = _int_arg(args, "lines", default=80, minimum=0, maximum=1000)
+ jobs_dir = _build_jobs_dir()
+ if not job_id:
+ jobs = []
+ if jobs_dir.exists():
+ for status_path in sorted(jobs_dir.glob("*/status.json"), key=lambda p: p.stat().st_mtime):
+ status = _read_job_status(status_path)
+ if status:
+ jobs.append(status)
+ jobs = jobs[-20:]
+ text = "\n".join(
+ f"{job.get('job_id')}: {job.get('state')} {job.get('started_at', '')}"
+ for job in jobs
+ )
+ return ok_text(text or "no build jobs found", {"jobs_dir": str(jobs_dir), "jobs": jobs})
+
+ job_dir = jobs_dir / job_id
+ status_path = job_dir / "status.json"
+ if not status_path.exists():
+ return tool_error(f"unknown build job: {job_id}", {"jobs_dir": str(jobs_dir)})
+ status = _read_job_status(status_path)
+ if not status:
+ return tool_error(f"could not read build job status: {job_id}")
+ log_path = Path(str(status.get("log_path") or job_dir / "build.log"))
+ log_tail = _tail_file(log_path, lines)
+ state = str(status.get("state") or "unknown")
+ returncode = status.get("returncode")
+ text_lines = [
+ f"job_id: {status.get('job_id')}",
+ f"state: {state}",
+ f"returncode: {returncode}",
+ f"log: {log_path}",
+ ]
+ if log_tail:
+ text_lines += ["", log_tail]
+ structured = dict(status)
+ structured["log_tail"] = log_tail
+ return {
+ "content": [{"type": "text", "text": "\n".join(text_lines)}],
+ "structuredContent": structured,
+ "isError": state == "finished" and returncode not in (0, None),
+ }
+
+
+def _mcp_state_dir() -> Path:
+ value = os.environ.get("SAILFISH_DEVEL_MCP_STATE_DIR")
+ if value:
+ return Path(value).expanduser()
+ value = os.environ.get("SAILFISH_DEVEL_MCP_LOG_DIR")
+ if value:
+ return Path(value).expanduser()
+ value = os.environ.get("XDG_STATE_HOME")
+ if value:
+ return Path(value).expanduser() / "sailfish-devel-mcp"
+ value = os.environ.get("HOME")
+ if value:
+ return Path(value).expanduser() / ".local" / "state" / "sailfish-devel-mcp"
+ return Path("/tmp") / "sailfish-devel-mcp"
+
+
+def _build_jobs_dir() -> Path:
+ return _mcp_state_dir() / "builds"
+
+
+def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ tmp.replace(path)
+
+
+def _start_background_command(label: str, command: list[str], *, timeout: int) -> dict[str, Any]:
+ now = datetime.now(timezone.utc)
+ job_id = f"build-{now.strftime('%Y%m%dT%H%M%S')}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
+ job_dir = _build_jobs_dir() / job_id
+ log_path = job_dir / "build.log"
+ status_path = job_dir / "status.json"
+ status = {
+ "job_id": job_id,
+ "label": label,
+ "state": "starting",
+ "argv": command,
+ "timeout": timeout,
+ "created_at": now.isoformat(),
+ "status_path": str(status_path),
+ "log_path": str(log_path),
+ }
+ _write_json_atomic(status_path, status)
+
+ supervisor = r"""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+import json
+import os
+from pathlib import Path
+import signal
+import subprocess
+import sys
+import time
+
+
+def now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def write_status(path: Path, data: dict[str, object]) -> None:
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ tmp.replace(path)
+
+
+status_path = Path(sys.argv[1])
+log_path = Path(sys.argv[2])
+timeout = int(sys.argv[3])
+command = sys.argv[4:]
+
+status = json.loads(status_path.read_text(encoding="utf-8"))
+status["supervisor_pid"] = os.getpid()
+status["started_at"] = now()
+log_path.parent.mkdir(parents=True, exist_ok=True)
+
+with log_path.open("a", encoding="utf-8", errors="replace") as log:
+ log.write(f"[{now()}] starting {' '.join(command)}\n")
+ log.flush()
+ process = subprocess.Popen(
+ command,
+ stdin=subprocess.DEVNULL,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ text=True,
+ start_new_session=True,
+ close_fds=True,
+ )
+ status["pid"] = process.pid
+ status["state"] = "running"
+ write_status(status_path, status)
+
+ deadline = time.monotonic() + timeout
+ timed_out = False
+ returncode = None
+ while True:
+ returncode = process.poll()
+ if returncode is not None:
+ break
+ if time.monotonic() >= deadline:
+ timed_out = True
+ log.write(f"[{now()}] timeout after {timeout}s; terminating process group {process.pid}\n")
+ log.flush()
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ try:
+ returncode = process.wait(timeout=30)
+ except subprocess.TimeoutExpired:
+ log.write(f"[{now()}] process group did not exit; killing {process.pid}\n")
+ log.flush()
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ returncode = process.wait()
+ break
+ time.sleep(1)
+
+ status["state"] = "finished"
+ status["returncode"] = returncode
+ status["timed_out"] = timed_out
+ status["finished_at"] = now()
+ write_status(status_path, status)
+ log.write(f"[{now()}] finished returncode={returncode} timed_out={timed_out}\n")
+"""
+ process = subprocess.Popen(
+ [sys.executable, "-c", supervisor, str(status_path), str(log_path), str(timeout), *command],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ close_fds=True,
+ )
+ status["supervisor_pid"] = process.pid
+ _write_json_atomic(status_path, status)
+ return status
+
+
+def _read_job_status(path: Path) -> dict[str, Any] | None:
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+
+def _tail_file(path: Path, lines: int) -> str:
+ if lines <= 0:
+ return ""
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return ""
+ return "\n".join(text.splitlines()[-lines:])
+
+
+def handle_sdk_refresh_metadata(config: Config, args: dict[str, Any]) -> dict[str, Any]:
+ local_sdk = _local_sdk_path(config, args)
+ if local_sdk is None:
+ return tool_error("paths.local_sdk or local_sdk argument is required")
+ target = _local_sdk_target(config, args, local_sdk)
+ if target is None:
+ return tool_error("target, arch, or a device with architecture is required")
+ main_target = _main_sdk_target(target)
+ timeout = _int_arg(args, "timeout", default=600, minimum=1, maximum=3600)
+ command = ["sb2", "-t", main_target, "-m", "sdk-install", "-R", "zypper", "ref"]
+ if _bool_arg(args, "force", default=False):
+ command.append("-f")
+ result = run(_local_sdk_docker_argv(local_sdk, command), timeout=timeout)
+ return command_result(
+ "refresh local SDK metadata",
+ result,
+ {
+ "local_sdk": str(local_sdk),
+ "target": target,
+ "main_target": main_target,
+ },
+ )
def handle_obs_results(config: Config, args: dict[str, Any]) -> dict[str, Any]:
@@ -368,10 +728,20 @@ def handle_obs_buildlog(config: Config, args: dict[str, Any]) -> dict[str, Any]:
arch = _str_arg(args, "arch")
api_alias = _optional_str(args, "api_alias") or config.paths.osc_api_alias
timeout = _int_arg(args, "timeout", default=90, minimum=1, maximum=1800)
+ nostream = _bool_arg(args, "nostream", default=True)
command = ["osc"]
if api_alias:
command += ["-A", api_alias]
- command += ["remotebuildlog", project, package, repository, arch]
+ if nostream:
+ path = "/build/{}/{}/{}/{}/_log?nostream=1".format(
+ quote(project, safe=""),
+ quote(repository, safe=""),
+ quote(arch, safe=""),
+ quote(package, safe=""),
+ )
+ command += ["api", path]
+ else:
+ command += ["remotebuildlog", project, package, repository, arch]
return command_result("OBS build log", run(command, timeout=timeout))
@@ -489,6 +859,11 @@ def _run_ssh(
)
+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"
+
+
def _screenshot_prepare_command(device: DeviceConfig, remote_path: str) -> str:
remote_dir = str(PurePosixPath(remote_path).parent)
home_path = _device_home_path(device)
@@ -519,6 +894,160 @@ fi
return remote_command(["sh", "-lc", script])
+def _run_as_user_command(device: DeviceConfig, inner: str) -> str:
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", device.username):
+ raise ValueError("device username contains unsupported characters")
+ username = shlex.quote(device.username)
+ quoted_inner = shlex.quote(inner)
+ script = f"""
+if command -v runuser >/dev/null 2>&1; then
+ runuser -u {username} -- {inner}
+else
+ su -s /bin/sh {username} -c {quoted_inner}
+fi
+""".strip()
+ return remote_command(["sh", "-lc", script])
+
+
+def _browser_launch_command(
+ device: DeviceConfig,
+ url: str,
+ stop_stale: bool,
+ wait_seconds: int,
+) -> str:
+ script = f"""
+set -u
+export XDG_RUNTIME_DIR={shlex.quote(device.user_bus_runtime_dir)}
+export DBUS_SESSION_BUS_ADDRESS={shlex.quote(device.user_bus_address)}
+if [ {shlex.quote("1" if stop_stale else "0")} = 1 ]; then
+ systemctl --user stop booster-browser@sailfish-browser.service >/dev/null 2>&1 || true
+ browser_name=sailfish-browser
+ for pid in $(pidof "$browser_name" 2>/dev/null || true); do
+ kill "$pid" >/dev/null 2>&1 || true
+ done
+ if command -v ps >/dev/null 2>&1; then
+ firejail_name=firejail
+ ps -eo pid=,comm=,args= 2>/dev/null |
+ awk -v c="$firejail_name" -v b="$browser_name" '$2 == c && index($0, b) {{ print $1 }}' |
+ while read -r pid; do
+ [ -n "$pid" ] || continue
+ kill "$pid" >/dev/null 2>&1 || true
+ done
+ fi
+fi
+WAYLAND_DISPLAY=../../display/wayland-0 QT_QPA_PLATFORM=hwcomposer \\
+ /usr/bin/invoker --type=browser,silica-qt5 -A -- \\
+ /usr/bin/sailfish-browser {shlex.quote(url)} \\
+ >/tmp/sailfish-browser-mcp.log 2>&1 &
+launcher_pid=$!
+echo "launcher_pid=$launcher_pid"
+echo "launch_log=/tmp/sailfish-browser-mcp.log"
+if [ {wait_seconds} -gt 0 ]; then
+ sleep {wait_seconds}
+fi
+reply=$(dbus-send --session --print-reply \\
+ --dest=org.nemomobile.lipstick \\
+ / \\
+ org.freedesktop.DBus.Properties.Get \\
+ string:org.nemomobile.compositor \\
+ string:privateTopmostWindowProcessId 2>/dev/null || true)
+topmost_pid=$(printf '%s\\n' "$reply" | awk '/\\b(int32|uint32|int64|uint64)\\b/ {{ print $2; exit }}')
+if [ -n "$topmost_pid" ]; then
+ echo "topmost_pid=$topmost_pid"
+ if [ -r "/proc/$topmost_pid/maps" ]; then
+ if grep -q 'libxul\\.so' "/proc/$topmost_pid/maps"; then
+ echo "libxul=present"
+ else
+ echo "libxul=absent"
+ fi
+ fi
+else
+ echo "topmost_pid="
+fi
+""".strip()
+ return remote_command(["sh", "-lc", script])
+
+
+def _local_sdk_path(config: Config, args: dict[str, Any]) -> Path | None:
+ value = _optional_str(args, "local_sdk")
+ if value:
+ return Path(value).expanduser().resolve(strict=False)
+ if config.paths.local_sdk:
+ return config.paths.local_sdk.expanduser().resolve(strict=False)
+ return None
+
+
+def _local_sdk_target(config: Config, args: dict[str, Any], local_sdk: Path) -> str | None:
+ explicit_target = _optional_str(args, "target")
+ if explicit_target:
+ return build_sailfishos.canonical_local_target_name(explicit_target)
+
+ device = config.device(_optional_str(args, "device")) if args.get("device") else None
+ arch = _optional_str(args, "arch") or (device.architecture if device else "")
+ if not arch:
+ return None
+ release = _optional_str(args, "release") or (device.release if device else "") or "live"
+ normalized_release = build_sailfishos.normalize_local_release(release) or "live"
+ matching = [
+ target
+ for target in build_sailfishos.list_local_sdk_targets(local_sdk)
+ if target.arch == arch
+ and build_sailfishos.local_target_matches_release(target, normalized_release)
+ ]
+ if matching:
+ return matching[0].target
+ if normalized_release == "live":
+ return arch
+ return f"{arch}-{normalized_release}"
+
+
+def _main_sdk_target(target: str) -> str:
+ if target.endswith(".default"):
+ return target
+ for suffix in (".build-sailfishos-skill", ".build"):
+ if target.endswith(suffix):
+ target = target[: -len(suffix)]
+ break
+ return f"{target}.default"
+
+
+def _local_sdk_docker_argv(local_sdk: Path, command: list[str]) -> list[str]:
+ user = build_sailfishos.host_user()
+ uid = os.getuid()
+ gid = os.getgid()
+ home = str(Path.home().resolve())
+ image = build_sailfishos.local_sdk_build_engine_image(user)
+ sdk_mount_root = build_sailfishos.local_sdk_mount_root(local_sdk)
+ inner = remote_command(command)
+ wrapper_command = f"""
+set -euo pipefail
+if [ ! -x "$LOCAL_SDK" ]; then
+ echo "Installed Sailfish SDK chroot not found or not executable at $LOCAL_SDK" >&2
+ exit 1
+fi
+if getent passwd mersdk >/dev/null 2>&1; then
+ sed -i 's#^mersdk:[^:]*:[0-9]*:[0-9]*:[^:]*:[^:]*:#{user}:x:{uid}:{gid}::{home}:#' /etc/passwd
+elif ! getent passwd {shlex.quote(user)} >/dev/null 2>&1; then
+ printf '%s:x:%s:%s::%s:/bin/bash\\n' {shlex.quote(user)} {uid} {gid} {shlex.quote(home)} >> /etc/passwd
+fi
+"$LOCAL_SDK" -u {shlex.quote(user)} {inner}
+""".strip()
+ return [
+ "docker",
+ "run",
+ "--rm",
+ "--privileged",
+ "-v",
+ f"{sdk_mount_root}:{sdk_mount_root}",
+ "-e",
+ f"LOCAL_SDK={local_sdk}",
+ image,
+ "bash",
+ "-lc",
+ wrapper_command,
+ ]
+
+
def _touch_discover_command(include_evdev_trace: bool) -> str:
evdev_trace = """
if command -v evdev_trace >/dev/null 2>&1; then
@@ -758,6 +1287,27 @@ def command_result(
}
+def combined_result(title: str, steps: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
+ text_parts: list[str] = []
+ structured_steps: dict[str, Any] = {}
+ is_error = False
+ for name, result in steps:
+ content = result.get("content") or []
+ step_text = content[0].get("text", "") if content else ""
+ text_parts.append(f"{name}:\n{step_text}".rstrip())
+ structured_steps[name] = result.get("structuredContent", {})
+ is_error = is_error or bool(result.get("isError", False))
+ return {
+ "content": [{"type": "text", "text": "\n\n".join(text_parts)}],
+ "structuredContent": {
+ "title": title,
+ "step_order": [name for name, _ in steps],
+ "steps": structured_steps,
+ },
+ "isError": is_error,
+ }
+
+
def ok_text(text: str, structured: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": text}],
@@ -850,6 +1400,17 @@ def _string_list_arg(args: dict[str, Any], name: str) -> list[str]:
return value
+def _command_list_arg(args: dict[str, Any], name: str) -> list[str]:
+ value = args.get(name)
+ if (
+ not isinstance(value, list)
+ or not value
+ or not all(isinstance(item, str) and item for item in value)
+ ):
+ raise ValueError(f"{name} must be a non-empty list of strings")
+ return value
+
+
def _service_unit_arg(args: dict[str, Any], name: str) -> str:
unit = _str_arg(args, name)
if not re.fullmatch(r"[A-Za-z0-9@_.:\-]+", unit):
@@ -862,7 +1423,7 @@ def _safe_input_path(config: Config, value: str, *, allow_tmp: bool) -> Path:
if not path.is_absolute():
path = config.paths.git_root / path
path = path.resolve(strict=False)
- roots = [config.paths.git_root.resolve(strict=False)]
+ roots = _host_path_roots(config)
if allow_tmp:
roots.append(Path("/tmp").resolve(strict=False))
if not _is_relative_to_any(path, roots):
@@ -875,12 +1436,21 @@ def _safe_output_path(config: Config, value: str) -> Path:
if not path.is_absolute():
path = config.paths.git_root / path
path = path.resolve(strict=False)
- roots = [config.paths.git_root.resolve(strict=False), Path("/tmp").resolve(strict=False)]
+ roots = _host_path_roots(config) + [Path("/tmp").resolve(strict=False)]
if not _is_relative_to_any(path, roots):
raise ValueError(f"output path is outside allowed roots: {path}")
return path
+def _host_path_roots(config: Config) -> list[Path]:
+ roots: list[Path] = []
+ for root in (config.paths.git_root, config.paths.obs_root):
+ resolved = root.resolve(strict=False)
+ if resolved not in roots:
+ roots.append(resolved)
+ return roots
+
+
def _is_relative_to_any(path: Path, roots: list[Path]) -> bool:
for root in roots:
try:
@@ -1083,6 +1653,49 @@ def _spec_device_touch() -> dict[str, Any]:
}
+def _spec_device_touch_workflow() -> dict[str, Any]:
+ return {
+ "name": "sailfish_device_touch_workflow",
+ "title": "Screenshot And Touch",
+ "description": "Capture a Lipstick screenshot, optionally list touch inputs, then inject a tap or swipe.",
+ "inputSchema": _object_schema(
+ {
+ "device": _device_prop(),
+ "action": {"type": "string", "enum": ["tap", "swipe"]},
+ "input_device": {
+ "type": "string",
+ "description": "Optional explicit device path, for example /dev/input/event5.",
+ },
+ "x": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "y": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "hold_ms": {"type": "integer", "minimum": 1, "maximum": 5000, "default": 80},
+ "start_x": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "start_y": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "end_x": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "end_y": {"type": "integer", "minimum": 0, "maximum": 10000},
+ "duration_ms": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 300},
+ "steps": {"type": "integer", "minimum": 1, "maximum": 200, "default": 12},
+ "screenshot_before": {"type": "boolean", "default": True},
+ "screenshot_after": {"type": "boolean", "default": False},
+ "discover_input": {"type": "boolean", "default": True},
+ "include_evdev_trace": {"type": "boolean", "default": False},
+ "privileged": {"type": "boolean", "default": True},
+ "local_path": {
+ "type": "string",
+ "description": "Optional local path for the before screenshot.",
+ },
+ "before_local_path": {"type": "string"},
+ "after_local_path": {"type": "string"},
+ "before_remote_path": {"type": "string"},
+ "after_remote_path": {"type": "string"},
+ "timeout": _timeout_prop(30),
+ },
+ ["action"],
+ ),
+ "annotations": _mutating_annotations("Screenshot And Touch"),
+ }
+
+
def _spec_device_user_bus_call() -> dict[str, Any]:
return {
"name": "sailfish_device_user_bus_call",
@@ -1108,6 +1721,33 @@ def _spec_device_user_bus_call() -> dict[str, Any]:
}
+def _spec_device_user_session_command() -> dict[str, Any]:
+ return {
+ "name": "sailfish_device_user_session_command",
+ "title": "User Session Command",
+ "description": "Run a command with the configured Sailfish user-session D-Bus environment.",
+ "inputSchema": _object_schema(
+ {
+ "device": _device_prop(),
+ "command": {
+ "type": "array",
+ "items": {"type": "string"},
+ "minItems": 1,
+ "description": "Command argv to run; shell parsing is not applied.",
+ },
+ "run_as_user": {
+ "type": "boolean",
+ "default": False,
+ "description": "Run through runuser/su as the configured device username.",
+ },
+ "timeout": _timeout_prop(30),
+ },
+ ["command"],
+ ),
+ "annotations": _mutating_annotations("User Session Command"),
+ }
+
+
def _spec_device_install_rpm() -> dict[str, Any]:
return {
"name": "sailfish_device_install_rpm",
@@ -1150,11 +1790,30 @@ def _spec_device_restart_service() -> dict[str, Any]:
}
+def _spec_device_browser_launch() -> dict[str, Any]:
+ return {
+ "name": "sailfish_device_browser_launch",
+ "title": "Launch Sailfish Browser",
+ "description": "Stop stale browser state, launch Sailfish Browser with display/session env, and report topmost PID details.",
+ "inputSchema": _object_schema(
+ {
+ "device": _device_prop(),
+ "url": {"type": "string"},
+ "stop_stale": {"type": "boolean", "default": True},
+ "wait_seconds": {"type": "integer", "minimum": 0, "maximum": 60, "default": 3},
+ "timeout": _timeout_prop(60),
+ },
+ ["url"],
+ ),
+ "annotations": _mutating_annotations("Launch Sailfish Browser"),
+ }
+
+
def _spec_build_rpm() -> dict[str, Any]:
return {
"name": "sailfish_build_rpm",
"title": "Build Sailfish RPM",
- "description": "Run the local build-sailfishos helper; paths.local_sdk is used only when it has a matching target.",
+ "description": "Start an asynchronous RPM build; paths.local_sdk defaults to the live installed SDK, with public SDK fallback only for explicit named releases.",
"inputSchema": _object_schema(
{
"project_path": {"type": "string"},
@@ -1175,6 +1834,11 @@ def _spec_build_rpm() -> dict[str, Any]:
"debug": {"type": "boolean", "default": False},
"no_pull": {"type": "boolean", "default": False},
"local_rpms_dir": {"type": "array", "items": {"type": "string"}},
+ "wait": {
+ "type": "boolean",
+ "default": False,
+ "description": "Wait for the build and return the old synchronous command result.",
+ },
"timeout": _timeout_prop(3600),
},
["project_path"],
@@ -1183,6 +1847,63 @@ def _spec_build_rpm() -> dict[str, Any]:
}
+def _spec_build_status() -> dict[str, Any]:
+ return {
+ "name": "sailfish_build_status",
+ "title": "Build Job Status",
+ "description": "Read status and recent log output for asynchronous Sailfish RPM build jobs.",
+ "inputSchema": _object_schema(
+ {
+ "job_id": {
+ "type": "string",
+ "description": "Build job id returned by sailfish_build_rpm. Omit to list recent jobs.",
+ },
+ "lines": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000,
+ "default": 80,
+ "description": "Number of trailing log lines to include.",
+ },
+ }
+ ),
+ "annotations": _read_only_annotations("Build Job Status"),
+ }
+
+
+def _spec_sdk_refresh_metadata() -> dict[str, Any]:
+ return {
+ "name": "sailfish_sdk_refresh_metadata",
+ "title": "Refresh SDK Metadata",
+ "description": "Refresh zypper repository metadata in an installed local SDK main target.",
+ "inputSchema": _object_schema(
+ {
+ "device": {
+ "type": "string",
+ "description": "Optional configured device to supply default release and architecture.",
+ },
+ "local_sdk": {
+ "type": "string",
+ "description": "Override paths.local_sdk for this call.",
+ },
+ "target": {
+ "type": "string",
+ "description": "Local SDK target base or .default target, for example aarch64 or aarch64.default.",
+ },
+ "release": {"type": "string"},
+ "arch": {"type": "string"},
+ "force": {
+ "type": "boolean",
+ "default": False,
+ "description": "Append zypper ref -f.",
+ },
+ "timeout": _timeout_prop(600),
+ }
+ ),
+ "annotations": _mutating_annotations("Refresh SDK Metadata"),
+ }
+
+
def _spec_obs_results() -> dict[str, Any]:
return {
"name": "sailfish_obs_results",
@@ -1205,7 +1926,7 @@ def _spec_obs_buildlog() -> dict[str, Any]:
return {
"name": "sailfish_obs_buildlog",
"title": "OBS Build Log",
- "description": "Fetch an OBS remote build log with osc.",
+ "description": "Fetch an OBS build log with osc; defaults to the API nostream form to avoid live streams.",
"inputSchema": _object_schema(
{
"project": {"type": "string"},
@@ -1213,6 +1934,7 @@ def _spec_obs_buildlog() -> dict[str, Any]:
"repository": {"type": "string"},
"arch": {"type": "string"},
"api_alias": {"type": "string"},
+ "nostream": {"type": "boolean", "default": True},
"timeout": _timeout_prop(90),
},
["project", "package", "repository", "arch"],
diff --git a/src/sailfish_devel_mcp/vendor/build_sailfishos.py b/src/sailfish_devel_mcp/vendor/build_sailfishos.py
index 98b52c0..c9ad480 100755
--- a/src/sailfish_devel_mcp/vendor/build_sailfishos.py
+++ b/src/sailfish_devel_mcp/vendor/build_sailfishos.py
@@ -848,12 +848,9 @@ def normalize_local_release(release: str | None) -> str:
def requested_release(project_dirs: Iterable[Path], explicit_release: str | None) -> str | None:
- if explicit_release:
- return explicit_release
-
- env_release = os.environ.get("SAILFISHOS_RELEASE")
- if env_release:
- return env_release
+ requested = explicit_requested_release(explicit_release)
+ if requested:
+ return requested
seen: set[Path] = set()
for project_dir in project_dirs:
@@ -867,6 +864,21 @@ def requested_release(project_dirs: Iterable[Path], explicit_release: str | None
return None
+def explicit_requested_release(explicit_release: str | None) -> str | None:
+ if explicit_release:
+ return explicit_release
+
+ env_release = os.environ.get("SAILFISHOS_RELEASE")
+ if env_release:
+ return env_release
+
+ return None
+
+
+def local_sdk_requested_release(explicit_release: str | None) -> str:
+ return normalize_local_release(explicit_requested_release(explicit_release)) or LIVE_RELEASE
+
+
def select_local_sdk_builds(
local_sdk: Path,
release: str,
@@ -1334,11 +1346,10 @@ def main() -> int:
project_dir = resolve_project_dir(requested_project_dir)
local_sdk_path = Path(args.local_sdk).expanduser().resolve(strict=False) if args.local_sdk else None
- raw_release = requested_release((requested_project_dir, project_dir), args.release)
- local_release = normalize_local_release(raw_release) or LIVE_RELEASE
local_builds: list[LocalSdkBuild] | None = None
if local_sdk_path:
+ local_release = local_sdk_requested_release(args.release)
local_builds = select_local_sdk_builds(
local_sdk_path,
local_release,