Skip to content

Commit d007b6f

Browse files
jpheinclaude
andcommitted
feat(cli): move — fast direct-to-daemon drawer relocation
Add `mempalace move <drawer_id> --wing W --room R`, the single-drawer metadata-relocation complement to the bulk `rename-wing`. Wraps the daemon's `PATCH /memory/{drawer_id}` (one network hop, no AGE locks). At least one of --wing / --room is required — an empty PATCH is an ambiguous no-op the daemon would 400, so it's refused client-side (exit 2) and no request is sent. Deliberately no --content flag: the fork's verbatim-always principle forbids the human CLI from mutating stored drawer text; move relocates metadata only. Output mirrors the sibling fast-daemon commands: default table shows the old→new wing/room confirmation, --json / --format=json passes the daemon envelope through. Failure modes match cmd_list/graph/cypher/ stats — unreachable / 404 / 401 / 403 exit 1, inner-error envelope (success=False) exit 2. Slice of #191. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ab056de commit d007b6f

2 files changed

Lines changed: 578 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,40 @@ def _post_daemon_rest(path: str, body: dict) -> dict:
271271
raise DaemonError(f"daemon unreachable at {_daemon_url()}: {e}") from e
272272

273273

274+
def _patch_daemon_rest(path: str, body: dict) -> dict:
275+
"""PATCH a daemon REST endpoint — for single-drawer metadata moves.
276+
277+
Mirrors :func:`_post_daemon_rest` (X-API-Key header, JSON body) but uses
278+
the PATCH verb. Returns the parsed JSON response, or None on 404/401/403
279+
(endpoint missing on an older daemon, or auth mismatch — the caller maps
280+
that to the same exit code as an unreachable daemon). Raises DaemonError
281+
on network failure.
282+
"""
283+
import urllib.error
284+
import urllib.request
285+
286+
url = f"{_daemon_url()}{path}"
287+
headers = {"content-type": "application/json"}
288+
api_key = os.environ.get("PALACE_API_KEY", "").strip()
289+
if api_key:
290+
headers["x-api-key"] = api_key
291+
req = urllib.request.Request(
292+
url,
293+
data=json.dumps(body).encode("utf-8"),
294+
headers=headers,
295+
method="PATCH",
296+
)
297+
try:
298+
with urllib.request.urlopen(req, timeout=_daemon_timeout()) as resp:
299+
return json.loads(resp.read().decode("utf-8", errors="replace"))
300+
except urllib.error.HTTPError as e:
301+
if e.code in (404, 401, 403):
302+
return None
303+
raise DaemonError(f"daemon REST {path} failed ({e.code}): {e.reason}") from e
304+
except (urllib.error.URLError, ConnectionError, OSError) as e:
305+
raise DaemonError(f"daemon unreachable at {_daemon_url()}: {e}") from e
306+
307+
274308
def _post_daemon_mine_cli(directory: str, wing: str, mode: str = "convos") -> bool:
275309
"""POST a mine request to the daemon's ``/mine`` endpoint.
276310
@@ -1805,6 +1839,140 @@ def cmd_list(args):
18051839
_print_list_table(data)
18061840

18071841

1842+
# ── mempalace move ────────────────────────────────────────────────────
1843+
#
1844+
# Single-drawer metadata relocation: wraps the daemon's
1845+
# ``PATCH /memory/{drawer_id}`` (palace-daemon main.py:1522). The route
1846+
# accepts content/wing/room, but `move` deliberately exposes only
1847+
# --wing / --room — the fork's verbatim-always principle forbids the
1848+
# human CLI from ever mutating stored drawer text. `move` relocates
1849+
# metadata only; the bulk wing-rename complement is `rename-wing`.
1850+
1851+
1852+
def _resolve_move_format(args) -> str:
1853+
"""Pick ``mempalace move`` output format. ``--format`` wins, then ``--json``."""
1854+
fmt = getattr(args, "format", None)
1855+
if fmt:
1856+
return fmt
1857+
if getattr(args, "json", False):
1858+
return "json"
1859+
return "table"
1860+
1861+
1862+
def cmd_move(args):
1863+
"""Fast direct-to-daemon single-drawer relocation (issue #191).
1864+
1865+
Wraps ``PATCH /memory/{drawer_id}`` with a body carrying only the
1866+
supplied ``wing`` / ``room`` keys. At least one is required — an empty
1867+
PATCH is an ambiguous no-op the daemon would 400, so we refuse it
1868+
client-side with a clear message. No ``--content`` flag exists by
1869+
design: verbatim-always means the human CLI never edits drawer text.
1870+
Daemon unreachable / 404 / 401 / 403 → exit 1 (sibling parity with
1871+
cmd_list / cmd_graph / cmd_cypher / cmd_stats); inner-error envelope
1872+
(daemon reachable but the move failed) → exit 2.
1873+
"""
1874+
fmt = _resolve_move_format(args)
1875+
want_json = fmt == "json"
1876+
1877+
drawer_id = args.drawer_id
1878+
new_wing = getattr(args, "wing", None)
1879+
new_room = getattr(args, "room", None)
1880+
1881+
if new_wing is None and new_room is None:
1882+
msg = "move requires at least one of --wing / --room (nothing to change)."
1883+
if want_json:
1884+
_emit_json({"error": "no_change", "hint": msg})
1885+
else:
1886+
print(f"\n ERROR: {msg}", file=sys.stderr)
1887+
sys.exit(2)
1888+
1889+
if not _daemon_url():
1890+
msg = (
1891+
"move requires the palace-daemon. Set PALACE_DAEMON_URL "
1892+
"(or daemon_url in ~/.mempalace/config.json) and retry."
1893+
)
1894+
if want_json:
1895+
_emit_json({"error": "daemon_required", "hint": msg})
1896+
else:
1897+
print(f"\n ERROR: {msg}", file=sys.stderr)
1898+
sys.exit(2)
1899+
1900+
body: dict = {}
1901+
if new_wing is not None:
1902+
body["wing"] = new_wing
1903+
if new_room is not None:
1904+
body["room"] = new_room
1905+
1906+
try:
1907+
data = _patch_daemon_rest(f"/memory/{drawer_id}", body)
1908+
except DaemonError as e:
1909+
if want_json:
1910+
_emit_json({"error": str(e), "source": "daemon"})
1911+
else:
1912+
print(
1913+
f"palace daemon unreachable at {_daemon_url()} — "
1914+
f"see mempalace status for diagnostics ({e})",
1915+
file=sys.stderr,
1916+
)
1917+
sys.exit(1)
1918+
1919+
if data is None:
1920+
# _patch_daemon_rest returns None on 404/401/403 — route missing on
1921+
# an older daemon, or auth mismatch. Exit 1 matches the unreachable
1922+
# case so scripts treat "no daemon move" uniformly.
1923+
if want_json:
1924+
_emit_json({"error": "daemon PATCH /memory unavailable", "source": "daemon"})
1925+
else:
1926+
print(
1927+
f"palace daemon unreachable at {_daemon_url()} — "
1928+
"see mempalace status for diagnostics",
1929+
file=sys.stderr,
1930+
)
1931+
sys.exit(1)
1932+
1933+
# mempalace_update_drawer returns success=False on a not-found drawer or
1934+
# an inner sanitize/validation failure — daemon reachable, move failed.
1935+
if not data.get("success", True):
1936+
if want_json:
1937+
_emit_json(data)
1938+
else:
1939+
print(f"\n ERROR: {data.get('error', 'move failed')}", file=sys.stderr)
1940+
sys.exit(2)
1941+
1942+
if want_json:
1943+
_emit_json(data)
1944+
return
1945+
1946+
_print_move_result(data, drawer_id, requested_wing=new_wing, requested_room=new_room)
1947+
1948+
1949+
def _print_move_result(data, drawer_id, requested_wing=None, requested_room=None):
1950+
"""Human-readable old→new confirmation for a single-drawer move.
1951+
1952+
The daemon's update_drawer response carries only the *new* wing/room
1953+
(there's no cheap single-drawer GET route to read the prior values), so
1954+
the "old" column shows the requested change as ``→ new`` and unchanged
1955+
fields are marked ``(unchanged)``. Warnings (e.g. non-canonical room
1956+
per the taxonomy) are surfaced if the daemon returned any.
1957+
"""
1958+
final_wing = data.get("wing", "")
1959+
final_room = data.get("room", "")
1960+
print()
1961+
print(f" Moved drawer {drawer_id}")
1962+
if requested_wing is not None:
1963+
print(f" wing → {final_wing}")
1964+
else:
1965+
print(f" wing {final_wing} (unchanged)")
1966+
if requested_room is not None:
1967+
print(f" room → {final_room}")
1968+
else:
1969+
print(f" room {final_room} (unchanged)")
1970+
warnings = data.get("warnings") or []
1971+
for w in warnings:
1972+
print(f" warning: {w}")
1973+
print()
1974+
1975+
18081976
# ── mempalace graph ───────────────────────────────────────────────────
18091977
#
18101978
# Pre-aggregated structural snapshot: wings, rooms, passive tunnels,
@@ -4206,6 +4374,24 @@ def main():
42064374
),
42074375
)
42084376

4377+
# move — single-drawer metadata relocation (complement to rename-wing)
4378+
p_move = sub.add_parser(
4379+
"move",
4380+
help="Relocate one drawer to a different wing/room (metadata only)",
4381+
)
4382+
p_move.add_argument("drawer_id", help="ID of the drawer to move")
4383+
p_move.add_argument("--wing", default=None, help="New wing (omit to leave unchanged)")
4384+
p_move.add_argument("--room", default=None, help="New room (omit to leave unchanged)")
4385+
p_move.add_argument(
4386+
"--format",
4387+
choices=("table", "json"),
4388+
default=None,
4389+
help=(
4390+
"Output format: table (default, old→new confirmation), "
4391+
"json (daemon pass-through; same as --json)"
4392+
),
4393+
)
4394+
42094395
# graph
42104396
p_graph = sub.add_parser(
42114397
"graph",
@@ -4691,6 +4877,7 @@ def _nonneg_int(value: str) -> int:
46914877
"split": cmd_split,
46924878
"search": cmd_search,
46934879
"list": cmd_list,
4880+
"move": cmd_move,
46944881
"graph": cmd_graph,
46954882
"cypher": cmd_cypher,
46964883
"export": cmd_export,

0 commit comments

Comments
 (0)