diff --git a/cortex/static/HELP.md b/cortex/static/HELP.md index 1b09dc6..e5f84da 100644 --- a/cortex/static/HELP.md +++ b/cortex/static/HELP.md @@ -66,7 +66,7 @@ The ⚡ toggle is **independent of the Role selector** — you can use any role | Web | `web_search`, `http_fetch` | | Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² | | Shell | `shell_exec` ¹ ², `claude_allow_dir` ¹ | -| System | `cortex_restart` ¹ ², `cortex_logs` ¹ | +| System | `cortex_restart` ¹ ², `cortex_logs` ¹, `cortex_status` ¹, `cortex_update` ¹ ² | | Tasks | `task_list`, `task_create`, `task_update`, `task_complete` | | Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` | | Reminders | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` ² | diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 361cea4..8d4b037 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -48,7 +48,12 @@ from tools.scratch import ( scratch_append as _scratch_append, scratch_clear as _scratch_clear, ) -from tools.system import cortex_restart as _cortex_restart, cortex_logs as _cortex_logs +from tools.system import ( + cortex_restart as _cortex_restart, + cortex_logs as _cortex_logs, + cortex_status as _cortex_status, + cortex_update as _cortex_update, +) from tools.web import http_fetch as _http_fetch from tools.files import file_list as _file_list, file_write as _file_write from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send @@ -389,6 +394,8 @@ _CALLABLES: dict[str, callable] = { "shell_exec": _shell_exec, "cortex_restart": _cortex_restart, "cortex_logs": _cortex_logs, + "cortex_status": _cortex_status, + "cortex_update": _cortex_update, "http_fetch": _http_fetch, "email_send": _email_send, "nc_talk_send": _nc_talk_send, @@ -793,6 +800,28 @@ _cortex_logs_declaration = types.FunctionDeclaration( ), ) +_cortex_status_declaration = types.FunctionDeclaration( + name="cortex_status", + description=( + "Return Cortex service status: current git branch and commit, how many commits " + "ahead/behind the remote, and the systemctl service state. " + "Use to check what version is running or whether the service is healthy. " + "ADMIN ONLY." + ), + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + +_cortex_update_declaration = types.FunctionDeclaration( + name="cortex_update", + description=( + "Pull the latest code from git, run a syntax check on all Python files, and report " + "what changed. Does NOT restart automatically — call cortex_restart separately after " + "reviewing the output. Will report syntax errors if the pull introduces broken code. " + "ADMIN ONLY. Requires confirmation." + ), + parameters=types.Schema(type=types.Type.OBJECT, properties={}), +) + _http_fetch_declaration = types.FunctionDeclaration( name="http_fetch", description=( @@ -919,6 +948,8 @@ TOOL_ROLES: dict[str, str] = { "claude_allow_dir": "admin", "cortex_restart": "admin", "cortex_logs": "admin", + "cortex_status": "admin", + "cortex_update": "admin", "file_read": "admin", "file_list": "admin", "file_write": "admin", @@ -932,6 +963,7 @@ TOOL_ROLES: dict[str, str] = { # the tool, prompting Claude to ask the user to confirm in a follow-up message. CONFIRM_REQUIRED: set[str] = { "cortex_restart", + "cortex_update", "file_write", "shell_exec", "cron_remove", @@ -966,6 +998,8 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = [ _shell_exec_declaration, _cortex_restart_declaration, _cortex_logs_declaration, + _cortex_status_declaration, + _cortex_update_declaration, _http_fetch_declaration, _email_send_declaration, _nc_talk_send_declaration, diff --git a/cortex/tools/system.py b/cortex/tools/system.py index a0e4ea3..fe9959b 100644 --- a/cortex/tools/system.py +++ b/cortex/tools/system.py @@ -2,16 +2,21 @@ System tools — local machine operations. These tools affect the host system directly. Use with care. -cortex_restart and cortex_logs require admin role. +All tools in this module require the admin role. """ import asyncio import logging import os import subprocess +from pathlib import Path logger = logging.getLogger(__name__) +# Absolute paths — resolved relative to this file so they work regardless of cwd +_CORTEX_DIR = Path(__file__).parent # .../Cortex_and_Inara_dev/cortex/ +_PROJECT_ROOT = _CORTEX_DIR.parent # .../Cortex_and_Inara_dev/ + ALLOW_SCRIPT = "/home/scott/.local/bin/claude-allow-dir" @@ -124,3 +129,120 @@ async def cortex_logs(lines: int = 50) -> str: except Exception as e: logger.error("cortex_logs error: %s", e) return f"Error: {e}" + + +async def cortex_status() -> str: + """Return Cortex service status: git branch/commit, ahead/behind remote, and systemctl state.""" + lines = [] + + async def _git(*args: str) -> str: + proc = await asyncio.create_subprocess_exec( + "git", "-C", str(_PROJECT_ROOT), *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + return stdout.decode(errors="replace").strip() + + try: + branch = await _git("rev-parse", "--abbrev-ref", "HEAD") + commit = await _git("log", "--oneline", "-1") + # fetch quietly so ahead/behind is current + await asyncio.create_subprocess_exec( + "git", "-C", str(_PROJECT_ROOT), "fetch", "--quiet", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + ) + ahead_behind = await _git("rev-list", "--left-right", "--count", f"HEAD...origin/{branch}") + ahead, behind = (ahead_behind.split() + ["?", "?"])[:2] + + lines.append(f"**Branch:** {branch} | ahead {ahead} / behind {behind}") + lines.append(f"**Commit:** {commit}") + except Exception as e: + lines.append(f"Git info unavailable: {e}") + + lines.append("") + + try: + proc = await asyncio.create_subprocess_exec( + "systemctl", "--user", "status", "cortex", "--no-pager", "-l", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + # First 15 lines of systemctl output is enough — avoids log flood + status_lines = stdout.decode(errors="replace").splitlines()[:15] + lines.extend(status_lines) + except Exception as e: + lines.append(f"systemctl status unavailable: {e}") + + return "\n".join(lines) + + +async def cortex_update() -> str: + """Pull the latest code from git, syntax-check all Python files, and report. + + Does NOT restart automatically — call cortex_restart separately after reviewing + the output if you want to apply changes. + """ + lines = [] + + async def _run(*cmd: str, cwd: Path = _PROJECT_ROOT, timeout: int = 30) -> tuple[int, str]: + proc = await asyncio.create_subprocess_exec( + *cmd, cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + return proc.returncode, stdout.decode(errors="replace").strip() + + # 1. Check for incoming commits before pulling + try: + await _run("git", "fetch", "--quiet") + rc, incoming = await _run("git", "log", "--oneline", "HEAD..origin/HEAD") + if rc == 0 and not incoming: + # Double-check with branch name in case origin/HEAD isn't set + branch_rc, branch = await _run("git", "rev-parse", "--abbrev-ref", "HEAD") + _, incoming = await _run("git", "log", "--oneline", f"HEAD..origin/{branch.strip()}") + except asyncio.TimeoutError: + return "Error: git fetch timed out — check network connectivity." + except Exception as e: + return f"Error during git fetch: {e}" + + if not incoming: + rc2, current = await _run("git", "log", "--oneline", "-1") + return f"Already up to date.\n\nCurrent commit: {current}" + + lines.append(f"**Incoming commits:**\n{incoming}\n") + + # 2. Pull + try: + rc, pull_out = await _run("git", "pull", "--ff-only") + except asyncio.TimeoutError: + return "Error: git pull timed out." + except Exception as e: + return f"Error during git pull: {e}" + + if rc != 0: + return f"git pull failed (exit {rc}):\n{pull_out}" + + lines.append(f"**git pull:**\n{pull_out}\n") + + # 3. Syntax check all Python files under cortex/ + py_files = sorted(_CORTEX_DIR.rglob("*.py")) + errors = [] + for f in py_files: + result = subprocess.run( + ["python3", "-m", "py_compile", str(f)], + capture_output=True, text=True, + ) + if result.returncode != 0: + errors.append(f" {f.relative_to(_PROJECT_ROOT)}: {result.stderr.strip()}") + + if errors: + lines.append(f"**Syntax errors — do NOT restart until fixed:**") + lines.extend(errors) + else: + lines.append(f"**Syntax check:** {len(py_files)} files — all OK.") + lines.append("Call `cortex_restart` to apply the update.") + + return "\n".join(lines)