feat: add cortex_status and cortex_update tools

cortex_status: git branch/commit/ahead-behind + systemctl state — read-only
cortex_update: git pull + syntax check all .py files + report; does NOT auto-restart.
  If syntax errors are found after pull, warns and blocks restart suggestion.
  Call cortex_restart separately to apply a clean update.

Both are admin-only. cortex_update is confirm-required (modifies files on disk).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-30 22:01:42 -04:00
parent 1ffa846edd
commit 77997bc4ae
3 changed files with 159 additions and 3 deletions

View File

@@ -66,7 +66,7 @@ The ⚡ toggle is **independent of the Role selector** — you can use any role
| Web | `web_search`, `http_fetch` | | Web | `web_search`, `http_fetch` |
| Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² | | Files | `file_read` ¹, `file_list` ¹, `file_write` ¹ ² |
| Shell | `shell_exec` ¹ ², `claude_allow_dir` ¹ | | 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` | | Tasks | `task_list`, `task_create`, `task_update`, `task_complete` |
| Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` | | Cron | `cron_list`, `cron_add`, `cron_remove` ², `cron_toggle` |
| Reminders | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` ² | | Reminders | `reminders_add`, `reminders_list`, `reminders_remove`, `reminders_clear` ² |

View File

@@ -48,7 +48,12 @@ from tools.scratch import (
scratch_append as _scratch_append, scratch_append as _scratch_append,
scratch_clear as _scratch_clear, 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.web import http_fetch as _http_fetch
from tools.files import file_list as _file_list, file_write as _file_write 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 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, "shell_exec": _shell_exec,
"cortex_restart": _cortex_restart, "cortex_restart": _cortex_restart,
"cortex_logs": _cortex_logs, "cortex_logs": _cortex_logs,
"cortex_status": _cortex_status,
"cortex_update": _cortex_update,
"http_fetch": _http_fetch, "http_fetch": _http_fetch,
"email_send": _email_send, "email_send": _email_send,
"nc_talk_send": _nc_talk_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( _http_fetch_declaration = types.FunctionDeclaration(
name="http_fetch", name="http_fetch",
description=( description=(
@@ -919,6 +948,8 @@ TOOL_ROLES: dict[str, str] = {
"claude_allow_dir": "admin", "claude_allow_dir": "admin",
"cortex_restart": "admin", "cortex_restart": "admin",
"cortex_logs": "admin", "cortex_logs": "admin",
"cortex_status": "admin",
"cortex_update": "admin",
"file_read": "admin", "file_read": "admin",
"file_list": "admin", "file_list": "admin",
"file_write": "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. # the tool, prompting Claude to ask the user to confirm in a follow-up message.
CONFIRM_REQUIRED: set[str] = { CONFIRM_REQUIRED: set[str] = {
"cortex_restart", "cortex_restart",
"cortex_update",
"file_write", "file_write",
"shell_exec", "shell_exec",
"cron_remove", "cron_remove",
@@ -966,6 +998,8 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = [
_shell_exec_declaration, _shell_exec_declaration,
_cortex_restart_declaration, _cortex_restart_declaration,
_cortex_logs_declaration, _cortex_logs_declaration,
_cortex_status_declaration,
_cortex_update_declaration,
_http_fetch_declaration, _http_fetch_declaration,
_email_send_declaration, _email_send_declaration,
_nc_talk_send_declaration, _nc_talk_send_declaration,

View File

@@ -2,16 +2,21 @@
System tools — local machine operations. System tools — local machine operations.
These tools affect the host system directly. Use with care. 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 asyncio
import logging import logging
import os import os
import subprocess import subprocess
from pathlib import Path
logger = logging.getLogger(__name__) 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" 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: except Exception as e:
logger.error("cortex_logs error: %s", e) logger.error("cortex_logs error: %s", e)
return f"Error: {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)