Skip to content

Commit 1c30488

Browse files
forge-fz2000FZ2000
andauthored
feat(#24,#25): --dry-run for collect + enhanced --dry-run for sync with file paths (#65)
* feat(#24,#25): dry-run for collect and sync with file path display Issue #25 — apc collect --dry-run: Added --dry-run flag to 'apc collect'. When set, shows what would be collected (cache file paths + counts, skill names, MCP server names, memory file labels) without writing to disk. Issue #24 — apc sync --dry-run show file paths: Enhanced the existing --dry-run in 'apc sync' to show the actual file paths that would be written for each target tool: - Skills directory - MCP config path - Memory target file path Resolves before the confirmation prompt so users can audit paths before committing. * chore: remove uv.lock from feature branch (not in main) * test: 18 tests for collect/sync --dry-run (unit + docker integration) Unit tests (test_dry_run.py, 12): - collect --dry-run: flag accepted, shows cache paths, shows item counts, lists skill names, does NOT write cache files, no-files-written message, memory entries listed - sync --dry-run: flag accepted, no-files-written message, shows tool name, sync_all not called Docker integration tests (test_docker_integration.py, 6): - collect --dry-run: no cache files written, preview output shown, control (without flag) does write cache - sync --dry-run: no tool files modified (mtime unchanged), output mentions tool, explicitly states no files written --------- Co-authored-by: FZ2000 <40145076+FZ2000@users.noreply.github.com>
1 parent b3c56db commit 1c30488

4 files changed

Lines changed: 338 additions & 2 deletions

File tree

src/collect.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,17 @@ def _resolve_memory_conflicts(
6464
help="Comma-separated list of tools to collect from (e.g., claude,cursor)",
6565
)
6666
@click.option("--no-memory", is_flag=True, help="Skip collecting memory entries")
67+
@click.option(
68+
"--dry-run",
69+
is_flag=True,
70+
help="Show what would be collected without writing to cache. (#25)",
71+
)
6772
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
68-
def collect(tools, no_memory, yes):
73+
def collect(tools, no_memory, dry_run, yes):
6974
"""Extract from installed AI tools and save to local cache.
7075
7176
No login or network required.
77+
Use --dry-run to preview what would be collected without writing.
7278
"""
7379
# --- Phase 1: Scan ---
7480
header("Scanning")
@@ -163,6 +169,35 @@ def collect(tools, no_memory, yes):
163169
store_secrets_batch("local", all_secrets)
164170
success(f"Stored {len(all_secrets)} secret(s) in OS keychain")
165171

172+
# --- Dry-run: preview without writing (#25) ---
173+
if dry_run:
174+
from cache import get_cache_dir
175+
176+
cache_dir = get_cache_dir()
177+
info("\n[dry-run] Would write to cache:")
178+
info(f" {cache_dir / 'skills.json'} ({len(new_skills)} skills)")
179+
info(f" {cache_dir / 'mcp.json'} ({len(new_mcp_servers)} MCP servers)")
180+
info(f" {cache_dir / 'memory.json'} ({len(selected_memory)} memory entries)")
181+
182+
if new_skills:
183+
info("\n Skills:")
184+
for s in new_skills:
185+
info(f" • {s.get('name', '?')} ({s.get('source_tool', '')})")
186+
187+
if new_mcp_servers:
188+
info("\n MCP Servers:")
189+
for sv in new_mcp_servers:
190+
info(f" • {sv.get('name', '?')} ({sv.get('source_tool', '')})")
191+
192+
if selected_memory:
193+
info("\n Memory files:")
194+
for e in selected_memory:
195+
label = e.get("label") or e.get("source_file") or e.get("content", "")[:40]
196+
info(f" • {e.get('source_tool', '?')}/{label}")
197+
198+
info("\n[dry-run] No files written.")
199+
return
200+
166201
# Merge into existing cache (upsert, never delete)
167202
merged_skills = merge_skills(load_skills(), new_skills)
168203
merged_mcp = merge_mcp_servers(load_mcp_servers(), new_mcp_servers)

src/main.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,40 @@ def sync(tools, apply_all, no_memory, override_mcp, dry_run, yes):
121121
info(f"Skills: {len(collected_skills)} collected + {installed_count} installed")
122122

123123
if dry_run:
124-
info("[dry-run] No files written.")
124+
# Show the file paths that would be written for each target tool (#24)
125+
from appliers import get_applier
126+
127+
info("\n[dry-run] Files that would be written:")
128+
for tool_name in tool_list:
129+
try:
130+
applier = get_applier(tool_name)
131+
info(f"\n [{tool_name}]")
132+
# Skills
133+
if collected_skills or installed_count:
134+
if hasattr(applier, "SKILL_DIR") and applier.SKILL_DIR:
135+
info(f" Skills dir: {applier.SKILL_DIR}")
136+
# MCP config
137+
for attr in ("_mcp_config", "_mcp_config_path"):
138+
fn = getattr(type(applier), attr, None) or getattr(applier, attr, None)
139+
if callable(fn):
140+
try:
141+
info(f" MCP config: {fn()}")
142+
except Exception:
143+
pass
144+
# Memory target
145+
mem_target = getattr(applier, "MEMORY_TARGET_FILE", None)
146+
if callable(mem_target):
147+
mem_target = mem_target # property — access below
148+
try:
149+
mf = applier.MEMORY_TARGET_FILE
150+
if mf:
151+
info(f" Memory: {mf}")
152+
except Exception:
153+
pass
154+
except Exception as e:
155+
info(f" [{tool_name}] (could not inspect: {e})")
156+
157+
info("\n[dry-run] No files written.")
125158
return
126159

127160
# Confirm

tests/test_docker_integration.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,3 +1449,99 @@ def test_status_after_round_trip(self, runner, cli, export_path):
14491449

14501450
r = runner.invoke(cli, ["status"])
14511451
assert r.exit_code == 0
1452+
1453+
1454+
# ---------------------------------------------------------------------------
1455+
# Phase 12: --dry-run for collect (#25) and sync (#24)
1456+
# ---------------------------------------------------------------------------
1457+
1458+
1459+
class TestCollectDryRun:
1460+
"""End-to-end: collect --dry-run previews without writing cache."""
1461+
1462+
def test_collect_dry_run_no_files_written(self, runner, cli, tmp_path, monkeypatch):
1463+
"""collect --dry-run must not create any cache files."""
1464+
monkeypatch.setenv("HOME", str(tmp_path))
1465+
(tmp_path / ".cursor").mkdir()
1466+
(tmp_path / ".cursor" / "mcp.json").write_text("{}")
1467+
1468+
result = runner.invoke(cli, ["collect", "--dry-run", "--yes"])
1469+
assert result.exit_code == 0, result.output
1470+
1471+
cache_dir = tmp_path / ".apc" / "cache"
1472+
for fname in ("skills.json", "mcp.json", "memory.json"):
1473+
assert not (cache_dir / fname).exists(), f"{fname} written despite --dry-run"
1474+
1475+
def test_collect_dry_run_prints_preview(self, runner, cli, tmp_path, monkeypatch):
1476+
"""collect --dry-run output shows 'Would write to cache' preview."""
1477+
monkeypatch.setenv("HOME", str(tmp_path))
1478+
(tmp_path / ".cursor").mkdir()
1479+
(tmp_path / ".cursor" / "mcp.json").write_text(
1480+
json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}})
1481+
)
1482+
1483+
result = runner.invoke(cli, ["collect", "--dry-run", "--yes"])
1484+
assert result.exit_code == 0, result.output
1485+
# Output says "Would write to cache:" or "No files written."
1486+
out = result.output.lower()
1487+
assert "write to cache" in out or "no files written" in out
1488+
1489+
def test_collect_without_dry_run_writes_cache(self, runner, cli, tmp_path, monkeypatch):
1490+
"""Control: without --dry-run the cache IS written."""
1491+
monkeypatch.setenv("HOME", str(tmp_path))
1492+
(tmp_path / ".cursor").mkdir()
1493+
(tmp_path / ".cursor" / "mcp.json").write_text(
1494+
json.dumps({"mcpServers": {"test-mcp": {"command": "npx", "args": []}}})
1495+
)
1496+
1497+
result = runner.invoke(cli, ["collect", "--yes"])
1498+
assert result.exit_code == 0, result.output
1499+
1500+
# At least one cache file must have been written
1501+
cache_dir = tmp_path / ".apc" / "cache"
1502+
cache_files = ("skills.json", "mcp.json", "memory.json")
1503+
written = [f for f in cache_files if (cache_dir / f).exists()]
1504+
assert written, f"No cache files written without --dry-run. Output:\n{result.output}"
1505+
1506+
1507+
class TestSyncDryRunIntegration:
1508+
"""End-to-end: sync --dry-run previews without modifying tool files."""
1509+
1510+
def test_sync_dry_run_no_files_written(self, runner, cli, tmp_path, monkeypatch):
1511+
"""sync --dry-run must not modify any tool config files."""
1512+
monkeypatch.setenv("HOME", str(tmp_path))
1513+
(tmp_path / ".cursor").mkdir()
1514+
mcp_path = tmp_path / ".cursor" / "mcp.json"
1515+
mcp_path.write_text(json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}}))
1516+
1517+
runner.invoke(cli, ["collect", "--yes"])
1518+
1519+
mtime_before = mcp_path.stat().st_mtime
1520+
result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"])
1521+
assert result.exit_code == 0, result.output
1522+
assert mcp_path.stat().st_mtime == mtime_before, "sync --dry-run modified mcp.json"
1523+
1524+
def test_sync_dry_run_output_mentions_tool(self, runner, cli, tmp_path, monkeypatch):
1525+
"""sync --dry-run output references the target tool."""
1526+
monkeypatch.setenv("HOME", str(tmp_path))
1527+
(tmp_path / ".cursor").mkdir()
1528+
(tmp_path / ".cursor" / "mcp.json").write_text(
1529+
json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}})
1530+
)
1531+
1532+
runner.invoke(cli, ["collect", "--yes"])
1533+
result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"])
1534+
assert result.exit_code == 0, result.output
1535+
assert "cursor" in result.output or "dry-run" in result.output.lower()
1536+
1537+
def test_sync_dry_run_shows_no_files_written(self, runner, cli, tmp_path, monkeypatch):
1538+
"""sync --dry-run explicitly states no files written."""
1539+
monkeypatch.setenv("HOME", str(tmp_path))
1540+
(tmp_path / ".cursor").mkdir()
1541+
(tmp_path / ".cursor" / "mcp.json").write_text(
1542+
json.dumps({"mcpServers": {"test": {"command": "npx", "args": []}}})
1543+
)
1544+
1545+
runner.invoke(cli, ["collect", "--yes"])
1546+
result = runner.invoke(cli, ["sync", "--tools", "cursor", "--dry-run"])
1547+
assert "No files written" in result.output or "dry-run" in result.output.lower()

tests/test_dry_run.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for --dry-run on apc collect (#25) and apc sync (#24).
2+
3+
apc collect --dry-run: previews what would be collected without writing.
4+
apc sync --dry-run: previews file paths per tool without writing.
5+
"""
6+
7+
import sys
8+
import unittest
9+
from pathlib import Path
10+
from unittest.mock import MagicMock, patch
11+
12+
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
13+
14+
from click.testing import CliRunner
15+
16+
# ---------------------------------------------------------------------------
17+
# Helpers
18+
# ---------------------------------------------------------------------------
19+
20+
21+
def _cli():
22+
from main import cli
23+
24+
return cli
25+
26+
27+
def _runner():
28+
return CliRunner()
29+
30+
31+
def _mock_extractor(skills=None, mcp=None, memory=None):
32+
"""Return a MagicMock extractor with canned data."""
33+
ext = MagicMock()
34+
ext.extract_skills.return_value = skills or []
35+
ext.extract_mcp_servers.return_value = mcp or []
36+
ext.extract_memory.return_value = memory or []
37+
return ext
38+
39+
40+
# ---------------------------------------------------------------------------
41+
# apc collect --dry-run (#25)
42+
# ---------------------------------------------------------------------------
43+
44+
45+
class TestCollectDryRun(unittest.TestCase):
46+
"""collect --dry-run must preview without touching the cache."""
47+
48+
def _invoke(self, skills=None, mcp=None, memory=None, extra_args=None):
49+
import tempfile
50+
51+
with tempfile.TemporaryDirectory() as td:
52+
td = Path(td)
53+
cache_dir = td / ".apc" / "cache"
54+
extractor = _mock_extractor(
55+
skills=skills or [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}],
56+
mcp=mcp
57+
or [{"name": "test-mcp", "source_tool": "cursor", "command": "npx", "args": []}],
58+
memory=memory or [],
59+
)
60+
args = ["collect", "--dry-run", "--yes"] + (extra_args or [])
61+
with (
62+
patch("collect.detect_installed_tools", return_value=["claude-code"]),
63+
patch("collect.get_extractor", return_value=extractor),
64+
patch("cache.get_cache_dir", return_value=cache_dir),
65+
):
66+
result = _runner().invoke(_cli(), args)
67+
return result, cache_dir
68+
69+
def test_dry_run_flag_accepted(self):
70+
result, _ = self._invoke()
71+
assert result.exit_code == 0, result.output
72+
73+
def test_dry_run_shows_cache_paths(self):
74+
result, _ = self._invoke()
75+
# Paths may wrap across lines in rich output; check for filename substrings
76+
flat = result.output.replace("\n", " ")
77+
assert "skills.j" in flat # skills.json (may wrap)
78+
assert "mcp.json" in flat
79+
assert "memory.j" in flat # memory.json (may wrap)
80+
81+
def test_dry_run_shows_skill_count(self):
82+
result, _ = self._invoke(
83+
skills=[
84+
{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"},
85+
{"name": "sk", "source_tool": "claude-code", "body": "# SK"},
86+
]
87+
)
88+
assert "2 skills" in result.output
89+
90+
def test_dry_run_shows_mcp_count(self):
91+
result, _ = self._invoke(
92+
mcp=[
93+
{"name": "mcp-a", "source_tool": "cursor", "command": "npx", "args": []},
94+
{"name": "mcp-b", "source_tool": "cursor", "command": "npx", "args": []},
95+
{"name": "mcp-c", "source_tool": "cursor", "command": "npx", "args": []},
96+
]
97+
)
98+
assert "3 MCP" in result.output
99+
100+
def test_dry_run_lists_skill_names(self):
101+
result, _ = self._invoke(
102+
skills=[{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}]
103+
)
104+
assert "pdf" in result.output
105+
106+
def test_dry_run_does_not_write_cache(self):
107+
"""Cache files must NOT be created when --dry-run is used."""
108+
result, cache_dir = self._invoke()
109+
assert result.exit_code == 0, result.output
110+
assert not (cache_dir / "skills.json").exists(), "skills.json written in dry-run"
111+
assert not (cache_dir / "mcp.json").exists(), "mcp.json written in dry-run"
112+
assert not (cache_dir / "memory.json").exists(), "memory.json written in dry-run"
113+
114+
def test_dry_run_no_files_written_message(self):
115+
result, _ = self._invoke(skills=[], mcp=[], memory=[])
116+
assert "No files written" in result.output or "dry-run" in result.output.lower()
117+
118+
def test_dry_run_memory_entries_listed(self):
119+
mem = [{"source_tool": "claude-code", "source_file": "CLAUDE.md", "content": "Some rule"}]
120+
result, _ = self._invoke(memory=mem)
121+
assert "claude-code" in result.output or "CLAUDE.md" in result.output
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# apc sync --dry-run (#24)
126+
# ---------------------------------------------------------------------------
127+
128+
129+
class TestSyncDryRun(unittest.TestCase):
130+
"""sync --dry-run must preview file paths per tool without writing."""
131+
132+
def _invoke_sync_dry(self, tools="cursor"):
133+
mock_bundle = {
134+
"skills": [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}],
135+
"mcp_servers": [{"name": "test-mcp", "command": "npx", "args": []}],
136+
"memory": [],
137+
}
138+
with (
139+
patch("main.load_local_bundle", return_value=mock_bundle),
140+
patch("main.count_installed_skills", return_value=1),
141+
patch("main.resolve_target_tools", return_value=[tools]),
142+
):
143+
return _runner().invoke(_cli(), ["sync", "--dry-run", "--yes"])
144+
145+
def test_dry_run_flag_accepted(self):
146+
result = self._invoke_sync_dry()
147+
assert result.exit_code == 0, result.output
148+
149+
def test_dry_run_shows_no_files_written(self):
150+
result = self._invoke_sync_dry()
151+
assert "No files written" in result.output or "dry-run" in result.output.lower()
152+
153+
def test_dry_run_shows_tool_name(self):
154+
result = self._invoke_sync_dry(tools="cursor")
155+
assert "cursor" in result.output
156+
157+
def test_dry_run_does_not_call_sync_all(self):
158+
"""sync_all must not be called in dry-run mode."""
159+
mock_bundle = {
160+
"skills": [{"name": "pdf", "source_tool": "claude-code", "body": "# PDF"}],
161+
"mcp_servers": [],
162+
"memory": [],
163+
}
164+
with (
165+
patch("main.load_local_bundle", return_value=mock_bundle),
166+
patch("main.count_installed_skills", return_value=1),
167+
patch("main.resolve_target_tools", return_value=["cursor"]),
168+
patch("main.sync_all") as mock_sync,
169+
):
170+
_runner().invoke(_cli(), ["sync", "--dry-run", "--yes"])
171+
172+
mock_sync.assert_not_called()

0 commit comments

Comments
 (0)