From 8faf0042b5d56854adad766d9c2e8d5e67d4ecc2 Mon Sep 17 00:00:00 2001
From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:10:17 -0300
Subject: [PATCH] fix(cli,mine): shell-quote project_dir in resume hints
The "Skipped. Run mempalace mine
" hint after declining the init
prompt and the "Re-run mempalace mine to resume" hint after a
Ctrl-C interruption both interpolated project_dir without shell-quoting.
A path containing spaces or metacharacters produced a copy-paste-broken
command.
Both spots now use shlex.quote(project_dir). Adds regression tests
covering each hint with a path that contains a space.
---
mempalace/cli.py | 2 +-
mempalace/miner.py | 3 ++-
tests/test_cli.py | 23 +++++++++++++++++++++++
tests/test_miner.py | 24 ++++++++++++++++++++++++
4 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/mempalace/cli.py b/mempalace/cli.py
index 88ad0ca..7382cf3 100644
--- a/mempalace/cli.py
+++ b/mempalace/cli.py
@@ -238,7 +238,7 @@ def _maybe_run_mine_after_init(args, cfg) -> None:
# we don't block. User can re-run with --auto-mine to opt in.
answer = "n"
if answer not in ("", "y", "yes"):
- print(f"\n Skipped. Run `mempalace mine {project_dir}` when ready.")
+ print(f"\n Skipped. Run `mempalace mine {shlex.quote(project_dir)}` when ready.")
return
palace_path = cfg.palace_path
diff --git a/mempalace/miner.py b/mempalace/miner.py
index 34f46ae..b593797 100644
--- a/mempalace/miner.py
+++ b/mempalace/miner.py
@@ -9,6 +9,7 @@ Stores verbatim chunks as drawers. No summaries. Ever.
import os
import sys
+import shlex
import hashlib
import fnmatch
from pathlib import Path
@@ -1103,7 +1104,7 @@ def mine(
print(f" drawers_filed: {total_drawers}")
print(f" last_file: {last_file or ''}")
print(
- f"\n Re-run `mempalace mine {project_dir}` to resume — "
+ f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` to resume — "
"already-filed drawers are\n upserted idempotently and will not duplicate.\n"
)
sys.exit(130)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 63b3892..b21ed8c 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -281,6 +281,29 @@ def test_maybe_run_mine_yes_and_auto_mine_fully_noninteractive(tmp_path):
mock_mine.assert_called_once()
+def test_maybe_run_mine_decline_quotes_path_with_spaces(tmp_path, capsys):
+ """The resume hint must shell-quote the project dir so paths with
+ spaces / metacharacters produce a copy-paste-safe command."""
+ from mempalace.cli import _maybe_run_mine_after_init
+
+ spaced_dir = tmp_path / "my project dir"
+ spaced_dir.mkdir()
+ args = argparse.Namespace(dir=str(spaced_dir), yes=False, auto_mine=False)
+ cfg = _fake_cfg(tmp_path)
+ with (
+ patch("mempalace.miner.mine"),
+ patch("mempalace.miner.scan_project", return_value=[]),
+ patch("builtins.input", return_value="n"),
+ ):
+ _maybe_run_mine_after_init(args, cfg)
+ out = capsys.readouterr().out
+ # shlex.quote wraps paths with spaces in single quotes.
+ assert f"mempalace mine '{spaced_dir}'" in out
+ # And the bare unquoted form is NOT printed (would break paste).
+ assert f"mempalace mine {spaced_dir} " not in out
+ assert f"mempalace mine {spaced_dir}`" not in out
+
+
def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys):
"""Piped / non-interactive stdin (EOFError) declines without crashing."""
from mempalace.cli import _maybe_run_mine_after_init
diff --git a/tests/test_miner.py b/tests/test_miner.py
index bb4f437..2dee259 100644
--- a/tests/test_miner.py
+++ b/tests/test_miner.py
@@ -653,6 +653,30 @@ def test_mine_keyboard_interrupt_prints_summary_and_exits_130(tmp_path, capsys):
assert "upserted idempotently" in out
+def test_mine_keyboard_interrupt_quotes_path_with_spaces_in_resume_hint(tmp_path, capsys):
+ """Resume hint must shell-quote the project dir so a path containing
+ spaces / metacharacters yields a copy-paste-safe `mempalace mine ...`
+ command. Otherwise users on a path like "My Project" hit a broken
+ invocation when they re-run after Ctrl-C."""
+ import pytest
+ from unittest.mock import patch
+
+ project_root = tmp_path / "my project"
+ project_root.mkdir()
+ _make_minable_project(project_root, n_files=2)
+ palace_path = project_root / "palace"
+
+ def fake_process_file(*args, **kwargs):
+ raise KeyboardInterrupt
+
+ with patch("mempalace.miner.process_file", side_effect=fake_process_file):
+ with pytest.raises(SystemExit):
+ mine(str(project_root), str(palace_path))
+
+ out = capsys.readouterr().out
+ assert f"mempalace mine '{project_root}'" in out
+
+
def test_mine_cleans_up_pid_file_on_interrupt(tmp_path):
"""Our own PID entry in mine.pid is removed in the finally clause."""
import pytest