Files
community-plugins/claude-companion/shim/noctalia-mcp.py
T
Jarred RobinsonandGitHub 14215c8cd4 feat: add claude-companion (#33)
* Add lowcache/claude-companion plugin for PR

* claude-companion: address review feedback

...

* Added tr & timeout to dependency list, and reduced thumbnail description

* added missing thumbnail.webp
2026-07-19 08:46:30 -04:00

560 lines
23 KiB
Python

#!/usr/bin/env python3
"""noctalia-mcp — stdio MCP shim bridging Claude Code <-> the Noctalia shell.
SENSES (shell -> Claude): query the env directly — the running compositor
(niri / Hyprland / Sway), playerctl, /sys, and
`noctalia msg status` for shell-internal state.
HANDS (Claude -> shell): `noctalia msg <action>` (request/response; reply on
stdout, "error:" prefix on failure). Desktop notifications
go through notify-send: noctalia exposes no generic notify
IPC (notifications are shell-internal / luau-only).
MCP transport: newline-delimited JSON-RPC 2.0 over stdio (one message per line,
no embedded newlines) — the stdio transport Claude Code speaks. Spawned by Claude
via --mcp-config; no daemon to babysit.
Tool set is the low/medium perception tier only — high-tier senses
(clipboard/screen/files) stay gated until a local backend lands.
"""
import datetime
import json
import os
import re
import subprocess
import sys
import tempfile
PROTOCOL_VERSION = "2024-11-05"
SERVER_INFO = {"name": "noctalia-mcp", "version": "0.1.0"}
def sh(args, timeout=5):
"""Run argv (no shell), return stdout or an 'error: ...' string."""
try:
out = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return (out.stdout or out.stderr).strip()
except Exception as e: # noqa: BLE001
return f"error: {e}"
# ── compositor abstraction ────────────────────────────────────────────────────
# The window/workspace ops are the ONLY compositor-coupled surface. Everything
# else (`noctalia msg`, playerctl, nmcli, /sys, notify-send) is compositor-neutral.
# Noctalia runs on niri, Hyprland, and Sway but exposes no generic window/workspace
# IPC (`noctalia msg` has no such verbs), so we speak each compositor's own protocol.
# Detection prefers the compositor's own socket env var, then XDG_CURRENT_DESKTOP.
#
# Command shapes: niri verified live; Hyprland against HyprCtl.cpp + the dispatcher
# wiki; Sway against sway-ipc(7)/sway(5). The pure helpers below (compositor_argv,
# pick_focused_monitor, find_focused_view, sway_focused_output) do no I/O, so they
# are unit-tested without a live session — see tests/shim_spec.py.
SUPPORTED_COMPOSITORS = ("niri", "hyprland", "sway")
def detect_compositor(env=None):
"""Identify the running compositor, or None if unsupported/undetectable."""
env = os.environ if env is None else env
if env.get("NIRI_SOCKET"):
return "niri"
if env.get("HYPRLAND_INSTANCE_SIGNATURE"):
return "hyprland"
if env.get("SWAYSOCK"):
return "sway"
xdg = (env.get("XDG_CURRENT_DESKTOP") or "").lower()
return next((c for c in SUPPORTED_COMPOSITORS if c in xdg), None)
def _ws_numeric(ref):
"""A workspace ref is 'numeric' if it is a plain (optionally signed) integer."""
return str(ref).lstrip("-").isdigit()
# Caller-supplied identifiers are VALIDATED, not quoted: swaymsg concatenates its
# argv into one command in Sway's command language, where `;`/`,` chain further
# commands (`exec` included) — so a hostile ref could smuggle arbitrary execution
# past a narrowly approved window/workspace action. Strict allowlists; every id
# the compositors themselves emit fits (niri/Sway integer ids, Hyprland 0x-hex
# addresses, workspace indices, single-word names).
_WINDOW_ID_RE = re.compile(r"(?:0x[0-9A-Fa-f]+|[0-9]+)\Z")
_WS_REF_RE = re.compile(r"[A-Za-z0-9._:+-]+\Z")
def valid_window_id(wid):
"""True iff wid is a compositor window id: decimal or 0x-prefixed hex."""
return _WINDOW_ID_RE.fullmatch(str(wid)) is not None
def valid_workspace_ref(ref):
"""True iff ref is a safe workspace index/name — no spaces and none of Sway's
command metacharacters (separators, quotes, criteria brackets)."""
return _WS_REF_RE.fullmatch(str(ref)) is not None
def compositor_argv(comp, op, ref=None, wid=None):
"""Pure map (compositor, op) -> argv; no I/O, so it is directly unit-testable.
For query ops that need client-side filtering (sway focused_*, hyprland
focused_output) this returns the *query* argv and the caller does the filtering.
Workspace refs: Hyprland needs a `name:` prefix for named workspaces; Sway needs
the `number` keyword for numeric ones (else the int is matched as a literal name).
Move semantics differ slightly and are documented, not normalized: niri and
Hyprland follow focus to the target workspace, Sway does not."""
if comp == "niri":
return {
"focused_output": ["niri", "msg", "-j", "focused-output"],
"focused_window": ["niri", "msg", "-j", "focused-window"],
"focus_window": ["niri", "msg", "action", "focus-window", "--id", str(wid)],
"focus_workspace": ["niri", "msg", "action", "focus-workspace", str(ref)],
"move_to_workspace": ["niri", "msg", "action", "move-column-to-workspace", str(ref)],
}[op]
if comp == "hyprland":
hws = str(ref) if _ws_numeric(ref) else "name:" + str(ref)
return {
"focused_output": ["hyprctl", "-j", "monitors"],
"focused_window": ["hyprctl", "-j", "activewindow"],
"focus_window": ["hyprctl", "dispatch", "focuswindow", "address:" + str(wid)],
"focus_workspace": ["hyprctl", "dispatch", "workspace", hws],
"move_to_workspace": ["hyprctl", "dispatch", "movetoworkspace", hws],
}[op]
if comp == "sway":
sws = ["number", str(ref)] if _ws_numeric(ref) else [str(ref)]
return {
"focused_output": ["swaymsg", "-t", "get_outputs"],
"focused_window": ["swaymsg", "-t", "get_tree"],
"focus_window": ["swaymsg", "[con_id=%s]" % wid, "focus"],
"focus_workspace": ["swaymsg", "workspace"] + sws,
"move_to_workspace": ["swaymsg", "move", "container", "to", "workspace"] + sws,
}[op]
raise KeyError((comp, op))
def pick_focused_monitor(mons):
"""Hyprland `monitors` array -> the focused monitor object (or None)."""
return next((m for m in mons if m.get("focused")), None)
def find_focused_view(node):
"""Sway `get_tree` -> the focused leaf view (con/floating_con), recursively.
Constrained to view node types so an ancestor workspace/output that also
reports focused does not shadow the actual window."""
if node.get("focused") and node.get("type") in ("con", "floating_con"):
return node
for child in node.get("nodes", []) + node.get("floating_nodes", []):
hit = find_focused_view(child)
if hit:
return hit
return None
def sway_focused_output(outputs, workspaces):
"""Sway output objects carry no `focused` flag [SUPPORTED, sway-ipc(7)]; derive
the focused output from the focused workspace's `output` name."""
ws = next((w for w in workspaces if w.get("focused")), None)
if not ws:
return None
return next((o for o in outputs if o.get("name") == ws.get("output")), None)
def _no_comp():
return "error: no supported compositor detected (niri/hyprland/sway)"
def _run_json(argv):
"""Run argv and parse stdout as JSON: (obj, None) on success, (None, err) else."""
out = sh(argv)
if out.startswith("error:"):
return None, out
try:
return json.loads(out), None
except Exception as e: # noqa: BLE001
return None, f"error: bad JSON from {argv[0]}: {e}"
def _get_workspace(a):
"""Focused output/monitor (connector, mode, current workspace), compositor-native JSON."""
comp = detect_compositor()
if comp is None:
return _no_comp()
if comp == "niri":
return sh(compositor_argv(comp, "focused_output"))
if comp == "hyprland":
mons, err = _run_json(compositor_argv(comp, "focused_output"))
if err:
return err
foc = pick_focused_monitor(mons)
return json.dumps(foc) if foc else "error: no focused monitor"
outs, err = _run_json(["swaymsg", "-t", "get_outputs"])
if err:
return err
wss, err = _run_json(["swaymsg", "-t", "get_workspaces"])
if err:
return err
foc = sway_focused_output(outs, wss)
return json.dumps(foc) if foc else "error: no focused output"
def _get_window(a):
"""Focused window (app id/class + title), compositor-native JSON."""
comp = detect_compositor()
if comp is None:
return _no_comp()
if comp in ("niri", "hyprland"):
return sh(compositor_argv(comp, "focused_window"))
tree, err = _run_json(compositor_argv(comp, "focused_window"))
if err:
return err
view = find_focused_view(tree)
return json.dumps(view) if view else "error: no focused window"
def _remember(a):
"""Persist a durable fact to GLOBAL memory's inbox; memd distills ~/.memory.
The membrane (decision #25): ephemeral senses -> noctalia.state; durable
learnings -> global memd. This is the durable side. Notes are routed:global
so memd's curator files them into the system-wide store, not a project."""
# Coerce: a model may send a non-string (number/list) — str() keeps the
# handler (and the server) from raising on .strip()/.lower().
text = str(a.get("text") or "").strip()
if not text:
return "error: 'text' is required"
slug = re.sub(r"[^a-z0-9]+", "-", str(a.get("topic") or "note").lower()).strip("-") or "note"
mem = os.path.expanduser("~/.memory")
inbox = os.path.join(mem, "inbox")
body = (
f"---\nrouted: global\ntopic: {slug}\n"
f"date: {datetime.date.today()}\nsource: noctalia-mcp/remember\n---\n\n"
f"{text}\n"
)
try:
os.makedirs(inbox, exist_ok=True)
# Concurrency: every Claude session runs its own shim, and the curator
# (memd) reads/clears this inbox in parallel. Two guards:
# 1) Unique name — microsecond timestamp + PID, so simultaneous notes
# from different sessions never collide (second-resolution did).
# 2) Atomic publish — write a temp file OUTSIDE the inbox, then
# os.replace() it in. A sweep either sees the whole note or not at
# all; it can never read a half-written file mid-write.
# 3) Crash durability — fsync the file before publish, then fsync the
# inbox dir after the rename. Without both, a power loss/panic can
# leave a flushed file whose directory entry never landed (lost
# note) or a renamed entry pointing at unflushed data. Cheap
# insurance; matters for an alpha shell on a crash-prone desktop.
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S-%f")
final = os.path.join(inbox, f"{ts}-{slug}-{os.getpid()}.md")
fd, tmp = tempfile.mkstemp(dir=mem, prefix=".remember-", suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
f.write(body)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, final)
dfd = os.open(inbox, os.O_RDONLY)
try:
os.fsync(dfd)
finally:
os.close(dfd)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return f"remembered -> {final}"
except OSError as e:
return f"error: {e}"
# ── additional senses (read-only) ────────────────────────────────────────────
def _get_power(a):
"""Battery level/status + AC state, as JSON. battery=null if none present."""
base = "/sys/class/power_supply"
try:
names = os.listdir(base)
except OSError as e:
return f"error: {e}"
def rd(node, field):
try:
with open(os.path.join(base, node, field)) as fh:
return fh.read().strip()
except OSError:
return None
out = {}
bats = sorted(n for n in names if n.startswith("BAT"))
out["battery"] = (
{b: {"capacity": rd(b, "capacity"), "status": rd(b, "status")} for b in bats}
if bats else None
)
for ac in ("AC", "ACAD", "ADP1", "AC0"):
online = rd(ac, "online")
if online is not None:
out["ac_online"] = online == "1"
break
return json.dumps(out)
def _get_network(a):
"""Connectivity + the active Wi-Fi connection (SSID/signal), as JSON."""
state = sh(["nmcli", "-t", "-f", "STATE,CONNECTIVITY", "general"])
if state.startswith("error:"):
return state
wifi = sh(["nmcli", "-t", "-f", "ACTIVE,SSID,SIGNAL", "dev", "wifi"])
active = next((l for l in wifi.splitlines() if l.startswith("yes:")), "")
return json.dumps({"general": state, "wifi_active": active})
def _get_processes(a):
"""Top processes by CPU (header + top 10), as text."""
out = sh(["ps", "-eo", "pid,pcpu,pmem,comm", "--sort=-pcpu"])
if out.startswith("error:"):
return out
return "\n".join(out.splitlines()[:11])
# ── additional hands (mutate shared desktop state) ────────────────────────────
# These race on the single real desktop across concurrent sessions (last write
# wins) — inherent to a shared environment, not a shim bug. Each is a stateless
# subprocess, so the shim itself stays concurrency-safe.
def _focus_window(a):
wid = str(a.get("id") or "").strip()
if not wid:
return "error: 'id' is required"
if not valid_window_id(wid):
return "error: invalid window id (use the numeric or 0x-hex id from get_window)"
comp = detect_compositor()
if comp is None:
return _no_comp()
return sh(compositor_argv(comp, "focus_window", wid=wid))
def _switch_workspace(a):
ref = str(a.get("reference") or "").strip()
if not ref:
return "error: 'reference' is required"
if not valid_workspace_ref(ref):
return "error: invalid workspace reference (letters/digits/._:+- only, no spaces)"
comp = detect_compositor()
if comp is None:
return _no_comp()
return sh(compositor_argv(comp, "focus_workspace", ref=ref))
def _move_to_workspace(a):
ref = str(a.get("reference") or "").strip()
if not ref:
return "error: 'reference' is required"
if not valid_workspace_ref(ref):
return "error: invalid workspace reference (letters/digits/._:+- only, no spaces)"
comp = detect_compositor()
if comp is None:
return _no_comp()
return sh(compositor_argv(comp, "move_to_workspace", ref=ref))
def _set_wallpaper(a):
"""Set a wallpaper by path, or switch to a random one if no path given."""
path = str(a.get("path") or "").strip()
conn = str(a.get("connector") or "").strip()
if path:
argv = ["noctalia", "msg", "wallpaper-set"] + ([conn] if conn else []) + [path]
else:
argv = ["noctalia", "msg", "wallpaper-random"] + ([conn] if conn else [])
return sh(argv)
# name -> (description, inputSchema properties, handler). Commands verified against
# noctalia 5.0.0 (`noctalia msg --help`); window/workspace ops route through the
# compositor abstraction above (niri / Hyprland / Sway).
TOOLS = {
# ── senses (low/medium tier) ──────────────────────────────────────────────
"get_workspace": (
"Focused output (connector, mode, current workspace) as compositor-native JSON.",
{},
_get_workspace,
),
"get_window": (
"Focused window (app_id/class and title) as compositor-native JSON.",
{},
_get_window,
),
"get_media": (
"Now-playing media (artist - title), or empty if nothing is playing.",
{},
lambda a: sh(["playerctl", "metadata", "--format", "{{artist}} - {{title}}"]),
),
"get_shell_state": (
"Noctalia shell-internal state (active panel, theme mode, etc.) as JSON.",
{},
lambda a: sh(["noctalia", "msg", "status"]),
),
"get_power": (
"Battery level/status and AC state as JSON (battery=null on desktops).",
{},
_get_power,
),
"get_network": (
"Connectivity and the active Wi-Fi connection (SSID/signal) as JSON.",
{},
_get_network,
),
"get_processes": (
"Top processes by CPU (pid, %cpu, %mem, command) as text.",
{},
_get_processes,
),
# ── memory (durable, cross-session) ───────────────────────────────────────
"remember": (
"Persist a durable fact, preference, or system detail to GLOBAL memory "
"(system-wide, cross-project) so future sessions know it. Use for things "
"true beyond the current task; memd distills it into ~/.memory.",
{
"text": {"type": "string", "required": True,
"description": "The durable fact/preference, 1-2 sentences."},
"topic": {"type": "string",
"description": "Short kebab-case slug for the note (optional)."},
},
_remember,
),
# ── hands ─────────────────────────────────────────────────────────────────
"notify": (
"Post a desktop notification.",
{
"title": {"type": "string", "description": "Notification title"},
"body": {"type": "string", "description": "Notification body"},
},
lambda a: sh(["notify-send", a.get("title", "Claude"), a.get("body", "")]),
),
"set_theme_mode": (
"Set the shell light/dark mode.",
{"mode": {"type": "string", "enum": ["dark", "light", "auto"]}},
lambda a: sh(["noctalia", "msg", "theme-mode-set", a.get("mode", "auto")]),
),
"set_color_scheme": (
"Set the active color palette.",
{
"source": {
"type": "string",
"description": "builtin | wallpaper | community | custom",
},
"name": {"type": "string", "description": "Scheme name/id for that source"},
},
lambda a: sh(["noctalia", "msg", "color-scheme-set", a.get("source", "builtin"), a.get("name", "")]),
),
"focus_window": (
"Focus a window by its id (the id/address returned by get_window).",
{"id": {"type": "string", "required": True,
"description": "Window id from get_window (niri id, Hyprland address, Sway con_id)"}},
_focus_window,
),
"switch_workspace": (
"Switch to a workspace by reference (index like '2', or its name).",
{"reference": {"type": "string", "required": True,
"description": "Workspace index or name"}},
_switch_workspace,
),
"move_to_workspace": (
"Move the focused column to a workspace by reference (index or name).",
{"reference": {"type": "string", "required": True,
"description": "Target workspace index or name"}},
_move_to_workspace,
),
"set_wallpaper": (
"Set the wallpaper to an image path, or switch to a random one if no path.",
{"path": {"type": "string",
"description": "Image path; omit for a random wallpaper"},
"connector": {"type": "string",
"description": "Output connector (optional; default all)"}},
_set_wallpaper,
),
}
def _tool_list():
tools = []
for name, (desc, props, _fn) in TOOLS.items():
# `required` is a control flag in our table, not a JSON Schema property
# keyword — lift it to the object-level `required` array and strip it
# from each property def so strict MCP validators accept the schema.
clean = {k: {pk: pv for pk, pv in spec.items() if pk != "required"}
for k, spec in props.items()}
tools.append({
"name": name,
"description": desc,
"inputSchema": {
"type": "object",
"properties": clean,
"required": [k for k, v in props.items() if v.get("required")],
},
})
return tools
def _dispatch(method, params):
"""Return (result, error) for a request; result is None for notifications."""
if method == "initialize":
return {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
}, None
if method == "tools/list":
return {"tools": _tool_list()}, None
if method == "tools/call":
name = params.get("name")
args = params.get("arguments") or {}
entry = TOOLS.get(name)
if not entry:
return None, {"code": -32602, "message": f"unknown tool: {name}"}
try:
text = entry[2](args if isinstance(args, dict) else {})
except Exception as e: # noqa: BLE001 — a tool bug must not crash the server
return None, {"code": -32603, "message": f"tool '{name}' failed: {e}"}
is_error = isinstance(text, str) and text.startswith("error:")
return {"content": [{"type": "text", "text": str(text)}], "isError": is_error}, None
return None, {"code": -32601, "message": f"method not found: {method}"}
def _emit(out, payload):
out.write(json.dumps(payload) + "\n")
out.flush()
def main():
out = sys.stdout
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
_emit(out, {"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "parse error"}})
continue
if not isinstance(req, dict):
_emit(out, {"jsonrpc": "2.0", "id": None,
"error": {"code": -32600, "message": "invalid request"}})
continue
mid = req.get("id")
method = req.get("method", "")
# Notifications (no id) — e.g. notifications/initialized: ack by ignoring.
if mid is None:
continue
try:
result, error = _dispatch(method, req.get("params") or {})
except Exception as e: # noqa: BLE001 — never let a handler kill the loop
result, error = None, {"code": -32603, "message": f"internal error: {e}"}
resp = {"jsonrpc": "2.0", "id": mid}
if error is not None:
resp["error"] = error
else:
resp["result"] = result
_emit(out, resp)
if __name__ == "__main__":
main()