"""Local file read/edit/write, intercepted from the server-relayed command channel the same way pet_actions.py intercepts petctl (see server_client. run_local_command / controller._handle_command). Whatever text the server's "command" tool sends is just a string this repo is free to interpret before it ever reaches subprocess — a `filectl` pseudo-command is one such interpretation, giving the model a way to read/write/edit files on this machine without constructing a raw shell heredoc, where quoting, `$`, backticks, and embedded quotes make anything beyond a one-liner failure-prone. Executing arbitrary commands already works today (that's exactly what run_local_command/subprocess.run does) — filectl doesn't add that ability, it only makes the read/write/edit slice of it reliable. It also doesn't expand what the server can already do to this machine: a relayed shell command could already overwrite any file the desktop user can write (see the security notes in CLAUDE.md) — filectl is a safer *path* to the same capability, not a new capability. Wire format: `filectl `, where is a single-line, compact JSON object — critically, ONE LINE. The server's "command" tool marker only captures the argument up to the next newline (see TOOL_SPECS/ _extract_all_tool_calls in the main repo's ai/agents/default.py — "command" is not declared multiline), so a marker-delimited multi-line payload (this module's first design) silently got truncated at the first line no matter how the prompt worded it. JSON sidesteps that for free: json.dumps() already encodes embedded newlines as the two characters "\n", not an actual line break, so arbitrarily multi-line file content still fits on the one physical line the extractor captures. filectl {"op": "list", "path": "", "pattern": "", "recursive": } filectl {"op": "read", "path": "", "start": , "end": } filectl {"op": "write", "path": "", "content": ""} filectl {"op": "edit", "path": "", "old": "", "new": ""} "pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list` exists even though a real `ls`/`dir` shell command already works, because this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't have to guess which listing command applies on this machine — one glob-based op covers all three. Must be invoked as the argument to the ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`) — see the pet-only paragraph in the main repo's ai/desk_api.py _system_context for the exact instruction the model is given, including the "keep it one line" requirement. Pure parsing (parse) is separated from the filesystem I/O (execute) so the syntax is unit-testable without touching disk, matching pet_actions.py's parse/describe split. """ from __future__ import annotations import json from pathlib import Path from typing import Optional from . import relay_json _PREFIXES = ("filectl", "file") # Keeps a runaway read/write/list from blowing up the tool_result relay (and, # for read/list, from flooding the model's context with a giant response). _MAX_READ_BYTES = 200_000 _MAX_WRITE_BYTES = 2_000_000 _MAX_LIST_ENTRIES = 500 HELP = ( 'filectl {"op": "list", "path": "", "pattern": "", "recursive": }\n' 'filectl {"op": "read", "path": "", "start": , "end": }\n' 'filectl {"op": "write", "path": "", "content": ""}\n' 'filectl {"op": "edit", "path": "", "old": "", "new": ""}\n' "Must be all on one line — this rides the single-line \"command\" marker." ) class FileOpError(Exception): """Bad filectl syntax or a filesystem error — reported back to the server as command output, exactly like ActionError in pet_actions.py.""" def is_file_command(command: str) -> bool: stripped = (command or "").strip() if not stripped: return False first_word = stripped.split(None, 1)[0] return first_word.lower() in _PREFIXES def parse(command: str) -> Optional[dict]: """Parse a `filectl ` string into an action dict, or None if this isn't a filectl command at all (caller should try the next handler, or fall back to a real shell command). Raises FileOpError on a filectl command that doesn't make sense.""" if not is_file_command(command): return None stripped = command.strip() _, _, rest = stripped.partition(" ") rest = rest.strip() if not rest or rest.lower() in ("help", "-h", "--help"): return {"action": "help"} # Lenient on purpose — see relay_json. A stray quote in machine-written # JSON should not cost a turn, but the repair is reported back so the model # is told it sent something broken while it can still learn from it. try: payload, repairs = relay_json.loads(rest) except relay_json.RelayJsonError as exc: raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc if not isinstance(payload, dict): raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}") op = str(payload.get("op") or "help").lower() # Carried on the action so execute() can tell the model what it got wrong; # a silent repair would fix today's call and guarantee tomorrow's. tag = {"_repairs": repairs} if repairs else {} if op == "help": return {"action": "help", **tag} if op == "list": path = _required_str(payload, "path") pattern = payload.get("pattern", "*") if not isinstance(pattern, str) or not pattern: raise FileOpError('"pattern" must be a non-empty string') return { "action": "list", "path": path, "pattern": pattern, "recursive": bool(payload.get("recursive")), **tag, } if op == "read": path = _required_str(payload, "path") return { "action": "read", "path": path, "start": _line_number(payload.get("start"), "start"), "end": _line_number(payload.get("end"), "end"), **tag, } if op == "write": path = _required_str(payload, "path") if payload.get("content") is None: raise FileOpError('write needs "content"') return {"action": "write", "path": path, "content": str(payload["content"]), **tag} if op == "edit": path = _required_str(payload, "path") if payload.get("old") is None or payload.get("new") is None: raise FileOpError('edit needs "old" and "new"') old, new = str(payload["old"]), str(payload["new"]) if old == new: raise FileOpError("old and new text are identical — nothing to edit") return {"action": "edit", "path": path, "old": old, "new": new, **tag} raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}") def _required_str(payload: dict, key: str) -> str: value = payload.get(key) if not isinstance(value, str) or not value.strip(): raise FileOpError(f'filectl needs a non-empty "{key}"') return value def _line_number(value, label: str) -> Optional[int]: if value is None: return None if isinstance(value, bool) or not isinstance(value, int): raise FileOpError(f"{label} must be an integer line number, got {value!r}") return value def describe(action: dict) -> str: """Text handed back to the server before execute() runs — mirrors pet_actions.describe, used only for the log line.""" kind = action.get("action") if kind == "help": return "[filectl] help" return f"[filectl] {kind} {action.get('path', '')}" def execute(action: dict) -> str: """Actually perform a parsed filectl action, returning the text to send back to the server as the command's output. Raises FileOpError on any filesystem problem, same as a bad-syntax parse error.""" kind = action.get("action") if kind == "help": return HELP if kind == "list": output = _do_list(action) elif kind == "read": output = _do_read(action) elif kind == "write": output = _do_write(action) elif kind == "edit": output = _do_edit(action) else: output = "[filectl] ok" return output + relay_json.repair_note(action.get("_repairs") or []) def _resolve(path_str: str) -> Path: return Path(path_str).expanduser() def _do_list(action: dict) -> str: path = _resolve(action["path"]) if not path.is_dir(): raise FileOpError(f"no such directory: {path}") pattern = action["pattern"] glob = path.rglob if action["recursive"] else path.glob try: entries = sorted(glob(pattern), key=lambda p: str(p).lower()) except OSError as exc: raise FileOpError(f"couldn't list {path}: {exc}") from exc if not entries: return f"[no entries matching {pattern!r} in {path}]" truncated = len(entries) > _MAX_LIST_ENTRIES lines = [] for entry in entries[:_MAX_LIST_ENTRIES]: rel = entry.relative_to(path) if entry.is_dir(): lines.append(f"{rel}/") continue try: size = entry.stat().st_size except OSError: size = -1 lines.append(f"{rel}\t{size}B") if truncated: lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"") return "\n".join(lines) def _do_read(action: dict) -> str: path = _resolve(action["path"]) if not path.is_file(): raise FileOpError(f"no such file: {path}") try: data = path.read_bytes() except OSError as exc: raise FileOpError(f"couldn't read {path}: {exc}") from exc if len(data) > _MAX_READ_BYTES: raise FileOpError( f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — " 'pass "start"/"end" to read a slice instead' ) try: text = data.decode("utf-8") except UnicodeDecodeError as exc: raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc lines = text.splitlines() start = max(1, action.get("start") or 1) end = min(len(lines), action.get("end") or len(lines)) if not lines: return "[empty file]" return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1)) def _do_write(action: dict) -> str: path = _resolve(action["path"]) content = action["content"] if len(content.encode("utf-8")) > _MAX_WRITE_BYTES: raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit") try: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") except OSError as exc: raise FileOpError(f"couldn't write {path}: {exc}") from exc return f"[filectl] wrote {len(content)} chars to {path}" def _do_edit(action: dict) -> str: path = _resolve(action["path"]) old, new = action["old"], action["new"] if not path.is_file(): raise FileOpError(f"no such file: {path}") try: text = path.read_text(encoding="utf-8") except OSError as exc: raise FileOpError(f"couldn't read {path}: {exc}") from exc count = text.count(old) if count == 0: raise FileOpError(f"didn't find that text in {path} — nothing changed") if count > 1: raise FileOpError( f"that text appears {count} times in {path} — filectl edit needs a unique match; " "include more surrounding context" ) try: path.write_text(text.replace(old, new, 1), encoding="utf-8") except OSError as exc: raise FileOpError(f"couldn't write {path}: {exc}") from exc return f"[filectl] edited {path}"