Skip to content

Commit 1ca544b

Browse files
jpheinclaude
andcommitted
feat(cli): bulk-move — multi-drawer metadata relocation by source wing/room
The multi-drawer complement to `move`. Selects drawers by source wing/room via paginated `GET /list` and PATCHes each match to a target wing/room. Metadata only — no `--content` flag, per the verbatim-always principle. Safety model: a source filter (--wing/--room) is required so it can never operate on the whole palace; a target (--to-wing/--to-room) is required; dry-run is the DEFAULT and emits a preview with no PATCH calls; --apply prompts for confirmation on a TTY (skip with --yes) and refuses to run unattended (non-TTY / json) without --yes so a pipeline can't silently mass-mutate; one drawer's PATCH failing never aborts the batch — failures are collected and reported, exit 2 if any failed. Daemon-down during listing exits 1, mirroring the `list`/`move` sibling exit codes. Slice of #191 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b46c893 commit 1ca544b

2 files changed

Lines changed: 877 additions & 0 deletions

File tree

mempalace/cli.py

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,6 +1973,317 @@ def _print_move_result(data, drawer_id, requested_wing=None, requested_room=None
19731973
print()
19741974

19751975

1976+
# ── mempalace bulk-move ───────────────────────────────────────────────
1977+
#
1978+
# Bulk metadata relocation: the multi-drawer complement to `move`. Selects
1979+
# drawers by source wing/room (``GET /list`` with offset pagination) and
1980+
# PATCHes each one independently to a target wing/room. Same verbatim-always
1981+
# constraint as `move` — only metadata moves, never drawer text, so there
1982+
# is no ``--content`` flag.
1983+
#
1984+
# The safety model is deliberately conservative because this mutates many
1985+
# drawers at once:
1986+
# * a source filter (--wing and/or --room) is *required* — never operate
1987+
# on the whole palace by accident;
1988+
# * a target (--to-wing and/or --to-room) is required;
1989+
# * dry-run is the DEFAULT — you must pass --apply to mutate;
1990+
# * --apply prompts for confirmation on a TTY (skip with --yes) and
1991+
# *refuses* to run unattended (non-TTY without --yes) so a pipeline
1992+
# can't silently mass-mutate the palace;
1993+
# * one drawer's PATCH failing does not abort the batch — failures are
1994+
# collected and reported, exit 2 if any failed.
1995+
1996+
1997+
def _resolve_bulk_move_format(args) -> str:
1998+
"""Pick ``mempalace bulk-move`` output format. ``--format`` wins, then ``--json``."""
1999+
fmt = getattr(args, "format", None)
2000+
if fmt:
2001+
return fmt
2002+
if getattr(args, "json", False):
2003+
return "json"
2004+
return "table"
2005+
2006+
2007+
def _gather_bulk_move_matches(wing, room, want_json):
2008+
"""Page through ``GET /list`` collecting every drawer matching wing/room.
2009+
2010+
Returns the list of drawer dicts (id + current wing/room). Exits the
2011+
process on daemon failure, mirroring ``cmd_list`` exit codes:
2012+
* DaemonError / None return (404/401/403) → exit 1 (sibling parity);
2013+
* inner-error envelope (``data["error"]`` and no drawers) → exit 2.
2014+
"""
2015+
matches: list[dict] = []
2016+
offset = 0
2017+
page = _LIST_LIMIT_MAX
2018+
while True:
2019+
params: dict = {"limit": page, "offset": offset}
2020+
if wing:
2021+
params["wing"] = wing
2022+
if room:
2023+
params["room"] = room
2024+
try:
2025+
data = _call_daemon_rest("/list", params)
2026+
except DaemonError as e:
2027+
if want_json:
2028+
_emit_json({"error": str(e), "source": "daemon"})
2029+
else:
2030+
print(
2031+
f"palace daemon unreachable at {_daemon_url()} — "
2032+
f"see mempalace status for diagnostics ({e})",
2033+
file=sys.stderr,
2034+
)
2035+
sys.exit(1)
2036+
2037+
if data is None:
2038+
if want_json:
2039+
_emit_json({"error": "daemon /list unavailable", "source": "daemon"})
2040+
else:
2041+
print(
2042+
f"palace daemon unreachable at {_daemon_url()} — "
2043+
"see mempalace status for diagnostics",
2044+
file=sys.stderr,
2045+
)
2046+
sys.exit(1)
2047+
2048+
drawers = data.get("drawers") or []
2049+
if "error" in data and not drawers:
2050+
if want_json:
2051+
_emit_json(data)
2052+
else:
2053+
print(f"\n {data['error']}", file=sys.stderr)
2054+
sys.exit(2)
2055+
2056+
matches.extend(drawers)
2057+
2058+
total = int(data.get("total", len(matches)) or 0)
2059+
offset += len(drawers)
2060+
# Stop when we've collected everything, or the daemon returned an
2061+
# empty page (defensive — avoids an infinite loop if total is stale).
2062+
if not drawers or offset >= total:
2063+
break
2064+
return matches
2065+
2066+
2067+
def _bulk_move_drawer_id(drawer: dict) -> str:
2068+
"""Pull the id out of a /list drawer dict (daemon uses ``drawer_id``)."""
2069+
return drawer.get("drawer_id") or drawer.get("id") or ""
2070+
2071+
2072+
def _bulk_move_label(wing, room) -> str:
2073+
"""``wing/room`` label for prompts/previews; ``*`` marks an unconstrained side."""
2074+
return f"{wing or '*'}/{room or '*'}"
2075+
2076+
2077+
def _validate_bulk_move_args(src_wing, src_room, to_wing, to_room, want_json):
2078+
"""Enforce the safety preconditions; exit 2 with guidance if violated.
2079+
2080+
Source filter required (never touch the whole palace), target required,
2081+
daemon required — mirrors ``cmd_move``'s no-change / daemon-required
2082+
exit-2 contract.
2083+
"""
2084+
if src_wing is None and src_room is None:
2085+
msg = (
2086+
"bulk-move requires a source filter: at least one of "
2087+
"--wing / --room (refusing to operate on the whole palace)."
2088+
)
2089+
if want_json:
2090+
_emit_json({"error": "no_source_filter", "hint": msg})
2091+
else:
2092+
print(f"\n ERROR: {msg}", file=sys.stderr)
2093+
sys.exit(2)
2094+
2095+
if to_wing is None and to_room is None:
2096+
msg = "bulk-move requires at least one of --to-wing / --to-room (nothing to change)."
2097+
if want_json:
2098+
_emit_json({"error": "no_change", "hint": msg})
2099+
else:
2100+
print(f"\n ERROR: {msg}", file=sys.stderr)
2101+
sys.exit(2)
2102+
2103+
if not _daemon_url():
2104+
msg = (
2105+
"bulk-move requires the palace-daemon. Set PALACE_DAEMON_URL "
2106+
"(or daemon_url in ~/.mempalace/config.json) and retry."
2107+
)
2108+
if want_json:
2109+
_emit_json({"error": "daemon_required", "hint": msg})
2110+
else:
2111+
print(f"\n ERROR: {msg}", file=sys.stderr)
2112+
sys.exit(2)
2113+
2114+
2115+
def _print_bulk_move_preview(matches, to_wing, to_room, src_label) -> None:
2116+
"""Human-readable dry-run preview: one ``id cur → target`` line per drawer."""
2117+
print()
2118+
print(f" Matched {len(matches)} drawer(s) in {src_label}")
2119+
for d in matches:
2120+
did = _bulk_move_drawer_id(d)
2121+
short = did[:12] if did else "(no-id)"
2122+
cur_w = d.get("wing") or ""
2123+
cur_r = d.get("room") or ""
2124+
tgt_w = to_wing if to_wing is not None else cur_w
2125+
tgt_r = to_room if to_room is not None else cur_r
2126+
print(f" {short} {cur_w}/{cur_r}{tgt_w}/{tgt_r}")
2127+
print(f"\n DRY RUN — re-run with --apply to move {len(matches)} drawers.\n")
2128+
2129+
2130+
def _bulk_move_confirm(args, matched, src_label, dst_label, want_json) -> bool:
2131+
"""Confirmation gate before a mass mutation.
2132+
2133+
Returns True to proceed. ``--yes`` skips the gate. On a TTY (and not
2134+
json) we prompt and proceed only on y/yes. Non-interactive / json
2135+
without ``--yes`` is *refused* (exit 2) — no silent mass mutation in a
2136+
pipeline. Returns False on an interactive decline (caller prints
2137+
``aborted`` and exits 0).
2138+
"""
2139+
if bool(getattr(args, "yes", False)):
2140+
return True
2141+
if want_json or not sys.stdin.isatty():
2142+
msg = (
2143+
f"refusing to bulk-move {matched} drawers without --yes "
2144+
"in a non-interactive shell"
2145+
)
2146+
if want_json:
2147+
_emit_json({"error": "confirmation_required", "hint": msg, "matched": matched})
2148+
else:
2149+
print(f"\n ERROR: {msg}", file=sys.stderr)
2150+
sys.exit(2)
2151+
answer = input(f"Move {matched} drawers {src_label}{dst_label}? [y/N] ").strip().lower()
2152+
return answer in ("y", "yes")
2153+
2154+
2155+
def _bulk_move_execute(matches, body_template):
2156+
"""PATCH each drawer independently; never abort the batch on one failure.
2157+
2158+
Returns ``(moved_ids, failures)`` where a failure is a
2159+
``{"id", "error"}`` dict (DaemonError, None return, or
2160+
``success is False``).
2161+
"""
2162+
moved: list[str] = []
2163+
failed: list[dict] = []
2164+
for d in matches:
2165+
did = _bulk_move_drawer_id(d)
2166+
if not did:
2167+
failed.append({"id": "", "error": "missing drawer id in /list response"})
2168+
continue
2169+
try:
2170+
result = _patch_daemon_rest(f"/memory/{did}", dict(body_template))
2171+
except DaemonError as e:
2172+
failed.append({"id": did, "error": str(e)})
2173+
continue
2174+
if result is None:
2175+
failed.append({"id": did, "error": "daemon PATCH /memory unavailable"})
2176+
continue
2177+
if result.get("success") is False:
2178+
failed.append({"id": did, "error": result.get("error", "move failed")})
2179+
continue
2180+
moved.append(did)
2181+
return moved, failed
2182+
2183+
2184+
def cmd_bulk_move(args):
2185+
"""Bulk drawer relocation by source wing/room (issue #191).
2186+
2187+
The multi-drawer complement to ``move``. Selects drawers via
2188+
``GET /list`` (offset-paginated) and PATCHes each match to the target
2189+
wing/room. Dry-run by default; ``--apply`` mutates (TTY prompt unless
2190+
``--yes``; refuses unattended without ``--yes``). Verbatim-always:
2191+
metadata only, no ``--content`` flag. Daemon unreachable / 404 / 401 /
2192+
403 during listing → exit 1; selection/target missing or any PATCH
2193+
failure → exit 2.
2194+
"""
2195+
fmt = _resolve_bulk_move_format(args)
2196+
want_json = fmt == "json"
2197+
2198+
src_wing = getattr(args, "wing", None)
2199+
src_room = getattr(args, "room", None)
2200+
to_wing = getattr(args, "to_wing", None)
2201+
to_room = getattr(args, "to_room", None)
2202+
2203+
_validate_bulk_move_args(src_wing, src_room, to_wing, to_room, want_json)
2204+
2205+
# Only the supplied target keys go into each PATCH body / the json target.
2206+
body_template: dict = {}
2207+
if to_wing is not None:
2208+
body_template["wing"] = to_wing
2209+
if to_room is not None:
2210+
body_template["room"] = to_room
2211+
target_obj = dict(body_template)
2212+
source_obj = {"wing": src_wing, "room": src_room}
2213+
2214+
src_label = _bulk_move_label(src_wing, src_room)
2215+
dst_label = _bulk_move_label(
2216+
to_wing if to_wing is not None else src_wing,
2217+
to_room if to_room is not None else src_room,
2218+
)
2219+
2220+
matches = _gather_bulk_move_matches(src_wing, src_room, want_json)
2221+
matched = len(matches)
2222+
2223+
# Dry-run is the DEFAULT — preview and exit, no PATCH calls.
2224+
if not bool(getattr(args, "apply", False)):
2225+
if want_json:
2226+
_emit_json(
2227+
{
2228+
"matched": matched,
2229+
"dry_run": True,
2230+
"moved": [],
2231+
"failed": [],
2232+
"target": target_obj,
2233+
"source": source_obj,
2234+
}
2235+
)
2236+
return
2237+
_print_bulk_move_preview(matches, to_wing, to_room, src_label)
2238+
return
2239+
2240+
if matched == 0:
2241+
# Nothing to do — report and exit cleanly. No prompt, no PATCH.
2242+
if want_json:
2243+
_emit_json(
2244+
{
2245+
"matched": 0,
2246+
"dry_run": False,
2247+
"moved": [],
2248+
"failed": [],
2249+
"target": target_obj,
2250+
"source": source_obj,
2251+
}
2252+
)
2253+
return
2254+
print(f"\n No drawers match {src_label} — nothing to move.\n")
2255+
return
2256+
2257+
if not _bulk_move_confirm(args, matched, src_label, dst_label, want_json):
2258+
print(" aborted")
2259+
return
2260+
2261+
moved, failed = _bulk_move_execute(matches, body_template)
2262+
2263+
if want_json:
2264+
_emit_json(
2265+
{
2266+
"matched": matched,
2267+
"dry_run": False,
2268+
"moved": moved,
2269+
"failed": failed,
2270+
"target": target_obj,
2271+
"source": source_obj,
2272+
}
2273+
)
2274+
else:
2275+
print(f"\n moved {len(moved)}, failed {len(failed)}")
2276+
if failed:
2277+
print(" failed drawers:")
2278+
for f in failed:
2279+
fid = (f.get("id") or "")[:12] or "(no-id)"
2280+
print(f" {fid} {f.get('error', '')}")
2281+
print()
2282+
2283+
if failed:
2284+
sys.exit(2)
2285+
2286+
19762287
# ── mempalace graph ───────────────────────────────────────────────────
19772288
#
19782289
# Pre-aggregated structural snapshot: wings, rooms, passive tunnels,
@@ -4392,6 +4703,54 @@ def main():
43924703
),
43934704
)
43944705

4706+
# bulk-move — multi-drawer metadata relocation by source wing/room
4707+
p_bulk_move = sub.add_parser(
4708+
"bulk-move",
4709+
help="Relocate many drawers (by source wing/room) to a target wing/room (metadata only)",
4710+
)
4711+
p_bulk_move.add_argument(
4712+
"--wing",
4713+
default=None,
4714+
help="Source: select drawers in this wing (at least one of --wing/--room required)",
4715+
)
4716+
p_bulk_move.add_argument(
4717+
"--room",
4718+
default=None,
4719+
help="Source: select drawers in this room (at least one of --wing/--room required)",
4720+
)
4721+
p_bulk_move.add_argument(
4722+
"--to-wing",
4723+
dest="to_wing",
4724+
default=None,
4725+
help="Target wing (at least one of --to-wing/--to-room required)",
4726+
)
4727+
p_bulk_move.add_argument(
4728+
"--to-room",
4729+
dest="to_room",
4730+
default=None,
4731+
help="Target room (at least one of --to-wing/--to-room required)",
4732+
)
4733+
p_bulk_move.add_argument(
4734+
"--apply",
4735+
action="store_true",
4736+
help="Actually move the drawers. Without this, bulk-move only previews (dry run).",
4737+
)
4738+
p_bulk_move.add_argument(
4739+
"--yes",
4740+
"-y",
4741+
action="store_true",
4742+
help="Skip the confirmation prompt (required to --apply in a non-interactive shell)",
4743+
)
4744+
p_bulk_move.add_argument(
4745+
"--format",
4746+
choices=("table", "json"),
4747+
default=None,
4748+
help=(
4749+
"Output format: table (default, preview/summary), "
4750+
"json (machine-readable; same as --json)"
4751+
),
4752+
)
4753+
43954754
# graph
43964755
p_graph = sub.add_parser(
43974756
"graph",
@@ -4878,6 +5237,7 @@ def _nonneg_int(value: str) -> int:
48785237
"search": cmd_search,
48795238
"list": cmd_list,
48805239
"move": cmd_move,
5240+
"bulk-move": cmd_bulk_move,
48815241
"graph": cmd_graph,
48825242
"cypher": cmd_cypher,
48835243
"export": cmd_export,

0 commit comments

Comments
 (0)