""" System tools — local machine operations. These tools affect the host system directly. Use with care. cortex_restart and cortex_logs require admin role. """ import asyncio import logging import os import subprocess logger = logging.getLogger(__name__) ALLOW_SCRIPT = "/home/scott/.local/bin/claude-allow-dir" async def claude_allow_dir(path: str, mode: str = "rw") -> str: """Add Read/Edit allow rules to ~/.claude/settings.json for a directory. Calls the claude-allow-dir script, which edits settings.json directly. Changes take effect in the next Claude Code session (or after /hooks reload). """ if mode not in ("r", "w", "rw"): return f"Error: mode must be r, w, or rw (got '{mode}')" try: proc = await asyncio.create_subprocess_exec( "python3", ALLOW_SCRIPT, path, mode, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) output = stdout.decode().strip() err = stderr.decode().strip() if proc.returncode != 0: logger.warning("claude-allow-dir failed (rc=%d): %s", proc.returncode, err) return f"Failed (exit {proc.returncode}): {err or output}" return output or "Done." except asyncio.TimeoutError: return "Error: script timed out" except Exception as e: logger.error("claude_allow_dir error: %s", e) return f"Error: {e}" async def shell_exec(command: str, working_dir: str | None = None, timeout: int = 30) -> str: """Execute a shell command on the Cortex host and return combined stdout/stderr.""" timeout = min(max(timeout, 1), 120) cwd = None if working_dir: cwd = os.path.expanduser(working_dir) if not os.path.isdir(cwd): return f"Error: working_dir '{working_dir}' does not exist or is not a directory" try: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) out = stdout.decode(errors="replace").strip() err = stderr.decode(errors="replace").strip() parts = [] if out: parts.append(out) if err: parts.append(f"[stderr]\n{err}") combined = "\n".join(parts) if parts else "(no output)" if proc.returncode != 0: return f"Exit {proc.returncode}:\n{combined}" return combined except asyncio.TimeoutError: return f"Error: command timed out after {timeout}s" except Exception as e: logger.error("shell_exec error: %s", e) return f"Error: {e}" async def cortex_restart() -> str: """Schedule a Cortex service restart 5 seconds from now. Uses a detached subprocess so the restart survives the current process being terminated by systemd. The calling session will drop — user should refresh. """ subprocess.Popen( ["bash", "-c", "sleep 5 && systemctl --user restart cortex"], start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, ) logger.info("cortex_restart: restart scheduled in 5 seconds") return ( "Cortex restart scheduled in 5 seconds. " "The current connection will drop — please refresh the page after a moment." ) async def cortex_logs(lines: int = 50) -> str: """Return recent lines from the Cortex systemd journal.""" n = min(max(int(lines), 1), 200) try: proc = await asyncio.create_subprocess_exec( "journalctl", "--user", "-u", "cortex", f"-n{n}", "--no-pager", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15) out = stdout.decode(errors="replace").strip() return out or stderr.decode(errors="replace").strip() or "No log output." except asyncio.TimeoutError: return "Error: journalctl timed out" except Exception as e: logger.error("cortex_logs error: %s", e) return f"Error: {e}"